From f045c1447211dbedcc1b778d8f2acdb0a355a589 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:16:27 -0700 Subject: [PATCH 1/3] feat!: ship ABI 2 with Arc objects and callback interfaces --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/workflows/ci.yml | 46 +- Cargo.lock | 479 +++--- Cargo.toml | 4 +- README.md | 196 ++- conformance/c/codec.c | 1092 ++++++++++++ conformance/c/events.c | 371 +++- conformance/c/inventory.c | 387 +++++ conformance/c/kvstore.c | 489 +++++- conformance/c/wvbuf.h | 35 + conformance/cpp/codec.cpp | 475 ++++++ conformance/cpp/events.cpp | 387 ++++- conformance/cpp/kvstore.cpp | 557 +++++-- conformance/dart/codec.dart | 597 +++++++ conformance/dart/contacts.dart | 8 + conformance/dart/events.dart | 293 +++- conformance/dart/kvstore.dart | 320 +++- conformance/dotnet/Codec.cs | 421 +++++ conformance/dotnet/Events.cs | 268 ++- conformance/dotnet/Kvstore.cs | 416 +++-- conformance/go/codec.go | 486 ++++++ conformance/go/events.go | 256 ++- conformance/go/kvstore.go | 273 ++- conformance/kotlin/codec.kt | 416 +++++ conformance/kotlin/events.kt | 287 +++- conformance/kotlin/kvstore.kt | 326 +++- conformance/node/async_demo.js | 7 +- conformance/node/codec.js | 351 ++++ conformance/node/contacts.js | 23 +- conformance/node/events.js | 242 ++- conformance/node/kvstore.js | 324 +++- conformance/python/codec.py | 375 +++++ conformance/python/contacts.py | 20 +- conformance/python/events.py | 260 ++- conformance/python/inventory.py | 169 ++ conformance/python/kvstore.py | 431 +++-- conformance/ruby/codec.rb | 360 ++++ conformance/ruby/contacts.rb | 34 +- conformance/ruby/events.rb | 262 ++- conformance/ruby/kvstore.rb | 296 +++- conformance/run.sh | 66 +- conformance/swift/codec.swift | 355 ++++ conformance/swift/events.swift | 219 ++- conformance/swift/kvstore.swift | 297 +++- conformance/wasm/async_demo.mjs | 67 +- conformance/wasm/codec.mjs | 443 +++++ conformance/wasm/contacts.mjs | 41 +- conformance/wasm/events.mjs | 312 +++- conformance/wasm/kvstore.mjs | 465 ++++-- conformance/wasm/shapes.mjs | 15 + crates/weaveffi-abi/Cargo.toml | 3 + crates/weaveffi-abi/src/arena.rs | 151 -- crates/weaveffi-abi/src/buffer.rs | 58 +- crates/weaveffi-abi/src/callback.rs | 392 +++++ crates/weaveffi-abi/src/lib.rs | 152 +- crates/weaveffi-abi/src/macros.rs | 42 +- crates/weaveffi-abi/src/object.rs | 205 +++ crates/weaveffi-abi/src/spawn.rs | 201 +++ crates/weaveffi-abi/tests/buffer_proptest.rs | 310 ++++ crates/weaveffi-abi/tests/export_runtime.rs | 56 +- crates/weaveffi-bridge/src/lib.rs | 510 ++++-- crates/weaveffi-cli/Cargo.toml | 2 +- crates/weaveffi-cli/benches/generate_bench.rs | 13 +- crates/weaveffi-cli/src/commands/diff.rs | 2 +- crates/weaveffi-cli/src/commands/generate.rs | 4 +- crates/weaveffi-cli/src/commands/package.rs | 11 +- crates/weaveffi-cli/src/commands/validate.rs | 60 +- crates/weaveffi-cli/src/config.rs | 18 +- crates/weaveffi-cli/src/main.rs | 7 +- crates/weaveffi-cli/tests/cli_check.rs | 6 +- crates/weaveffi-cli/tests/cli_diagnostics.rs | 6 +- crates/weaveffi-cli/tests/cli_errors.rs | 10 +- crates/weaveffi-cli/tests/cli_extract.rs | 257 ++- crates/weaveffi-cli/tests/cli_generate.rs | 4 +- crates/weaveffi-cli/tests/cli_package.rs | 5 +- .../weaveffi-cli/tests/cli_project_config.rs | 6 +- crates/weaveffi-cli/tests/cli_schema.rs | 3 +- .../weaveffi-cli/tests/cli_schema_version.rs | 2 +- crates/weaveffi-cli/tests/determinism.rs | 6 +- crates/weaveffi-cli/tests/docs_examples.rs | 26 +- .../weaveffi-cli/tests/extract_roundtrip.rs | 66 +- .../tests/fixtures/docs_everywhere.yml | 28 +- .../tests/fixtures/edge_cases.yml | 101 +- .../tests/fixtures/kitchen_sink.yml | 58 +- .../tests/fixtures/kitchen_sink_annotated.rs | 53 +- .../tests/fixtures/nested_modules.yml | 2 +- crates/weaveffi-cli/tests/fixtures/shapes.yml | 2 +- crates/weaveffi-cli/tests/no_silent_stubs.rs | 30 +- crates/weaveffi-cli/tests/publish_script.rs | 2 +- crates/weaveffi-cli/tests/safety_audit.rs | 75 +- crates/weaveffi-cli/tests/snapshots.rs | 25 +- ...android_docs_everywhere__build_gradle.snap | 31 - ...erywhere__src_main_cpp_weaveffi_jni_c.snap | 285 ---- .../android_edge_cases__build_gradle.snap | 31 - .../android_kitchen_sink__build_gradle.snap | 31 - .../android_nested_modules__build_gradle.snap | 31 - .../android_shapes__build_gradle.snap | 31 - .../android_shapes__settings_gradle.snap | 10 - ...d_shapes__src_main_cpp_CMakeLists_txt.snap | 13 - ...d_shapes__src_main_cpp_weaveffi_jni_c.snap | 88 - .../c_docs_everywhere__weaveffi_h.snap | 37 +- .../snapshots/c_edge_cases__weaveffi_h.snap | 69 +- .../snapshots/c_kitchen_sink__weaveffi_h.snap | 54 +- .../c_nested_modules__weaveffi_h.snap | 13 +- .../tests/snapshots/c_shapes__weaveffi_h.snap | 13 +- .../cpp_docs_everywhere__weaveffi_hpp.snap | 351 ++-- .../cpp_edge_cases__weaveffi_hpp.snap | 1263 +++++++++----- .../cpp_kitchen_sink__weaveffi_hpp.snap | 567 +++++-- .../cpp_nested_modules__weaveffi_hpp.snap | 65 +- .../snapshots/cpp_shapes__weaveffi_hpp.snap | 92 +- .../dart_docs_everywhere__README_md.snap | 16 +- ...rt_docs_everywhere__lib_weaveffi_dart.snap | 226 ++- .../dart_docs_everywhere__pubspec_yaml.snap | 2 +- .../snapshots/dart_edge_cases__README_md.snap | 16 +- .../dart_edge_cases__lib_weaveffi_dart.snap | 693 ++++++-- .../dart_edge_cases__pubspec_yaml.snap | 2 +- .../dart_kitchen_sink__README_md.snap | 16 +- .../dart_kitchen_sink__lib_weaveffi_dart.snap | 368 +++- .../dart_kitchen_sink__pubspec_yaml.snap | 2 +- .../dart_nested_modules__README_md.snap | 16 +- ...art_nested_modules__lib_weaveffi_dart.snap | 20 +- .../dart_nested_modules__pubspec_yaml.snap | 2 +- .../snapshots/dart_shapes__README_md.snap | 16 +- .../dart_shapes__lib_weaveffi_dart.snap | 20 +- .../snapshots/dart_shapes__pubspec_yaml.snap | 2 +- .../dotnet_docs_everywhere__WeaveFFI_cs.snap | 256 ++- .../dotnet_edge_cases__WeaveFFI_cs.snap | 831 +++++++-- .../dotnet_kitchen_sink__WeaveFFI_cs.snap | 437 +++-- .../dotnet_nested_modules__WeaveFFI_cs.snap | 58 +- .../snapshots/dotnet_shapes__WeaveFFI_cs.snap | 58 +- .../go_docs_everywhere__README_md.snap | 9 +- .../go_docs_everywhere__weaveffi_go.snap | 211 ++- .../snapshots/go_edge_cases__README_md.snap | 9 +- .../snapshots/go_edge_cases__weaveffi_go.snap | 867 ++++++---- .../snapshots/go_kitchen_sink__README_md.snap | 9 +- .../go_kitchen_sink__weaveffi_go.snap | 349 ++-- .../go_nested_modules__README_md.snap | 9 +- .../go_nested_modules__weaveffi_go.snap | 15 +- .../tests/snapshots/go_shapes__README_md.snap | 9 +- .../snapshots/go_shapes__weaveffi_go.snap | 25 +- ...lin_docs_everywhere__build_gradle_kts.snap | 36 + ...docs_everywhere__settings_gradle_kts.snap} | 4 +- ...rywhere__src_main_cpp_CMakeLists_txt.snap} | 2 +- ...erywhere__src_main_cpp_weaveffi_jni_c.snap | 378 +++++ ...main_kotlin_com_weaveffi_WeaveFFI_kt.snap} | 116 +- .../kotlin_edge_cases__build_gradle_kts.snap | 36 + ...tlin_edge_cases__settings_gradle_kts.snap} | 4 +- ...e_cases__src_main_cpp_CMakeLists_txt.snap} | 2 +- ...e_cases__src_main_cpp_weaveffi_jni_c.snap} | 681 +++++--- ...main_kotlin_com_weaveffi_WeaveFFI_kt.snap} | 311 +++- ...kotlin_kitchen_sink__build_gradle_kts.snap | 36 + ...in_kitchen_sink__settings_gradle_kts.snap} | 4 +- ...en_sink__src_main_cpp_CMakeLists_txt.snap} | 2 +- ...en_sink__src_main_cpp_weaveffi_jni_c.snap} | 376 +++-- ...main_kotlin_com_weaveffi_WeaveFFI_kt.snap} | 192 ++- ...tlin_nested_modules__build_gradle_kts.snap | 36 + ..._nested_modules__settings_gradle_kts.snap} | 4 +- ...modules__src_main_cpp_CMakeLists_txt.snap} | 2 +- ...modules__src_main_cpp_weaveffi_jni_c.snap} | 90 +- ...main_kotlin_com_weaveffi_WeaveFFI_kt.snap} | 17 +- .../kotlin_shapes__build_gradle_kts.snap | 36 + .../kotlin_shapes__settings_gradle_kts.snap | 10 + ...n_shapes__src_main_cpp_CMakeLists_txt.snap | 13 + ...n_shapes__src_main_cpp_weaveffi_jni_c.snap | 174 ++ ...main_kotlin_com_weaveffi_WeaveFFI_kt.snap} | 17 +- .../node_docs_everywhere__index_js.snap | 58 +- .../node_docs_everywhere__types_d_ts.snap | 46 +- ...ode_docs_everywhere__weaveffi_addon_c.snap | 474 ++++-- .../snapshots/node_edge_cases__index_js.snap | 160 +- .../node_edge_cases__types_d_ts.snap | 100 +- .../node_edge_cases__weaveffi_addon_c.snap | 1234 +++++++++++--- .../node_kitchen_sink__index_js.snap | 81 +- .../node_kitchen_sink__types_d_ts.snap | 68 +- .../node_kitchen_sink__weaveffi_addon_c.snap | 669 ++++++-- .../node_nested_modules__index_js.snap | 6 + .../node_nested_modules__types_d_ts.snap | 6 +- ...node_nested_modules__weaveffi_addon_c.snap | 109 +- .../snapshots/node_shapes__index_js.snap | 14 +- .../snapshots/node_shapes__types_d_ts.snap | 16 +- .../node_shapes__weaveffi_addon_c.snap | 181 +- ...ython_docs_everywhere__pyproject_toml.snap | 3 + ...on_docs_everywhere__weaveffi_py_typed.snap | 6 + ...docs_everywhere__weaveffi_weaveffi_py.snap | 220 ++- ...ocs_everywhere__weaveffi_weaveffi_pyi.snap | 23 +- .../python_edge_cases__pyproject_toml.snap | 3 + .../python_edge_cases__weaveffi_py_typed.snap | 6 + ...thon_edge_cases__weaveffi_weaveffi_py.snap | 624 +++++-- ...hon_edge_cases__weaveffi_weaveffi_pyi.snap | 64 +- .../python_kitchen_sink__pyproject_toml.snap | 3 + ...ython_kitchen_sink__weaveffi_py_typed.snap | 6 + ...on_kitchen_sink__weaveffi_weaveffi_py.snap | 332 +++- ...n_kitchen_sink__weaveffi_weaveffi_pyi.snap | 41 +- ...python_nested_modules__pyproject_toml.snap | 3 + ...hon_nested_modules__weaveffi_py_typed.snap | 6 + ..._nested_modules__weaveffi_weaveffi_py.snap | 55 +- ...nested_modules__weaveffi_weaveffi_pyi.snap | 5 + .../python_shapes__pyproject_toml.snap | 3 + .../python_shapes__weaveffi_py_typed.snap | 6 + .../python_shapes__weaveffi_weaveffi_py.snap | 55 +- .../python_shapes__weaveffi_weaveffi_pyi.snap | 5 + ...ruby_docs_everywhere__lib_weaveffi_rb.snap | 202 ++- .../ruby_edge_cases__lib_weaveffi_rb.snap | 670 ++++++-- .../ruby_kitchen_sink__lib_weaveffi_rb.snap | 347 +++- .../ruby_nested_modules__lib_weaveffi_rb.snap | 27 +- .../ruby_shapes__lib_weaveffi_rb.snap | 25 +- ...here__Sources_WeaveFFI_WeaveFFI_swift.snap | 107 +- ...ases__Sources_WeaveFFI_WeaveFFI_swift.snap | 475 +++++- ...sink__Sources_WeaveFFI_WeaveFFI_swift.snap | 282 +++- ...ules__Sources_WeaveFFI_WeaveFFI_swift.snap | 11 + ...apes__Sources_WeaveFFI_WeaveFFI_swift.snap | 11 + .../wasm_docs_everywhere__README_md.snap | 73 +- ...m_docs_everywhere__weaveffi_wasm_d_ts.snap | 43 +- ...asm_docs_everywhere__weaveffi_wasm_js.snap | 377 +++-- .../snapshots/wasm_edge_cases__README_md.snap | 154 +- .../wasm_edge_cases__weaveffi_wasm_d_ts.snap | 121 +- .../wasm_edge_cases__weaveffi_wasm_js.snap | 1261 +++++++++----- .../wasm_kitchen_sink__README_md.snap | 98 +- ...wasm_kitchen_sink__weaveffi_wasm_d_ts.snap | 65 +- .../wasm_kitchen_sink__weaveffi_wasm_js.snap | 642 +++++-- .../wasm_nested_modules__README_md.snap | 28 +- ...sm_nested_modules__weaveffi_wasm_d_ts.snap | 7 +- ...wasm_nested_modules__weaveffi_wasm_js.snap | 237 ++- .../snapshots/wasm_shapes__README_md.snap | 28 +- .../wasm_shapes__weaveffi_wasm_d_ts.snap | 7 +- .../wasm_shapes__weaveffi_wasm_js.snap | 315 ++-- crates/weaveffi-core/benches/codegen_bench.rs | 25 +- crates/weaveffi-core/src/abi/ctype.rs | 106 +- crates/weaveffi-core/src/abi/lower.rs | 129 +- crates/weaveffi-core/src/abi/mod.rs | 75 +- crates/weaveffi-core/src/backend/mod.rs | 84 +- crates/weaveffi-core/src/cabi.rs | 205 +-- crates/weaveffi-core/src/cache.rs | 3 +- crates/weaveffi-core/src/capabilities.rs | 83 +- crates/weaveffi-core/src/codegen/common.rs | 3 +- crates/weaveffi-core/src/codegen/mod.rs | 36 +- crates/weaveffi-core/src/errors.rs | 199 +-- crates/weaveffi-core/src/model/mod.rs | 402 +++-- crates/weaveffi-core/src/model/ty.rs | 179 +- crates/weaveffi-core/src/plan.rs | 233 ++- crates/weaveffi-core/src/platform.rs | 193 ++- crates/weaveffi-core/src/resolved.rs | 48 +- crates/weaveffi-core/src/utils.rs | 14 +- crates/weaveffi-core/src/validate.rs | 158 +- .../weaveffi-core/src/validate/diagnostic.rs | 10 +- crates/weaveffi-core/src/validate/rules.rs | 411 ++--- crates/weaveffi-core/src/validate/tests.rs | 169 +- crates/weaveffi-core/tests/codegen_hooks.rs | 5 +- .../fuzz/seeds/fuzz_parse_json/minimal.json | 2 +- .../fuzz/seeds/fuzz_parse_toml/minimal.toml | 2 +- .../fuzz/seeds/fuzz_parse_yaml/minimal.yml | 2 +- .../fuzz/seeds/fuzz_validate/minimal.yml | 2 +- crates/weaveffi-gen-android/src/lib.rs | 351 ---- crates/weaveffi-gen-android/src/package.rs | 73 - crates/weaveffi-gen-android/src/runtime.rs | 384 ----- crates/weaveffi-gen-c/src/lib.rs | 4 +- crates/weaveffi-gen-cpp/Cargo.toml | 3 + crates/weaveffi-gen-cpp/src/callbacks.rs | 279 ++++ crates/weaveffi-gen-cpp/src/calls.rs | 860 +++++----- crates/weaveffi-gen-cpp/src/codec.rs | 85 +- crates/weaveffi-gen-cpp/src/entities.rs | 277 +-- crates/weaveffi-gen-cpp/src/lib.rs | 242 +-- crates/weaveffi-gen-cpp/src/package.rs | 4 +- crates/weaveffi-gen-cpp/src/runtime.rs | 62 +- crates/weaveffi-gen-cpp/src/tests.rs | 642 +++++++ crates/weaveffi-gen-cpp/src/types.rs | 100 +- crates/weaveffi-gen-dart/Cargo.toml | 3 + crates/weaveffi-gen-dart/src/callbacks.rs | 372 +++++ crates/weaveffi-gen-dart/src/calls.rs | 370 ++-- crates/weaveffi-gen-dart/src/codec.rs | 42 +- crates/weaveffi-gen-dart/src/docs.rs | 11 +- crates/weaveffi-gen-dart/src/entities.rs | 101 +- crates/weaveffi-gen-dart/src/lib.rs | 82 +- crates/weaveffi-gen-dart/src/package.rs | 21 +- crates/weaveffi-gen-dart/src/runtime.rs | 134 +- crates/weaveffi-gen-dart/src/types.rs | 104 +- crates/weaveffi-gen-dart/tests/abi2.rs | 429 +++++ crates/weaveffi-gen-dotnet/Cargo.toml | 3 + crates/weaveffi-gen-dotnet/src/callbacks.rs | 274 +++ crates/weaveffi-gen-dotnet/src/calls.rs | 808 +++------ crates/weaveffi-gen-dotnet/src/codec.rs | 49 +- crates/weaveffi-gen-dotnet/src/entities.rs | 214 +-- crates/weaveffi-gen-dotnet/src/lib.rs | 78 +- crates/weaveffi-gen-dotnet/src/package.rs | 4 +- crates/weaveffi-gen-dotnet/src/pinvoke.rs | 185 +- crates/weaveffi-gen-dotnet/src/runtime.rs | 68 +- crates/weaveffi-gen-dotnet/src/tests.rs | 452 +++++ crates/weaveffi-gen-dotnet/src/types.rs | 42 +- crates/weaveffi-gen-go/Cargo.toml | 3 + crates/weaveffi-gen-go/src/calls.rs | 688 ++++---- crates/weaveffi-gen-go/src/codec.rs | 66 +- crates/weaveffi-gen-go/src/entities.rs | 365 ++-- crates/weaveffi-gen-go/src/lib.rs | 683 ++++++-- crates/weaveffi-gen-go/src/package.rs | 62 +- crates/weaveffi-gen-go/src/runtime.rs | 105 +- crates/weaveffi-gen-go/src/types.rs | 89 +- .../Cargo.toml | 9 +- .../src/calls.rs | 119 +- .../src/codec.rs | 45 +- .../src/docs.rs | 0 .../src/entities.rs | 220 ++- .../src/jni.rs | 694 ++++---- crates/weaveffi-gen-kotlin/src/lib.rs | 443 +++++ crates/weaveffi-gen-kotlin/src/package.rs | 264 +++ crates/weaveffi-gen-kotlin/src/runtime.rs | 798 +++++++++ crates/weaveffi-gen-kotlin/src/tests.rs | 395 +++++ .../src/types.rs | 167 +- crates/weaveffi-gen-node/Cargo.toml | 3 + crates/weaveffi-gen-node/src/addon.rs | 1462 +++++++++------- crates/weaveffi-gen-node/src/codec.rs | 81 +- crates/weaveffi-gen-node/src/entities.rs | 397 +++-- crates/weaveffi-gen-node/src/lib.rs | 551 +++++- crates/weaveffi-gen-node/src/package.rs | 100 +- crates/weaveffi-gen-node/src/runtime.rs | 35 +- crates/weaveffi-gen-node/src/types.rs | 86 +- crates/weaveffi-gen-python/Cargo.toml | 4 + crates/weaveffi-gen-python/src/calls.rs | 462 +++-- crates/weaveffi-gen-python/src/codec.rs | 31 +- crates/weaveffi-gen-python/src/entities.rs | 79 +- crates/weaveffi-gen-python/src/lib.rs | 121 +- crates/weaveffi-gen-python/src/package.rs | 18 +- crates/weaveffi-gen-python/src/runtime.rs | 129 +- crates/weaveffi-gen-python/src/stubs.rs | 91 +- crates/weaveffi-gen-python/src/tests.rs | 471 ++++++ crates/weaveffi-gen-python/src/types.rs | 101 +- crates/weaveffi-gen-ruby/Cargo.toml | 3 + crates/weaveffi-gen-ruby/src/callbacks.rs | 316 ++++ crates/weaveffi-gen-ruby/src/calls.rs | 423 ++--- crates/weaveffi-gen-ruby/src/codec.rs | 30 +- crates/weaveffi-gen-ruby/src/docs.rs | 10 +- crates/weaveffi-gen-ruby/src/entities.rs | 95 +- crates/weaveffi-gen-ruby/src/lib.rs | 471 +++++- crates/weaveffi-gen-ruby/src/package.rs | 9 +- crates/weaveffi-gen-ruby/src/runtime.rs | 98 +- crates/weaveffi-gen-ruby/src/types.rs | 32 +- crates/weaveffi-gen-swift/src/callbacks.rs | 319 ++++ crates/weaveffi-gen-swift/src/calls.rs | 461 ++--- crates/weaveffi-gen-swift/src/codec.rs | 25 +- crates/weaveffi-gen-swift/src/entities.rs | 41 +- crates/weaveffi-gen-swift/src/lib.rs | 305 +++- crates/weaveffi-gen-swift/src/package.rs | 2 + crates/weaveffi-gen-swift/src/runtime.rs | 67 +- crates/weaveffi-gen-swift/src/types.rs | 40 +- crates/weaveffi-gen-wasm/src/calls.rs | 771 +++++---- crates/weaveffi-gen-wasm/src/codec.rs | 51 +- crates/weaveffi-gen-wasm/src/dts.rs | 248 +-- crates/weaveffi-gen-wasm/src/entities.rs | 433 ++--- crates/weaveffi-gen-wasm/src/lib.rs | 172 +- crates/weaveffi-gen-wasm/src/package.rs | 336 +++- crates/weaveffi-gen-wasm/src/runtime.rs | 259 ++- crates/weaveffi-gen-wasm/src/types.rs | 93 +- crates/weaveffi-gen-wasm/tests/abi2.rs | 695 ++++++++ crates/weaveffi-ir/src/ir.rs | 326 ++-- crates/weaveffi-ir/src/parse.rs | 16 +- crates/weaveffi-macros/Cargo.toml | 4 + .../weaveffi-macros/src/codegen/async_fns.rs | 419 +++-- .../weaveffi-macros/src/codegen/callbacks.rs | 326 ++-- crates/weaveffi-macros/src/codegen/enums.rs | 35 +- crates/weaveffi-macros/src/codegen/helpers.rs | 495 ++++-- .../weaveffi-macros/src/codegen/interfaces.rs | 54 +- .../weaveffi-macros/src/codegen/iterators.rs | 56 +- crates/weaveffi-macros/src/codegen/marshal.rs | 278 +-- crates/weaveffi-macros/src/codegen/mod.rs | 23 +- crates/weaveffi-macros/src/codegen/sync.rs | 22 +- crates/weaveffi-macros/src/lib.rs | 23 +- crates/weaveffi-macros/tests/ui.rs | 12 + .../tests/ui/fail_box_param.rs | 12 + .../tests/ui/fail_box_param.stderr | 5 + .../tests/ui/fail_callback_mut_self.rs | 9 + .../tests/ui/fail_callback_mut_self.stderr | 5 + .../tests/ui/fail_callback_string_return.rs | 9 + .../ui/fail_callback_string_return.stderr | 7 + .../tests/ui/fail_enum_without_repr.rs | 9 + .../tests/ui/fail_enum_without_repr.stderr | 5 + .../tests/ui/fail_interface_not_sync.rs | 17 + .../tests/ui/fail_interface_not_sync.stderr | 19 + .../tests/ui/fail_interface_self_by_value.rs | 11 + .../ui/fail_interface_self_by_value.stderr | 5 + .../tests/ui/fail_iter_param.rs | 9 + .../tests/ui/fail_iter_param.stderr | 5 + .../tests/ui/fail_mut_ref_param.rs | 9 + .../tests/ui/fail_mut_ref_param.stderr | 5 + .../tests/ui/fail_nested_iter_return.rs | 9 + .../tests/ui/fail_nested_iter_return.stderr | 5 + .../tests/ui/fail_raw_pointer.rs | 11 + .../tests/ui/fail_raw_pointer.stderr | 5 + .../ui/fail_result_without_error_domain.rs | 13 + .../fail_result_without_error_domain.stderr | 5 + .../tests/ui/pass_full_surface.rs | 130 ++ crates/weaveffi/src/lib.rs | 30 +- crates/weaveffi/tests/runtime.rs | 736 +++++++- docs/src/SUMMARY.md | 5 +- docs/src/api/README.md | 15 +- docs/src/api/rust.md | 210 ++- docs/src/architecture.md | 449 +++-- docs/src/comparison.md | 267 +-- docs/src/faq.md | 225 ++- docs/src/generators/README.md | 89 +- docs/src/generators/android.md | 691 -------- docs/src/generators/c.md | 714 +++++--- docs/src/generators/cpp.md | 557 ++++--- docs/src/generators/dart.md | 767 ++++++--- docs/src/generators/dotnet.md | 820 ++++++--- docs/src/generators/go.md | 885 ++++++---- docs/src/generators/kotlin.md | 812 +++++++++ docs/src/generators/node.md | 540 +++--- docs/src/generators/python.md | 861 ++++++---- docs/src/generators/ruby.md | 983 ++++++----- docs/src/generators/swift.md | 603 ++++--- docs/src/generators/wasm.md | 731 +++++--- docs/src/getting-started.md | 53 +- docs/src/guides/async.md | 609 ++++--- docs/src/guides/config.md | 28 +- docs/src/guides/errors.md | 546 +++--- docs/src/guides/extract.md | 281 ++-- docs/src/guides/memory.md | 483 ++++-- docs/src/guides/packaging.md | 276 ++- docs/src/guides/producer-macro.md | 667 +++++++- docs/src/intro.md | 47 +- docs/src/performance.md | 38 +- docs/src/reference/README.md | 20 + docs/src/reference/abi.md | 247 +++ docs/src/reference/idl.md | 1485 ++++++++++------- docs/src/reference/memory-error.md | 379 +++-- docs/src/reference/naming.md | 345 +++- docs/src/reference/value-buffers.md | 39 +- docs/src/roadmap.md | 166 +- docs/src/samples.md | 290 ++-- docs/src/stability.md | 136 +- docs/src/tutorials/README.md | 16 +- docs/src/tutorials/android.md | 274 --- docs/src/tutorials/calculator.md | 184 +- docs/src/tutorials/kotlin.md | 405 +++++ docs/src/tutorials/node.md | 120 +- docs/src/tutorials/python.md | 130 +- docs/src/tutorials/swift.md | 209 ++- samples/async-demo/src/lib.rs | 22 +- samples/codec/Cargo.toml | 14 + samples/codec/src/lib.rs | 522 ++++++ samples/codec/weaveffi.toml | 26 + samples/events/src/lib.rs | 434 +++-- samples/events/weaveffi.toml | 2 +- samples/kvstore/src/lib.rs | 408 ++++- samples/node-addon/Cargo.toml | 26 - samples/node-addon/build.rs | 3 - samples/node-addon/src/lib.rs | 162 -- scripts/publish-crates.sh | 2 +- weaveffi.schema.json | 163 +- 448 files changed, 62310 insertions(+), 23397 deletions(-) create mode 100644 conformance/c/codec.c create mode 100644 conformance/c/inventory.c create mode 100644 conformance/cpp/codec.cpp create mode 100644 conformance/dart/codec.dart create mode 100644 conformance/dotnet/Codec.cs create mode 100644 conformance/go/codec.go create mode 100644 conformance/kotlin/codec.kt create mode 100644 conformance/node/codec.js create mode 100644 conformance/python/codec.py create mode 100644 conformance/python/inventory.py create mode 100644 conformance/ruby/codec.rb create mode 100644 conformance/swift/codec.swift create mode 100644 conformance/wasm/codec.mjs delete mode 100644 crates/weaveffi-abi/src/arena.rs create mode 100644 crates/weaveffi-abi/src/callback.rs create mode 100644 crates/weaveffi-abi/src/object.rs create mode 100644 crates/weaveffi-abi/src/spawn.rs create mode 100644 crates/weaveffi-abi/tests/buffer_proptest.rs delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__build_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_edge_cases__build_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__build_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_nested_modules__build_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_shapes__build_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_shapes__settings_gradle.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_CMakeLists_txt.snap delete mode 100644 crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_weaveffi_jni_c.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__build_gradle_kts.snap rename crates/weaveffi-cli/tests/snapshots/{android_edge_cases__settings_gradle.snap => kotlin_docs_everywhere__settings_gradle_kts.snap} (79%) rename crates/weaveffi-cli/tests/snapshots/{android_kitchen_sink__src_main_cpp_CMakeLists_txt.snap => kotlin_docs_everywhere__src_main_cpp_CMakeLists_txt.snap} (94%) create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap rename crates/weaveffi-cli/tests/snapshots/{android_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap => kotlin_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap} (73%) create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__build_gradle_kts.snap rename crates/weaveffi-cli/tests/snapshots/{android_kitchen_sink__settings_gradle.snap => kotlin_edge_cases__settings_gradle_kts.snap} (79%) rename crates/weaveffi-cli/tests/snapshots/{android_edge_cases__src_main_cpp_CMakeLists_txt.snap => kotlin_edge_cases__src_main_cpp_CMakeLists_txt.snap} (94%) rename crates/weaveffi-cli/tests/snapshots/{android_edge_cases__src_main_cpp_weaveffi_jni_c.snap => kotlin_edge_cases__src_main_cpp_weaveffi_jni_c.snap} (57%) rename crates/weaveffi-cli/tests/snapshots/{android_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap => kotlin_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap} (67%) create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__build_gradle_kts.snap rename crates/weaveffi-cli/tests/snapshots/{android_docs_everywhere__settings_gradle.snap => kotlin_kitchen_sink__settings_gradle_kts.snap} (79%) rename crates/weaveffi-cli/tests/snapshots/{android_nested_modules__src_main_cpp_CMakeLists_txt.snap => kotlin_kitchen_sink__src_main_cpp_CMakeLists_txt.snap} (94%) rename crates/weaveffi-cli/tests/snapshots/{android_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap => kotlin_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap} (56%) rename crates/weaveffi-cli/tests/snapshots/{android_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap => kotlin_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap} (67%) create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__build_gradle_kts.snap rename crates/weaveffi-cli/tests/snapshots/{android_nested_modules__settings_gradle.snap => kotlin_nested_modules__settings_gradle_kts.snap} (79%) rename crates/weaveffi-cli/tests/snapshots/{android_docs_everywhere__src_main_cpp_CMakeLists_txt.snap => kotlin_nested_modules__src_main_cpp_CMakeLists_txt.snap} (94%) rename crates/weaveffi-cli/tests/snapshots/{android_nested_modules__src_main_cpp_weaveffi_jni_c.snap => kotlin_nested_modules__src_main_cpp_weaveffi_jni_c.snap} (51%) rename crates/weaveffi-cli/tests/snapshots/{android_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap => kotlin_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap} (93%) create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_shapes__build_gradle_kts.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_shapes__settings_gradle_kts.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_CMakeLists_txt.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_weaveffi_jni_c.snap rename crates/weaveffi-cli/tests/snapshots/{android_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap => kotlin_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap} (93%) create mode 100644 crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_py_typed.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_py_typed.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_py_typed.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_py_typed.snap create mode 100644 crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_py_typed.snap delete mode 100644 crates/weaveffi-gen-android/src/lib.rs delete mode 100644 crates/weaveffi-gen-android/src/package.rs delete mode 100644 crates/weaveffi-gen-android/src/runtime.rs create mode 100644 crates/weaveffi-gen-cpp/src/callbacks.rs create mode 100644 crates/weaveffi-gen-cpp/src/tests.rs create mode 100644 crates/weaveffi-gen-dart/src/callbacks.rs create mode 100644 crates/weaveffi-gen-dart/tests/abi2.rs create mode 100644 crates/weaveffi-gen-dotnet/src/callbacks.rs create mode 100644 crates/weaveffi-gen-dotnet/src/tests.rs rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/Cargo.toml (56%) rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/calls.rs (75%) rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/codec.rs (75%) rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/docs.rs (100%) rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/entities.rs (70%) rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/jni.rs (61%) create mode 100644 crates/weaveffi-gen-kotlin/src/lib.rs create mode 100644 crates/weaveffi-gen-kotlin/src/package.rs create mode 100644 crates/weaveffi-gen-kotlin/src/runtime.rs create mode 100644 crates/weaveffi-gen-kotlin/src/tests.rs rename crates/{weaveffi-gen-android => weaveffi-gen-kotlin}/src/types.rs (65%) create mode 100644 crates/weaveffi-gen-python/src/tests.rs create mode 100644 crates/weaveffi-gen-ruby/src/callbacks.rs create mode 100644 crates/weaveffi-gen-swift/src/callbacks.rs create mode 100644 crates/weaveffi-gen-wasm/tests/abi2.rs create mode 100644 crates/weaveffi-macros/tests/ui.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_box_param.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_box_param.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_callback_mut_self.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_callback_mut_self.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_callback_string_return.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_callback_string_return.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_enum_without_repr.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_enum_without_repr.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_interface_not_sync.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_interface_not_sync.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_iter_param.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_iter_param.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_mut_ref_param.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_mut_ref_param.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_nested_iter_return.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_nested_iter_return.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_raw_pointer.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_raw_pointer.stderr create mode 100644 crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.rs create mode 100644 crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.stderr create mode 100644 crates/weaveffi-macros/tests/ui/pass_full_surface.rs delete mode 100644 docs/src/generators/android.md create mode 100644 docs/src/generators/kotlin.md create mode 100644 docs/src/reference/abi.md delete mode 100644 docs/src/tutorials/android.md create mode 100644 docs/src/tutorials/kotlin.md create mode 100644 samples/codec/Cargo.toml create mode 100644 samples/codec/src/lib.rs create mode 100644 samples/codec/weaveffi.toml delete mode 100644 samples/node-addon/Cargo.toml delete mode 100644 samples/node-addon/build.rs delete mode 100644 samples/node-addon/src/lib.rs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4b0d7df4..9d187f9c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,7 @@ ran. - WeaveFFI version: - OS: - Rust version (`rustc --version`): -- Target generator(s): C / C++ / Swift / Android / Node / Wasm / Python / .NET / Dart / Go / Ruby +- Target generator(s): C / C++ / Swift / Kotlin / Node / Wasm / Python / .NET / Dart / Go / Ruby ## Logs or output diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 5e5e5cd6..271aa356 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -26,7 +26,7 @@ Which target generators or project areas would this affect? - [ ] C - [ ] C++ - [ ] Swift -- [ ] Android / Kotlin +- [ ] Kotlin (Android / JVM) - [ ] Node.js - [ ] WebAssembly - [ ] Python diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9b6b2ba..9acb71f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,26 @@ jobs: "$BIN" generate samples/calculator/src/lib.rs -o generated - name: Build sample producer cdylibs (debug) - run: cargo build -p calculator -p contacts -p kvstore + run: cargo build -p calculator -p contacts -p kvstore -p events -p codec + + # `weaveffi diff --check` is the CI gate we recommend to users, so it has + # to hold for our own samples: a fresh tree diffs clean (exit 0), and a + # tree with a stale file is reported (exit 1) instead of silently passing. + - name: Verify `weaveffi diff --check` on every sample + shell: bash + run: | + set -euo pipefail + BIN=target/release/weaveffi + [ "${{ runner.os }}" = "Windows" ] && BIN="${BIN}.exe" + for sample in calculator contacts events kvstore shapes async-demo codec inventory; do + "$BIN" generate "samples/$sample/src/lib.rs" -o "diffcheck/$sample" --force --quiet + "$BIN" diff "samples/$sample/src/lib.rs" -o "diffcheck/$sample" --check --quiet + done + echo "stale" >> diffcheck/calculator/c/weaveffi.h + if "$BIN" diff samples/calculator/src/lib.rs -o diffcheck/calculator --check --quiet; then + echo "diff --check did not detect a stale file" >&2 + exit 1 + fi - name: Add wasm target if: runner.os != 'Windows' @@ -99,13 +118,14 @@ jobs: conformance: # Correctness gate: every consumer binds through the *generated* wrappers and # asserts concrete results, so a backend that lowers a type wrong, drifts a - # symbol, or skips a feature fails here. Covers contacts + events + kvstore - # across C, C++, Python, Ruby, Dart, Go, Swift, .NET, Node, Kotlin, and - # Wasm: the events lanes exercise listeners/callbacks + iterators, and the - # kvstore lanes exercise interfaces (constructors, methods, async methods, - # iterator methods, destroy), typed error domains, struct getters - # (bytes/optional/list/map), builders, and eviction listeners; see - # conformance/run.sh. + # symbol, or skips a feature fails here. Covers contacts, events, kvstore, + # shapes, async-demo, and codec across C, C++, Python, Ruby, Dart, Go, + # Swift, .NET, Node, Kotlin, and Wasm: the events lanes exercise a callback + # interface plus objects and iterators, the kvstore lanes exercise objects + # in every position (constructors, methods, async methods, iterator methods, + # records and lists carrying objects, clone/destroy), typed error domains, + # builders, and an eviction callback interface, and the codec lanes + # round-trip every value-buffer wire shape; see conformance/run.sh. name: Conformance harness runs-on: ubuntu-latest steps: @@ -196,7 +216,7 @@ jobs: "generated/c/weaveffi.h", "generated/swift/Package.swift", "generated/node/types.d.ts", - "generated/android/build.gradle", + "generated/kotlin/build.gradle.kts", "generated/python/calculator/weaveffi.py", "generated/dotnet/WeaveFFI.cs", "generated/wasm/README.md" @@ -238,10 +258,10 @@ jobs: - name: Build CLI (release) run: cargo build -p weaveffi-cli --release - - name: Generate Android + C bindings for contacts + - name: Generate Kotlin + C bindings for contacts run: | target/release/weaveffi generate samples/contacts/src/lib.rs -o generated --target c - target/release/weaveffi generate samples/contacts/src/lib.rs -o generated --target android + target/release/weaveffi generate samples/contacts/src/lib.rs -o generated --target kotlin - name: Compile JNI shim with NDK clang env: @@ -258,7 +278,7 @@ jobs: -I generated/c \ -Wl,--unresolved-symbols=ignore-all \ -o libweaveffi_jni_test.so \ - generated/android/src/main/cpp/weaveffi_jni.c + generated/kotlin/src/main/cpp/weaveffi_jni.c - name: Assert exported JNI symbols env: @@ -269,7 +289,7 @@ jobs: file libweaveffi_jni_test.so ldd libweaveffi_jni_test.so || true "$NM" -D --defined-only libweaveffi_jni_test.so > nm.out - # The Android generator lowers each interface to its own Kotlin + # The Kotlin generator lowers each interface to its own Kotlin # class with `native*` externals, so the JNI exports are namespaced # per type: the ContactBook interface's constructor, methods, and # destroy hook. Records (like Contact) are value types serialized diff --git a/Cargo.lock b/Cargo.lock index 3db8fb51..41468b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,21 @@ dependencies = [ "backtrace", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -291,6 +306,13 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "codec" +version = "0.1.0" +dependencies = [ + "weaveffi", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -315,15 +337,6 @@ dependencies = [ "weaveffi", ] -[[package]] -name = "convert_case" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -410,12 +423,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctor" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" - [[package]] name = "difflib" version = "0.4.0" @@ -495,29 +502,10 @@ dependencies = [ ] [[package]] -name = "futures" -version = "0.3.34" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures-core" @@ -525,40 +513,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - [[package]] name = "futures-task" version = "0.3.34" @@ -571,13 +525,8 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -592,6 +541,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -600,7 +561,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -609,6 +570,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "half" version = "2.7.1" @@ -729,7 +696,7 @@ version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] @@ -773,26 +740,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -859,70 +806,6 @@ dependencies = [ "adler2", ] -[[package]] -name = "napi" -version = "3.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09" -dependencies = [ - "bitflags", - "ctor", - "futures", - "libc", - "napi-build", - "napi-sys", - "nohash-hasher", - "rustc-hash", -] - -[[package]] -name = "napi-build" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab" - -[[package]] -name = "napi-derive" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af" -dependencies = [ - "convert_case", - "ctor", - "napi-derive-backend", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "napi-derive-backend" -version = "6.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "semver", - "syn 2.0.119", -] - -[[package]] -name = "napi-sys" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" -dependencies = [ - "libloading 0.9.0", -] - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1014,6 +897,15 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -1053,6 +945,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -1062,12 +979,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1123,12 +1084,6 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustix" version = "1.1.4" @@ -1148,6 +1103,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1188,12 +1155,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1257,6 +1218,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1370,6 +1340,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + [[package]] name = "tempfile" version = "3.27.0" @@ -1377,12 +1353,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -1455,11 +1440,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap 2.14.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -1469,6 +1469,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1477,10 +1486,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.1", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -1489,6 +1507,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -1538,12 +1562,33 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.5+spec-1.1.0", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1556,12 +1601,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - [[package]] name = "unicode-width" version = "0.1.14" @@ -1617,6 +1656,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -1674,6 +1722,9 @@ dependencies = [ [[package]] name = "weaveffi-abi" version = "0.22.0" +dependencies = [ + "proptest", +] [[package]] name = "weaveffi-bridge" @@ -1701,16 +1752,16 @@ dependencies = [ "serde_yaml", "similar", "tempfile", - "toml", + "toml 0.8.23", "tracing-subscriber", "weaveffi-bridge", "weaveffi-core", - "weaveffi-gen-android", "weaveffi-gen-c", "weaveffi-gen-cpp", "weaveffi-gen-dart", "weaveffi-gen-dotnet", "weaveffi-gen-go", + "weaveffi-gen-kotlin", "weaveffi-gen-node", "weaveffi-gen-python", "weaveffi-gen-ruby", @@ -1734,7 +1785,7 @@ dependencies = [ "sha2", "tempfile", "thiserror", - "toml", + "toml 0.8.23", "weaveffi-ir", ] @@ -1748,62 +1799,67 @@ dependencies = [ ] [[package]] -name = "weaveffi-gen-android" +name = "weaveffi-gen-c" version = "0.22.0" dependencies = [ "camino", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] -name = "weaveffi-gen-c" +name = "weaveffi-gen-cpp" version = "0.22.0" dependencies = [ "camino", + "heck", "serde", "weaveffi-core", "weaveffi-ir", ] [[package]] -name = "weaveffi-gen-cpp" +name = "weaveffi-gen-dart" version = "0.22.0" dependencies = [ "camino", "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] -name = "weaveffi-gen-dart" +name = "weaveffi-gen-dotnet" version = "0.22.0" dependencies = [ "camino", "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] -name = "weaveffi-gen-dotnet" +name = "weaveffi-gen-go" version = "0.22.0" dependencies = [ "camino", "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] -name = "weaveffi-gen-go" +name = "weaveffi-gen-kotlin" version = "0.22.0" dependencies = [ "camino", - "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] @@ -1814,6 +1870,7 @@ dependencies = [ "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] @@ -1824,6 +1881,7 @@ dependencies = [ "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] @@ -1834,6 +1892,7 @@ dependencies = [ "heck", "serde", "weaveffi-core", + "weaveffi-ir", ] [[package]] @@ -1868,7 +1927,7 @@ dependencies = [ "serde_json", "serde_yaml", "thiserror", - "toml", + "toml 0.8.23", ] [[package]] @@ -1878,23 +1937,13 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", + "trybuild", + "weaveffi", "weaveffi-bridge", "weaveffi-core", "weaveffi-ir", ] -[[package]] -name = "weaveffi-node-addon" -version = "0.1.0" -dependencies = [ - "libloading 0.8.9", - "napi", - "napi-build", - "napi-derive", - "once_cell", - "weaveffi-abi", -] - [[package]] name = "web-sys" version = "0.3.104" @@ -1938,6 +1987,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index fd67819c..ef321eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ "crates/weaveffi-core", "crates/weaveffi-gen-c", "crates/weaveffi-gen-swift", - "crates/weaveffi-gen-android", + "crates/weaveffi-gen-kotlin", "crates/weaveffi-gen-node", "crates/weaveffi-gen-wasm", "crates/weaveffi-gen-python", @@ -19,13 +19,13 @@ members = [ "crates/weaveffi-gen-ruby", "crates/weaveffi-cli", "samples/calculator", - "samples/node-addon", "samples/contacts", "samples/inventory", "samples/async-demo", "samples/events", "samples/kvstore", "samples/shapes", + "samples/codec", "crates/weaveffi-fuzz", ] resolver = "2" diff --git a/README.md b/README.md index 05b056d6..eea6e295 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # WeaveFFI -[![CI](https://github.com/weavefoundry/weaveffi/actions/workflows/ci.yml/badge.svg)](https://github.com/weavefoundry/weaveffi/actions/workflows/ci.yml) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue)](LICENSE-MIT) [![crates.io](https://img.shields.io/crates/v/weaveffi-cli.svg)](https://crates.io/crates/weaveffi-cli) [![Schema](https://img.shields.io/badge/schema-0.8.0-orange)](./weaveffi.schema.json) [![downloads](https://img.shields.io/crates/d/weaveffi-cli.svg)](https://crates.io/crates/weaveffi-cli) +[![CI](https://github.com/weavefoundry/weaveffi/actions/workflows/ci.yml/badge.svg)](https://github.com/weavefoundry/weaveffi/actions/workflows/ci.yml) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue)](LICENSE-MIT) [![crates.io](https://img.shields.io/crates/v/weaveffi-cli.svg)](https://crates.io/crates/weaveffi-cli) [![Schema](https://img.shields.io/badge/schema-0.9.0-orange)](./weaveffi.schema.json) [![downloads](https://img.shields.io/crates/d/weaveffi-cli.svg)](https://crates.io/crates/weaveffi-cli) WeaveFFI generates type-safe bindings for 11 languages for any native library that exposes a C ABI, whether it's written in Rust, C, C++, Zig, or anything else: no hand-written JNI, no duplicate implementations, no unsafe boilerplate. Define your API once as an IDL in YAML, JSON, or TOML and ship idiomatic -packages for C, C++, Swift, Kotlin/Android, Node.js, WebAssembly, Python, .NET, -Dart, Go, and Ruby that all talk to the same stable C ABI. Interfaces become -real classes with methods and automatic cleanup, and error domains become -typed errors consumers can catch and match on, not flat functions and raw -integer codes. Writing your producer in Rust? Annotate a normal module with +packages for C, C++, Swift, Kotlin, Node.js, WebAssembly, Python, .NET, Dart, +Go, and Ruby that all talk to the same stable C ABI. Interfaces become real, +reference-counted objects that consumers can share, store in records and +lists, and release deterministically; callback interfaces let the native +library call back into code the consumer wrote; and error domains become typed +errors consumers can catch and match on, not flat functions and raw integer +codes. Writing your producer in Rust? Annotate a normal module with `#[weaveffi::module]` and the macro generates both the C ABI and the IDL for you. Every path shares one engine, so the library you build and the bindings you ship cannot drift. @@ -25,11 +27,12 @@ cargo install weaveffi-cli **2. Define your API as an IDL** in `kvstore.yml`. Any native library that exposes a C ABI (written in C, C++, Zig, Rust, ...) implements the symbols it -declares. An interface is a real object with methods; an error domain plus -`throws: true` gives its fallible members typed errors: +declares. An interface is a reference-counted object with constructors and +methods; an error domain plus `throws: true` gives its fallible members typed +errors: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: kv errors: @@ -58,8 +61,10 @@ modules: **Producing in Rust?** Skip the hand-written IDL: annotate a normal module with `#[weaveffi::module]` (after `cargo add weaveffi`) and the macro emits the C ABI -and derives the IDL for you, so you write no `unsafe` glue. See -[The Rust Producer Macro](docs/src/guides/producer-macro.md) for the full +and derives the IDL for you, so you write no `unsafe` glue. An interface type +must be `Send + Sync` (the object is shared across the boundary and reference +counted as an `Arc`); its `pub` methods take `&self` or `self: Arc`. +See [The Rust Producer Macro](docs/src/guides/producer-macro.md) for the full walkthrough. ```rust @@ -77,6 +82,8 @@ pub mod kv { StoreFull = 1003, } + // Reference counted by the producer; every consumer wrapper holds one + // strong reference and releases it when it's closed or collected. #[weaveffi::interface] pub struct Store { entries: Mutex>>, @@ -116,34 +123,27 @@ weaveffi generate src/lib.rs -o generated --target c,swift,python ``` **4. Use the generated code from any of the eleven supported languages.** -Every target gets a real `Store` class whose objects clean up after -themselves, and a typed `KvError` consumers can catch and match on. Click -each block below to see what WeaveFFI emits. +Every target gets a real `Store` class whose objects hold one strong +reference to the native store and release it deterministically (`deinit`, +`close()`, `Dispose()`, RAII) with a garbage-collector backstop where the +language has one, plus a typed `KvError` consumers can catch and match on. +Click each block below to see what WeaveFFI emits.
C: generated/c/weaveffi.h ```c +typedef enum { weaveffi_kv_KvError_KeyNotFound = 1001, weaveffi_kv_KvError_StoreFull = 1003 } weaveffi_kv_KvError; typedef struct weaveffi_kv_Store weaveffi_kv_Store; -typedef enum { - weaveffi_kv_KvError_KeyNotFound = 1001, - weaveffi_kv_KvError_StoreFull = 1003 -} weaveffi_kv_KvError; - -weaveffi_kv_Store* weaveffi_kv_Store_open( - const char* path, weaveffi_error* out_err); - -bool weaveffi_kv_Store_put( - const weaveffi_kv_Store* self, - const char* key, - const uint8_t* value_ptr, size_t value_len, - weaveffi_error* out_err); - -int64_t weaveffi_kv_Store_count( - const weaveffi_kv_Store* self, weaveffi_error* out_err); - -void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); +// Module: kv +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err); +WEAVEFFI_API bool weaveffi_kv_Store_put(const weaveffi_kv_Store* self, const char* key, const uint8_t* value_ptr, size_t value_len, weaveffi_error* out_err); +WEAVEFFI_API int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ +WEAVEFFI_API void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); ```
@@ -158,7 +158,11 @@ public enum KvError: Error, LocalizedError { } public final class Store { - // The native object is released automatically on deinit. + let ptr: OpaquePointer + + deinit { + weaveffi_kv_Store_destroy(ptr) + } public static func open(path: String) throws -> Store { /* ... */ } @@ -171,10 +175,12 @@ public final class Store {
-Python: generated/python/weaveffi/weaveffi.pyi +Python: generated/python/kvstore/weaveffi.pyi ```python -class KvError(WeaveFFIError): ... +class KvError(WeaveFFIError): + KeyNotFound: Type["KeyNotFound"] + StoreFull: Type["StoreFull"] class KeyNotFound(KvError): CODE: int # 1001 @@ -183,6 +189,9 @@ class StoreFull(KvError): CODE: int # 1003 class Store: + def close(self) -> None: ... + def __enter__(self) -> "Store": ... + def __exit__(self, *exc: object) -> bool: ... @classmethod def open(cls, path: str) -> "Store": ... def put(self, key: str, value: bytes) -> bool: ... @@ -191,9 +200,12 @@ class Store:
-The remaining targets follow the same pattern with their own idioms: an -owned class (or the closest analogue) wired to the object's destructor, and -the module's error domain as a typed error or exception. +The remaining targets follow the same pattern with their own idioms: a class +(or the closest analogue) that owns one reference to the native object and +releases it through the language's natural disposal hook, and the module's +error domain as a typed error or exception. Add a `callback_interfaces:` block +to the IDL and each target also gets an interface, protocol, or abstract class +the consumer implements and the native library calls. ## Why WeaveFFI? @@ -207,41 +219,58 @@ the module's error domain as a typed error or exception. `#[weaveffi::module]` macro; any other backend that can expose a C ABI (C, C++, Zig) implements the generated header directly. - **Idiomatic per-target output.** No lowest-common-denominator surface area. - Interfaces become real classes with methods and automatic disposal, and + Interfaces become real classes with methods and deterministic disposal, and error domains become typed errors (a Swift error enum, Python exception classes, and each remaining target's own exception idiom). Swift gets `async/await` and `throws`, Kotlin gets `suspend` and JNI glue, Python - gets typed `.pyi` stubs, TypeScript gets `Promise`s, and Dart gets - `dart:ffi`, all from the same definition. + gets typed `.pyi` stubs, TypeScript gets `Promise`s and `BigInt`, and Dart + gets `dart:ffi` with `NativeFinalizer`, all from the same definition. +- **Objects that compose.** Interface objects are reference counted by the + producer (`Arc` in Rust) and expose `_clone`/`_destroy` at the C ABI, so + they can be returned, passed, shared between wrappers, and nested inside + records, optionals, lists, maps, iterators, and async results. Every wrapper + releases its reference when it's closed, disposed, or dropped. +- **Callback interfaces.** Declare a `callback_interfaces:` block (or a + `#[weaveffi::callback_interface] trait` in Rust) and the consumer implements + it as a protocol, interface, or abstract class. The producer receives an + `Arc` it can retain and call from any thread; a consumer-side + failure surfaces to the original caller as a foreign error rather than a + crash. - **The whole IDL surface, on every target.** Interfaces, typed error - domains, async functions, iterators, callbacks, and event listeners work - across all eleven languages. Generators declare their capabilities and + domains, async functions (with a pluggable spawner, so Tokio users can plug + in their runtime), iterators, and callback interfaces work across all + eleven languages. Generators declare their capabilities and `weaveffi generate` fails with a clear error (never a silent skip) in the rare mode that can't deliver a feature you use (Wasm's Emscripten compat - mode excludes async, callbacks, and listeners). See the + mode excludes async functions and callback interfaces). See the [feature matrix](docs/src/generators/README.md#feature-support-matrix). ## How does it compare? See [Comparison](docs/src/comparison.md) for a side-by-side feature matrix -versus UniFFI, cbindgen, diplomat, SWIG, and autocxx, plus an honest -"when to choose WeaveFFI" guide. +versus UniFFI, Diplomat, cbindgen, swift-bridge, napi-rs, wasm-bindgen, SWIG, +and autocxx, plus an honest "when to choose WeaveFFI" guide. ## Supported targets -| Target | Output directory | What you get | -|--------|------------------|--------------| -| **C** | `c/` | `weaveffi.h` header with interface typedefs, function prototypes, and the shared `weaveffi_error` type | -| **C++** | `cpp/` | RAII header (`weaveffi.hpp`) with move semantics, `std::optional`/`std::vector`/`std::unordered_map` wrappers, exception-based errors, and a `CMakeLists.txt` | -| **Swift** | `swift/` | SwiftPM package wrapping the C ABI with `throws`, `async/await`, and `Codable`-friendly types | -| **Android** | `android/` | Kotlin JNI wrapper, C shim, and a Gradle project skeleton | -| **Node.js** | `node/` | N-API addon loader + TypeScript declarations and a `package.json` | -| **Wasm** | `wasm/` | JavaScript loader + TypeScript declarations for `wasm32-unknown-unknown` builds | -| **Python** | `python/` | `ctypes` bindings + `.pyi` type stubs + `pyproject.toml` | -| **.NET** | `dotnet/` | C# P/Invoke bindings + `.csproj` + `.nuspec` for NuGet | -| **Dart** | `dart/` | `dart:ffi` bindings + `pubspec.yaml` for Flutter and Dart projects | -| **Go** | `go/` | CGo bindings + `go.mod` for Go modules | -| **Ruby** | `ruby/` | FFI gem bindings + `.gemspec` for RubyGems | +Every target consumes the same C ABI (revision 2). The table lists what each +generator emits, how its wrapper owns an interface object (each wrapper holds +one strong reference and releases it through the listed hook), and how a +consumer implements a callback interface. + +| Target | Output directory | What you get | Objects | Callback interfaces | +|--------|------------------|--------------|---------|---------------------| +| **C** | `c/` | `weaveffi.h` header with opaque interface typedefs, `_clone`/`_destroy` pairs, vtable typedefs, function prototypes, and the shared `weaveffi_error` type | Raw `{tag}*`; call `_clone` to share and `_destroy` to release | Fill a `{tag}_vtable` struct (`ctx` + function pointers + `free`) by hand | +| **C++** | `cpp/` | RAII header (`weaveffi.hpp`) with `std::optional`/`std::vector`/`std::unordered_map` wrappers, exception-based errors, and a `CMakeLists.txt` | Copyable RAII class (copy calls `_clone`, destructor calls `_destroy`) | Subclass the abstract class and pass a `std::shared_ptr` | +| **Swift** | `swift/` | SwiftPM package wrapping the C ABI with `throws`, `async/await`, and `Sequence` iterators | `final class` releasing in `deinit` | Conform to the generated `protocol` | +| **Kotlin** | `kotlin/` | Kotlin wrapper, JNI C shim, and a Gradle (`build.gradle.kts`) project for Android or the desktop JVM | `AutoCloseable` class with a `java.lang.ref.Cleaner` backstop | Implement the generated `interface` | +| **Node.js** | `node/` | N-API addon loader + TypeScript declarations (`BigInt` for 64-bit integers) and a `package.json` | Class with `close()` / `[Symbol.dispose]` and a `FinalizationRegistry` backstop | Pass any object matching the TypeScript `interface` | +| **Wasm** | `wasm/` | JavaScript loader + TypeScript declarations for `wasm32-unknown-unknown` builds, packaged as npm | Class with `close()` / `[Symbol.dispose]` and a `FinalizationRegistry` backstop | Pass any object matching the TypeScript `interface`; delivery is synchronous | +| **Python** | `python/` | `ctypes` bindings + `.pyi` type stubs + `pyproject.toml` | Class with `close()`, context-manager support, and a `__del__` backstop | Subclass the generated `ABC` | +| **.NET** | `dotnet/` | C# P/Invoke bindings + `.csproj` + `.nuspec` for NuGet | `IDisposable` class with a finalizer backstop | Implement the generated `I*` interface | +| **Dart** | `dart/` | `dart:ffi` bindings + `pubspec.yaml` for Flutter and Dart projects | `Finalizable` class with `dispose()` and a `NativeFinalizer` backstop | Extend the generated `abstract class` | +| **Go** | `go/` | CGo bindings + `go.mod` for Go modules | Struct with `Close()` and a `runtime.SetFinalizer` backstop | Implement the generated `interface` | +| **Ruby** | `ruby/` | FFI gem bindings + `.gemspec` for RubyGems | Class with `close` backed by an `FFI::AutoPointer` | Include the generated module and implement its methods | ## Install @@ -260,7 +289,7 @@ Verify the install: ```bash weaveffi --version -weaveffi schema-version # prints 0.8.0 +weaveffi schema-version # prints 0.9.0 ``` ## CLI reference @@ -268,12 +297,12 @@ weaveffi schema-version # prints 0.8.0 | Command | Description | |---------|-------------| | `weaveffi generate -o ` | Generate bindings from annotated Rust (`.rs`) or an IDL (`.yml`/`.json`/`.toml`); `--target c,swift,...` to subset, `--config weaveffi.toml` to override the auto-discovered project config, `--dry-run` to preview, `--force` to bypass the output cache | -| `weaveffi package -o ` | Assemble publishable, per-platform packages that bundle a prebuilt native library; `--binaries ` for prebuilt libs or `--build ` to cross-compile a Rust producer | +| `weaveffi package -o ` | Assemble publishable, per-platform packages that bundle a prebuilt native library (Kotlin bundles `jniLibs//`, Wasm bundles the `.wasm` into an npm package); `--binaries ` for prebuilt libs or `--build ` to cross-compile a Rust producer | | `weaveffi validate ` | Validate an API definition without generating; `--warn` to also report advisory lints, `--format json` for machine-readable output | | `weaveffi diff ` | Show what would change if bindings were regenerated; `--check` for CI | | `weaveffi extract ` | Derive an IDL document from `#[weaveffi::module]`-annotated Rust source | | `weaveffi schema --format json-schema` | Print the JSON Schema for the IDL | -| `weaveffi schema-version` | Print the current IDL schema version (`0.8.0`) | +| `weaveffi schema-version` | Print the current IDL schema version (`0.9.0`) | | `weaveffi completions ` | Print shell completion scripts (`bash`, `zsh`, `fish`, `powershell`, `elvish`) | ### Project configuration: `weaveffi.toml` @@ -308,7 +337,7 @@ Reference the JSON Schema from your IDL for editor autocompletion: ```yaml # yaml-language-server: $schema=./weaveffi.schema.json -version: "0.8.0" +version: "0.9.0" modules: ... ``` @@ -320,29 +349,40 @@ Full documentation lives at (sources under [`docs/`](./docs/)). Key pages: - [Introduction](docs/src/intro.md): what WeaveFFI is and why it exists -- [Getting Started](docs/src/getting-started.md): install → IDL → generate → call from C -- [Comparison](docs/src/comparison.md): feature matrix vs UniFFI, cbindgen, diplomat, SWIG, autocxx -- [FAQ](docs/src/faq.md): top questions about scope, runtime cost, and platform support +- [Getting Started](docs/src/getting-started.md): install, define an IDL, generate, and call from C +- [C ABI Contract](docs/src/reference/abi.md): the normative description of ABI revision 2 (objects, callback interfaces, value buffers, async, iterators) +- [Comparison](docs/src/comparison.md): feature matrix vs UniFFI, Diplomat, cbindgen, swift-bridge, napi-rs, wasm-bindgen, SWIG, autocxx +- [FAQ](docs/src/faq.md): top questions about scope, object ownership, callbacks, runtime cost, and platform support +- [Samples](docs/src/samples.md): the eight sample producers, from `calculator` to the `codec` round-trip oracle - [Generators](docs/src/generators/): per-target reference for each of the eleven languages -- [Guides](docs/src/guides/): memory ownership, error handling, async, configuration +- [Guides](docs/src/guides/): the producer macro, memory ownership, error handling, async, configuration, packaging +- [Stability and Versioning](docs/src/stability.md): what changed in schema 0.9.0 / ABI 2 and how to migrate ## Status -WeaveFFI is in active `0.x` development. Following [Semantic -Versioning](https://semver.org/), the public surface (the CLI, the IDL -schema, the generated code, and the `weaveffi-abi` runtime symbols) may -change between minor releases while the project is pre-1.0, and only the -current IDL schema version is accepted. See [Stability and -Versioning](docs/src/stability.md) for what that means in practice and the -recommended `weaveffi diff --check` CI workflow. +WeaveFFI is in active `0.x` development. The current IDL schema is `0.9.0` +and the C ABI is at revision 2 (reference-counted objects, callback +interfaces, and a pluggable async spawner; `handle`, borrowed `&str`/`&[u8]` +spellings, module-level callbacks and listeners, and the arena runtime were +removed). Following [Semantic Versioning](https://semver.org/), the public +surface (the CLI, the IDL schema, the generated code, and the `weaveffi-abi` +runtime symbols) may change between minor releases while the project is +pre-1.0, and only the current IDL schema version is accepted. See [Stability +and Versioning](docs/src/stability.md) for the migration guide and the +recommended `weaveffi diff --check` CI workflow, and [Roadmap](docs/src/roadmap.md) +for what's planned next. The full quality gate (`cargo fmt`, `cargo clippy -D warnings`, `cargo test`, `cargo doc -D warnings`, `cargo deny`, `cargo audit`, `cargo machete`, `cargo insta test --check`, `cargo bench --no-run`, and -`weaveffi diff --check` on every sample) runs in CI on every PR, plus a -conformance harness that builds real producers and runs generated -consumers end to end in all eleven languages, including dedicated async -lanes. +`weaveffi diff --check` on every sample) runs in CI on every PR. The test +suite includes a property-based test of the value-buffer codec, `trybuild` +UI tests pinning the producer macro's compile-time diagnostics, and a +snapshot corpus of five IDL fixtures across all eleven generators. A +conformance harness builds real producers and runs generated consumers end +to end in all eleven languages, with dedicated async lanes and a `codec` +lane that round-trips every value-buffer wire shape (including object +tokens) through every language. Releases are fully automated by [semantic-release](https://semantic-release.gitbook.io/) on merge to `main`. diff --git a/conformance/c/codec.c b/conformance/c/codec.c new file mode 100644 index 00000000..4ef67365 --- /dev/null +++ b/conformance/c/codec.c @@ -0,0 +1,1092 @@ +// Conformance consumer: codec sample, C target (ABI revision 2). +// +// A round-trip check of the value-buffer protocol against the producer's own +// codec: every fixed-width scalar, strings (including non-ASCII), bytes, +// present and absent optionals, lists (nested and empty), string- and +// integer-keyed maps with record values, nested records, every rich-enum +// variant, lists of optionals and enums, and object tokens in a record +// field, an optional, and a list. Fixtures fetched from the producer are +// checked field by field (producer encodes, consumer decodes), handed back +// to `verify_*` (consumer encodes, producer decodes), and re-encoded through +// `roundtrip_*` and compared again; hand-built edge values (empty +// containers, 64-bit extremes, NaN, infinities, negative zero) go through +// the same round trip. Exits 0 on success; aborts on any mismatch. + +#include +#include +#include +#include +#include + +#include "weaveffi.h" +#include "wvbuf.h" + +// ── Scalars ──────────────────────────────────────────────────────────────── + +typedef struct { + int8_t i8v; + uint8_t u8v; + int16_t i16v; + uint16_t u16v; + int32_t i32v; + uint32_t u32v; + int64_t i64v; + uint64_t u64v; + float f32v; + double f64v; + int flag; + int32_t color; +} scalars_t; + +static void read_scalars(wv_reader* r, scalars_t* s) { + s->i8v = wv_get_i8(r); + s->u8v = wv_get_u8(r); + s->i16v = wv_get_i16(r); + s->u16v = wv_get_u16(r); + s->i32v = wv_get_i32(r); + s->u32v = wv_get_u32(r); + s->i64v = wv_get_i64(r); + s->u64v = wv_get_u64(r); + s->f32v = wv_get_f32(r); + s->f64v = wv_get_f64(r); + s->flag = wv_get_bool(r); + s->color = wv_get_i32(r); +} + +static void write_scalars(wv_writer* w, const scalars_t* s) { + wv_put_i8(w, s->i8v); + wv_put_u8(w, s->u8v); + wv_put_i16(w, s->i16v); + wv_put_u16(w, s->u16v); + wv_put_i32(w, s->i32v); + wv_put_u32(w, s->u32v); + wv_put_i64(w, s->i64v); + wv_put_u64(w, s->u64v); + wv_put_f32(w, s->f32v); + wv_put_f64(w, s->f64v); + wv_put_bool(w, s->flag); + wv_put_i32(w, s->color); +} + +// Bitwise float equality, so NaN payloads and the sign of zero count. +static int f32_bits_eq(float a, float b) { return memcmp(&a, &b, 4) == 0; } +static int f64_bits_eq(double a, double b) { return memcmp(&a, &b, 8) == 0; } + +static int scalars_eq(const scalars_t* a, const scalars_t* b) { + return a->i8v == b->i8v && a->u8v == b->u8v && a->i16v == b->i16v && + a->u16v == b->u16v && a->i32v == b->i32v && a->u32v == b->u32v && + a->i64v == b->i64v && a->u64v == b->u64v && + f32_bits_eq(a->f32v, b->f32v) && f64_bits_eq(a->f64v, b->f64v) && + a->flag == b->flag && a->color == b->color; +} + +// The canonical fixture the producer hands out and expects back. +static const scalars_t CANONICAL_SCALARS = { + -8, 200, -16000, 60000, -2000000000, 4000000000u, + -9007199254740993LL, 18446744073709551615ULL, 1.5f, -2.25e100, 1, + weaveffi_codec_Color_Blue, +}; + +// ── Shape (rich enum) ────────────────────────────────────────────────────── + +typedef struct { + int32_t tag; + double radius; // Circle + float width, height; // Rect + char* label; // Labeled + int32_t count; // Labeled + scalars_t inner; // Nested + int has_note; // Nested + char* note; // Nested (when has_note) +} shape_t; + +static void read_shape(wv_reader* r, shape_t* s) { + memset(s, 0, sizeof *s); + s->tag = wv_get_i32(r); + switch (s->tag) { + case weaveffi_codec_Shape_Empty: + break; + case weaveffi_codec_Shape_Circle: + s->radius = wv_get_f64(r); + break; + case weaveffi_codec_Shape_Rect: + s->width = wv_get_f32(r); + s->height = wv_get_f32(r); + break; + case weaveffi_codec_Shape_Labeled: + s->label = wv_get_str(r); + s->count = wv_get_i32(r); + break; + case weaveffi_codec_Shape_Nested: + read_scalars(r, &s->inner); + s->has_note = wv_get_bool(r); + if (s->has_note) s->note = wv_get_str(r); + break; + default: + assert(0 && "unknown Shape tag"); + } +} + +static void write_shape(wv_writer* w, const shape_t* s) { + wv_put_i32(w, s->tag); + switch (s->tag) { + case weaveffi_codec_Shape_Empty: + break; + case weaveffi_codec_Shape_Circle: + wv_put_f64(w, s->radius); + break; + case weaveffi_codec_Shape_Rect: + wv_put_f32(w, s->width); + wv_put_f32(w, s->height); + break; + case weaveffi_codec_Shape_Labeled: + wv_put_str(w, s->label); + wv_put_i32(w, s->count); + break; + case weaveffi_codec_Shape_Nested: + write_scalars(w, &s->inner); + wv_put_bool(w, s->has_note); + if (s->has_note) wv_put_str(w, s->note); + break; + default: + assert(0 && "unknown Shape tag"); + } +} + +static int str_eq(const char* a, const char* b) { + if (a == NULL || b == NULL) return a == b; + return strcmp(a, b) == 0; +} + +static int shape_eq(const shape_t* a, const shape_t* b) { + if (a->tag != b->tag) return 0; + switch (a->tag) { + case weaveffi_codec_Shape_Empty: + return 1; + case weaveffi_codec_Shape_Circle: + return f64_bits_eq(a->radius, b->radius); + case weaveffi_codec_Shape_Rect: + return f32_bits_eq(a->width, b->width) && f32_bits_eq(a->height, b->height); + case weaveffi_codec_Shape_Labeled: + return str_eq(a->label, b->label) && a->count == b->count; + case weaveffi_codec_Shape_Nested: + return scalars_eq(&a->inner, &b->inner) && a->has_note == b->has_note && + (!a->has_note || str_eq(a->note, b->note)); + default: + return 0; + } +} + +static void shape_free(shape_t* s) { + free(s->label); + free(s->note); + s->label = s->note = NULL; +} + +static shape_t shape_empty(void) { + shape_t s; + memset(&s, 0, sizeof s); + s.tag = weaveffi_codec_Shape_Empty; + return s; +} + +static shape_t shape_circle(double radius) { + shape_t s = shape_empty(); + s.tag = weaveffi_codec_Shape_Circle; + s.radius = radius; + return s; +} + +static shape_t shape_rect(float w, float h) { + shape_t s = shape_empty(); + s.tag = weaveffi_codec_Shape_Rect; + s.width = w; + s.height = h; + return s; +} + +// `label` is copied so every shape_t owns its strings uniformly. +static shape_t shape_labeled(const char* label, int32_t count) { + shape_t s = shape_empty(); + s.tag = weaveffi_codec_Shape_Labeled; + s.label = strdup(label); + s.count = count; + return s; +} + +static shape_t shape_nested(const scalars_t* inner, const char* note) { + shape_t s = shape_empty(); + s.tag = weaveffi_codec_Shape_Nested; + s.inner = *inner; + s.has_note = note != NULL; + s.note = note ? strdup(note) : NULL; + return s; +} + +// ── Composite ────────────────────────────────────────────────────────────── + +typedef struct { + int32_t* v; + uint32_t n; +} i32_list_t; + +typedef struct { + char* key; + int64_t value; +} by_name_t; + +typedef struct { + int32_t key; + scalars_t value; +} by_id_t; + +typedef struct { + int has; + int value; +} opt_bool_t; + +typedef struct { + char* name; + uint8_t* blob; + uint32_t blob_len; + int has_some_i64; + int64_t some_i64; + int has_none_i64; + int64_t none_i64; + int has_some_text; + char* some_text; + char** names; + uint32_t names_n; + i32_list_t* matrix; + uint32_t matrix_n; + double* empty; + uint32_t empty_n; + by_name_t* by_name; + uint32_t by_name_n; + by_id_t* by_id; + uint32_t by_id_n; + scalars_t scalars; + shape_t shape; + shape_t* shapes; + uint32_t shapes_n; + int has_maybe_shape; + shape_t maybe_shape; + int has_maybe_list; + uint8_t* maybe_list; + uint32_t maybe_list_n; + opt_bool_t* sparse; + uint32_t sparse_n; + int32_t* colors; + uint32_t colors_n; +} composite_t; + +static void* xcalloc(size_t n, size_t size) { + void* p = calloc(n ? n : 1, size); + assert(p != NULL); + return p; +} + +static void read_composite(wv_reader* r, composite_t* c) { + memset(c, 0, sizeof *c); + c->name = wv_get_str(r); + c->blob_len = wv_get_u32(r); + c->blob = (uint8_t*)xcalloc(c->blob_len, 1); + memcpy(c->blob, wv_take(r, c->blob_len), c->blob_len); + c->has_some_i64 = wv_get_bool(r); + if (c->has_some_i64) c->some_i64 = wv_get_i64(r); + c->has_none_i64 = wv_get_bool(r); + if (c->has_none_i64) c->none_i64 = wv_get_i64(r); + c->has_some_text = wv_get_bool(r); + if (c->has_some_text) c->some_text = wv_get_str(r); + c->names_n = wv_get_u32(r); + c->names = (char**)xcalloc(c->names_n, sizeof(char*)); + for (uint32_t i = 0; i < c->names_n; i++) c->names[i] = wv_get_str(r); + c->matrix_n = wv_get_u32(r); + c->matrix = (i32_list_t*)xcalloc(c->matrix_n, sizeof(i32_list_t)); + for (uint32_t i = 0; i < c->matrix_n; i++) { + c->matrix[i].n = wv_get_u32(r); + c->matrix[i].v = (int32_t*)xcalloc(c->matrix[i].n, sizeof(int32_t)); + for (uint32_t j = 0; j < c->matrix[i].n; j++) c->matrix[i].v[j] = wv_get_i32(r); + } + c->empty_n = wv_get_u32(r); + c->empty = (double*)xcalloc(c->empty_n, sizeof(double)); + for (uint32_t i = 0; i < c->empty_n; i++) c->empty[i] = wv_get_f64(r); + c->by_name_n = wv_get_u32(r); + c->by_name = (by_name_t*)xcalloc(c->by_name_n, sizeof(by_name_t)); + for (uint32_t i = 0; i < c->by_name_n; i++) { + c->by_name[i].key = wv_get_str(r); + c->by_name[i].value = wv_get_i64(r); + } + c->by_id_n = wv_get_u32(r); + c->by_id = (by_id_t*)xcalloc(c->by_id_n, sizeof(by_id_t)); + for (uint32_t i = 0; i < c->by_id_n; i++) { + c->by_id[i].key = wv_get_i32(r); + read_scalars(r, &c->by_id[i].value); + } + read_scalars(r, &c->scalars); + read_shape(r, &c->shape); + c->shapes_n = wv_get_u32(r); + c->shapes = (shape_t*)xcalloc(c->shapes_n, sizeof(shape_t)); + for (uint32_t i = 0; i < c->shapes_n; i++) read_shape(r, &c->shapes[i]); + c->has_maybe_shape = wv_get_bool(r); + if (c->has_maybe_shape) read_shape(r, &c->maybe_shape); + c->has_maybe_list = wv_get_bool(r); + if (c->has_maybe_list) { + c->maybe_list_n = wv_get_u32(r); + c->maybe_list = (uint8_t*)xcalloc(c->maybe_list_n, 1); + for (uint32_t i = 0; i < c->maybe_list_n; i++) c->maybe_list[i] = wv_get_u8(r); + } + c->sparse_n = wv_get_u32(r); + c->sparse = (opt_bool_t*)xcalloc(c->sparse_n, sizeof(opt_bool_t)); + for (uint32_t i = 0; i < c->sparse_n; i++) { + c->sparse[i].has = wv_get_bool(r); + if (c->sparse[i].has) c->sparse[i].value = wv_get_bool(r); + } + c->colors_n = wv_get_u32(r); + c->colors = (int32_t*)xcalloc(c->colors_n, sizeof(int32_t)); + for (uint32_t i = 0; i < c->colors_n; i++) c->colors[i] = wv_get_i32(r); +} + +static void write_composite(wv_writer* w, const composite_t* c) { + wv_put_str(w, c->name); + wv_put_bytes(w, c->blob, c->blob_len); + wv_put_bool(w, c->has_some_i64); + if (c->has_some_i64) wv_put_i64(w, c->some_i64); + wv_put_bool(w, c->has_none_i64); + if (c->has_none_i64) wv_put_i64(w, c->none_i64); + wv_put_bool(w, c->has_some_text); + if (c->has_some_text) wv_put_str(w, c->some_text); + wv_put_u32(w, c->names_n); + for (uint32_t i = 0; i < c->names_n; i++) wv_put_str(w, c->names[i]); + wv_put_u32(w, c->matrix_n); + for (uint32_t i = 0; i < c->matrix_n; i++) { + wv_put_u32(w, c->matrix[i].n); + for (uint32_t j = 0; j < c->matrix[i].n; j++) wv_put_i32(w, c->matrix[i].v[j]); + } + wv_put_u32(w, c->empty_n); + for (uint32_t i = 0; i < c->empty_n; i++) wv_put_f64(w, c->empty[i]); + wv_put_u32(w, c->by_name_n); + for (uint32_t i = 0; i < c->by_name_n; i++) { + wv_put_str(w, c->by_name[i].key); + wv_put_i64(w, c->by_name[i].value); + } + wv_put_u32(w, c->by_id_n); + for (uint32_t i = 0; i < c->by_id_n; i++) { + wv_put_i32(w, c->by_id[i].key); + write_scalars(w, &c->by_id[i].value); + } + write_scalars(w, &c->scalars); + write_shape(w, &c->shape); + wv_put_u32(w, c->shapes_n); + for (uint32_t i = 0; i < c->shapes_n; i++) write_shape(w, &c->shapes[i]); + wv_put_bool(w, c->has_maybe_shape); + if (c->has_maybe_shape) write_shape(w, &c->maybe_shape); + wv_put_bool(w, c->has_maybe_list); + if (c->has_maybe_list) { + wv_put_u32(w, c->maybe_list_n); + for (uint32_t i = 0; i < c->maybe_list_n; i++) wv_put_u8(w, c->maybe_list[i]); + } + wv_put_u32(w, c->sparse_n); + for (uint32_t i = 0; i < c->sparse_n; i++) { + wv_put_bool(w, c->sparse[i].has); + if (c->sparse[i].has) wv_put_bool(w, c->sparse[i].value); + } + wv_put_u32(w, c->colors_n); + for (uint32_t i = 0; i < c->colors_n; i++) wv_put_i32(w, c->colors[i]); +} + +static int composite_eq(const composite_t* a, const composite_t* b) { + if (!str_eq(a->name, b->name)) return 0; + if (a->blob_len != b->blob_len || memcmp(a->blob, b->blob, a->blob_len) != 0) return 0; + if (a->has_some_i64 != b->has_some_i64 || (a->has_some_i64 && a->some_i64 != b->some_i64)) + return 0; + if (a->has_none_i64 != b->has_none_i64 || (a->has_none_i64 && a->none_i64 != b->none_i64)) + return 0; + if (a->has_some_text != b->has_some_text || + (a->has_some_text && !str_eq(a->some_text, b->some_text))) + return 0; + if (a->names_n != b->names_n) return 0; + for (uint32_t i = 0; i < a->names_n; i++) + if (!str_eq(a->names[i], b->names[i])) return 0; + if (a->matrix_n != b->matrix_n) return 0; + for (uint32_t i = 0; i < a->matrix_n; i++) { + if (a->matrix[i].n != b->matrix[i].n) return 0; + for (uint32_t j = 0; j < a->matrix[i].n; j++) + if (a->matrix[i].v[j] != b->matrix[i].v[j]) return 0; + } + if (a->empty_n != b->empty_n) return 0; + for (uint32_t i = 0; i < a->empty_n; i++) + if (!f64_bits_eq(a->empty[i], b->empty[i])) return 0; + if (a->by_name_n != b->by_name_n) return 0; + for (uint32_t i = 0; i < a->by_name_n; i++) + if (!str_eq(a->by_name[i].key, b->by_name[i].key) || + a->by_name[i].value != b->by_name[i].value) + return 0; + if (a->by_id_n != b->by_id_n) return 0; + for (uint32_t i = 0; i < a->by_id_n; i++) + if (a->by_id[i].key != b->by_id[i].key || + !scalars_eq(&a->by_id[i].value, &b->by_id[i].value)) + return 0; + if (!scalars_eq(&a->scalars, &b->scalars)) return 0; + if (!shape_eq(&a->shape, &b->shape)) return 0; + if (a->shapes_n != b->shapes_n) return 0; + for (uint32_t i = 0; i < a->shapes_n; i++) + if (!shape_eq(&a->shapes[i], &b->shapes[i])) return 0; + if (a->has_maybe_shape != b->has_maybe_shape || + (a->has_maybe_shape && !shape_eq(&a->maybe_shape, &b->maybe_shape))) + return 0; + if (a->has_maybe_list != b->has_maybe_list) return 0; + if (a->has_maybe_list && + (a->maybe_list_n != b->maybe_list_n || + memcmp(a->maybe_list, b->maybe_list, a->maybe_list_n) != 0)) + return 0; + if (a->sparse_n != b->sparse_n) return 0; + for (uint32_t i = 0; i < a->sparse_n; i++) + if (a->sparse[i].has != b->sparse[i].has || + (a->sparse[i].has && a->sparse[i].value != b->sparse[i].value)) + return 0; + if (a->colors_n != b->colors_n) return 0; + for (uint32_t i = 0; i < a->colors_n; i++) + if (a->colors[i] != b->colors[i]) return 0; + return 1; +} + +static void composite_free(composite_t* c) { + free(c->name); + free(c->blob); + free(c->some_text); + for (uint32_t i = 0; i < c->names_n; i++) free(c->names[i]); + free(c->names); + for (uint32_t i = 0; i < c->matrix_n; i++) free(c->matrix[i].v); + free(c->matrix); + free(c->empty); + for (uint32_t i = 0; i < c->by_name_n; i++) free(c->by_name[i].key); + free(c->by_name); + free(c->by_id); + shape_free(&c->shape); + for (uint32_t i = 0; i < c->shapes_n; i++) shape_free(&c->shapes[i]); + free(c->shapes); + if (c->has_maybe_shape) shape_free(&c->maybe_shape); + free(c->maybe_list); + free(c->sparse); + free(c->colors); + memset(c, 0, sizeof *c); +} + +// ── ABI helpers ──────────────────────────────────────────────────────────── + +// Decode a buffered return, checking it was consumed exactly, and free it. +#define TAKE_BUFFER(ptr, len, reader_body) \ + do { \ + assert((ptr) != NULL); \ + wv_reader r_; \ + wv_r_init(&r_, (ptr), (len)); \ + reader_body; \ + wv_r_expect_end(&r_); \ + weaveffi_free_bytes((uint8_t*)(ptr), (len)); \ + } while (0) + +static void fetch_sample_scalars(scalars_t* out) { + weaveffi_error err = {0}; + size_t len = 0; + const uint8_t* p = weaveffi_codec_sample_scalars(&len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, read_scalars(&r_, out)); +} + +static void fetch_sample_composite(composite_t* out) { + weaveffi_error err = {0}; + size_t len = 0; + const uint8_t* p = weaveffi_codec_sample_composite(&len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, read_composite(&r_, out)); +} + +static void roundtrip_scalars(const scalars_t* in, scalars_t* out) { + weaveffi_error err = {0}; + wv_writer w; + wv_w_init(&w); + write_scalars(&w, in); + size_t len = 0; + const uint8_t* p = weaveffi_codec_roundtrip_scalars(w.buf, w.len, &len, &err); + assert(err.code == 0); + assert(len == w.len && memcmp(p, w.buf, len) == 0 && + "our encoding is byte-identical to the producer's"); + wv_w_free(&w); + TAKE_BUFFER(p, len, read_scalars(&r_, out)); +} + +static void roundtrip_composite(const composite_t* in, composite_t* out) { + weaveffi_error err = {0}; + wv_writer w; + wv_w_init(&w); + write_composite(&w, in); + size_t len = 0; + const uint8_t* p = weaveffi_codec_roundtrip_composite(w.buf, w.len, &len, &err); + assert(err.code == 0); + assert(len == w.len && memcmp(p, w.buf, len) == 0 && + "our encoding is byte-identical to the producer's"); + wv_w_free(&w); + TAKE_BUFFER(p, len, read_composite(&r_, out)); +} + +static void roundtrip_shape(const shape_t* in, shape_t* out) { + weaveffi_error err = {0}; + wv_writer w; + wv_w_init(&w); + write_shape(&w, in); + size_t len = 0; + const uint8_t* p = weaveffi_codec_roundtrip_shape(w.buf, w.len, &len, &err); + assert(err.code == 0); + assert(len == w.len && memcmp(p, w.buf, len) == 0); + wv_w_free(&w); + TAKE_BUFFER(p, len, read_shape(&r_, out)); +} + +static int verify_scalars(const scalars_t* s, weaveffi_error* err) { + wv_writer w; + wv_w_init(&w); + write_scalars(&w, s); + int ok = weaveffi_codec_verify_scalars(w.buf, w.len, err); + wv_w_free(&w); + return ok; +} + +static int verify_composite(const composite_t* c, weaveffi_error* err) { + wv_writer w; + wv_w_init(&w); + write_composite(&w, c); + int ok = weaveffi_codec_verify_composite(w.buf, w.len, err); + wv_w_free(&w); + return ok; +} + +static char* describe_shape(const shape_t* s) { + weaveffi_error err = {0}; + wv_writer w; + wv_w_init(&w); + write_shape(&w, s); + const char* text = weaveffi_codec_describe_shape(w.buf, w.len, &err); + assert(err.code == 0 && text != NULL); + wv_w_free(&w); + char* copy = strdup(text); + weaveffi_free_string(text); + return copy; +} + +// ── Holder (objects inside buffers) ──────────────────────────────────────── + +typedef struct { + weaveffi_codec_Token* primary; + weaveffi_codec_Token* spare; // NULL when absent + weaveffi_codec_Token* many[8]; + uint32_t many_n; +} holder_t; + +// Decoding adopts one strong reference per token. +static void read_holder(wv_reader* r, holder_t* h) { + memset(h, 0, sizeof *h); + h->primary = (weaveffi_codec_Token*)wv_get_obj(r); + h->spare = wv_get_bool(r) ? (weaveffi_codec_Token*)wv_get_obj(r) : NULL; + h->many_n = wv_get_u32(r); + assert(h->many_n <= 8); + for (uint32_t i = 0; i < h->many_n; i++) h->many[i] = (weaveffi_codec_Token*)wv_get_obj(r); +} + +// Encoding mints a fresh reference per token with `_clone`; the holder's +// own references are untouched. +static void write_holder(wv_writer* w, const holder_t* h) { + wv_put_obj(w, weaveffi_codec_Token_clone(h->primary)); + wv_put_bool(w, h->spare != NULL); + if (h->spare) wv_put_obj(w, weaveffi_codec_Token_clone(h->spare)); + wv_put_u32(w, h->many_n); + for (uint32_t i = 0; i < h->many_n; i++) wv_put_obj(w, weaveffi_codec_Token_clone(h->many[i])); +} + +static void holder_release(holder_t* h) { + weaveffi_codec_Token_destroy(h->primary); + weaveffi_codec_Token_destroy(h->spare); + for (uint32_t i = 0; i < h->many_n; i++) weaveffi_codec_Token_destroy(h->many[i]); + memset(h, 0, sizeof *h); +} + +static int64_t token_value(const weaveffi_codec_Token* t) { + weaveffi_error err = {0}; + int64_t v = weaveffi_codec_Token_value(t, &err); + assert(err.code == 0); + return v; +} + +static void make_holder(int64_t base, int with_spare, holder_t* out) { + weaveffi_error err = {0}; + size_t len = 0; + const uint8_t* p = weaveffi_codec_make_holder(base, with_spare, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, read_holder(&r_, out)); +} + +// ── main ─────────────────────────────────────────────────────────────────── + +int main(void) { + weaveffi_error err = {0}; + + assert(weaveffi_abi_version() == 2u && WEAVEFFI_ABI_VERSION == 2u); + + // ── Scalars: producer encodes, consumer decodes ────────────────────── + scalars_t s; + fetch_sample_scalars(&s); + assert(s.i8v == -8); + assert(s.u8v == 200); + assert(s.i16v == -16000); + assert(s.u16v == 60000); + assert(s.i32v == -2000000000); + assert(s.u32v == 4000000000u); + assert(s.i64v == -9007199254740993LL); + assert(s.u64v == UINT64_MAX); + assert(s.f32v == 1.5f); + assert(s.f64v == -2.25e100); + assert(s.flag == 1); + assert(s.color == weaveffi_codec_Color_Blue && s.color == 7); + assert(scalars_eq(&s, &CANONICAL_SCALARS)); + + // Consumer encodes, producer decodes and compares to its canonical value. + assert(verify_scalars(&s, &err) && err.code == 0); + + // Consumer encodes, producer re-encodes: field-by-field equality. + scalars_t s2; + roundtrip_scalars(&s, &s2); + assert(scalars_eq(&s, &s2)); + + // A one-field change is a Mismatch (code 1) with the doc-comment message. + s2.flag = 0; + assert(!verify_scalars(&s2, &err)); + assert(err.code == weaveffi_codec_CodecError_Mismatch && err.code == 1); + assert(err.message != NULL && + strcmp(err.message, "value does not match the canonical fixture") == 0); + assert(err.payload_ptr == NULL && err.payload_len == 0); + weaveffi_error_clear(&err); + + // Hand-built edge scalars: extremes, NaN, infinities, negative zero. + scalars_t edge = { + INT8_MIN, UINT8_MAX, INT16_MIN, UINT16_MAX, INT32_MIN, UINT32_MAX, + INT64_MIN, UINT64_MAX, NAN, -0.0, 0, weaveffi_codec_Color_Red, + }; + scalars_t edge2; + roundtrip_scalars(&edge, &edge2); + assert(scalars_eq(&edge, &edge2)); + assert(isnan(edge2.f32v) && signbit(edge2.f64v) && edge2.f64v == 0.0); + edge.i8v = INT8_MAX; + edge.i16v = INT16_MAX; + edge.i32v = INT32_MAX; + edge.i64v = INT64_MAX; + edge.u8v = 0; + edge.u16v = 0; + edge.u32v = 0; + edge.u64v = 0; + edge.f32v = -INFINITY; + edge.f64v = INFINITY; + edge.color = weaveffi_codec_Color_Green; + roundtrip_scalars(&edge, &edge2); + assert(scalars_eq(&edge, &edge2)); + assert(isinf(edge2.f32v) && edge2.f32v < 0 && isinf(edge2.f64v) && edge2.f64v > 0); + edge.f64v = NAN; + edge.f32v = 0.0f; + roundtrip_scalars(&edge, &edge2); + assert(isnan(edge2.f64v) && scalars_eq(&edge, &edge2)); + + // ── Composite: producer encodes, consumer decodes ──────────────────── + composite_t c; + fetch_sample_composite(&c); + assert(strcmp(c.name, "h\xC3\xA9llo w\xC3\xB6rld \xE2\x9C\x93") == 0); + const uint8_t blob[6] = {0, 1, 2, 253, 254, 255}; + assert(c.blob_len == 6 && memcmp(c.blob, blob, 6) == 0); + assert(c.has_some_i64 && c.some_i64 == INT64_MIN); + assert(!c.has_none_i64); + assert(c.has_some_text && strcmp(c.some_text, "") == 0); + assert(c.names_n == 3 && strcmp(c.names[0], "a") == 0 && strcmp(c.names[1], "") == 0 && + strcmp(c.names[2], "ccc") == 0); + assert(c.matrix_n == 3); + assert(c.matrix[0].n == 3 && c.matrix[0].v[0] == 1 && c.matrix[0].v[1] == 2 && + c.matrix[0].v[2] == 3); + assert(c.matrix[1].n == 0); + assert(c.matrix[2].n == 1 && c.matrix[2].v[0] == -4); + assert(c.empty_n == 0); + // BTreeMap keys arrive sorted. + assert(c.by_name_n == 3); + assert(strcmp(c.by_name[0].key, "neg") == 0 && c.by_name[0].value == -3); + assert(strcmp(c.by_name[1].key, "one") == 0 && c.by_name[1].value == 1); + assert(strcmp(c.by_name[2].key, "two") == 0 && c.by_name[2].value == 2); + assert(c.by_id_n == 2); + assert(c.by_id[0].key == -1 && scalars_eq(&c.by_id[0].value, &CANONICAL_SCALARS)); + assert(c.by_id[1].key == 42 && c.by_id[1].value.flag == 0 && + c.by_id[1].value.u64v == UINT64_MAX); + assert(scalars_eq(&c.scalars, &CANONICAL_SCALARS)); + assert(c.shape.tag == weaveffi_codec_Shape_Labeled && strcmp(c.shape.label, "tag") == 0 && + c.shape.count == 3); + assert(c.shapes_n == 5); + assert(c.shapes[0].tag == weaveffi_codec_Shape_Empty); + assert(c.shapes[1].tag == weaveffi_codec_Shape_Circle && c.shapes[1].radius == 2.5); + assert(c.shapes[2].tag == weaveffi_codec_Shape_Rect && c.shapes[2].width == 1.0f && + c.shapes[2].height == 0.5f); + assert(c.shapes[3].tag == weaveffi_codec_Shape_Labeled && strcmp(c.shapes[3].label, "") == 0 && + c.shapes[3].count == -1); + assert(c.shapes[4].tag == weaveffi_codec_Shape_Nested && + scalars_eq(&c.shapes[4].inner, &CANONICAL_SCALARS) && c.shapes[4].has_note && + strcmp(c.shapes[4].note, "n") == 0); + assert(c.has_maybe_shape && c.maybe_shape.tag == weaveffi_codec_Shape_Nested && + !c.maybe_shape.has_note && scalars_eq(&c.maybe_shape.inner, &CANONICAL_SCALARS)); + assert(c.has_maybe_list && c.maybe_list_n == 2 && c.maybe_list[0] == 9 && c.maybe_list[1] == 8); + assert(c.sparse_n == 3); + assert(c.sparse[0].has && c.sparse[0].value == 1); + assert(!c.sparse[1].has); + assert(c.sparse[2].has && c.sparse[2].value == 0); + assert(c.colors_n == 3 && c.colors[0] == weaveffi_codec_Color_Red && + c.colors[1] == weaveffi_codec_Color_Green && c.colors[2] == weaveffi_codec_Color_Blue); + + // Consumer encodes, producer decodes. + assert(verify_composite(&c, &err) && err.code == 0); + + // Consumer encodes, producer re-encodes, consumer decodes again. + composite_t c2; + roundtrip_composite(&c, &c2); + assert(composite_eq(&c, &c2)); + + // Perturb one deeply nested field: Mismatch. + c2.sparse[1].has = 1; + c2.sparse[1].value = 1; + assert(!verify_composite(&c2, &err)); + assert(err.code == weaveffi_codec_CodecError_Mismatch); + weaveffi_error_clear(&err); + composite_free(&c2); + + // describe_composite renders what the producer saw. + { + wv_writer w; + wv_w_init(&w); + write_composite(&w, &c); + const char* text = weaveffi_codec_describe_composite(w.buf, w.len, &err); + assert(err.code == 0 && text != NULL); + assert(strstr(text, "name: \"h\xC3\xA9llo w\xC3\xB6rld \xE2\x9C\x93\"") != NULL); + assert(strstr(text, "some_i64: Some(-9223372036854775808)") != NULL); + assert(strstr(text, "shape: Labeled { label: \"tag\", count: 3 }") != NULL); + weaveffi_free_string(text); + wv_w_free(&w); + } + composite_free(&c); + + // Hand-built edge composite: everything empty or absent, unicode name, + // maps with extreme keys, a list of absent optionals. + composite_t e; + memset(&e, 0, sizeof e); + e.name = strdup("\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E \xF0\x9F\x8E\x89"); // 日本語 🎉 + e.blob = (uint8_t*)xcalloc(0, 1); + e.blob_len = 0; + e.has_some_i64 = 1; + e.some_i64 = INT64_MAX; + e.has_none_i64 = 1; + e.none_i64 = -1; + e.has_some_text = 0; + e.names_n = 0; + e.names = (char**)xcalloc(0, sizeof(char*)); + e.matrix_n = 1; + e.matrix = (i32_list_t*)xcalloc(1, sizeof(i32_list_t)); + e.matrix[0].n = 2; + e.matrix[0].v = (int32_t*)xcalloc(2, sizeof(int32_t)); + e.matrix[0].v[0] = INT32_MIN; + e.matrix[0].v[1] = INT32_MAX; + e.empty_n = 2; + e.empty = (double*)xcalloc(2, sizeof(double)); + e.empty[0] = -0.0; + e.empty[1] = NAN; + e.by_name_n = 1; + e.by_name = (by_name_t*)xcalloc(1, sizeof(by_name_t)); + e.by_name[0].key = strdup(""); + e.by_name[0].value = INT64_MIN; + e.by_id_n = 0; + e.by_id = (by_id_t*)xcalloc(0, sizeof(by_id_t)); + e.scalars = edge; + e.shape = shape_empty(); + e.shapes_n = 0; + e.shapes = (shape_t*)xcalloc(0, sizeof(shape_t)); + e.has_maybe_shape = 0; + e.has_maybe_list = 1; + e.maybe_list_n = 0; + e.maybe_list = (uint8_t*)xcalloc(0, 1); + e.sparse_n = 2; + e.sparse = (opt_bool_t*)xcalloc(2, sizeof(opt_bool_t)); + e.colors_n = 0; + e.colors = (int32_t*)xcalloc(0, sizeof(int32_t)); + composite_t e2; + roundtrip_composite(&e, &e2); + assert(composite_eq(&e, &e2)); + assert(strcmp(e2.name, e.name) == 0 && e2.blob_len == 0 && e2.has_maybe_list && + e2.maybe_list_n == 0 && isnan(e2.empty[1]) && signbit(e2.empty[0])); + assert(!verify_composite(&e, &err) && err.code == 1); + weaveffi_error_clear(&err); + composite_free(&e2); + composite_free(&e); + + // ── Shape variants through roundtrip_shape and describe_shape ─────── + shape_t variants[5]; + variants[0] = shape_empty(); + variants[1] = shape_circle(2.5); + variants[2] = shape_rect(1.0f, 0.5f); + variants[3] = shape_labeled("tag", 3); + variants[4] = shape_nested(&CANONICAL_SCALARS, "n"); + const char* descriptions[5] = { + "Empty", + "Circle { radius: 2.5 }", + "Rect { width: 1.0, height: 0.5 }", + "Labeled { label: \"tag\", count: 3 }", + NULL, + }; + for (int i = 0; i < 5; i++) { + shape_t back; + roundtrip_shape(&variants[i], &back); + assert(shape_eq(&variants[i], &back)); + char* text = describe_shape(&back); + if (descriptions[i]) { + assert(strcmp(text, descriptions[i]) == 0); + } else { + assert(strstr(text, "Nested { inner: Scalars { i8_value: -8,") != NULL && + strstr(text, "note: Some(\"n\") }") != NULL); + } + free(text); + shape_free(&back); + } + { + shape_t no_note = shape_nested(&edge, NULL); + shape_t back; + roundtrip_shape(&no_note, &back); + assert(shape_eq(&no_note, &back) && !back.has_note); + shape_free(&back); + shape_free(&no_note); + + shape_t huge = shape_labeled("", INT32_MIN); + roundtrip_shape(&huge, &back); + assert(shape_eq(&huge, &back)); + shape_free(&back); + shape_free(&huge); + } + + // roundtrip_shapes: a list of every variant. + { + wv_writer w; + wv_w_init(&w); + wv_put_u32(&w, 5); + for (int i = 0; i < 5; i++) write_shape(&w, &variants[i]); + size_t len = 0; + const uint8_t* p = weaveffi_codec_roundtrip_shapes(w.buf, w.len, &len, &err); + assert(err.code == 0); + assert(len == w.len && memcmp(p, w.buf, len) == 0); + wv_w_free(&w); + TAKE_BUFFER(p, len, { + assert(wv_get_u32(&r_) == 5); + for (int i = 0; i < 5; i++) { + shape_t back; + read_shape(&r_, &back); + assert(shape_eq(&variants[i], &back)); + shape_free(&back); + } + }); + wv_w_init(&w); + wv_put_u32(&w, 0); + p = weaveffi_codec_roundtrip_shapes(w.buf, w.len, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, assert(wv_get_u32(&r_) == 0)); + wv_w_free(&w); + } + for (int i = 0; i < 5; i++) shape_free(&variants[i]); + + // A malformed buffer (truncated mid-value) is a marshalling failure (-3). + { + uint8_t truncated[3] = {1, 0, 0}; + size_t len = 0; + assert(weaveffi_codec_roundtrip_shape(truncated, sizeof truncated, &len, &err) == NULL); + assert(err.code == -3); + weaveffi_error_clear(&err); + } + + // ── Standalone optional, map, string, bytes, and direct scalars ────── + { + wv_writer w; + wv_w_init(&w); + wv_put_bool(&w, 1); + wv_put_i64(&w, -1); + size_t len = 0; + const uint8_t* p = weaveffi_codec_roundtrip_opt_i64(w.buf, w.len, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, { assert(wv_get_bool(&r_) == 1 && wv_get_i64(&r_) == -1); }); + wv_w_free(&w); + const uint8_t none[1] = {0}; + p = weaveffi_codec_roundtrip_opt_i64(none, 1, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, assert(wv_get_bool(&r_) == 0)); + + // Map: encoded out of key order, comes back sorted by the BTreeMap. + wv_w_init(&w); + wv_put_u32(&w, 3); + wv_put_str(&w, "b"); + wv_put_i64(&w, -2); + wv_put_str(&w, "a"); + wv_put_i64(&w, 1); + wv_put_str(&w, ""); + wv_put_i64(&w, INT64_MAX); + p = weaveffi_codec_roundtrip_map(w.buf, w.len, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, { + assert(wv_get_u32(&r_) == 3); + char* k = wv_get_str(&r_); + assert(strcmp(k, "") == 0 && wv_get_i64(&r_) == INT64_MAX); + free(k); + k = wv_get_str(&r_); + assert(strcmp(k, "a") == 0 && wv_get_i64(&r_) == 1); + free(k); + k = wv_get_str(&r_); + assert(strcmp(k, "b") == 0 && wv_get_i64(&r_) == -2); + free(k); + }); + wv_w_free(&w); + wv_w_init(&w); + wv_put_u32(&w, 0); + p = weaveffi_codec_roundtrip_map(w.buf, w.len, &len, &err); + assert(err.code == 0); + TAKE_BUFFER(p, len, assert(wv_get_u32(&r_) == 0)); + wv_w_free(&w); + + // Strings and bytes keep their direct ABI. + const char* text = weaveffi_codec_roundtrip_string("h\xC3\xA9llo \xE2\x9C\x93", &err); + assert(err.code == 0 && strcmp(text, "h\xC3\xA9llo \xE2\x9C\x93") == 0); + weaveffi_free_string(text); + text = weaveffi_codec_roundtrip_string("", &err); + assert(err.code == 0 && strcmp(text, "") == 0); + weaveffi_free_string(text); + assert(weaveffi_codec_roundtrip_string(NULL, &err) == NULL && err.code == -3); + weaveffi_error_clear(&err); + const uint8_t raw[4] = {0, 255, 0, 128}; + p = weaveffi_codec_roundtrip_bytes(raw, 4, &len, &err); + assert(err.code == 0 && len == 4 && memcmp(p, raw, 4) == 0); + weaveffi_free_bytes((uint8_t*)p, len); + p = weaveffi_codec_roundtrip_bytes(raw, 0, &len, &err); + assert(err.code == 0 && len == 0); + weaveffi_free_bytes((uint8_t*)p, len); + + // Direct family. + assert(weaveffi_codec_roundtrip_i64(INT64_MIN, &err) == INT64_MIN); + assert(weaveffi_codec_roundtrip_i64(INT64_MAX, &err) == INT64_MAX); + assert(weaveffi_codec_roundtrip_u64(UINT64_MAX, &err) == UINT64_MAX); + assert(weaveffi_codec_roundtrip_u64(1ULL << 63, &err) == (1ULL << 63)); + assert(isnan(weaveffi_codec_roundtrip_f64(NAN, &err))); + assert(signbit(weaveffi_codec_roundtrip_f64(-0.0, &err))); + assert(weaveffi_codec_roundtrip_f64(INFINITY, &err) == INFINITY); + assert(weaveffi_codec_roundtrip_f64(-2.25e100, &err) == -2.25e100); + assert(weaveffi_codec_roundtrip_bool(true, &err) == true); + assert(weaveffi_codec_roundtrip_bool(false, &err) == false); + assert(weaveffi_codec_roundtrip_color(weaveffi_codec_Color_Blue, &err) == + weaveffi_codec_Color_Blue); + assert(weaveffi_codec_roundtrip_color(weaveffi_codec_Color_Red, &err) == 0); + assert(err.code == 0); + weaveffi_codec_roundtrip_color((weaveffi_codec_Color)3, &err); + assert(err.code == -3 && "an undeclared discriminant is a marshalling failure"); + weaveffi_error_clear(&err); + } + + // ── Token objects and Holder (objects inside buffers) ──────────────── + weaveffi_codec_Token* t = weaveffi_codec_Token_new(5, &err); + assert(err.code == 0 && t != NULL); + assert(token_value(t) == 5); + weaveffi_codec_Token* t2 = weaveffi_codec_Token_clone(t); + assert(t2 == t); + weaveffi_codec_Token_destroy(t); + assert(token_value(t2) == 5 && "clone survives destroying the original"); + weaveffi_codec_Token_destroy(t2); + weaveffi_codec_Token_destroy(NULL); + assert(weaveffi_codec_Token_clone(NULL) == NULL); + + holder_t h; + make_holder(10, 1, &h); + assert(token_value(h.primary) == 10); + assert(h.spare != NULL && token_value(h.spare) == 11); + assert(h.many_n == 3); + assert(token_value(h.many[0]) == 12 && token_value(h.many[1]) == 13 && + token_value(h.many[2]) == 14); + assert(h.primary != h.spare && h.primary != h.many[0]); + + // sum_holder consumes one encoding (one reference per token). + { + wv_writer w; + wv_w_init(&w); + write_holder(&w, &h); + assert(weaveffi_codec_sum_holder(w.buf, w.len, &err) == 10 + 11 + 12 + 13 + 14); + assert(err.code == 0); + wv_w_free(&w); + assert(token_value(h.primary) == 10 && "our references are intact"); + } + + // primary_of returns the very same object as holder.primary, as an + // owned reference we release; the holder's reference stays valid. + { + wv_writer w; + wv_w_init(&w); + write_holder(&w, &h); + weaveffi_codec_Token* primary = weaveffi_codec_primary_of(w.buf, w.len, &err); + assert(err.code == 0); + assert(primary == h.primary); + wv_w_free(&w); + assert(token_value(primary) == 10); + weaveffi_codec_Token_destroy(primary); + assert(token_value(h.primary) == 10); + } + + // same_primary: two encodings of the same holder share a primary; a + // different holder does not. + holder_t h2; + make_holder(100, 0, &h2); + assert(h2.spare == NULL && h2.many_n == 3 && token_value(h2.primary) == 100); + { + wv_writer a, b; + wv_w_init(&a); + wv_w_init(&b); + write_holder(&a, &h); + write_holder(&b, &h); + assert(weaveffi_codec_same_primary(a.buf, a.len, b.buf, b.len, &err)); + assert(err.code == 0); + wv_w_free(&a); + wv_w_free(&b); + wv_w_init(&a); + wv_w_init(&b); + write_holder(&a, &h); + write_holder(&b, &h2); + assert(!weaveffi_codec_same_primary(a.buf, a.len, b.buf, b.len, &err)); + assert(err.code == 0); + wv_w_free(&a); + wv_w_free(&b); + + // A holder mixing our own tokens with the producer's. + holder_t mixed; + memset(&mixed, 0, sizeof mixed); + mixed.primary = weaveffi_codec_Token_new(1000, &err); + mixed.spare = weaveffi_codec_Token_clone(h.primary); + mixed.many_n = 2; + mixed.many[0] = weaveffi_codec_Token_clone(h2.primary); + mixed.many[1] = weaveffi_codec_Token_new(-1, &err); + wv_w_init(&a); + write_holder(&a, &mixed); + assert(weaveffi_codec_sum_holder(a.buf, a.len, &err) == 1000 + 10 + 100 - 1); + wv_w_free(&a); + wv_w_init(&a); + write_holder(&a, &mixed); + weaveffi_codec_Token* p = weaveffi_codec_primary_of(a.buf, a.len, &err); + assert(p == mixed.primary && token_value(p) == 1000); + weaveffi_codec_Token_destroy(p); + wv_w_free(&a); + holder_release(&mixed); + assert(token_value(h.primary) == 10 && token_value(h2.primary) == 100 && + "the clones written into `mixed` never touched our references"); + } + holder_release(&h2); + holder_release(&h); + + printf("c/codec: OK\n"); + return 0; +} diff --git a/conformance/c/events.c b/conformance/c/events.c index bd05a04e..561eb794 100644 --- a/conformance/c/events.c +++ b/conformance/c/events.c @@ -1,62 +1,355 @@ -// Conformance consumer: events sample, C target. +// Conformance consumer: events sample, C target (ABI revision 2). // -// Exercises a context-carrying callback, a register/unregister listener pair -// (returning a uint64_t subscription id), and an opaque iterator driven by the -// `int32_t next(iter, &out_item, &err)` contract: the three features whose ABI -// historically drifted between generators and hand-written consumers. +// Exercises the raw callback-interface ABI: a hand-written `Subscriber` +// vtable (one function per method taking `void* ctx` first and a trailing +// `weaveffi_error* out_err`, plus the `free` entry the producer calls when +// it drops its last reference), the reference-counted `EventBus` object +// (`_clone`/`_destroy`, an object handed *to* the consumer through +// `on_attached`), `Delivery` return values steering `publish`'s accepted +// count, a foreign error raised from a callback surfacing to the caller as +// code -4, the `Message` record decoded from a borrowed value buffer, the +// `messages()` iterator, the `last_message()` optional, and the async +// `publish_later` launcher. Exits 0 on success; aborts on any mismatch. #include -#include +#include #include +#include #include +#include #include "weaveffi.h" +#include "wvbuf.h" -static int g_count = 0; -static char g_last[64] = {0}; +// ── consumer-side subscriber ─────────────────────────────────────────────── -static void on_message(const char* message, void* context) { - int* counter = (int*)context; - (*counter)++; - g_count = *counter; - strncpy(g_last, message, sizeof(g_last) - 1); -} +// Per-subscriber state the producer sees only as an opaque `void* ctx`. +typedef struct { + const char* skip_topic; // route() answers Skip for this topic + const char* stop_topic; // route() answers AcceptAndStop for this topic + const char* fail_topic; // route() reports a foreign error for this topic + int keep_bus; // on_attached keeps the bus reference in `bus` + int64_t received; // running count returned from on_message + int attached; // on_attached invocations + weaveffi_events_EventBus* bus; // kept reference (when keep_bus) + int64_t attached_count; // subscriber_count observed inside on_attached + // Fields of the last decoded Message. + int64_t last_seq; + char last_topic[64]; + char last_text[64]; + uint32_t last_tag_count; + char last_tag0[64]; +} sub_ctx; -int main(void) { - weaveffi_error err = {0, NULL}; +static int g_freed = 0; - int counter = 0; - uint64_t id = weaveffi_events_register_message_listener(on_message, &counter); - assert(id > 0); +static weaveffi_events_Delivery sub_route(void* ctx, const char* topic, + weaveffi_error* out_err) { + sub_ctx* s = (sub_ctx*)ctx; + if (s->fail_topic && strcmp(topic, s->fail_topic) == 0) { + weaveffi_error_set(out_err, -4, "subscriber rejected topic"); + return weaveffi_events_Delivery_Skip; + } + if (s->skip_topic && strcmp(topic, s->skip_topic) == 0) { + return weaveffi_events_Delivery_Skip; + } + if (s->stop_topic && strcmp(topic, s->stop_topic) == 0) { + return weaveffi_events_Delivery_AcceptAndStop; + } + return weaveffi_events_Delivery_Accept; +} - weaveffi_events_send_message("hello", &err); - assert(err.code == 0); - weaveffi_events_send_message("world", &err); +// Message record: seq (i64), topic (string), text (string), tags ([string]). +static int64_t sub_on_message(void* ctx, const uint8_t* message_ptr, + size_t message_len, weaveffi_error* out_err) { + (void)out_err; + sub_ctx* s = (sub_ctx*)ctx; + wv_reader r; + wv_r_init(&r, message_ptr, message_len); + s->last_seq = wv_get_i64(&r); + char* topic = wv_get_str(&r); + snprintf(s->last_topic, sizeof s->last_topic, "%s", topic); + free(topic); + char* text = wv_get_str(&r); + snprintf(s->last_text, sizeof s->last_text, "%s", text); + free(text); + s->last_tag_count = wv_get_u32(&r); + s->last_tag0[0] = '\0'; + for (uint32_t i = 0; i < s->last_tag_count; i++) { + char* tag = wv_get_str(&r); + if (i == 0) snprintf(s->last_tag0, sizeof s->last_tag0, "%s", tag); + free(tag); + } + wv_r_expect_end(&r); + s->received++; + return s->received; +} + +// The bus arrives as one strong reference the consumer adopts: it is usable +// right here, and it is ours to keep or release. +static void sub_on_attached(void* ctx, weaveffi_events_EventBus* bus, + weaveffi_error* out_err) { + (void)out_err; + sub_ctx* s = (sub_ctx*)ctx; + s->attached++; + weaveffi_error err = {0}; + s->attached_count = weaveffi_events_EventBus_subscriber_count(bus, &err); assert(err.code == 0); - assert(counter == 2); - assert(g_count == 2); - assert(strcmp(g_last, "world") == 0); + if (s->keep_bus) { + s->bus = bus; + } else { + weaveffi_events_EventBus_destroy(bus); + } +} + +static void sub_free(void* ctx) { + sub_ctx* s = (sub_ctx*)ctx; + weaveffi_events_EventBus_destroy(s->bus); // null is a no-op + free(s); + g_freed++; +} - // Iterate via the opaque-handle ABI. - weaveffi_events_GetMessagesIterator* it = weaveffi_events_get_messages(&err); +static const weaveffi_events_Subscriber_vtable SUB_VTABLE = { + sub_route, + sub_on_message, + sub_on_attached, + sub_free, +}; + +static sub_ctx* new_sub(const char* skip, const char* stop, const char* fail, + int keep_bus) { + sub_ctx* s = (sub_ctx*)calloc(1, sizeof *s); + assert(s != NULL); + s->skip_topic = skip; + s->stop_topic = stop; + s->fail_topic = fail; + s->keep_bus = keep_bus; + return s; +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +// publish(topic, text, tags): `tags` is a buffered `[string]`. +static int64_t publish(weaveffi_events_EventBus* bus, const char* topic, + const char* text, const char** tags, uint32_t ntags, + weaveffi_error* err) { + wv_writer w; + wv_w_init(&w); + wv_put_u32(&w, ntags); + for (uint32_t i = 0; i < ntags; i++) wv_put_str(&w, tags[i]); + int64_t n = weaveffi_events_EventBus_publish(bus, topic, text, w.buf, w.len, err); + wv_w_free(&w); + return n; +} + +// Collect messages() into `out`, returning how many were yielded. +static int collect_messages(weaveffi_events_EventBus* bus, char** out, int cap) { + weaveffi_error err = {0}; + weaveffi_events_EventBus_MessagesIterator* it = + weaveffi_events_EventBus_messages(bus, &err); assert(err.code == 0 && it != NULL); - const char* expected[] = {"hello", "world"}; - int i = 0; + int n = 0; const char* item = NULL; - while (weaveffi_events_GetMessagesIterator_next(it, &item, &err)) { - assert(err.code == 0); - assert(strcmp(item, expected[i]) == 0); + while (weaveffi_events_EventBus_MessagesIterator_next(it, &item, &err) != 0) { + assert(err.code == 0 && item != NULL); + assert(n < cap); + out[n++] = strdup(item); weaveffi_free_string(item); - i++; } assert(err.code == 0); - assert(i == 2); - weaveffi_events_GetMessagesIterator_destroy(it); + weaveffi_events_EventBus_MessagesIterator_destroy(it); + return n; +} + +// ── async completion state ───────────────────────────────────────────────── +static atomic_int g_later_done = 0; +static int32_t g_later_err = -1; +static int64_t g_later_result = -1; + +static void on_publish_later(void* context, weaveffi_error* err, int64_t result) { + assert(context == (void*)0x1234); + g_later_err = err ? err->code : 0; + weaveffi_error_free(err); + g_later_result = result; + atomic_store(&g_later_done, 1); +} + +int main(void) { + weaveffi_error err = {0}; + + // ABI revision handshake. + assert(WEAVEFFI_ABI_VERSION == 2u); + assert(weaveffi_abi_version() == 2u); + + weaveffi_events_EventBus* bus = weaveffi_events_EventBus_new(&err); + assert(err.code == 0 && bus != NULL); + assert(weaveffi_events_EventBus_subscriber_count(bus, &err) == 0); + + // last_message() on an empty bus: a buffered `Message?` with flag 0. + size_t out_len = 0; + const uint8_t* last = weaveffi_events_EventBus_last_message(bus, &out_len, &err); + assert(err.code == 0 && last != NULL); + { + wv_reader r; + wv_r_init(&r, last, out_len); + assert(wv_get_bool(&r) == 0 && "no message yet"); + wv_r_expect_end(&r); + } + weaveffi_free_bytes((uint8_t*)last, out_len); + + // messages() on an empty bus yields nothing. + char* texts[8]; + assert(collect_messages(bus, texts, 8) == 0); + + // Three subscribers: `a` skips "quiet", stops on "stop", and keeps the + // bus reference handed to on_attached; `b` accepts everything and drops + // its bus reference; `c` fails on "boom". + sub_ctx* a = new_sub("quiet", "stop", NULL, 1); + sub_ctx* b = new_sub(NULL, NULL, NULL, 0); + sub_ctx* c = new_sub(NULL, NULL, "boom", 0); + + assert(weaveffi_events_EventBus_subscribe(bus, a, &SUB_VTABLE, &err) == 1); + assert(err.code == 0); + assert(a->attached == 1); + assert(a->attached_count == 0 && "on_attached runs before the bus retains us"); + assert(a->bus == bus && "the object handed to the callback is the same bus"); + assert(weaveffi_events_EventBus_subscribe(bus, b, &SUB_VTABLE, &err) == 2); + assert(b->attached == 1 && b->attached_count == 1); + assert(weaveffi_events_EventBus_subscribe(bus, c, &SUB_VTABLE, &err) == 3); + assert(c->attached == 1 && c->attached_count == 2); + assert(weaveffi_events_EventBus_subscriber_count(bus, &err) == 3); + + // The kept reference is usable independently of the original pointer. + assert(weaveffi_events_EventBus_subscriber_count(a->bus, &err) == 3); + + // Everyone accepts "news": 3 deliveries, and each saw the same Message. + const char* tags[] = {"x", "y"}; + assert(publish(bus, "news", "hello", tags, 2, &err) == 3); + assert(err.code == 0); + assert(a->received == 1 && b->received == 1 && c->received == 1); + assert(a->last_seq == 1); + assert(strcmp(a->last_topic, "news") == 0); + assert(strcmp(a->last_text, "hello") == 0); + assert(a->last_tag_count == 2 && strcmp(a->last_tag0, "x") == 0); + assert(b->last_seq == 1 && strcmp(c->last_text, "hello") == 0); + + // `a` skips "quiet": 2 deliveries, a's count unchanged. + assert(publish(bus, "quiet", "psst", NULL, 0, &err) == 2); + assert(a->received == 1 && b->received == 2 && c->received == 2); + assert(b->last_seq == 2 && b->last_tag_count == 0); + + // `a` answers AcceptAndStop for "stop": exactly 1 delivery, later + // subscribers never see it. + assert(publish(bus, "stop", "last", NULL, 0, &err) == 1); + assert(a->received == 2 && b->received == 2 && c->received == 2); + assert(a->last_seq == 3 && strcmp(a->last_text, "last") == 0); + + // `c` raises through out_err on "boom": the whole publish aborts with + // FOREIGN_ERROR_CODE and the foreign message. + publish(bus, "boom", "x", NULL, 0, &err); + assert(err.code == -4); + assert(err.message != NULL && strstr(err.message, "rejected topic") != NULL); + weaveffi_error_clear(&err); + assert(err.code == 0 && err.message == NULL); + + // The bus (and its subscribers) stay usable afterward. + assert(publish(bus, "ok", "y", NULL, 0, &err) == 3); + assert(err.code == 0); + + // Async publish: the completion callback fires on a producer thread with + // the accepted count. + weaveffi_events_EventBus_publish_later_async(bus, "later", "z", on_publish_later, + (void*)0x1234); + for (int i = 0; i < 5000 && !atomic_load(&g_later_done); i++) usleep(1000); + assert(atomic_load(&g_later_done)); + assert(g_later_err == 0); + assert(g_later_result == 3); + + // messages(): every published text in order, including the aborted one + // (the bus logs before it dispatches). + int n = collect_messages(bus, texts, 8); + const char* expected[] = {"hello", "psst", "last", "x", "y", "z"}; + assert(n == 6); + for (int i = 0; i < n; i++) { + assert(strcmp(texts[i], expected[i]) == 0); + free(texts[i]); + } + + // last_message(): present, with the async publish's fields. + last = weaveffi_events_EventBus_last_message(bus, &out_len, &err); + assert(err.code == 0 && last != NULL); + { + wv_reader r; + wv_r_init(&r, last, out_len); + assert(wv_get_bool(&r) == 1); + assert(wv_get_i64(&r) == 6); + char* topic = wv_get_str(&r); + assert(strcmp(topic, "later") == 0); + free(topic); + char* text = wv_get_str(&r); + assert(strcmp(text, "z") == 0); + free(text); + assert(wv_get_u32(&r) == 0 && "publish_later attaches no tags"); + wv_r_expect_end(&r); + } + weaveffi_free_bytes((uint8_t*)last, out_len); + + // route_once: a free function taking the callback interface. The + // producer does not retain it, so `free` runs before the call returns. + sub_ctx* d = new_sub("quiet", NULL, NULL, 0); + assert(g_freed == 0); + assert(weaveffi_events_route_once(d, &SUB_VTABLE, "quiet", &err) == + weaveffi_events_Delivery_Skip); + assert(err.code == 0); + assert(g_freed == 1 && "route_once released its subscriber"); + sub_ctx* e = new_sub(NULL, "stop", NULL, 0); + assert(weaveffi_events_route_once(e, &SUB_VTABLE, "stop", &err) == + weaveffi_events_Delivery_AcceptAndStop); + assert(weaveffi_events_route_once(new_sub(NULL, NULL, NULL, 0), &SUB_VTABLE, + "anything", &err) == + weaveffi_events_Delivery_Accept); + assert(g_freed == 3); + + // A foreign error from route_once surfaces the same way. + sub_ctx* f = new_sub(NULL, NULL, "boom", 0); + weaveffi_events_route_once(f, &SUB_VTABLE, "boom", &err); + assert(err.code == -4); + weaveffi_error_clear(&err); + assert(g_freed == 4); + + // Release the bus reference `a` kept, then drop every subscriber: each + // `free` entry runs exactly once. + weaveffi_events_EventBus_destroy(a->bus); + a->bus = NULL; + assert(weaveffi_events_EventBus_subscriber_count(bus, &err) == 3); + weaveffi_events_EventBus_clear_subscribers(bus, &err); + assert(err.code == 0); + assert(g_freed == 7 && "clear_subscribers freed a, b, and c"); + assert(weaveffi_events_EventBus_subscriber_count(bus, &err) == 0); + assert(publish(bus, "empty", "nobody", NULL, 0, &err) == 0); + + // Reference counting: clone yields the same pointer; destroying the + // original leaves the clone usable. + weaveffi_events_EventBus* again = weaveffi_events_EventBus_clone(bus); + assert(again == bus); + weaveffi_events_EventBus_destroy(bus); + assert(weaveffi_events_EventBus_subscriber_count(again, &err) == 0); + assert(err.code == 0); + n = collect_messages(again, texts, 8); + assert(n == 7); + for (int i = 0; i < n; i++) free(texts[i]); + weaveffi_events_EventBus_destroy(again); + weaveffi_events_EventBus_destroy(NULL); + assert(weaveffi_events_EventBus_clone(NULL) == NULL); - // Unregister stops delivery. - weaveffi_events_unregister_message_listener(id); - weaveffi_events_send_message("again", &err); - assert(counter == 2); + // Destroying a bus releases its subscribers too. + weaveffi_events_EventBus* bus2 = weaveffi_events_EventBus_new(&err); + sub_ctx* g = new_sub(NULL, NULL, NULL, 0); + assert(weaveffi_events_EventBus_subscribe(bus2, g, &SUB_VTABLE, &err) == 1); + assert(publish(bus2, "t", "u", NULL, 0, &err) == 1); + assert(g->received == 1); + weaveffi_events_EventBus_destroy(bus2); + assert(g_freed == 8 && "destroying the bus frees its subscriber"); printf("c/events: OK\n"); return 0; diff --git a/conformance/c/inventory.c b/conformance/c/inventory.c new file mode 100644 index 00000000..efd2cb7b --- /dev/null +++ b/conformance/c/inventory.c @@ -0,0 +1,387 @@ +// Conformance consumer: inventory sample, C target (ABI revision 2). +// +// The only multi-module sample: the `products` module (the `Catalog` +// interface, the `Product` record with an optional and a list field, the +// `Category` enum, the `ProductsError` domain) and the `orders` module (free +// functions over `OrderItem`/`Order` records, the `OrdersError` domain, and +// `add_product_to_order`, which takes a `products::Product` across the +// module boundary). Both error domains reuse the small codes 1 and 2, so the +// consumer checks the doc-comment messages as well as the codes to prove +// each function reports its own domain. Also covers `Catalog` reference +// counting (`_clone`/`_destroy`). Exits 0 on success; aborts on any mismatch. + +#include +#include +#include +#include +#include + +#include "weaveffi.h" +#include "wvbuf.h" + +// ── products::Product ────────────────────────────────────────────────────── +// id, name, description?, price, category, tags. + +typedef struct { + int64_t id; + char* name; + char* description; // NULL when absent + double price; + int32_t category; + char** tags; + uint32_t tags_n; +} product_t; + +static void read_product(wv_reader* r, product_t* p) { + memset(p, 0, sizeof *p); + p->id = wv_get_i64(r); + p->name = wv_get_str(r); + p->description = wv_get_bool(r) ? wv_get_str(r) : NULL; + p->price = wv_get_f64(r); + p->category = wv_get_i32(r); + p->tags_n = wv_get_u32(r); + p->tags = (char**)calloc(p->tags_n ? p->tags_n : 1, sizeof(char*)); + assert(p->tags != NULL); + for (uint32_t i = 0; i < p->tags_n; i++) p->tags[i] = wv_get_str(r); +} + +static void write_product(wv_writer* w, const product_t* p) { + wv_put_i64(w, p->id); + wv_put_str(w, p->name); + wv_put_bool(w, p->description != NULL); + if (p->description) wv_put_str(w, p->description); + wv_put_f64(w, p->price); + wv_put_i32(w, p->category); + wv_put_u32(w, p->tags_n); + for (uint32_t i = 0; i < p->tags_n; i++) wv_put_str(w, p->tags[i]); +} + +static void product_free(product_t* p) { + free(p->name); + free(p->description); + for (uint32_t i = 0; i < p->tags_n; i++) free(p->tags[i]); + free(p->tags); + memset(p, 0, sizeof *p); +} + +// Decode one Product from an owned return buffer, then release the buffer. +static void take_product(const uint8_t* ptr, size_t len, product_t* p) { + assert(ptr != NULL); + wv_reader r; + wv_r_init(&r, ptr, len); + read_product(&r, p); + wv_r_expect_end(&r); + weaveffi_free_bytes((uint8_t*)ptr, len); +} + +// ── orders::OrderItem / orders::Order ────────────────────────────────────── +// OrderItem: product_id, quantity (i32), unit_price. Order: id, items, total, +// status. + +typedef struct { + int64_t product_id; + int32_t quantity; + double unit_price; +} order_item_t; + +typedef struct { + int64_t id; + order_item_t* items; + uint32_t items_n; + double total; + char* status; +} order_t; + +static void write_items(wv_writer* w, const order_item_t* items, uint32_t n) { + wv_put_u32(w, n); + for (uint32_t i = 0; i < n; i++) { + wv_put_i64(w, items[i].product_id); + wv_put_i32(w, items[i].quantity); + wv_put_f64(w, items[i].unit_price); + } +} + +static void take_order(const uint8_t* ptr, size_t len, order_t* o) { + assert(ptr != NULL); + wv_reader r; + wv_r_init(&r, ptr, len); + o->id = wv_get_i64(&r); + o->items_n = wv_get_u32(&r); + o->items = (order_item_t*)calloc(o->items_n ? o->items_n : 1, sizeof(order_item_t)); + assert(o->items != NULL); + for (uint32_t i = 0; i < o->items_n; i++) { + o->items[i].product_id = wv_get_i64(&r); + o->items[i].quantity = wv_get_i32(&r); + o->items[i].unit_price = wv_get_f64(&r); + } + o->total = wv_get_f64(&r); + o->status = wv_get_str(&r); + wv_r_expect_end(&r); + weaveffi_free_bytes((uint8_t*)ptr, len); +} + +static void order_free(order_t* o) { + free(o->items); + free(o->status); + memset(o, 0, sizeof *o); +} + +static int64_t create_order(const order_item_t* items, uint32_t n, weaveffi_error* err) { + wv_writer w; + wv_w_init(&w); + write_items(&w, items, n); + int64_t id = weaveffi_orders_create_order(w.buf, w.len, err); + wv_w_free(&w); + return id; +} + +static void get_order(int64_t id, order_t* o) { + weaveffi_error err = {0}; + size_t len = 0; + const uint8_t* p = weaveffi_orders_get_order(id, &len, &err); + assert(err.code == 0); + take_order(p, len, o); +} + +static int approx(double a, double b) { return fabs(a - b) < 1e-9; } + +int main(void) { + weaveffi_error err = {0}; + size_t out_len = 0; + + assert(weaveffi_abi_version() == 2u && WEAVEFFI_ABI_VERSION == 2u); + + // ── products ───────────────────────────────────────────────────────── + weaveffi_products_Catalog* catalog = weaveffi_products_Catalog_new(&err); + assert(err.code == 0 && catalog != NULL); + + // add_product -> Product buffer with a fresh id, absent description, and + // no tags. + const uint8_t* buf = weaveffi_products_Catalog_add_product( + catalog, "Widget", 9.99, weaveffi_products_Category_Electronics, &out_len, &err); + assert(err.code == 0); + product_t widget; + take_product(buf, out_len, &widget); + assert(widget.id == 1 && "each catalog hands out ids from 1"); + assert(strcmp(widget.name, "Widget") == 0); + assert(widget.description == NULL); + assert(widget.price == 9.99); + assert(widget.category == weaveffi_products_Category_Electronics); + assert(widget.tags_n == 0); + + // ProductsError.InvalidPrice (1) with its doc-comment message. + assert(weaveffi_products_Catalog_add_product(catalog, "Free", 0.0, + weaveffi_products_Category_Food, &out_len, + &err) == NULL); + assert(err.code == weaveffi_products_ProductsError_InvalidPrice); + assert(err.message != NULL && strcmp(err.message, "price must be positive") == 0); + assert(err.payload_ptr == NULL && err.payload_len == 0); + weaveffi_error_clear(&err); + assert(err.code == 0 && err.message == NULL); + + // ProductsError.ProductNotFound (2). + assert(weaveffi_products_Catalog_get_product(catalog, 999, &out_len, &err) == NULL); + assert(err.code == weaveffi_products_ProductsError_ProductNotFound); + assert(strcmp(err.message, "product not found") == 0); + weaveffi_error_clear(&err); + + // An undeclared Category discriminant is a marshalling failure (-3). + assert(weaveffi_products_Catalog_add_product(catalog, "Odd", 1.0, + (weaveffi_products_Category)42, &out_len, + &err) == NULL); + assert(err.code == -3); + weaveffi_error_clear(&err); + + // Two more products, then search by shelf: a buffered list of records. + buf = weaveffi_products_Catalog_add_product( + catalog, "Shirt", 29.99, weaveffi_products_Category_Clothing, &out_len, &err); + assert(err.code == 0); + product_t shirt; + take_product(buf, out_len, &shirt); + assert(shirt.id == 2 && shirt.category == weaveffi_products_Category_Clothing); + buf = weaveffi_products_Catalog_add_product( + catalog, "Phone", 499.99, weaveffi_products_Category_Electronics, &out_len, &err); + assert(err.code == 0); + product_t phone; + take_product(buf, out_len, &phone); + assert(phone.id == 3); + + buf = weaveffi_products_Catalog_search(catalog, weaveffi_products_Category_Electronics, + &out_len, &err); + assert(err.code == 0 && buf != NULL); + { + wv_reader r; + wv_r_init(&r, buf, out_len); + assert(wv_get_u32(&r) == 2); + product_t a, b; + read_product(&r, &a); + read_product(&r, &b); + wv_r_expect_end(&r); + assert(a.id == 1 && strcmp(a.name, "Widget") == 0); + assert(b.id == 3 && strcmp(b.name, "Phone") == 0 && b.price == 499.99); + product_free(&a); + product_free(&b); + } + weaveffi_free_bytes((uint8_t*)buf, out_len); + buf = weaveffi_products_Catalog_search(catalog, weaveffi_products_Category_Books, &out_len, + &err); + assert(err.code == 0 && buf != NULL); + { + wv_reader r; + wv_r_init(&r, buf, out_len); + assert(wv_get_u32(&r) == 0 && "empty list is just a zero count"); + wv_r_expect_end(&r); + } + weaveffi_free_bytes((uint8_t*)buf, out_len); + + // update_price: bool return, typed error path. + assert(weaveffi_products_Catalog_update_price(catalog, widget.id, 20.0, &err)); + assert(err.code == 0); + buf = weaveffi_products_Catalog_get_product(catalog, widget.id, &out_len, &err); + assert(err.code == 0); + product_t fetched; + take_product(buf, out_len, &fetched); + assert(fetched.price == 20.0 && strcmp(fetched.name, "Widget") == 0); + product_free(&fetched); + assert(!weaveffi_products_Catalog_update_price(catalog, 999, 1.0, &err)); + assert(err.code == 0); + assert(!weaveffi_products_Catalog_update_price(catalog, widget.id, -5.0, &err)); + assert(err.code == weaveffi_products_ProductsError_InvalidPrice); + weaveffi_error_clear(&err); + + // remove: true once, then false. + assert(weaveffi_products_Catalog_remove(catalog, shirt.id, &err) && err.code == 0); + assert(!weaveffi_products_Catalog_remove(catalog, shirt.id, &err) && err.code == 0); + assert(weaveffi_products_Catalog_get_product(catalog, shirt.id, &out_len, &err) == NULL); + assert(err.code == weaveffi_products_ProductsError_ProductNotFound); + weaveffi_error_clear(&err); + + // Reference counting: a clone is the same pointer and outlives the + // original reference. + weaveffi_products_Catalog* again = weaveffi_products_Catalog_clone(catalog); + assert(again == catalog); + weaveffi_products_Catalog_destroy(catalog); + buf = weaveffi_products_Catalog_get_product(again, phone.id, &out_len, &err); + assert(err.code == 0); + take_product(buf, out_len, &fetched); + assert(strcmp(fetched.name, "Phone") == 0); + product_free(&fetched); + catalog = again; + + // A second catalog is independent (its own ids and products). + weaveffi_products_Catalog* other = weaveffi_products_Catalog_new(&err); + buf = weaveffi_products_Catalog_add_product(other, "Novel", 12.5, + weaveffi_products_Category_Books, &out_len, + &err); + assert(err.code == 0); + take_product(buf, out_len, &fetched); + assert(fetched.id == 1 && fetched.category == weaveffi_products_Category_Books); + product_free(&fetched); + assert(weaveffi_products_Catalog_get_product(other, phone.id, &out_len, &err) == NULL); + assert(err.code == weaveffi_products_ProductsError_ProductNotFound); + weaveffi_error_clear(&err); + weaveffi_products_Catalog_destroy(other); + + // ── orders ─────────────────────────────────────────────────────────── + // create_order takes a buffered list of records and returns an id. + order_item_t items[2] = {{1, 2, 10.0}, {2, 1, 25.0}}; + int64_t order_id = create_order(items, 2, &err); + assert(err.code == 0 && order_id >= 1); + + order_t order; + get_order(order_id, &order); + assert(order.id == order_id); + assert(order.items_n == 2); + assert(order.items[0].product_id == 1 && order.items[0].quantity == 2 && + order.items[0].unit_price == 10.0); + assert(order.items[1].product_id == 2 && order.items[1].quantity == 1 && + order.items[1].unit_price == 25.0); + assert(order.total == 45.0); + assert(strcmp(order.status, "pending") == 0); + order_free(&order); + + // OrdersError.EmptyOrder (2): same numeric code as ProductNotFound but a + // different domain, distinguishable by message. + assert(create_order(NULL, 0, &err) == 0); + assert(err.code == weaveffi_orders_OrdersError_EmptyOrder); + assert(strcmp(err.message, "order must contain at least one item") == 0); + weaveffi_error_clear(&err); + + // OrdersError.OrderNotFound (1). + assert(weaveffi_orders_get_order(999, &out_len, &err) == NULL); + assert(err.code == weaveffi_orders_OrdersError_OrderNotFound); + assert(strcmp(err.message, "order not found") == 0); + weaveffi_error_clear(&err); + + // cancel_order: true on the transition, false afterward and for unknown + // ids; status is visible through get_order. + assert(weaveffi_orders_cancel_order(order_id, &err) && err.code == 0); + assert(!weaveffi_orders_cancel_order(order_id, &err) && err.code == 0); + assert(!weaveffi_orders_cancel_order(999, &err) && err.code == 0); + get_order(order_id, &order); + assert(strcmp(order.status, "cancelled") == 0); + order_free(&order); + + // ── cross-module: a products::Product parameter in the orders module ─ + // Hand-build a Product with the optional description present and tags. + order_item_t seed[1] = {{0, 1, 1.0}}; + int64_t order2 = create_order(seed, 1, &err); + assert(err.code == 0 && order2 == order_id + 1); + + product_t gadget; + memset(&gadget, 0, sizeof gadget); + gadget.id = 77; + gadget.name = strdup("Gadget"); + gadget.description = strdup("shiny \xE2\x9C\x93"); + gadget.price = 49.99; + gadget.category = weaveffi_products_Category_Electronics; + gadget.tags_n = 2; + gadget.tags = (char**)calloc(2, sizeof(char*)); + gadget.tags[0] = strdup("new"); + gadget.tags[1] = strdup(""); + wv_writer pw; + wv_w_init(&pw); + write_product(&pw, &gadget); + assert(weaveffi_orders_add_product_to_order(order2, pw.buf, pw.len, &err)); + assert(err.code == 0); + assert(!weaveffi_orders_add_product_to_order(999, pw.buf, pw.len, &err)); + assert(err.code == 0); + wv_w_free(&pw); + + get_order(order2, &order); + assert(order.items_n == 2); + assert(order.items[1].product_id == 77 && order.items[1].quantity == 1 && + order.items[1].unit_price == 49.99); + assert(approx(order.total, 50.99)); + assert(strcmp(order.status, "pending") == 0); + order_free(&order); + + // The producer-made Product round-trips into an order too. + wv_w_init(&pw); + write_product(&pw, &phone); + assert(weaveffi_orders_add_product_to_order(order2, pw.buf, pw.len, &err)); + wv_w_free(&pw); + get_order(order2, &order); + assert(order.items_n == 3 && order.items[2].product_id == 3 && + order.items[2].unit_price == 499.99); + assert(approx(order.total, 550.98)); + order_free(&order); + + // A truncated Product buffer is a marshalling failure (-3). + { + uint8_t truncated[2] = {1, 0}; + assert(!weaveffi_orders_add_product_to_order(order2, truncated, 2, &err)); + assert(err.code == -3); + weaveffi_error_clear(&err); + } + + product_free(&gadget); + product_free(&widget); + product_free(&shirt); + product_free(&phone); + weaveffi_products_Catalog_destroy(catalog); + weaveffi_products_Catalog_destroy(NULL); + + printf("c/inventory: OK\n"); + return 0; +} diff --git a/conformance/c/kvstore.c b/conformance/c/kvstore.c index 92f01670..738fc2c3 100644 --- a/conformance/c/kvstore.c +++ b/conformance/c/kvstore.c @@ -1,33 +1,94 @@ -// Conformance consumer: kvstore sample, C target. +// Conformance consumer: kvstore sample, C target (ABI revision 2). // // Exercises the Store interface ABI end to end: the fallible constructor // (`Store_open`), instance methods taking the receiver as the leading // argument, the static method (`Store_default_capacity`), the typed // error-domain codes surfaced through the error-out slot, the iterator // out-param `next` convention, the `Entry` record decoded from a value -// buffer (list and map fields included), the `kv.stats` submodule, the raw -// listener registration ABI (register -> fire synchronously on delete -> -// unregister), and the raw `_async` launcher whose completion callback -// arrives on the producer's worker thread (synchronized here with C11 -// atomics). Exits 0 on success; aborts otherwise. +// buffer (list and map fields included), the `kv.stats` submodule, the +// hand-written `EvictionListener` vtable (set -> fire synchronously on +// delete and on expiry-on-read -> replace/clear/detach -> `free`), a +// foreign error raised from the listener surfacing as code -4, the +// reference-counted object graph (`share`, `fork`, `clone`/`destroy`, +// `larger` with `Store?` both ways, `describe` returning a record that +// carries object tokens, `open_many` returning a list of objects, +// `total_count` taking objects inside a list and a record), and the raw +// `_async` launcher with a cancel token. Completion arrives on the +// producer's worker thread (synchronized here with C11 atomics). Exits 0 on +// success; aborts otherwise. #include #include #include +#include #include #include #include "weaveffi.h" #include "wvbuf.h" -// ── eviction listener state ──────────────────────────────────────────────── -static int g_evictions = 0; -static char g_last_evicted[64]; +// ── eviction listener (consumer-implemented callback interface) ──────────── -static void on_evict(const char* key, void* context) { - (void)context; - g_evictions++; - snprintf(g_last_evicted, sizeof g_last_evicted, "%s", key); +typedef struct { + int evictions; + char last_key[64]; + int32_t last_reason; + size_t last_value_len; + int detach_after; // return false (detach) once this many evictions ran + int fail; // report a foreign error instead of observing +} listener_ctx; + +static int g_listener_freed = 0; + +// Entry record: id, key, value (bytes), created_at, expires_at?, tags, +// metadata. The buffer is borrowed for the duration of the dispatch. +static bool on_evict(void* ctx, const uint8_t* entry_ptr, size_t entry_len, + weaveffi_kv_EvictionReason reason, weaveffi_error* out_err) { + listener_ctx* l = (listener_ctx*)ctx; + if (l->fail) { + weaveffi_error_set(out_err, -4, "listener exploded"); + return true; + } + wv_reader r; + wv_r_init(&r, entry_ptr, entry_len); + int64_t id = wv_get_i64(&r); + assert(id > 0); + char* key = wv_get_str(&r); + snprintf(l->last_key, sizeof l->last_key, "%s", key); + free(key); + l->last_value_len = wv_get_u32(&r); + wv_take(&r, l->last_value_len); + assert(wv_get_i64(&r) > 0 && "created_at"); + if (wv_get_bool(&r)) wv_get_i64(&r); // expires_at? + uint32_t tags = wv_get_u32(&r); + for (uint32_t i = 0; i < tags; i++) free(wv_get_str(&r)); + uint32_t meta = wv_get_u32(&r); + for (uint32_t i = 0; i < meta; i++) { + free(wv_get_str(&r)); + free(wv_get_str(&r)); + } + wv_r_expect_end(&r); + l->last_reason = (int32_t)reason; + l->evictions++; + return l->detach_after == 0 || l->evictions < l->detach_after; +} + +static void listener_free(void* ctx) { + free(ctx); + g_listener_freed++; +} + +static const weaveffi_kv_EvictionListener_vtable LISTENER_VTABLE = { + on_evict, + listener_free, +}; + +static listener_ctx* new_listener(int detach_after, int fail) { + listener_ctx* l = (listener_ctx*)calloc(1, sizeof *l); + assert(l != NULL); + l->detach_after = detach_after; + l->fail = fail; + return l; } // ── async completion state ───────────────────────────────────────────────── @@ -38,50 +99,132 @@ static int32_t g_compact_err = -1; static void on_compact_done(void* context, weaveffi_error* err, int64_t result) { (void)context; g_compact_err = err ? err->code : 0; + weaveffi_error_free(err); g_compact_result = result; atomic_store(&g_compact_done, 1); } +static void wait_compact(void) { + for (int i = 0; i < 5000 && !atomic_load(&g_compact_done); i++) usleep(1000); + assert(atomic_load(&g_compact_done)); + atomic_store(&g_compact_done, 0); +} + +// ── helpers ──────────────────────────────────────────────────────────────── + // Optional-i64 parameter encodings for `ttl_seconds`. static const uint8_t TTL_NONE[1] = {0}; +static weaveffi_kv_Store* open_store(const char* path) { + weaveffi_error err = {0}; + weaveffi_kv_Store* s = weaveffi_kv_Store_open(path, &err); + assert(err.code == 0 && s != NULL); + return s; +} + +static void put(weaveffi_kv_Store* s, const char* key, const uint8_t* v, size_t n) { + weaveffi_error err = {0}; + assert(weaveffi_kv_Store_put(s, key, v, n, weaveffi_kv_EntryKind_Persistent, + TTL_NONE, sizeof TTL_NONE, &err)); + assert(err.code == 0); +} + +// put with `Some(ttl)`: flag byte + little-endian i64. +static void put_ttl(weaveffi_kv_Store* s, const char* key, const uint8_t* v, + size_t n, int64_t ttl) { + weaveffi_error err = {0}; + wv_writer w; + wv_w_init(&w); + wv_put_bool(&w, 1); + wv_put_i64(&w, ttl); + assert(weaveffi_kv_Store_put(s, key, v, n, weaveffi_kv_EntryKind_Volatile, + w.buf, w.len, &err)); + assert(err.code == 0); + wv_w_free(&w); +} + +static int64_t count(weaveffi_kv_Store* s) { + weaveffi_error err = {0}; + int64_t n = weaveffi_kv_Store_count(s, &err); + assert(err.code == 0); + return n; +} + +// A decoded StoreInfo record: label, store (object token), mirror (object +// token?), count. The adopted references are the caller's to destroy. +typedef struct { + char* label; + weaveffi_kv_Store* store; + weaveffi_kv_Store* mirror; // NULL when absent + int64_t count; +} store_info_t; + +static void read_store_info(wv_reader* r, store_info_t* info) { + info->label = wv_get_str(r); + info->store = (weaveffi_kv_Store*)wv_get_obj(r); + info->mirror = wv_get_bool(r) ? (weaveffi_kv_Store*)wv_get_obj(r) : NULL; + info->count = wv_get_i64(r); +} + +// Encode a StoreInfo. Object fields are written as fresh strong references +// (`_clone`), since the buffer's reader adopts them. +static void write_store_info(wv_writer* w, const char* label, + weaveffi_kv_Store* store, weaveffi_kv_Store* mirror, + int64_t n) { + wv_put_str(w, label); + wv_put_obj(w, weaveffi_kv_Store_clone(store)); + wv_put_bool(w, mirror != NULL); + if (mirror) wv_put_obj(w, weaveffi_kv_Store_clone(mirror)); + wv_put_i64(w, n); +} + int main(void) { weaveffi_error err = {0}; + const uint8_t payload[3] = {1, 2, 3}; + + assert(weaveffi_abi_version() == 2u && WEAVEFFI_ABI_VERSION == 2u); // Static method: no receiver, plain error-out slot. assert(weaveffi_kv_Store_default_capacity(&err) == 1000000); assert(err.code == 0); // Fallible constructor, typed error path: an empty path reports the - // KvError.IoError domain code (1004). + // KvError.IoError domain code (1004) with its doc-comment message. weaveffi_kv_Store* bad = weaveffi_kv_Store_open("", &err); assert(bad == NULL); assert(err.code == weaveffi_kv_KvError_IoError); + assert(err.message != NULL && strcmp(err.message, "I/O failure") == 0); weaveffi_error_clear(&err); - weaveffi_kv_Store* store = - weaveffi_kv_Store_open("/tmp/conformance-kvstore-c", &err); - assert(err.code == 0); - assert(store != NULL); + // A null `string` argument is a marshalling failure (-3). + assert(weaveffi_kv_Store_open(NULL, &err) == NULL); + assert(err.code == -3); + weaveffi_error_clear(&err); - // Populate two keys so count/iterator/stats have something to report. The - // optional TTL parameter is a buffered `i64?` (flag byte + payload). - const uint8_t payload[3] = {1, 2, 3}; - assert(weaveffi_kv_Store_put(store, "alpha", payload, sizeof payload, - weaveffi_kv_EntryKind_Persistent, - TTL_NONE, sizeof TTL_NONE, &err)); - assert(err.code == 0); + weaveffi_kv_Store* store = open_store("/tmp/conformance-kvstore-c"); + + // Populate two keys so count/iterator/stats have something to report. + put(store, "alpha", payload, sizeof payload); assert(weaveffi_kv_Store_put(store, "beta", payload, sizeof payload, weaveffi_kv_EntryKind_Volatile, TTL_NONE, sizeof TTL_NONE, &err)); assert(err.code == 0); - assert(weaveffi_kv_Store_count(store, &err) == 2); + assert(count(store) == 2); + + // An out-of-range enum discriminant is a marshalling failure (-3). + assert(!weaveffi_kv_Store_put(store, "bad", payload, sizeof payload, + (weaveffi_kv_EntryKind)999, TTL_NONE, + sizeof TTL_NONE, &err)); + assert(err.code == -3); + weaveffi_error_clear(&err); + assert(count(store) == 2); // Typed error path on a method: a missing key reports KeyNotFound (1001). size_t out_len = 0; const uint8_t* nope = weaveffi_kv_Store_get(store, "missing", &out_len, &err); assert(nope == NULL); assert(err.code == weaveffi_kv_KvError_KeyNotFound); + assert(strcmp(err.message, "key not found") == 0); weaveffi_error_clear(&err); // Deprecated method still works at the ABI level. The generated header @@ -93,24 +236,39 @@ int main(void) { #pragma clang diagnostic pop assert(err.code == 0); assert(weaveffi_kv_Store_delete(store, "old", &err) && err.code == 0); + assert(!weaveffi_kv_Store_delete(store, "old", &err) && err.code == 0); - // Iterator: `next` writes one element per call and returns 1/0. The - // optional prefix parameter is a buffered `string?`; absent here. + // Iterator: `next` writes one element per call and returns 1/0. Keys + // come back sorted. The optional prefix parameter is a buffered + // `string?`; absent first, then present. weaveffi_kv_Store_ListKeysIterator* it = weaveffi_kv_Store_list_keys(store, TTL_NONE, sizeof TTL_NONE, &err); assert(err.code == 0 && it != NULL); - int seen_alpha = 0, seen_beta = 0, n = 0; + const char* expected_keys[] = {"alpha", "beta"}; + int n = 0; const char* item = NULL; weaveffi_error iter_err = {0}; while (weaveffi_kv_Store_ListKeysIterator_next(it, &item, &iter_err) != 0) { - if (strcmp(item, "alpha") == 0) seen_alpha = 1; - if (strcmp(item, "beta") == 0) seen_beta = 1; + assert(n < 2 && strcmp(item, expected_keys[n]) == 0); weaveffi_free_string(item); n++; } assert(iter_err.code == 0); weaveffi_kv_Store_ListKeysIterator_destroy(it); - assert(n == 2 && seen_alpha && seen_beta); + assert(n == 2); + + wv_writer prefix; + wv_w_init(&prefix); + wv_put_bool(&prefix, 1); + wv_put_str(&prefix, "be"); + it = weaveffi_kv_Store_list_keys(store, prefix.buf, prefix.len, &err); + assert(err.code == 0 && it != NULL); + assert(weaveffi_kv_Store_ListKeysIterator_next(it, &item, &iter_err) == 1); + assert(strcmp(item, "beta") == 0); + weaveffi_free_string(item); + assert(weaveffi_kv_Store_ListKeysIterator_next(it, &item, &iter_err) == 0); + weaveffi_kv_Store_ListKeysIterator_destroy(it); + wv_w_free(&prefix); // get -> buffered `Entry?`: flag byte, then the record's fields in // declaration order (id, key, value, created_at, expires_at?, tags, @@ -121,7 +279,7 @@ int main(void) { wv_r_init(&r, ebuf, out_len); assert(wv_get_bool(&r) == 1 && "entry present"); int64_t id = wv_get_i64(&r); - assert(id > 0); + assert(id == 1 && "first id handed out by this store"); char* key = wv_get_str(&r); assert(strcmp(key, "alpha") == 0); free(key); @@ -132,13 +290,8 @@ int main(void) { int64_t created_at = wv_get_i64(&r); assert(created_at > 0); assert(wv_get_bool(&r) == 0 && "no TTL: expires_at is absent"); - uint32_t tag_count = wv_get_u32(&r); - for (uint32_t i = 0; i < tag_count; i++) free(wv_get_str(&r)); - uint32_t meta_count = wv_get_u32(&r); - for (uint32_t i = 0; i < meta_count; i++) { - free(wv_get_str(&r)); - free(wv_get_str(&r)); - } + assert(wv_get_u32(&r) == 0 && "no tags"); + assert(wv_get_u32(&r) == 0 && "no metadata"); wv_r_expect_end(&r); weaveffi_free_bytes((uint8_t*)ebuf, out_len); @@ -148,41 +301,249 @@ int main(void) { wv_r_init(&r, sbuf, out_len); assert(wv_get_i64(&r) == 2 && "total_entries"); assert(wv_get_i64(&r) == 6 && "total_bytes: two 3-byte values"); - wv_get_i64(&r); // expired_entries: timing-dependent, just consume it. + assert(wv_get_i64(&r) == 0 && "expired_entries"); wv_r_expect_end(&r); weaveffi_free_bytes((uint8_t*)sbuf, out_len); - // Eviction listener: delete fires the raw callback synchronously on the - // calling thread. - uint64_t sub = weaveffi_kv_register_eviction_listener(on_evict, NULL); - assert(sub > 0); + // ── eviction listener ──────────────────────────────────────────────── + // delete fires on_evict synchronously on the calling thread with the + // decoded Entry and reason Deleted. + listener_ctx* l1 = new_listener(0, 0); + weaveffi_kv_Store_set_eviction_listener(store, l1, &LISTENER_VTABLE, &err); + assert(err.code == 0); assert(weaveffi_kv_Store_delete(store, "beta", &err) && err.code == 0); - assert(g_evictions == 1 && strcmp(g_last_evicted, "beta") == 0); - - // Unregister stops delivery. - weaveffi_kv_unregister_eviction_listener(sub); - assert(weaveffi_kv_Store_delete(store, "alpha", &err) && err.code == 0); - assert(g_evictions == 1); - - // Async method: an immediately-expired entry gives compact 3 bytes to - // reclaim. The raw `_async` launcher returns immediately; completion - // arrives on the producer's worker thread, so poll the atomic flag. - uint8_t ttl_zero[9]; - ttl_zero[0] = 1; - memset(ttl_zero + 1, 0, 8); // Some(0): flag byte + little-endian i64 0. - assert(weaveffi_kv_Store_put(store, "doomed", payload, sizeof payload, - weaveffi_kv_EntryKind_Volatile, - ttl_zero, sizeof ttl_zero, &err)); + assert(l1->evictions == 1); + assert(strcmp(l1->last_key, "beta") == 0); + assert(l1->last_reason == weaveffi_kv_EvictionReason_Deleted); + assert(l1->last_value_len == 3); + + // An expired entry is evicted on read: get reports Expired (1002) and + // the listener sees reason Expired. + put_ttl(store, "stale", payload, 2, -1); + assert(weaveffi_kv_Store_get(store, "stale", &out_len, &err) == NULL); + assert(err.code == weaveffi_kv_KvError_Expired); + weaveffi_error_clear(&err); + assert(l1->evictions == 2); + assert(strcmp(l1->last_key, "stale") == 0); + assert(l1->last_reason == weaveffi_kv_EvictionReason_Expired); + assert(l1->last_value_len == 2); + + // Replacing the listener frees the previous one; clearing frees the + // current one. + assert(g_listener_freed == 0); + listener_ctx* l2 = new_listener(0, 0); + weaveffi_kv_Store_set_eviction_listener(store, l2, &LISTENER_VTABLE, &err); + assert(g_listener_freed == 1 && "replaced listener freed"); + weaveffi_kv_Store_clear_eviction_listener(store, &err); assert(err.code == 0); + assert(g_listener_freed == 2 && "cleared listener freed"); + weaveffi_kv_Store_clear_eviction_listener(store, &err); + assert(err.code == 0 && g_listener_freed == 2); + + // Nothing is attached now: a delete is not observed anywhere. + put(store, "gamma", payload, 1); + assert(weaveffi_kv_Store_delete(store, "gamma", &err)); + + // A listener returning false detaches itself (and is freed). + listener_ctx* l3 = new_listener(1, 0); + weaveffi_kv_Store_set_eviction_listener(store, l3, &LISTENER_VTABLE, &err); + put(store, "d1", payload, 1); + put(store, "d2", payload, 1); + assert(weaveffi_kv_Store_delete(store, "d1", &err)); + assert(g_listener_freed == 3 && "listener that answered false is freed"); + assert(weaveffi_kv_Store_delete(store, "d2", &err)); + assert(g_listener_freed == 3); + + // A listener that reports a foreign error aborts the delete with -4; the + // store stays usable and still holds the listener until it is cleared. + listener_ctx* l4 = new_listener(0, 1); + weaveffi_kv_Store_set_eviction_listener(store, l4, &LISTENER_VTABLE, &err); + put(store, "boom", payload, 1); + weaveffi_kv_Store_delete(store, "boom", &err); + assert(err.code == -4); + assert(err.message != NULL && strstr(err.message, "listener exploded") != NULL); + weaveffi_error_clear(&err); + assert(count(store) == 1 && "the entry was removed before the listener ran"); + assert(g_listener_freed == 3); + weaveffi_kv_Store_clear_eviction_listener(store, &err); + assert(g_listener_freed == 4); + + // ── async compaction ───────────────────────────────────────────────── + // An immediately-expired entry gives compact 3 bytes to reclaim. The raw + // `_async` launcher returns immediately; completion arrives on the + // producer's worker thread. + put_ttl(store, "doomed", payload, sizeof payload, 0); weaveffi_kv_Store_compact_async(store, NULL, on_compact_done, NULL); - for (int i = 0; i < 5000 && !atomic_load(&g_compact_done); i++) usleep(1000); - assert(atomic_load(&g_compact_done)); + wait_compact(); assert(g_compact_err == 0); assert(g_compact_result == 3); - assert(weaveffi_kv_Store_count(store, &err) == 0 && err.code == 0); + assert(count(store) == 1); + + // A cancelled token makes compact fail with IoError through the + // callback's heap-boxed error. + weaveffi_cancel_token* token = weaveffi_cancel_token_create(); + assert(!weaveffi_cancel_token_is_cancelled(token)); + weaveffi_cancel_token_cancel(token); + assert(weaveffi_cancel_token_is_cancelled(token)); + weaveffi_kv_Store_compact_async(store, token, on_compact_done, NULL); + wait_compact(); + assert(g_compact_err == weaveffi_kv_KvError_IoError); + weaveffi_cancel_token_destroy(token); + + // ── reference counting and the object graph ────────────────────────── + // share() returns the very same object: identical pointer, and after the + // original reference is released the shared one still sees the data. + weaveffi_kv_Store* shared = weaveffi_kv_Store_share(store, &err); + assert(err.code == 0); + assert(shared == store && "share returns the same object"); + weaveffi_kv_Store_destroy(store); + assert(count(shared) == 1 && "still alive through the shared reference"); + put(shared, "via-shared", payload, 1); + assert(count(shared) == 2); + store = shared; + + // clone: same pointer; destroy the original, the clone still works. + weaveffi_kv_Store* cloned = weaveffi_kv_Store_clone(store); + assert(cloned == store); + weaveffi_kv_Store_destroy(store); + assert(count(cloned) == 2 && "clone outlives the destroyed original"); + store = cloned; + assert(weaveffi_kv_Store_clone(NULL) == NULL); + weaveffi_kv_Store_destroy(NULL); + + // fork() is a distinct object with a copy of the entries. + weaveffi_kv_Store* forked = weaveffi_kv_Store_fork(store, &err); + assert(err.code == 0 && forked != NULL && forked != store); + assert(count(forked) == 2); + put(forked, "only-in-fork", payload, 1); + assert(count(forked) == 3 && count(store) == 2); + + // larger(): `Store?` in and out. null means "none" both ways; a returned + // object is an owned reference. + weaveffi_kv_Store* empty = open_store("/tmp/conformance-kvstore-c-empty"); + assert(weaveffi_kv_Store_larger(empty, NULL, &err) == NULL); + assert(err.code == 0); + weaveffi_kv_Store* bigger = weaveffi_kv_Store_larger(empty, forked, &err); + assert(bigger == forked); + weaveffi_kv_Store_destroy(bigger); + bigger = weaveffi_kv_Store_larger(forked, store, &err); + assert(bigger == forked && "self wins when it holds more"); + weaveffi_kv_Store_destroy(bigger); + bigger = weaveffi_kv_Store_larger(store, NULL, &err); + assert(bigger == store && "a non-empty self is returned when other is absent"); + weaveffi_kv_Store_destroy(bigger); + assert(count(forked) == 3 && "still alive after releasing the returned refs"); + + // describe(): a record carrying the object itself (a token that is one + // strong reference) and an optional object. + const uint8_t* ibuf = weaveffi_kv_Store_describe(store, "primary", NULL, &out_len, &err); + assert(err.code == 0 && ibuf != NULL); + store_info_t info; + wv_r_init(&r, ibuf, out_len); + read_store_info(&r, &info); + wv_r_expect_end(&r); + weaveffi_free_bytes((uint8_t*)ibuf, out_len); + assert(strcmp(info.label, "primary") == 0); + assert(info.store == store && "describe().store is the receiver"); + assert(info.mirror == NULL); + assert(info.count == 2); + free(info.label); + weaveffi_kv_Store_destroy(info.store); // release the adopted token + ibuf = weaveffi_kv_Store_describe(forked, "with-mirror", store, &out_len, &err); + assert(err.code == 0 && ibuf != NULL); + wv_r_init(&r, ibuf, out_len); + read_store_info(&r, &info); + wv_r_expect_end(&r); + weaveffi_free_bytes((uint8_t*)ibuf, out_len); + assert(strcmp(info.label, "with-mirror") == 0); + assert(info.store == forked && info.mirror == store && info.count == 3); + assert(count(info.mirror) == 2 && "the mirror token is a live reference"); + free(info.label); + weaveffi_kv_Store_destroy(info.store); + weaveffi_kv_Store_destroy(info.mirror); + + // open_many(): a list of objects as a return (u32 count + tokens). + wv_writer paths; + wv_w_init(&paths); + wv_put_u32(&paths, 2); + wv_put_str(&paths, "/a"); + wv_put_str(&paths, "/b"); + const uint8_t* mbuf = weaveffi_kv_Store_open_many(paths.buf, paths.len, &out_len, &err); + assert(err.code == 0 && mbuf != NULL); + wv_r_init(&r, mbuf, out_len); + assert(wv_get_u32(&r) == 2); + weaveffi_kv_Store* m0 = (weaveffi_kv_Store*)wv_get_obj(&r); + weaveffi_kv_Store* m1 = (weaveffi_kv_Store*)wv_get_obj(&r); + wv_r_expect_end(&r); + weaveffi_free_bytes((uint8_t*)mbuf, out_len); + wv_w_free(&paths); + assert(m0 != NULL && m1 != NULL && m0 != m1); + put(m0, "m", payload, 1); + assert(count(m0) == 1 && count(m1) == 0); + + // A failing path fails the whole call with the typed code and no list. + wv_w_init(&paths); + wv_put_u32(&paths, 2); + wv_put_str(&paths, "/ok"); + wv_put_str(&paths, ""); + assert(weaveffi_kv_Store_open_many(paths.buf, paths.len, &out_len, &err) == NULL); + assert(err.code == weaveffi_kv_KvError_IoError); + weaveffi_error_clear(&err); + wv_w_free(&paths); + + // total_count(): objects inside a parameter list and inside an optional + // record. Every token written is a fresh `_clone`, which the producer + // adopts and drops; our own references stay valid afterward. + wv_writer stores; + wv_w_init(&stores); + wv_put_u32(&stores, 3); + wv_put_obj(&stores, weaveffi_kv_Store_clone(m0)); + wv_put_obj(&stores, weaveffi_kv_Store_clone(m1)); + wv_put_obj(&stores, weaveffi_kv_Store_clone(forked)); + wv_writer extra; + wv_w_init(&extra); + wv_put_bool(&extra, 1); + write_store_info(&extra, "extra", store, forked, count(store)); + int64_t total = weaveffi_kv_Store_total_count(stores.buf, stores.len, + extra.buf, extra.len, &err); + assert(err.code == 0); + assert(total == 1 + 0 + 3 + 2); + wv_w_free(&stores); + wv_w_free(&extra); + + // ... and with the optional record absent, plus an empty list. + wv_w_init(&stores); + wv_put_u32(&stores, 1); + wv_put_obj(&stores, weaveffi_kv_Store_clone(m0)); + const uint8_t none[1] = {0}; + assert(weaveffi_kv_Store_total_count(stores.buf, stores.len, none, 1, &err) == 1); + wv_w_free(&stores); + wv_w_init(&stores); + wv_put_u32(&stores, 0); + assert(weaveffi_kv_Store_total_count(stores.buf, stores.len, none, 1, &err) == 0); + wv_w_free(&stores); + + // Everything we still hold is intact. + assert(count(m0) == 1 && count(m1) == 0 && count(forked) == 3 && count(store) == 2); + + // clear() and release every reference exactly once. + weaveffi_kv_Store_clear(forked, &err); + assert(err.code == 0 && count(forked) == 0); + weaveffi_kv_Store_destroy(m0); + weaveffi_kv_Store_destroy(m1); + weaveffi_kv_Store_destroy(empty); + weaveffi_kv_Store_destroy(forked); weaveffi_kv_Store_destroy(store); + // A store dropped with a listener attached frees the listener. + weaveffi_kv_Store* with_listener = open_store("/tmp/conformance-kvstore-c-l"); + weaveffi_kv_Store_set_eviction_listener(with_listener, new_listener(0, 0), + &LISTENER_VTABLE, &err); + assert(g_listener_freed == 4); + weaveffi_kv_Store_destroy(with_listener); + assert(g_listener_freed == 5 && "dropping the store frees its listener"); + printf("c/kvstore: OK\n"); return 0; } diff --git a/conformance/c/wvbuf.h b/conformance/c/wvbuf.h index a6bf8c48..3a44b70b 100644 --- a/conformance/c/wvbuf.h +++ b/conformance/c/wvbuf.h @@ -44,8 +44,18 @@ static inline void wv_put_raw(wv_writer* w, const void* p, size_t n) { } static inline void wv_put_u8(wv_writer* w, uint8_t v) { wv_put_raw(w, &v, 1); } +static inline void wv_put_i8(wv_writer* w, int8_t v) { wv_put_u8(w, (uint8_t)v); } static inline void wv_put_bool(wv_writer* w, int v) { wv_put_u8(w, v ? 1 : 0); } +static inline void wv_put_u16(wv_writer* w, uint16_t v) { + uint8_t b[2] = {(uint8_t)v, (uint8_t)(v >> 8)}; + wv_put_raw(w, b, 2); +} + +static inline void wv_put_i16(wv_writer* w, int16_t v) { + wv_put_u16(w, (uint16_t)v); +} + static inline void wv_put_u32(wv_writer* w, uint32_t v) { uint8_t b[4] = {(uint8_t)v, (uint8_t)(v >> 8), (uint8_t)(v >> 16), (uint8_t)(v >> 24)}; @@ -89,6 +99,14 @@ static inline void wv_put_bytes(wv_writer* w, const uint8_t* p, size_t n) { wv_put_raw(w, p, n); } +// Object token: the object pointer widened to u64. The token carries one +// strong reference, so the caller passes a pointer it owns and gives up (in +// practice the result of `{tag}_clone`), never a pointer it still holds. +static inline void wv_put_obj(wv_writer* w, const void* owned_ref) { + assert(owned_ref != NULL && "zero object token in a required position"); + wv_put_u64(w, (uint64_t)(uintptr_t)owned_ref); +} + // ── reader ────────────────────────────────────────────────────────────────── typedef struct { @@ -111,6 +129,7 @@ static inline const uint8_t* wv_take(wv_reader* r, size_t n) { } static inline uint8_t wv_get_u8(wv_reader* r) { return *wv_take(r, 1); } +static inline int8_t wv_get_i8(wv_reader* r) { return (int8_t)wv_get_u8(r); } static inline int wv_get_bool(wv_reader* r) { uint8_t v = wv_get_u8(r); @@ -118,6 +137,13 @@ static inline int wv_get_bool(wv_reader* r) { return v; } +static inline uint16_t wv_get_u16(wv_reader* r) { + const uint8_t* b = wv_take(r, 2); + return (uint16_t)((uint16_t)b[0] | ((uint16_t)b[1] << 8)); +} + +static inline int16_t wv_get_i16(wv_reader* r) { return (int16_t)wv_get_u16(r); } + static inline uint32_t wv_get_u32(wv_reader* r) { const uint8_t* b = wv_take(r, 4); return (uint32_t)b[0] | ((uint32_t)b[1] << 8) | ((uint32_t)b[2] << 16) | @@ -160,6 +186,15 @@ static inline char* wv_get_str(wv_reader* r) { return s; } +// Object token: the reader adopts the strong reference it carries, so the +// returned pointer must eventually be released with `{tag}_destroy` (or kept +// as the consumer's own reference to the object). +static inline void* wv_get_obj(wv_reader* r) { + uint64_t token = wv_get_u64(r); + assert(token != 0 && "zero object token in a required position"); + return (void*)(uintptr_t)token; +} + static inline void wv_r_expect_end(const wv_reader* r) { assert(r->off == r->len && "trailing bytes after value"); } diff --git a/conformance/cpp/codec.cpp b/conformance/cpp/codec.cpp new file mode 100644 index 00000000..6981c3ae --- /dev/null +++ b/conformance/cpp/codec.cpp @@ -0,0 +1,475 @@ +// Conformance consumer: codec sample, C++ target (ABI revision 2). +// +// Round-trips every value-buffer wire shape through the producer oracle with +// the generated header-only wrapper: +// - `sample_*` (producer encodes, consumer decodes) is checked field by +// field against the canonical fixture, then handed back to `verify_*` +// (consumer encodes, producer decodes) which throws `MismatchError` +// unless the bytes decode to exactly the canonical value; +// - `roundtrip_*` covers the direct, string, and bytes families plus +// consumer-built values with edge cases (empty strings, lists, and maps, +// non-ASCII text, i64/u64 extremes, NaN, infinities, negative zero); +// - `Shape` exercises every rich-enum variant as a std::variant; +// - `Holder` puts `Token` objects inside a record field, an optional, and a +// list: decoding adopts one reference per token, encoding clones one per +// token, `primary_of` returns the same object as `holder.primary`, and +// `same_primary` compares identity through two encodings. +// Exits non-zero on the first failed check. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "weaveffi.hpp" + +// The root namespace is `codec` and the module namespace is `codec::codec`, +// so types live at `codec::Scalars` and functions at `codec::codec::f`. +namespace oracle = codec::codec; +using codec::Color; +using codec::Composite; +using codec::Holder; +using codec::Scalars; +using codec::Shape; +using codec::Token; + +static void check(bool ok, const char* what) { + if (!ok) { + std::fprintf(stderr, "cpp/codec: FAIL: %s\n", what); + std::exit(1); + } +} + +static bool same_f32(float a, float b) { + if (std::isnan(a) && std::isnan(b)) return true; + return a == b && std::signbit(a) == std::signbit(b); +} + +static bool same_f64(double a, double b) { + if (std::isnan(a) && std::isnan(b)) return true; + return a == b && std::signbit(a) == std::signbit(b); +} + +static bool scalars_equal(const Scalars& a, const Scalars& b) { + return a.i8_value == b.i8_value && a.u8_value == b.u8_value && a.i16_value == b.i16_value && + a.u16_value == b.u16_value && a.i32_value == b.i32_value && + a.u32_value == b.u32_value && a.i64_value == b.i64_value && + a.u64_value == b.u64_value && same_f32(a.f32_value, b.f32_value) && + same_f64(a.f64_value, b.f64_value) && a.flag == b.flag && a.color == b.color; +} + +static bool shapes_equal(const Shape& a, const Shape& b) { + if (a.tag() != b.tag()) return false; + switch (a.tag()) { + case Shape::Tag::Empty: + return true; + case Shape::Tag::Circle: + return same_f64(std::get(a.value).radius, + std::get(b.value).radius); + case Shape::Tag::Rect: { + const auto& x = std::get(a.value); + const auto& y = std::get(b.value); + return same_f32(x.width, y.width) && same_f32(x.height, y.height); + } + case Shape::Tag::Labeled: { + const auto& x = std::get(a.value); + const auto& y = std::get(b.value); + return x.label == y.label && x.count == y.count; + } + case Shape::Tag::Nested: { + const auto& x = std::get(a.value); + const auto& y = std::get(b.value); + return scalars_equal(x.inner, y.inner) && x.note == y.note; + } + } + return false; +} + +static bool composites_equal(const Composite& a, const Composite& b) { + if (a.name != b.name || a.blob != b.blob || a.some_i64 != b.some_i64 || + a.none_i64 != b.none_i64 || a.some_text != b.some_text || a.names != b.names || + a.matrix != b.matrix || a.by_name != b.by_name || a.maybe_list != b.maybe_list || + a.sparse != b.sparse || a.colors != b.colors) { + return false; + } + if (a.empty.size() != b.empty.size()) return false; + for (size_t i = 0; i < a.empty.size(); i++) { + if (!same_f64(a.empty[i], b.empty[i])) return false; + } + if (a.by_id.size() != b.by_id.size()) return false; + for (const auto& kv : a.by_id) { + auto it = b.by_id.find(kv.first); + if (it == b.by_id.end() || !scalars_equal(kv.second, it->second)) return false; + } + if (!scalars_equal(a.scalars, b.scalars) || !shapes_equal(a.shape, b.shape)) return false; + if (a.shapes.size() != b.shapes.size()) return false; + for (size_t i = 0; i < a.shapes.size(); i++) { + if (!shapes_equal(a.shapes[i], b.shapes[i])) return false; + } + if (a.maybe_shape.has_value() != b.maybe_shape.has_value()) return false; + if (a.maybe_shape.has_value() && !shapes_equal(*a.maybe_shape, *b.maybe_shape)) return false; + return true; +} + +static void check_canonical_scalars(const Scalars& s, const char* who) { + std::string prefix = std::string(who) + ": canonical scalars "; + check(s.i8_value == -8, (prefix + "i8").c_str()); + check(s.u8_value == 200, (prefix + "u8").c_str()); + check(s.i16_value == -16000, (prefix + "i16").c_str()); + check(s.u16_value == 60000, (prefix + "u16").c_str()); + check(s.i32_value == -2000000000, (prefix + "i32").c_str()); + check(s.u32_value == 4000000000u, (prefix + "u32").c_str()); + check(s.i64_value == -9007199254740993LL, (prefix + "i64").c_str()); + check(s.u64_value == std::numeric_limits::max(), (prefix + "u64").c_str()); + check(s.f32_value == 1.5f, (prefix + "f32").c_str()); + check(s.f64_value == -2.25e100, (prefix + "f64").c_str()); + check(s.flag, (prefix + "flag").c_str()); + check(s.color == Color::Blue, (prefix + "color").c_str()); +} + +int main() { + codec::check_abi_version(); + + // Scalars: producer encodes, consumer decodes, and back again. + Scalars sample = oracle::sample_scalars(); + check_canonical_scalars(sample, "sample_scalars"); + check(oracle::verify_scalars(sample), "verify_scalars accepts the re-encoded sample"); + Scalars echoed = oracle::roundtrip_scalars(sample); + check_canonical_scalars(echoed, "roundtrip_scalars"); + check(scalars_equal(sample, echoed), "roundtrip_scalars is identical"); + + // A mismatch is the typed domain exception with its declared code. + { + Scalars changed = sample; + changed.u8_value = 201; + bool caught = false; + try { + oracle::verify_scalars(changed); + } catch (const codec::MismatchError& e) { + caught = (e.code() == 1); + check(dynamic_cast(&e) != nullptr, + "MismatchError is a CodecError"); + check(std::string(e.what()) == "value does not match the canonical fixture", + "MismatchError carries the doc-comment message"); + } + check(caught, "verify_scalars throws MismatchError for a changed value"); + changed = sample; + changed.flag = false; + caught = false; + try { + oracle::verify_scalars(changed); + } catch (const codec::CodecError& e) { + caught = (e.code() == 1); + } + check(caught, "a flipped bool is detected"); + } + + // Consumer-built scalars at the edges of every width. + { + Scalars edge{std::numeric_limits::min(), std::numeric_limits::max(), + std::numeric_limits::min(), std::numeric_limits::max(), + std::numeric_limits::min(), std::numeric_limits::max(), + std::numeric_limits::min(), std::numeric_limits::max(), + -0.0f, std::numeric_limits::quiet_NaN(), + false, Color::Red}; + Scalars back = oracle::roundtrip_scalars(edge); + check(scalars_equal(edge, back), "edge scalars round-trip"); + check(std::signbit(back.f32_value) && back.f32_value == 0.0f, "negative zero f32 keeps its sign"); + check(std::isnan(back.f64_value), "NaN f64 round-trips as NaN"); + + Scalars inf = edge; + inf.f32_value = std::numeric_limits::infinity(); + inf.f64_value = -std::numeric_limits::infinity(); + inf.i64_value = std::numeric_limits::max(); + inf.color = Color::Green; + back = oracle::roundtrip_scalars(inf); + check(scalars_equal(inf, back), "infinity scalars round-trip"); + check(std::isinf(back.f32_value) && back.f32_value > 0, "+inf f32"); + check(std::isinf(back.f64_value) && back.f64_value < 0, "-inf f64"); + } + + // Composite: every nested shape, checked against the canonical fixture. + Composite comp = oracle::sample_composite(); + check(comp.name == "h\xC3\xA9llo w\xC3\xB6rld \xE2\x9C\x93", "composite name (UTF-8)"); + check(comp.blob == std::vector({0, 1, 2, 253, 254, 255}), "composite blob"); + check(comp.some_i64.has_value() && *comp.some_i64 == std::numeric_limits::min(), + "composite some_i64 is i64::MIN"); + check(!comp.none_i64.has_value(), "composite none_i64 absent"); + check(comp.some_text.has_value() && comp.some_text->empty(), "composite some_text is Some(\"\")"); + check(comp.names.size() == 3 && comp.names[0] == "a" && comp.names[1].empty() && + comp.names[2] == "ccc", + "composite names"); + check(comp.matrix.size() == 3 && comp.matrix[0] == std::vector({1, 2, 3}) && + comp.matrix[1].empty() && comp.matrix[2] == std::vector({-4}), + "composite matrix"); + check(comp.empty.empty(), "composite empty list"); + check(comp.by_name.size() == 3 && comp.by_name.at("one") == 1 && comp.by_name.at("two") == 2 && + comp.by_name.at("neg") == -3, + "composite by_name"); + check(comp.by_id.size() == 2, "composite by_id has two entries"); + check_canonical_scalars(comp.by_id.at(-1), "by_id[-1]"); + check(!comp.by_id.at(42).flag && comp.by_id.at(42).u64_value == std::numeric_limits::max(), + "composite by_id[42] differs only in flag"); + check_canonical_scalars(comp.scalars, "composite.scalars"); + check(comp.shape.tag() == Shape::Tag::Labeled && + std::get(comp.shape.value).label == "tag" && + std::get(comp.shape.value).count == 3, + "composite shape is Labeled{tag, 3}"); + check(comp.shapes.size() == 5, "composite shapes has one of each variant"); + check(comp.shapes[0].tag() == Shape::Tag::Empty, "shapes[0] Empty"); + check(comp.shapes[1].tag() == Shape::Tag::Circle && + std::get(comp.shapes[1].value).radius == 2.5, + "shapes[1] Circle{2.5}"); + check(comp.shapes[2].tag() == Shape::Tag::Rect && + std::get(comp.shapes[2].value).width == 1.0f && + std::get(comp.shapes[2].value).height == 0.5f, + "shapes[2] Rect{1, 0.5}"); + check(comp.shapes[3].tag() == Shape::Tag::Labeled && + std::get(comp.shapes[3].value).label.empty() && + std::get(comp.shapes[3].value).count == -1, + "shapes[3] Labeled{\"\", -1}"); + check(comp.shapes[4].tag() == Shape::Tag::Nested, "shapes[4] Nested"); + { + const auto& nested = std::get(comp.shapes[4].value); + check_canonical_scalars(nested.inner, "shapes[4].inner"); + check(nested.note.has_value() && *nested.note == "n", "shapes[4].note is Some(n)"); + } + check(comp.maybe_shape.has_value() && comp.maybe_shape->tag() == Shape::Tag::Nested && + !std::get(comp.maybe_shape->value).note.has_value(), + "composite maybe_shape is Nested with no note"); + check(comp.maybe_list.has_value() && *comp.maybe_list == std::vector({9, 8}), + "composite maybe_list"); + check(comp.sparse.size() == 3 && comp.sparse[0] == std::optional(true) && + !comp.sparse[1].has_value() && comp.sparse[2] == std::optional(false), + "composite sparse"); + check(comp.colors == std::vector({Color::Red, Color::Green, Color::Blue}), + "composite colors"); + + check(oracle::verify_composite(comp), "verify_composite accepts the re-encoded sample"); + Composite comp_back = oracle::roundtrip_composite(comp); + check(composites_equal(comp, comp_back), "roundtrip_composite is identical"); + check(oracle::verify_composite(comp_back), "the round-tripped composite still verifies"); + { + std::string text = oracle::describe_composite(comp); + check(text.find("h\xC3\xA9llo w\xC3\xB6rld \xE2\x9C\x93") != std::string::npos, + "describe_composite renders the name"); + check(text.find("Labeled") != std::string::npos, "describe_composite renders the shape"); + + Composite changed = comp; + changed.sparse[1] = true; + bool caught = false; + try { + oracle::verify_composite(changed); + } catch (const codec::MismatchError&) { + caught = true; + } + check(caught, "a changed nested optional is detected"); + changed = comp; + changed.by_id.erase(42); + caught = false; + try { + oracle::verify_composite(changed); + } catch (const codec::MismatchError&) { + caught = true; + } + check(caught, "a removed map entry is detected"); + } + + // A consumer-built composite with the sparse/empty corners. + { + Scalars zero{0, 0, 0, 0, 0, 0, 0, 0, 0.0f, 0.0, false, Color::Red}; + Composite mine; + mine.name = ""; + mine.blob = {}; + mine.some_i64 = std::numeric_limits::max(); + mine.none_i64 = -1; + mine.some_text = std::nullopt; + mine.names = {"\xF0\x9F\x8E\x89", "", "\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"}; + mine.matrix = {}; + mine.empty = {-0.0, std::numeric_limits::infinity(), 1e-300}; + mine.by_name = {}; + mine.by_id = {{std::numeric_limits::min(), zero}, + {0, sample}, + {std::numeric_limits::max(), zero}}; + mine.scalars = zero; + mine.shape = Shape{Shape::Empty{}}; + mine.shapes = {}; + mine.maybe_shape = std::nullopt; + mine.maybe_list = std::vector{}; + mine.sparse = {std::nullopt, std::nullopt}; + mine.colors = {}; + Composite back = oracle::roundtrip_composite(mine); + check(composites_equal(mine, back), "consumer-built composite round-trips"); + check(back.name.empty() && back.blob.empty() && back.matrix.empty() && + back.by_name.empty() && back.shapes.empty() && back.colors.empty(), + "empty containers stay empty"); + check(back.maybe_list.has_value() && back.maybe_list->empty(), + "Some(empty list) stays Some"); + check(!back.some_text.has_value() && back.none_i64 == std::optional(-1), + "optional fields keep their presence"); + check(back.by_id.size() == 3 && back.by_id.count(std::numeric_limits::min()) == 1, + "int-keyed map keeps extreme keys"); + check(std::signbit(back.empty[0]) && std::isinf(back.empty[1]) && back.empty[2] == 1e-300, + "f64 list keeps -0.0, inf, and denormal-range values"); + bool caught = false; + try { + oracle::verify_composite(mine); + } catch (const codec::MismatchError&) { + caught = true; + } + check(caught, "a non-canonical composite is rejected"); + } + + // Rich enum variants one at a time, plus a list of them. + { + std::vector all{ + Shape{Shape::Empty{}}, + Shape{Shape::Circle{-0.0}}, + Shape{Shape::Rect{std::numeric_limits::infinity(), -1.25f}}, + Shape{Shape::Labeled{"h\xC3\xA9", std::numeric_limits::min()}}, + Shape{Shape::Nested{sample, std::string("")}}, + Shape{Shape::Nested{sample, std::nullopt}}, + }; + for (const Shape& s : all) { + Shape back = oracle::roundtrip_shape(s); + check(shapes_equal(s, back), "roundtrip_shape preserves the variant"); + } + check(oracle::describe_shape(all[0]) == "Empty", "describe_shape Empty"); + check(oracle::describe_shape(Shape{Shape::Circle{2.5}}) == "Circle { radius: 2.5 }", + "describe_shape Circle"); + check(oracle::describe_shape(Shape{Shape::Labeled{"x", 3}}) == + "Labeled { label: \"x\", count: 3 }", + "describe_shape Labeled"); + std::vector many_back = oracle::roundtrip_shapes(all); + check(many_back.size() == all.size(), "roundtrip_shapes keeps the count"); + for (size_t i = 0; i < all.size(); i++) { + check(shapes_equal(all[i], many_back[i]), "roundtrip_shapes preserves each element"); + } + check(oracle::roundtrip_shapes({}).empty(), "roundtrip_shapes of an empty list"); + } + + // Top-level optionals, maps, strings, bytes, and direct scalars. + { + check(oracle::roundtrip_opt_i64(std::nullopt) == std::nullopt, "roundtrip_opt_i64 none"); + check(oracle::roundtrip_opt_i64(std::numeric_limits::min()) == + std::optional(std::numeric_limits::min()), + "roundtrip_opt_i64 some(i64::MIN)"); + check(oracle::roundtrip_opt_i64(0) == std::optional(0), "roundtrip_opt_i64 some(0)"); + + std::unordered_map m{{"", 0}, {"k", -1}, {"\xE2\x9C\x93", 7}}; + check(oracle::roundtrip_map(m) == m, "roundtrip_map"); + check(oracle::roundtrip_map({}).empty(), "roundtrip_map empty"); + + check(oracle::roundtrip_string("") == "", "roundtrip_string empty"); + check(oracle::roundtrip_string("h\xC3\xA9llo \xE2\x9C\x93 \xF0\x9F\x8E\x89") == + "h\xC3\xA9llo \xE2\x9C\x93 \xF0\x9F\x8E\x89", + "roundtrip_string non-ASCII"); + + check(oracle::roundtrip_bytes({}).empty(), "roundtrip_bytes empty"); + std::vector all_bytes; + for (int i = 0; i < 256; i++) all_bytes.push_back(static_cast(i)); + check(oracle::roundtrip_bytes(all_bytes) == all_bytes, "roundtrip_bytes 0..255"); + + check(oracle::roundtrip_i64(std::numeric_limits::min()) == + std::numeric_limits::min(), + "roundtrip_i64 min"); + check(oracle::roundtrip_i64(std::numeric_limits::max()) == + std::numeric_limits::max(), + "roundtrip_i64 max"); + check(oracle::roundtrip_u64(std::numeric_limits::max()) == + std::numeric_limits::max(), + "roundtrip_u64 max"); + check(oracle::roundtrip_u64(1ull << 63) == (1ull << 63), "roundtrip_u64 2^63"); + check(std::isnan(oracle::roundtrip_f64(std::numeric_limits::quiet_NaN())), + "roundtrip_f64 NaN"); + check(oracle::roundtrip_f64(-std::numeric_limits::infinity()) == + -std::numeric_limits::infinity(), + "roundtrip_f64 -inf"); + double neg_zero = oracle::roundtrip_f64(-0.0); + check(neg_zero == 0.0 && std::signbit(neg_zero), "roundtrip_f64 -0.0"); + check(oracle::roundtrip_bool(true) && !oracle::roundtrip_bool(false), "roundtrip_bool"); + check(oracle::roundtrip_color(Color::Blue) == Color::Blue, "roundtrip_color Blue (7)"); + check(oracle::roundtrip_color(Color::Red) == Color::Red, "roundtrip_color Red"); + } + + // Objects inside buffers. + { + Token t(5); + check(t.value() == 5, "Token constructor and method"); + Token t_copy = t; + check(t_copy.handle() == t.handle() && t_copy.value() == 5, "Token copy shares the object"); + + Holder holder = oracle::make_holder(10, true); + check(holder.primary.value() == 10, "holder.primary"); + check(holder.spare.has_value() && holder.spare->value() == 11, "holder.spare"); + check(holder.many.size() == 3 && holder.many[0].value() == 12 && + holder.many[1].value() == 13 && holder.many[2].value() == 14, + "holder.many"); + check(holder.primary.handle() != holder.spare->handle(), "distinct tokens are distinct objects"); + + // Encoding clones one reference per token; the wrappers stay valid. + check(oracle::sum_holder(holder) == 10 + 11 + 12 + 13 + 14, "sum_holder"); + check(oracle::sum_holder(holder) == 60, "sum_holder is repeatable (buffers are single use)"); + check(holder.primary.value() == 10 && holder.many[2].value() == 14, + "wrappers survive being encoded"); + + Token primary = oracle::primary_of(holder); + check(primary.handle() == holder.primary.handle(), "primary_of returns the same object"); + check(primary.value() == 10, "primary_of is usable"); + + check(oracle::same_primary(holder, holder), "same_primary(h, h)"); + Holder holder_copy = holder; + check(holder_copy.primary.handle() == holder.primary.handle(), + "a copied record shares its objects"); + check(oracle::same_primary(holder, holder_copy), "same_primary with a copied holder"); + Holder other = oracle::make_holder(10, true); + check(!oracle::same_primary(holder, other), "same_primary with a fresh holder is false"); + check(oracle::sum_holder(other) == 60, "fresh holder sums the same"); + + Holder without = oracle::make_holder(0, false); + check(!without.spare.has_value(), "make_holder(_, false) has no spare"); + check(oracle::sum_holder(without) == 0 + 2 + 3 + 4, "sum_holder without spare"); + + // A consumer-built holder: objects the consumer created cross inside + // a buffer and come back as the same objects. + Holder mine{Token(1), Token(2), {Token(3), Token(4)}}; + check(oracle::sum_holder(mine) == 10, "consumer-built holder sums"); + Token mine_primary = oracle::primary_of(mine); + check(mine_primary.handle() == mine.primary.handle(), "consumer-built primary identity"); + Holder bare{Token(100), std::nullopt, {}}; + check(oracle::sum_holder(bare) == 100, "holder with no spare and an empty list"); + Holder mixed{t, holder.spare, {t, primary, without.primary}}; + check(oracle::sum_holder(mixed) == 5 + 11 + 5 + 10 + 0, "holder mixing shared tokens"); + check(oracle::same_primary(mixed, Holder{t_copy, std::nullopt, {}}), + "same_primary through a consumer-built pair"); + + // Moving out leaves an empty wrapper whose destructor is a no-op. + Token moved = std::move(t_copy); + check(t_copy.handle() == nullptr && moved.value() == 5, "Token move"); + Holder moved_holder = std::move(holder_copy); + check(holder_copy.primary.handle() == nullptr && moved_holder.primary.value() == 10, + "Holder move"); + + // Many encode/decode cycles must neither leak nor double free: every + // wrapper here is still valid at scope exit and releases exactly once. + for (int i = 0; i < 1000; i++) { + Holder h = oracle::make_holder(i, (i % 2) == 0); + int64_t expected = i + (i % 2 == 0 ? i + 1 : 0) + (i + 2) + (i + 3) + (i + 4); + check(oracle::sum_holder(h) == expected, "sum_holder in a loop"); + Token p = oracle::primary_of(h); + check(p.handle() == h.primary.handle(), "primary_of in a loop"); + } + check(t.value() == 5 && primary.value() == 10 && mine.many[1].value() == 4, + "long-lived tokens are still alive after the loop"); + } + + std::printf("cpp/codec: OK\n"); + return 0; +} diff --git a/conformance/cpp/events.cpp b/conformance/cpp/events.cpp index 4dbdb8f9..a8af3a6c 100644 --- a/conformance/cpp/events.cpp +++ b/conformance/cpp/events.cpp @@ -1,59 +1,356 @@ -// Conformance consumer: events sample, C++ target. +// Conformance consumer: events sample, C++ target (ABI revision 2). // -// Drives the *generated* idiomatic wrapper. The 0.5.0 surface puts functions -// and listeners at bare snake_case names inside the per-module namespace -// (`weaveffi::events::send_message`, not `weaveffi::events_send_message`). -// Two key assertions: -// - `events::get_messages()` returns a lazy single-pass range (RAII handle, -// `begin()`/`end()`, one producer `next` per step): that only compiles and -// runs if the wrapper uses the real opaque-iterator ABI -// (launcher + next/destroy). -// - the `std::function` listener wrapper round-trips: register pins the -// closure, the producer fires it synchronously on send, unregister stops -// delivery. - -#include +// Drives the generated header-only wrapper over the callback-interface and +// reference-counted-object surface: +// - `Subscriber` is an abstract class the consumer subclasses; a +// `std::shared_ptr` crosses into the producer, which calls +// `route`, `on_message`, and `on_attached` through a static vtable and +// releases the box (running the consumer's destructor) when it drops its +// last reference. +// - `EventBus` is a copyable RAII wrapper: copies `_clone` the producer +// object and share it; moves transfer the handle; destructors release. +// - the `bus` handed to `on_attached` is an owned wrapper the consumer may +// keep and use. +// - `Delivery` return values steer `publish`'s accepted count. +// - a subscriber that throws surfaces to the caller as `WeaveFFIError` +// with code -4 and leaves the bus usable. +// - `messages()` is a lazy single-pass range; `last_message()` is an +// optional record; `publish_later` is a std::future settled from a +// producer thread. +// Exits non-zero on the first failed check. + #include +#include +#include +#include +#include +#include #include +#include #include #include "weaveffi.hpp" +using namespace weaveffi; + +static void check(bool ok, const char* what) { + if (!ok) { + std::fprintf(stderr, "cpp/events: FAIL: %s\n", what); + std::exit(1); + } +} + +// State outlives the subscriber so the test can inspect it after the +// producer has freed the implementation. +struct SubState { + std::vector routed; + std::vector received; + int attached = 0; + int64_t attached_count_seen = -1; + const weaveffi_events_EventBus* attached_handle = nullptr; + std::optional kept_bus; + int freed = 0; +}; + +class RecordingSubscriber : public Subscriber { + std::shared_ptr state_; + std::string skip_topic_; + std::string stop_topic_; + bool keep_bus_; + +public: + RecordingSubscriber(std::shared_ptr state, std::string skip_topic, + std::string stop_topic, bool keep_bus) + : state_(std::move(state)), + skip_topic_(std::move(skip_topic)), + stop_topic_(std::move(stop_topic)), + keep_bus_(keep_bus) {} + + ~RecordingSubscriber() override { state_->freed++; } + + Delivery route(const std::string& topic) override { + state_->routed.push_back(topic); + if (topic == skip_topic_) return Delivery::Skip; + if (topic == stop_topic_) return Delivery::AcceptAndStop; + return Delivery::Accept; + } + + int64_t on_message(const Message& message) override { + state_->received.push_back(message); + return static_cast(state_->received.size()); + } + + void on_attached(EventBus bus) override { + state_->attached++; + state_->attached_handle = bus.handle(); + // The wrapper is fully usable inside the callback; `subscribe` calls + // `on_attached` before it pushes the subscriber, so the count is the + // pre-subscribe value. + state_->attached_count_seen = bus.subscriber_count(); + if (keep_bus_) state_->kept_bus = std::move(bus); + // Otherwise `bus` goes out of scope here and releases its reference. + } +}; + +// Throws from `route` for one topic and from `on_message` for one text. +class ThrowingSubscriber : public Subscriber { + std::string route_bomb_; + std::string message_bomb_; + +public: + ThrowingSubscriber(std::string route_bomb, std::string message_bomb) + : route_bomb_(std::move(route_bomb)), message_bomb_(std::move(message_bomb)) {} + + Delivery route(const std::string& topic) override { + if (topic == route_bomb_) throw std::runtime_error("route rejected " + topic); + return Delivery::Accept; + } + + int64_t on_message(const Message& message) override { + if (message.text == message_bomb_) throw std::logic_error("on_message exploded"); + return 1; + } + + void on_attached(EventBus) override {} +}; + int main() { - std::vector received; - uint64_t sub = weaveffi::events::register_message_listener( - [&received](std::string message) { received.push_back(std::move(message)); }); - assert(sub > 0); - - weaveffi::events::send_message("alpha"); - weaveffi::events::send_message("beta"); - assert(received.size() == 2); - assert(received[0] == "alpha"); - assert(received[1] == "beta"); - - // The lazy range materializes through iteration: one producer `next` per - // step, handle destroyed on exhaustion. - std::vector msgs; - for (auto&& m : weaveffi::events::get_messages()) msgs.push_back(m); - assert(msgs.size() == 2); - assert(msgs[0] == "alpha"); - assert(msgs[1] == "beta"); - - // Unregister stops delivery; the producer still records the message. - weaveffi::events::unregister_message_listener(sub); - weaveffi::events::send_message("gamma"); - assert(received.size() == 2); - msgs.clear(); - for (auto&& m : weaveffi::events::get_messages()) msgs.push_back(m); - assert(msgs.size() == 3); - - // Abandoning the range early destroys the handle via RAII. - { - auto range = weaveffi::events::get_messages(); + check_abi_version(); + + EventBus bus; + check(bus.handle() != nullptr, "constructor yields a live handle"); + check(bus.subscriber_count() == 0, "fresh bus has no subscribers"); + check(!bus.last_message().has_value(), "fresh bus has no last message"); + { + int n = 0; + for (auto&& m : bus.messages()) { + (void)m; + n++; + } + check(n == 0, "fresh bus streams no messages"); + } + + // Copy semantics: a copy shares the object (same pointer), both usable. + { + EventBus copy = bus; + check(copy.handle() == bus.handle(), "copy constructor clones the same object"); + check(copy.subscriber_count() == 0, "copy is usable"); + EventBus assigned; + check(assigned.handle() != bus.handle(), "a second constructor makes a distinct object"); + assigned = bus; + check(assigned.handle() == bus.handle(), "copy assignment clones the same object"); + EventBus& same = assigned; + assigned = same; + check(assigned.handle() == bus.handle(), "self-assignment is a no-op"); + EventBus moved = std::move(copy); + check(moved.handle() == bus.handle(), "move constructor transfers the handle"); + check(copy.handle() == nullptr, "moved-from wrapper is empty"); + // All three destruct here: two releases plus a harmless empty one. + } + check(bus.subscriber_count() == 0, "bus survives its copies' destructors"); + + // Subscribers: A skips "quiet", B stops on "stop", C accepts everything. + auto a_state = std::make_shared(); + auto b_state = std::make_shared(); + auto c_state = std::make_shared(); + check(bus.subscribe(std::make_shared(a_state, "quiet", "", true)) == 1, + "subscribe returns 1"); + check(a_state->attached == 1, "on_attached fired once for A"); + check(a_state->attached_handle == bus.handle(), "on_attached received the same bus object"); + check(a_state->attached_count_seen == 0, "on_attached ran before the subscriber was retained"); + check(a_state->kept_bus.has_value(), "A kept the bus handed to it"); + check(a_state->kept_bus->subscriber_count() == 1, "the kept bus is usable after the callback"); + + check(bus.subscribe(std::make_shared(b_state, "", "stop", false)) == 2, + "subscribe returns 2"); + check(b_state->attached == 1 && b_state->attached_count_seen == 1, "B attached second"); + check(bus.subscribe(std::make_shared(c_state, "", "", false)) == 3, + "subscribe returns 3"); + check(bus.subscriber_count() == 3, "subscriber_count is 3"); + check(a_state->freed == 0 && b_state->freed == 0 && c_state->freed == 0, + "retained subscribers are not freed"); + + // Everyone accepts. + check(bus.publish("news", "hello", {"a", "b"}) == 3, "publish delivers to all three"); + check(a_state->received.size() == 1 && b_state->received.size() == 1 && + c_state->received.size() == 1, + "each subscriber received one message"); + { + const Message& m = a_state->received[0]; + check(m.seq == 1, "first message has seq 1"); + check(m.topic == "news", "message topic"); + check(m.text == "hello", "message text"); + check(m.tags.size() == 2 && m.tags[0] == "a" && m.tags[1] == "b", "message tags"); + } + check(a_state->routed.size() == 1 && a_state->routed[0] == "news", "route saw the topic"); + + // A skips "quiet". + check(bus.publish("quiet", "psst", {}) == 2, "Skip lowers the accepted count"); + check(a_state->received.size() == 1, "A was skipped"); + check(a_state->routed.size() == 2 && a_state->routed[1] == "quiet", "A was still asked"); + check(b_state->received.size() == 2 && b_state->received[1].seq == 2, "B got seq 2"); + check(c_state->received[1].tags.empty(), "empty tags list round-trips"); + + // B stops the chain, so C is neither asked nor delivered. + check(bus.publish("stop", "last", {"x"}) == 2, "AcceptAndStop halts later subscribers"); + check(a_state->received.size() == 2, "A accepted before the stop"); + check(b_state->received.size() == 3, "B accepted with stop"); + check(c_state->received.size() == 2, "C did not receive after the stop"); + check(c_state->routed.size() == 2, "C was not asked after the stop"); + + // Iterator: one producer `next` per step, in publish order. + { + std::vector texts; + for (auto&& t : bus.messages()) texts.push_back(t); + check(texts.size() == 3, "messages streams three texts"); + check(texts[0] == "hello" && texts[1] == "psst" && texts[2] == "last", + "messages are in publish order"); + } + // Abandoning the range early destroys the producer iterator via RAII. + { + auto range = bus.messages(); auto it = range.begin(); - assert(*it == "alpha"); + check(*it == "hello", "early-abandoned range yields the first element"); + } + // Manual `next()` drains and then reports exhaustion. + { + auto range = bus.messages(); + int n = 0; + while (range.next().has_value()) n++; + check(n == 3, "manual next() yields three elements"); + check(!range.next().has_value(), "exhausted range stays exhausted"); + } + + // Optional record return. + { + std::optional last = bus.last_message(); + check(last.has_value(), "last_message is present"); + check(last->seq == 3 && last->topic == "stop" && last->text == "last", + "last_message is the third message"); + check(last->tags.size() == 1 && last->tags[0] == "x", "last_message tags"); } + // Async: settled from a producer thread; subscribers run there too. + { + std::future pending = bus.publish_later("later", "async"); + check(pending.get() == 3, "publish_later resolves with the accepted count"); + check(a_state->received.size() == 3 && a_state->received[2].seq == 4, + "A received the async message"); + check(b_state->received.size() == 4 && b_state->received[3].text == "async", + "B received the async message"); + check(c_state->received.size() == 3 && c_state->received[2].topic == "later", + "C received the async message"); + std::optional last = bus.last_message(); + check(last.has_value() && last->seq == 4 && last->tags.empty(), + "async publish is logged with empty tags"); + } + + // Free function taking a callback interface: routes without retaining. + { + auto tmp_state = std::make_shared(); + auto tmp = std::make_shared(tmp_state, "quiet", "stop", false); + std::weak_ptr weak = tmp; + check(events::route_once(std::move(tmp), "quiet") == Delivery::Skip, "route_once Skip"); + check(weak.expired() && tmp_state->freed == 1, + "route_once released the subscriber when the call returned"); + tmp_state = std::make_shared(); + check(events::route_once(std::make_shared(tmp_state, "", "stop", false), + "stop") == Delivery::AcceptAndStop, + "route_once AcceptAndStop"); + check(events::route_once(std::make_shared(tmp_state, "", "", false), + "other") == Delivery::Accept, + "route_once Accept"); + check(tmp_state->attached == 0, "route_once never calls on_attached"); + } + + // A null callback interface is rejected before crossing the ABI. + { + bool threw = false; + try { + bus.subscribe(nullptr); + } catch (const std::invalid_argument&) { + threw = true; + } + check(threw, "null subscriber throws std::invalid_argument"); + check(bus.subscriber_count() == 3, "null subscribe left the bus unchanged"); + } + + // Foreign errors: a throwing implementation aborts the call with -4. + { + EventBus bus2; + auto ok_state = std::make_shared(); + check(bus2.subscribe(std::make_shared(ok_state, "", "", false)) == 1, + "bus2 first subscriber"); + check(bus2.subscribe(std::make_shared("boom", "explode")) == 2, + "bus2 throwing subscriber"); + + bool caught = false; + try { + bus2.publish("boom", "x", {}); + } catch (const WeaveFFIError& e) { + caught = true; + check(e.code() == -4, "route exception maps to FOREIGN_ERROR_CODE (-4)"); + check(std::string(e.what()).find("route rejected boom") != std::string::npos, + "foreign error carries the C++ exception message"); + } + check(caught, "publish threw for a throwing route"); + check(ok_state->received.size() == 1, "earlier subscriber was delivered before the abort"); + + caught = false; + try { + bus2.publish("fine", "explode", {}); + } catch (const WeaveFFIError& e) { + caught = true; + check(e.code() == -4, "on_message exception maps to -4"); + check(std::string(e.what()).find("on_message exploded") != std::string::npos, + "on_message foreign error message"); + } + check(caught, "publish threw for a throwing on_message"); + + // The bus is still usable afterward, and the log kept both messages. + check(bus2.publish("ok", "y", {}) == 2, "bus is usable after a foreign error"); + std::optional last = bus2.last_message(); + check(last.has_value() && last->seq == 3, "aborted publishes were still logged"); + + // The async path surfaces the same code through the future. + caught = false; + try { + bus2.publish_later("boom", "z").get(); + } catch (const WeaveFFIError& e) { + caught = true; + check(e.code() == -4, "async foreign error maps to -4"); + } + check(caught, "publish_later future rethrows the foreign error"); + + // route_once with a throwing subscriber. + caught = false; + try { + events::route_once(std::make_shared("boom", ""), "boom"); + } catch (const WeaveFFIError& e) { + caught = (e.code() == -4); + } + check(caught, "route_once surfaces the foreign error"); + + bus2.clear_subscribers(); + check(ok_state->freed == 1, "clearing bus2 freed its subscribers"); + } + + // Releasing: clear_subscribers drops the producer's references, which + // runs every implementation's destructor (A's releases its kept bus). + bus.clear_subscribers(); + check(bus.subscriber_count() == 0, "clear_subscribers empties the bus"); + check(a_state->freed == 1 && b_state->freed == 1 && c_state->freed == 1, + "free ran exactly once per subscriber"); + // The wrapper A stashed in its state still owns a reference and is + // usable; dropping it releases that reference. + check(a_state->kept_bus.has_value() && a_state->kept_bus->handle() == bus.handle(), + "kept bus still points at the bus"); + check(a_state->kept_bus->subscriber_count() == 0, "kept bus is usable after free"); + a_state->kept_bus.reset(); + check(bus.publish("after", "clear", {}) == 0, "publishing with no subscribers accepts none"); + check(bus.last_message()->seq == 5, "log kept counting"); + + // `bus` releases the last reference when main returns. std::printf("cpp/events: OK\n"); return 0; } diff --git a/conformance/cpp/kvstore.cpp b/conformance/cpp/kvstore.cpp index 54f5b42e..e09a17ea 100644 --- a/conformance/cpp/kvstore.cpp +++ b/conformance/cpp/kvstore.cpp @@ -1,195 +1,408 @@ -// Conformance consumer: kvstore sample, C++ target. +// Conformance consumer: kvstore sample, C++ target (ABI revision 2). // -// Drives the generated header-only wrappers for the value-type surface + -// typed error surface: `Store` is a move-only RAII interface class -// constructed via the `Store::open` static factory (destroy runs in the -// destructor) and `Store::default_capacity` is a static member, while `Entry` -// and `Stats` are plain value structs decoded from value buffers (with the -// optional, list, and map fields riding inside the same buffer). Throwing -// wrappers surface the `KvError` domain hierarchy (`KeyNotFoundError`, -// `IoError`, ...), including from the std::future-backed async `compact`. -// Also keeps the pre-overhaul coverage: list/map fields round-tripping -// through the generated pack/unpack pair, the iterator-backed `list_keys`, -// the `kv.stats` nested-module wrapper, and the std::function eviction -// listener. Aborts (non-zero) on any failed assertion. - -#include +// Drives the generated header-only wrapper over the object-graph surface: +// - `Store` is a copyable RAII interface wrapper (copies `_clone`, moves +// transfer, destructors release) constructed via the `Store::open` +// static factory, which throws the typed `IoError` on failure. +// - `share()` returns a wrapper to the SAME producer object; `fork()` a new +// one; `larger(std::optional)` exercises `Store?` both ways; +// `describe()` returns a record carrying a `Store` field and an optional +// one; `open_many` returns a vector of objects; `total_count` takes a +// vector of objects plus an optional record holding an object. +// - `EvictionListener` is an abstract class the consumer subclasses; a +// returned `false` detaches (and frees) it, replacing or clearing frees +// the previous one, and an implementation that throws surfaces to the +// caller as `WeaveFFIError` code -4. +// - the pre-existing surface still works: value records with optional, +// list, map, and bytes fields, the `KvError` hierarchy, the lazy +// `list_keys` range, the `kv::stats` nested module, and the +// std::future-backed cancellable `compact`. +// Exits non-zero on the first failed check. + #include +#include #include +#include #include +#include #include -#include +#include #include #include "weaveffi.hpp" using namespace kvstore; +static void check(bool ok, const char* what) { + if (!ok) { + std::fprintf(stderr, "cpp/kvstore: FAIL: %s\n", what); + std::exit(1); + } +} + +// State outlives the listener so the test can inspect it after the producer +// has freed the implementation. +struct ListenerState { + std::vector> evictions; + std::vector ids; + int freed = 0; +}; + +class RecordingListener : public EvictionListener { + std::shared_ptr state_; + size_t keep_while_fewer_than_; + std::string bomb_key_; + +public: + RecordingListener(std::shared_ptr state, size_t keep_while_fewer_than, + std::string bomb_key = "") + : state_(std::move(state)), + keep_while_fewer_than_(keep_while_fewer_than), + bomb_key_(std::move(bomb_key)) {} + + ~RecordingListener() override { state_->freed++; } + + bool on_evict(const Entry& entry, EvictionReason reason) override { + if (entry.key == bomb_key_) throw std::runtime_error("listener refused " + entry.key); + state_->evictions.emplace_back(entry.key, reason); + state_->ids.push_back(entry.id); + return state_->evictions.size() < keep_while_fewer_than_; + } +}; + int main() { - // Static member on the interface class. - assert(Store::default_capacity() == 1000000); + check_abi_version(); + + check(Store::default_capacity() == 1000000, "static default_capacity"); - // Constructor `open` is not named `new`, so it maps to a static factory. Store store = Store::open("/tmp/conformance-kvstore-cpp"); + check(store.handle() != nullptr, "open yields a live handle"); - // A failing constructor throws the typed domain exception. - bool caught_open = false; - try { - Store bad = Store::open(""); - } catch (const IoError& e) { - caught_open = (e.code() == 1004); + // A failing constructor throws the most-derived typed exception. + { + bool caught = false; + try { + Store bad = Store::open(""); + } catch (const IoError& e) { + caught = (e.code() == 1004); + check(dynamic_cast(&e) != nullptr, "IoError is a KvError"); + check(dynamic_cast(&e) != nullptr, "IoError is a WeaveFFIError"); + } + check(caught, "open(\"\") throws IoError 1004"); } - assert(caught_open); const std::vector payload{1, 2, 3}; - assert(store.put("alpha", payload, EntryKind::Persistent, std::nullopt)); - assert(store.put("beta", payload, EntryKind::Volatile, std::nullopt)); - assert(store.count() == 2); - - // Iterator-backed listing: a lazy single-pass range, drained into a - // vector here (one producer `next` per step). - std::vector keys; - for (auto&& k : store.list_keys(std::nullopt)) keys.push_back(k); - assert(keys.size() == 2); - bool saw_alpha = false, saw_beta = false; - for (const auto& k : keys) { - if (k == "alpha") saw_alpha = true; - if (k == "beta") saw_beta = true; - } - assert(saw_alpha && saw_beta); - - // A present key comes back as an engaged optional holding the decoded - // value struct; the optional, list, and map fields decode with it (empty - // here: the producer stores no TTL, tags, or metadata on put). - std::optional found = store.get("alpha"); - assert(found.has_value()); - assert(found->id > 0); - assert(found->key == "alpha"); - assert(found->value == payload); - assert(found->created_at > 0); - assert(!found->expires_at.has_value()); - assert(found->tags.empty()); - assert(found->metadata.empty()); - - // A put with a TTL surfaces as a present optional field on read. - assert(store.put("ttl", payload, EntryKind::Volatile, 3600)); - std::optional ttl_entry = store.get("ttl"); - assert(ttl_entry.has_value()); - assert(ttl_entry->expires_at.has_value()); - assert(*ttl_entry->expires_at > ttl_entry->created_at); - assert(store.delete_("ttl")); - - // A missing key throws the per-code subclass; catching the domain base - // proves the hierarchy (KeyNotFoundError -> KvError -> WeaveFFIError). - bool caught_missing = false; - try { - store.get("missing"); - } catch (const KvError& e) { - caught_missing = (e.code() == 1001); - } - assert(caught_missing); - - // The most-derived type is the per-code class. - bool caught_typed = false; - try { - store.get("missing"); - } catch (const KeyNotFoundError&) { - caught_typed = true; - } - assert(caught_typed); - - // The sample declares no Entry-typed parameter, so drive the generated - // pack/unpack pair directly: a [string] list and a {string:string} map - // encode *in* and decode back *out* of one value buffer (the coverage the - // builder + getters used to give). - Entry entry{7, - "alpha", - payload, - 1000, - std::nullopt, - {"hot", "fast"}, - {{"source", "test"}, {"env", "prod"}}}; - detail::BufferWriter w; - detail::write_Entry(w, entry); - detail::BufferReader r(w.data(), w.size()); - Entry back = detail::read_Entry(r); - r.expect_end(); - assert(back.id == 7); - assert(back.key == "alpha"); - assert(back.value == payload); - assert(back.created_at == 1000); - assert(!back.expires_at.has_value()); - - assert(back.tags.size() == 2); - bool saw_hot = false, saw_fast = false; - for (const auto& t : back.tags) { - if (t == "hot") saw_hot = true; - if (t == "fast") saw_fast = true; - } - assert(saw_hot && saw_fast); - - assert(back.metadata.size() == 2); - assert(back.metadata.at("source") == "test"); - assert(back.metadata.at("env") == "prod"); - - // Empty list and map fields round-trip as empty (the zero-count branch). - Entry empty{8, "k", payload, 1, std::nullopt, {}, {}}; - detail::BufferWriter we; - detail::write_Entry(we, empty); - detail::BufferReader re(we.data(), we.size()); - Entry empty_back = detail::read_Entry(re); - re.expect_end(); - assert(empty_back.tags.empty()); - assert(empty_back.metadata.empty()); - - // kv.stats nested-module wrapper takes the interface by const reference - // and returns the Stats value struct. - Stats st = kv::stats::get_stats(store); - assert(st.total_entries == 2); - assert(st.total_bytes == 6); - - // Eviction listener: delete fires the std::function trampoline - // synchronously on the calling thread. `delete` is a C++ keyword, so the - // method is escaped to `delete_`. - std::vector evicted; - uint64_t sub = kv::register_eviction_listener( - [&evicted](std::string key) { evicted.push_back(std::move(key)); }); - assert(sub > 0); - assert(store.delete_("beta")); - assert(evicted.size() == 1 && evicted[0] == "beta"); - - // Unregister stops delivery. - kv::unregister_eviction_listener(sub); - assert(store.delete_("alpha")); - assert(evicted.size() == 1); - - // Async: an immediately-expired entry gives compact 3 bytes to reclaim; - // the std::future is settled from the producer's worker thread. - assert(store.put("doomed", payload, EntryKind::Volatile, 0)); - std::future pending = store.compact(); - assert(pending.get() == 3); - assert(store.count() == 0); - - // A pre-cancelled compact settles the future with the typed exception. - weaveffi_cancel_token* token = weaveffi_cancel_token_create(); - weaveffi_cancel_token_cancel(token); - std::future cancelled = store.compact(token); - bool caught_async = false; - try { - cancelled.get(); - } catch (const IoError& e) { - caught_async = (e.code() == 1004); - } - assert(caught_async); - weaveffi_cancel_token_destroy(token); - - // Non-throwing method (clear declares no error domain) still works. - assert(store.put("last", payload, EntryKind::Volatile, std::nullopt)); + check(store.put("alpha", payload, EntryKind::Persistent, std::nullopt), "put alpha"); + check(store.put("beta", payload, EntryKind::Volatile, std::nullopt), "put beta"); + check(store.count() == 2, "count is 2"); + + // Copy semantics: copies share the object; mutations through one are + // visible through the other; destructors release without killing it. + { + Store copy = store; + check(copy.handle() == store.handle(), "copy constructor clones the same object"); + check(copy.count() == 2, "copy is usable"); + check(copy.put("gamma", payload, EntryKind::Volatile, std::nullopt), "put through copy"); + check(store.count() == 3, "mutation through the copy is visible through the original"); + + Store other = Store::open("/tmp/other"); + check(other.handle() != store.handle(), "a second open is a distinct object"); + other = store; + check(other.handle() == store.handle(), "copy assignment clones the same object"); + Store& same = other; + other = same; + check(other.handle() == store.handle(), "self-assignment keeps the handle"); + + Store moved = std::move(copy); + check(moved.handle() == store.handle(), "move constructor transfers the handle"); + check(copy.handle() == nullptr, "moved-from wrapper is empty"); + Store moved_into = Store::open("/tmp/x"); + moved_into = std::move(other); + check(moved_into.handle() == store.handle() && other.handle() == nullptr, + "move assignment transfers the handle"); + check(store.delete_("gamma"), "delete gamma through the original"); + // copy (empty), other (empty), moved, moved_into all destruct here. + } + check(store.count() == 2, "store survives its copies' destructors"); + + // share(): the same object again (refcount bump), not a snapshot. + { + Store shared = store.share(); + check(shared.handle() == store.handle(), "share returns the same object"); + check(shared.put("via-share", payload, EntryKind::Volatile, std::nullopt), + "put through share"); + check(store.count() == 3, "mutation through share is visible through the original"); + check(store.delete_("via-share"), "delete via original"); + check(shared.count() == 2, "deletion visible through share"); + } + + // fork(): a distinct object with a copy of the entries. + { + Store forked = store.fork(); + check(forked.handle() != store.handle(), "fork is a distinct object"); + check(forked.count() == 2, "fork copied the live entries"); + check(forked.put("only-in-fork", payload, EntryKind::Volatile, std::nullopt), + "put into fork"); + check(forked.count() == 3 && store.count() == 2, "fork is independent"); + } + + // larger(): `Store?` as a parameter and as a return. + { + Store empty = Store::open("/tmp/empty"); + check(!empty.larger(std::nullopt).has_value(), "larger(null) on an empty store is none"); + std::optional own = store.larger(std::nullopt); + check(own.has_value() && own->handle() == store.handle(), + "larger(null) on a non-empty store returns itself"); + std::optional bigger = empty.larger(store); + check(bigger.has_value() && bigger->handle() == store.handle(), + "larger(other) picks the bigger other"); + std::optional self_wins = store.larger(empty); + check(self_wins.has_value() && self_wins->handle() == store.handle(), + "larger(smaller) returns self"); + std::optional none; + check(!empty.larger(none).has_value(), "disengaged optional passes as null"); + } + + // describe(): a record carrying the object itself plus an optional one. + { + StoreInfo info = store.describe("primary", std::nullopt); + check(info.label == "primary", "describe label"); + check(info.count == 2, "describe count"); + check(info.store.handle() == store.handle(), "describe().store is the same object"); + check(!info.mirror.has_value(), "describe mirror absent"); + check(info.store.count() == 2, "the record's store is usable"); + + Store mirror = Store::open("/tmp/mirror"); + StoreInfo with_mirror = store.describe("mirrored", mirror); + check(with_mirror.mirror.has_value() && with_mirror.mirror->handle() == mirror.handle(), + "describe mirror present and identical"); + + // A record copy clones its object fields. + StoreInfo info_copy = info; + check(info_copy.store.handle() == store.handle(), "copied record shares the object"); + } + + // open_many(): a list of objects as a return; total_count(): a list of + // objects and an optional record holding an object as parameters. + { + std::vector many = Store::open_many({"/a", "/b", "/c"}); + check(many.size() == 3, "open_many returns three stores"); + check(many[0].handle() != many[1].handle() && many[1].handle() != many[2].handle(), + "open_many stores are distinct"); + check(many[0].put("m0", payload, EntryKind::Volatile, std::nullopt), "put into many[0]"); + check(many[0].put("m1", payload, EntryKind::Volatile, std::nullopt), "put into many[0]"); + check(many[2].put("m2", payload, EntryKind::Volatile, std::nullopt), "put into many[2]"); + check(many[0].count() == 2 && many[1].count() == 0 && many[2].count() == 1, + "open_many stores are independent"); + + check(Store::total_count(many, std::nullopt) == 3, "total_count over the list"); + StoreInfo info = store.describe("extra", std::nullopt); + check(Store::total_count(many, info) == 5, "total_count adds the record's store"); + check(Store::total_count({}, info) == 2, "total_count with an empty list"); + check(Store::total_count({}, std::nullopt) == 0, "total_count with nothing"); + // Encoding the parameters cloned each object; every wrapper is still + // valid and holds its own reference. + check(many[0].count() == 2 && info.store.count() == 2, + "wrappers survive being encoded into parameter buffers"); + + bool caught = false; + try { + Store::open_many({"/ok", ""}); + } catch (const IoError& e) { + caught = (e.code() == 1004); + } + check(caught, "open_many with an empty path throws IoError"); + } + + // Lazy iterator: sorted keys, one producer `next` per step. + { + std::vector keys; + for (auto&& k : store.list_keys(std::nullopt)) keys.push_back(k); + check(keys.size() == 2 && keys[0] == "alpha" && keys[1] == "beta", + "list_keys yields sorted keys"); + keys.clear(); + for (auto&& k : store.list_keys(std::string("al"))) keys.push_back(k); + check(keys.size() == 1 && keys[0] == "alpha", "list_keys honors the prefix"); + auto range = store.list_keys(std::nullopt); + auto it = range.begin(); + check(*it == "alpha", "early-abandoned range yields the first key"); + } + + // Value record with optional, list, map, and bytes fields. + { + std::optional found = store.get("alpha"); + check(found.has_value(), "get alpha present"); + check(found->id > 0 && found->key == "alpha" && found->value == payload, + "entry fields"); + check(found->created_at > 0 && !found->expires_at.has_value(), "entry timestamps"); + check(found->tags.empty() && found->metadata.empty(), "entry collections empty"); + + check(store.put("ttl", payload, EntryKind::Volatile, 3600), "put with ttl"); + std::optional ttl_entry = store.get("ttl"); + check(ttl_entry.has_value() && ttl_entry->expires_at.has_value() && + *ttl_entry->expires_at > ttl_entry->created_at, + "ttl surfaces as a present optional"); + check(store.delete_("ttl"), "delete ttl"); + check(!store.delete_("ttl"), "second delete reports false"); + + // Drive the generated pack/unpack pair directly for the list and map + // fields the sample never accepts as a parameter. + Entry entry{7, "k", payload, 1000, 55, {"hot", "fast"}, {{"source", "test"}, {"env", "prod"}}}; + detail::BufferWriter w; + detail::write_Entry(w, entry); + detail::BufferReader r(w.data(), w.size()); + Entry back = detail::read_Entry(r); + r.expect_end(); + check(back.id == 7 && back.key == "k" && back.value == payload && back.created_at == 1000, + "entry scalar fields round-trip"); + check(back.expires_at.has_value() && *back.expires_at == 55, "entry optional round-trips"); + check(back.tags.size() == 2 && back.tags[0] == "hot" && back.tags[1] == "fast", + "entry list round-trips in order"); + check(back.metadata.size() == 2 && back.metadata.at("source") == "test" && + back.metadata.at("env") == "prod", + "entry map round-trips"); + } + + // Typed errors: the per-code subclass is the most-derived type. + { + bool caught_base = false; + try { + store.get("missing"); + } catch (const KvError& e) { + caught_base = (e.code() == 1001); + } + check(caught_base, "missing key throws KvError 1001"); + bool caught_typed = false; + try { + store.get("missing"); + } catch (const KeyNotFoundError& e) { + caught_typed = (std::string(e.what()) == "key not found"); + } + check(caught_typed, "missing key throws KeyNotFoundError with the doc message"); + } + + // kv.stats nested module takes the interface by const reference. + { + Stats st = kv::stats::get_stats(store); + check(st.total_entries == 2 && st.total_bytes == 6 && st.expired_entries == 0, + "get_stats snapshot"); + } + + // Eviction listener: delete and expiry-on-read notify it; returning + // false detaches it (the producer frees it); replacement and clear free. + { + auto state = std::make_shared(); + store.set_eviction_listener(std::make_shared(state, 2)); + check(state->freed == 0, "attached listener is retained"); + + check(store.put("evict-me", payload, EntryKind::Volatile, std::nullopt), "put evict-me"); + std::optional to_evict = store.get("evict-me"); + check(store.delete_("evict-me"), "delete evict-me"); + check(state->evictions.size() == 1 && state->evictions[0].first == "evict-me" && + state->evictions[0].second == EvictionReason::Deleted, + "delete notified the listener with Deleted"); + check(to_evict.has_value() && state->ids[0] == to_evict->id, + "the evicted entry carries its id"); + + check(store.put("expiring", payload, EntryKind::Volatile, -1), "put already expired"); + bool caught = false; + try { + store.get("expiring"); + } catch (const ExpiredError& e) { + caught = (e.code() == 1002); + } + check(caught, "reading an expired entry throws ExpiredError 1002"); + check(state->evictions.size() == 2 && state->evictions[1].first == "expiring" && + state->evictions[1].second == EvictionReason::Expired, + "expiry on read notified the listener with Expired"); + // The second eviction returned false, so the store detached and + // freed the listener; a third eviction is not observed. + check(state->freed == 1, "detached listener was freed"); + check(store.put("again", payload, EntryKind::Volatile, std::nullopt), "put again"); + check(store.delete_("again"), "delete again"); + check(state->evictions.size() == 2, "detached listener is not notified"); + + auto first = std::make_shared(); + auto second = std::make_shared(); + store.set_eviction_listener(std::make_shared(first, 1000)); + check(first->freed == 0, "first listener retained"); + store.set_eviction_listener(std::make_shared(second, 1000)); + check(first->freed == 1, "replaced listener is freed"); + check(second->freed == 0, "replacement is retained"); + store.clear_eviction_listener(); + check(second->freed == 1, "cleared listener is freed"); + store.clear_eviction_listener(); + + bool threw = false; + try { + store.set_eviction_listener(nullptr); + } catch (const std::invalid_argument&) { + threw = true; + } + check(threw, "null listener throws std::invalid_argument"); + } + + // A listener that throws surfaces to the caller as a foreign error (-4) + // on a `throws` method: the generic WeaveFFIError, not a KvError. + { + auto state = std::make_shared(); + store.set_eviction_listener(std::make_shared(state, 1000, "bomb")); + check(store.put("bomb", payload, EntryKind::Volatile, std::nullopt), "put bomb"); + bool caught = false; + try { + store.delete_("bomb"); + } catch (const KvError&) { + check(false, "a foreign error must not be mapped to a domain exception"); + } catch (const WeaveFFIError& e) { + caught = true; + check(e.code() == -4, "listener exception maps to FOREIGN_ERROR_CODE (-4)"); + check(std::string(e.what()).find("listener refused bomb") != std::string::npos, + "foreign error carries the C++ exception message"); + } + check(caught, "delete threw for a throwing listener"); + check(!store.delete_("bomb"), "the entry was removed before the listener ran"); + check(state->freed == 0, "a throwing listener stays attached"); + // Still usable, and a non-bomb key is observed normally. + check(store.put("fine", payload, EntryKind::Volatile, std::nullopt), "put fine"); + check(store.delete_("fine"), "delete fine"); + check(state->evictions.size() == 1 && state->evictions[0].first == "fine", + "listener still attached after the foreign error"); + store.clear_eviction_listener(); + check(state->freed == 1, "listener freed on clear"); + } + + // Async: an immediately-expired entry gives compact 3 bytes to reclaim. + { + check(store.put("doomed", payload, EntryKind::Volatile, 0), "put doomed"); + std::future pending = store.compact(); + check(pending.get() == 3, "compact reclaimed the expired bytes"); + check(store.count() == 2, "compact left the live entries"); + + weaveffi_cancel_token* token = weaveffi_cancel_token_create(); + weaveffi_cancel_token_cancel(token); + std::future cancelled = store.compact(token); + bool caught = false; + try { + cancelled.get(); + } catch (const IoError& e) { + caught = (e.code() == 1004); + } + check(caught, "a pre-cancelled compact settles the future with IoError"); + weaveffi_cancel_token_destroy(token); + } + + // Non-throwing method still works, then release everything via RAII. store.clear(); - assert(store.count() == 0); + check(store.count() == 0, "clear empties the store"); + + // Destroying the last wrapper releases the object; a move-emptied wrapper + // destructs as a no-op. + { + Store last = std::move(store); + check(store.handle() == nullptr, "moved-from store is empty"); + check(last.count() == 0, "moved-to store is usable"); + } - // RAII: the Store destructor calls weaveffi_kv_Store_destroy when `store` - // leaves scope; no explicit close. std::printf("cpp/kvstore: OK\n"); return 0; } diff --git a/conformance/dart/codec.dart b/conformance/dart/codec.dart new file mode 100644 index 00000000..4a71c16b --- /dev/null +++ b/conformance/dart/codec.dart @@ -0,0 +1,597 @@ +// Conformance consumer: codec sample, Dart target. +// +// Round-trips every value-buffer wire shape through the producer oracle in +// both directions. `sample*` proves the Dart decoder reads exactly what Rust +// encoded (checked field by field against the canonical fixture and by +// handing the value back to `verify*`); `roundtrip*` proves the Dart encoder +// matches (the producer decodes, re-encodes, and the result is compared +// field by field); Dart-built values with edge cases (empty strings, lists, +// and maps, non-ASCII and NUL-bearing text, i64/u64 extremes, NaN, the +// infinities, negative zero, f32 rounding) cover what the fixture does not; +// `Shape` exercises every rich-enum variant; `Holder` exercises object +// tokens inside records, optionals, and lists (each encoding mints a cloned +// reference, `primaryOf` returns the same object, wrappers stay usable and +// dispose cleanly, double dispose is safe). Dart ints are signed 64-bit, so +// the generator carries `u64` as its two's-complement bit pattern: u64::MAX +// is -1. Throws (non-zero exit) on any mismatch. + +import 'dart:typed_data'; + +import 'package:__PKG__/__LIB__.dart' as wv; + +void expect(bool cond, String msg) { + if (!cond) throw StateError('assertion failed: $msg'); +} + +const int i64Min = -9223372036854775808; +const int i64Max = 9223372036854775807; +// u64::MAX and 2^63 as Dart's signed 64-bit bit patterns. +const int u64Max = -1; +const int u64High = i64Min; + +/// The value an f64 takes after passing through an f32 slot. +double f32(double v) => (Float32List(1)..[0] = v)[0]; + +bool sameDouble(double a, double b) { + if (a.isNaN || b.isNaN) return a.isNaN && b.isNaN; + return a == b && a.isNegative == b.isNegative; +} + +bool listEq(List a, List b, [bool Function(T, T)? eq]) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!(eq?.call(a[i], b[i]) ?? a[i] == b[i])) return false; + } + return true; +} + +bool scalarsEq(wv.Scalars a, wv.Scalars b) => + a.i8Value == b.i8Value && + a.u8Value == b.u8Value && + a.i16Value == b.i16Value && + a.u16Value == b.u16Value && + a.i32Value == b.i32Value && + a.u32Value == b.u32Value && + a.i64Value == b.i64Value && + a.u64Value == b.u64Value && + sameDouble(a.f32Value, b.f32Value) && + sameDouble(a.f64Value, b.f64Value) && + a.flag == b.flag && + a.color == b.color; + +bool shapeEq(wv.Shape a, wv.Shape b) { + switch ((a, b)) { + case (wv.ShapeEmpty(), wv.ShapeEmpty()): + return true; + case (wv.ShapeCircle x, wv.ShapeCircle y): + return sameDouble(x.radius, y.radius); + case (wv.ShapeRect x, wv.ShapeRect y): + return sameDouble(x.width, y.width) && sameDouble(x.height, y.height); + case (wv.ShapeLabeled x, wv.ShapeLabeled y): + return x.label == y.label && x.count == y.count; + case (wv.ShapeNested x, wv.ShapeNested y): + return scalarsEq(x.inner, y.inner) && x.note == y.note; + default: + return false; + } +} + +bool mapEq(Map a, Map b, [bool Function(V, V)? eq]) { + if (a.length != b.length) return false; + for (final e in a.entries) { + if (!b.containsKey(e.key)) return false; + final other = b[e.key] as V; + if (!(eq?.call(e.value, other) ?? e.value == other)) return false; + } + return true; +} + +bool compositeEq(wv.Composite a, wv.Composite b) => + a.name == b.name && + listEq(a.blob, b.blob) && + a.someI64 == b.someI64 && + a.noneI64 == b.noneI64 && + a.someText == b.someText && + listEq(a.names, b.names) && + listEq(a.matrix, b.matrix, (x, y) => listEq(x, y)) && + listEq(a.empty, b.empty, sameDouble) && + mapEq(a.byName, b.byName) && + mapEq(a.byId, b.byId, scalarsEq) && + scalarsEq(a.scalars, b.scalars) && + shapeEq(a.shape, b.shape) && + listEq(a.shapes, b.shapes, shapeEq) && + ((a.maybeShape == null && b.maybeShape == null) || + (a.maybeShape != null && + b.maybeShape != null && + shapeEq(a.maybeShape!, b.maybeShape!))) && + ((a.maybeList == null && b.maybeList == null) || + (a.maybeList != null && + b.maybeList != null && + listEq(a.maybeList!, b.maybeList!))) && + listEq(a.sparse, b.sparse) && + listEq(a.colors, b.colors); + +wv.Scalars canonicalScalars({bool flag = true}) => wv.Scalars( + i8Value: -8, + u8Value: 200, + i16Value: -16000, + u16Value: 60000, + i32Value: -2000000000, + u32Value: 4000000000, + i64Value: -9007199254740993, + u64Value: u64Max, + f32Value: 1.5, + f64Value: -2.25e100, + flag: flag, + color: wv.Color.blue, + ); + +wv.Composite canonicalComposite() => wv.Composite( + name: 'héllo wörld ✓', + blob: [0, 1, 2, 253, 254, 255], + someI64: i64Min, + noneI64: null, + someText: '', + names: ['a', '', 'ccc'], + matrix: >[ + [1, 2, 3], + [], + [-4] + ], + empty: [], + byName: {'one': 1, 'two': 2, 'neg': -3}, + byId: { + -1: canonicalScalars(), + 42: canonicalScalars(flag: false), + }, + scalars: canonicalScalars(), + shape: wv.ShapeLabeled('tag', 3), + shapes: [ + wv.ShapeEmpty(), + wv.ShapeCircle(2.5), + wv.ShapeRect(1.0, 0.5), + wv.ShapeLabeled('', -1), + wv.ShapeNested(canonicalScalars(), 'n'), + ], + maybeShape: wv.ShapeNested(canonicalScalars(), null), + maybeList: [9, 8], + sparse: [true, null, false], + colors: [wv.Color.red, wv.Color.green, wv.Color.blue], + ); + +void checkScalars() { + // Producer encodes, Dart decodes: every field of the canonical fixture. + final s = wv.sampleScalars(); + expect(s.i8Value == -8, 'i8 (got ${s.i8Value})'); + expect(s.u8Value == 200, 'u8 (got ${s.u8Value})'); + expect(s.i16Value == -16000, 'i16 (got ${s.i16Value})'); + expect(s.u16Value == 60000, 'u16 (got ${s.u16Value})'); + expect(s.i32Value == -2000000000, 'i32 (got ${s.i32Value})'); + expect(s.u32Value == 4000000000, 'u32 (got ${s.u32Value})'); + expect(s.i64Value == -9007199254740993, 'i64 (got ${s.i64Value})'); + expect(s.u64Value == u64Max, 'u64::MAX arrives as -1 (got ${s.u64Value})'); + expect(s.f32Value == 1.5, 'f32 (got ${s.f32Value})'); + expect(s.f64Value == -2.25e100, 'f64 (got ${s.f64Value})'); + expect(s.flag, 'flag'); + expect(s.color == wv.Color.blue, 'color blue'); + expect(scalarsEq(s, canonicalScalars()), 'sample equals the Dart fixture'); + + // Dart encodes, producer decodes. + expect(wv.verifyScalars(s), 'verifyScalars(sample)'); + expect(wv.verifyScalars(canonicalScalars()), 'verifyScalars(Dart-built)'); + expect(scalarsEq(wv.roundtripScalars(s), s), 'roundtripScalars(sample)'); + + // A mismatch is the typed domain error. + final tweaked = wv.Scalars( + i8Value: s.i8Value, + u8Value: s.u8Value, + i16Value: s.i16Value, + u16Value: s.u16Value, + i32Value: s.i32Value, + u32Value: s.u32Value, + i64Value: s.i64Value, + u64Value: s.u64Value, + f32Value: s.f32Value, + f64Value: s.f64Value, + flag: false, + color: s.color, + ); + try { + wv.verifyScalars(tweaked); + throw StateError('expected MismatchException'); + } on wv.MismatchException catch (e) { + expect(e.code == 1, 'Mismatch code == 1 (got ${e.code})'); + expect(e.message == 'value does not match the canonical fixture', + 'Mismatch message (got ${e.message})'); + expect(e is wv.CodecException && e is wv.WeaveFFIException, + 'Mismatch extends the domain and brand exceptions'); + } + + // Edge values in every slot: signed and unsigned extremes, u64 above 2^63, + // negative zero in f32, NaN in f64. + final edges = wv.Scalars( + i8Value: -128, + u8Value: 255, + i16Value: -32768, + u16Value: 65535, + i32Value: -2147483648, + u32Value: 4294967295, + i64Value: i64Min, + u64Value: u64High, + f32Value: -0.0, + f64Value: double.nan, + flag: false, + color: wv.Color.red, + ); + final back = wv.roundtripScalars(edges); + expect(scalarsEq(back, edges), 'roundtripScalars(edges)'); + expect(back.f32Value == 0 && back.f32Value.isNegative, 'f32 -0.0 kept'); + expect(back.f64Value.isNaN, 'f64 NaN kept'); + expect(back.u64Value == u64High, 'u64 2^63 bit pattern kept'); + final highs = wv.Scalars( + i8Value: 127, + u8Value: 0, + i16Value: 32767, + u16Value: 0, + i32Value: 2147483647, + u32Value: 0, + i64Value: i64Max, + u64Value: 0, + f32Value: f32(0.1), + f64Value: double.negativeInfinity, + flag: true, + color: wv.Color.green, + ); + final back2 = wv.roundtripScalars(highs); + expect(scalarsEq(back2, highs), 'roundtripScalars(highs)'); + expect(back2.f32Value == f32(0.1), 'f32 0.1 rounds once'); + expect(back2.f64Value == double.negativeInfinity, 'f64 -inf kept'); +} + +void checkComposite() { + final c = wv.sampleComposite(); + expect(c.name == 'héllo wörld ✓', 'name (got ${c.name})'); + expect(listEq(c.blob, [0, 1, 2, 253, 254, 255]), 'blob (got ${c.blob})'); + expect(c.someI64 == i64Min, 'someI64 == i64::MIN (got ${c.someI64})'); + expect(c.noneI64 == null, 'noneI64 absent'); + expect(c.someText == '', 'someText present and empty (got ${c.someText})'); + expect(listEq(c.names, ['a', '', 'ccc']), 'names (got ${c.names})'); + expect(c.matrix.length == 3 && + listEq(c.matrix[0], [1, 2, 3]) && + c.matrix[1].isEmpty && + listEq(c.matrix[2], [-4]), + 'matrix (got ${c.matrix})'); + expect(c.empty.isEmpty, 'empty list'); + expect(c.byName.length == 3 && + c.byName['one'] == 1 && + c.byName['two'] == 2 && + c.byName['neg'] == -3, + 'byName (got ${c.byName})'); + expect(c.byId.length == 2, 'byId size'); + expect(scalarsEq(c.byId[-1]!, canonicalScalars()), 'byId[-1]'); + expect(scalarsEq(c.byId[42]!, canonicalScalars(flag: false)), 'byId[42]'); + expect(scalarsEq(c.scalars, canonicalScalars()), 'nested scalars'); + expect(c.shape is wv.ShapeLabeled, 'shape variant'); + final labeled = c.shape as wv.ShapeLabeled; + expect(labeled.label == 'tag' && labeled.count == 3, 'shape fields'); + expect(c.shapes.length == 5, 'shapes length'); + expect(c.shapes[0] is wv.ShapeEmpty, 'shapes[0] Empty'); + expect((c.shapes[1] as wv.ShapeCircle).radius == 2.5, 'shapes[1] Circle'); + final rect = c.shapes[2] as wv.ShapeRect; + expect(rect.width == 1.0 && rect.height == 0.5, 'shapes[2] Rect'); + final lab = c.shapes[3] as wv.ShapeLabeled; + expect(lab.label == '' && lab.count == -1, 'shapes[3] Labeled'); + final nested = c.shapes[4] as wv.ShapeNested; + expect(scalarsEq(nested.inner, canonicalScalars()) && nested.note == 'n', + 'shapes[4] Nested'); + final maybe = c.maybeShape; + expect(maybe is wv.ShapeNested && maybe.note == null, + 'maybeShape Nested with absent note'); + expect(listEq(c.maybeList!, [9, 8]), 'maybeList (got ${c.maybeList})'); + expect(c.sparse.length == 3 && + c.sparse[0] == true && + c.sparse[1] == null && + c.sparse[2] == false, + 'sparse (got ${c.sparse})'); + expect(listEq(c.colors, [wv.Color.red, wv.Color.green, wv.Color.blue]), + 'colors (got ${c.colors})'); + expect(compositeEq(c, canonicalComposite()), 'sample equals the Dart fixture'); + + // Dart encodes, producer decodes and compares. + expect(wv.verifyComposite(c), 'verifyComposite(sample)'); + expect(wv.verifyComposite(canonicalComposite()), + 'verifyComposite(Dart-built)'); + expect(compositeEq(wv.roundtripComposite(c), c), 'roundtripComposite'); + final text = wv.describeComposite(c); + expect(text.contains('héllo wörld ✓') && text.contains('Labeled'), + 'describeComposite renders the value (got $text)'); + + // A one-element change deep inside is a Mismatch. + final changed = wv.Composite( + name: c.name, + blob: c.blob, + someI64: c.someI64, + noneI64: c.noneI64, + someText: c.someText, + names: c.names, + matrix: c.matrix, + empty: c.empty, + byName: c.byName, + byId: c.byId, + scalars: c.scalars, + shape: c.shape, + shapes: c.shapes, + maybeShape: c.maybeShape, + maybeList: c.maybeList, + sparse: [true, true, false], + colors: c.colors, + ); + try { + wv.verifyComposite(changed); + throw StateError('expected MismatchException for changed composite'); + } on wv.MismatchException catch (e) { + expect(e.code == 1, 'composite Mismatch code'); + } + + // A Dart-built composite full of edge cases: empty everything, NUL and + // astral-plane text, special floats, 32/64-bit extremes, absent optionals. + final edge = wv.Composite( + name: 'nul\u0000inside \u{1F600} end', + blob: [], + someI64: null, + noneI64: i64Max, + someText: null, + names: [], + matrix: >[[], [-2147483648, 2147483647]], + empty: [ + double.nan, + double.infinity, + double.negativeInfinity, + -0.0, + 5e-324, + 1.7976931348623157e308, + ], + byName: {'': i64Min, 'max': i64Max, 'z': 0}, + byId: { + 2147483647: canonicalScalars(flag: false), + -2147483648: canonicalScalars(), + 0: canonicalScalars(), + }, + scalars: canonicalScalars(), + shape: wv.ShapeEmpty(), + shapes: [], + maybeShape: null, + maybeList: null, + sparse: [null, null], + colors: [], + ); + final edgeBack = wv.roundtripComposite(edge); + expect(compositeEq(edgeBack, edge), 'roundtripComposite(edge)'); + expect(edgeBack.name.length == edge.name.length && + edgeBack.name.codeUnitAt(3) == 0, + 'NUL survives inside a length-prefixed string'); + expect(edgeBack.empty[0].isNaN, 'NaN in a list'); + expect(edgeBack.empty[3] == 0 && edgeBack.empty[3].isNegative, + 'negative zero in a list'); + expect(edgeBack.byName[''] == i64Min, 'empty-string key with i64::MIN'); + expect( + listEq(edgeBack.byId.keys.toList()..sort(), + [-2147483648, 0, 2147483647]), + 'i32 map keys (got ${edgeBack.byId.keys})'); + expect(edgeBack.maybeShape == null && edgeBack.maybeList == null, + 'absent optionals stay absent'); + expect(edgeBack.someI64 == null && edgeBack.noneI64 == i64Max, + 'optional i64 both ways'); + + // Fully empty composite. + final bare = wv.Composite( + name: '', + blob: [], + names: [], + matrix: >[], + empty: [], + byName: {}, + byId: {}, + scalars: canonicalScalars(), + shape: wv.ShapeEmpty(), + shapes: [], + sparse: [], + colors: [], + ); + expect(compositeEq(wv.roundtripComposite(bare), bare), + 'roundtripComposite(bare)'); +} + +void checkShapes() { + final variants = [ + wv.ShapeEmpty(), + wv.ShapeCircle(2.5), + wv.ShapeCircle(double.nan), + wv.ShapeCircle(-0.0), + wv.ShapeRect(1.0, 0.5), + wv.ShapeRect(f32(0.1), double.infinity), + wv.ShapeLabeled('tag', 3), + wv.ShapeLabeled('', -2147483648), + wv.ShapeLabeled('ünïcödé ✓', 2147483647), + wv.ShapeNested(canonicalScalars(), 'note'), + wv.ShapeNested(canonicalScalars(flag: false), null), + ]; + for (final v in variants) { + final back = wv.roundtripShape(v); + expect(back.runtimeType == v.runtimeType, + 'roundtripShape variant ${v.runtimeType} (got ${back.runtimeType})'); + expect(shapeEq(back, v), 'roundtripShape fields for ${v.runtimeType}'); + } + final all = wv.roundtripShapes(variants); + expect(listEq(all, variants, shapeEq), 'roundtripShapes list'); + expect(wv.roundtripShapes([]).isEmpty, 'roundtripShapes empty'); + + // describeShape renders the Rust Debug form, so the tag and payload the + // producer actually decoded are visible. + expect(wv.describeShape(wv.ShapeEmpty()) == 'Empty', + 'describe Empty (got ${wv.describeShape(wv.ShapeEmpty())})'); + expect(wv.describeShape(wv.ShapeCircle(2.5)) == 'Circle { radius: 2.5 }', + 'describe Circle (got ${wv.describeShape(wv.ShapeCircle(2.5))})'); + expect( + wv.describeShape(wv.ShapeRect(1.0, 0.5)) == + 'Rect { width: 1.0, height: 0.5 }', + 'describe Rect (got ${wv.describeShape(wv.ShapeRect(1.0, 0.5))})'); + expect( + wv.describeShape(wv.ShapeLabeled('tag', 3)) == + 'Labeled { label: "tag", count: 3 }', + 'describe Labeled (got ${wv.describeShape(wv.ShapeLabeled('tag', 3))})'); + final nestedText = wv.describeShape(wv.ShapeNested(canonicalScalars(), null)); + expect( + nestedText.startsWith('Nested { inner: Scalars {') && + nestedText.contains('u64_value: 18446744073709551615') && + nestedText.endsWith('note: None }'), + 'describe Nested shows u64::MAX from the -1 bit pattern (got $nestedText)'); +} + +void checkDirect() { + // Optionals, maps, strings, and bytes as top-level buffered or pointer + // values. + expect(wv.roundtripOptI64(null) == null, 'opt i64 null'); + expect(wv.roundtripOptI64(0) == 0, 'opt i64 zero'); + expect(wv.roundtripOptI64(i64Min) == i64Min, 'opt i64 min'); + expect(wv.roundtripOptI64(i64Max) == i64Max, 'opt i64 max'); + expect(wv.roundtripMap({}).isEmpty, 'empty map'); + final m = wv.roundtripMap({'': i64Min, 'k': -1, 'ü': i64Max}); + expect(m.length == 3 && m[''] == i64Min && m['k'] == -1 && m['ü'] == i64Max, + 'map values (got $m)'); + expect(wv.roundtripString('') == '', 'empty string'); + expect(wv.roundtripString('héllo wörld ✓ \u{1F600}') == + 'héllo wörld ✓ \u{1F600}', + 'unicode string'); + expect(wv.roundtripBytes([]).isEmpty, 'empty bytes'); + expect(listEq(wv.roundtripBytes([0, 127, 128, 255]), + [0, 127, 128, 255]), + 'bytes'); + + // 64-bit extremes by value; u64 above 2^63 travels as its bit pattern. + expect(wv.roundtripI64(i64Min) == i64Min, 'i64 min'); + expect(wv.roundtripI64(i64Max) == i64Max, 'i64 max'); + expect(wv.roundtripI64(0) == 0, 'i64 zero'); + expect(wv.roundtripU64(0) == 0, 'u64 zero'); + expect(wv.roundtripU64(i64Max) == i64Max, 'u64 2^63-1'); + expect(wv.roundtripU64(u64High) == u64High, 'u64 2^63'); + expect(wv.roundtripU64(u64Max) == u64Max, 'u64 max'); + + // Floats: NaN, infinities, negative zero, subnormal, and the largest finite. + expect(wv.roundtripF64(double.nan).isNaN, 'f64 NaN'); + expect(wv.roundtripF64(double.infinity) == double.infinity, 'f64 +inf'); + expect(wv.roundtripF64(double.negativeInfinity) == double.negativeInfinity, + 'f64 -inf'); + final negZero = wv.roundtripF64(-0.0); + expect(negZero == 0 && negZero.isNegative, 'f64 -0.0'); + expect(wv.roundtripF64(5e-324) == 5e-324, 'f64 subnormal'); + expect(wv.roundtripF64(1.7976931348623157e308) == 1.7976931348623157e308, + 'f64 max'); + expect(wv.roundtripF64(0.1) == 0.1, 'f64 0.1 exact'); + + expect(wv.roundtripBool(true) && !wv.roundtripBool(false), 'bool'); + for (final c in wv.Color.values) { + expect(wv.roundtripColor(c) == c, 'color $c'); + } + expect(wv.Color.blue.value == 7 && wv.Color.fromValue(7) == wv.Color.blue, + 'Color non-contiguous discriminant'); +} + +void checkHolder() { + // Producer-built holder: object tokens in a field, an optional, and a list + // each decode into their own wrapper holding one reference. + final h = wv.makeHolder(10, true); + expect(h.primary.value() == 10, 'primary value'); + expect(h.spare != null && h.spare!.value() == 11, 'spare value'); + expect(h.many.length == 3, 'many length'); + expect(listEq(h.many.map((t) => t.value()).toList(), [12, 13, 14]), + 'many values'); + + // Each encoding clones a fresh reference per token, so the same holder can + // be encoded repeatedly and every wrapper stays valid. + expect(wv.sumHolder(h) == 60, 'sumHolder (got ${wv.sumHolder(h)})'); + expect(wv.sumHolder(h) == 60, 'sumHolder again'); + expect(h.primary.value() == 10 && h.many[2].value() == 14, + 'wrappers alive after encoding'); + + // primaryOf returns the SAME object as h.primary: the producer compares + // Arc identity, so a holder built around the returned wrapper matches, + // while a fresh Token with the same value does not. + final p = wv.primaryOf(h); + expect(p.value() == 10, 'primaryOf value'); + final viaPrimaryOf = wv.Holder(primary: p, spare: null, many: []); + expect(wv.samePrimary(h, viaPrimaryOf), 'primaryOf is the same object'); + expect(wv.samePrimary(h, h), 'samePrimary reflexive'); + final lookalike = wv.Token(10); + final other = + wv.Holder(primary: lookalike, spare: null, many: []); + expect(!wv.samePrimary(h, other), 'equal value, different object'); + expect(wv.sumHolder(viaPrimaryOf) == 10, 'sum of a Dart-built holder'); + + // Absent optional object. + final bare = wv.makeHolder(0, false); + expect(bare.spare == null, 'spare absent'); + expect(wv.sumHolder(bare) == 0 + 2 + 3 + 4, 'sum without spare'); + expect(bare.primary.value() == 0, 'zero-valued token'); + + // Dart-built holder with Dart-created tokens, one of them repeated in every + // position: every occurrence is a separate cloned reference. + final t = wv.Token(5); + final repeated = wv.Holder(primary: t, spare: t, many: [t, t, t]); + expect(wv.sumHolder(repeated) == 25, 'repeated token summed 5 times'); + expect(wv.samePrimary(repeated, repeated), 'repeated reflexive'); + final mixed = wv.Holder( + primary: wv.Token(-1), spare: wv.Token(i64Min + 1), many: [t]); + expect(wv.sumHolder(mixed) == i64Min + 5, 'i64 extremes in tokens'); + final empty = wv.Holder(primary: wv.Token(7), many: []); + expect(wv.sumHolder(empty) == 7, 'holder with empty list'); + + // Release: dispose every wrapper, twice; disposed wrappers are refused at + // encode time (a Dart StateError, never a native double free), and + // wrappers that share an object with a disposed one stay valid. + p.dispose(); + p.dispose(); + expect(h.primary.value() == 10, 'h.primary alive after primaryOf disposed'); + try { + wv.sumHolder(viaPrimaryOf); + throw StateError('expected StateError encoding a disposed token'); + } on StateError catch (e) { + expect(e.message.contains('dispose'), 'disposed token in a buffer'); + } + t.dispose(); + t.dispose(); + try { + t.value(); + throw StateError('expected StateError after dispose'); + } on StateError catch (_) {} + for (final holder in [h, bare]) { + holder.primary.dispose(); + holder.spare?.dispose(); + for (final m in holder.many) { + m.dispose(); + m.dispose(); + } + } + lookalike.dispose(); + mixed.primary.dispose(); + mixed.spare!.dispose(); + empty.primary.dispose(); + + // Fresh objects still work after all of the above were released. + final again = wv.makeHolder(100, true); + expect(wv.sumHolder(again) == 100 + 101 + 102 + 103 + 104, 'fresh holder'); + again.primary.dispose(); + again.spare!.dispose(); + for (final m in again.many) { + m.dispose(); + } +} + +void main() { + checkScalars(); + checkComposite(); + checkShapes(); + checkDirect(); + checkHolder(); + print('dart/codec: OK'); +} diff --git a/conformance/dart/contacts.dart b/conformance/dart/contacts.dart index bca56a3a..b4ac2ec9 100644 --- a/conformance/dart/contacts.dart +++ b/conformance/dart/contacts.dart @@ -65,6 +65,14 @@ void main() { expect(e is wv.WeaveFFIException, 'NotFound extends the generic brand'); } + // Releasing the object twice is safe; using it afterward is a StateError. book.dispose(); + book.dispose(); + try { + book.count(); + throw StateError('expected StateError after dispose'); + } on StateError catch (e) { + expect(e.message.contains('dispose'), 'use after dispose message'); + } print('dart/contacts: OK'); } diff --git a/conformance/dart/events.dart b/conformance/dart/events.dart index 489c44ce..1c2aa1bb 100644 --- a/conformance/dart/events.dart +++ b/conformance/dart/events.dart @@ -1,9 +1,19 @@ // Conformance consumer: events sample, Dart target. // -// Exercises the NativeCallable listener trampoline (register -> the producer -// fires the Dart closure synchronously on send -> unregister stops delivery) -// and the opaque-iterator ABI behind getMessages. Throws (non-zero exit) on -// any mismatch. +// Drives the ABI 2 surface end to end: a consumer-implemented `Subscriber` +// callback interface (a Dart class extending the generated abstract class, +// bound through isolate-local NativeCallable vtable trampolines), the +// reference-counted `EventBus` object (constructor, methods, dispose, double +// dispose, use after dispose), an object handed *to* the consumer through a +// callback method (`onAttached` adopts a second `EventBus` wrapper to the +// same native object and keeps it alive past the original's dispose), the +// `Delivery` enum steering `publish`'s accepted count, the buffered +// `Message` record decoded inside a callback, the lazy `Iterable` iterator +// behind `messages()`, the optional `lastMessage()`, the Future-backed +// `publishLater`, and a Dart exception thrown inside a callback surfacing to +// the original caller as `WeaveFFIException` with the foreign code (-4) +// without unwinding through the native frame. Throws (non-zero exit) on any +// mismatch. import 'package:__PKG__/__LIB__.dart' as wv; @@ -11,25 +21,268 @@ void expect(bool cond, String msg) { if (!cond) throw StateError('assertion failed: $msg'); } -void main() { - final received = []; - final sub = wv.registerMessageListener(received.add); - expect(sub > 0, 'listener id positive'); +/// A subscriber that records every callback it receives. `skipTopic` is +/// routed as [wv.Delivery.skip], `stop` as [wv.Delivery.acceptAndStop], and +/// `failTopic` throws from `route`; `failOnMessage` throws from `onMessage`. +class RecordingSubscriber extends wv.Subscriber { + RecordingSubscriber(this.name, + {this.skipTopic = '', this.failTopic = '', this.failOnMessage = false}); - wv.sendMessage('alpha'); - wv.sendMessage('beta'); - expect(received.length == 2 && received[0] == 'alpha' && received[1] == 'beta', - 'listener received sends (got $received)'); + final String name; + final String skipTopic; + final String failTopic; + final bool failOnMessage; - final msgs = wv.getMessages().toList(); - expect(msgs.length == 2 && msgs[0] == 'alpha' && msgs[1] == 'beta', - 'iterator yields messages in order (got $msgs)'); + final List routed = []; + final List received = []; + int attachedCalls = 0; + int? subscribersSeenOnAttach; + wv.EventBus? adoptedBus; - // Unregister stops delivery; the producer still records the message. - wv.unregisterMessageListener(sub); - wv.sendMessage('gamma'); - expect(received.length == 2, 'no delivery after unregister (got $received)'); - expect(wv.getMessages().length == 3, 'producer kept recording'); + @override + wv.Delivery route(String topic) { + routed.add(topic); + if (topic == failTopic) throw StateError('$name rejected topic $topic'); + if (topic == skipTopic) return wv.Delivery.skip; + if (topic == 'stop') return wv.Delivery.acceptAndStop; + return wv.Delivery.accept; + } + + @override + int onMessage(wv.Message message) { + if (failOnMessage) throw ArgumentError('$name cannot take ${message.text}'); + received.add(message); + return received.length; + } + + @override + void onAttached(wv.EventBus bus) { + attachedCalls++; + // The handed-in object is live and usable right here: the producer calls + // onAttached before it appends this subscriber, so the count excludes us. + subscribersSeenOnAttach = bus.subscriberCount(); + // The reference is ours. One subscriber keeps it (released at the end of + // main); the others drop theirs right away. + if (name == 'keeper') { + adoptedBus = bus; + } else { + bus.dispose(); + } + } +} + +Future main() async { + // routeOnce: a free function taking the callback interface, whose Delivery + // return crosses back as the C enum discriminant. + final probe = RecordingSubscriber('probe', skipTopic: 'quiet'); + expect(wv.routeOnce(probe, 'quiet') == wv.Delivery.skip, 'routeOnce skip'); + expect(wv.routeOnce(probe, 'news') == wv.Delivery.accept, 'routeOnce accept'); + expect(wv.routeOnce(probe, 'stop') == wv.Delivery.acceptAndStop, + 'routeOnce acceptAndStop'); + expect(probe.routed.join(',') == 'quiet,news,stop', + 'routeOnce delivered each topic (got ${probe.routed})'); + expect(probe.attachedCalls == 0, 'routeOnce never attaches'); + + // Enum value mapping matches the producer's discriminants. + expect(wv.Delivery.accept.value == 0 && + wv.Delivery.skip.value == 1 && + wv.Delivery.acceptAndStop.value == 2, + 'Delivery discriminants'); + expect(wv.Delivery.fromValue(2) == wv.Delivery.acceptAndStop, + 'Delivery.fromValue'); + + // A fresh bus: no subscribers, no messages, no last message. + final bus = wv.EventBus(); + expect(bus.subscriberCount() == 0, 'fresh bus has no subscribers'); + expect(bus.messages().isEmpty, 'fresh bus has no messages'); + expect(bus.lastMessage() == null, 'fresh bus lastMessage is null'); + + // subscribe: onAttached fires synchronously with the bus object, and the + // return value is the new subscriber count. + final keeper = RecordingSubscriber('keeper', skipTopic: 'quiet'); + final second = RecordingSubscriber('second'); + expect(bus.subscribe(keeper) == 1, 'first subscribe returns 1'); + expect(keeper.attachedCalls == 1, 'keeper attached once'); + expect(keeper.subscribersSeenOnAttach == 0, + 'keeper saw 0 subscribers inside onAttached ' + '(got ${keeper.subscribersSeenOnAttach})'); + expect(keeper.adoptedBus != null, 'keeper adopted the bus reference'); + expect(bus.subscribe(second) == 2, 'second subscribe returns 2'); + expect(second.attachedCalls == 1, 'second attached once'); + expect(second.subscribersSeenOnAttach == 1, + 'second saw 1 subscriber inside onAttached'); + expect(bus.subscriberCount() == 2, 'subscriberCount == 2'); + + // The adopted wrapper is a second reference to the same object. + expect(keeper.adoptedBus!.subscriberCount() == 2, + 'adopted bus sees the same subscribers'); + + // publish: both accept -> 2; the Message record decodes inside onMessage + // with every field (seq, topic, text, tags list). + expect(bus.publish('news', 'hello', ['x', 'y']) == 2, + 'publish news accepted by 2'); + expect(keeper.received.length == 1 && second.received.length == 1, + 'both received news'); + final m = keeper.received.first; + expect(m.seq == 1, 'first message seq == 1 (got ${m.seq})'); + expect(m.topic == 'news', 'message topic'); + expect(m.text == 'hello', 'message text'); + expect(m.tags.length == 2 && m.tags[0] == 'x' && m.tags[1] == 'y', + 'message tags (got ${m.tags})'); + expect(second.received.first.seq == 1 && second.received.first.text == 'hello', + 'second decoded the same message'); + + // Skip: the keeper routes `quiet` as skip, so only `second` accepts. + expect(bus.publish('quiet', 'psst', []) == 1, 'publish quiet -> 1'); + expect(keeper.received.length == 1, 'keeper skipped quiet'); + expect(second.received.length == 2 && second.received.last.seq == 2, + 'second received quiet with seq 2'); + expect(second.received.last.tags.isEmpty, 'empty tags list decodes empty'); + + // AcceptAndStop: the keeper (first in line) accepts and stops delivery, so + // `second` never sees it. + expect(bus.publish('stop', 'last', []) == 1, 'publish stop -> 1'); + expect(keeper.received.length == 2 && keeper.received.last.text == 'last', + 'keeper received stop'); + expect(second.received.length == 2, 'second not reached after stop'); + expect(keeper.routed.join(',') == 'news,quiet,stop', 'keeper routing log'); + expect(second.routed.join(',') == 'news,quiet', 'second routing log'); + + // Iterator: lazy Iterable over the native iterator, in publish order; a + // second iteration launches a fresh native iterator, and an abandoned + // partial iteration is harmless. + final texts = bus.messages().toList(); + expect(texts.join('|') == 'hello|psst|last', + 'messages in order (got $texts)'); + expect(bus.messages().length == 3, 'second iteration yields 3 again'); + expect(bus.messages().first == 'hello', 'partial iteration'); + expect(bus.messages().skip(1).first == 'psst', 'skip then first'); + + // Optional record return. + final last = bus.lastMessage(); + expect(last != null, 'lastMessage present'); + expect(last!.seq == 3 && last.topic == 'stop' && last.text == 'last', + 'lastMessage is the stop message'); + + // Foreign error from route: a Dart exception inside the callback aborts the + // publish and surfaces to the caller as the generic exception carrying the + // foreign code and the exception text; the VM does not crash, and the bus + // stays usable. Earlier subscribers already accepted the message. + final rejecting = RecordingSubscriber('rejecting', failTopic: 'boom'); + expect(bus.subscribe(rejecting) == 3, 'third subscribe returns 3'); + try { + bus.publish('boom', 'x', []); + throw StateError('expected WeaveFFIException for rejected topic'); + } on wv.WeaveFFIException catch (e) { + expect(e.code == wv.WeaveFFIException.foreignCode, + 'foreign code -4 (got ${e.code})'); + expect(e.code == -4, 'foreignCode constant is -4'); + expect(e.message.contains('rejecting rejected topic boom'), + 'foreign message carries the Dart exception text (got ${e.message})'); + } + expect(keeper.received.length == 3 && second.received.length == 3, + 'subscribers ahead of the failing one still accepted boom'); + expect(rejecting.received.isEmpty, 'rejecting never received'); + expect(bus.messages().length == 4, 'the aborted publish was still logged'); + expect(bus.lastMessage()!.text == 'x', 'lastMessage is the aborted one'); + expect(bus.publish('ok', 'y', ['t']) == 3, + 'bus usable after a foreign error'); + expect(rejecting.received.length == 1 && rejecting.received.first.seq == 5, + 'rejecting received ok with seq 5'); + + // Foreign error from onMessage (a different vtable slot and exception type). + final fragile = RecordingSubscriber('fragile', failOnMessage: true); + expect(bus.subscribe(fragile) == 4, 'fourth subscribe returns 4'); + try { + bus.publish('any', 'payload', []); + throw StateError('expected WeaveFFIException for failing onMessage'); + } on wv.WeaveFFIException catch (e) { + expect(e.code == wv.WeaveFFIException.foreignCode, + 'onMessage foreign code -4 (got ${e.code})'); + expect(e.message.contains('fragile cannot take payload'), + 'onMessage foreign message (got ${e.message})'); + } + expect(fragile.routed.length == 1, 'fragile was routed once'); + expect(bus.subscriberCount() == 4, 'a failing subscriber stays subscribed'); + + // Foreign error from onAttached: subscribe itself fails and the subscriber + // is not retained. + final unattachable = FailingAttachSubscriber(); + try { + bus.subscribe(unattachable); + throw StateError('expected WeaveFFIException for failing onAttached'); + } on wv.WeaveFFIException catch (e) { + expect(e.code == -4, 'onAttached foreign code -4 (got ${e.code})'); + expect(e.message.contains('refuses to attach'), + 'onAttached foreign message (got ${e.message})'); + } + expect(bus.subscriberCount() == 4, 'failed subscribe did not retain'); + + // clearSubscribers drops every retained subscriber; publishing then + // reaches nobody but is still logged. + bus.clearSubscribers(); + expect(bus.subscriberCount() == 0, 'cleared subscribers'); + expect(bus.publish('news', 'nobody', []) == 0, + 'publish with no subscribers -> 0'); + expect(keeper.received.length == 5, + 'cleared keeper receives nothing (got ${keeper.received.length})'); + + // Async: publishLater runs on a producer thread and settles a Future. The + // generated Dart callback trampolines are isolate-local, so subscribers + // can't be driven from that thread; with none attached the call resolves + // with 0 accepted and the message is logged. + final laterCount = await bus.publishLater('later', 'zzz'); + expect(laterCount == 0, 'publishLater resolves with 0 (got $laterCount)'); + expect(bus.lastMessage()!.text == 'zzz' && bus.lastMessage()!.seq == 8, + 'publishLater logged its message (got ${bus.lastMessage()!.text})'); + expect(bus.messages().length == 8, 'eight messages logged'); + final second1 = await bus.publishLater('later', 'again'); + expect(second1 == 0 && bus.messages().length == 9, 'second publishLater'); + + // Object lifetime: dispose the original wrapper; the adopted reference + // keeps the native bus alive and fully usable. Double dispose is safe and + // use after dispose is a StateError rather than a native fault. + bus.dispose(); + bus.dispose(); + try { + bus.subscriberCount(); + throw StateError('expected StateError after dispose'); + } on StateError catch (e) { + expect(e.message.contains('dispose'), 'use after dispose message'); + } + final adopted = keeper.adoptedBus!; + expect(adopted.messages().length == 9, + 'adopted reference outlives the original wrapper'); + expect(adopted.publish('final', 'bye', []) == 0, + 'publish through the adopted reference'); + expect(adopted.lastMessage()!.text == 'bye', 'adopted lastMessage'); + adopted.dispose(); + adopted.dispose(); + + // A bus that is dropped while still holding subscribers releases them on + // its last reference (the consumer's `free` entries run through a listener + // NativeCallable once the event loop turns). + final short = wv.EventBus(); + final tenant = RecordingSubscriber('tenant'); + short.subscribe(tenant); + tenant.adoptedBus?.dispose(); + short.dispose(); + await Future.delayed(Duration.zero); print('dart/events: OK'); } + +/// A subscriber whose `onAttached` throws, so `subscribe` itself fails. +class FailingAttachSubscriber extends wv.Subscriber { + @override + wv.Delivery route(String topic) => wv.Delivery.accept; + + @override + int onMessage(wv.Message message) => 0; + + @override + void onAttached(wv.EventBus bus) { + bus.dispose(); + throw StateError('refuses to attach'); + } +} diff --git a/conformance/dart/kvstore.dart b/conformance/dart/kvstore.dart index cb8ad842..5853b7cd 100644 --- a/conformance/dart/kvstore.dart +++ b/conformance/dart/kvstore.dart @@ -4,15 +4,19 @@ // (fallible `Store.open` named factory, instance methods passing the object // pointer, the `defaultCapacity` static, the deprecated `legacyPut`), the // typed KvException hierarchy (KeyNotFoundException = 1001, -// IoException = 1004) thrown by throwing members, optional record returns -// (`Entry?`) decoded from value buffers with bytes / optional-scalar / list / -// map fields, the plain-value Entry class (list + map field construction), -// the iterator-backed `listKeys` method, the cross-module `getStats` -// (parameter typed as the parent module's Store), the -// NativeCallable.isolateLocal eviction listener (register -> fire -// synchronously on delete -> unregister), and the Future-returning `compact` -// settled through a NativeCallable.listener from the producer's worker -// thread. Throws (non-zero exit) on any mismatch. +// ExpiredException = 1002, IoException = 1004) thrown by throwing members, +// optional record returns (`Entry?`) decoded from value buffers with bytes / +// optional-scalar / list / map fields, the plain-value Entry class, the +// iterator-backed `listKeys` method, the cross-module `getStats`, the +// consumer-implemented `EvictionListener` callback interface (set, replaced, +// detached by returning false, cleared, and throwing so the caller sees the +// foreign error code -4), the object graph (`share()` as a second wrapper to +// the same native object, `fork()`, `Store?` in and out of `larger`, the +// `StoreInfo` record carrying a `Store` field plus an optional `Store`, +// `openMany` returning a list of objects, `totalCount` taking a list of +// objects and a Dart-built record holding objects), the Future-returning +// `compact`, and dispose / double dispose / use after dispose on every +// wrapper. Throws (non-zero exit) on any mismatch. import 'package:__PKG__/__LIB__.dart' as wv; @@ -20,6 +24,27 @@ void expect(bool cond, String msg) { if (!cond) throw StateError('assertion failed: $msg'); } +/// Records every eviction; returns false (detaching itself) once it has seen +/// `keepFor` evictions, and throws when `fail` is set. +class RecordingListener extends wv.EvictionListener { + RecordingListener(this.name, {this.keepFor = 1 << 30, this.fail = false}); + + final String name; + final int keepFor; + final bool fail; + final List<(wv.Entry, wv.EvictionReason)> evictions = + <(wv.Entry, wv.EvictionReason)>[]; + + @override + bool onEvict(wv.Entry entry, wv.EvictionReason reason) { + if (fail) throw StateError('$name refuses eviction of ${entry.key}'); + evictions.add((entry, reason)); + return evictions.length < keepFor; + } + + List get keys => evictions.map((e) => e.$1.key).toList(); +} + Future main() async { // Fallible constructor: an empty path reports the IoError domain code // through the typed exception hierarchy. @@ -28,6 +53,7 @@ Future main() async { throw StateError('expected IoException for empty path'); } on wv.IoException catch (e) { expect(e.code == 1004, 'IoError code == 1004 (got ${e.code})'); + expect(e.message == 'I/O failure', 'IoError message (got ${e.message})'); expect(e is wv.KvException, 'IoException extends KvException'); expect(e is wv.WeaveFFIException, 'IoException extends the generic brand'); } @@ -43,13 +69,17 @@ Future main() async { expect(store.put('beta', payload, wv.EntryKind.volatile, 3600), 'put beta'); expect(store.count() == 2, 'count == 2'); - // Iterator-backed list-of-string method, with and without the prefix. - final keys = store.listKeys(null).toList()..sort(); + // Iterator-backed list-of-string method, with and without the prefix; the + // producer yields sorted keys, and each iteration is a fresh native + // iterator. + final keys = store.listKeys(null).toList(); expect(keys.length == 2 && keys[0] == 'alpha' && keys[1] == 'beta', - 'listKeys values'); + 'listKeys values (got $keys)'); final filtered = store.listKeys('al').toList(); expect(filtered.length == 1 && filtered[0] == 'alpha', 'listKeys prefix filter'); + expect(store.listKeys('zzz').isEmpty, 'listKeys no match'); + expect(store.listKeys(null).first == 'alpha', 'abandoned partial iteration'); // Optional record return: the `Entry?` buffer decodes into a plain value // class covering every complex field type. @@ -64,21 +94,26 @@ Future main() async { expect(alpha.metadata.isEmpty, 'entry metadata empty'); final beta = store.get('beta')!; - expect(beta.expiresAt != null && beta.expiresAt! > 0, 'beta expiresAt set'); + expect(beta.expiresAt != null && beta.expiresAt! > beta.createdAt, + 'beta expiresAt set'); + expect(beta.id == alpha.id + 1, 'ids are monotonic'); // Typed error: a missing key throws the KeyNotFoundException class of the - // KvException domain, carrying its stable code. + // KvException domain, carrying its stable code and default message. try { store.get('missing'); throw StateError('expected KeyNotFoundException for missing key'); } on wv.KeyNotFoundException catch (e) { expect(e.code == 1001, 'KeyNotFound code == 1001 (got ${e.code})'); + expect(e.message == 'key not found', 'KeyNotFound message'); expect(e is wv.KvException, 'KeyNotFound extends KvException'); } // Deprecated method still works. expect(store.legacyPut('legacy', payload), 'legacy put'); + expect(store.count() == 3, 'count == 3 after legacy put'); expect(store.delete('legacy'), 'delete legacy'); + expect(!store.delete('legacy'), 'second delete returns false'); // Record construction: scalars, bytes, optional, list, and map fields on // the plain-value Entry class. @@ -100,38 +135,235 @@ Future main() async { expect(md.length == 2 && md['source'] == 'test' && md['env'] == 'prod', 'entry metadata'); - // Empty list/map fields hold as zero-length. - final empty = wv.Entry( - id: 8, - key: 'k', - value: payload, - createdAt: 1000, - expiresAt: null, - tags: [], - metadata: {}, - ); - expect(empty.metadata.isEmpty, 'empty metadata'); - expect(empty.tags.isEmpty, 'empty tags'); - // Cross-module call: getStats lives in kv.stats and takes the parent // module's Store interface as a parameter. final st = wv.getStats(store); expect(st.totalEntries == 2, 'stats total entries == 2'); + expect(st.totalBytes == 6, 'stats total bytes == 6 (got ${st.totalBytes})'); expect(st.expiredEntries == 0, 'stats expired entries == 0'); - // Eviction listener: delete fires the isolate-local NativeCallable - // synchronously on the calling thread. - final evicted = []; - final sub = wv.registerEvictionListener(evicted.add); - expect(sub > 0, 'listener id positive'); + // ── Eviction listener (callback interface) ── + // delete fires onEvict synchronously on the calling thread with the full + // Entry record and the Deleted reason. + final listener = RecordingListener('first'); + store.setEvictionListener(listener); expect(store.delete('beta'), 'delete beta'); - expect(evicted.length == 1 && evicted[0] == 'beta', - 'eviction fired for beta (got $evicted)'); + expect(listener.evictions.length == 1, 'one eviction (got ${listener.keys})'); + final (evictedBeta, betaReason) = listener.evictions.single; + expect(betaReason == wv.EvictionReason.deleted, 'reason deleted'); + expect(evictedBeta.key == 'beta', 'evicted key'); + expect(evictedBeta.id == beta.id, 'evicted id matches the stored entry'); + expect(evictedBeta.value.length == 3 && evictedBeta.value[1] == 2, + 'evicted value bytes'); + expect(evictedBeta.expiresAt == beta.expiresAt, 'evicted expiresAt'); + expect(evictedBeta.tags.isEmpty && evictedBeta.metadata.isEmpty, + 'evicted empty list and map'); - // Unregister stops delivery. - wv.unregisterEvictionListener(sub); - expect(store.delete('alpha'), 'delete alpha'); - expect(evicted.length == 1, 'no eviction after unregister (got $evicted)'); + // An expired entry is evicted on read with the Expired reason, and the + // read itself reports ExpiredException (1002). + expect(store.put('expiring', [9], wv.EntryKind.volatile, -1), + 'put expiring'); + try { + store.get('expiring'); + throw StateError('expected ExpiredException'); + } on wv.ExpiredException catch (e) { + expect(e.code == 1002, 'Expired code == 1002 (got ${e.code})'); + } + expect(listener.evictions.length == 2, 'expiry evicted'); + expect(listener.evictions.last.$1.key == 'expiring' && + listener.evictions.last.$2 == wv.EvictionReason.expired, + 'expiry eviction details'); + expect(store.count() == 1, 'only alpha remains'); + + // Replacing the listener: the new one receives, the old one does not. + final replacement = RecordingListener('replacement'); + store.setEvictionListener(replacement); + expect(store.put('r1', payload, wv.EntryKind.persistent, null), 'put r1'); + expect(store.delete('r1'), 'delete r1'); + expect(listener.evictions.length == 2, 'replaced listener is silent'); + expect(replacement.keys.join(',') == 'r1', 'replacement received r1'); + + // Clearing the listener stops notifications. + store.clearEvictionListener(); + expect(store.put('r2', payload, wv.EntryKind.persistent, null), 'put r2'); + expect(store.delete('r2'), 'delete r2'); + expect(replacement.evictions.length == 1, 'cleared listener is silent'); + store.clearEvictionListener(); + + // Returning false detaches the listener after that eviction. + final brief = RecordingListener('brief', keepFor: 2); + store.setEvictionListener(brief); + for (final k in ['d1', 'd2', 'd3']) { + expect(store.put(k, payload, wv.EntryKind.persistent, null), 'put $k'); + expect(store.delete(k), 'delete $k'); + } + expect(brief.keys.join(',') == 'd1,d2', + 'listener detached after returning false (got ${brief.keys})'); + + // A throwing listener: the Dart exception surfaces to the caller of + // `delete` as the generic exception with the foreign code (-4), not as a + // KvException; the entry was already removed, the VM keeps running, and + // the listener stays attached. + final hostile = RecordingListener('hostile', fail: true); + store.setEvictionListener(hostile); + expect(store.put('doomed', payload, wv.EntryKind.persistent, null), + 'put doomed'); + try { + store.delete('doomed'); + throw StateError('expected WeaveFFIException from throwing listener'); + } on wv.WeaveFFIException catch (e) { + expect(e is! wv.KvException, 'foreign error is not a domain error'); + expect(e.code == wv.WeaveFFIException.foreignCode && e.code == -4, + 'foreign code -4 (got ${e.code})'); + expect(e.message.contains('hostile refuses eviction of doomed'), + 'foreign message carries the exception text (got ${e.message})'); + } + expect(store.count() == 1, 'entry removed despite the listener failing'); + expect(store.put('doomed2', payload, wv.EntryKind.persistent, null), + 'put doomed2'); + try { + store.delete('doomed2'); + throw StateError('expected a second foreign error'); + } on wv.WeaveFFIException catch (e) { + expect(e.code == -4, 'listener still attached after failing'); + } + store.clearEvictionListener(); + expect(store.put('calm', payload, wv.EntryKind.persistent, null), 'put calm'); + expect(store.delete('calm'), 'delete after clearing the hostile listener'); + + // ── Object graph ── + // share() returns a second wrapper to the SAME native object: a mutation + // through one is visible through the other, and disposing one (twice) leaves + // the other usable. + final shared = store.share(); + expect(shared.count() == store.count(), 'share sees the same count'); + expect(shared.put('via-share', [4, 4], wv.EntryKind.persistent, null), + 'put through share'); + expect(store.get('via-share')!.value.length == 2, + 'mutation through share visible in the original'); + expect(store.put('via-orig', [5], wv.EntryKind.persistent, null), + 'put through original'); + expect(shared.get('via-orig')!.value.single == 5, + 'mutation through original visible in share'); + expect(store.count() == 3 && shared.count() == 3, 'counts agree'); + shared.dispose(); + shared.dispose(); + try { + shared.count(); + throw StateError('expected StateError after dispose'); + } on StateError catch (e) { + expect(e.message.contains('dispose'), 'use after dispose message'); + } + expect(store.count() == 3, 'original alive after disposing its share'); + + // fork() is a distinct object: a copy that diverges. + final forked = store.fork(); + expect(forked.count() == 3, 'fork copies live entries'); + expect(forked.put('only-fork', payload, wv.EntryKind.persistent, null), + 'put into fork'); + expect(forked.count() == 4 && store.count() == 3, 'fork diverged'); + try { + store.get('only-fork'); + throw StateError('fork mutation leaked into the original'); + } on wv.KeyNotFoundException catch (_) {} + + // larger(): `Store?` both ways. + final empty = wv.Store.open('/tmp/conformance-kvstore-dart-empty'); + expect(empty.larger(null) == null, 'empty.larger(null) is null'); + final self = store.larger(null); + expect(self != null, 'store.larger(null) present'); + expect(self!.count() == 3, 'larger(null) is the receiver'); + expect(self.put('via-larger', payload, wv.EntryKind.persistent, null), + 'put through larger(null)'); + expect(store.count() == 4, 'larger(null) aliases the receiver'); + self.dispose(); + final bigger = empty.larger(forked); + expect(bigger != null && bigger.count() == 4, 'empty.larger(fork) is fork'); + expect(bigger!.put('via-bigger', payload, wv.EntryKind.persistent, null), + 'put through larger(other)'); + expect(forked.count() == 5, 'larger(other) aliases other'); + bigger.dispose(); + final own = forked.larger(empty); + expect(own != null && own.count() == 5, 'fork.larger(empty) is fork'); + own!.dispose(); + own.dispose(); + + // describe(): a record carrying the object itself plus an optional object. + final info = store.describe('primary', null); + expect(info.label == 'primary', 'describe label'); + expect(info.count == 4, 'describe count (got ${info.count})'); + expect(info.mirror == null, 'describe mirror absent'); + expect(info.store.count() == 4, 'describe store usable'); + expect(info.store.put('via-info', payload, wv.EntryKind.persistent, null), + 'put through the record object'); + expect(store.count() == 5, 'record object aliases the store'); + final mirrored = store.describe('with-mirror', empty); + expect(mirrored.mirror != null, 'describe mirror present'); + expect(mirrored.mirror!.count() == 0, 'mirror is the empty store'); + expect(mirrored.mirror!.put('m', payload, wv.EntryKind.persistent, null), + 'put through mirror'); + expect(empty.count() == 1, 'mirror aliases the empty store'); + expect(mirrored.store.count() == 5 && mirrored.count == 5, + 'mirrored describe snapshot'); + + // openMany(): a list of objects as a return; the error path is typed. + final many = wv.Store.openMany(['/tmp/many-a', '/tmp/many-b']); + expect(many.length == 2, 'openMany returns 2 stores'); + expect(many[0].count() == 0 && many[1].count() == 0, 'fresh stores'); + expect(many[0].put('m0', payload, wv.EntryKind.persistent, null), 'put m0'); + expect(many[0].count() == 1 && many[1].count() == 0, + 'openMany stores are distinct'); + expect(wv.Store.openMany([]).isEmpty, 'openMany empty list'); + try { + wv.Store.openMany(['/tmp/ok', '']); + throw StateError('expected IoException from openMany'); + } on wv.IoException catch (e) { + expect(e.code == 1004, 'openMany IoError code'); + } + + // totalCount(): objects inside a list parameter and inside an optional + // record parameter (each written as a cloned token, so the wrappers stay + // valid afterward). The record is built in Dart from live wrappers. + expect(wv.Store.totalCount([], null) == 0, 'totalCount of nothing'); + expect(wv.Store.totalCount(many, null) == 1, 'totalCount of many'); + expect(wv.Store.totalCount([store, forked, empty], null) == 11, + 'totalCount of three stores'); + expect(wv.Store.totalCount([], info) == 5, + 'totalCount of a record only'); + expect(wv.Store.totalCount(many, mirrored) == 6, 'totalCount list + record'); + final built = wv.StoreInfo( + label: 'built', store: forked, mirror: empty, count: -1); + expect(wv.Store.totalCount(many, built) == 6, + 'totalCount with a Dart-built record holding objects'); + final builtNoMirror = wv.StoreInfo(label: 'b2', store: many[0], count: 0); + expect(wv.Store.totalCount([many[0]], builtNoMirror) == 2, + 'totalCount counts the same object twice'); + expect(store.count() == 5 && forked.count() == 5 && empty.count() == 1, + 'wrappers untouched after being encoded into buffers'); + + // Encoding a disposed wrapper into a buffer is a Dart-side StateError, not + // a native fault. + final gone = store.share(); + gone.dispose(); + try { + wv.Store.totalCount([gone], null); + throw StateError('expected StateError encoding a disposed wrapper'); + } on StateError catch (e) { + expect(e.message.contains('dispose'), 'disposed wrapper in a buffer'); + } + + // Release every object from the graph; the store itself must survive all + // of them because it still holds its own reference. + info.store.dispose(); + mirrored.store.dispose(); + mirrored.mirror!.dispose(); + mirrored.mirror!.dispose(); + for (final s in many) { + s.dispose(); + } + forked.dispose(); + empty.dispose(); + expect(store.count() == 5, 'store alive after releasing the graph'); // Async: an immediately-expired entry gives compact 3 bytes to reclaim; the // Future settles via a NativeCallable.listener message from the producer's @@ -139,8 +371,18 @@ Future main() async { expect(store.put('doomed', payload, wv.EntryKind.volatile, 0), 'put doomed'); final reclaimed = await store.compact(); expect(reclaimed == 3, 'compact reclaimed 3 bytes (got $reclaimed)'); - expect(store.count() == 0, 'store empty after deletes + compact'); + expect(store.count() == 5, 'live entries untouched by compact'); + expect((await store.compact()) == 0, 'second compact reclaims nothing'); + + store.clear(); + expect(store.count() == 0, 'clear empties the store'); store.dispose(); + store.dispose(); + try { + store.count(); + throw StateError('expected StateError after dispose'); + } on StateError catch (_) {} + print('dart/kvstore: OK'); } diff --git a/conformance/dotnet/Codec.cs b/conformance/dotnet/Codec.cs new file mode 100644 index 00000000..3b1ab23c --- /dev/null +++ b/conformance/dotnet/Codec.cs @@ -0,0 +1,421 @@ +// Conformance consumer: codec sample, .NET target. +// +// Round-trips every value-buffer wire shape through the generated P/Invoke +// wrapper (Codec.cs, namespace Codec) against the producer's oracle: the +// canonical Scalars and Composite fixtures are decoded and checked field by +// field (producer encodes, consumer decodes), handed back through Verify* +// (consumer encodes, producer decodes) and Roundtrip* (both), then rebuilt +// from scratch with edge values (empty strings, lists, and maps; unicode; +// long/ulong extremes; NaN, the infinities, and negative zero) and +// round-tripped again. Also covers every Shape variant of the rich enum, the +// typed CodecException (Mismatch=1), and the Holder record carrying Token +// objects in a required field, an optional, and a list: each encoded token is +// a fresh clone, PrimaryOf returns a wrapper to the same native object as the +// holder's Primary, and every wrapper is disposed (double Dispose safe, +// ObjectDisposedException after). The producer cdylib is resolved by absolute +// path via a DllImportResolver reading WEAVEFFI_LIBRARY. +// +// The harness compiles the generated source into this assembly, so the +// wrapper's `internal` Handle property is reachable and used to prove two +// wrappers point at the same native object. The IDL sets the .NET namespace +// to `Codec` and the module is also `codec`, so the free-function class is +// `Codec.Codec`; `using static` imports those statics. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Codec; +using static Codec.Codec; + +internal static class Program +{ + static void Expect(bool cond, string msg) + { + if (!cond) + { + Console.Error.WriteLine($"assertion failed: {msg}"); + Environment.Exit(1); + } + } + + static bool SameBits(double a, double b) + { + return BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b); + } + + static bool SameBits(float a, float b) + { + return BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b); + } + + static Scalars CanonicalScalars() + { + return new Scalars(-8, 200, -16_000, 60_000, -2_000_000_000, 4_000_000_000U, + -9_007_199_254_740_993L, ulong.MaxValue, 1.5f, -2.25e100, true, Color.Blue); + } + + static void CheckScalars(Scalars s, string what) + { + Expect(s.I8Value == -8, $"{what}: i8 (got {s.I8Value})"); + Expect(s.U8Value == 200, $"{what}: u8 (got {s.U8Value})"); + Expect(s.I16Value == -16_000, $"{what}: i16 (got {s.I16Value})"); + Expect(s.U16Value == 60_000, $"{what}: u16 (got {s.U16Value})"); + Expect(s.I32Value == -2_000_000_000, $"{what}: i32 (got {s.I32Value})"); + Expect(s.U32Value == 4_000_000_000U, $"{what}: u32 (got {s.U32Value})"); + Expect(s.I64Value == -9_007_199_254_740_993L, $"{what}: i64 (got {s.I64Value})"); + Expect(s.U64Value == ulong.MaxValue, $"{what}: u64 (got {s.U64Value})"); + Expect(s.F32Value == 1.5f, $"{what}: f32 (got {s.F32Value})"); + Expect(s.F64Value == -2.25e100, $"{what}: f64 (got {s.F64Value})"); + Expect(s.Flag, $"{what}: flag"); + Expect(s.Color == Color.Blue, $"{what}: color (got {s.Color})"); + } + + static void ExpectScalarsEqual(Scalars a, Scalars b, string what) + { + Expect(a.I8Value == b.I8Value && a.U8Value == b.U8Value + && a.I16Value == b.I16Value && a.U16Value == b.U16Value + && a.I32Value == b.I32Value && a.U32Value == b.U32Value + && a.I64Value == b.I64Value && a.U64Value == b.U64Value + && SameBits(a.F32Value, b.F32Value) && SameBits(a.F64Value, b.F64Value) + && a.Flag == b.Flag && a.Color == b.Color, + $"{what}: scalars equal"); + } + + static void ExpectShapesEqual(Shape a, Shape b, string what) + { + Expect(a.GetType() == b.GetType(), $"{what}: same variant ({a.GetType().Name} vs {b.GetType().Name})"); + switch (a) + { + case Shape.Empty _: + break; + case Shape.Circle ca: + Expect(SameBits(ca.Radius, ((Shape.Circle)b).Radius), $"{what}: circle radius"); + break; + case Shape.Rect ra: + var rb = (Shape.Rect)b; + Expect(SameBits(ra.Width, rb.Width) && SameBits(ra.Height, rb.Height), $"{what}: rect dims"); + break; + case Shape.Labeled la: + var lb = (Shape.Labeled)b; + Expect(la.Label == lb.Label && la.Count == lb.Count, $"{what}: labeled payload"); + break; + case Shape.Nested na: + var nb = (Shape.Nested)b; + ExpectScalarsEqual(na.Inner, nb.Inner, what + ": nested inner"); + Expect(na.Note == nb.Note, $"{what}: nested note"); + break; + default: + Expect(false, $"{what}: unknown variant"); + break; + } + } + + static void ExpectCompositesEqual(Composite a, Composite b, string what) + { + Expect(a.Name == b.Name, $"{what}: name"); + Expect(a.Blob.SequenceEqual(b.Blob), $"{what}: blob"); + Expect(a.SomeI64 == b.SomeI64, $"{what}: some_i64"); + Expect(a.NoneI64 == b.NoneI64, $"{what}: none_i64"); + Expect(a.SomeText == b.SomeText, $"{what}: some_text"); + Expect(a.Names.SequenceEqual(b.Names), $"{what}: names"); + Expect(a.Matrix.Length == b.Matrix.Length + && a.Matrix.Zip(b.Matrix, (x, y) => x.SequenceEqual(y)).All(eq => eq), + $"{what}: matrix"); + Expect(a.Empty.Length == b.Empty.Length + && a.Empty.Zip(b.Empty, (x, y) => SameBits(x, y)).All(eq => eq), + $"{what}: empty list"); + Expect(a.ByName.Count == b.ByName.Count + && a.ByName.All(kv => b.ByName.TryGetValue(kv.Key, out var v) && v == kv.Value), + $"{what}: by_name"); + Expect(a.ById.Count == b.ById.Count && a.ById.Keys.All(k => b.ById.ContainsKey(k)), + $"{what}: by_id keys"); + foreach (var kv in a.ById) + { + ExpectScalarsEqual(kv.Value, b.ById[kv.Key], $"{what}: by_id[{kv.Key}]"); + } + ExpectScalarsEqual(a.Scalars, b.Scalars, what + ": scalars"); + ExpectShapesEqual(a.Shape, b.Shape, what + ": shape"); + Expect(a.Shapes.Length == b.Shapes.Length, $"{what}: shapes length"); + for (int i = 0; i < a.Shapes.Length; i++) + { + ExpectShapesEqual(a.Shapes[i], b.Shapes[i], $"{what}: shapes[{i}]"); + } + Expect((a.MaybeShape == null) == (b.MaybeShape == null), $"{what}: maybe_shape presence"); + if (a.MaybeShape != null) + { + ExpectShapesEqual(a.MaybeShape, b.MaybeShape, what + ": maybe_shape"); + } + Expect((a.MaybeList == null) == (b.MaybeList == null) + && (a.MaybeList == null || a.MaybeList.SequenceEqual(b.MaybeList)), + $"{what}: maybe_list"); + Expect(a.Sparse.SequenceEqual(b.Sparse), $"{what}: sparse"); + Expect(a.Colors.SequenceEqual(b.Colors), $"{what}: colors"); + } + + static int Main() + { + var lib = Environment.GetEnvironmentVariable("WEAVEFFI_LIBRARY"); + NativeLibrary.SetDllImportResolver(typeof(Program).Assembly, (name, asm, search) => + { + if (name == "weaveffi" && !string.IsNullOrEmpty(lib)) + return NativeLibrary.Load(lib); + return IntPtr.Zero; + }); + + // --- Scalars: producer encodes, consumer decodes; then back. --- + var scalars = SampleScalars(); + CheckScalars(scalars, "sample_scalars"); + Expect(VerifyScalars(scalars), "verify_scalars accepts the decoded sample"); + CheckScalars(RoundtripScalars(scalars), "roundtrip_scalars"); + var mine = CanonicalScalars(); + Expect(VerifyScalars(mine), "verify_scalars accepts a consumer-built canonical value"); + + // A one-field mismatch reports the typed domain error. + var wrong = new Scalars(-8, 200, -16_000, 60_000, -2_000_000_000, 4_000_000_000U, + -9_007_199_254_740_993L, ulong.MaxValue, 1.5f, -2.25e100, false, Color.Blue); + try + { + VerifyScalars(wrong); + Expect(false, "expected CodecException for a mismatched Scalars"); + } + catch (CodecException e) + { + Expect(e.Code == CodecException.Mismatch, $"Mismatch code == 1 (got {e.Code})"); + Expect(e.Code == 1, "Mismatch constant is 1"); + Expect(e is WeaveFFIException, "typed exception extends the brand exception"); + } + + // Scalars at the extremes, including float edge values whose bit + // patterns must survive both directions. + var extremes = new Scalars(sbyte.MinValue, byte.MaxValue, short.MinValue, ushort.MaxValue, + int.MinValue, uint.MaxValue, long.MinValue, ulong.MaxValue, + float.NegativeInfinity, -0.0, false, Color.Red); + var extremesBack = RoundtripScalars(extremes); + ExpectScalarsEqual(extremes, extremesBack, "extremes"); + Expect(BitConverter.DoubleToInt64Bits(extremesBack.F64Value) == long.MinValue, "negative zero keeps its sign bit"); + var nans = new Scalars(sbyte.MaxValue, 0, short.MaxValue, 0, int.MaxValue, 0, long.MaxValue, 0, + float.NaN, double.NaN, true, Color.Green); + var nansBack = RoundtripScalars(nans); + Expect(float.IsNaN(nansBack.F32Value) && double.IsNaN(nansBack.F64Value), "NaN survives both floats"); + Expect(nansBack.I64Value == long.MaxValue && nansBack.I8Value == sbyte.MaxValue, "max extremes"); + var infs = new Scalars(0, 0, 0, 0, 0, 0, 0, 0, float.PositiveInfinity, double.PositiveInfinity, false, Color.Red); + var infsBack = RoundtripScalars(infs); + Expect(float.IsPositiveInfinity(infsBack.F32Value) && double.IsPositiveInfinity(infsBack.F64Value), + "+inf survives both floats"); + Expect(double.IsNegativeInfinity(RoundtripScalars(new Scalars(0, 0, 0, 0, 0, 0, 0, 0, 0f, + double.NegativeInfinity, false, Color.Red)).F64Value), "-inf survives f64"); + + // --- Composite: every nested wire shape. --- + var composite = SampleComposite(); + Expect(composite.Name == "héllo wörld ✓", $"name (got '{composite.Name}')"); + Expect(composite.Blob.SequenceEqual(new byte[] { 0, 1, 2, 253, 254, 255 }), "blob"); + Expect(composite.SomeI64 == long.MinValue, $"some_i64 == i64::MIN (got {composite.SomeI64})"); + Expect(composite.NoneI64 == null, "none_i64 absent"); + Expect(composite.SomeText != null && composite.SomeText == "", "some_text is a present empty string"); + Expect(composite.Names.SequenceEqual(new[] { "a", "", "ccc" }), "names"); + Expect(composite.Matrix.Length == 3 + && composite.Matrix[0].SequenceEqual(new[] { 1, 2, 3 }) + && composite.Matrix[1].Length == 0 + && composite.Matrix[2].SequenceEqual(new[] { -4 }), + "matrix"); + Expect(composite.Empty.Length == 0, "empty list"); + Expect(composite.ByName.Count == 3 + && composite.ByName["one"] == 1 && composite.ByName["two"] == 2 && composite.ByName["neg"] == -3, + "by_name"); + Expect(composite.ById.Count == 2 && composite.ById.ContainsKey(-1) && composite.ById.ContainsKey(42), "by_id keys"); + CheckScalars(composite.ById[-1], "by_id[-1]"); + Expect(!composite.ById[42].Flag && composite.ById[42].U64Value == ulong.MaxValue, "by_id[42] differs only in flag"); + CheckScalars(composite.Scalars, "composite.scalars"); + Expect(composite.Shape is Shape.Labeled sl && sl.Label == "tag" && sl.Count == 3, "shape is Labeled(tag, 3)"); + Expect(composite.Shapes.Length == 5, "five shapes"); + Expect(composite.Shapes[0] is Shape.Empty, "shapes[0] Empty"); + Expect(composite.Shapes[1] is Shape.Circle c1 && c1.Radius == 2.5, "shapes[1] Circle(2.5)"); + Expect(composite.Shapes[2] is Shape.Rect r2 && r2.Width == 1.0f && r2.Height == 0.5f, "shapes[2] Rect(1, 0.5)"); + Expect(composite.Shapes[3] is Shape.Labeled l3 && l3.Label == "" && l3.Count == -1, "shapes[3] Labeled('', -1)"); + Expect(composite.Shapes[4] is Shape.Nested n4 && n4.Note == "n", "shapes[4] Nested(note n)"); + CheckScalars(((Shape.Nested)composite.Shapes[4]).Inner, "shapes[4].inner"); + Expect(composite.MaybeShape is Shape.Nested mn && mn.Note == null, "maybe_shape Nested(note absent)"); + CheckScalars(((Shape.Nested)composite.MaybeShape).Inner, "maybe_shape.inner"); + Expect(composite.MaybeList != null && composite.MaybeList.SequenceEqual(new byte[] { 9, 8 }), "maybe_list"); + Expect(composite.Sparse.SequenceEqual(new bool?[] { true, null, false }), "sparse"); + Expect(composite.Colors.SequenceEqual(new[] { Color.Red, Color.Green, Color.Blue }), "colors"); + + Expect(VerifyComposite(composite), "verify_composite accepts the decoded sample"); + ExpectCompositesEqual(composite, RoundtripComposite(composite), "roundtrip_composite"); + var described = DescribeComposite(composite); + Expect(described.Contains("héllo wörld ✓") && described.Contains("Labeled"), + $"describe_composite renders the value (got '{described}')"); + + // The same composite with one nested change fails verification. + var tweaked = new Composite(composite.Name, composite.Blob, composite.SomeI64, composite.NoneI64, + composite.SomeText, composite.Names, composite.Matrix, composite.Empty, composite.ByName, + composite.ById, composite.Scalars, composite.Shape, composite.Shapes, composite.MaybeShape, + composite.MaybeList, new bool?[] { true, true, false }, composite.Colors); + try + { + VerifyComposite(tweaked); + Expect(false, "expected CodecException for a tweaked Composite"); + } + catch (CodecException e) + { + Expect(e.Code == CodecException.Mismatch, "tweaked composite reports Mismatch"); + } + + // A consumer-built composite with edge values: empty everything, + // unicode, extremes, NaN, the infinities, and negative zero. + var edge = new Composite( + "", + new byte[0], + long.MaxValue, + null, + "日本語 🎉 \u0000 tail", + new string[0], + new int[][] { new int[0], new[] { int.MinValue, int.MaxValue } }, + new[] { double.NaN, double.PositiveInfinity, double.NegativeInfinity, -0.0, double.Epsilon, double.MaxValue }, + new Dictionary(), + new Dictionary { [int.MinValue] = extremes, [0] = nans, [int.MaxValue] = infs }, + extremes, + new Shape.Empty(), + new Shape[0], + null, + null, + new bool?[0], + new Color[0]); + var edgeBack = RoundtripComposite(edge); + ExpectCompositesEqual(edge, edgeBack, "edge composite"); + Expect(edgeBack.SomeText == "日本語 🎉 \u0000 tail", "embedded NUL and astral characters survive inside a buffer"); + Expect(double.IsNaN(edgeBack.Empty[0]) && double.IsPositiveInfinity(edgeBack.Empty[1]) + && double.IsNegativeInfinity(edgeBack.Empty[2]) + && BitConverter.DoubleToInt64Bits(edgeBack.Empty[3]) == long.MinValue, + "float edge values in a list"); + Expect(edgeBack.MaybeShape == null && edgeBack.MaybeList == null && edgeBack.NoneI64 == null, + "absent optionals stay absent"); + + // Rich enum variants on their own and in a list. + Shape[] variants = + { + new Shape.Empty(), + new Shape.Circle(double.NaN), + new Shape.Circle(-0.0), + new Shape.Rect(float.MaxValue, float.Epsilon), + new Shape.Labeled("", int.MinValue), + new Shape.Labeled("ünïcödé", int.MaxValue), + new Shape.Nested(extremes, null), + new Shape.Nested(nans, ""), + }; + foreach (var v in variants) + { + ExpectShapesEqual(v, RoundtripShape(v), "roundtrip_shape " + v.GetType().Name); + } + var listBack = RoundtripShapes(variants); + Expect(listBack.Length == variants.Length, "roundtrip_shapes length"); + for (int i = 0; i < variants.Length; i++) + { + ExpectShapesEqual(variants[i], listBack[i], $"roundtrip_shapes[{i}]"); + } + Expect(RoundtripShapes(new Shape[0]).Length == 0, "roundtrip_shapes empty"); + Expect(DescribeShape(new Shape.Empty()) == "Empty", "describe_shape Empty"); + Expect(DescribeShape(new Shape.Circle(2.5)) == "Circle { radius: 2.5 }", + $"describe_shape Circle (got '{DescribeShape(new Shape.Circle(2.5))}')"); + Expect(DescribeShape(new Shape.Labeled("x", 7)) == "Labeled { label: \"x\", count: 7 }", + "describe_shape Labeled"); + + // Top-level optionals, maps, strings, bytes, and direct scalars. + Expect(RoundtripOptI64(null) == null, "opt_i64 absent"); + Expect(RoundtripOptI64(long.MinValue) == long.MinValue, "opt_i64 i64::MIN"); + Expect(RoundtripOptI64(0) == 0, "opt_i64 zero is present"); + Expect(RoundtripMap(new Dictionary()).Count == 0, "empty map"); + var map = RoundtripMap(new Dictionary { [""] = long.MinValue, ["k"] = 0, ["ключ"] = long.MaxValue }); + Expect(map.Count == 3 && map[""] == long.MinValue && map["k"] == 0 && map["ключ"] == long.MaxValue, "map contents"); + Expect(RoundtripString("") == "", "empty string"); + Expect(RoundtripString("héllo wörld ✓ 🎉") == "héllo wörld ✓ 🎉", "unicode string"); + Expect(RoundtripBytes(new byte[0]).Length == 0, "empty bytes"); + Expect(RoundtripBytes(new byte[] { 0, 127, 128, 255 }).SequenceEqual(new byte[] { 0, 127, 128, 255 }), "bytes"); + Expect(RoundtripI64(long.MinValue) == long.MinValue, "i64::MIN direct"); + Expect(RoundtripI64(long.MaxValue) == long.MaxValue, "i64::MAX direct"); + Expect(RoundtripU64(ulong.MaxValue) == ulong.MaxValue, "u64::MAX direct"); + Expect(RoundtripU64(1UL << 63) == (1UL << 63), "2^63 direct"); + Expect(double.IsNaN(RoundtripF64(double.NaN)), "f64 NaN direct"); + Expect(double.IsPositiveInfinity(RoundtripF64(double.PositiveInfinity)), "f64 +inf direct"); + Expect(double.IsNegativeInfinity(RoundtripF64(double.NegativeInfinity)), "f64 -inf direct"); + Expect(BitConverter.DoubleToInt64Bits(RoundtripF64(-0.0)) == long.MinValue, "f64 -0.0 direct"); + Expect(RoundtripF64(double.MaxValue) == double.MaxValue, "f64 max direct"); + Expect(RoundtripBool(true) && !RoundtripBool(false), "bool direct"); + Expect(RoundtripColor(Color.Blue) == Color.Blue && (int)RoundtripColor(Color.Blue) == 7, "enum direct"); + Expect(RoundtripColor(Color.Red) == Color.Red, "enum zero direct"); + + // --- Objects inside buffers. --- + var holder = MakeHolder(10, true); + Expect(holder.Primary.Value() == 10, "holder.primary"); + Expect(holder.Spare != null && holder.Spare.Value() == 11, "holder.spare"); + Expect(holder.Many.Select(t => t.Value()).SequenceEqual(new long[] { 12, 13, 14 }), "holder.many"); + Expect(holder.Many.Select(t => t.Handle).Distinct().Count() == 3, "holder.many are distinct objects"); + Expect(SumHolder(holder) == 10 + 11 + 12 + 13 + 14, "sum_holder"); + // Encoding cloned each token, so the holder is still fully usable. + Expect(SumHolder(holder) == 60, "sum_holder again after re-encoding"); + Expect(holder.Primary.Value() == 10, "primary alive after encoding twice"); + + var primary = PrimaryOf(holder); + Expect(!ReferenceEquals(primary, holder.Primary), "primary_of is a new wrapper"); + Expect(primary.Handle == holder.Primary.Handle, "primary_of wraps the same native object"); + Expect(primary.Value() == 10, "primary_of value"); + Expect(SamePrimary(holder, holder), "same_primary with itself"); + var other = MakeHolder(10, true); + Expect(!SamePrimary(holder, other), "same_primary across holders"); + var rebuilt = new Holder(primary, null, new Token[0]); + Expect(SamePrimary(holder, rebuilt), "same_primary through a consumer-built holder"); + Expect(SumHolder(rebuilt) == 10, "sum_holder of a consumer-built holder"); + + var noSpare = MakeHolder(0, false); + Expect(noSpare.Spare == null, "make_holder without spare"); + Expect(SumHolder(noSpare) == 0 + 2 + 3 + 4, "sum_holder without spare"); + + // Consumer-created tokens travel into buffers too. + var t1 = new Token(1); + var t2 = new Token(2); + var t3 = new Token(long.MinValue); + var own = new Holder(t1, t2, new[] { t3, t1 }); + Expect(SumHolder(own) == 1 + 2 + long.MinValue + 1, "sum_holder over consumer tokens"); + var ownPrimary = PrimaryOf(own); + Expect(ownPrimary.Handle == t1.Handle && ownPrimary.Value() == 1, "primary_of consumer token"); + + // Reference counting: dropping one wrapper leaves the others valid; + // double Dispose is a no-op; a disposed wrapper throws. + holder.Primary.Dispose(); + holder.Primary.Dispose(); + Expect(primary.Value() == 10, "primary_of wrapper outlives holder.Primary"); + try + { + holder.Primary.Value(); + Expect(false, "expected ObjectDisposedException"); + } + catch (ObjectDisposedException) + { + } + try + { + SumHolder(holder); + Expect(false, "encoding a disposed token must throw before calling the producer"); + } + catch (ObjectDisposedException) + { + } + ownPrimary.Dispose(); + Expect(t1.Value() == 1, "t1 alive after disposing its twin"); + + foreach (var t in new[] { primary, holder.Spare, other.Primary, other.Spare, noSpare.Primary, t1, t2, t3 } + .Concat(holder.Many).Concat(other.Many).Concat(noSpare.Many)) + { + t.Dispose(); + t.Dispose(); + } + using (var scoped = new Token(99)) + { + Expect(scoped.Value() == 99, "scoped token"); + } + + Console.WriteLine("dotnet/codec: OK"); + return 0; + } +} diff --git a/conformance/dotnet/Events.cs b/conformance/dotnet/Events.cs index 98bc61b3..594c0a81 100644 --- a/conformance/dotnet/Events.cs +++ b/conformance/dotnet/Events.cs @@ -1,17 +1,90 @@ // Conformance consumer: events sample, .NET target. // -// Exercises the delegate + GCHandle listener trampoline (register pins the -// Action, the producer fires it synchronously on send, unregister -// frees the handle and stops delivery) and the opaque-iterator ABI behind -// Events.GetMessages. The producer cdylib is resolved by absolute path via a -// DllImportResolver reading WEAVEFFI_LIBRARY. +// Drives the ABI 2 surface of the generated P/Invoke wrapper (WeaveFFI.cs): +// the `ISubscriber` callback interface implemented in C# and adapted to the +// producer's vtable by the generated trampolines (Route steering Publish's +// accepted count through the `Delivery` enum, OnMessage decoding the buffered +// `Message` record, OnAttached adopting a reference-counted `EventBus` handed +// through the callback), the `EventBus` object class (constructor, Task-based +// PublishLater, once-enumerable Messages iterator, optional LastMessage, +// IDisposable release with a safe double Dispose and ObjectDisposedException +// afterward), the free function RouteOnce, and a subscriber that throws, +// which the trampolines report as the generated WeaveFFIException carrying +// ForeignErrorCode (-4) without crashing the runtime. The producer cdylib is +// resolved by absolute path via a DllImportResolver reading WEAVEFFI_LIBRARY. +// +// The harness compiles the generated source into this assembly, so the +// wrapper's `internal` Handle property is reachable and used to prove two +// wrappers point at the same native object. using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading.Tasks; using WeaveFFI; +internal sealed class TestSubscriber : ISubscriber +{ + private readonly string _skipTopic; + private readonly string _failTopic; + private readonly bool _keepBus; + + public readonly List Routed = new List(); + public readonly List Received = new List(); + public int Attached; + public long AttachedCount = -1; + public EventBus KeptBus; + + public TestSubscriber(string skipTopic, string failTopic, bool keepBus = false) + { + _skipTopic = skipTopic; + _failTopic = failTopic; + _keepBus = keepBus; + } + + public Delivery Route(string topic) + { + Routed.Add(topic); + if (topic == _failTopic) + { + throw new InvalidOperationException("subscriber rejected topic " + topic); + } + if (topic == _skipTopic) + { + return Delivery.Skip; + } + return topic == "stop" ? Delivery.AcceptAndStop : Delivery.Accept; + } + + public long OnMessage(Message message) + { + if (message.Text == "explode") + { + throw new ApplicationException("subscriber exploded on " + message.Text); + } + Received.Add(message); + return Received.Count; + } + + public void OnAttached(EventBus bus) + { + Attached++; + // The reference is ours: the bus is usable here, and releasing it + // must not tear down the caller's live bus. + AttachedCount = bus.SubscriberCount(); + if (_keepBus) + { + KeptBus = bus; + } + else + { + bus.Dispose(); + } + } +} + internal static class Program { static void Expect(bool cond, string msg) @@ -23,7 +96,17 @@ static void Expect(bool cond, string msg) } } - static int Main() + // Subscribes a subscriber nothing else references, so once the producer's + // `free(ctx)` releases the GCHandle the object becomes collectible. + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference SubscribeTransient(EventBus bus) + { + var sub = new TestSubscriber("", ""); + bus.Subscribe(sub); + return new WeakReference(sub); + } + + static async Task Main() { var lib = Environment.GetEnvironmentVariable("WEAVEFFI_LIBRARY"); NativeLibrary.SetDllImportResolver(typeof(Program).Assembly, (name, asm, search) => @@ -33,24 +116,167 @@ static int Main() return IntPtr.Zero; }); - var received = new List(); - ulong sub = Events.RegisterMessageListener(received.Add); - Expect(sub > 0, "listener id positive"); + var bus = new EventBus(); + Expect(bus.SubscriberCount() == 0, "fresh bus has no subscribers"); + Expect(bus.LastMessage() == null, "fresh bus has no last message"); + Expect(!bus.Messages().Any(), "fresh bus has no messages"); + + // Subscribe: the producer calls OnAttached with a reference-counted + // bus object before retaining the subscriber. + var a = new TestSubscriber("quiet", "", keepBus: true); + var b = new TestSubscriber("", ""); + Expect(bus.Subscribe(a) == 1, "first subscribe returns 1"); + Expect(bus.Subscribe(b) == 2, "second subscribe returns 2"); + Expect(a.Attached == 1 && b.Attached == 1, "OnAttached fired once per subscriber"); + Expect(a.AttachedCount == 0, $"a saw 0 subscribers at attach (got {a.AttachedCount})"); + Expect(b.AttachedCount == 1, $"b saw 1 subscriber at attach (got {b.AttachedCount})"); + Expect(a.KeptBus != null && a.KeptBus.Handle == bus.Handle, + "bus handed to OnAttached is the same native object"); + Expect(bus.SubscriberCount() == 2, "subscriber count == 2"); + + // Delivery steers the accepted count: Accept delivers, Skip does not, + // AcceptAndStop delivers and halts later subscribers. + Expect(bus.Publish("news", "hello", new[] { "a", "b" }) == 2, "news accepted by both"); + Expect(bus.Publish("quiet", "psst", new string[0]) == 1, "quiet skipped by a"); + Expect(bus.Publish("stop", "last", new[] { "z" }) == 1, "stop halts after a"); - Events.SendMessage("alpha"); - Events.SendMessage("beta"); - Expect(received.SequenceEqual(new[] { "alpha", "beta" }), - $"listener received sends (got [{string.Join(", ", received)}])"); + Expect(a.Routed.SequenceEqual(new[] { "news", "quiet", "stop" }), + $"a routed every topic (got [{string.Join(", ", a.Routed)}])"); + Expect(b.Routed.SequenceEqual(new[] { "news", "quiet" }), + $"b never routed the stopped topic (got [{string.Join(", ", b.Routed)}])"); + Expect(a.Received.Select(m => m.Text).SequenceEqual(new[] { "hello", "last" }), + "a received hello and last"); + Expect(b.Received.Select(m => m.Text).SequenceEqual(new[] { "hello", "psst" }), + "b received hello and psst"); - var msgs = Events.GetMessages().ToList(); - Expect(msgs.SequenceEqual(new[] { "alpha", "beta" }), - $"iterator yields messages in order (got [{string.Join(", ", msgs)}])"); + // The buffered Message record decodes intact inside the callback. + var first = a.Received[0]; + Expect(first.Seq == 1, $"first seq == 1 (got {first.Seq})"); + Expect(first.Topic == "news", "first topic"); + Expect(first.Text == "hello", "first text"); + Expect(first.Tags.SequenceEqual(new[] { "a", "b" }), + $"first tags (got [{string.Join(", ", first.Tags)}])"); + Expect(a.Received[1].Seq == 3 && a.Received[1].Tags.SequenceEqual(new[] { "z" }), + "third message decoded for a"); + Expect(b.Received[1].Seq == 2 && b.Received[1].Tags.Length == 0, + "second message decoded for b with empty tags"); - // Unregister stops delivery; the producer still records the message. - Events.UnregisterMessageListener(sub); - Events.SendMessage("gamma"); - Expect(received.Count == 2, "no delivery after unregister"); - Expect(Events.GetMessages().Count() == 3, "producer kept recording"); + // Async publish settles the Task from the producer's worker thread. + long later = await bus.PublishLater("async", "later"); + Expect(later == 2, $"PublishLater accepted by both (got {later})"); + Expect(a.Received.Last().Text == "later" && b.Received.Last().Text == "later", + "async publish reached both subscribers"); + + // Iterator and optional record return. + var texts = bus.Messages().ToList(); + Expect(texts.SequenceEqual(new[] { "hello", "psst", "last", "later" }), + $"messages in order (got [{string.Join(", ", texts)}])"); + var last = bus.LastMessage(); + Expect(last != null && last.Seq == 4 && last.Topic == "async" && last.Text == "later", + "LastMessage is the async publish"); + + // Early exit disposes the enumerator (and the native iterator). + foreach (var t in bus.Messages()) + { + Expect(t == "hello", "first streamed message"); + break; + } + var once = bus.Messages(); + once.ToList(); + try + { + once.ToList(); + Expect(false, "second enumeration should throw"); + } + catch (InvalidOperationException) + { + } + + // Free function taking the callback interface without a bus. + var probe = new TestSubscriber("quiet", ""); + Expect(Events.RouteOnce(probe, "quiet") == Delivery.Skip, "RouteOnce skip"); + Expect(Events.RouteOnce(probe, "stop") == Delivery.AcceptAndStop, "RouteOnce accept-and-stop"); + Expect(Events.RouteOnce(probe, "x") == Delivery.Accept, "RouteOnce accept"); + Expect(probe.Routed.SequenceEqual(new[] { "quiet", "stop", "x" }), "RouteOnce routed topics"); + Expect(probe.Attached == 0, "RouteOnce never attaches"); + + // A throwing subscriber surfaces to the caller as the foreign error + // (code -4) with the exception's message, and the bus keeps working. + var c = new TestSubscriber("", "boom"); + Expect(bus.Subscribe(c) == 3, "third subscribe returns 3"); + try + { + bus.Publish("boom", "x", new string[0]); + Expect(false, "expected WeaveFFIException from throwing Route"); + } + catch (WeaveFFIException e) + { + Expect(e.Code == WeaveFFIException.ForeignErrorCode, + $"foreign error code == -4 (got {e.Code})"); + Expect(e.Code == -4, "ForeignErrorCode constant is -4"); + Expect(e.Message.Contains("subscriber rejected topic boom"), + $"foreign error carries the exception message (got '{e.Message}')"); + } + try + { + bus.Publish("ok", "explode", new string[0]); + Expect(false, "expected WeaveFFIException from throwing OnMessage"); + } + catch (WeaveFFIException e) + { + Expect(e.Code == WeaveFFIException.ForeignErrorCode, + $"OnMessage foreign error code == -4 (got {e.Code})"); + Expect(e.Message.Contains("subscriber exploded"), + $"OnMessage foreign error message (got '{e.Message}')"); + } + Expect(bus.Publish("ok", "y", new string[0]) == 3, "bus still delivers after a foreign error"); + try + { + Events.RouteOnce(c, "boom"); + Expect(false, "expected WeaveFFIException from RouteOnce"); + } + catch (WeaveFFIException e) + { + Expect(e.Code == WeaveFFIException.ForeignErrorCode, "RouteOnce foreign error code"); + } + + // ClearSubscribers drops the producer's references; the consumer's + // `free(ctx)` releases the GCHandle so the subscriber is collectible. + var weak = SubscribeTransient(bus); + Expect(bus.SubscriberCount() == 4, "subscriber count == 4"); + bus.ClearSubscribers(); + Expect(bus.SubscriberCount() == 0, "subscriber count == 0 after clear"); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Expect(!weak.IsAlive, "freed subscriber was collected"); + Expect(bus.Publish("nobody", "home", new string[0]) == 0, "no subscribers accept"); + + // Reference counting: the wrapper adopted in OnAttached outlives the + // original wrapper; disposing either releases only its own reference. + var kept = a.KeptBus; + bus.Dispose(); + bus.Dispose(); + try + { + bus.SubscriberCount(); + Expect(false, "expected ObjectDisposedException"); + } + catch (ObjectDisposedException) + { + } + // Every publish is logged, including the two aborted by foreign errors. + var keptCount = kept.Messages().Count(); + Expect(keptCount == 8, $"kept bus still alive with 8 messages (got {keptCount})"); + var keptLast = kept.LastMessage(); + Expect(keptLast != null && keptLast.Text == "home", "kept bus sees the last publish"); + kept.Dispose(); + kept.Dispose(); + + using (var scoped = new EventBus()) + { + Expect(scoped.Publish("t", "u", new string[0]) == 0, "scoped bus publishes"); + } Console.WriteLine("dotnet/events: OK"); return 0; diff --git a/conformance/dotnet/Kvstore.cs b/conformance/dotnet/Kvstore.cs index fb228add..5cb6fdaf 100644 --- a/conformance/dotnet/Kvstore.cs +++ b/conformance/dotnet/Kvstore.cs @@ -1,18 +1,28 @@ // Conformance consumer: kvstore sample, .NET target. // // Full-surface drive of the generated P/Invoke wrapper (Kvstore.cs, namespace -// Kvstore): the Store interface class (static Open factory throwing the typed -// KvException IoError=1004 on an empty path, instance methods put/get/delete/ -// list_keys/count/clear, the deprecated legacy_put, the Task-returning -// Compact settled from the producer's worker thread, and the DefaultCapacity -// static), the optional buffered `Entry?` return decoded into a plain value -// class (bytes / nullable-scalar / array / dictionary properties), direct -// value-class construction of Entry with non-empty list and map fields, the -// IEnumerable-backed ListKeys iterator, the cross-module KvStats.GetStats -// (Stats decoded from one value buffer), the delegate + GCHandle eviction -// listener (register -> fire synchronously on delete -> unregister), and the -// typed KvException codes (KeyNotFound=1001). The producer cdylib is resolved -// by absolute path via a DllImportResolver reading WEAVEFFI_LIBRARY. +// Kvstore) at ABI 2: the reference-counted Store object class (static Open +// factory throwing the typed KvException IoError=1004 on an empty path, +// instance methods put/get/delete/list_keys/count/clear, the deprecated +// legacy_put, the Task-returning Compact, the DefaultCapacity static), the +// optional buffered `Entry?` return decoded into a plain value class, direct +// value-class construction of Entry, the IEnumerable-backed ListKeys iterator, +// the cross-module KvStats.GetStats, the `IEvictionListener` callback +// interface implemented in C# (fired synchronously on delete and on an +// expired read, detached when it returns false, replaced and cleared, and a +// throwing listener surfacing to the caller as WeaveFFIException with +// ForeignErrorCode -4), the object graph (Share returning a second wrapper to +// the same native object, Fork, `Store?` both ways through Larger, the +// StoreInfo record carrying Store objects, OpenMany returning a list of +// objects, TotalCount taking a list of objects plus an optional record with +// objects), the typed KvException codes (KeyNotFound=1001, Expired=1002), and +// IDisposable release (safe double Dispose, ObjectDisposedException after). +// The producer cdylib is resolved by absolute path via a DllImportResolver +// reading WEAVEFFI_LIBRARY. +// +// The harness compiles the generated source into this assembly, so the +// wrapper's `internal` Handle property is reachable and used to prove two +// wrappers point at the same native object. using System; using System.Collections.Generic; @@ -21,6 +31,35 @@ using System.Threading.Tasks; using Kvstore; +internal sealed class RecordingListener : IEvictionListener +{ + private readonly int _keepAfter; + public readonly List> Evictions = + new List>(); + + public RecordingListener(int keepAfter) + { + _keepAfter = keepAfter; + } + + public bool OnEvict(Entry entry, EvictionReason reason) + { + Evictions.Add(new KeyValuePair(entry.Key, reason)); + return Evictions.Count < _keepAfter; + } +} + +internal sealed class ThrowingListener : IEvictionListener +{ + public int Calls; + + public bool OnEvict(Entry entry, EvictionReason reason) + { + Calls++; + throw new InvalidOperationException("listener refused " + entry.Key); + } +} + internal static class Program { static void Expect(bool cond, string msg) @@ -52,106 +91,277 @@ static async Task Main() catch (KvException e) { Expect(e.Code == KvException.IoError, $"IoError code == 1004 (got {e.Code})"); + Expect(e.Message == "I/O failure", $"IoError default message (got '{e.Message}')"); } Expect(Store.DefaultCapacity() == 1_000_000, "static default capacity"); - using (var store = Store.Open("/tmp/conformance-kvstore-dotnet")) - { - var payload = new byte[] { 1, 2, 3 }; - - // The optional TTL crosses as a buffered `i64?` parameter. - Expect(store.Put("alpha", payload, EntryKind.Persistent, null), "put alpha"); - Expect(store.Put("beta", payload, EntryKind.Volatile, 3600), "put beta with ttl"); - Expect(store.Count() == 2, "count == 2"); - - // Iterator-backed list-of-string return drained through - // IEnumerable; the optional prefix is a buffered `string?`. - var keys = store.ListKeys(null).OrderBy(k => k).ToList(); - Expect(keys.SequenceEqual(new[] { "alpha", "beta" }), - $"list_keys values (got [{string.Join(", ", keys)}])"); - - // Optional buffered `Entry?` return decoded into a plain value - // class covering every complex field type. - var alpha = store.Get("alpha"); - Expect(alpha != null, "get alpha present"); - Expect(alpha.Id > 0, "entry id positive"); - Expect(alpha.Key == "alpha", "entry key"); - Expect(alpha.Value.SequenceEqual(payload), "entry value bytes"); - Expect(alpha.CreatedAt > 0, "entry created_at positive"); - Expect(alpha.ExpiresAt == null, "alpha ExpiresAt null"); - Expect(alpha.Tags.Length == 0, "alpha tags empty"); - Expect(alpha.Metadata.Count == 0, "alpha metadata empty"); - - var beta = store.Get("beta"); - Expect(beta != null && beta.ExpiresAt != null && beta.ExpiresAt > 0, - "beta ExpiresAt present"); - - // Typed method error: a missing key reports KvError::KeyNotFound. - try - { - store.Get("missing"); - Expect(false, "expected KvException for missing key"); - } - catch (KvException e) - { - Expect(e.Code == KvException.KeyNotFound, - $"KeyNotFound code == 1001 (got {e.Code})"); - Expect(e is WeaveFFIException, "typed exception extends the brand exception"); - } - - // Entry is a plain value class now (no builder, no handle): - // non-empty list and map fields live directly on the instance. - var built = new Entry( - 7, - "built", - payload, - 1000, - null, - new[] { "hot", "fast" }, - new Dictionary { ["source"] = "test", ["env"] = "prod" }); - Expect(built.Tags.OrderBy(t => t).SequenceEqual(new[] { "fast", "hot" }), "built tags"); - Expect(built.Metadata["source"] == "test" && built.Metadata["env"] == "prod", - "built metadata"); - Expect(built.ExpiresAt == null, "built ExpiresAt null"); - - // Cross-module call: Stats lives in kv.stats, store is a kv.Store. - var stats = KvStats.GetStats(store); - Expect(stats.TotalEntries == 2, "stats total entries == 2"); - Expect(stats.TotalBytes == 6, "stats total bytes == 6"); - Expect(stats.ExpiredEntries == 0, "stats expired entries == 0"); - - // Eviction listener: delete fires the pinned delegate synchronously. - var evicted = new List(); - ulong sub = Kv.RegisterEvictionListener(evicted.Add); - Expect(sub > 0, "listener id positive"); - Expect(store.Delete("beta"), "delete beta"); - Expect(evicted.SequenceEqual(new[] { "beta" }), - $"eviction fired for beta (got [{string.Join(", ", evicted)}])"); - - // Unregister stops delivery. - Kv.UnregisterEvictionListener(sub); - Expect(store.Delete("alpha"), "delete alpha"); - Expect(evicted.Count == 1, "no eviction after unregister"); - - // Deprecated method still round-trips (volatile put, no TTL). + var store = Store.Open("/tmp/conformance-kvstore-dotnet"); + var payload = new byte[] { 1, 2, 3 }; + + // The optional TTL crosses as a buffered `i64?` parameter. + Expect(store.Put("alpha", payload, EntryKind.Persistent, null), "put alpha"); + Expect(store.Put("beta", payload, EntryKind.Volatile, 3600), "put beta with ttl"); + Expect(store.Count() == 2, "count == 2"); + + // Iterator-backed list-of-string return drained through IEnumerable; + // the optional prefix is a buffered `string?`. + var keys = store.ListKeys(null).ToList(); + Expect(keys.SequenceEqual(new[] { "alpha", "beta" }), + $"list_keys sorted values (got [{string.Join(", ", keys)}])"); + Expect(store.ListKeys("al").SequenceEqual(new[] { "alpha" }), "list_keys with prefix"); + Expect(!store.ListKeys("zzz").Any(), "list_keys with unmatched prefix is empty"); + + // Optional buffered `Entry?` return decoded into a plain value class + // covering every complex field type. + var alpha = store.Get("alpha"); + Expect(alpha != null, "get alpha present"); + Expect(alpha.Id > 0, "entry id positive"); + Expect(alpha.Key == "alpha", "entry key"); + Expect(alpha.Value.SequenceEqual(payload), "entry value bytes"); + Expect(alpha.CreatedAt > 0, "entry created_at positive"); + Expect(alpha.ExpiresAt == null, "alpha ExpiresAt null"); + Expect(alpha.Tags.Length == 0, "alpha tags empty"); + Expect(alpha.Metadata.Count == 0, "alpha metadata empty"); + + var beta = store.Get("beta"); + Expect(beta != null && beta.ExpiresAt != null && beta.ExpiresAt > beta.CreatedAt, + "beta ExpiresAt present and after CreatedAt"); + Expect(beta.Id == alpha.Id + 1, "ids are monotonic"); + + // Typed method error: a missing key reports KvError::KeyNotFound. + try + { + store.Get("missing"); + Expect(false, "expected KvException for missing key"); + } + catch (KvException e) + { + Expect(e.Code == KvException.KeyNotFound, $"KeyNotFound code == 1001 (got {e.Code})"); + Expect(e.Message == "key not found", $"KeyNotFound message (got '{e.Message}')"); + Expect(e is WeaveFFIException, "typed exception extends the brand exception"); + } + + // Entry is a plain value class: non-empty list and map fields live + // directly on the instance. + var built = new Entry( + 7, + "built", + payload, + 1000, + null, + new[] { "hot", "fast" }, + new Dictionary { ["source"] = "test", ["env"] = "prod" }); + Expect(built.Tags.SequenceEqual(new[] { "hot", "fast" }), "built tags"); + Expect(built.Metadata["source"] == "test" && built.Metadata["env"] == "prod", "built metadata"); + Expect(built.ExpiresAt == null, "built ExpiresAt null"); + + // Cross-module call: Stats lives in kv.stats, store is a kv.Store. + var stats = KvStats.GetStats(store); + Expect(stats.TotalEntries == 2, "stats total entries == 2"); + Expect(stats.TotalBytes == 6, "stats total bytes == 6"); + Expect(stats.ExpiredEntries == 0, "stats expired entries == 0"); + + // Eviction listener: a C# implementation of the callback interface. + // Delete fires it synchronously with the decoded Entry and the reason. + var listener = new RecordingListener(keepAfter: 2); + store.SetEvictionListener(listener); + Expect(store.Delete("beta"), "delete beta"); + Expect(listener.Evictions.Count == 1 + && listener.Evictions[0].Key == "beta" + && listener.Evictions[0].Value == EvictionReason.Deleted, + "eviction fired for beta with Deleted"); + Expect(!store.Delete("beta"), "second delete reports missing"); + Expect(listener.Evictions.Count == 1, "no eviction for a missing key"); + + // An already-expired entry is evicted on read with Expired, and the + // typed error still reaches the caller. The listener returned false + // on this second call, so the store detaches it. + Expect(store.Put("expiring", new byte[] { 9 }, EntryKind.Volatile, -1), "put expired entry"); + try + { + store.Get("expiring"); + Expect(false, "expected KvException for expired key"); + } + catch (KvException e) + { + Expect(e.Code == KvException.Expired, $"Expired code == 1002 (got {e.Code})"); + } + Expect(listener.Evictions.Count == 2 + && listener.Evictions[1].Key == "expiring" + && listener.Evictions[1].Value == EvictionReason.Expired, + "eviction fired for expiring with Expired"); + Expect(store.Put("again", payload, EntryKind.Volatile, null), "put again"); + Expect(store.Delete("again"), "delete again"); + Expect(listener.Evictions.Count == 2, "detached listener is not notified"); + + // A throwing listener surfaces to the caller as the foreign error + // (code -4). Delete has a domain, so the wrapper's typed check falls + // back to the brand exception for the reserved code. + var thrower = new ThrowingListener(); + store.SetEvictionListener(thrower); + Expect(store.Put("doomed", payload, EntryKind.Volatile, null), "put doomed"); + try + { + store.Delete("doomed"); + Expect(false, "expected WeaveFFIException from throwing listener"); + } + catch (KvException) + { + Expect(false, "foreign error must not wear the domain exception type"); + } + catch (WeaveFFIException e) + { + Expect(e.Code == WeaveFFIException.ForeignErrorCode, $"foreign error code == -4 (got {e.Code})"); + Expect(e.Message.Contains("listener refused doomed"), + $"foreign error carries the exception message (got '{e.Message}')"); + } + Expect(thrower.Calls == 1, "throwing listener was called once"); + Expect(store.Count() == 1, "doomed was removed before the listener ran"); + + // Replacing and clearing the listener stops delivery. + var replacement = new RecordingListener(keepAfter: int.MaxValue); + store.SetEvictionListener(replacement); + Expect(store.Put("r1", payload, EntryKind.Volatile, null) && store.Delete("r1"), "delete r1"); + Expect(replacement.Evictions.Count == 1 && thrower.Calls == 1, "replacement receives, old does not"); + store.ClearEvictionListener(); + Expect(store.Put("r2", payload, EntryKind.Volatile, null) && store.Delete("r2"), "delete r2"); + Expect(replacement.Evictions.Count == 1, "cleared listener is not notified"); + store.ClearEvictionListener(); + + // Deprecated method still round-trips (volatile put, no TTL). #pragma warning disable CS0618 - Expect(store.LegacyPut("legacy", payload), "legacy_put inserts"); + Expect(store.LegacyPut("legacy", payload), "legacy_put inserts"); #pragma warning restore CS0618 - Expect(store.Count() == 1, "count == 1 after legacy_put"); + Expect(store.Count() == 2, "count == 2 after legacy_put"); + + // Share: a second wrapper to the SAME native object. Mutations through + // one are visible through the other; disposing one leaves the other + // usable. + var twin = store.Share(); + Expect(!ReferenceEquals(twin, store), "Share returns a distinct wrapper"); + Expect(twin.Handle == store.Handle, "Share wraps the same native pointer"); + Expect(twin.Count() == 2, "twin sees the same entries"); + Expect(twin.Put("via-twin", payload, EntryKind.Persistent, null), "put via twin"); + Expect(store.Count() == 3 && store.Get("via-twin") != null, "store sees the twin's put"); + twin.Dispose(); + twin.Dispose(); + try + { + twin.Count(); + Expect(false, "expected ObjectDisposedException on disposed twin"); + } + catch (ObjectDisposedException) + { + } + Expect(store.Count() == 3, "store survives disposing its twin"); + + // Fork: an independent copy. + var forked = store.Fork(); + Expect(forked.Handle != store.Handle, "Fork is a different native object"); + Expect(forked.Count() == 3, "fork copied live entries"); + Expect(forked.Put("only-in-fork", payload, EntryKind.Volatile, null), "put into fork"); + Expect(forked.Count() == 4 && store.Count() == 3, "fork and original diverge"); + + // `Store?` as parameter and return. + var empty = Store.Open("/tmp/conformance-kvstore-dotnet-empty"); + Expect(empty.Larger(null) == null, "empty.Larger(null) is null"); + var bigger = empty.Larger(store); + Expect(bigger != null && bigger.Handle == store.Handle, "empty.Larger(store) is store"); + Expect(bigger.Count() == 3, "returned larger store is usable"); + bigger.Dispose(); + var own = store.Larger(null); + Expect(own != null && own.Handle == store.Handle, "store.Larger(null) is store itself"); + own.Dispose(); + var forkWins = store.Larger(forked); + Expect(forkWins != null && forkWins.Handle == forked.Handle, "store.Larger(fork) is the fork"); + forkWins.Dispose(); + Expect(store.Count() == 3 && forked.Count() == 4, "both stores alive after Larger releases"); + + // A record carrying objects: `store` is required, `mirror` optional. + var info = store.Describe("primary", null); + Expect(info.Label == "primary", "describe label"); + Expect(info.Count == 3, $"describe count == 3 (got {info.Count})"); + Expect(info.Store.Handle == store.Handle, "describe().Store is the same native object"); + Expect(info.Mirror == null, "describe().Mirror absent"); + Expect(info.Store.Count() == 3, "describe().Store is usable"); + var mirrored = store.Describe("mirrored", forked); + Expect(mirrored.Mirror != null && mirrored.Mirror.Handle == forked.Handle, + "describe().Mirror is the fork"); + Expect(mirrored.Mirror.Count() == 4, "describe().Mirror is usable"); + + // A list of objects as a return, and objects inside a list and an + // optional record as parameters (each encoded token is a fresh clone). + var many = Store.OpenMany(new[] { "/tmp/many-a", "/tmp/many-b" }); + Expect(many.Length == 2, "open_many returns two stores"); + Expect(many[0].Handle != many[1].Handle, "open_many stores are distinct"); + Expect(many[0].Count() == 0 && many[1].Count() == 0, "open_many stores start empty"); + Expect(many[0].Put("m", payload, EntryKind.Volatile, null), "put into many[0]"); + Expect(Store.TotalCount(many, null) == 1, "total_count over the list"); + Expect(Store.TotalCount(many, info) == 1 + 3, "total_count adds the record's store"); + Expect(Store.TotalCount(many, mirrored) == 1 + 3, "total_count ignores the mirror"); + Expect(Store.TotalCount(new Store[0], null) == 0, "total_count of nothing"); + var handBuilt = new StoreInfo("hand", forked, store, 0); + Expect(Store.TotalCount(new[] { store, forked }, handBuilt) == 3 + 4 + 4, + "total_count with a consumer-built record"); + Expect(store.Count() == 3 && forked.Count() == 4 && many[0].Count() == 1, + "every store survives being encoded into buffers"); + try + { + Store.OpenMany(new[] { "/tmp/ok", "" }); + Expect(false, "expected KvException from open_many"); + } + catch (KvException e) + { + Expect(e.Code == KvException.IoError, "open_many reports IoError"); + } - // Async: an immediately-expired entry gives compact 3 bytes to - // reclaim; the Task completes from the producer's worker thread. - Expect(store.Put("doomed", payload, EntryKind.Volatile, 0), "put doomed"); - long reclaimed = await store.Compact(); - Expect(reclaimed == 3, $"compact reclaimed 3 bytes (got {reclaimed})"); - Expect(store.Count() == 1, "live entry survives compact"); + // Async: an immediately-expired entry gives compact 3 bytes to + // reclaim; the Task completes from the producer's worker thread. + Expect(store.Put("doomed", payload, EntryKind.Volatile, 0), "put doomed"); + long reclaimed = await store.Compact(); + Expect(reclaimed == 3, $"compact reclaimed 3 bytes (got {reclaimed})"); + Expect(store.Count() == 3, "live entries survive compact"); + Expect((await forked.Compact()) == 0, "nothing to compact in the fork"); - store.Clear(); - Expect(store.Count() == 0, "store empty after clear"); + store.Clear(); + Expect(store.Count() == 0, "store empty after clear"); + Expect(info.Store.Count() == 0, "describe().Store observes the clear"); - // Store.Dispose lowers to the destroy symbol when the using block - // closes; no explicit close call exists (or is needed). + // Release every wrapper. Records holding objects expose IDisposable + // wrappers the consumer disposes; double Dispose is a no-op. + foreach (var s in many) + { + s.Dispose(); + s.Dispose(); + } + info.Store.Dispose(); + mirrored.Store.Dispose(); + mirrored.Mirror.Dispose(); + empty.Dispose(); + forked.Dispose(); + store.Dispose(); + store.Dispose(); + try + { + KvStats.GetStats(store); + Expect(false, "expected ObjectDisposedException for a disposed store argument"); + } + catch (ObjectDisposedException) + { + } + try + { + await store.Compact(); + Expect(false, "expected ObjectDisposedException from async on a disposed store"); + } + catch (ObjectDisposedException) + { + } + + using (var scoped = Store.Open("/tmp/conformance-kvstore-dotnet-scoped")) + { + Expect(scoped.Count() == 0, "scoped store opens empty"); } Console.WriteLine("dotnet/kvstore: OK"); diff --git a/conformance/go/codec.go b/conformance/go/codec.go new file mode 100644 index 00000000..37eea906 --- /dev/null +++ b/conformance/go/codec.go @@ -0,0 +1,486 @@ +// Conformance consumer: codec sample, Go target. +// +// Drives the value-buffer round-trip oracle through the generated cgo +// bindings. For Scalars and Composite it fetches the producer's canonical +// fixture (producer encodes, Go decodes), checks every field against the +// values in samples/codec/src/lib.rs, hands the fixture back to verify_* +// (Go encodes, producer decodes and compares), and re-encodes through +// roundtrip_* comparing field by field. It then builds its own values with +// edge cases (empty strings, lists and maps, embedded NUL and non-BMP text, +// int64/uint64 extremes, NaN, the infinities, negative zero, present-but- +// empty optionals, lists of absent optionals) and round-trips them, exercises +// every Shape variant, the top-level optional/list/map/string/bytes/scalar +// echoes, the typed CodecError on a mismatch, and Holder: object tokens in a +// record field, an optional, and a list, with primary_of returning a wrapper +// to the same object, same_primary proving identity, and every wrapper +// released (double Close harmless, use after Close trapped). Exits 0 on +// success; aborts (non-zero) on any mismatch. + +package main + +import ( + "errors" + "fmt" + "math" + "os" + "strings" + + wv "__MODPATH__" +) + +func expect(cond bool, msg string) { + if !cond { + fmt.Fprintln(os.Stderr, "assertion failed:", msg) + os.Exit(1) + } +} + +// Bit-exact float comparison so NaN payloads and the sign of zero count. +func sameF64(a, b float64) bool { return math.Float64bits(a) == math.Float64bits(b) } +func sameF32(a, b float32) bool { return math.Float32bits(a) == math.Float32bits(b) } + +func sameStrPtr(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func sameI64Ptr(a, b *int64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func sameBytes(a, b []byte) bool { + if (a == nil) != (b == nil) || len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func sameScalars(a, b wv.Scalars) bool { + return a.I8Value == b.I8Value && a.U8Value == b.U8Value && + a.I16Value == b.I16Value && a.U16Value == b.U16Value && + a.I32Value == b.I32Value && a.U32Value == b.U32Value && + a.I64Value == b.I64Value && a.U64Value == b.U64Value && + sameF32(a.F32Value, b.F32Value) && sameF64(a.F64Value, b.F64Value) && + a.Flag == b.Flag && a.Color == b.Color +} + +func sameShape(a, b wv.Shape) bool { + switch x := a.(type) { + case nil: + return b == nil + case wv.ShapeEmpty: + _, ok := b.(wv.ShapeEmpty) + return ok + case wv.ShapeCircle: + y, ok := b.(wv.ShapeCircle) + return ok && sameF64(x.Radius, y.Radius) + case wv.ShapeRect: + y, ok := b.(wv.ShapeRect) + return ok && sameF32(x.Width, y.Width) && sameF32(x.Height, y.Height) + case wv.ShapeLabeled: + y, ok := b.(wv.ShapeLabeled) + return ok && x.Label == y.Label && x.Count == y.Count + case wv.ShapeNested: + y, ok := b.(wv.ShapeNested) + return ok && sameScalars(x.Inner, y.Inner) && sameStrPtr(x.Note, y.Note) + } + return false +} + +func sameComposite(a, b wv.Composite) (bool, string) { + switch { + case a.Name != b.Name: + return false, "name" + case !sameBytes(a.Blob, b.Blob): + return false, "blob" + case !sameI64Ptr(a.SomeI64, b.SomeI64): + return false, "some_i64" + case !sameI64Ptr(a.NoneI64, b.NoneI64): + return false, "none_i64" + case !sameStrPtr(a.SomeText, b.SomeText): + return false, "some_text" + case len(a.Names) != len(b.Names): + return false, "names" + case len(a.Matrix) != len(b.Matrix): + return false, "matrix" + case len(a.Empty) != len(b.Empty): + return false, "empty" + case len(a.ByName) != len(b.ByName): + return false, "by_name" + case len(a.ById) != len(b.ById): + return false, "by_id" + case !sameScalars(a.Scalars, b.Scalars): + return false, "scalars" + case !sameShape(a.Shape, b.Shape): + return false, "shape" + case len(a.Shapes) != len(b.Shapes): + return false, "shapes" + case !sameShape(a.MaybeShape, b.MaybeShape): + return false, "maybe_shape" + case !sameBytes(a.MaybeList, b.MaybeList): + return false, "maybe_list" + case len(a.Sparse) != len(b.Sparse): + return false, "sparse" + case len(a.Colors) != len(b.Colors): + return false, "colors" + } + for i := range a.Names { + if a.Names[i] != b.Names[i] { + return false, fmt.Sprintf("names[%d]", i) + } + } + for i := range a.Matrix { + if len(a.Matrix[i]) != len(b.Matrix[i]) { + return false, fmt.Sprintf("matrix[%d]", i) + } + for j := range a.Matrix[i] { + if a.Matrix[i][j] != b.Matrix[i][j] { + return false, fmt.Sprintf("matrix[%d][%d]", i, j) + } + } + } + for i := range a.Empty { + if !sameF64(a.Empty[i], b.Empty[i]) { + return false, fmt.Sprintf("empty[%d]", i) + } + } + for k, v := range a.ByName { + if w, ok := b.ByName[k]; !ok || v != w { + return false, "by_name[" + k + "]" + } + } + for k, v := range a.ById { + if w, ok := b.ById[k]; !ok || !sameScalars(v, w) { + return false, fmt.Sprintf("by_id[%d]", k) + } + } + for i := range a.Shapes { + if !sameShape(a.Shapes[i], b.Shapes[i]) { + return false, fmt.Sprintf("shapes[%d]", i) + } + } + for i := range a.Sparse { + x, y := a.Sparse[i], b.Sparse[i] + if (x == nil) != (y == nil) || (x != nil && *x != *y) { + return false, fmt.Sprintf("sparse[%d]", i) + } + } + for i := range a.Colors { + if a.Colors[i] != b.Colors[i] { + return false, fmt.Sprintf("colors[%d]", i) + } + } + return true, "" +} + +func expectSameComposite(a, b wv.Composite, what string) { + same, field := sameComposite(a, b) + expect(same, fmt.Sprintf("%s: field %s differs\n a: %+v\n b: %+v", what, field, a, b)) +} + +func catchPanic(f func()) (v any) { + defer func() { v = recover() }() + f() + return nil +} + +func ptrI64(v int64) *int64 { return &v } +func ptrStr(v string) *string { return &v } +func ptrBool(v bool) *bool { return &v } + +// canonicalScalars mirrors `canonical_scalars()` in the producer. +func canonicalScalars() wv.Scalars { + return wv.Scalars{ + I8Value: -8, + U8Value: 200, + I16Value: -16_000, + U16Value: 60_000, + I32Value: -2_000_000_000, + U32Value: 4_000_000_000, + I64Value: -9_007_199_254_740_993, + U64Value: 18_446_744_073_709_551_615, + F32Value: 1.5, + F64Value: -2.25e100, + Flag: true, + Color: wv.ColorBlue, + } +} + +func main() { + // ── Scalars: producer encodes, Go decodes ── + s := wv.SampleScalars() + expect(sameScalars(s, canonicalScalars()), fmt.Sprintf("sample_scalars matches the fixture (got %+v)", s)) + expect(s.Color == 7, "Blue keeps its explicit discriminant 7") + + // Go encodes, producer decodes and compares. + ok, err := wv.VerifyScalars(s) + expect(err == nil && ok, fmt.Sprintf("verify_scalars(sample) (err %v)", err)) + back := wv.RoundtripScalars(s) + expect(sameScalars(back, s), fmt.Sprintf("roundtrip_scalars echoes the sample (got %+v)", back)) + + // A mismatch is the typed CodecError. + changed := s + changed.U64Value-- + ok, err = wv.VerifyScalars(changed) + var cerr *wv.CodecError + expect(!ok && errors.As(err, &cerr), fmt.Sprintf("verify_scalars(changed) yields *CodecError (got %T %v)", err, err)) + expect(cerr.Code == wv.CodecErrorMismatch, fmt.Sprintf("mismatch code (got %d)", cerr.Code)) + expect(cerr.Code == 1, fmt.Sprintf("mismatch code is 1 (got %d)", cerr.Code)) + expect(cerr.Message == "value does not match the canonical fixture", fmt.Sprintf("mismatch default message (got %q)", cerr.Message)) + + // Extremes and IEEE specials through the buffered record. + extreme := wv.Scalars{ + I8Value: math.MinInt8, + U8Value: math.MaxUint8, + I16Value: math.MinInt16, + U16Value: math.MaxUint16, + I32Value: math.MinInt32, + U32Value: math.MaxUint32, + I64Value: math.MaxInt64, + U64Value: math.MaxUint64, + F32Value: float32(math.NaN()), + F64Value: math.Copysign(0, -1), + Flag: false, + Color: wv.ColorRed, + } + back = wv.RoundtripScalars(extreme) + expect(sameScalars(back, extreme), fmt.Sprintf("roundtrip_scalars keeps extremes, NaN, and -0 (got %+v)", back)) + expect(math.IsNaN(float64(back.F32Value)) && math.Signbit(back.F64Value) && back.F64Value == 0, + "NaN and negative zero decode as such") + extreme.I64Value, extreme.F64Value, extreme.F32Value = math.MinInt64, math.Inf(-1), float32(math.Inf(1)) + back = wv.RoundtripScalars(extreme) + expect(sameScalars(back, extreme), "roundtrip_scalars keeps MinInt64 and the infinities") + + // ── Composite: producer encodes, Go decodes ── + c := wv.SampleComposite() + expect(c.Name == "héllo wörld ✓", fmt.Sprintf("name (got %q)", c.Name)) + expect(sameBytes(c.Blob, []byte{0, 1, 2, 253, 254, 255}), fmt.Sprintf("blob (got %v)", c.Blob)) + expect(c.SomeI64 != nil && *c.SomeI64 == math.MinInt64, "some_i64 == i64::MIN") + expect(c.NoneI64 == nil, "none_i64 absent") + expect(c.SomeText != nil && *c.SomeText == "", "some_text is a present empty string") + expect(len(c.Names) == 3 && c.Names[0] == "a" && c.Names[1] == "" && c.Names[2] == "ccc", + fmt.Sprintf("names (got %q)", c.Names)) + expect(len(c.Matrix) == 3 && len(c.Matrix[0]) == 3 && c.Matrix[0][2] == 3 && + len(c.Matrix[1]) == 0 && len(c.Matrix[2]) == 1 && c.Matrix[2][0] == -4, + fmt.Sprintf("matrix (got %v)", c.Matrix)) + expect(len(c.Empty) == 0, "empty list") + expect(len(c.ByName) == 3 && c.ByName["one"] == 1 && c.ByName["two"] == 2 && c.ByName["neg"] == -3, + fmt.Sprintf("by_name (got %v)", c.ByName)) + expect(len(c.ById) == 2, "by_id has two entries") + expect(sameScalars(c.ById[-1], canonicalScalars()), "by_id[-1] is the canonical scalars") + unflagged := canonicalScalars() + unflagged.Flag = false + expect(sameScalars(c.ById[42], unflagged), "by_id[42] is the canonical scalars with flag false") + expect(sameScalars(c.Scalars, canonicalScalars()), "nested scalars") + expect(sameShape(c.Shape, wv.ShapeLabeled{Label: "tag", Count: 3}), fmt.Sprintf("shape (got %+v)", c.Shape)) + expect(len(c.Shapes) == 5, fmt.Sprintf("shapes has one of each variant (got %d)", len(c.Shapes))) + expect(sameShape(c.Shapes[0], wv.ShapeEmpty{}), "shapes[0] Empty") + expect(sameShape(c.Shapes[1], wv.ShapeCircle{Radius: 2.5}), "shapes[1] Circle") + expect(sameShape(c.Shapes[2], wv.ShapeRect{Width: 1.0, Height: 0.5}), "shapes[2] Rect") + expect(sameShape(c.Shapes[3], wv.ShapeLabeled{Label: "", Count: -1}), "shapes[3] Labeled") + expect(sameShape(c.Shapes[4], wv.ShapeNested{Inner: canonicalScalars(), Note: ptrStr("n")}), "shapes[4] Nested") + expect(sameShape(c.MaybeShape, wv.ShapeNested{Inner: canonicalScalars(), Note: nil}), "maybe_shape Nested without note") + expect(sameBytes(c.MaybeList, []byte{9, 8}), fmt.Sprintf("maybe_list (got %v)", c.MaybeList)) + expect(len(c.Sparse) == 3 && c.Sparse[0] != nil && *c.Sparse[0] && c.Sparse[1] == nil && + c.Sparse[2] != nil && !*c.Sparse[2], "sparse [true, nil, false]") + expect(len(c.Colors) == 3 && c.Colors[0] == wv.ColorRed && c.Colors[1] == wv.ColorGreen && c.Colors[2] == wv.ColorBlue, + fmt.Sprintf("colors (got %v)", c.Colors)) + + // Go encodes, producer decodes and compares; then echo and compare. + ok, err = wv.VerifyComposite(c) + expect(err == nil && ok, fmt.Sprintf("verify_composite(sample) (err %v)", err)) + expectSameComposite(wv.RoundtripComposite(c), c, "roundtrip_composite(sample)") + desc := wv.DescribeComposite(c) + expect(strings.Contains(desc, "name: \"héllo wörld ✓\"") && strings.Contains(desc, "some_i64: Some(-9223372036854775808)"), + fmt.Sprintf("describe_composite renders the sample (got %s)", desc)) + + // A one-field change is a mismatch. + c2 := wv.RoundtripComposite(c) + c2.Sparse[1] = ptrBool(true) + ok, err = wv.VerifyComposite(c2) + cerr = nil + expect(!ok && errors.As(err, &cerr) && cerr.Code == wv.CodecErrorMismatch, "verify_composite(changed) is Mismatch") + + // A Composite built from scratch with edge values. + custom := wv.Composite{ + Name: "a\x00b \U0001F600 ünï", + Blob: []byte{}, + SomeI64: ptrI64(math.MaxInt64), + NoneI64: ptrI64(math.MinInt64), + SomeText: nil, + Names: []string{}, + Matrix: [][]int32{{}, {math.MinInt32, math.MaxInt32}}, + Empty: []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.Copysign(0, -1), 5e-324}, + ByName: map[string]int64{"": 0, "k": math.MinInt64, "z": math.MaxInt64}, + ById: map[int32]wv.Scalars{math.MinInt32: extreme, math.MaxInt32: canonicalScalars(), 0: {}}, + Scalars: wv.Scalars{}, + Shape: wv.ShapeEmpty{}, + Shapes: []wv.Shape{}, + MaybeShape: wv.ShapeNested{ + Inner: extreme, + Note: ptrStr(""), + }, + MaybeList: []byte{}, + Sparse: []*bool{nil, nil}, + Colors: []wv.Color{}, + } + echoed := wv.RoundtripComposite(custom) + expectSameComposite(echoed, custom, "roundtrip_composite(custom)") + expect(echoed.MaybeList != nil && len(echoed.MaybeList) == 0, "present-but-empty optional list stays present") + expect(echoed.SomeText == nil, "absent optional string stays absent") + expect(math.IsNaN(echoed.Empty[0]) && math.IsInf(echoed.Empty[1], 1) && math.IsInf(echoed.Empty[2], -1) && + math.Signbit(echoed.Empty[3]) && echoed.Empty[4] == 5e-324, "float specials survive inside a list") + ok, err = wv.VerifyComposite(custom) + cerr = nil + expect(!ok && errors.As(err, &cerr), "the custom composite is not the fixture") + desc = wv.DescribeComposite(custom) + expect(strings.Contains(desc, "name: \"a\\0b 😀 ünï\"") && strings.Contains(desc, "empty: [NaN, inf, -inf, -0.0, 5e-324]"), + fmt.Sprintf("describe_composite renders the custom value (got %s)", desc)) + + // An absent optional list and absent optional shape. + custom.MaybeList = nil + custom.MaybeShape = nil + echoed = wv.RoundtripComposite(custom) + expect(echoed.MaybeList == nil && echoed.MaybeShape == nil, "absent optionals stay absent") + + // ── Shape: every variant, alone and in a list ── + shapes := []wv.Shape{ + wv.ShapeEmpty{}, + wv.ShapeCircle{Radius: math.Inf(1)}, + wv.ShapeRect{Width: -0.0, Height: float32(math.Inf(-1))}, + wv.ShapeLabeled{Label: "✓ label", Count: math.MinInt32}, + wv.ShapeNested{Inner: extreme, Note: nil}, + wv.ShapeNested{Inner: canonicalScalars(), Note: ptrStr("note")}, + } + for i, sh := range shapes { + expect(sameShape(wv.RoundtripShape(sh), sh), fmt.Sprintf("roundtrip_shape[%d] (%+v)", i, sh)) + } + echoedShapes := wv.RoundtripShapes(shapes) + expect(len(echoedShapes) == len(shapes), "roundtrip_shapes length") + for i := range shapes { + expect(sameShape(echoedShapes[i], shapes[i]), fmt.Sprintf("roundtrip_shapes[%d]", i)) + } + expect(len(wv.RoundtripShapes(nil)) == 0, "roundtrip_shapes of nothing") + expect(wv.DescribeShape(wv.ShapeEmpty{}) == "Empty", "describe_shape(Empty)") + expect(wv.DescribeShape(wv.ShapeCircle{Radius: 2.5}) == "Circle { radius: 2.5 }", + fmt.Sprintf("describe_shape(Circle) (got %q)", wv.DescribeShape(wv.ShapeCircle{Radius: 2.5}))) + expect(wv.DescribeShape(wv.ShapeLabeled{Label: "tag", Count: 3}) == "Labeled { label: \"tag\", count: 3 }", + "describe_shape(Labeled)") + switch v := wv.RoundtripShape(wv.ShapeRect{Width: 1, Height: 2}).(type) { + case wv.ShapeRect: + expect(v.Width == 1 && v.Height == 2, "Rect payload") + default: + expect(false, fmt.Sprintf("roundtrip_shape(Rect) returned %T", v)) + } + + // ── Top-level buffered and direct echoes ── + expect(wv.RoundtripOptI64(nil) == nil, "roundtrip_opt_i64(nil)") + o := wv.RoundtripOptI64(ptrI64(math.MinInt64)) + expect(o != nil && *o == math.MinInt64, "roundtrip_opt_i64(MinInt64)") + o = wv.RoundtripOptI64(ptrI64(0)) + expect(o != nil && *o == 0, "roundtrip_opt_i64(0) is present") + m := wv.RoundtripMap(map[string]int64{"": math.MaxInt64, "héllo": -1, "x": 0}) + expect(len(m) == 3 && m[""] == math.MaxInt64 && m["héllo"] == -1 && m["x"] == 0, fmt.Sprintf("roundtrip_map (got %v)", m)) + expect(len(wv.RoundtripMap(nil)) == 0, "roundtrip_map(empty)") + expect(wv.RoundtripString("héllo wörld ✓ 😀") == "héllo wörld ✓ 😀", "roundtrip_string unicode") + expect(wv.RoundtripString("") == "", "roundtrip_string empty") + expect(sameBytes(wv.RoundtripBytes([]byte{0, 255, 128}), []byte{0, 255, 128}), "roundtrip_bytes") + expect(len(wv.RoundtripBytes([]byte{})) == 0, "roundtrip_bytes empty") + expect(wv.RoundtripI64(math.MinInt64) == math.MinInt64, "roundtrip_i64 MinInt64") + expect(wv.RoundtripI64(math.MaxInt64) == math.MaxInt64, "roundtrip_i64 MaxInt64") + expect(wv.RoundtripU64(math.MaxUint64) == math.MaxUint64, "roundtrip_u64 MaxUint64") + expect(wv.RoundtripU64(1<<63) == 1<<63, "roundtrip_u64 2^63") + expect(math.IsNaN(wv.RoundtripF64(math.NaN())), "roundtrip_f64 NaN") + expect(math.IsInf(wv.RoundtripF64(math.Inf(1)), 1) && math.IsInf(wv.RoundtripF64(math.Inf(-1)), -1), "roundtrip_f64 infinities") + negZero := wv.RoundtripF64(math.Copysign(0, -1)) + expect(negZero == 0 && math.Signbit(negZero), "roundtrip_f64 -0") + expect(wv.RoundtripF64(5e-324) == 5e-324 && wv.RoundtripF64(math.MaxFloat64) == math.MaxFloat64, "roundtrip_f64 subnormal and max") + expect(wv.RoundtripBool(true) && !wv.RoundtripBool(false), "roundtrip_bool") + expect(wv.RoundtripColor(wv.ColorBlue) == wv.ColorBlue && wv.RoundtripColor(wv.ColorGreen) == 1, "roundtrip_color") + + // ── Holder: objects inside buffers ── + h := wv.MakeHolder(10, true) + expect(h.Primary != nil && h.Primary.Value() == 10, "holder.primary value 10") + expect(h.Spare != nil && h.Spare.Value() == 11, "holder.spare value 11") + expect(len(h.Many) == 3 && h.Many[0].Value() == 12 && h.Many[1].Value() == 13 && h.Many[2].Value() == 14, + "holder.many values 12..14") + // Each encoding mints fresh references; the producer adopts and drops + // them, so repeated calls keep the wrappers valid. + expect(wv.SumHolder(h) == 10+11+12+13+14, "sum_holder(holder)") + expect(wv.SumHolder(h) == 60, "sum_holder is repeatable") + expect(h.Primary.Value() == 10, "wrapper alive after being encoded twice") + + // primary_of returns a wrapper to the SAME object as holder.primary. + p := wv.PrimaryOf(h) + expect(p != nil && p != h.Primary, "primary_of yields a distinct wrapper") + expect(p.Value() == 10, "primary_of value 10") + expect(wv.SamePrimary(h, wv.Holder{Primary: p}), "same_primary(holder, {primary_of}) is true") + expect(wv.SamePrimary(h, h), "same_primary(holder, holder) is true") + expect(!wv.SamePrimary(h, wv.Holder{Primary: h.Spare}), "same_primary against the spare is false") + other := wv.MakeHolder(10, true) + expect(!wv.SamePrimary(h, other), "equal values, distinct objects") + + // Without a spare. + bare := wv.MakeHolder(0, false) + expect(bare.Spare == nil, "make_holder(0, false) has no spare") + expect(wv.SumHolder(bare) == 0+2+3+4, "sum_holder without spare") + + // A holder built from Go-constructed tokens. + mine := wv.Holder{ + Primary: wv.NewToken(-5), + Spare: nil, + Many: []*wv.Token{wv.NewToken(1), wv.NewToken(2)}, + } + expect(mine.Primary.Value() == -5, "new token value") + expect(wv.SumHolder(mine) == -2, "sum_holder(mine)") + mp := wv.PrimaryOf(mine) + expect(mp.Value() == -5 && wv.SamePrimary(mine, wv.Holder{Primary: mp}), "primary_of(mine) aliases the Go-made token") + expect(wv.SumHolder(wv.Holder{Primary: mp, Spare: mp, Many: []*wv.Token{mp, mp}}) == -20, + "the same wrapper may be encoded in several positions at once") + big := wv.NewToken(math.MaxInt64) + expect(big.Value() == math.MaxInt64 && wv.SumHolder(wv.Holder{Primary: big}) == math.MaxInt64, "token carries MaxInt64") + big.Close() + + // Release: closing the holder's wrapper leaves primary_of's reference + // alive; every Close is idempotent; a closed wrapper traps on use and + // can't be encoded. + h.Primary.Close() + h.Primary.Close() + expect(p.Value() == 10, "primary_of's reference survives closing holder.primary") + expect(catchPanic(func() { h.Primary.Value() }) != nil, "use after Close traps") + expect(catchPanic(func() { wv.SumHolder(h) }) != nil, "encoding a closed token traps") + p.Close() + p.Close() + h.Spare.Close() + for _, t := range h.Many { + t.Close() + t.Close() + } + other.Primary.Close() + other.Spare.Close() + for _, t := range other.Many { + t.Close() + } + bare.Primary.Close() + for _, t := range bare.Many { + t.Close() + } + mine.Primary.Close() + for _, t := range mine.Many { + t.Close() + } + mp.Close() + mp.Close() + + fmt.Println("go/codec: OK") +} diff --git a/conformance/go/events.go b/conformance/go/events.go index 9e4c172b..8b098142 100644 --- a/conformance/go/events.go +++ b/conformance/go/events.go @@ -1,16 +1,30 @@ // Conformance consumer: events sample, Go target. // -// Exercises the cgo //export listener trampoline (register -> the producer -// fires the Go closure synchronously on send -> unregister stops delivery) -// and the opaque-iterator ABI behind GetMessages. Everything here is -// non-throwing, so the wrappers have plain returns (no error results). -// Exits 0 on success; aborts (non-zero) on any mismatch. +// Exercises the ABI 2 surface end to end: a Go type implementing the +// generated Subscriber callback interface (crossing as a cgo.Handle plus a +// static vtable of //export trampolines), the reference-counted EventBus +// object (including a second wrapper handed back through OnAttached, its +// explicit Close, and a harmless double Close), Delivery return values +// steering Publish's accepted count, the Message record decoded from a +// borrowed value buffer inside a callback, the async PublishLater bridge, +// the lazy iter.Seq behind Messages, the optional LastMessage, the free +// function RouteOnce, and the foreign-error path: a subscriber that panics +// surfaces to the caller as a recoverable *WeaveFFIError with code -4 and +// leaves the bus usable. Finally ClearSubscribers must let the producer's +// `free` entry run, which is observed through a Go finalizer on the +// implementation once its handle is deleted. Exits 0 on success; aborts +// (non-zero) on any mismatch. package main import ( + "errors" "fmt" "os" + "runtime" + "strings" + "sync/atomic" + "time" wv "__MODPATH__" ) @@ -22,44 +36,216 @@ func expect(cond bool, msg string) { } } -func main() { - var received []string - sub := wv.RegisterMessageListener(func(message string) { - received = append(received, message) - }) - expect(sub > 0, "listener id positive") - - wv.SendMessage("alpha") - wv.SendMessage("beta") - expect(len(received) == 2 && received[0] == "alpha" && received[1] == "beta", - fmt.Sprintf("listener received sends (got %v)", received)) - - // GetMessages returns a lazy iter.Seq[string]: one producer next per step. - var msgs []string - for m := range wv.GetMessages() { - msgs = append(msgs, m) +// subState is the observable side of a subscriber. It's held separately +// from the implementing value so the implementation itself can become +// unreachable (and be finalized) once the producer frees its handle. +type subState struct { + name string + skip string + fails bool + routed []string + received []wv.Message + attached int + bus *wv.EventBus + freed *atomic.Int32 +} + +// recorder implements wv.Subscriber. The subscriber's configured skip topic +// is skipped and "stop" accepts and stops later subscribers. A subscriber +// marked `fails` panics inside Route for "boom-route" and inside OnMessage +// for "boom-message"; the others accept both, so the earlier subscribers +// still receive them. +type recorder struct { + st *subState +} + +func (r *recorder) Route(topic string) wv.Delivery { + r.st.routed = append(r.st.routed, topic) + switch topic { + case r.st.skip: + return wv.DeliverySkip + case "stop": + return wv.DeliveryAcceptAndStop + case "boom-route": + if r.st.fails { + panic("subscriber " + r.st.name + " rejected topic") + } + } + return wv.DeliveryAccept +} + +func (r *recorder) OnMessage(message wv.Message) int64 { + if message.Topic == "boom-message" && r.st.fails { + panic(fmt.Errorf("subscriber %s failed on message %d", r.st.name, message.Seq)) } - expect(len(msgs) == 2 && msgs[0] == "alpha" && msgs[1] == "beta", - fmt.Sprintf("iterator yields messages in order (got %v)", msgs)) + r.st.received = append(r.st.received, message) + return int64(len(r.st.received)) +} + +func (r *recorder) OnAttached(bus *wv.EventBus) { + r.st.attached++ + // The reference is ours; keep the wrapper so main can prove it aliases + // the bus that called subscribe. + r.st.bus = bus +} + +// attach subscribes a fresh recorder and returns only its state. The +// recorder value stays reachable solely through the cgo.Handle the +// producer holds, so its finalizer fires once the producer calls `free`. +func attach(bus *wv.EventBus, name, skip string, fails bool, freed *atomic.Int32) (*subState, int64) { + st := &subState{name: name, skip: skip, fails: fails, freed: freed} + r := &recorder{st: st} + runtime.SetFinalizer(r, func(*recorder) { freed.Add(1) }) + return st, bus.Subscribe(r) +} + +// catchPanic runs f and returns the recovered panic value (nil if none). +func catchPanic(f func()) (v any) { + defer func() { v = recover() }() + f() + return nil +} - // Unregister stops delivery; the producer still records the message. - wv.UnregisterMessageListener(sub) - wv.SendMessage("gamma") - expect(len(received) == 2, fmt.Sprintf("no delivery after unregister (got %v)", received)) +// expectForeignError asserts that v is the generated brand error carrying +// FOREIGN_ERROR_CODE (-4) and the Go panic text. +func expectForeignError(v any, needle string, what string) { + expect(v != nil, what+": expected a panic") + err, isErr := v.(error) + expect(isErr, fmt.Sprintf("%s: panic value is an error (got %T)", what, v)) + var ferr *wv.WeaveFFIError + expect(errors.As(err, &ferr), fmt.Sprintf("%s: *WeaveFFIError (got %T: %v)", what, v, v)) + expect(ferr.Code == -4, fmt.Sprintf("%s: code -4 (got %d)", what, ferr.Code)) + expect(strings.Contains(ferr.Message, needle), + fmt.Sprintf("%s: message carries the Go panic text (got %q)", what, ferr.Message)) +} - msgs = msgs[:0] - for m := range wv.GetMessages() { - msgs = append(msgs, m) +// waitFreed drives the GC until n implementations have been finalized. +func waitFreed(freed *atomic.Int32, n int32) bool { + for i := 0; i < 200; i++ { + runtime.GC() + if freed.Load() >= n { + return true + } + time.Sleep(5 * time.Millisecond) } - expect(len(msgs) == 3, "producer kept recording") + return freed.Load() >= n +} + +func main() { + var freed atomic.Int32 + bus := wv.NewEventBus() + expect(bus.SubscriberCount() == 0, "fresh bus has no subscribers") + expect(bus.LastMessage() == nil, "fresh bus has no last message") + + // Subscribe: the producer calls OnAttached with a strong reference to + // the bus before retaining the subscriber, then returns the new count. + a, n := attach(bus, "a", "quiet", false, &freed) + expect(n == 1, fmt.Sprintf("first subscribe returns 1 (got %d)", n)) + expect(a.attached == 1, "OnAttached called exactly once") + expect(a.bus != nil, "OnAttached received a bus wrapper") + expect(a.bus.SubscriberCount() == 1, "the attached wrapper aliases the live bus") + + b, n := attach(bus, "b", "", false, &freed) + expect(n == 2, fmt.Sprintf("second subscribe returns 2 (got %d)", n)) + expect(bus.SubscriberCount() == 2, "subscriber_count == 2") + + // Delivery steers the accepted count: both accept "news". + delivered := bus.Publish("news", "hello", []string{"x", "yy"}) + expect(delivered == 2, fmt.Sprintf("news accepted by both (got %d)", delivered)) + expect(len(a.received) == 1 && len(b.received) == 1, "both subscribers received news") + m := a.received[0] + expect(m.Seq == 1, fmt.Sprintf("seq starts at 1 (got %d)", m.Seq)) + expect(m.Topic == "news" && m.Text == "hello", "message topic/text") + expect(len(m.Tags) == 2 && m.Tags[0] == "x" && m.Tags[1] == "yy", + fmt.Sprintf("tags round-trip through the borrowed buffer (got %v)", m.Tags)) + expect(a.routed[0] == "news" && b.routed[0] == "news", "route called with the topic") + + // "quiet": a skips, b accepts. + delivered = bus.Publish("quiet", "psst", nil) + expect(delivered == 1, fmt.Sprintf("quiet accepted by one (got %d)", delivered)) + expect(len(a.received) == 1, "a skipped quiet") + expect(len(b.received) == 2 && b.received[1].Seq == 2 && len(b.received[1].Tags) == 0, + "b received quiet with seq 2 and empty tags") + + // "stop": a accepts and stops, so b is never routed. + delivered = bus.Publish("stop", "last", []string{}) + expect(delivered == 1, fmt.Sprintf("stop accepted by one (got %d)", delivered)) + expect(len(a.received) == 2 && a.received[1].Text == "last", "a received stop") + expect(len(b.routed) == 2, fmt.Sprintf("b not routed after AcceptAndStop (got %v)", b.routed)) - // Early break destroys the iterator handle without draining it. + // The wrapper handed through OnAttached sees the same log. + last := a.bus.LastMessage() + expect(last != nil && last.Seq == 3 && last.Topic == "stop" && last.Text == "last", + fmt.Sprintf("last_message via the attached wrapper (got %+v)", last)) + + // Async: the producer publishes from its own thread, and the callback + // trampolines run there; the wrapper parks the goroutine until done. + delivered = bus.PublishLater("async", "later") + expect(delivered == 2, fmt.Sprintf("publish_later accepted by both (got %d)", delivered)) + expect(len(a.received) == 3 && a.received[2].Seq == 4 && a.received[2].Topic == "async", + "a received the async message") + expect(len(b.received) == 3 && b.received[2].Text == "later", "b received the async message") + + // Iterator: lazy iter.Seq over every message text, in order. + var texts []string + for t := range bus.Messages() { + texts = append(texts, t) + } + expect(len(texts) == 4 && texts[0] == "hello" && texts[1] == "psst" && + texts[2] == "last" && texts[3] == "later", + fmt.Sprintf("messages iterator in order (got %v)", texts)) first := "" - for m := range wv.GetMessages() { - first = m + for t := range bus.Messages() { + first = t break } - expect(first == "alpha", "early break yields only the first message") + expect(first == "hello", "early break yields only the first message") + + last = bus.LastMessage() + expect(last != nil && last.Seq == 4 && last.Text == "later" && len(last.Tags) == 0, + fmt.Sprintf("last_message optional present (got %+v)", last)) + + // Free function taking the callback interface: the producer frees the + // handle right after the call. + probe := &recorder{st: &subState{name: "probe", skip: "quiet", fails: true}} + expect(wv.RouteOnce(probe, "quiet") == wv.DeliverySkip, "route_once quiet -> Skip") + expect(wv.RouteOnce(probe, "stop") == wv.DeliveryAcceptAndStop, "route_once stop -> AcceptAndStop") + expect(wv.RouteOnce(probe, "other") == wv.DeliveryAccept, "route_once other -> Accept") + expect(len(probe.st.routed) == 3, "route_once called Route each time") + expectForeignError(catchPanic(func() { wv.RouteOnce(probe, "boom-route") }), + "subscriber probe rejected topic", "route_once foreign error") + + // Foreign errors through the bus: a Go panic inside Route or OnMessage + // aborts publish with code -4, and the bus stays usable afterwards. + f, n := attach(bus, "f", "", true, &freed) + expect(n == 3 && f.attached == 1, "third subscriber attached") + expectForeignError(catchPanic(func() { bus.Publish("boom-route", "x", nil) }), + "subscriber f rejected topic", "publish with panicking Route") + expectForeignError(catchPanic(func() { bus.Publish("boom-message", "y", nil) }), + "subscriber f failed on message", "publish with panicking OnMessage") + // The bus logged both aborted publishes and still delivers. + last = bus.LastMessage() + expect(last != nil && last.Seq == 6 && last.Topic == "boom-message", "aborted publishes were logged") + delivered = bus.Publish("ok", "z", nil) + expect(delivered == 3, fmt.Sprintf("bus usable after foreign error (got %d)", delivered)) + expect(len(f.received) == 1 && f.received[0].Seq == 7, "f received the follow-up message") + expect(bus.SubscriberCount() == 3, "subscriber_count == 3") + + // Releasing subscribers runs each `free`, which deletes the cgo.Handle + // and lets the Go implementation be collected. + bus.ClearSubscribers() + expect(bus.SubscriberCount() == 0, "subscriber_count == 0 after clear") + expect(waitFreed(&freed, 3), fmt.Sprintf("all three subscribers freed (got %d)", freed.Load())) + delivered = bus.Publish("after", "nobody", nil) + expect(delivered == 0, "no subscribers accept after clear") + // Every wrapper releases its own reference; Close is idempotent. + a.bus.Close() + a.bus.Close() + expect(bus.SubscriberCount() == 0, "bus outlives the attached wrapper's release") + b.bus.Close() + f.bus.Close() + bus.Close() + bus.Close() fmt.Println("go/events: OK") } diff --git a/conformance/go/kvstore.go b/conformance/go/kvstore.go index e4a69e59..4aa5a5ec 100644 --- a/conformance/go/kvstore.go +++ b/conformance/go/kvstore.go @@ -3,14 +3,24 @@ // Exercises the Store interface end to end: the throwing factory constructor // (OpenStore), methods on the wrapper across every shape (sync put/get/delete, // the iterator-backed ListKeys, plain count/clear, the async cancellable -// Compact, the deprecated LegacyPut), the package-level static -// (StoreDefaultCapacity), and the explicit Close. Asserts the typed KvError -// domain via errors.As (IoError on an empty open path, KeyNotFound on a -// missing get). Also covers the Entry record decoded from a value buffer -// (bytes, optional present and absent, empty list, and empty map fields), the -// buffered optional TTL and prefix parameters, the eviction listener -// trampoline, and the nested kv.stats submodule borrowing the Store across -// the module boundary. Exits 0 on success; aborts (non-zero) on any mismatch. +// Compact, the deprecated LegacyPut), the package-level statics +// (StoreDefaultCapacity, StoreOpenMany, StoreTotalCount), and the explicit, +// idempotent Close. Asserts the typed KvError domain via errors.As (IoError +// on an empty open path, KeyNotFound on a missing get, Expired on a stale +// get). Covers the Entry record decoded from a value buffer (bytes, optional +// present and absent, empty list, and empty map fields), the buffered +// optional TTL and prefix parameters, and the nested kv.stats submodule +// borrowing the Store across the module boundary. +// +// ABI 2 surface: a Go type implementing the EvictionListener callback +// interface (called with the decoded Entry and the EvictionReason, its bool +// return detaching it, replacement and clear releasing the old handle, and a +// panicking implementation surfacing to the throwing caller as a +// *WeaveFFIError with code -4), plus reference-counted objects everywhere: +// Share() aliasing the same store, Fork() copying it, Store? both ways in +// Larger, a Store inside the StoreInfo record (Describe), a list of stores +// returned by StoreOpenMany, and stores encoded into a parameter buffer by +// StoreTotalCount. Exits 0 on success; aborts (non-zero) on any mismatch. package main @@ -18,7 +28,11 @@ import ( "errors" "fmt" "os" + "runtime" "sort" + "strings" + "sync/atomic" + "time" wv "__MODPATH__" ) @@ -30,6 +44,61 @@ func expect(cond bool, msg string) { } } +type eviction struct { + key string + value []byte + reason wv.EvictionReason +} + +// listenerState is the observable side of a listener, held apart from the +// implementing value so that value can be finalized once the producer +// releases its handle. +type listenerState struct { + seen []eviction + limit int +} + +// watcher implements wv.EvictionListener. It keeps receiving until it has +// seen `limit` evictions, then returns false to detach itself. A key +// starting with "boom" makes it panic instead. +type watcher struct { + st *listenerState +} + +func (l *watcher) OnEvict(entry wv.Entry, reason wv.EvictionReason) bool { + if strings.HasPrefix(entry.Key, "boom") { + panic("listener refused " + entry.Key) + } + l.st.seen = append(l.st.seen, eviction{key: entry.Key, value: entry.Value, reason: reason}) + return len(l.st.seen) < l.st.limit +} + +// listen attaches a fresh watcher and returns only its state; the watcher +// itself stays reachable solely through the producer's cgo.Handle. +func listen(store *wv.Store, limit int, freed *atomic.Int32) *listenerState { + st := &listenerState{limit: limit} + l := &watcher{st: st} + runtime.SetFinalizer(l, func(*watcher) { freed.Add(1) }) + store.SetEvictionListener(l) + return st +} + +func waitFreed(freed *atomic.Int32, n int32) bool { + for i := 0; i < 200; i++ { + runtime.GC() + if freed.Load() >= n { + return true + } + time.Sleep(5 * time.Millisecond) + } + return freed.Load() >= n +} + +func put(store *wv.Store, key string, value []byte, ttl *int64) { + ok, err := store.Put(key, value, wv.EntryKindPersistent, ttl) + expect(err == nil && ok, fmt.Sprintf("put %s (err %v)", key, err)) +} + func main() { store, err := wv.OpenStore("/tmp/conformance-kvstore-go") expect(err == nil, "open store") @@ -61,7 +130,7 @@ func main() { expect(err == nil && e != nil, "get alpha") expect(e.Id > 0, "entry id positive") expect(e.Key == "alpha", "entry key") - expect(len(e.Value) == 3 && e.Value[0] == 1, "entry value bytes") + expect(len(e.Value) == 3 && e.Value[0] == 1 && e.Value[2] == 3, "entry value bytes") expect(e.CreatedAt > 0, "entry created_at set") expect(e.ExpiresAt == nil, "no ttl decodes as nil expires_at") expect(len(e.Tags) == 0, "empty tags len 0") @@ -73,6 +142,7 @@ func main() { expect(errors.As(err, &kerr), "missing key yields a *KvError") expect(kerr.Code == wv.KvErrorKeyNotFound, fmt.Sprintf("missing key code == 1001 (got %d)", kerr.Code)) + expect(kerr.Message == "key not found", "key not found default message") // Iterator-backed method: a lazy iter.Seq2[string, error], with and // without the buffered optional prefix. Errors surface per step through @@ -94,16 +164,26 @@ func main() { } expect(len(keys) == 1 && keys[0] == "alpha", "list_keys prefix filter") + // Early break destroys the producer iterator without draining it. + first := "" + for k, serr := range store.ListKeys(nil) { + expect(serr == nil, "list_keys early-break step error") + first = k + break + } + expect(first == "alpha", "early break yields the first sorted key") + // Deprecated member keeps working. ok, err = store.LegacyPut("legacy", payload) expect(err == nil && ok, "legacy put") ok, err = store.Delete("legacy") expect(err == nil && ok, "delete legacy") + ok, err = store.Delete("legacy") + expect(err == nil && !ok, "second delete reports false") // Present optional: a TTL'd put round-trips as a non-nil ExpiresAt // pointing past CreatedAt. - ok, err = store.Put("gamma", payload, wv.EntryKindEncrypted, ptrInt64(3600)) - expect(err == nil && ok, "put gamma with ttl") + put(store, "gamma", payload, ptrInt64(3600)) g, err := store.Get("gamma") expect(err == nil && g != nil, "get gamma") expect(g.ExpiresAt != nil, "ttl decodes as non-nil expires_at") @@ -116,36 +196,171 @@ func main() { st, err := wv.GetStats(store) expect(err == nil, "get stats") expect(st.TotalEntries == 2, "stats total entries == 2") + expect(st.TotalBytes == 6, fmt.Sprintf("stats total bytes == 6 (got %d)", st.TotalBytes)) + expect(st.ExpiredEntries == 0, "stats expired entries == 0") - // Eviction listener: delete fires the //export trampoline synchronously - // on the deleting goroutine's thread. - var evicted []string - sub := wv.RegisterEvictionListener(func(key string) { - evicted = append(evicted, key) - }) - expect(sub > 0, "listener id positive") + // ── Eviction listener (callback interface) ── + var freed atomic.Int32 + l1 := listen(store, 2, &freed) + + // Delete fires the trampoline synchronously with the decoded Entry. ok, err = store.Delete("beta") expect(err == nil && ok, "delete beta") - expect(len(evicted) == 1 && evicted[0] == "beta", - fmt.Sprintf("eviction fired for beta (got %v)", evicted)) + expect(len(l1.seen) == 1, fmt.Sprintf("eviction fired once (got %v)", l1.seen)) + expect(l1.seen[0].key == "beta" && l1.seen[0].reason == wv.EvictionReasonDeleted, + fmt.Sprintf("delete evicts with reason Deleted (got %+v)", l1.seen[0])) + expect(len(l1.seen[0].value) == 3 && l1.seen[0].value[1] == 2, "evicted entry carries its bytes") + + // A stale read evicts with reason Expired and reports KvError Expired. + put(store, "stale", []byte{9}, ptrInt64(-1)) + _, err = store.Get("stale") + kerr = nil + expect(errors.As(err, &kerr) && kerr.Code == wv.KvErrorExpired, + fmt.Sprintf("stale get reports Expired (got %v)", err)) + expect(len(l1.seen) == 2 && l1.seen[1].key == "stale" && l1.seen[1].reason == wv.EvictionReasonExpired, + fmt.Sprintf("expiry evicts with reason Expired (got %v)", l1.seen)) + + // The second eviction returned false, so the store detached and freed + // the listener; a third eviction is not observed. + expect(waitFreed(&freed, 1), "detached listener is freed") + put(store, "unseen", payload, nil) + ok, err = store.Delete("unseen") + expect(err == nil && ok, "delete unseen") + expect(len(l1.seen) == 2, "no eviction after the listener detached") + + // Replacing a listener frees the previous one; clearing frees the last. + l2 := listen(store, 1000, &freed) + l3 := listen(store, 1000, &freed) + expect(waitFreed(&freed, 2), "replaced listener is freed") + put(store, "delta", payload, nil) + ok, err = store.Delete("delta") + expect(err == nil && ok, "delete delta") + expect(len(l2.seen) == 0 && len(l3.seen) == 1 && l3.seen[0].key == "delta", + "only the current listener observes evictions") + store.ClearEvictionListener() + expect(waitFreed(&freed, 3), "cleared listener is freed") + put(store, "epsilon", payload, nil) + ok, err = store.Delete("epsilon") + expect(err == nil && ok, "delete epsilon") + expect(len(l3.seen) == 1, "no eviction after clear") + store.ClearEvictionListener() + + // A panicking listener surfaces to the throwing caller as the generic + // error with FOREIGN_ERROR_CODE, and the store keeps working. + l4 := listen(store, 1000, &freed) + put(store, "boom", payload, nil) + _, err = store.Delete("boom") + var ferr *wv.WeaveFFIError + expect(errors.As(err, &ferr), fmt.Sprintf("panicking listener yields *WeaveFFIError (got %T %v)", err, err)) + expect(ferr.Code == -4, fmt.Sprintf("foreign error code -4 (got %d)", ferr.Code)) + expect(strings.Contains(ferr.Message, "listener refused boom"), + fmt.Sprintf("foreign error carries the panic text (got %q)", ferr.Message)) + expect(store.Count() == 1, "entry was removed before the listener ran") + ok, err = store.Delete("boom") + expect(err == nil && !ok, "store usable after the foreign error") + put(store, "zeta", payload, nil) + ok, err = store.Delete("zeta") + expect(err == nil && ok && len(l4.seen) == 1 && l4.seen[0].key == "zeta", + "listener still attached after its panic") + store.ClearEvictionListener() + expect(waitFreed(&freed, 4), "all four listeners freed") - // Unregister stops delivery. - wv.UnregisterEvictionListener(sub) - ok, err = store.Delete("alpha") - expect(err == nil && ok, "delete alpha") - expect(len(evicted) == 1, fmt.Sprintf("no eviction after unregister (got %v)", evicted)) + // ── Objects: share, fork, optional, records, lists ── + // Share returns a second wrapper for the SAME object: a write through one + // is visible through the other, and releasing one leaves it alive. + shared := store.Share() + expect(shared != store, "share yields a distinct wrapper") + expect(shared.Count() == 1, "shared sees alpha") + put(shared, "via-shared", []byte{7, 7}, nil) + expect(store.Count() == 2, "put through share visible through the original") + got, err := store.Get("via-shared") + expect(err == nil && got != nil && len(got.Value) == 2, "value written through share readable") + shared.Close() + shared.Close() + expect(store.Count() == 2, "original alive after the shared wrapper closed") + + // Fork copies the live entries into an independent object. + forked := store.Fork() + expect(forked.Count() == 2, "fork copies entries") + put(forked, "fork-only", payload, nil) + put(forked, "fork-only-2", payload, nil) + expect(forked.Count() == 4 && store.Count() == 2, "fork is independent") + + // Store? as parameter and return. + empty, err := wv.OpenStore("/tmp/empty") + expect(err == nil, "open empty") + expect(empty.Larger(nil) == nil, "larger(nil) on an empty store is nil") + self := store.Larger(nil) + expect(self != nil, "larger(nil) on a non-empty store returns itself") + put(self, "via-larger", payload, nil) + expect(store.Count() == 3, "larger(nil) aliases the receiver") + self.Close() + bigger := store.Larger(forked) + expect(bigger != nil && bigger.Count() == 4, "larger(other) picks the bigger store") + put(bigger, "via-bigger", payload, nil) + expect(forked.Count() == 5 && store.Count() == 3, "larger(other) aliases the other store") + bigger.Close() + own := store.Larger(empty) + expect(own != nil && own.Count() == 3, "larger(smaller) returns the receiver") + own.Close() + + // A record carrying an object (and an absent, then present, optional). + info := store.Describe("primary", nil) + expect(info.Label == "primary", "describe label") + expect(info.Count == 3, fmt.Sprintf("describe count == 3 (got %d)", info.Count)) + expect(info.Mirror == nil, "describe mirror absent") + expect(info.Store != nil && info.Store.Count() == 3, "describe.store is usable") + put(info.Store, "via-info", payload, nil) + expect(store.Count() == 4, "describe.store aliases the receiver") + withMirror := forked.Describe("mirrored", store) + expect(withMirror.Count == 5, "mirrored describe count") + expect(withMirror.Mirror != nil && withMirror.Mirror.Count() == 4, "describe.mirror adopted") + expect(withMirror.Store.Count() == 5, "describe.store on the fork") + + // A list of objects as a throwing static return. + many, err := wv.StoreOpenMany([]string{"/tmp/a", "/tmp/b", "/tmp/c"}) + expect(err == nil && len(many) == 3, fmt.Sprintf("open_many opens 3 (got %d, %v)", len(many), err)) + put(many[0], "m0", payload, nil) + put(many[2], "m2a", payload, nil) + put(many[2], "m2b", payload, nil) + expect(many[0].Count() == 1 && many[1].Count() == 0 && many[2].Count() == 2, "open_many stores are independent") + _, err = wv.StoreOpenMany([]string{"/tmp/ok", ""}) + kerr = nil + expect(errors.As(err, &kerr) && kerr.Code == wv.KvErrorIoError, "open_many propagates IoError") + none, err := wv.StoreOpenMany(nil) + expect(err == nil && len(none) == 0, "open_many of nothing is empty") + + // Objects encoded into a parameter buffer (list and record fields). + expect(wv.StoreTotalCount(many, nil) == 3, "total_count sums the list") + expect(wv.StoreTotalCount(nil, nil) == 0, "total_count of nothing is 0") + expect(wv.StoreTotalCount(many, &info) == 3+4, "total_count adds extra.store") + expect(wv.StoreTotalCount([]*wv.Store{store, forked}, &withMirror) == 4+5+5, + "total_count with a mirrored extra") + // The wrappers keep their own references after being encoded. + expect(store.Count() == 4 && forked.Count() == 5 && info.Store.Count() == 4, "wrappers alive after encoding") // Async: an immediately-expired entry gives compact 3 bytes to reclaim; // the cgo trampoline bridges the producer's worker thread to a channel. - ok, err = store.Put("doomed", payload, wv.EntryKindVolatile, ptrInt64(0)) - expect(err == nil && ok, "put doomed") + put(store, "doomed", payload, ptrInt64(0)) reclaimed, err := store.Compact() expect(err == nil, "compact async") expect(reclaimed == 3, fmt.Sprintf("compact reclaimed 3 bytes (got %d)", reclaimed)) - expect(store.Count() == 0, "store empty after deletes + compact") + expect(store.Count() == 4, "store count after compact") - // Plain void method, then release the object. + // Plain void method, then release every object; Close is idempotent. store.Clear() + expect(store.Count() == 0 && info.Store.Count() == 0, "clear visible through every alias") + for _, s := range many { + s.Close() + s.Close() + } + withMirror.Store.Close() + withMirror.Mirror.Close() + info.Store.Close() + info.Store.Close() + empty.Close() + forked.Close() + store.Close() store.Close() fmt.Println("go/kvstore: OK") } diff --git a/conformance/kotlin/codec.kt b/conformance/kotlin/codec.kt new file mode 100644 index 00000000..1bf59dce --- /dev/null +++ b/conformance/kotlin/codec.kt @@ -0,0 +1,416 @@ +// Conformance consumer: codec sample, Kotlin (JVM via JNI) target. +// +// Drives the value-buffer round-trip oracle through the generated Kotlin +// encoder and decoder (`WeaveBufferWriter`/`WeaveBufferReader` plus the +// per-type `pack*`/`unpack*` routines). For `Scalars` and `Composite` the +// consumer decodes the producer's canonical fixture and checks every field +// against concrete values, hands the same value back through `verify*` (which +// proves the Kotlin encoder produced exactly the bytes Rust encodes), and +// compares `roundtrip*` output field by field. It then builds its own values +// from scratch with edge cases (empty strings, lists, maps, and byte arrays; +// present-but-empty optionals; BMP and supplementary unicode; the i8..u64 +// extremes, with unsigned values carried in Kotlin's signed types; NaN, both +// infinities, negative zero, and a subnormal) and round-trips them, exercises +// every `Shape` variant alone and inside lists and optionals, checks the +// typed `CodecException.Mismatch` from a rejected fixture, and exercises +// `Holder` (objects inside records, optionals, and lists: `sumHolder`, +// `primaryOf` returning the same native object as `holder.primary`, +// `samePrimary`, and clean release of every adopted reference with double +// `close()` safe and use after close rejected). Compiled in-module with the +// generated `WeaveFFI.kt`, so the `internal` buffer helpers and `handle` are +// reachable. +@file:JvmName("Main") + +import com.weaveffi.CodecException +import com.weaveffi.Color +import com.weaveffi.Composite +import com.weaveffi.Holder +import com.weaveffi.Scalars +import com.weaveffi.Shape +import com.weaveffi.Token +import com.weaveffi.WeaveFFI +import com.weaveffi.WeaveFFIException +import com.weaveffi.packComposite +import com.weaveffi.unpackComposite +import com.weaveffi.weaveDecode +import com.weaveffi.weaveEncode +import kotlin.system.exitProcess + +fun expect(cond: Boolean, msg: String) { + if (!cond) { + System.err.println("assertion failed: $msg") + exitProcess(1) + } +} + +/** Run `block` and return the exception it threw, or null if it completed. */ +inline fun thrownBy(block: () -> Unit): Throwable? = + try { + block() + null + } catch (e: Throwable) { + e + } + +/** The canonical `Scalars` fixture, spelled with Kotlin's signed carriers for the unsigned fields. */ +fun canonicalScalars(): Scalars = Scalars( + i8_value = (-8).toByte(), + u8_value = 200.toByte(), + i16_value = (-16_000).toShort(), + u16_value = 60_000.toShort(), + i32_value = -2_000_000_000, + u32_value = 4_000_000_000L, + i64_value = -9_007_199_254_740_993L, + u64_value = ULong.MAX_VALUE.toLong(), + f32_value = 1.5f, + f64_value = -2.25e100, + flag = true, + color = Color.Blue, +) + +/** + * Field-by-field equality for `Composite`: the data class holds `ByteArray` + * fields, whose generated `equals` compares identity, so the comparison has + * to spell them out with `contentEquals`. + */ +fun compositeEquals(a: Composite, b: Composite): Boolean = + a.name == b.name && + a.blob.contentEquals(b.blob) && + a.some_i64 == b.some_i64 && + a.none_i64 == b.none_i64 && + a.some_text == b.some_text && + a.names == b.names && + a.matrix == b.matrix && + a.empty == b.empty && + a.by_name == b.by_name && + a.by_id == b.by_id && + a.scalars == b.scalars && + a.shape == b.shape && + a.shapes == b.shapes && + a.maybe_shape == b.maybe_shape && + ((a.maybe_list == null && b.maybe_list == null) || + (a.maybe_list != null && b.maybe_list != null && a.maybe_list.contentEquals(b.maybe_list))) && + a.sparse == b.sparse && + a.colors == b.colors + +fun checkScalars() { + val canonical = canonicalScalars() + val sample = WeaveFFI.sampleScalars() + // Producer encodes, consumer decodes: every field, with the unsigned ones + // read back through their signed carriers. + expect(sample.i8_value.toInt() == -8, "i8 (got ${sample.i8_value})") + expect(sample.u8_value.toUByte().toInt() == 200, "u8 (got ${sample.u8_value.toUByte()})") + expect(sample.i16_value.toInt() == -16_000, "i16 (got ${sample.i16_value})") + expect(sample.u16_value.toUShort().toInt() == 60_000, "u16 (got ${sample.u16_value.toUShort()})") + expect(sample.i32_value == -2_000_000_000, "i32 (got ${sample.i32_value})") + expect(sample.u32_value == 4_000_000_000L, "u32 (got ${sample.u32_value})") + expect(sample.i64_value == -9_007_199_254_740_993L, "i64 (got ${sample.i64_value})") + expect(sample.u64_value.toULong() == ULong.MAX_VALUE, "u64 max (got ${sample.u64_value.toULong()})") + expect(sample.f32_value == 1.5f, "f32 (got ${sample.f32_value})") + expect(sample.f64_value == -2.25e100, "f64 (got ${sample.f64_value})") + expect(sample.flag, "flag") + expect(sample.color == Color.Blue, "color Blue (got ${sample.color})") + expect(sample == canonical, "sample equals the locally built canonical Scalars") + + // Consumer encodes, producer decodes and compares with its canonical. + expect(WeaveFFI.verifyScalars(sample), "verifyScalars(sample)") + expect(WeaveFFI.verifyScalars(canonical), "verifyScalars(locally built canonical)") + expect(WeaveFFI.roundtripScalars(sample) == sample, "roundtripScalars(sample) equals sample") + + // A rejected fixture is the typed domain error. + val mismatch = thrownBy { WeaveFFI.verifyScalars(canonical.copy(flag = false)) } + expect(mismatch is CodecException.Mismatch, "verifyScalars(modified) throws Mismatch (got $mismatch)") + expect((mismatch as WeaveFFIException).code == 1, "Mismatch code 1 (got ${mismatch.code})") + expect(mismatch.message == "value does not match the canonical fixture", "Mismatch message (got ${mismatch.message})") + expect(thrownBy { WeaveFFI.verifyScalars(canonical.copy(u64_value = 0L)) } is CodecException.Mismatch, "u64 difference detected") + expect(thrownBy { WeaveFFI.verifyScalars(canonical.copy(color = Color.Red)) } is CodecException.Mismatch, "enum difference detected") + + // From scratch, at the extremes of every width, plus the special floats. + val edge = Scalars( + i8_value = Byte.MIN_VALUE, + u8_value = (-1).toByte(), + i16_value = Short.MIN_VALUE, + u16_value = (-1).toShort(), + i32_value = Int.MIN_VALUE, + u32_value = 0xFFFF_FFFFL, + i64_value = Long.MIN_VALUE, + u64_value = Long.MIN_VALUE, + f32_value = Float.NaN, + f64_value = -0.0, + flag = false, + color = Color.Red, + ) + val edgeBack = WeaveFFI.roundtripScalars(edge) + expect(edgeBack.i8_value == Byte.MIN_VALUE, "i8 min round-trips") + expect(edgeBack.u8_value.toUByte() == UByte.MAX_VALUE, "u8 255 round-trips") + expect(edgeBack.i16_value == Short.MIN_VALUE, "i16 min round-trips") + expect(edgeBack.u16_value.toUShort() == UShort.MAX_VALUE, "u16 65535 round-trips") + expect(edgeBack.i32_value == Int.MIN_VALUE, "i32 min round-trips") + expect(edgeBack.u32_value == 0xFFFF_FFFFL, "u32 max round-trips (got ${edgeBack.u32_value})") + expect(edgeBack.i64_value == Long.MIN_VALUE, "i64 min round-trips") + expect(edgeBack.u64_value.toULong() == 9_223_372_036_854_775_808UL, "u64 2^63 round-trips") + expect(edgeBack.f32_value.isNaN(), "f32 NaN round-trips") + expect(edgeBack.f32_value.toRawBits() == Float.NaN.toRawBits(), "f32 NaN bits preserved") + expect(edgeBack.f64_value == 0.0 && 1.0 / edgeBack.f64_value < 0.0, "f64 -0.0 round-trips as negative zero") + expect(edgeBack.f64_value.toRawBits() == (-0.0).toRawBits(), "f64 -0.0 bits preserved") + expect(edgeBack.color == Color.Red && !edgeBack.flag, "enum and flag round-trip") + expect(edgeBack == edge, "edge Scalars equals (data class compares floats by bits)") + val maxes = edge.copy( + i8_value = Byte.MAX_VALUE, + u8_value = 0, + i16_value = Short.MAX_VALUE, + u16_value = 0, + i32_value = Int.MAX_VALUE, + u32_value = 0L, + i64_value = Long.MAX_VALUE, + u64_value = 0L, + f32_value = Float.NEGATIVE_INFINITY, + f64_value = Double.POSITIVE_INFINITY, + color = Color.Green, + ) + expect(WeaveFFI.roundtripScalars(maxes) == maxes, "max Scalars round-trip") + val subnormal = edge.copy(f32_value = Float.MIN_VALUE, f64_value = Double.MIN_VALUE) + expect(WeaveFFI.roundtripScalars(subnormal) == subnormal, "subnormal floats round-trip") +} + +fun checkComposite() { + val canonical = canonicalScalars() + val sample = WeaveFFI.sampleComposite() + expect(sample.name == "héllo wörld ✓", "name (got ${sample.name})") + expect(sample.blob.contentEquals(byteArrayOf(0, 1, 2, 253.toByte(), 254.toByte(), 255.toByte())), "blob (got ${sample.blob.toList()})") + expect(sample.some_i64 == Long.MIN_VALUE, "some_i64 is i64::MIN (got ${sample.some_i64})") + expect(sample.none_i64 == null, "none_i64 absent") + expect(sample.some_text != null && sample.some_text.isEmpty(), "some_text present and empty (got ${sample.some_text})") + expect(sample.names == listOf("a", "", "ccc"), "names (got ${sample.names})") + expect(sample.matrix == listOf(listOf(1, 2, 3), listOf(), listOf(-4)), "matrix (got ${sample.matrix})") + expect(sample.empty.isEmpty(), "empty list") + expect(sample.by_name == mapOf("one" to 1L, "two" to 2L, "neg" to -3L), "by_name (got ${sample.by_name})") + expect(sample.by_id.keys == setOf(-1, 42), "by_id keys (got ${sample.by_id.keys})") + expect(sample.by_id[-1] == canonical, "by_id[-1] is the canonical Scalars") + expect(sample.by_id[42] == canonical.copy(flag = false), "by_id[42] is canonical with flag=false") + expect(sample.scalars == canonical, "scalars nested record") + expect(sample.shape == Shape.Labeled("tag", 3), "shape (got ${sample.shape})") + expect( + sample.shapes == listOf( + Shape.Empty, + Shape.Circle(2.5), + Shape.Rect(1.0f, 0.5f), + Shape.Labeled("", -1), + Shape.Nested(canonical, "n"), + ), + "shapes (got ${sample.shapes})" + ) + expect(sample.maybe_shape == Shape.Nested(canonical, null), "maybe_shape (got ${sample.maybe_shape})") + expect(sample.maybe_list != null && sample.maybe_list.contentEquals(byteArrayOf(9, 8)), "maybe_list (got ${sample.maybe_list?.toList()})") + expect(sample.sparse == listOf(true, null, false), "sparse (got ${sample.sparse})") + expect(sample.colors == listOf(Color.Red, Color.Green, Color.Blue), "colors (got ${sample.colors})") + + // Consumer encodes, producer decodes and compares. + expect(WeaveFFI.verifyComposite(sample), "verifyComposite(sample)") + val back = WeaveFFI.roundtripComposite(sample) + expect(compositeEquals(back, sample), "roundtripComposite(sample) equals sample") + // The local encoder and decoder agree with each other too. + val local = weaveDecode(weaveEncode { w -> packComposite(w, sample) }) { r -> unpackComposite(r) } + expect(compositeEquals(local, sample), "local encode/decode round trip") + + // The producer's rendering is a debugging aid: check it saw our unicode. + val described = WeaveFFI.describeComposite(sample) + expect(described.contains("héllo wörld ✓"), "describeComposite carries the name (got $described)") + expect(described.contains("Labeled { label: \"tag\", count: 3 }"), "describeComposite carries the shape") + + // Any single change is detected by the producer. + val changed = sample.copy(sparse = listOf(true, true, false)) + val mismatch = thrownBy { WeaveFFI.verifyComposite(changed) } + expect(mismatch is CodecException.Mismatch, "verifyComposite(changed) throws Mismatch (got $mismatch)") + expect(thrownBy { WeaveFFI.verifyComposite(sample.copy(none_i64 = 0L)) } is CodecException.Mismatch, "present vs absent optional detected") + expect(thrownBy { WeaveFFI.verifyComposite(sample.copy(maybe_list = null)) } is CodecException.Mismatch, "absent list detected") + expect(thrownBy { WeaveFFI.verifyComposite(sample.copy(some_text = null)) } is CodecException.Mismatch, "absent empty string detected") + + // From scratch: everything empty or absent, with supplementary unicode. + val bare = Composite( + name = "", + blob = byteArrayOf(), + some_i64 = null, + none_i64 = null, + some_text = null, + names = listOf(), + matrix = listOf(), + empty = listOf(), + by_name = mapOf(), + by_id = mapOf(), + scalars = canonical, + shape = Shape.Empty, + shapes = listOf(), + maybe_shape = null, + maybe_list = null, + sparse = listOf(), + colors = listOf(), + ) + val bareBack = WeaveFFI.roundtripComposite(bare) + expect(compositeEquals(bareBack, bare), "bare Composite round-trips (got ${WeaveFFI.describeComposite(bareBack)})") + expect(bareBack.maybe_list == null && bareBack.some_text == null, "absent optionals stay absent") + + val full = Composite( + name = "日本語 🚀 \u0000 emoji and NUL", + blob = ByteArray(256) { it.toByte() }, + some_i64 = Long.MAX_VALUE, + none_i64 = Long.MIN_VALUE, + some_text = "🚀🚀", + names = listOf("", "x".repeat(1000), "ünïcödé"), + matrix = listOf(listOf(), listOf(Int.MIN_VALUE, Int.MAX_VALUE), listOf(0)), + empty = listOf(Double.NaN, Double.NEGATIVE_INFINITY, -0.0, Double.MIN_VALUE), + by_name = mapOf("" to 0L, "k" to Long.MIN_VALUE, "🚀" to Long.MAX_VALUE), + by_id = mapOf(Int.MIN_VALUE to canonical, Int.MAX_VALUE to canonical.copy(color = Color.Red), 0 to canonical), + scalars = canonical.copy(f32_value = Float.POSITIVE_INFINITY), + shape = Shape.Nested(canonical, ""), + shapes = listOf(Shape.Circle(Double.NaN), Shape.Rect(-0.0f, Float.MIN_VALUE), Shape.Labeled("🚀", Int.MIN_VALUE), Shape.Empty, Shape.Empty), + maybe_shape = Shape.Circle(-0.0), + maybe_list = byteArrayOf(), + sparse = listOf(null, null, true), + colors = listOf(Color.Blue, Color.Blue, Color.Red), + ) + val fullBack = WeaveFFI.roundtripComposite(full) + expect(compositeEquals(fullBack, full), "full Composite round-trips (got ${WeaveFFI.describeComposite(fullBack)})") + expect(fullBack.maybe_list != null && fullBack.maybe_list.isEmpty(), "present-but-empty bytes stay present") + expect(fullBack.empty[0].isNaN() && fullBack.empty[2].toRawBits() == (-0.0).toRawBits(), "special doubles inside a list") + expect((fullBack.shapes[0] as Shape.Circle).radius.isNaN(), "NaN inside a rich enum") + expect((fullBack.shapes[1] as Shape.Rect).width.toRawBits() == (-0.0f).toRawBits(), "-0.0f inside a rich enum") + expect(fullBack.name == full.name, "supplementary characters and NUL survive inside a buffer") + expect(WeaveFFI.describeComposite(full).contains("日本語 🚀 \\0 emoji and NUL"), "producer decoded the unicode name") +} + +fun checkShapes() { + val canonical = canonicalScalars() + val all = listOf( + Shape.Empty, + Shape.Circle(2.5), + Shape.Rect(1.0f, 0.5f), + Shape.Labeled("tag", 3), + Shape.Nested(canonical, "n"), + Shape.Nested(canonical.copy(flag = false), null), + ) + for (s in all) { + expect(WeaveFFI.roundtripShape(s) == s, "roundtripShape($s)") + } + expect(WeaveFFI.roundtripShapes(all) == all, "roundtripShapes(all)") + expect(WeaveFFI.roundtripShapes(listOf()).isEmpty(), "roundtripShapes(empty)") + expect(WeaveFFI.roundtripShape(Shape.Empty) === Shape.Empty, "Empty decodes to the singleton") + expect(WeaveFFI.describeShape(Shape.Empty) == "Empty", "describe Empty") + expect(WeaveFFI.describeShape(Shape.Circle(2.5)) == "Circle { radius: 2.5 }", "describe Circle (got ${WeaveFFI.describeShape(Shape.Circle(2.5))})") + expect(WeaveFFI.describeShape(Shape.Rect(1.0f, 0.5f)) == "Rect { width: 1.0, height: 0.5 }", "describe Rect") + expect(WeaveFFI.describeShape(Shape.Labeled("tag", 3)) == "Labeled { label: \"tag\", count: 3 }", "describe Labeled") + val nested = WeaveFFI.describeShape(Shape.Nested(canonical, null)) + expect(nested.startsWith("Nested { inner: Scalars { i8_value: -8, u8_value: 200,") && nested.endsWith("note: None }"), "describe Nested (got $nested)") + expect(WeaveFFI.describeShape(Shape.Labeled("🚀", -1)) == "Labeled { label: \"🚀\", count: -1 }", "describe Labeled with emoji") +} + +fun checkPrimitives() { + expect(WeaveFFI.roundtripOptI64(null) == null, "roundtripOptI64(null)") + expect(WeaveFFI.roundtripOptI64(Long.MIN_VALUE) == Long.MIN_VALUE, "roundtripOptI64(min)") + expect(WeaveFFI.roundtripOptI64(0L) == 0L, "roundtripOptI64(0)") + expect(WeaveFFI.roundtripMap(mapOf()) == mapOf(), "roundtripMap(empty)") + val m = mapOf("" to 0L, "a" to -1L, "héllo 🚀" to Long.MAX_VALUE) + expect(WeaveFFI.roundtripMap(m) == m, "roundtripMap (got ${WeaveFFI.roundtripMap(m)})") + + // Direct strings: BMP and supplementary characters both survive the JNI + // crossing (the bridge converts to standard UTF-8, not modified UTF-8). + for (s in listOf("", "plain", "héllo wörld ✓", "rocket 🚀 end", "🚀", "\uFFFF")) { + expect(WeaveFFI.roundtripString(s) == s, "roundtripString(${s.toByteArray().toList()})") + } + // A C string can't carry U+0000: the producer reports a marshalling error + // rather than truncating. + val nul = thrownBy { WeaveFFI.roundtripString("a\u0000b") } + expect(nul is WeaveFFIException && nul.code == -3, "embedded NUL is rejected with code -3 (got $nul)") + expect(WeaveFFI.roundtripBytes(byteArrayOf()).isEmpty(), "roundtripBytes(empty)") + val allBytes = ByteArray(256) { it.toByte() } + expect(WeaveFFI.roundtripBytes(allBytes).contentEquals(allBytes), "roundtripBytes(0..255)") + + expect(WeaveFFI.roundtripI64(Long.MIN_VALUE) == Long.MIN_VALUE, "roundtripI64(min)") + expect(WeaveFFI.roundtripI64(Long.MAX_VALUE) == Long.MAX_VALUE, "roundtripI64(max)") + expect(WeaveFFI.roundtripI64(-1L) == -1L, "roundtripI64(-1)") + expect(WeaveFFI.roundtripU64(ULong.MAX_VALUE.toLong()).toULong() == ULong.MAX_VALUE, "roundtripU64(max)") + expect(WeaveFFI.roundtripU64(Long.MIN_VALUE).toULong() == 9_223_372_036_854_775_808UL, "roundtripU64(2^63)") + expect(WeaveFFI.roundtripU64(0L) == 0L, "roundtripU64(0)") + expect(WeaveFFI.roundtripF64(Double.NaN).isNaN(), "roundtripF64(NaN)") + expect(WeaveFFI.roundtripF64(Double.POSITIVE_INFINITY) == Double.POSITIVE_INFINITY, "roundtripF64(+inf)") + expect(WeaveFFI.roundtripF64(Double.NEGATIVE_INFINITY) == Double.NEGATIVE_INFINITY, "roundtripF64(-inf)") + expect(WeaveFFI.roundtripF64(-0.0).toRawBits() == (-0.0).toRawBits(), "roundtripF64(-0.0) keeps the sign") + expect(WeaveFFI.roundtripF64(Double.MIN_VALUE) == Double.MIN_VALUE, "roundtripF64(subnormal)") + expect(WeaveFFI.roundtripF64(Double.MAX_VALUE) == Double.MAX_VALUE, "roundtripF64(max)") + expect(WeaveFFI.roundtripBool(true) && !WeaveFFI.roundtripBool(false), "roundtripBool") + expect(WeaveFFI.roundtripColor(Color.Blue) == Color.Blue, "roundtripColor(Blue)") + expect(WeaveFFI.roundtripColor(Color.Red) == Color.Red, "roundtripColor(Red)") + expect(Color.Blue.value == 7 && Color.fromValue(7) == Color.Blue, "Color discriminants") +} + +fun checkHolders() { + // Objects inside a record decoded from a buffer: each token is one adopted + // strong reference wrapped in a Token. + val holder = WeaveFFI.makeHolder(10L, true) + expect(holder.primary.value() == 10L, "primary value (got ${holder.primary.value()})") + expect(holder.spare != null && holder.spare.value() == 11L, "spare value (got ${holder.spare?.value()})") + expect(holder.many.map { it.value() } == listOf(12L, 13L, 14L), "many values (got ${holder.many.map { it.value() }})") + expect(holder.many.map { it.handle }.toSet().size == 3, "many are distinct objects") + + // Encoding a holder mints one fresh reference per token, so the wrappers + // stay valid after the producer consumed the buffer. + expect(WeaveFFI.sumHolder(holder) == 10L + 11L + 12L + 13L + 14L, "sumHolder (got ${WeaveFFI.sumHolder(holder)})") + expect(WeaveFFI.sumHolder(holder) == 60L, "sumHolder again (wrappers still alive)") + expect(holder.primary.value() == 10L, "primary still usable after encoding") + + // An object return is the same native object as the record's field. + val primary = WeaveFFI.primaryOf(holder) + expect(primary !== holder.primary, "primaryOf returns a new wrapper") + expect(primary.handle == holder.primary.handle, "primaryOf wraps the same native object") + expect(primary.value() == 10L, "primaryOf value") + expect(WeaveFFI.samePrimary(holder, holder), "samePrimary(holder, holder)") + val other = WeaveFFI.makeHolder(10L, false) + expect(other.spare == null, "spare absent when not requested") + expect(other.primary.value() == 10L, "other primary value equal but ...") + expect(!WeaveFFI.samePrimary(holder, other), "... samePrimary distinguishes distinct objects") + expect(WeaveFFI.sumHolder(other) == 10L + 12L + 13L + 14L, "sumHolder without spare") + + // A holder assembled in Kotlin from consumer-created tokens, sharing one + // token across positions. + val shared = Token(100L) + val mine = Holder(primary = shared, spare = shared, many = listOf(shared, Token(1L), primary)) + expect(WeaveFFI.sumHolder(mine) == 100L + 100L + 100L + 1L + 10L, "sumHolder over a Kotlin-built holder") + expect(WeaveFFI.samePrimary(mine, Holder(shared, null, listOf())), "samePrimary across Kotlin-built holders") + expect(WeaveFFI.samePrimary(Holder(primary, null, listOf()), holder), "samePrimary through primaryOf's wrapper") + val mineBack = WeaveFFI.primaryOf(mine) + expect(mineBack.handle == shared.handle && mineBack.value() == 100L, "primaryOf a Kotlin-built holder") + + // Release everything. Closing one wrapper over a shared object leaves the + // others valid; double close is safe; use after close throws; closing a + // token that another wrapper still references never frees the object. + primary.close() + primary.close() + expect(thrownBy { primary.value() } is IllegalStateException, "closed token rejects use") + expect(holder.primary.value() == 10L, "the record's wrapper survives closing primaryOf's wrapper") + expect(thrownBy { WeaveFFI.sumHolder(mine) } is IllegalStateException, "encoding a holder with a closed token throws") + mineBack.close() + expect(shared.value() == 100L, "shared token alive after closing its second wrapper") + shared.close() + mine.many[1].close() + holder.primary.close() + holder.spare!!.close() + holder.many.forEach { it.close() } + holder.many.forEach { it.close() } + other.primary.close() + other.many.forEach { it.close() } + expect(thrownBy { WeaveFFI.sumHolder(holder) } is IllegalStateException, "fully closed holder rejects encoding") + + // Wrappers that are never closed are released by the Cleaner; create a + // batch and let them go. + repeat(50) { WeaveFFI.makeHolder(it.toLong(), true) } + System.gc() +} + +fun main() { + checkScalars() + checkComposite() + checkShapes() + checkPrimitives() + checkHolders() + println("kotlin/codec: OK") +} diff --git a/conformance/kotlin/events.kt b/conformance/kotlin/events.kt index f64e327b..19160d93 100644 --- a/conformance/kotlin/events.kt +++ b/conformance/kotlin/events.kt @@ -1,14 +1,33 @@ -// Conformance consumer: events sample, Android/Kotlin (JNI) target. +// Conformance consumer: events sample, Kotlin (JVM via JNI) target. // -// Exercises the JNI listener trampoline (register pins the Kotlin lambda with -// a GlobalRef, the producer fires it synchronously on send via FunctionN.invoke, -// unregister releases it and stops delivery) and the iterator-backed -// getMessages drained through Kotlin's Iterator. Function names are the 0.5.0 -// defaults: lowerCamelCase with the module prefix stripped. +// Exercises the ABI 2 callback-interface and object surface: `Subscriber` is +// a generated Kotlin interface the consumer implements (the JNI vtable +// trampolines pin the implementing object with a GlobalRef and dispatch +// through the `SubscriberJni` shims), `EventBus` is a reference-counted +// `AutoCloseable` wrapper (companion `invoke` for `new`, `close()` releases +// one strong reference, the `Cleaner` is the backstop), and `Message` is a +// data class decoded from a value buffer. Asserts that every callback method +// is invoked with the right arguments (including the bus object handed to +// `onAttached`, which is usable and independently closeable), that `Delivery` +// return values steer `publish`'s accepted count, that a Kotlin exception +// thrown inside a callback surfaces to the caller as `WeaveFFIException` with +// code -4 without unwinding through the JVM, the `suspend` async `publishLater` +// driven with `runBlocking`, the iterator-backed `messages()` drained as a +// `Sequence`, the nullable `lastMessage()`, `routeOnce` without a bus, and the +// close semantics (double `close()` is safe, use after close throws). +// Compiled in-module with the generated `WeaveFFI.kt`. @file:JvmName("Main") +import com.weaveffi.Delivery +import com.weaveffi.EventBus +import com.weaveffi.EventsEventBusMessagesIterator +import com.weaveffi.Message +import com.weaveffi.Subscriber import com.weaveffi.WeaveFFI +import com.weaveffi.WeaveFFIException +import java.lang.ref.WeakReference import kotlin.system.exitProcess +import kotlinx.coroutines.runBlocking fun expect(cond: Boolean, msg: String) { if (!cond) { @@ -17,28 +36,242 @@ fun expect(cond: Boolean, msg: String) { } } +/** Run `block` and return the exception it threw, or null if it completed. */ +inline fun thrownBy(block: () -> Unit): Throwable? = + try { + block() + null + } catch (e: Throwable) { + e + } + +/** Spin the collector until `ref` clears or we give up. */ +fun collected(ref: WeakReference<*>): Boolean { + for (i in 0 until 200) { + if (ref.get() == null) return true + System.gc() + System.runFinalization() + Thread.sleep(5) + } + return ref.get() == null +} + +/** + * A subscriber that records every callback. `skipTopic` is routed as Skip, + * `stopTopic` as AcceptAndStop, `failTopic` throws from `route`, and + * `failOnMessage` throws from `onMessage` instead. + */ +class RecordingSubscriber( + val name: String, + private val skipTopic: String? = null, + private val stopTopic: String? = null, + private val failTopic: String? = null, + private val failOnMessage: Boolean = false, + private val failOnAttached: Boolean = false, +) : Subscriber { + val routed = mutableListOf() + val messages = mutableListOf() + var attachedCount = 0 + var subscriberCountAtAttach = -1L + var keptBus: EventBus? = null + + override fun route(topic: String): Delivery { + routed.add(topic) + if (topic == failTopic) throw IllegalStateException("subscriber $name rejected $topic") + return when (topic) { + skipTopic -> Delivery.Skip + stopTopic -> Delivery.AcceptAndStop + else -> Delivery.Accept + } + } + + override fun onMessage(message: Message): Long { + if (failOnMessage) throw RuntimeException("subscriber $name failed on message ${message.seq}") + messages.add(message) + return messages.size.toLong() + } + + override fun onAttached(bus: EventBus) { + attachedCount++ + if (failOnAttached) throw IllegalArgumentException("subscriber $name refused to attach") + // The bus arrives as an adopted strong reference: it's usable right + // here (subscribe hands it over before it takes its lock) and we keep + // the wrapper so a later close proves it's independent of the caller's. + subscriberCountAtAttach = bus.subscriberCount() + keptBus = bus + } +} + +/** Route once through a subscriber nothing else references, returning a weak handle to it. */ +fun routeOnceThrowaway(): WeakReference { + val sub = RecordingSubscriber("throwaway") + expect(WeaveFFI.routeOnce(sub, "t") == Delivery.Accept, "routeOnce throwaway") + return WeakReference(sub) +} + +/** Subscribe a subscriber nothing else references, returning a weak handle to it. */ +fun subscribeThrowaway(bus: EventBus): WeakReference { + val sub = RecordingSubscriber("gone") + expect(bus.subscribe(sub) == 4L, "subscribe gone") + sub.keptBus!!.close() + return WeakReference(sub) +} + fun main() { - val received = mutableListOf() - val sub = WeaveFFI.registerMessageListener { message -> received.add(message) } - expect(sub > 0L, "listener id positive") - - WeaveFFI.sendMessage("alpha") - WeaveFFI.sendMessage("beta") - expect(received == listOf("alpha", "beta"), "listener received sends (got $received)") - - val msgs = mutableListOf() - val it = WeaveFFI.getMessages() - while (it.hasNext()) msgs.add(it.next()) - expect(msgs == listOf("alpha", "beta"), "iterator yields messages in order (got $msgs)") - - // Unregister stops delivery; the producer still records the message. - WeaveFFI.unregisterMessageListener(sub) - WeaveFFI.sendMessage("gamma") - expect(received == listOf("alpha", "beta"), "no delivery after unregister (got $received)") - val after = mutableListOf() - val it2 = WeaveFFI.getMessages() - while (it2.hasNext()) after.add(it2.next()) - expect(after.size == 3, "producer kept recording (got $after)") + val bus = EventBus() + expect(bus.subscriberCount() == 0L, "fresh bus has no subscribers") + expect(bus.lastMessage() == null, "fresh bus has no last message") + expect(bus.messages().asSequence().toList().isEmpty(), "fresh bus has no messages") + expect(bus.publish("void", "nobody home", listOf()) == 0L, "publish with no subscribers accepts 0") + + // Subscribe three subscribers; each gets exactly one onAttached with the + // bus, observing the count before it was added. + val quiet = RecordingSubscriber("quiet", skipTopic = "quiet") + val stopper = RecordingSubscriber("stopper", stopTopic = "stop") + val tail = RecordingSubscriber("tail") + expect(bus.subscribe(quiet) == 1L, "first subscribe returns 1") + expect(bus.subscribe(stopper) == 2L, "second subscribe returns 2") + expect(bus.subscribe(tail) == 3L, "third subscribe returns 3") + expect(bus.subscriberCount() == 3L, "subscriberCount == 3") + for ((i, s) in listOf(quiet, stopper, tail).withIndex()) { + expect(s.attachedCount == 1, "${s.name} attached once (got ${s.attachedCount})") + expect( + s.subscriberCountAtAttach == i.toLong(), + "${s.name} saw $i subscribers at attach (got ${s.subscriberCountAtAttach})" + ) + expect(s.keptBus != null, "${s.name} kept the bus") + } + + // Delivery steering: Accept everywhere -> 3; Skip for `quiet` -> 2; + // AcceptAndStop in the middle -> 2 and `tail` never sees it. + expect(bus.publish("news", "hello", listOf("a", "b")) == 2L + 1L, "publish news accepted by 3") + expect(bus.publish("quiet", "psst", listOf()) == 2L, "publish quiet accepted by 2") + expect(bus.publish("stop", "last", listOf("z")) == 2L, "publish stop accepted by 2") + expect(quiet.routed == listOf("news", "quiet", "stop"), "quiet routed every topic (got ${quiet.routed})") + expect(stopper.routed == listOf("news", "quiet", "stop"), "stopper routed every topic") + expect(tail.routed == listOf("news", "quiet"), "tail never routed stop (got ${tail.routed})") + expect(quiet.messages.map { it.text } == listOf("hello", "last"), "quiet got hello,last (got ${quiet.messages.map { it.text }})") + expect(stopper.messages.map { it.text } == listOf("hello", "psst", "last"), "stopper got all three") + expect(tail.messages.map { it.text } == listOf("hello", "psst"), "tail got hello,psst (got ${tail.messages.map { it.text }})") + + // Message record fields decoded from the callback's buffer. seq counts + // from 1 and includes the subscriber-less publish above. + val first = stopper.messages[0] + expect(first == Message(2L, "news", "hello", listOf("a", "b")), "first message record (got $first)") + expect(stopper.messages[1].tags.isEmpty(), "psst carries no tags") + expect(stopper.messages[2] == Message(4L, "stop", "last", listOf("z")), "last message record (got ${stopper.messages[2]})") + + // Async publish via the suspend wrapper, resumed from the producer thread. + val later = runBlocking { bus.publishLater("later", "async hello") } + expect(later == 3L, "publishLater accepted by 3 (got $later)") + expect(stopper.messages.last().text == "async hello", "async message delivered") + expect(stopper.messages.last().tags.isEmpty(), "async message has no tags") + expect(stopper.messages.last().seq == 5L, "async message seq 5 (got ${stopper.messages.last().seq})") + + // Iterator-backed messages(): drained as a Sequence, and again by hand. + val texts = bus.messages().asSequence().toList() + expect( + texts == listOf("nobody home", "hello", "psst", "last", "async hello"), + "messages in publish order (got $texts)" + ) + val byHand = mutableListOf() + val it = bus.messages() + while (it.hasNext()) byHand.add(it.next()) + expect(byHand == texts, "manual iteration matches") + expect(thrownBy { it.next() } is NoSuchElementException, "exhausted iterator throws NoSuchElementException") + // An abandoned iterator can be closed early, and closed twice. + val partial = bus.messages() as EventsEventBusMessagesIterator + expect(partial.next() == "nobody home", "partial iterator first element") + partial.close() + partial.close() + expect(!partial.hasNext(), "closed iterator reports no more elements") + + // Nullable record return. + val last = bus.lastMessage() + expect(last == Message(5L, "later", "async hello", listOf()), "lastMessage (got $last)") + + // routeOnce: a free function taking the callback interface without a bus. + expect(WeaveFFI.routeOnce(quiet, "quiet") == Delivery.Skip, "routeOnce quiet -> Skip") + expect(WeaveFFI.routeOnce(stopper, "stop") == Delivery.AcceptAndStop, "routeOnce stop -> AcceptAndStop") + expect(WeaveFFI.routeOnce(tail, "anything") == Delivery.Accept, "routeOnce -> Accept") + expect(quiet.routed.last() == "quiet", "routeOnce reached route()") + // The producer drops its only reference when routeOnce returns, so the + // GlobalRef pinning a throwaway subscriber is released and it becomes + // collectable once the frame that created it is gone. + expect(collected(routeOnceThrowaway()), "routeOnce released its subscriber (free ran)") + + // The bus handed to onAttached is a distinct wrapper over the same + // object: usable, and closing it leaves the caller's wrapper alive. + val kept = tail.keptBus!! + expect(kept.handle == bus.handle, "onAttached bus is the same native object") + expect(kept.subscriberCount() == 3L, "kept bus reads the live subscriber count") + kept.close() + kept.close() + expect(thrownBy { kept.subscriberCount() } is IllegalStateException, "kept bus rejects use after close") + expect(bus.subscriberCount() == 3L, "original bus still alive after closing the kept wrapper") + quiet.keptBus!!.close() + stopper.keptBus!!.close() + + // Foreign errors: a Kotlin exception thrown from any callback method + // surfaces to the caller as WeaveFFIException(-4) carrying the throwable's + // text, and the JVM keeps running. Use a fresh bus for exact counts. + EventBus().use { bus2 -> + val ok = RecordingSubscriber("ok") + val rejecter = RecordingSubscriber("rejecter", failTopic = "boom") + bus2.subscribe(ok) + bus2.subscribe(rejecter) + ok.keptBus!!.close() + rejecter.keptBus!!.close() + val routeErr = thrownBy { bus2.publish("boom", "x", listOf()) } + expect(routeErr is WeaveFFIException, "throwing route() surfaces as WeaveFFIException (got $routeErr)") + expect((routeErr as WeaveFFIException).code == -4, "foreign error code -4 (got ${routeErr.code})") + expect( + routeErr.message?.contains("rejected boom") == true, + "foreign error carries the Kotlin message (got ${routeErr.message})" + ) + expect(routeErr.message?.contains("IllegalStateException") == true, "foreign error names the exception class") + // The earlier subscriber was still delivered to, the producer logged + // the message, and the bus remains usable. + expect(ok.messages.map { it.text } == listOf("x"), "subscriber before the failure still received x") + expect(bus2.lastMessage()?.text == "x", "producer logged the message despite the failure") + expect(bus2.publish("fine", "y", listOf()) == 2L, "bus usable after a foreign error") + + val onMsg = RecordingSubscriber("onMsg", failOnMessage = true) + bus2.subscribe(onMsg) + onMsg.keptBus!!.close() + val msgErr = thrownBy { bus2.publish("any", "z", listOf()) } + expect(msgErr is WeaveFFIException && msgErr.code == -4, "throwing onMessage() surfaces as -4 (got $msgErr)") + expect(msgErr?.message?.contains("failed on message") == true, "onMessage error text (got ${msgErr?.message})") + + // Throwing from onAttached aborts subscribe itself and the subscriber + // isn't retained. + val refuser = RecordingSubscriber("refuser", failOnAttached = true) + val attachErr = thrownBy { bus2.subscribe(refuser) } + expect(attachErr is WeaveFFIException && attachErr.code == -4, "throwing onAttached() surfaces as -4 (got $attachErr)") + expect(bus2.subscriberCount() == 3L, "refused subscriber not retained (got ${bus2.subscriberCount()})") + + // routeOnce also propagates the foreign error. + val onceErr = thrownBy { WeaveFFI.routeOnce(rejecter, "boom") } + expect(onceErr is WeaveFFIException && onceErr.code == -4, "routeOnce propagates -4 (got $onceErr)") + expect(WeaveFFI.routeOnce(rejecter, "calm") == Delivery.Accept, "rejecter still works for other topics") + } + + // clearSubscribers drops every retained subscriber (each free runs), and + // the bus keeps its log. + val goneRef = subscribeThrowaway(bus) + expect(bus.subscriberCount() == 4L, "subscribe gone") + bus.clearSubscribers() + expect(bus.subscriberCount() == 0L, "clearSubscribers empties the bus") + expect(collected(goneRef), "cleared subscriber was released by the producer") + expect(bus.publish("after", "nobody", listOf()) == 0L, "publish after clear accepts 0") + expect(bus.lastMessage()?.text == "nobody", "log continues after clear") + expect(quiet.messages.size == 3, "cleared subscribers receive nothing more") + + // Close semantics: double close is safe, use after close throws. + bus.close() + bus.close() + expect(thrownBy { bus.subscriberCount() } is IllegalStateException, "bus rejects use after close") + expect(thrownBy { bus.publish("x", "y", listOf()) } is IllegalStateException, "publish rejects use after close") println("kotlin/events: OK") } diff --git a/conformance/kotlin/kvstore.kt b/conformance/kotlin/kvstore.kt index 0886caf4..13db3ef0 100644 --- a/conformance/kotlin/kvstore.kt +++ b/conformance/kotlin/kvstore.kt @@ -1,33 +1,44 @@ -// Conformance consumer: kvstore sample, Android/Kotlin (JNI) target. +// Conformance consumer: kvstore sample, Kotlin (JVM via JNI) target. // -// Exercises the value-type interface surface: `Store` is a generated Closeable -// class (companion factory `open`, instance methods, static `defaultCapacity`, -// destroy through `close()`), `Entry` and `Stats` are plain data classes -// decoded from value buffers, and `KvError` is a typed exception domain -// (`KvException` sealed subclasses extending the generic `WeaveFFIException`, -// raised through the payload-aware `fromCode` factory). Asserts the typed-error -// paths (IoError from `open("")`, KeyNotFound from a missing `get`, Expired -// from a TTL-elapsed `get`), plus the existing behavioral surface: record -// materialization (`Entry.value` bytes, nullable `expires_at`, `tags` list, -// `metadata` map decoded from one buffer), the buffered-optional parameters -// (`ttlSeconds`, the `listKeys` prefix), the iterator-backed `listKeys`, the -// `Entry` value-buffer round trip through the generated pack/unpack routines, -// the nested `kv.stats` module, the JNI eviction listener (register, fire, -// unregister), the deprecated `legacyPut`, and the suspend `compact` resumed -// from the producer's worker thread. Compiled in-module with the generated -// `WeaveFFI.kt`, so the `internal` buffer helpers are reachable. +// Exercises the reference-counted interface surface: `Store` is a generated +// `AutoCloseable` class (companion factory `open`, instance methods, statics +// `defaultCapacity`/`openMany`/`totalCount`, `close()` releases one strong +// reference and the `Cleaner` is the backstop), `Entry`, `Stats`, and +// `StoreInfo` are data classes decoded from value buffers (`StoreInfo` carries +// a `Store` object and a nullable one as fields), and `KvError` is a typed +// exception domain (`KvException` sealed subclasses extending the generic +// `WeaveFFIException`). Asserts the typed-error paths (IoError from +// `open("")` and `openMany`, KeyNotFound, Expired), the ABI 2 object graph +// (`share()` returns a wrapper over the same object so writes through one are +// visible through the other, `fork()` is independent, `larger(null)`, +// `describe().store`, `openMany`, `totalCount` with objects inside lists and +// records), the consumer-implemented `EvictionListener` callback interface +// (every eviction arrives with the decoded `Entry` and the right +// `EvictionReason`; returning false detaches; replacing or clearing releases +// the previous listener; a Kotlin exception thrown inside `onEvict` surfaces +// to the caller as `WeaveFFIException` code -4 without crashing the JVM), +// plus the existing surface: record materialization, buffered optional +// parameters, the iterator-backed `listKeys`, the `Entry` pack/unpack round +// trip, the nested `kv.stats` module, the deprecated `legacyPut`, the suspend +// `compact` driven with `runBlocking`, and close semantics (double `close()` +// safe, use after close throws). Compiled in-module with the generated +// `WeaveFFI.kt`, so the `internal` helpers and `handle` are reachable. @file:JvmName("Main") import com.weaveffi.Entry import com.weaveffi.EntryKind +import com.weaveffi.EvictionListener +import com.weaveffi.EvictionReason import com.weaveffi.KvException import com.weaveffi.Store +import com.weaveffi.StoreInfo import com.weaveffi.WeaveFFI import com.weaveffi.WeaveFFIException import com.weaveffi.packEntry import com.weaveffi.unpackEntry import com.weaveffi.weaveDecode import com.weaveffi.weaveEncode +import java.lang.ref.WeakReference import kotlin.system.exitProcess import kotlinx.coroutines.runBlocking @@ -47,6 +58,59 @@ inline fun thrownBy(block: () -> Unit): Throwable? = e } +/** Spin the collector until `ref` clears or we give up. */ +fun collected(ref: WeakReference<*>): Boolean { + for (i in 0 until 200) { + if (ref.get() == null) return true + System.gc() + System.runFinalization() + Thread.sleep(5) + } + return ref.get() == null +} + +/** + * A listener that records every eviction. It detaches itself (returns false) + * once it has seen `keepAfter` evictions, and throws when the evicted key is + * `failOnKey`. + */ +class RecordingListener( + val name: String, + private val keepAfter: Int = Int.MAX_VALUE, + private val failOnKey: String? = null, +) : EvictionListener { + val evictions = mutableListOf>() + val entries = mutableListOf() + + override fun onEvict(entry: Entry, reason: EvictionReason): Boolean { + if (entry.key == failOnKey) throw IllegalStateException("listener $name refused ${entry.key}") + evictions.add(entry.key to reason) + entries.add(entry) + return evictions.size < keepAfter + } +} + +/** Attach a listener nothing else references, returning a weak handle to it. */ +fun attachThrowaway(store: Store, name: String): WeakReference { + val listener = RecordingListener(name) + store.setEvictionListener(listener) + return WeakReference(listener) +} + +/** Attach a self-detaching listener and drive one eviction, returning a weak handle to it. */ +fun attachSelfDetaching(store: Store, payload: ByteArray): WeakReference { + val listener = RecordingListener("once", keepAfter = 1) + store.setEvictionListener(listener) + expect(store.put("first", payload, EntryKind.Volatile, null), "put first") + expect(store.put("second", payload, EntryKind.Volatile, null), "put second") + expect(store.delete("first"), "delete first") + expect(listener.evictions == listOf("first" to EvictionReason.Deleted), "self-detaching listener saw first") + // It returned false, so the store dropped it; the next eviction isn't seen. + expect(store.delete("second"), "delete second") + expect(listener.evictions.size == 1, "detached listener saw nothing more (got ${listener.evictions})") + return WeakReference(listener) +} + fun main() { // Typed error from a constructor: an empty path is rejected with the // domain's IoError (1004), which is both the sealed domain type and the @@ -74,63 +138,49 @@ fun main() { expect(legacyOk, "legacyPut inserts") expect(store.count() == 3L, "count == 3 after legacyPut") expect(store.delete("legacy"), "delete legacy") + expect(!store.delete("legacy"), "second delete reports false") // Iterator-backed list-of-string return, drained through Kotlin's - // Iterator; the backing BTreeMap yields sorted order. The absent - // prefix crosses as a buffered `string?`. + // Iterator and as a Sequence; the backing BTreeMap yields sorted + // order. The absent prefix crosses as a buffered `string?`. val keys = mutableListOf() val it = store.listKeys(null) while (it.hasNext()) keys.add(it.next()) expect(keys == listOf("alpha", "beta"), "listKeys sorted (got $keys)") - - // Present optional prefix filter. - val filtered = mutableListOf() - val itAl = store.listKeys("al") - while (itAl.hasNext()) filtered.add(itAl.next()) - expect(filtered == listOf("alpha"), "listKeys prefix filter (got $filtered)") + expect(thrownBy { it.next() } is NoSuchElementException, "exhausted iterator throws") + expect(store.listKeys("al").asSequence().toList() == listOf("alpha"), "listKeys prefix filter") + expect(store.listKeys("zz").asSequence().toList().isEmpty(), "listKeys unmatched prefix") // Buffered `Entry?` return, decoded into the data class. val alpha = store.get("alpha") expect(alpha != null, "get alpha present") expect(alpha!!.id > 0, "entry id positive") expect(alpha.key == "alpha", "entry key") - - // Bytes field -> ByteArray. - expect( - alpha.value.size == 3 && alpha.value[0].toInt() == 1 && alpha.value[2].toInt() == 3, - "entry value bytes" - ) - // Optional scalar field: alpha had no TTL -> null. + expect(alpha.value.contentEquals(payload), "entry value bytes") expect(alpha.expires_at == null, "alpha expires_at null") - // `put` stores empty tags/metadata, so the decoded collections are empty. expect(alpha.tags.isEmpty(), "alpha tags empty") expect(alpha.metadata.isEmpty(), "alpha metadata empty") val beta = store.get("beta") - expect(beta != null && beta.expires_at != null && beta.expires_at!! > 0L, "beta expires_at present") + expect(beta != null && beta.expires_at != null && beta.expires_at > beta.created_at, "beta expires_at present") + expect(beta!!.id == alpha.id + 1, "ids are monotonic (got ${alpha.id}, ${beta.id})") // Typed error from a method: a missing key reports KeyNotFound (1001). val missingErr = thrownBy { store.get("missing") } - expect( - missingErr is KvException.KeyNotFound, - "get(missing) throws KvException.KeyNotFound (got $missingErr)" - ) - val missingCode = (missingErr as? WeaveFFIException)?.code - expect(missingCode == 1001, "KeyNotFound code 1001 (got $missingCode)") + expect(missingErr is KvException.KeyNotFound, "get(missing) throws KvException.KeyNotFound (got $missingErr)") + expect((missingErr as WeaveFFIException).code == 1001, "KeyNotFound code 1001 (got ${missingErr.code})") + expect(missingErr.message == "key not found", "KeyNotFound message (got ${missingErr.message})") // TTL expiry: a zero-TTL entry is already expired, so `get` reports // Expired (1002) and evicts the entry on read. expect(store.put("ephemeral", payload, EntryKind.Volatile, 0L), "put ephemeral") val expiredErr = thrownBy { store.get("ephemeral") } - expect( - expiredErr is KvException.Expired, - "get(expired) throws KvException.Expired (got $expiredErr)" - ) + expect(expiredErr is KvException.Expired, "get(expired) throws KvException.Expired (got $expiredErr)") + expect((expiredErr as WeaveFFIException).code == 1002, "Expired code 1002") expect(store.count() == 2L, "expired entry evicted on read") // An Entry with a non-empty list + map round-trips through the - // generated pack/unpack routines (the value-buffer replacement for the - // old builder + per-field getters). + // generated pack/unpack routines. val built = Entry( id = 7L, key = "built", @@ -141,28 +191,14 @@ fun main() { metadata = mapOf("source" to "test", "env" to "prod"), ) val builtBack = weaveDecode(weaveEncode { w -> packEntry(w, built) }) { r -> unpackEntry(r) } - expect(builtBack.tags.toSet() == setOf("hot", "fast"), "built tags") - expect( - builtBack.metadata["source"] == "test" && builtBack.metadata["env"] == "prod", - "built metadata" - ) + expect(builtBack.tags == listOf("hot", "fast"), "built tags") + expect(builtBack.metadata == mapOf("source" to "test", "env" to "prod"), "built metadata") expect(builtBack.value.contentEquals(payload), "built value bytes") - // Optional field left null round-trips as absent, not an error. expect(builtBack.expires_at == null, "built expires_at null") - // Empty collections and a present optional still decode cleanly. - val empty = Entry( - id = 8L, - key = "empty", - value = payload, - created_at = 1L, - expires_at = 99L, - tags = listOf(), - metadata = emptyMap(), - ) + val empty = Entry(8L, "empty", payload, 1L, 99L, listOf(), emptyMap()) val emptyBack = weaveDecode(weaveEncode { w -> packEntry(w, empty) }) { r -> unpackEntry(r) } - expect(emptyBack.tags.isEmpty(), "empty tags") - expect(emptyBack.metadata.isEmpty(), "empty metadata") + expect(emptyBack.tags.isEmpty() && emptyBack.metadata.isEmpty(), "empty collections") expect(emptyBack.expires_at == 99L, "empty expires_at present") // kv.stats submodule: free function taking the interface (borrowed @@ -170,26 +206,149 @@ fun main() { val stats = WeaveFFI.getStats(store) expect(stats.total_entries == 2L, "stats total entries == 2") expect(stats.total_bytes == 6L, "stats total bytes == 6 (got ${stats.total_bytes})") + expect(stats.expired_entries == 0L, "stats expired == 0") - // Eviction listener: deleting an existing key fires OnEvict synchronously - // through the JNI trampoline (producer thread == caller thread here). - val evicted = mutableListOf() - val sub = WeaveFFI.registerEvictionListener { key -> evicted.add(key) } - expect(sub > 0L, "listener id positive") - expect(store.delete("beta"), "delete beta") - expect(evicted == listOf("beta"), "eviction fired for beta (got $evicted)") - - // Unregister stops delivery. - WeaveFFI.unregisterEvictionListener(sub) - expect(store.delete("alpha"), "delete alpha") - expect(evicted == listOf("beta"), "no eviction after unregister (got $evicted)") + // --- Callback interface: EvictionListener ------------------------- - // Suspend async: an immediately-expired entry gives compact 3 bytes to - // reclaim; the continuation resumes from the producer's worker thread. + // Deleting an existing key fires onEvict synchronously with the + // decoded Entry and reason Deleted; a TTL-expired read fires Expired. + val listener = RecordingListener("main") + store.setEvictionListener(listener) + expect(store.delete("beta"), "delete beta") + expect(listener.evictions == listOf("beta" to EvictionReason.Deleted), "eviction for beta (got ${listener.evictions})") + expect(listener.entries[0].id == beta.id, "evicted entry carries beta's id") + expect(listener.entries[0].value.contentEquals(payload), "evicted entry carries beta's bytes") + expect(listener.entries[0].expires_at == beta.expires_at, "evicted entry carries beta's expiry") + expect(!store.delete("beta"), "delete of a missing key") + expect(listener.evictions.size == 1, "missing key evicts nothing") + expect(store.put("gone", payload, EntryKind.Volatile, 0L), "put gone with zero TTL") + expect(thrownBy { store.get("gone") } is KvException.Expired, "gone is expired") + expect( + listener.evictions == listOf("beta" to EvictionReason.Deleted, "gone" to EvictionReason.Expired), + "expiry eviction (got ${listener.evictions})" + ) + // compact does not notify (it reclaims without going through the + // listener), so listener state is unchanged afterwards. expect(store.put("doomed", payload, EntryKind.Volatile, 0L), "put doomed") val reclaimed = runBlocking { store.compact() } expect(reclaimed == 3L, "compact reclaimed 3 bytes (got $reclaimed)") - expect(store.count() == 0L, "store empty after deletes + compact") + expect(listener.evictions.size == 2, "compact bypasses the listener") + + // Replacing the listener releases the previous one (the producer's + // `free` drops the GlobalRef, so it becomes collectable), as does + // clearing it. + val first = attachThrowaway(store, "first") + System.gc() + expect(first.get() != null, "attached listener stays pinned by the producer") + val second = attachThrowaway(store, "second") + expect(collected(first), "replaced listener released") + expect(second.get() != null, "current listener still pinned") + store.clearEvictionListener() + expect(collected(second), "cleared listener released") + expect(store.delete("alpha"), "delete alpha with no listener") + expect(listener.evictions.size == 2, "detached listener sees nothing") + + // A listener returning false is detached by the store and released. + expect(collected(attachSelfDetaching(store, payload)), "self-detached listener released") + expect(store.count() == 0L, "store empty after self-detach test") + + // A Kotlin exception thrown from onEvict surfaces to the caller as + // WeaveFFIException(-4), not as a KvException, even though `delete` + // has the KvError domain; the JVM keeps running, the entry is gone + // (the producer removes before notifying), and the listener stays + // attached. + val thrower = RecordingListener("thrower", failOnKey = "boom") + store.setEvictionListener(thrower) + expect(store.put("boom", payload, EntryKind.Persistent, null), "put boom") + val foreign = thrownBy { store.delete("boom") } + expect(foreign is WeaveFFIException, "throwing onEvict surfaces as WeaveFFIException (got $foreign)") + expect(foreign !is KvException, "foreign error is not a domain error") + expect((foreign as WeaveFFIException).code == -4, "foreign error code -4 (got ${foreign.code})") + expect(foreign.message?.contains("refused boom") == true, "foreign message text (got ${foreign.message})") + expect(store.count() == 0L, "boom was removed before the listener ran") + expect(store.put("calm", payload, EntryKind.Persistent, null), "store usable after foreign error") + expect(store.delete("calm"), "delete calm") + expect(thrower.evictions == listOf("calm" to EvictionReason.Deleted), "listener still attached after throwing") + store.clearEvictionListener() + + // --- Object graph: share / fork / larger / describe / openMany ----- + + expect(store.put("one", payload, EntryKind.Persistent, null), "put one") + val shared = store.share() + expect(shared !== store, "share() returns a distinct wrapper") + expect(shared.handle == store.handle, "share() wraps the same native object") + expect(shared.count() == 1L, "shared sees existing entries") + expect(shared.put("two", payload, EntryKind.Persistent, null), "put through shared") + expect(store.count() == 2L, "write through shared is visible through the original") + expect(store.get("two")!!.key == "two", "entry written through shared readable through original") + shared.close() + shared.close() + expect(thrownBy { shared.count() } is IllegalStateException, "closed shared wrapper rejects use") + expect(store.count() == 2L, "original still alive after closing the shared wrapper") + + val forked = store.fork() + expect(forked.handle != store.handle, "fork() is a new object") + expect(forked.count() == 2L, "fork copies live entries") + expect(forked.put("three", payload, EntryKind.Persistent, null), "put into fork") + expect(forked.count() == 3L && store.count() == 2L, "fork is independent") + expect(forked.listKeys(null).asSequence().toList() == listOf("one", "three", "two"), "fork keys") + + // `Store?` both ways. + Store.open("/tmp/empty").use { emptyStore -> + expect(emptyStore.larger(null) == null, "empty.larger(null) is null") + val own = store.larger(null) + expect(own != null && own.handle == store.handle, "store.larger(null) is the store itself") + own!!.close() + val bigger = store.larger(forked) + expect(bigger != null && bigger.handle == forked.handle, "store.larger(fork) is the fork") + bigger!!.close() + val self = forked.larger(emptyStore) + expect(self != null && self.handle == forked.handle, "fork.larger(empty) is the fork") + self!!.close() + expect(store.larger(emptyStore)!!.use { it.handle } == store.handle, "store.larger(empty) is the store") + } + + // Objects inside a record, with the optional absent and present. + val info = store.describe("primary", null) + expect(info.label == "primary", "describe label") + expect(info.count == 2L, "describe count (got ${info.count})") + expect(info.mirror == null, "describe mirror absent") + expect(info.store.handle == store.handle, "describe().store is the described object") + expect(info.store.count() == 2L, "describe().store is usable") + val mirrored = store.describe("mirrored", forked) + val mirror = mirrored.mirror + expect(mirror != null && mirror.handle == forked.handle, "describe mirror present") + expect(mirror!!.count() == 3L, "mirror usable") + + // A list of objects as a return, and the typed error from the static. + val many = Store.openMany(listOf("/a", "/b", "/c")) + expect(many.size == 3, "openMany returns 3 stores (got ${many.size})") + expect(many.map { it.handle }.toSet().size == 3, "openMany stores are distinct") + expect(many[0].put("m", payload, EntryKind.Persistent, null), "put into openMany[0]") + expect(many[1].put("n", payload, EntryKind.Persistent, null), "put into openMany[1]") + expect(many.map { it.count() } == listOf(1L, 1L, 0L), "openMany counts") + val manyErr = thrownBy { Store.openMany(listOf("/ok", "")) } + expect(manyErr is KvException.IoError, "openMany with an empty path throws IoError (got $manyErr)") + + // A list of objects and an object inside a record as parameters. The + // encoder mints one reference per object, so the wrappers stay valid. + expect(Store.totalCount(listOf(), null) == 0L, "totalCount of nothing") + expect(Store.totalCount(many, null) == 2L, "totalCount over openMany (got ${Store.totalCount(many, null)})") + expect(Store.totalCount(listOf(store, forked), null) == 5L, "totalCount store + fork") + expect(Store.totalCount(listOf(store, forked), info) == 7L, "totalCount with extra record") + expect(Store.totalCount(listOf(forked, forked), mirrored) == 8L, "totalCount repeats and mirror record") + expect(store.count() == 2L && forked.count() == 3L, "stores still alive after being encoded") + expect(many.all { it.handle != 0L }, "openMany wrappers still alive after being encoded") + + // Release every wrapper we minted; the originals keep working. + info.store.close() + mirrored.store.close() + mirror.close() + many.forEach { it.close() } + expect(thrownBy { Store.totalCount(many, null) } is IllegalStateException, "encoding a closed store throws") + expect(store.count() == 2L, "store alive after releasing record and list references") + expect(forked.count() == 3L, "fork alive after releasing record references") + forked.close() // clear() drops everything that remains. expect(store.put("last", payload, EntryKind.Persistent, null), "put last") @@ -197,5 +356,14 @@ fun main() { expect(store.count() == 0L, "store empty after clear") } + // Close semantics: `use` closed it; a second close is safe and any use + // afterwards throws. + val closed = Store.open("/tmp/closed") + closed.close() + closed.close() + expect(thrownBy { closed.count() } is IllegalStateException, "use after close throws") + expect(thrownBy { closed.share() } is IllegalStateException, "share after close throws") + expect(thrownBy { WeaveFFI.getStats(closed) } is IllegalStateException, "borrowing a closed store throws") + println("kotlin/kvstore: OK") } diff --git a/conformance/node/async_demo.js b/conformance/node/async_demo.js index 7d6b69b3..1810f53c 100644 --- a/conformance/node/async_demo.js +++ b/conformance/node/async_demo.js @@ -32,7 +32,7 @@ function expect(cond, msg) { (async () => { // Async record return: the Promise resolves with a plain TaskResult object. const result = await wv.runTask('alpha'); - expect(result.id > 0, 'runTask assigns an id'); + expect(typeof result.id === 'bigint' && result.id > 0n, 'runTask assigns a BigInt id'); expect(result.value === 'completed: alpha', `runTask value (got ${result.value})`); expect(result.success === true, 'runTask success flag'); @@ -62,7 +62,10 @@ function expect(cond, msg) { expect(wv.cancelTask(1) === false, 'cancelTask reports not cancelled'); // Every spawned task body has completed by the time its callback fires. - expect(wv.activeCallbacks() === 0, 'activeCallbacks settles to zero'); + // i64 returns are BigInt. + const active = wv.activeCallbacks(); + expect(typeof active === 'bigint', `activeCallbacks is a BigInt (got ${typeof active})`); + expect(active === 0n, `activeCallbacks settles to zero (got ${active})`); console.log('node async-demo conformance: OK'); })().catch((e) => { diff --git a/conformance/node/codec.js b/conformance/node/codec.js new file mode 100644 index 00000000..a5758cdf --- /dev/null +++ b/conformance/node/codec.js @@ -0,0 +1,351 @@ +// Conformance consumer: codec sample, Node (N-API) target. +// +// Checks the generated value-buffer codec (index.js) against the producer's +// round-trip oracle in both directions: `sample*` fixtures decoded field by +// field against concrete expected values (producer encodes, consumer +// decodes), `verify*` accepting the same fixture back (consumer encodes, +// producer decodes), `roundtrip*` returning consumer-built values with edge +// cases (empty strings, lists, and maps; unicode; i64/u64 extremes as BigInt; +// NaN, +/-Infinity, and -0 doubles; every Shape variant), the typed +// MismatchError, and objects inside buffers through `Holder` (a Token field, +// an optional Token, a list of Tokens; `primaryOf` returning the same object; +// `samePrimary`; every wrapper released with an idempotent `close()`). The +// harness passes the built addon via WV_ADDON; the generated loader honors +// WEAVEFFI_ADDON. + +'use strict'; + +const assert = require('assert'); +const path = require('path'); + +const ADDON = path.resolve(process.env.WV_ADDON); +process.env.WEAVEFFI_ADDON = ADDON; +// WV_ADDON = /conformance-build/node-codec/build/Release/index.node; +// the generated files sit at /conformance-gen/codec/node/. +const wv = require( + path.resolve(ADDON, '../../../../..', 'conformance-gen', 'codec', 'node', 'index.js') +); + +let failures = 0; +function expect(cond, msg) { + if (!cond) { + console.error('assertion failed: ' + msg); + failures++; + } +} +// Structural equality with SameValue semantics for primitives: BigInt by +// value, NaN equal to NaN, -0 distinct from +0, Buffer by contents. +function same(actual, expected, msg) { + try { + assert.deepStrictEqual(actual, expected); + } catch (e) { + console.error('assertion failed: ' + msg); + console.error(e.message); + failures++; + } +} +function throwsMismatch(fn, msg) { + try { + fn(); + expect(false, msg + ': expected MismatchError'); + } catch (e) { + expect(e instanceof wv.MismatchError, msg + ': MismatchError instance (got ' + (e && e.constructor.name) + ')'); + expect(e instanceof wv.CodecError, msg + ': extends CodecError'); + expect(e instanceof wv.WeaveFFIError, msg + ': extends WeaveFFIError'); + expect(e.code === 1 && wv.MismatchError.CODE === 1, msg + ': code 1'); + } +} + +// The C-style enum is exported as a frozen object (forward and reverse +// mappings), the runtime value `types.d.ts` declares as `export enum`. +const Color = wv.Color; +expect(Color.Red === 0 && Color.Green === 1 && Color.Blue === 7, 'Color values'); +expect(Color[7] === 'Blue', 'Color reverse mapping'); + +// --- Scalars ---------------------------------------------------------------- +const canonicalScalars = { + i8_value: -8, + u8_value: 200, + i16_value: -16000, + u16_value: 60000, + i32_value: -2000000000, + u32_value: 4000000000, + i64_value: -9007199254740993n, + u64_value: 18446744073709551615n, + f32_value: 1.5, + f64_value: -2.25e100, + flag: true, + color: Color.Blue, +}; +const scalars = wv.sampleScalars(); +same(scalars, canonicalScalars, 'sampleScalars matches the canonical fixture'); +expect(typeof scalars.i64_value === 'bigint', 'i64 field is a BigInt'); +expect(typeof scalars.u64_value === 'bigint', 'u64 field is a BigInt'); +expect(typeof scalars.i32_value === 'number', 'i32 field is a number'); +expect(scalars.i64_value === -9007199254740993n, 'i64 beyond 2^53 is exact'); +expect(wv.verifyScalars(scalars) === true, 'verifyScalars accepts the decoded fixture'); +expect(wv.verifyScalars(canonicalScalars) === true, 'verifyScalars accepts a hand-built fixture'); +same(wv.roundtripScalars(scalars), canonicalScalars, 'roundtripScalars preserves every field'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, u64_value: 18446744073709551614n }), 'u64 off by one'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, flag: false }), 'flag flipped'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, color: Color.Red }), 'color changed'); + +// Consumer-built extremes, including doubles JS can express but JSON can't. +const extremes = { + i8_value: -128, + u8_value: 255, + i16_value: -32768, + u16_value: 65535, + i32_value: -2147483648, + u32_value: 4294967295, + i64_value: -9223372036854775808n, + u64_value: 0n, + f32_value: -0, + f64_value: NaN, + flag: false, + color: Color.Red, +}; +const extremesBack = wv.roundtripScalars(extremes); +same(extremesBack, extremes, 'extreme scalars round-trip'); +expect(Object.is(extremesBack.f32_value, -0), 'f32 -0 keeps its sign'); +expect(Number.isNaN(extremesBack.f64_value), 'f64 NaN survives'); +const maxes = { + ...extremes, + i8_value: 127, + i16_value: 32767, + i32_value: 2147483647, + i64_value: 9223372036854775807n, + u64_value: 18446744073709551615n, + f32_value: Infinity, + f64_value: -Infinity, + color: Color.Green, +}; +same(wv.roundtripScalars(maxes), maxes, 'max scalars round-trip'); +// A number for a 64-bit field is accepted by the encoder (BigInt(v)). +same(wv.roundtripScalars({ ...extremes, i64_value: 42, u64_value: 7 }), { ...extremes, i64_value: 42n, u64_value: 7n }, 'number literals widen to BigInt on the way back'); + +// Direct-family 64-bit params and returns are BigInt end to end. +for (const v of [0n, 1n, -1n, 9007199254740993n, -9007199254740993n, 9223372036854775807n, -9223372036854775808n]) { + const back = wv.roundtripI64(v); + expect(typeof back === 'bigint' && back === v, `roundtripI64(${v}) (got ${back})`); +} +expect(wv.roundtripI64(5) === 5n, 'roundtripI64 accepts a number and returns a BigInt'); +for (const v of [0n, 1n, 9223372036854775808n, 18446744073709551615n]) { + const back = wv.roundtripU64(v); + expect(typeof back === 'bigint' && back === v, `roundtripU64(${v}) (got ${back})`); +} +for (const bad of [9223372036854775808n, -9223372036854775809n]) { + try { + wv.roundtripI64(bad); + expect(false, `roundtripI64(${bad}) should reject an out-of-range BigInt`); + } catch (e) { + expect(e instanceof RangeError, `out-of-range i64 is a RangeError (got ${e && e.constructor.name})`); + } +} +try { + wv.roundtripU64(-1n); + expect(false, 'roundtripU64(-1n) should reject'); +} catch (e) { + expect(e instanceof RangeError, `negative u64 is a RangeError (got ${e && e.constructor.name})`); +} + +// Doubles by value. +for (const v of [0, -0, 1.5, -2.25e100, 5e-324, Number.MAX_VALUE, Infinity, -Infinity]) { + expect(Object.is(wv.roundtripF64(v), v), `roundtripF64(${v})`); +} +expect(Number.isNaN(wv.roundtripF64(NaN)), 'roundtripF64(NaN)'); +expect(wv.roundtripBool(true) === true && wv.roundtripBool(false) === false, 'roundtripBool'); +expect(wv.roundtripColor(Color.Blue) === 7, 'roundtripColor(Blue) == 7'); +expect(wv.roundtripColor(Color.Red) === 0, 'roundtripColor(Red) == 0'); + +// Strings and bytes at the top level. +for (const s of ['', 'ascii', 'héllo wörld ✓', '日本語', 'emoji 🎉 pair', 'a'.repeat(70000)]) { + expect(wv.roundtripString(s) === s, `roundtripString(${JSON.stringify(s)})`); +} +const allBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); +expect(wv.roundtripBytes(allBytes).equals(allBytes), 'roundtripBytes(0..255)'); +const emptyBytes = wv.roundtripBytes(Buffer.alloc(0)); +expect(Buffer.isBuffer(emptyBytes) && emptyBytes.length === 0, 'roundtripBytes(empty) is an empty Buffer'); + +// Optionals and maps at the top level. +expect(wv.roundtripOptI64(null) === null, 'roundtripOptI64(null)'); +expect(wv.roundtripOptI64(undefined) === null, 'roundtripOptI64(undefined) is null'); +expect(wv.roundtripOptI64(-9223372036854775808n) === -9223372036854775808n, 'roundtripOptI64(i64::MIN)'); +expect(wv.roundtripOptI64(0n) === 0n, 'roundtripOptI64(0n) is present'); +same(wv.roundtripMap({}), {}, 'roundtripMap({})'); +same(wv.roundtripMap({ a: 1n, '': -2n, 'ключ': 9223372036854775807n }), { a: 1n, '': -2n, 'ключ': 9223372036854775807n }, 'roundtripMap with odd keys'); + +// --- Shape (rich enum) ------------------------------------------------------ +const shapeCases = [ + { tag: 'Empty' }, + { tag: 'Circle', radius: 2.5 }, + { tag: 'Circle', radius: -0 }, + { tag: 'Rect', width: 1.0, height: 0.5 }, + { tag: 'Labeled', label: 'tag', count: 3 }, + { tag: 'Labeled', label: '', count: -2147483648 }, + { tag: 'Nested', inner: canonicalScalars, note: 'n' }, + { tag: 'Nested', inner: extremes, note: null }, +]; +for (const s of shapeCases) { + same(wv.roundtripShape(s), s, `roundtripShape(${s.tag})`); +} +same(wv.roundtripShapes(shapeCases), shapeCases, 'roundtripShapes(all variants)'); +same(wv.roundtripShapes([]), [], 'roundtripShapes([])'); +expect(wv.describeShape({ tag: 'Empty' }) === 'Empty', 'describeShape(Empty)'); +expect(wv.describeShape({ tag: 'Circle', radius: 2.5 }) === 'Circle { radius: 2.5 }', `describeShape(Circle) (got ${wv.describeShape({ tag: 'Circle', radius: 2.5 })})`); +expect(wv.describeShape({ tag: 'Labeled', label: 'tag', count: 3 }) === 'Labeled { label: "tag", count: 3 }', 'describeShape(Labeled)'); +const nestedBack = wv.roundtripShape({ tag: 'Nested', inner: extremes, note: null }); +expect(Number.isNaN(nestedBack.inner.f64_value) && nestedBack.note === null, 'nested NaN and absent note'); +try { + wv.roundtripShape({ tag: 'Hexagon' }); + expect(false, 'unknown tag should throw'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -2, `unknown tag is a runtime trap (got ${e && e.code})`); +} + +// --- Composite -------------------------------------------------------------- +const canonicalComposite = { + name: 'héllo wörld ✓', + blob: Buffer.from([0, 1, 2, 253, 254, 255]), + some_i64: -9223372036854775808n, + none_i64: null, + some_text: '', + names: ['a', '', 'ccc'], + matrix: [[1, 2, 3], [], [-4]], + empty: [], + by_name: { one: 1n, two: 2n, neg: -3n }, + by_id: { '-1': canonicalScalars, '42': { ...canonicalScalars, flag: false } }, + scalars: canonicalScalars, + shape: { tag: 'Labeled', label: 'tag', count: 3 }, + shapes: [ + { tag: 'Empty' }, + { tag: 'Circle', radius: 2.5 }, + { tag: 'Rect', width: 1.0, height: 0.5 }, + { tag: 'Labeled', label: '', count: -1 }, + { tag: 'Nested', inner: canonicalScalars, note: 'n' }, + ], + maybe_shape: { tag: 'Nested', inner: canonicalScalars, note: null }, + maybe_list: Buffer.from([9, 8]), + sparse: [true, null, false], + colors: [Color.Red, Color.Green, Color.Blue], +}; +const composite = wv.sampleComposite(); +same(composite, canonicalComposite, 'sampleComposite matches the canonical fixture'); +expect(Buffer.isBuffer(composite.blob), 'blob is a Buffer'); +expect(Buffer.isBuffer(composite.maybe_list), 'maybe_list is a Buffer'); +expect(typeof composite.some_i64 === 'bigint', 'some_i64 is a BigInt'); +expect(typeof composite.by_name.one === 'bigint', 'map values are BigInt'); +expect(composite.by_id[42].flag === false && composite.by_id[-1].flag === true, 'i32-keyed map indexes by number'); +expect(wv.verifyComposite(composite) === true, 'verifyComposite accepts the decoded fixture'); +expect(wv.verifyComposite(canonicalComposite) === true, 'verifyComposite accepts a hand-built fixture'); +same(wv.roundtripComposite(composite), canonicalComposite, 'roundtripComposite preserves every field'); +const described = wv.describeComposite(composite); +expect(typeof described === 'string' && described.startsWith('Composite {') && described.includes('name: "héllo wörld ✓"'), `describeComposite renders (got ${described.slice(0, 40)}...)`); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, sparse: [true, true, false] }), 'sparse changed'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, none_i64: 0n }), 'absent optional made present'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, by_name: { one: 1n, two: 2n } }), 'map entry missing'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, name: 'hello world' }), 'unicode name changed'); + +// A consumer-built composite with edge values in every position. +const edge = { + name: '', + blob: Buffer.alloc(0), + some_i64: 9223372036854775807n, + none_i64: -1n, + some_text: null, + names: [], + matrix: [[], [-2147483648, 2147483647]], + empty: [NaN, -0, Infinity, -Infinity, 5e-324], + by_name: {}, + by_id: { '-2147483648': extremes, '0': maxes, '2147483647': canonicalScalars }, + scalars: extremes, + shape: { tag: 'Empty' }, + shapes: [], + maybe_shape: null, + maybe_list: null, + sparse: [null, null], + colors: [], +}; +const edgeBack = wv.roundtripComposite(edge); +same(edgeBack, edge, 'edge composite round-trips'); +expect(Number.isNaN(edgeBack.empty[0]) && Object.is(edgeBack.empty[1], -0), 'NaN and -0 inside a list'); +expect(edgeBack.by_id[-2147483648].i64_value === -9223372036854775808n, 'i32::MIN map key with i64::MIN value'); +const bigList = { + ...edge, + names: Array.from({ length: 1000 }, (_, i) => 'name-' + i), + blob: allBytes, + maybe_list: Buffer.from([255]), + sparse: Array.from({ length: 333 }, (_, i) => (i % 3 === 0 ? null : i % 3 === 1)), +}; +same(wv.roundtripComposite(bigList), bigList, 'large composite round-trips (writer growth)'); + +// --- Holder: objects inside buffers ----------------------------------------- +const holder = wv.makeHolder(10n, true); +expect(holder.primary instanceof wv.Token, 'holder.primary is a Token'); +expect(holder.primary.value() === 10n, `primary value 10n (got ${holder.primary.value()})`); +expect(holder.spare instanceof wv.Token && holder.spare.value() === 11n, 'spare is Token(11)'); +expect(Array.isArray(holder.many) && holder.many.length === 3, 'many has 3 tokens'); +same(holder.many.map((t) => t.value()), [12n, 13n, 14n], 'many values 12..14'); +expect(wv.sumHolder(holder) === 60n, `sumHolder == 60n (got ${wv.sumHolder(holder)})`); +// Encoding clones each handle, so the wrappers are still usable afterwards. +expect(holder.primary.value() === 10n && holder.many[2].value() === 14n, 'tokens usable after sumHolder'); +expect(wv.sumHolder(holder) === 60n, 'sumHolder again (fresh clones each call)'); + +// primaryOf returns a new wrapper over the same underlying object. +const primary = wv.primaryOf(holder); +expect(primary instanceof wv.Token && primary !== holder.primary, 'primaryOf returns a distinct wrapper'); +expect(primary.value() === 10n, 'primaryOf value 10n'); +expect(wv.samePrimary(holder, { primary, spare: null, many: [] }) === true, 'primaryOf aliases holder.primary'); +expect(wv.samePrimary(holder, holder) === true, 'samePrimary(holder, holder)'); +const other = wv.makeHolder(10n, true); +expect(wv.samePrimary(holder, other) === false, 'distinct holders with equal values are different objects'); +expect(wv.sumHolder(other) === 60n, 'other sums to 60n as well'); + +const noSpare = wv.makeHolder(-5n, false); +expect(noSpare.spare === null, 'spare absent is null'); +expect(noSpare.primary.value() === -5n, 'negative base'); +expect(wv.sumHolder(noSpare) === -5n + -3n + -2n + -1n, 'sumHolder without spare'); + +// Consumer-constructed tokens in every buffered position. +const t1 = new wv.Token(100n); +const t2 = new wv.Token(200); +const t3 = new wv.Token(-9223372036854775808n); +expect(t1.value() === 100n && t2.value() === 200n && t3.value() === -9223372036854775808n, 'constructed token values'); +const mine = { primary: t1, spare: t2, many: [t3, t1, t1] }; +expect(wv.sumHolder(mine) === 100n + 200n + -9223372036854775808n + 100n + 100n, 'sumHolder over consumer tokens'); +expect(wv.sumHolder({ primary: t1, spare: null, many: [] }) === 100n, 'sumHolder with no spare and empty many'); +expect(wv.samePrimary(mine, { primary: t1, spare: null, many: [] }) === true, 'same consumer token in two holders'); +expect(wv.samePrimary(mine, { primary: t2, spare: null, many: [] }) === false, 'different consumer tokens'); +const primaryMine = wv.primaryOf(mine); +expect(primaryMine.value() === 100n, 'primaryOf a consumer holder'); + +// Release everything: close is idempotent, and a closed token can't be +// encoded (borrow after close traps with -3) or used. +const everyToken = [ + holder.primary, holder.spare, ...holder.many, primary, + other.primary, other.spare, ...other.many, + noSpare.primary, ...noSpare.many, + t1, t2, t3, primaryMine, +]; +for (const t of everyToken) t.close(); +for (const t of everyToken) t.close(); +try { + holder.primary.value(); + expect(false, 'expected throw for use after close'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `use after close is code -3 (got ${e && e.code})`); +} +try { + wv.sumHolder(holder); + expect(false, 'expected throw for encoding a closed object'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `closed object in a buffer is code -3 (got ${e && e.code})`); +} +if (typeof Symbol.dispose === 'symbol') { + const disposable = new wv.Token(1n); + disposable[Symbol.dispose](); + disposable[Symbol.dispose](); +} + +if (failures > 0) process.exit(1); +console.log('node/codec: OK'); diff --git a/conformance/node/contacts.js b/conformance/node/contacts.js index 1dbf76c9..bafa6745 100644 --- a/conformance/node/contacts.js +++ b/conformance/node/contacts.js @@ -4,8 +4,9 @@ // interface class (canonical `new` constructor, instance methods, per-object // state), struct returns materialized as real JS objects, optional strings // (null email), enum-as-int params, list-of-struct returns, the bool return, -// and the typed error surface (NotFoundError / InvalidNameError extending -// ContactsError extending WeaveFFIError, each carrying its stable code). The +// the typed error surface (NotFoundError / InvalidNameError extending +// ContactsError extending WeaveFFIError, each carrying its stable code), and +// the idempotent `close()` of the reference-counted wrapper. The // harness passes the built addon via WV_ADDON; the generated loader honors // WEAVEFFI_ADDON, and the generated index.js lives in the sibling // conformance-gen tree. @@ -29,7 +30,11 @@ function expect(cond, msg) { } } -const ContactType = { Personal: 0, Work: 1, Other: 2 }; +// The C-style enum is exported as a frozen object (forward and reverse +// mappings), the runtime value `types.d.ts` declares as `export enum`. +const ContactType = wv.ContactType; +expect(ContactType.Personal === 0 && ContactType.Work === 1 && ContactType.Other === 2, 'ContactType values'); +expect(ContactType[1] === 'Work', 'ContactType reverse mapping'); const book = new wv.ContactBook(); expect(book instanceof wv.ContactBook, 'book is a ContactBook instance'); @@ -93,7 +98,15 @@ try { expect(e.code === 1, 'InvalidName code == 1 (got ' + e.code + ')'); } -other.destroy(); -book.destroy(); +// Explicit release; close() is idempotent and a closed wrapper rejects use. +other.close(); +other.close(); +book.close(); +try { + book.count(); + expect(false, 'expected throw for use after close'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, 'use after close is a marshalling error (got ' + e.code + ')'); +} console.log('node/contacts: OK'); diff --git a/conformance/node/events.js b/conformance/node/events.js index 6359533c..b2202c9f 100644 --- a/conformance/node/events.js +++ b/conformance/node/events.js @@ -1,14 +1,18 @@ // Conformance consumer: events sample, Node (N-API) target. // -// Exercises the napi_threadsafe_function listener path (register -> the -// producer fires synchronously on send, the TSFN queues onto the JS thread -> -// unregister releases the TSFN and stops delivery) and the lazy iterable -// getMessages (one producer next per step, drained here via spread), through -// the generated wrapper layer (index.js): function names -// are lowerCamelCase with the module prefix stripped by default. Listener -// delivery is asynchronous (event-loop tick), so assertions run after a -// setImmediate boundary. The harness passes the built addon via WV_ADDON; the -// generated loader honors WEAVEFFI_ADDON. +// Drives the ABI 2 callback-interface surface through the generated wrapper +// layer (index.js): a `Subscriber` implemented as a plain JS object (route -> +// Delivery, onMessage -> running count, onAttached receiving the EventBus +// object the producer hands over), the reference-counted `EventBus` class +// (constructor, methods, idempotent `close()`, use-after-close trap), the +// `Delivery` return steering `publish`'s accepted count, the Promise-backed +// `publishLater` whose callbacks hop from the producer thread onto the JS +// thread, the lazy iterable `messages()`, the nullable `lastMessage()` +// record, the free function `routeOnce`, and a subscriber that throws: the +// exception surfaces to the caller as a WeaveFFIError with code -4 (foreign +// error) without aborting the process. i64 values cross as BigInt. The +// harness passes the built addon via WV_ADDON; the generated loader honors +// WEAVEFFI_ADDON. 'use strict'; @@ -30,40 +34,214 @@ function expect(cond, msg) { } } -const tick = () => new Promise((resolve) => setImmediate(resolve)); +// The C-style enum is exported as a frozen object with forward and reverse +// mappings, the runtime value `types.d.ts` declares as `export enum`. +const Delivery = wv.Delivery; +expect(Delivery.Accept === 0 && Delivery.Skip === 1 && Delivery.AcceptAndStop === 2, 'Delivery values'); +expect(Delivery[2] === 'AcceptAndStop', 'Delivery reverse mapping'); +expect(Object.isFrozen(Delivery), 'Delivery is frozen'); + +// A subscriber as a class: `route` steers delivery per topic, `onMessage` +// records what it saw and returns its running count (an i64, so a BigInt), +// and `onAttached` adopts the bus object the producer hands over. +class RecordingSubscriber { + constructor(name, opts) { + this.name = name; + this.skipTopic = (opts && opts.skip) || null; + this.stopTopic = (opts && opts.stop) || null; + this.throwTopic = (opts && opts.throwOn) || null; + this.routed = []; + this.received = []; + this.attachedBuses = []; + this.attachedCounts = []; + } + route(topic) { + this.routed.push(topic); + if (topic === this.throwTopic) { + throw new Error('subscriber ' + this.name + ' rejects ' + topic); + } + if (topic === this.skipTopic) return Delivery.Skip; + if (topic === this.stopTopic) return Delivery.AcceptAndStop; + return Delivery.Accept; + } + onMessage(message) { + this.received.push(message); + return BigInt(this.received.length); + } + onAttached(bus) { + // The object is usable right away: `subscribe` attaches before it + // appends, so the count seen here is the pre-subscribe count. + expect(bus instanceof wv.EventBus, this.name + ': onAttached receives an EventBus'); + this.attachedCounts.push(bus.subscriberCount()); + this.attachedBuses.push(bus); + } +} (async () => { - const received = []; - const sub = wv.registerMessageListener((message) => received.push(message)); - expect(typeof sub === 'number' && sub > 0, 'listener id positive'); + const bus = new wv.EventBus(); + expect(bus instanceof wv.EventBus, 'constructor returns an EventBus'); + expect(bus.subscriberCount() === 0n, 'fresh bus has no subscribers'); + expect(bus.lastMessage() === null, 'lastMessage is null before any publish'); + expect([...bus.messages()].length === 0, 'messages() empty before any publish'); - wv.sendMessage('alpha'); - wv.sendMessage('beta'); - await tick(); + // subscribe() returns the new subscriber count and calls onAttached with + // the bus object (one strong reference transferred to the consumer). + const a = new RecordingSubscriber('a', { skip: 'quiet', stop: 'stop' }); + const b = new RecordingSubscriber('b'); + expect(bus.subscribe(a) === 1n, 'subscribe a returns 1n'); + expect(bus.subscribe(b) === 2n, 'subscribe b returns 2n'); + expect(bus.subscriberCount() === 2n, 'subscriberCount == 2n'); + expect(a.attachedBuses.length === 1, 'a attached once'); + expect(a.attachedCounts[0] === 0n, `a saw 0 subscribers at attach (got ${a.attachedCounts[0]})`); + expect(b.attachedCounts[0] === 1n, `b saw 1 subscriber at attach (got ${b.attachedCounts[0]})`); + + // Both accept: two deliveries, one Message each with every field decoded. + const n1 = bus.publish('news', 'hello', ['x', 'y']); + expect(typeof n1 === 'bigint', 'publish returns a BigInt'); + expect(n1 === 2n, `publish(news) accepted by 2 (got ${n1})`); + expect(a.received.length === 1 && b.received.length === 1, 'both received news'); + const m = a.received[0]; + expect(m.seq === 1n, `message seq is 1n (got ${m.seq})`); + expect(m.topic === 'news', 'message topic'); + expect(m.text === 'hello', 'message text'); expect( - received.length === 2 && received[0] === 'alpha' && received[1] === 'beta', - `listener received sends (got ${JSON.stringify(received)})` + Array.isArray(m.tags) && m.tags.length === 2 && m.tags[0] === 'x' && m.tags[1] === 'y', + `message tags (got ${JSON.stringify(m.tags)})` ); + expect(a.routed.length === 1 && a.routed[0] === 'news', 'route called with topic'); + + // Skip steers the count: a skips "quiet", only b accepts. + const n2 = bus.publish('quiet', 'psst', []); + expect(n2 === 1n, `publish(quiet) accepted by 1 (got ${n2})`); + expect(a.received.length === 1, 'a skipped quiet'); + expect(b.received.length === 2 && b.received[1].seq === 2n, 'b received quiet with seq 2n'); + expect(b.received[1].tags.length === 0, 'empty tags list decodes as []'); + + // AcceptAndStop: a accepts and the bus stops before reaching b. + const n3 = bus.publish('stop', 'last', ['z']); + expect(n3 === 1n, `publish(stop) accepted by 1 (got ${n3})`); + expect(a.received.length === 2 && a.received[1].text === 'last', 'a received stop'); + expect(b.received.length === 2, 'b not reached after AcceptAndStop'); + expect(b.routed.length === 2, 'b.route not consulted after stop'); - const msgs = [...wv.getMessages()]; + // The adopted bus is the same underlying object: it observes the same log. + const viaA = a.attachedBuses[0]; + expect(viaA.subscriberCount() === 2n, 'adopted bus sees both subscribers'); + const viaAMsgs = [...viaA.messages()]; expect( - msgs.length === 2 && msgs[0] === 'alpha' && msgs[1] === 'beta', - `iterator yields messages in order (got ${JSON.stringify(msgs)})` + viaAMsgs.join('|') === 'hello|psst|last', + `adopted bus shares the log (got ${JSON.stringify(viaAMsgs)})` ); + const lastViaA = viaA.lastMessage(); + expect(lastViaA !== null && lastViaA.seq === 3n && lastViaA.text === 'last', 'adopted bus lastMessage'); + // Releasing the adopted references leaves the original alive. + for (const adopted of a.attachedBuses.concat(b.attachedBuses)) { + adopted.close(); + adopted.close(); + } + expect(bus.subscriberCount() === 2n, 'bus alive after adopted wrappers closed'); + + // Async publish: the producer thread calls route/onMessage, which hop to + // the JS thread while this frame awaits. + const later = bus.publishLater('news', 'later'); + expect(later instanceof Promise, 'publishLater returns a Promise'); + const n4 = await later; + expect(n4 === 2n, `publishLater accepted by 2 (got ${n4})`); + expect(a.received.length === 3 && a.received[2].text === 'later', 'a received the async message'); + expect(b.received.length === 3 && b.received[2].seq === 4n, 'b received the async message'); + expect(a.received[2].tags.length === 0, 'publishLater sends no tags'); - // Unregister stops delivery; the producer still records the message. - wv.unregisterMessageListener(sub); - wv.sendMessage('gamma'); - await tick(); - expect(received.length === 2, `no delivery after unregister (got ${JSON.stringify(received)})`); - expect([...wv.getMessages()].length === 3, 'producer kept recording'); + // Iterator and nullable record. + const msgs = [...bus.messages()]; + expect( + msgs.join('|') === 'hello|psst|last|later', + `messages() in order (got ${JSON.stringify(msgs)})` + ); + const last = bus.lastMessage(); + expect(last !== null && typeof last === 'object', 'lastMessage present'); + expect(last.seq === 4n && last.topic === 'news' && last.text === 'later', 'lastMessage fields'); + expect(Array.isArray(last.tags) && last.tags.length === 0, 'lastMessage tags empty'); - // Early return closes the underlying producer handle without draining. - const it = wv.getMessages()[Symbol.iterator](); + // Early return closes the underlying producer iterator without draining. + const it = bus.messages()[Symbol.iterator](); const first = it.next(); - expect(!first.done && first.value === 'alpha', 'lazy first element'); - if (typeof it.return === 'function') it.return(); + expect(!first.done && first.value === 'hello', 'lazy first element'); + expect(typeof it.return === 'function', 'iterator has return()'); + it.return(); + expect(it.next().done === true, 'iterator done after return()'); + + // A subscriber whose route throws: the producer aborts publish and the + // caller sees the foreign error (code -4) carrying the thrown message. + const bad = new RecordingSubscriber('bad', { throwOn: 'boom' }); + expect(bus.subscribe(bad) === 3n, 'subscribe bad returns 3n'); + try { + bus.publish('boom', 'x', []); + expect(false, 'expected publish to throw when a subscriber throws'); + } catch (e) { + expect(e instanceof wv.WeaveFFIError, `foreign error is a WeaveFFIError (got ${e && e.constructor.name})`); + expect(e.code === -4, `foreign error code is -4 (got ${e.code})`); + expect( + typeof e.errorMessage === 'string' && e.errorMessage.includes('rejects boom'), + `foreign error carries the JS message (got ${JSON.stringify(e.errorMessage)})` + ); + } + // a and b ran before bad and still accepted; the bus is intact. + expect(a.received.length === 4 && b.received.length === 4, 'earlier subscribers still delivered'); + expect(bus.subscriberCount() === 3n, 'bus intact after foreign error'); + expect(bus.publish('ok', 'y', []) === 3n, 'publish works again after foreign error'); + + // The same through the async path: the Promise rejects with code -4. + try { + await bus.publishLater('boom', 'z'); + expect(false, 'expected publishLater to reject when a subscriber throws'); + } catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -4, `async foreign error code -4 (got ${e && e.code})`); + expect(e.errorMessage.includes('rejects boom'), 'async foreign error carries the JS message'); + } + + // A wrong return type from the callback is also a foreign error. + const weird = { route() { return 'not a number'; }, onMessage() { return 0n; }, onAttached() {} }; + try { + wv.routeOnce(weird, 'any'); + expect(false, 'expected routeOnce to throw for a bad return type'); + } catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -4, `bad return type is code -4 (got ${e && e.code})`); + } + + // routeOnce: a free function taking the callback interface; no bus involved. + const solo = new RecordingSubscriber('solo', { skip: 'quiet' }); + expect(wv.routeOnce(solo, 'quiet') === Delivery.Skip, 'routeOnce(quiet) == Skip'); + expect(wv.routeOnce(solo, 'news') === Delivery.Accept, 'routeOnce(news) == Accept'); + expect(solo.routed.length === 2 && solo.attachedBuses.length === 0, 'routeOnce calls route only'); + // A plain object literal works as an implementation too. + const literal = { route: () => Delivery.AcceptAndStop, onMessage: () => 1n, onAttached: () => {} }; + expect(wv.routeOnce(literal, 'x') === Delivery.AcceptAndStop, 'object literal subscriber'); + + // clearSubscribers drops every retained subscriber. + bus.clearSubscribers(); + expect(bus.subscriberCount() === 0n, 'subscriberCount == 0n after clear'); + expect(bus.publish('news', 'nobody', []) === 0n, 'publish with no subscribers accepts 0'); + const all = [...bus.messages()]; + expect(all.length === 8, `log records every publish, aborted ones included (got ${all.length})`); + + // Idempotent close; use after close is a marshalling error (-3). + bus.close(); + bus.close(); + try { + bus.subscriberCount(); + expect(false, 'expected throw for use after close'); + } catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `use after close is code -3 (got ${e && e.code})`); + } + if (typeof Symbol.dispose === 'symbol') { + const disposable = new wv.EventBus(); + disposable[Symbol.dispose](); + disposable[Symbol.dispose](); + } if (failures > 0) process.exit(1); console.log('node/events: OK'); -})(); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/conformance/node/kvstore.js b/conformance/node/kvstore.js index 0333028a..8ebee464 100644 --- a/conformance/node/kvstore.js +++ b/conformance/node/kvstore.js @@ -1,18 +1,21 @@ // Conformance consumer: kvstore sample, Node (N-API) target. // -// Drives the generated wrapper layer (index.js) end to end: the Store -// interface class (non-`new` constructor mapped to the static `open` factory, -// instance methods, the static `defaultCapacity`, explicit `destroy`), the -// typed error surface (KeyNotFoundError / IoError extending KvError extending -// WeaveFFIError, each carrying its stable code), struct materialization with -// complex fields (`Buffer` bytes, nullable scalar, list, map), the -// iterator-backed `listKeys` (order + prefix filter), the deprecated +// Drives the generated wrapper layer (index.js) end to end: the +// reference-counted Store class (static `open` factory, instance methods, +// the static `defaultCapacity`, idempotent `close()`, use-after-close trap), +// the typed error surface (KeyNotFoundError / ExpiredError / IoError +// extending KvError extending WeaveFFIError, each carrying its stable code), +// record materialization with complex fields (`Buffer` bytes, nullable +// BigInt, list, map), the iterator-backed `listKeys`, the deprecated // `legacyPut`, the `kv.stats` submodule taking the interface by reference, -// the TSFN-backed eviction listener (delivery hops to the JS thread, so -// assertions follow a setImmediate boundary), and the promise-returning -// `compact` settled via a threadsafe function from the producer's worker -// thread. The harness passes the built addon via WV_ADDON; the generated -// loader honors WEAVEFFI_ADDON. +// the synchronous `EvictionListener` callback interface implemented as a JS +// class (return value detaches, replace and clear, a throwing listener +// surfacing to the caller as code -4), objects in every buffered position +// (`share()` aliasing the same object, `fork()`, `larger(null)`, +// `describe().store`, `openMany`, `totalCount` with a record that carries +// objects), and the Promise-returning `compact`. i64 values cross as BigInt. +// The harness passes the built addon via WV_ADDON; the generated loader +// honors WEAVEFFI_ADDON. 'use strict'; @@ -26,16 +29,34 @@ const wv = require( path.resolve(ADDON, '../../../../..', 'conformance-gen', 'kvstore', 'node', 'index.js') ); +let failures = 0; function expect(cond, msg) { if (!cond) { console.error('assertion failed: ' + msg); - process.exit(1); + failures++; } } -const tick = () => new Promise((resolve) => setImmediate(resolve)); +// C-style enums are exported as frozen objects (forward and reverse mappings). +const EntryKind = wv.EntryKind; +const EvictionReason = wv.EvictionReason; +expect(EntryKind.Volatile === 0 && EntryKind.Persistent === 1 && EntryKind.Encrypted === 2, 'EntryKind values'); +expect(EvictionReason.Deleted === 0 && EvictionReason.Expired === 1, 'EvictionReason values'); +expect(EvictionReason[1] === 'Expired', 'EvictionReason reverse mapping'); -const EntryKind = { Volatile: 0, Persistent: 1, Encrypted: 2 }; +// An eviction listener as a JS class: records every notification and keeps +// receiving until `stopAfter` notifications have arrived. +class Listener { + constructor(name, stopAfter) { + this.name = name; + this.stopAfter = stopAfter === undefined ? Infinity : stopAfter; + this.seen = []; + } + onEvict(entry, reason) { + this.seen.push({ key: entry.key, reason, entry }); + return this.seen.length < this.stopAfter; + } +} // Typed error on the factory: an empty path is rejected with the IoError // class (code 1004; `IoError` already ends in `Error`, so no suffix stacking). @@ -47,25 +68,29 @@ try { expect(e instanceof wv.KvError, 'IoError extends KvError'); expect(e instanceof wv.WeaveFFIError, 'IoError extends WeaveFFIError'); expect(e.code === 1004, 'IoError code == 1004 (got ' + e.code + ')'); + expect(wv.IoError.CODE === 1004, 'IoError.CODE == 1004'); } const store = wv.Store.open('/tmp/conformance-kvstore-node'); expect(store instanceof wv.Store, 'open returns a Store instance'); -// Static method. -expect(wv.Store.defaultCapacity() === 1000000, 'defaultCapacity == 1000000'); +// Static method; i64 returns are BigInt. +const cap = wv.Store.defaultCapacity(); +expect(typeof cap === 'bigint', 'defaultCapacity is a BigInt'); +expect(cap === 1000000n, `defaultCapacity == 1000000n (got ${cap})`); const payload = Buffer.from([1, 2, 3]); expect(store.put('alpha', payload, EntryKind.Persistent, null) === true, 'put alpha'); -expect(store.put('beta', payload, EntryKind.Volatile, 3600) === true, 'put beta with ttl'); +expect(store.put('beta', payload, EntryKind.Volatile, 3600n) === true, 'put beta with BigInt ttl'); +expect(store.put('gamma', payload, EntryKind.Encrypted, 7200) === true, 'put gamma with number ttl'); -expect(store.count() === 2, 'count == 2'); +expect(store.count() === 3n, `count == 3n (got ${store.count()})`); // Iterator-backed method return: a lazy iterable streaming keys in sorted // order (one producer next per step), optionally filtered by prefix. const keys = [...store.listKeys(null)]; expect( - keys.length === 2 && keys[0] === 'alpha' && keys[1] === 'beta', + keys.join(',') === 'alpha,beta,gamma', `listKeys yields sorted keys (got ${JSON.stringify(keys)})` ); const filtered = [...store.listKeys('al')]; @@ -73,16 +98,22 @@ expect( filtered.length === 1 && filtered[0] === 'alpha', `listKeys honors the prefix (got ${JSON.stringify(filtered)})` ); +expect([...store.listKeys('zzz')].length === 0, 'listKeys with no match is empty'); +const keyIt = store.listKeys(null)[Symbol.iterator](); +expect(keyIt.next().value === 'alpha', 'lazy first key'); +keyIt.return(); +expect(keyIt.next().done === true, 'iterator done after return()'); -// Struct materialization with complex fields. +// Record materialization with complex fields. const alpha = store.get('alpha'); expect(typeof alpha === 'object' && alpha !== null, 'get alpha object'); -expect(alpha.id > 0, 'entry id positive'); +expect(typeof alpha.id === 'bigint' && alpha.id === 1n, `entry id is 1n (got ${alpha.id})`); expect(alpha.key === 'alpha', 'entry key'); +expect(typeof alpha.created_at === 'bigint' && alpha.created_at > 0n, 'created_at is a positive BigInt'); // Bytes getter -> Buffer. expect(Buffer.isBuffer(alpha.value), 'entry value is Buffer'); -expect(alpha.value.length === 3 && alpha.value[0] === 1 && alpha.value[2] === 3, 'entry value bytes'); +expect(alpha.value.equals(payload), 'entry value bytes'); // Optional-scalar getter: alpha had no TTL -> null. expect(alpha.expires_at === null, 'alpha expires_at null'); @@ -96,9 +127,11 @@ expect( 'alpha metadata empty object' ); -// beta had a TTL, so the nullable-scalar getter yields a number. +// beta had a TTL, so the nullable-scalar getter yields a BigInt. const beta = store.get('beta'); -expect(typeof beta.expires_at === 'number' && beta.expires_at > 0, 'beta expires_at number'); +expect(typeof beta.expires_at === 'bigint', 'beta expires_at BigInt'); +expect(beta.expires_at === beta.created_at + 3600n, 'beta expires_at == created_at + 3600'); +expect(beta.id === 2n, 'beta id 2n'); // Typed error on a method: a missing key throws the KeyNotFound class. try { @@ -110,44 +143,229 @@ try { expect(e instanceof wv.WeaveFFIError, 'KeyNotFoundError extends WeaveFFIError'); expect(e.code === 1001, 'KeyNotFound code == 1001 (got ' + e.code + ')'); expect(wv.KeyNotFoundError.CODE === 1001, 'KeyNotFoundError.CODE == 1001'); + expect(e.errorMessage === 'key not found', `default message (got ${JSON.stringify(e.errorMessage)})`); } -// Deprecated method still works (delete before the listener registers so the -// eviction counts below stay exact). +// Deprecated method still works. expect(store.legacyPut('legacy', payload) === true, 'legacyPut inserts'); -expect(store.count() === 3, 'count == 3 after legacyPut'); +expect(store.count() === 4n, 'count == 4n after legacyPut'); expect(store.delete('legacy') === true, 'delete legacy'); +expect(store.delete('legacy') === false, 'second delete returns false'); // kv.stats submodule: takes the Store instance by reference. const st = wv.getStats(store); -expect(st.total_entries === 2, 'stats total entries == 2'); -expect(st.total_bytes === 6, 'stats total bytes == 6'); -expect(st.expired_entries === 0, 'stats expired entries == 0'); +expect(st.total_entries === 3n, `stats total entries == 3n (got ${st.total_entries})`); +expect(st.total_bytes === 9n, `stats total bytes == 9n (got ${st.total_bytes})`); +expect(st.expired_entries === 0n, 'stats expired entries == 0n'); -(async () => { - // Eviction listener: the producer fires on the deleting thread; the TSFN - // queues delivery onto the JS thread (visible after an event-loop tick). - const evicted = []; - const sub = wv.registerEvictionListener((key) => evicted.push(key)); - expect(typeof sub === 'number' && sub > 0, 'listener id positive'); - - expect(store.delete('beta') === true, 'delete beta'); - await tick(); - expect(evicted.length === 1 && evicted[0] === 'beta', `eviction fired for beta (got ${JSON.stringify(evicted)})`); - - // Unregister stops delivery. - wv.unregisterEvictionListener(sub); - expect(store.delete('alpha') === true, 'delete alpha'); - await tick(); - expect(evicted.length === 1, `no eviction after unregister (got ${JSON.stringify(evicted)})`); +// --- Eviction listener (synchronous callback interface) --------------------- +const l1 = new Listener('l1'); +store.setEvictionListener(l1); +expect(store.delete('gamma') === true, 'delete gamma'); +expect(l1.seen.length === 1, `listener fired synchronously (got ${l1.seen.length})`); +expect(l1.seen[0].key === 'gamma', 'evicted key gamma'); +expect(l1.seen[0].reason === EvictionReason.Deleted, `reason Deleted (got ${l1.seen[0].reason})`); +expect(l1.seen[0].entry.id === 3n && l1.seen[0].entry.value.equals(payload), 'evicted entry decoded'); + +// An expired entry is evicted on read: get() throws ExpiredError and the +// listener sees reason Expired. +expect(store.put('doomed', Buffer.from([9]), EntryKind.Volatile, -1n) === true, 'put doomed'); +try { + store.get('doomed'); + expect(false, 'expected ExpiredError'); +} catch (e) { + expect(e instanceof wv.ExpiredError && e.code === 1002, `ExpiredError code 1002 (got ${e && e.code})`); + expect(e instanceof wv.KvError, 'ExpiredError extends KvError'); +} +expect(l1.seen.length === 2 && l1.seen[1].key === 'doomed', 'listener saw the expiry'); +expect(l1.seen[1].reason === EvictionReason.Expired, `reason Expired (got ${l1.seen[1].reason})`); +expect(l1.seen[1].entry.expires_at !== null && l1.seen[1].entry.expires_at < l1.seen[1].entry.created_at, 'expired entry carries its past expiry'); + +// Replacing the listener: the new one receives, the old one doesn't. +const l2 = new Listener('l2', 1); +store.setEvictionListener(l2); +expect(store.put('x1', payload, EntryKind.Volatile, null) && store.put('x2', payload, EntryKind.Volatile, null), 'put x1 x2'); +expect(store.delete('x1') === true, 'delete x1'); +expect(l2.seen.length === 1 && l2.seen[0].key === 'x1', 'replacement listener fired'); +expect(l1.seen.length === 2, 'replaced listener no longer fires'); +// l2 returned false: it detached itself, so the next eviction is unobserved. +expect(store.delete('x2') === true, 'delete x2'); +expect(l2.seen.length === 1, 'listener detached after returning false'); + +// A listener that throws: delete() aborts with the foreign error (code -4) +// and the process keeps running; the entry is gone regardless. +const thrower = { + onEvict(entry) { + throw new TypeError('listener refuses ' + entry.key); + }, +}; +store.setEvictionListener(thrower); +expect(store.put('x3', payload, EntryKind.Volatile, null) === true, 'put x3'); +try { + store.delete('x3'); + expect(false, 'expected delete to throw when the listener throws'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError, `foreign error is a WeaveFFIError (got ${e && e.constructor.name})`); + expect(!(e instanceof wv.KvError), 'foreign error is not a domain error'); + expect(e.code === -4, `foreign error code -4 (got ${e.code})`); + expect(e.errorMessage.includes('listener refuses x3'), `carries the JS message (got ${JSON.stringify(e.errorMessage)})`); +} +expect(store.count() === 2n, 'x3 removed despite the listener failure'); +// A wrong return type is a foreign error too. +store.setEvictionListener({ onEvict: () => 'yes' }); +expect(store.put('x4', payload, EntryKind.Volatile, null) === true, 'put x4'); +try { + store.delete('x4'); + expect(false, 'expected delete to throw for a bad listener return'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -4, `bad return type is code -4 (got ${e && e.code})`); +} +store.clearEvictionListener(); +expect(store.put('x5', payload, EntryKind.Volatile, null) === true, 'put x5'); +expect(store.delete('x5') === true, 'delete after clearEvictionListener'); +expect(store.count() === 2n, 'count back to 2n'); + +// --- Objects in every position ---------------------------------------------- +// share(): a second wrapper over the same object. Writes through one are +// visible through the other, and closing one leaves the other alive. +const shared = store.share(); +expect(shared instanceof wv.Store, 'share returns a Store'); +expect(shared !== store, 'share returns a distinct wrapper'); +expect(shared.count() === 2n, 'shared sees the same entries'); +expect(shared.put('via-shared', payload, EntryKind.Volatile, null) === true, 'put through shared'); +expect(store.count() === 3n, 'write through share visible through the original'); +expect(store.get('via-shared').key === 'via-shared', 'entry readable through the original'); +const l3 = new Listener('l3'); +shared.setEvictionListener(l3); +expect(store.delete('via-shared') === true, 'delete through the original'); +expect(l3.seen.length === 1 && l3.seen[0].key === 'via-shared', 'listener set through share fires for the original'); +store.clearEvictionListener(); +shared.close(); +shared.close(); +expect(store.count() === 2n, 'original alive after shared wrapper closed'); + +// fork(): an independent copy. +const forked = store.fork(); +expect(forked.count() === 2n, 'fork copies live entries'); +expect(forked.put('only-in-fork', payload, EntryKind.Volatile, null) === true, 'put in fork'); +expect(forked.count() === 3n && store.count() === 2n, 'fork is independent'); +expect(forked.get('alpha').id === 1n, 'fork preserves entry ids'); + +// larger(): Store? both ways. +const empty = wv.Store.open('/tmp/empty'); +expect(empty.larger(null) === null, 'larger(null) on an empty store is null'); +expect(empty.larger(undefined) === null, 'larger(undefined) on an empty store is null'); +const own = store.larger(null); +expect(own instanceof wv.Store && own.count() === 2n, 'larger(null) on a non-empty store is itself'); +const bigger = store.larger(forked); +expect(bigger instanceof wv.Store && bigger.count() === 3n, 'larger(forked) picks the fork'); +const stillOwn = store.larger(empty); +expect(stillOwn.count() === 2n, 'larger(empty) picks self'); +own.close(); +bigger.close(); +stillOwn.close(); +expect(store.count() === 2n && forked.count() === 3n, 'sources alive after result wrappers closed'); +// describe(): a record carrying an object field and an optional object. +const info = store.describe('primary', null); +expect(info.label === 'primary', 'describe label'); +expect(info.count === 2n, `describe count 2n (got ${info.count})`); +expect(info.store instanceof wv.Store, 'describe().store is a Store'); +expect(info.mirror === null, 'describe().mirror is null when absent'); +expect(info.store.count() === 2n, 'describe().store is usable'); +expect(info.store.put('via-info', payload, EntryKind.Volatile, null) === true, 'put through describe().store'); +expect(store.count() === 3n, 'describe().store aliases the original'); +const infoWithMirror = store.describe('mirrored', forked); +expect(infoWithMirror.mirror instanceof wv.Store, 'describe().mirror is a Store when present'); +expect(infoWithMirror.mirror.count() === 3n, 'mirror is usable'); +expect(infoWithMirror.mirror.get('only-in-fork').key === 'only-in-fork', 'mirror aliases the fork'); + +// openMany(): a list of objects as a return; a bad path fails the whole call. +const many = wv.Store.openMany(['/tmp/m1', '/tmp/m2', '/tmp/m3']); +expect(Array.isArray(many) && many.length === 3, `openMany returns 3 stores (got ${many && many.length})`); +expect(many.every((s) => s instanceof wv.Store && s.count() === 0n), 'openMany stores are fresh'); +expect(many[0].put('m', payload, EntryKind.Volatile, null) === true, 'put in many[0]'); +expect(many[1].count() === 0n, 'openMany stores are distinct'); +try { + wv.Store.openMany(['/tmp/ok', '']); + expect(false, 'expected openMany to throw for an empty path'); +} catch (e) { + expect(e instanceof wv.IoError && e.code === 1004, `openMany IoError (got ${e && e.code})`); +} +expect(wv.Store.openMany([]).length === 0, 'openMany([]) is empty'); + +// totalCount(): a list of objects as a parameter plus an optional record +// that carries objects (the encoder clones each handle for the buffer). +expect(wv.Store.totalCount(many, null) === 1n, 'totalCount(many) == 1n'); +expect(wv.Store.totalCount([], null) === 0n, 'totalCount([]) == 0n'); +expect(wv.Store.totalCount([store, forked], null) === 6n, `totalCount([store, forked]) == 6n`); +const total = wv.Store.totalCount(many, info); +expect(total === 4n, `totalCount(many, info) == 4n (got ${total})`); +const total2 = wv.Store.totalCount([], infoWithMirror); +expect(total2 === 3n, `totalCount([], infoWithMirror) == 3n (got ${total2})`); +// Hand-built record with the mirror present. +const total3 = wv.Store.totalCount([empty], { label: 'x', store: forked, mirror: store, count: 0n }); +expect(total3 === 3n, `totalCount with hand-built StoreInfo == 3n (got ${total3})`); +// Every source is still alive: encoding borrowed clones, not the wrappers. +expect(store.count() === 3n && forked.count() === 3n && many[0].count() === 1n, 'sources alive after totalCount'); + +// Release the object graph; every wrapper closes independently. +info.store.close(); +infoWithMirror.store.close(); +infoWithMirror.mirror.close(); +for (const s of many) s.close(); +for (const s of many) s.close(); +empty.close(); +forked.close(); +expect(store.count() === 3n, 'original alive after releasing every alias'); +try { + forked.count(); + expect(false, 'expected throw for use after close'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `use after close is code -3 (got ${e && e.code})`); +} +try { + wv.Store.totalCount([forked], null); + expect(false, 'expected throw for a closed object inside a list'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `closed object in a buffer is code -3 (got ${e && e.code})`); +} +try { + wv.getStats(forked); + expect(false, 'expected throw for a closed object passed by reference'); +} catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `closed object by reference is code -3 (got ${e && e.code})`); +} + +(async () => { // Async: an immediately-expired entry gives compact 3 bytes to reclaim; the // promise settles via a TSFN from the producer's worker thread. - expect(store.put('doomed', payload, EntryKind.Volatile, 0) === true, 'put doomed'); - const reclaimed = await store.compact(); - expect(reclaimed === 3, `compact reclaimed 3 bytes (got ${reclaimed})`); - expect(store.count() === 0, 'store empty after deletes + compact'); + expect(store.delete('via-info') === true, 'delete via-info'); + expect(store.put('dead', payload, EntryKind.Volatile, 0n) === true, 'put dead'); + const st2 = wv.getStats(store); + expect(st2.expired_entries === 1n, `one expired entry pending (got ${st2.expired_entries})`); + const pending = store.compact(); + expect(pending instanceof Promise, 'compact returns a Promise'); + const reclaimed = await pending; + expect(typeof reclaimed === 'bigint', 'compact resolves with a BigInt'); + expect(reclaimed === 3n, `compact reclaimed 3 bytes (got ${reclaimed})`); + expect((await store.compact()) === 0n, 'second compact reclaims nothing'); + expect(store.count() === 2n, 'alpha and beta remain'); + store.clear(); + expect(store.count() === 0n, 'store empty after clear'); + + store.close(); + store.close(); + try { + await store.compact(); + expect(false, 'expected compact on a closed store to throw'); + } catch (e) { + expect(e instanceof wv.WeaveFFIError && e.code === -3, `compact after close is code -3 (got ${e && e.code})`); + } - store.destroy(); + if (failures > 0) process.exit(1); console.log('node/kvstore: OK'); -})(); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/conformance/python/codec.py b/conformance/python/codec.py new file mode 100644 index 00000000..84f34fea --- /dev/null +++ b/conformance/python/codec.py @@ -0,0 +1,375 @@ +"""Conformance consumer: codec sample, Python target. + +Drives the generated ctypes wrapper's value-buffer encoder and decoder against +the producer's round-trip oracle: the canonical `Scalars` and `Composite` +fixtures are decoded and checked field by field (producer encodes, consumer +decodes), handed back through `verify_*` (consumer encodes, producer decodes), +and re-encoded through `roundtrip_*` and compared; consumer-built values with +edge cases (every integer extreme, NaN, the infinities, negative zero, empty +strings, lists, and maps, non-BMP unicode) round-trip; every `Shape` variant +crosses alone, in a list, and inside `Composite`; the direct, string, bytes, +and optional families round-trip; and `Holder` carries `Token` objects in a +field, an optional, and a list (`sum_holder`, `primary_of` returning the same +underlying object, `same_primary`, and every wrapper released with an +idempotent `close()`). The generated package is placed on sys.path via WV_PY; +the cdylib is selected with WEAVEFFI_LIBRARY. +""" +import dataclasses +import gc +import math +import os +import sys + +sys.path.insert(0, os.environ["WV_PY"]) + +import codec as wv # noqa: E402 + +I64_MIN = -(1 << 63) +I64_MAX = (1 << 63) - 1 +U64_MAX = (1 << 64) - 1 + + +def check(cond: bool, what: str) -> None: + if not cond: + print(f"python/codec: FAIL: {what}", file=sys.stderr) + sys.exit(1) + + +def same_float(a: float, b: float) -> bool: + """Bitwise-aware float equality: NaN matches NaN and -0.0 differs from 0.0.""" + if math.isnan(a) or math.isnan(b): + return math.isnan(a) and math.isnan(b) + return a == b and math.copysign(1.0, a) == math.copysign(1.0, b) + + +def canonical_scalars() -> wv.Scalars: + return wv.Scalars( + i8_value=-8, + u8_value=200, + i16_value=-16_000, + u16_value=60_000, + i32_value=-2_000_000_000, + u32_value=4_000_000_000, + i64_value=-9_007_199_254_740_993, + u64_value=U64_MAX, + f32_value=1.5, + f64_value=-2.25e100, + flag=True, + color=wv.Color.Blue, + ) + + +def canonical_composite() -> wv.Composite: + scalars = canonical_scalars() + return wv.Composite( + name="héllo wörld ✓", + blob=bytes([0, 1, 2, 253, 254, 255]), + some_i64=I64_MIN, + none_i64=None, + some_text="", + names=["a", "", "ccc"], + matrix=[[1, 2, 3], [], [-4]], + empty=[], + by_name={"neg": -3, "one": 1, "two": 2}, + by_id={-1: scalars, 42: dataclasses.replace(scalars, flag=False)}, + scalars=scalars, + shape=wv.Shape.Labeled(label="tag", count=3), + shapes=[ + wv.Shape.Empty(), + wv.Shape.Circle(radius=2.5), + wv.Shape.Rect(width=1.0, height=0.5), + wv.Shape.Labeled(label="", count=-1), + wv.Shape.Nested(inner=scalars, note="n"), + ], + maybe_shape=wv.Shape.Nested(inner=scalars, note=None), + maybe_list=bytes([9, 8]), + sparse=[True, None, False], + colors=[wv.Color.Red, wv.Color.Green, wv.Color.Blue], + ) + + +def scalars() -> None: + check(wv.Color.Red == 0 and wv.Color.Green == 1 and wv.Color.Blue == 7, "Color discriminants") + + # Producer encodes, consumer decodes: every field of the fixture. + sample = wv.sample_scalars() + check(isinstance(sample, wv.Scalars), "sample_scalars type") + check(sample.i8_value == -8 and sample.u8_value == 200, "i8/u8") + check(sample.i16_value == -16_000 and sample.u16_value == 60_000, "i16/u16") + check(sample.i32_value == -2_000_000_000 and sample.u32_value == 4_000_000_000, "i32/u32") + check(sample.i64_value == -9_007_199_254_740_993, f"i64 {sample.i64_value}") + check(sample.u64_value == U64_MAX, f"u64 {sample.u64_value}") + check(sample.f32_value == 1.5 and sample.f64_value == -2.25e100, "f32/f64") + check(sample.flag is True and sample.color is wv.Color.Blue, "flag/color") + check(sample == canonical_scalars(), "sample_scalars equals the local canonical value") + + # Consumer encodes, producer decodes. + check(wv.verify_scalars(sample) is True, "verify_scalars(sample)") + check(wv.verify_scalars(canonical_scalars()) is True, "verify_scalars(local canonical)") + check(wv.roundtrip_scalars(sample) == sample, "roundtrip_scalars(sample)") + + # A one-field change is a typed Mismatch. + changed = dataclasses.replace(sample, u8_value=201) + try: + wv.verify_scalars(changed) + check(False, "expected CodecError.Mismatch") + except wv.CodecError.Mismatch as exc: + check(exc.code == 1 and exc.CODE == 1, f"Mismatch code {exc.code}") + check(isinstance(exc, wv.CodecError) and isinstance(exc, wv.WeaveFFIError), "hierarchy") + check(exc.message == "value does not match the canonical fixture", + f"Mismatch message {exc.message!r}") + check(wv.Mismatch is wv.CodecError.Mismatch, "bare Mismatch is the scoped alias") + + # Consumer-built extremes: every integer bound, negative zero, NaN, inf. + edges = wv.Scalars( + i8_value=-128, u8_value=255, i16_value=-32768, u16_value=65535, + i32_value=-(1 << 31), u32_value=(1 << 32) - 1, i64_value=I64_MIN, u64_value=U64_MAX, + f32_value=-0.0, f64_value=float("nan"), flag=False, color=wv.Color.Red, + ) + back = wv.roundtrip_scalars(edges) + check(back.i8_value == -128 and back.u8_value == 255, "edge i8/u8") + check(back.i16_value == -32768 and back.u16_value == 65535, "edge i16/u16") + check(back.i32_value == -(1 << 31) and back.u32_value == (1 << 32) - 1, "edge i32/u32") + check(back.i64_value == I64_MIN and back.u64_value == U64_MAX, "edge i64/u64") + check(same_float(back.f32_value, -0.0), f"f32 negative zero {back.f32_value!r}") + check(same_float(back.f64_value, float("nan")), f"f64 NaN {back.f64_value!r}") + check(back.flag is False and back.color is wv.Color.Red, "edge flag/color") + highs = wv.Scalars( + i8_value=127, u8_value=0, i16_value=32767, u16_value=0, + i32_value=(1 << 31) - 1, u32_value=0, i64_value=I64_MAX, u64_value=0, + f32_value=float("inf"), f64_value=float("-inf"), flag=True, color=wv.Color.Green, + ) + back = wv.roundtrip_scalars(highs) + check(back.i8_value == 127 and back.i16_value == 32767 and back.i32_value == (1 << 31) - 1, + "high signed bounds") + check(back.u8_value == 0 and back.u16_value == 0 and back.u32_value == 0 + and back.u64_value == 0, "unsigned zeros") + check(back.i64_value == I64_MAX, "i64 max") + check(back.f32_value == float("inf") and back.f64_value == float("-inf"), "infinities") + check(back.color is wv.Color.Green, "Green through a record") + # f32 rounds to the nearest single: 0.1 does not survive, 0.25 does. + check(wv.roundtrip_scalars(dataclasses.replace(highs, f32_value=0.25)).f32_value + == 0.25, "f32 exact value") + check(wv.roundtrip_scalars(dataclasses.replace(highs, f32_value=0.1)).f32_value + != 0.1, "f32 narrows to single precision") + + +def composite() -> None: + sample = wv.sample_composite() + check(isinstance(sample, wv.Composite), "sample_composite type") + check(sample.name == "héllo wörld ✓", f"name {sample.name!r}") + check(sample.blob == bytes([0, 1, 2, 253, 254, 255]), f"blob {sample.blob!r}") + check(sample.some_i64 == I64_MIN and sample.none_i64 is None, "optionals") + check(sample.some_text == "", f"some_text {sample.some_text!r}") + check(sample.names == ["a", "", "ccc"], f"names {sample.names}") + check(sample.matrix == [[1, 2, 3], [], [-4]], f"matrix {sample.matrix}") + check(sample.empty == [], "empty list") + check(sample.by_name == {"one": 1, "two": 2, "neg": -3}, f"by_name {sample.by_name}") + check(sorted(sample.by_id) == [-1, 42], f"by_id keys {sorted(sample.by_id)}") + check(sample.by_id[-1] == canonical_scalars(), "by_id[-1]") + check(sample.by_id[42].flag is False and sample.by_id[42].u64_value == U64_MAX, "by_id[42]") + check(sample.scalars == canonical_scalars(), "nested scalars") + check(sample.shape == wv.Shape.Labeled("tag", 3), f"shape {sample.shape}") + check(sample.shape.tag == wv.Shape.Tag.Labeled and isinstance(sample.shape, wv.Shape), + "shape tag") + check([s.tag for s in sample.shapes] == list(wv.Shape.Tag), "shapes one of each variant") + check(sample.shapes[1] == wv.Shape.Circle(2.5) and sample.shapes[2] == wv.Shape.Rect(1.0, 0.5), + "circle / rect") + check(sample.shapes[3] == wv.Shape.Labeled("", -1), "labeled with empty label") + check(sample.shapes[4] == wv.Shape.Nested(canonical_scalars(), "n"), "nested with note") + check(sample.maybe_shape == wv.Shape.Nested(canonical_scalars(), None), "maybe_shape") + check(sample.maybe_list == bytes([9, 8]), f"maybe_list {sample.maybe_list!r}") + check(sample.sparse == [True, None, False], f"sparse {sample.sparse}") + check(sample.colors == [wv.Color.Red, wv.Color.Green, wv.Color.Blue], f"colors {sample.colors}") + check(all(isinstance(c, wv.Color) for c in sample.colors), "colors are enum members") + check(sample == canonical_composite(), "sample_composite equals the local canonical value") + + check(wv.verify_composite(sample) is True, "verify_composite(sample)") + check(wv.verify_composite(canonical_composite()) is True, "verify_composite(local canonical)") + check(wv.roundtrip_composite(sample) == sample, "roundtrip_composite(sample)") + described = wv.describe_composite(sample) + check(described.startswith("Composite { name: \"héllo wörld ✓\"") and "sparse: [Some(true), None, Some(false)]" in described, + f"describe_composite {described[:80]!r}") + + changed = dataclasses.replace(sample, sparse=[True, True, False]) + try: + wv.verify_composite(changed) + check(False, "expected CodecError.Mismatch for a changed composite") + except wv.CodecError.Mismatch: + pass + + # A consumer-built composite full of empties and extremes. + nan_scalars = wv.Scalars(0, 0, 0, 0, 0, 0, 0, 0, float("nan"), -0.0, False, wv.Color.Green) + mine = wv.Composite( + name="", + blob=b"", + some_i64=I64_MAX, + none_i64=-1, + some_text=None, + names=[], + matrix=[[], [(1 << 31) - 1, -(1 << 31)]], + empty=[float("inf"), float("-inf"), -0.0, float("nan"), 1e-308, 1.7976931348623157e308], + by_name={"": I64_MIN, "𝔘𝔫𝔦𝔠𝔬𝔡𝔢 日本語 🧵": I64_MAX}, + by_id={}, + scalars=nan_scalars, + shape=wv.Shape.Empty(), + shapes=[], + maybe_shape=None, + maybe_list=None, + sparse=[], + colors=[], + ) + back = wv.roundtrip_composite(mine) + check(back.name == "" and back.blob == b"" and back.names == [], "empties") + check(back.some_i64 == I64_MAX and back.none_i64 == -1 and back.some_text is None, + "swapped optionals") + check(back.matrix == [[], [(1 << 31) - 1, -(1 << 31)]], f"matrix {back.matrix}") + check(len(back.empty) == 6 and all(same_float(a, b) for a, b in zip(back.empty, mine.empty)), + f"float list {back.empty}") + check(back.by_name == mine.by_name, f"unicode-keyed map {back.by_name}") + check(back.by_id == {} and back.shapes == [] and back.sparse == [] and back.colors == [], + "empty containers") + check(back.maybe_shape is None and back.maybe_list is None, "absent optionals") + check(back.shape == wv.Shape.Empty(), "unit variant") + check(same_float(back.scalars.f32_value, float("nan")) + and same_float(back.scalars.f64_value, -0.0), "NaN / -0.0 inside a nested record") + check(back.scalars.color is wv.Color.Green, "nested enum") + check(wv.describe_composite(mine).startswith("Composite { name: \"\", blob: []"), + "describe_composite of the local value") + # Presence flags matter: an absent optional list differs from an empty one. + with_empty_list = dataclasses.replace(mine, maybe_list=b"") + check(wv.roundtrip_composite(with_empty_list).maybe_list == b"", "present empty optional list") + + +def shapes() -> None: + variants = [ + wv.Shape.Empty(), + wv.Shape.Circle(radius=-0.0), + wv.Shape.Rect(width=0.5, height=float("inf")), + wv.Shape.Labeled(label="ünïcødé ✓", count=-(1 << 31)), + wv.Shape.Nested(inner=canonical_scalars(), note=""), + wv.Shape.Nested(inner=canonical_scalars(), note=None), + ] + for v in variants: + back = wv.roundtrip_shape(v) + check(type(back) is type(v) and back.tag == v.tag, f"roundtrip_shape type {v}") + check(back == v, f"roundtrip_shape {v} -> {back}") + check(same_float(wv.roundtrip_shape(variants[1]).radius, -0.0), "Circle -0.0 radius") + check(wv.roundtrip_shape(variants[2]).height == float("inf"), "Rect inf height") + check(wv.roundtrip_shapes(variants) == variants, "roundtrip_shapes") + check(wv.roundtrip_shapes([]) == [], "roundtrip_shapes([])") + check(wv.describe_shape(wv.Shape.Empty()) == "Empty", "describe Empty") + check(wv.describe_shape(wv.Shape.Circle(2.5)) == "Circle { radius: 2.5 }", "describe Circle") + check(wv.describe_shape(wv.Shape.Rect(1.0, 0.5)) == "Rect { width: 1.0, height: 0.5 }", + "describe Rect") + check(wv.describe_shape(wv.Shape.Labeled("x", 7)) == "Labeled { label: \"x\", count: 7 }", + "describe Labeled") + check(wv.describe_shape(wv.Shape.Nested(canonical_scalars(), None)).startswith( + "Nested { inner: Scalars { i8_value: -8,"), "describe Nested") + check(wv.Shape.Tag.Nested == 4 and wv.Shape.Nested.TAG == wv.Shape.Tag.Nested, "Tag values") + + +def direct_families() -> None: + check(wv.roundtrip_opt_i64(None) is None, "opt_i64 None") + check(wv.roundtrip_opt_i64(0) == 0, "opt_i64 0") + check(wv.roundtrip_opt_i64(I64_MIN) == I64_MIN, "opt_i64 min") + check(wv.roundtrip_opt_i64(I64_MAX) == I64_MAX, "opt_i64 max") + check(wv.roundtrip_map({}) == {}, "map {}") + m = {"": 0, "a": I64_MIN, "ü": I64_MAX, "z" * 300: -1} + check(wv.roundtrip_map(m) == m, "map contents") + check(wv.roundtrip_string("") == "", "string ''") + check(wv.roundtrip_string("héllo 日本語 🧵") == "héllo 日本語 🧵", "unicode string") + check(wv.roundtrip_bytes(b"") == b"", "bytes b''") + check(wv.roundtrip_bytes(bytes(range(256))) == bytes(range(256)), "all byte values") + for v in (0, 1, -1, I64_MIN, I64_MAX): + check(wv.roundtrip_i64(v) == v, f"i64 {v}") + for v in (0, 1, 1 << 63, U64_MAX): + check(wv.roundtrip_u64(v) == v, f"u64 {v}") + for v in (0.0, -0.0, 1.5, -2.25e100, float("inf"), float("-inf"), float("nan"), + 5e-324, 1.7976931348623157e308): + check(same_float(wv.roundtrip_f64(v), v), f"f64 {v!r} -> {wv.roundtrip_f64(v)!r}") + check(wv.roundtrip_bool(True) is True and wv.roundtrip_bool(False) is False, "bool") + for c in wv.Color: + check(wv.roundtrip_color(c) is c, f"color {c}") + + +def holders() -> None: + holder = wv.make_holder(10, True) + check(isinstance(holder, wv.Holder), "make_holder type") + check(isinstance(holder.primary, wv.Token) and holder.primary.value() == 10, "primary") + check(holder.spare is not None and holder.spare.value() == 11, "spare present") + check([t.value() for t in holder.many] == [12, 13, 14], "many") + check(wv.sum_holder(holder) == 10 + 11 + 12 + 13 + 14, "sum_holder") + # Encoding clones each token, so the wrappers stay usable afterwards and + # the holder can be sent again and again. + check(wv.sum_holder(holder) == 60 and holder.primary.value() == 10, "sum_holder twice") + + # primary_of hands back a wrapper over the very same object as + # holder.primary, which the producer confirms by pointer identity. + primary = wv.primary_of(holder) + check(isinstance(primary, wv.Token) and primary is not holder.primary, "primary_of wrapper") + check(primary.value() == 10, "primary_of value") + check(wv.same_primary(holder, wv.Holder(primary=primary, spare=None, many=[])) is True, + "primary_of is the same object as holder.primary") + check(wv.same_primary(holder, holder) is True, "same_primary(holder, holder)") + check(wv.same_primary(holder, wv.make_holder(10, True)) is False, + "distinct tokens with equal values are not the same object") + primary.close() + primary.close() + check(holder.primary.value() == 10, "holder.primary alive after closing the other wrapper") + + without = wv.make_holder(0, False) + check(without.spare is None, "spare absent") + check(wv.sum_holder(without) == 0 + 2 + 3 + 4, "sum without spare") + + # Consumer-built tokens in every buffered position. + with wv.Token(1) as a, wv.Token(2) as b: + c, d = wv.Token(3), wv.Token(4) + mine = wv.Holder(primary=a, spare=b, many=[c, d, a]) + check(wv.sum_holder(mine) == 1 + 2 + 3 + 4 + 1, "sum of a local holder") + check(wv.sum_holder(wv.Holder(primary=a, spare=None, many=[])) == 1, "minimal holder") + p = wv.primary_of(mine) + check(p.value() == 1 and wv.same_primary(mine, wv.Holder(p, None, [])) is True, + "primary_of a local holder") + check(wv.same_primary(mine, wv.Holder(b, None, [])) is False, "different primary") + p.close() + c.close() + d.close() + c.close() + # A closed token anywhere in the holder rejects the whole encoding + # before any reference is minted, so the live tokens leak nothing. + for broken in (wv.Holder(primary=c, spare=None, many=[]), + wv.Holder(primary=a, spare=b, many=[a, d])): + try: + wv.sum_holder(broken) + check(False, "expected error encoding a closed token") + except wv.WeaveFFIError as exc: + check("Token used after close" in exc.message, + f"closed token message {exc.message!r}") + check(a.value() == 1, "a still alive inside the with block") + try: + a.value() + check(False, "expected use-after-close error") + except wv.WeaveFFIError: + pass + + # Release every producer-built wrapper explicitly (twice), then let the + # rest go to the garbage collector. + for t in [holder.primary, holder.spare, without.primary] + holder.many + without.many: + t.close() + t.close() + del holder, without, primary + gc.collect() + + +def main() -> None: + scalars() + composite() + shapes() + direct_families() + holders() + gc.collect() + print("python/codec: OK") + + +main() diff --git a/conformance/python/contacts.py b/conformance/python/contacts.py index 5fd25e58..a514a0bb 100644 --- a/conformance/python/contacts.py +++ b/conformance/python/contacts.py @@ -1,7 +1,8 @@ """Conformance consumer: contacts sample, Python target. -Exercises the generated ctypes wrapper end to end: the `ContactBook` -interface (its `__init__` calls the C constructor and `__del__` the destroy +Exercises the generated ctypes wrapper end to end: the reference-counted +`ContactBook` interface (its `__init__` calls the C constructor; `close()`, +the `with` statement, or `__del__` release the reference through the destroy symbol), enum marshalling, the `Contact` record decoded from a value buffer into a plain dataclass with value equality, optional strings (a buffered `string?` parameter and field), list-of-record returns, boolean returns, and @@ -74,8 +75,19 @@ def main() -> None: assert wv.NotFound is wv.ContactsError.NotFound # A second book is independent state: contacts don't leak across objects. - other = wv.ContactBook() - assert other.count() == 0 + # Objects are context managers with an idempotent close(); a closed + # wrapper rejects further use. + with wv.ContactBook() as other: + assert other.count() == 0 + other.close() + other.close() + try: + other.count() + raise AssertionError("expected use-after-close error") + except wv.WeaveFFIError as exc: + assert "after close" in exc.message, exc.message + assert book.count() == 1 + book.close() print("python/contacts: OK") diff --git a/conformance/python/events.py b/conformance/python/events.py index ec5500a4..353de9b8 100644 --- a/conformance/python/events.py +++ b/conformance/python/events.py @@ -1,38 +1,260 @@ """Conformance consumer: events sample, Python target. -Exercises the full events surface: the ctypes CFUNCTYPE listener trampoline -(register -> fire synchronously on send -> unregister stops delivery) and the -opaque-iterator ABI (`next(iter, &out_item, &err) -> int32`) behind -`get_messages()`. +Exercises the ABI 2 events surface through the generated ctypes wrapper: the +`Subscriber` callback interface implemented by subclassing the generated ABC +(every method is called with the right arguments, the `EventBus` handed to +`on_attached` is a usable wrapper that owns its own strong reference), the +`Delivery` return steering `publish`'s accepted count, the reference-counted +`EventBus` object (`with` statement, idempotent `close()`, use after close +rejected), the asyncio-bridged `publish_later` method, the `messages()` +iterator, the `last_message()` optional record, `route_once` releasing a +subscriber it doesn't retain, and a subscriber that raises a Python exception +surfacing to the caller as `WeaveFFIError` with `FOREIGN_ERROR_CODE` (-4) +without taking the interpreter down. The generated package is placed on +sys.path via WV_PY; the cdylib is selected with WEAVEFFI_LIBRARY. """ +import asyncio +import gc import os import sys +import weakref sys.path.insert(0, os.environ["WV_PY"]) import events as wv # noqa: E402 +def check(cond: bool, what: str) -> None: + if not cond: + print(f"python/events: FAIL: {what}", file=sys.stderr) + sys.exit(1) + + +class Recorder(wv.Subscriber): + """A subscriber that records every callback it receives. + + `skip_topic` is routed Skip, `stop_topic` AcceptAndStop, `fail_topic` + raises from `route`, `fail_text` raises from `on_message`, and + everything else is Accept. The bus received in `on_attached` is kept so + the consumer can prove it stays alive independently of the caller's + wrapper. + """ + + def __init__(self, name: str, skip_topic: str = "", stop_topic: str = "", + fail_topic: str = "", fail_text: str = "") -> None: + self.name = name + self.skip_topic = skip_topic + self.stop_topic = stop_topic + self.fail_topic = fail_topic + self.fail_text = fail_text + self.routed: list[str] = [] + self.messages: list[wv.Message] = [] + self.attached: list[wv.EventBus] = [] + self.attach_counts: list[int] = [] + + def route(self, topic: str) -> wv.Delivery: + self.routed.append(topic) + if topic == self.fail_topic: + raise ValueError(f"{self.name} rejected topic {topic}") + if topic == self.skip_topic: + return wv.Delivery.Skip + if topic == self.stop_topic: + return wv.Delivery.AcceptAndStop + return wv.Delivery.Accept + + def on_message(self, message: wv.Message) -> int: + if message.text == self.fail_text: + raise RuntimeError(f"{self.name} choked on {message.text}") + self.messages.append(message) + return len(self.messages) + + def on_attached(self, bus: wv.EventBus) -> None: + # The wrapper is usable right away: subscribe has not pushed us yet. + self.attach_counts.append(bus.subscriber_count()) + self.attached.append(bus) + + +class Exploding(wv.Subscriber): + """A subscriber whose `on_attached` raises, so `subscribe` itself fails.""" + + def route(self, topic: str) -> wv.Delivery: + return wv.Delivery.Accept + + def on_message(self, message: wv.Message) -> int: + return 0 + + def on_attached(self, bus: wv.EventBus) -> None: + bus.close() + raise KeyError("refusing to attach") + + def main() -> None: - received: list[str] = [] - sub = wv.register_message_listener(received.append) - assert sub > 0, sub + check(wv.Delivery.Accept == 0 and wv.Delivery.Skip == 1 + and wv.Delivery.AcceptAndStop == 2, "Delivery discriminants") + + with wv.EventBus() as bus: + check(bus.subscriber_count() == 0, "fresh bus has no subscribers") + check(bus.last_message() is None, "fresh bus has no last message") + check(list(bus.messages()) == [], "fresh bus has no messages") + + quiet = Recorder("quiet", skip_topic="quiet") + loud = Recorder("loud", stop_topic="stop") + tail = Recorder("tail") + + check(bus.subscribe(quiet) == 1, "first subscribe returns 1") + check(bus.subscribe(loud) == 2, "second subscribe returns 2") + check(bus.subscribe(tail) == 3, "third subscribe returns 3") + check(bus.subscriber_count() == 3, "subscriber_count after three subscribes") + + # on_attached fired once per subscriber, before the push, with a + # live bus wrapper. + check(quiet.attach_counts == [0], f"quiet attach count {quiet.attach_counts}") + check(loud.attach_counts == [1], f"loud attach count {loud.attach_counts}") + check(tail.attach_counts == [2], f"tail attach count {tail.attach_counts}") + check(len(quiet.attached) == 1, "quiet kept its attached bus") + check(isinstance(quiet.attached[0], wv.EventBus), + "on_attached receives an EventBus wrapper") + # The adopted reference is independent of the caller's wrapper: it is + # a different Python object on the same underlying bus. + check(quiet.attached[0] is not bus, "attached wrapper is a distinct object") + check(quiet.attached[0].subscriber_count() == 3, + "attached bus wrapper observes the shared state") + + # Accept everywhere: every subscriber gets it. + n = bus.publish("news", "hello", ["a", "b"]) + check(n == 3, f"publish news accepted {n}") + check(quiet.routed == ["news"] and loud.routed == ["news"] + and tail.routed == ["news"], "route called on each subscriber") + for sub in (quiet, loud, tail): + check(len(sub.messages) == 1, f"{sub.name} received one message") + m = sub.messages[0] + check(m == wv.Message(seq=1, topic="news", text="hello", tags=["a", "b"]), + f"{sub.name} message {m}") + + # Skip steers the count: quiet drops its own topic. + n = bus.publish("quiet", "psst", []) + check(n == 2, f"publish quiet accepted {n}") + check(len(quiet.messages) == 1, "quiet skipped its topic") + check(loud.messages[-1].seq == 2 and loud.messages[-1].tags == [], + "seq advances and empty tags round-trip") + + # AcceptAndStop: loud accepts and stops delivery to tail. + n = bus.publish("stop", "last", ["z"]) + check(n == 2, f"publish stop accepted {n}") + check(len(quiet.messages) == 2, "quiet accepted stop") + check(len(loud.messages) == 3, "loud accepted stop") + check(len(tail.messages) == 2, "tail was not reached after AcceptAndStop") + check(tail.routed == ["news", "quiet"], f"tail routed {tail.routed}") + + # Async publish: resolved from the producer's thread through asyncio. + n = asyncio.run(bus.publish_later("later", "async")) + check(n == 3, f"publish_later accepted {n}") + check(tail.messages[-1] == wv.Message(4, "later", "async", []), + f"async message {tail.messages[-1]}") + + # Iterator and optional record. + check(list(bus.messages()) == ["hello", "psst", "last", "async"], + "messages() streams every text in order") + it = bus.messages() + check(next(it) == "hello", "manual next on iterator") + it.close() + it.close() + last = bus.last_message() + check(last == wv.Message(seq=4, topic="later", text="async", tags=[]), + f"last_message {last}") + + # A subscriber that raises from route aborts publish with the foreign + # error code, and the bus is still usable afterwards. + grumpy = Recorder("grumpy", fail_topic="boom", fail_text="gag") + check(bus.subscribe(grumpy) == 4, "grumpy subscribed") + try: + bus.publish("boom", "x", []) + check(False, "expected WeaveFFIError from a raising route") + except wv.WeaveFFIError as exc: + check(exc.code == wv.WeaveFFIError.FOREIGN_ERROR_CODE == -4, + f"foreign error code {exc.code}") + check("grumpy rejected topic boom" in exc.message, + f"foreign error message {exc.message!r}") + # Earlier subscribers were already delivered before grumpy raised. + check(len(quiet.messages) == 4, "quiet got the aborted message first") + # A raise from on_message is reported the same way. + try: + bus.publish("ok", "gag", []) + check(False, "expected WeaveFFIError from a raising on_message") + except wv.WeaveFFIError as exc: + check(exc.code == -4, f"on_message foreign code {exc.code}") + check("choked on gag" in exc.message, f"on_message message {exc.message!r}") + # The log still recorded both aborted publishes. + check(bus.last_message().text == "gag" and bus.last_message().seq == 6, + "aborted publishes are still logged") + n = bus.publish("ok", "recovered", []) + check(n == 4, f"bus usable after foreign error, accepted {n}") + check(grumpy.messages[-1].text == "recovered", "grumpy delivered after recovering") + + # A raise from on_attached fails subscribe itself and leaves the + # subscriber unretained (its free runs during the abort). + exploding = Exploding() + exploding_ref = weakref.ref(exploding) + try: + bus.subscribe(exploding) + check(False, "expected WeaveFFIError from a raising on_attached") + except wv.WeaveFFIError as exc: + check(exc.code == -4, f"on_attached foreign code {exc.code}") + check("refusing to attach" in exc.message, f"on_attached message {exc.message!r}") + check(bus.subscriber_count() == 4, "failed subscribe did not retain") + del exploding + gc.collect() + check(exploding_ref() is None, "unretained subscriber released after failed subscribe") - wv.send_message("alpha") - wv.send_message("beta") - assert received == ["alpha", "beta"], received + # route_once does not retain the subscriber: its free runs before the + # call returns, so nothing but our local keeps it alive. + probe = Recorder("probe", skip_topic="quiet") + probe_ref = weakref.ref(probe) + check(wv.route_once(probe, "quiet") == wv.Delivery.Skip, "route_once Skip") + check(wv.route_once(probe, "other") == wv.Delivery.Accept, "route_once Accept") + check(probe.routed == ["quiet", "other"], f"route_once routed {probe.routed}") + check(probe.attached == [], "route_once never attaches") + del probe + gc.collect() + check(probe_ref() is None, "route_once released its subscriber") + try: + wv.route_once(Recorder("x", fail_topic="bad"), "bad") + check(False, "expected WeaveFFIError from route_once") + except wv.WeaveFFIError as exc: + check(exc.code == -4, f"route_once foreign code {exc.code}") - wv.send_message("gamma") - assert received == ["alpha", "beta", "gamma"], received + # Clearing the subscribers runs each free: once the consumer drops its + # own references, nothing keeps the implementations alive. + refs = [weakref.ref(s) for s in (quiet, loud, tail, grumpy)] + kept_bus = quiet.attached[0] + del quiet, loud, tail, grumpy, sub + gc.collect() + check(all(r() is not None for r in refs), "retained subscribers stay alive") + bus.clear_subscribers() + check(bus.subscriber_count() == 0, "clear_subscribers empties the bus") + gc.collect() + check(all(r() is None for r in refs), "cleared subscribers were freed") - msgs = list(wv.get_messages()) - assert msgs == ["alpha", "beta", "gamma"], msgs + # The bus reference adopted through on_attached outlives the caller's + # wrapper: closing ours leaves the object alive through the other. + bus.close() + bus.close() + try: + bus.subscriber_count() + check(False, "expected use-after-close error") + except wv.WeaveFFIError as exc: + check("after close" in exc.message, f"use-after-close message {exc.message!r}") + check(list(kept_bus.messages()) == ["hello", "psst", "last", "async", "x", "gag", "recovered"], + "bus alive through the callback-adopted reference") + kept_bus.close() + kept_bus.close() + # Leaving the with block closes an already-closed bus: a no-op. - # Unregister stops delivery; messages still accumulate producer-side. - wv.unregister_message_listener(sub) - wv.send_message("delta") - assert received == ["alpha", "beta", "gamma"], received - assert list(wv.get_messages()) == ["alpha", "beta", "gamma", "delta"] + # A fresh bus is independent state. + other = wv.EventBus() + check(other.subscriber_count() == 0 and other.last_message() is None, + "second bus is independent") + other.close() print("python/events: OK") diff --git a/conformance/python/inventory.py b/conformance/python/inventory.py new file mode 100644 index 00000000..a91bf708 --- /dev/null +++ b/conformance/python/inventory.py @@ -0,0 +1,169 @@ +"""Conformance consumer: inventory sample, Python target. + +Drives the only multi-module sample through the generated ctypes wrapper. The +`products` module contributes the `Catalog` interface (constructor, `with` +statement, idempotent `close()`), the `Product` record with an optional +string, an enum, and a list field, and the `ProductsError` domain; the +`orders` module contributes free functions over `Order`/`OrderItem` records +(a list-of-record parameter, a buffered record return) and the `OrdersError` +domain. Both domains reuse the codes 1 and 2, so each throwing callable must +map its code to its own module's classes. `add_product_to_order` takes a +`products.Product` across the module boundary. The generated package is +placed on sys.path via WV_PY; the cdylib is selected with WEAVEFFI_LIBRARY. +""" +import os +import sys + +sys.path.insert(0, os.environ["WV_PY"]) + +import inventory as wv # noqa: E402 + + +def check(cond: bool, what: str) -> None: + if not cond: + print(f"python/inventory: FAIL: {what}", file=sys.stderr) + sys.exit(1) + + +def expect(fn, cls, code: int, message: str, what: str) -> None: + try: + fn() + except cls as exc: + check(exc.code == code and exc.CODE == code, f"{what}: code {exc.code}") + check(exc.message == message, f"{what}: message {exc.message!r}") + check(isinstance(exc, wv.WeaveFFIError), f"{what}: hierarchy") + return + check(False, f"{what}: expected {cls.__name__}") + + +def products() -> wv.Product: + check(wv.Category.Electronics == 0 and wv.Category.Books == 3, "Category discriminants") + check(wv.InvalidPrice is wv.ProductsError.InvalidPrice + and wv.ProductNotFound is wv.ProductsError.ProductNotFound, "products aliases") + check(issubclass(wv.ProductsError, wv.WeaveFFIError) + and not issubclass(wv.ProductsError, wv.OrdersError), "domains are distinct classes") + + with wv.Catalog() as catalog: + widget = catalog.add_product("Widget", 9.99, wv.Category.Electronics) + check(isinstance(widget, wv.Product), "add_product returns a Product") + check(widget.id > 0 and widget.name == "Widget" and widget.price == 9.99, f"widget {widget}") + check(widget.category is wv.Category.Electronics, "category enum member") + check(widget.description is None and widget.tags == [], "absent optional / empty list") + check(widget == wv.Product(id=widget.id, name="Widget", description=None, price=9.99, + category=wv.Category.Electronics, tags=[]), + "Product value equality") + + shirt = catalog.add_product("Shirt", 29.5, wv.Category.Clothing) + phone = catalog.add_product("Phöne 📱", 499.0, wv.Category.Electronics) + check(shirt.id == widget.id + 1 and phone.id == shirt.id + 1, "ids are monotonic") + check(phone.name == "Phöne 📱", f"unicode name {phone.name!r}") + + # Typed errors in the products domain (codes 1 and 2). + expect(lambda: catalog.add_product("Free", 0.0, wv.Category.Food), + wv.ProductsError.InvalidPrice, 1, "price must be positive", "add_product(0.0)") + expect(lambda: catalog.add_product("Debt", -5.0, wv.Category.Food), + wv.ProductsError.InvalidPrice, 1, "price must be positive", "add_product(-5.0)") + expect(lambda: catalog.get_product(999), wv.ProductsError.ProductNotFound, 2, + "product not found", "get_product(999)") + + fetched = catalog.get_product(shirt.id) + check(fetched == shirt, f"get_product round-trips {fetched}") + + # List-of-record return. + electronics = catalog.search(wv.Category.Electronics) + check([p.name for p in electronics] == ["Widget", "Phöne 📱"], f"search {electronics}") + check(catalog.search(wv.Category.Books) == [], "search on an empty shelf") + + check(catalog.update_price(widget.id, 12.25) is True, "update_price existing") + check(catalog.get_product(widget.id).price == 12.25, "price updated") + check(catalog.update_price(999, 1.0) is False, "update_price missing is False") + expect(lambda: catalog.update_price(widget.id, -1.0), wv.InvalidPrice, 1, + "price must be positive", "update_price(-1.0)") + + check(catalog.remove(shirt.id) is True, "remove existing") + check(catalog.remove(shirt.id) is False, "remove missing is False") + expect(lambda: catalog.get_product(shirt.id), wv.ProductNotFound, 2, + "product not found", "get_product after remove") + + # Catalogs are independent objects. + other = wv.Catalog() + check(other.search(wv.Category.Electronics) == [], "second catalog is empty") + other.close() + other.close() + try: + other.remove(1) + check(False, "expected use-after-close error") + except wv.WeaveFFIError as exc: + check("after close" in exc.message, f"use-after-close message {exc.message!r}") + closed = catalog + gadget = catalog.get_product(phone.id) + closed.close() + try: + closed.search(wv.Category.Food) + check(False, "expected use-after-close error after with") + except wv.WeaveFFIError: + pass + return gadget + + +def orders(product: wv.Product) -> None: + check(wv.OrderNotFound is wv.OrdersError.OrderNotFound + and wv.EmptyOrder is wv.OrdersError.EmptyOrder, "orders aliases") + + # List-of-record parameter in one value buffer; buffered record return. + items = [wv.OrderItem(product_id=1, quantity=2, unit_price=10.0), + wv.OrderItem(product_id=2, quantity=1, unit_price=25.0)] + order_id = wv.create_order(items) + check(order_id > 0, f"create_order id {order_id}") + order = wv.get_order(order_id) + check(isinstance(order, wv.Order), "get_order returns an Order") + check(order.id == order_id and order.status == "pending", f"order {order}") + check(order.items == items, f"items round-trip {order.items}") + check(order.total == 45.0, f"total {order.total}") + check(order == wv.Order(id=order_id, items=items, total=45.0, status="pending"), + "Order value equality") + + # Typed errors in the orders domain: the same numeric codes as the + # products domain resolve to the orders classes. + expect(lambda: wv.create_order([]), wv.OrdersError.EmptyOrder, 2, + "order must contain at least one item", "create_order([])") + expect(lambda: wv.get_order(999), wv.OrdersError.OrderNotFound, 1, + "order not found", "get_order(999)") + try: + wv.get_order(999) + except wv.ProductsError: + check(False, "orders code 1 must not map to ProductsError.InvalidPrice") + except wv.OrdersError: + pass + + # Cross-module record parameter: a products.Product appended to an order. + check(wv.add_product_to_order(order_id, product) is True, "add_product_to_order") + order = wv.get_order(order_id) + check(len(order.items) == 3, f"items after add {order.items}") + check(order.items[2] == wv.OrderItem(product_id=product.id, quantity=1, unit_price=499.0), + f"appended item {order.items[2]}") + check(order.total == 45.0 + 499.0, f"total after add {order.total}") + check(wv.add_product_to_order(999, product) is False, "add_product_to_order missing order") + # A locally built Product with every field populated crosses too. + local = wv.Product(id=77, name="Local", description="built in Python", price=0.5, + category=wv.Category.Books, tags=["x", "yy"]) + check(wv.add_product_to_order(order_id, local) is True, "add local product") + check(wv.get_order(order_id).items[3].product_id == 77, "local product id recorded") + + check(wv.cancel_order(order_id) is True, "cancel_order") + check(wv.cancel_order(order_id) is False, "cancel_order twice is False") + check(wv.get_order(order_id).status == "cancelled", "status after cancel") + check(wv.cancel_order(999) is False, "cancel_order missing is False") + + second = wv.create_order([wv.OrderItem(product_id=3, quantity=0, unit_price=1e9)]) + check(second == order_id + 1, "order ids are monotonic") + check(wv.get_order(second).total == 0.0, "zero quantity yields zero total") + + +def main() -> None: + gadget = products() + orders(gadget) + print("python/inventory: OK") + + +main() diff --git a/conformance/python/kvstore.py b/conformance/python/kvstore.py index 032290b7..d198bf1e 100644 --- a/conformance/python/kvstore.py +++ b/conformance/python/kvstore.py @@ -1,134 +1,337 @@ """Conformance consumer: kvstore sample, Python target. -Full-surface drive of the generated ctypes wrapper: the `Store` interface -(fallible `open` factory, instance methods passing the object pointer, the +Full-surface drive of the generated ctypes wrapper against ABI 2: the +reference-counted `Store` interface (fallible `open` factory, `with` +statement, idempotent `close()`, use after close rejected, the `default_capacity` static, the deprecated `legacy_put`), typed `KvError` -subclasses (`KvError.KeyNotFound` / `KvError.IoError`) raised by throwing -callables, the optional-record return (`Entry | None`) decoded from a value -buffer into a plain dataclass with bytes / optional-scalar / list / map -fields, the buffered `i64?` and `string?` parameters (`ttl_seconds`, -`prefix`), the iterator-backed `list_keys` method, the cross-module -`get_stats` (parameter annotated as the bare local `Store`), the CFUNCTYPE -eviction listener (register -> fire on delete -> unregister), and the -asyncio-bridged `compact` coroutine. The generated package is placed on -sys.path via WV_PY; the cdylib is selected with WEAVEFFI_LIBRARY. +subclasses raised by throwing callables, the optional-record return +(`Entry | None`) decoded from a value buffer, buffered `i64?` and `string?` +parameters, the iterator-backed `list_keys`, the cross-module `get_stats`, +the `EvictionListener` callback interface implemented by subclassing the +generated ABC (fired on delete and on expired reads, detached by returning +False, replaced and cleared, a raising listener surfacing as +`FOREIGN_ERROR_CODE`), objects in every buffered position (`share()` as a +second wrapper over the same object, `fork()`, `larger(None)` and +`larger(other)`, `describe().store`, `open_many`, `total_count` with a +locally built `StoreInfo`), and the asyncio-bridged `compact` coroutine. The +generated package is placed on sys.path via WV_PY; the cdylib is selected +with WEAVEFFI_LIBRARY. """ import asyncio +import gc import os import sys import warnings +import weakref sys.path.insert(0, os.environ["WV_PY"]) import kvstore as wv # noqa: E402 +PATH = "/tmp/conformance-kvstore-py" -def main() -> None: - # Fallible constructor: an empty path reports the IoError domain code - # through the typed exception hierarchy. + +def check(cond: bool, what: str) -> None: + if not cond: + print(f"python/kvstore: FAIL: {what}", file=sys.stderr) + sys.exit(1) + + +class Listener(wv.EvictionListener): + """Records `(key, reason)` pairs; detaches itself after `keep` events and + raises when it sees `poison` as the evicted key.""" + + def __init__(self, keep: int = 1 << 30, poison: str = "") -> None: + self.keep = keep + self.poison = poison + self.seen: list[tuple[str, wv.EvictionReason]] = [] + self.entries: list[wv.Entry] = [] + + def on_evict(self, entry: wv.Entry, reason: wv.EvictionReason) -> bool: + if entry.key == self.poison: + raise RuntimeError(f"listener refused {entry.key}") + self.seen.append((entry.key, reason)) + self.entries.append(entry) + return len(self.seen) < self.keep + + +def expect_kv_error(fn, cls, code: int, what: str) -> wv.KvError: + try: + fn() + except cls as exc: + check(exc.code == code and exc.CODE == code, f"{what}: code {exc.code}") + check(isinstance(exc, wv.KvError) and isinstance(exc, wv.WeaveFFIError), + f"{what}: hierarchy") + return exc + check(False, f"{what}: expected {cls.__name__}") + raise AssertionError # unreachable + + +def basics() -> None: + # Fallible constructor: an empty path reports the IoError domain code. + expect_kv_error(lambda: wv.Store.open(""), wv.KvError.IoError, 1004, "open('')") + check(wv.IoError is wv.KvError.IoError, "bare IoError is the scoped alias") + try: + wv.Store() + check(False, "Store() must not construct") + except TypeError: + pass + + check(wv.Store.default_capacity() == 1_000_000, "default_capacity static") + + with wv.Store.open(PATH) as store: + payload = b"\x01\x02\x03" + check(store.put("alpha", payload, wv.EntryKind.Persistent, None) is True, "put alpha") + check(store.put("beta", payload, wv.EntryKind.Volatile, 3600) is True, "put beta") + check(store.count() == 2, "count after two puts") + + # Iterator-backed list-of-string return with an absent and a present + # buffered `string?` prefix. + check(list(store.list_keys(None)) == ["alpha", "beta"], "list_keys(None)") + check(list(store.list_keys("al")) == ["alpha"], "list_keys('al')") + it = store.list_keys(None) + check(next(it) == "alpha", "manual next on key iterator") + it.close() + it.close() + + # Optional-record return decoded into an Entry dataclass. + alpha = store.get("alpha") + check(alpha is not None and alpha.id > 0 and alpha.key == "alpha", f"alpha {alpha}") + check(alpha.value == payload and alpha.expires_at is None, "alpha payload / no expiry") + check(alpha.tags == [] and alpha.metadata == {}, "alpha empty tags / metadata") + beta = store.get("beta") + check(beta is not None and beta.expires_at is not None + and beta.expires_at > beta.created_at, "beta expiry set") + + # Typed errors carry stable codes and the class hierarchy. + exc = expect_kv_error(lambda: store.get("missing"), wv.KvError.KeyNotFound, 1001, + "get missing") + check(exc.message == "key not found", f"KeyNotFound message {exc.message!r}") + check(wv.KeyNotFound is wv.KvError.KeyNotFound, "bare KeyNotFound is the scoped alias") + check(store.put("gone", b"x", wv.EntryKind.Volatile, -1) is True, "put expired") + expect_kv_error(lambda: store.get("gone"), wv.KvError.Expired, 1002, "get expired") + check(store.count() == 2, "expired entry evicted on read") + + # Deprecated method still works but warns. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + check(store.legacy_put("legacy", b"zz") is True, "legacy_put") + check(any(issubclass(w.category, DeprecationWarning) for w in caught), + "legacy_put warns DeprecationWarning") + check(store.delete("legacy") is True, "delete legacy") + check(store.delete("legacy") is False, "delete missing is False") + + # Records are plain value types with native list/map/optional fields. + built = wv.Entry(id=7, key="built", value=payload, created_at=1000, expires_at=None, + tags=["hot", "fast"], metadata={"source": "test", "env": "prod"}) + check(built == wv.Entry(7, "built", payload, 1000, None, ["hot", "fast"], + {"source": "test", "env": "prod"}), "Entry value equality") + + # Cross-module call: kv.stats.get_stats takes the parent's Store. + stats = wv.get_stats(store) + check(stats == wv.Stats(total_entries=2, total_bytes=6, expired_entries=0), + f"stats {stats}") + + # Async: an immediately expired entry gives compact 3 bytes to reclaim. + check(store.put("doomed", payload, wv.EntryKind.Volatile, 0) is True, "put doomed") + check(store.count() == 2, "doomed is already expired") + reclaimed = asyncio.run(store.compact()) + check(reclaimed == 3, f"compact reclaimed {reclaimed}") + check(asyncio.run(store.compact()) == 0, "second compact reclaims nothing") + store.clear() + check(store.count() == 0, "clear empties the store") + closed = store + # __exit__ closed the wrapper; every further use is rejected, and both + # a second close and the eventual __del__ are no-ops. + closed.close() + try: + closed.count() + check(False, "expected use-after-close error") + except wv.WeaveFFIError as exc: + check("after close" in exc.message, f"use-after-close message {exc.message!r}") try: - wv.Store.open("") - raise AssertionError("expected IoError for empty path") - except wv.KvError.IoError as exc: - assert exc.code == 1004, exc.code - assert isinstance(exc, wv.KvError) - assert isinstance(exc, wv.WeaveFFIError) - - store = wv.Store.open("/tmp/conformance-kvstore-py") - payload = b"\x01\x02\x03" - - # Static method on the interface. - assert wv.Store.default_capacity() == 1_000_000 - - assert store.put("alpha", payload, wv.EntryKind.Persistent, None) is True - assert store.put("beta", payload, wv.EntryKind.Volatile, 3600) is True - assert store.count() == 2 - - # Iterator-backed list-of-string return; the prefix is a buffered - # `string?`, exercised both absent and present. - keys = sorted(store.list_keys(None)) - assert keys == ["alpha", "beta"], keys - assert list(store.list_keys("al")) == ["alpha"] - - # Optional-record return: one value buffer decodes to an Entry dataclass - # covering every complex field type. - alpha = store.get("alpha") - assert alpha is not None - assert alpha.id > 0 - assert alpha.key == "alpha" - assert alpha.value == payload - assert alpha.expires_at is None # optional scalar, absent - assert alpha.tags == [] - assert alpha.metadata == {} - - beta = store.get("beta") - assert beta is not None and beta.expires_at is not None and beta.expires_at > 0 - - # Typed error: a missing key raises the KeyNotFound class of the KvError - # domain, carrying its stable code. The bare name is the same class as - # the scoped alias. + wv.get_stats(closed) + check(False, "expected use-after-close error as a parameter") + except wv.WeaveFFIError: + pass + + +def eviction_listener() -> None: + store = wv.Store.open(PATH) + store.put("a", b"1", wv.EntryKind.Persistent, None) + store.put("b", b"22", wv.EntryKind.Persistent, None) + store.put("old", b"333", wv.EntryKind.Volatile, -5) + + # Delete fires the trampoline synchronously with the removed Entry. + first = Listener(keep=2) + first_ref = weakref.ref(first) + store.set_eviction_listener(first) + check(store.delete("a") is True, "delete a") + check(first.seen == [("a", wv.EvictionReason.Deleted)], f"first saw {first.seen}") + check(first.entries[0].value == b"1" and first.entries[0].id > 0, "evicted Entry decoded") + + # An expired read evicts with reason Expired; that was the second event, + # so the listener asked to detach and the store freed it. + expect_kv_error(lambda: store.get("old"), wv.KvError.Expired, 1002, "expired read") + check(first.seen == [("a", wv.EvictionReason.Deleted), ("old", wv.EvictionReason.Expired)], + f"first saw {first.seen}") + del first + gc.collect() + check(first_ref() is None, "detached listener was freed by the producer") + check(store.delete("b") is True, "delete b with no listener") + + # Replacing a listener frees the previous one; clearing frees the current. + second = Listener() + second_ref = weakref.ref(second) + store.set_eviction_listener(second) + third = Listener() + third_ref = weakref.ref(third) + store.set_eviction_listener(third) + del second + gc.collect() + check(second_ref() is None, "replaced listener was freed") + store.put("c", b"3", wv.EntryKind.Persistent, None) + check(store.delete("c") is True, "delete c") + check(third.seen == [("c", wv.EvictionReason.Deleted)], f"third saw {third.seen}") + store.clear_eviction_listener() + del third + gc.collect() + check(third_ref() is None, "cleared listener was freed") + + # A listener that raises aborts the delete with the foreign error code; + # the entry is gone regardless and the store stays usable. + angry = Listener(poison="bad") + store.set_eviction_listener(angry) + store.put("bad", b"!", wv.EntryKind.Persistent, None) + store.put("fine", b"?", wv.EntryKind.Persistent, None) + try: + store.delete("bad") + check(False, "expected WeaveFFIError from a raising listener") + except wv.KvError: + check(False, "a foreign error is not a KvError") + except wv.WeaveFFIError as exc: + check(exc.code == wv.WeaveFFIError.FOREIGN_ERROR_CODE == -4, + f"foreign error code {exc.code}") + check("listener refused bad" in exc.message, f"foreign message {exc.message!r}") + check(store.count() == 1, "entry removed despite the listener raising") + check(store.delete("fine") is True, "delete after foreign error") + check(angry.seen == [("fine", wv.EvictionReason.Deleted)], f"angry saw {angry.seen}") + + # Destroying the store frees the listener it still holds. + angry_ref = weakref.ref(angry) + del angry + gc.collect() + check(angry_ref() is not None, "attached listener kept alive by the store") + store.close() + gc.collect() + check(angry_ref() is None, "closing the store freed its listener") + + +def object_graph() -> None: + store = wv.Store.open(PATH) + store.put("k", b"v", wv.EntryKind.Persistent, None) + + # share(): a second wrapper over the same object. + shared = store.share() + check(isinstance(shared, wv.Store) and shared is not store, "share returns a new wrapper") + check(shared.put("k2", b"vv", wv.EntryKind.Persistent, None) is True, "put via shared") + check(store.count() == 2, "mutation through share visible through the original") + store.close() + check(shared.count() == 2, "object alive through the shared wrapper") + + # fork(): an independent copy. + forked = shared.fork() + check(forked.count() == 2, "fork copies live entries") + forked.put("only-forked", b"x", wv.EntryKind.Persistent, None) + check(forked.count() == 3 and shared.count() == 2, "fork is independent") + + # larger(): Store? in both directions. + empty = wv.Store.open(PATH) + check(empty.larger(None) is None, "larger(None) on an empty store is None") + own = shared.larger(None) + check(own is not None and own.count() == 2, "larger(None) on a populated store is itself") + own.put("k3", b"3", wv.EntryKind.Persistent, None) + check(shared.count() == 3, "larger(None) returned the same object") + own.close() + bigger = empty.larger(forked) + check(bigger is not None and bigger.count() == 3, "larger(other) picks the bigger") + bigger.put("k4", b"4", wv.EntryKind.Persistent, None) + check(forked.count() == 4, "larger(other) returned the other object") + bigger.close() + check(shared.larger(empty).count() == 3, "larger prefers self over a smaller other") + + # describe(): a record carrying the object (and an optional second one). + info = shared.describe("primary", None) + check(isinstance(info, wv.StoreInfo), "describe returns StoreInfo") + check(info.label == "primary" and info.count == 3 and info.mirror is None, f"info {info}") + check(isinstance(info.store, wv.Store) and info.store is not shared, + "record object field is a wrapper") + info.store.put("k5", b"5", wv.EntryKind.Persistent, None) + check(shared.count() == 4, "record object field refers to the same store") + mirrored = shared.describe("mirrored", forked) + check(mirrored.mirror is not None and mirrored.mirror.count() == 4, "mirror present") + mirrored.mirror.put("k6", b"6", wv.EntryKind.Persistent, None) + check(forked.count() == 5, "mirror refers to the other store") + + # open_many(): a list of objects as a return, and its throwing path. + many = wv.Store.open_many(["/a", "/b", "/c"]) + check(len(many) == 3 and all(isinstance(s, wv.Store) for s in many), "open_many list") + check([s.count() for s in many] == [0, 0, 0], "open_many stores are empty") + many[0].put("m", b"1", wv.EntryKind.Persistent, None) + many[2].put("n", b"2", wv.EntryKind.Persistent, None) + check([s.count() for s in many] == [1, 0, 1], "open_many stores are distinct") + expect_kv_error(lambda: wv.Store.open_many(["/ok", ""]), wv.KvError.IoError, 1004, + "open_many with an empty path") + check(wv.Store.open_many([]) == [], "open_many([])") + + # total_count(): list of objects and an object inside an optional record + # as parameters. Encoding clones each object; the wrappers stay usable. + check(wv.Store.total_count(many, None) == 2, "total_count without extra") + check(wv.Store.total_count([], None) == 0, "total_count of nothing") + extra = wv.StoreInfo(label="extra", store=shared, mirror=forked, count=-1) + check(wv.Store.total_count(many, extra) == 2 + 4, "total_count with a local StoreInfo") + check(wv.Store.total_count(many + [shared, forked], info) == 2 + 4 + 5 + 4, + "total_count with duplicates and a producer-built StoreInfo") + check(shared.count() == 4 and forked.count() == 5 and many[0].count() == 1, + "wrappers usable after being encoded") + # A closed store in a buffered position is rejected before any reference + # is minted, so the live store beside it leaks nothing. + try: + wv.Store.total_count([empty, closed_store()], None) + check(False, "expected error encoding a closed store") + except wv.WeaveFFIError as exc: + check("Store used after close" in exc.message, f"closed store in list {exc.message!r}") try: - store.get("missing") - raise AssertionError("expected KeyNotFound for missing key") - except wv.KvError.KeyNotFound as exc: - assert exc.code == 1001, exc.code - assert exc.CODE == 1001 - assert isinstance(exc, wv.KvError) - assert isinstance(exc, wv.WeaveFFIError) - assert wv.KeyNotFound is wv.KvError.KeyNotFound - - # Deprecated method still works but warns. - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - assert store.legacy_put("legacy", b"zz") is True - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - assert store.delete("legacy") is True - - # Records are plain value types now: an Entry constructs locally with - # native list/map/optional fields and compares by value. - built = wv.Entry( - id=7, - key="built", - value=payload, - created_at=1000, - expires_at=None, - tags=["hot", "fast"], - metadata={"source": "test", "env": "prod"}, - ) - assert set(built.tags) == {"hot", "fast"} - assert built.metadata == {"source": "test", "env": "prod"} - assert built.expires_at is None - assert built == wv.Entry(7, "built", payload, 1000, None, ["hot", "fast"], - {"source": "test", "env": "prod"}) - - # Cross-module call: get_stats lives in kv.stats and takes the parent - # module's Store interface as a parameter. - stats = wv.get_stats(store) - assert stats.total_entries == 2 - assert stats.expired_entries == 0 - - # Eviction listener: delete fires the CFUNCTYPE trampoline synchronously. - evicted: list[str] = [] - sub = wv.register_eviction_listener(evicted.append) - assert sub > 0 - assert store.delete("beta") is True - assert evicted == ["beta"], evicted - - # Unregister stops delivery. - wv.unregister_eviction_listener(sub) - assert store.delete("alpha") is True - assert evicted == ["beta"], evicted - - # Async: an immediately-expired entry gives compact 3 bytes to reclaim; - # the coroutine bridges the producer's worker-thread callback to asyncio. - assert store.put("doomed", payload, wv.EntryKind.Volatile, 0) is True - reclaimed = asyncio.run(store.compact()) - assert reclaimed == 3, reclaimed - assert store.count() == 0 - - # Each Store owns its object pointer and releases it once in `__del__` - # via the generated destroy symbol; no explicit close call exists. + wv.Store.total_count([], wv.StoreInfo("x", empty, closed_store(), 0)) + check(False, "expected error encoding a closed mirror") + except wv.WeaveFFIError as exc: + check("Store used after close" in exc.message, f"closed mirror {exc.message!r}") + check(empty.count() == 0, "live store usable after a rejected encoding") + # Release everything, twice where it is cheap to prove idempotence. + for s in [shared, forked, empty, info.store, mirrored.store, mirrored.mirror] + many: + s.close() + s.close() + for s in [shared, forked, empty] + many: + try: + s.count() + check(False, "closed store still usable") + except wv.WeaveFFIError: + pass + + +def closed_store() -> wv.Store: + s = wv.Store.open(PATH) + s.close() + return s + + +def main() -> None: + basics() + eviction_listener() + object_graph() + # Whatever wrappers were left to garbage collection release exactly once. + gc.collect() print("python/kvstore: OK") diff --git a/conformance/ruby/codec.rb b/conformance/ruby/codec.rb new file mode 100644 index 00000000..d797e50a --- /dev/null +++ b/conformance/ruby/codec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true +# Conformance consumer: codec sample, Ruby target. +# +# Round-trip oracle for the generated value-buffer codec. For each fixture the +# producer encodes and Ruby decodes (`sample_*`, checked field by field), Ruby +# encodes and the producer decodes (`verify_*` must return true), and both +# directions compose (`roundtrip_*` must equal the input). Ruby-built values +# cover the edges: empty strings, lists, and maps; non-ASCII text; i64/u64 +# extremes; Float::NAN, +/-Float::INFINITY, and -0.0; every Shape variant; and +# objects inside buffers (Holder: a Token field, an optional Token, and a list +# of Tokens; `primary_of` returns a wrapper to the SAME object as +# `holder.primary`; every wrapper closes exactly once). Exit is non-zero on +# any mismatch. The cdylib is selected via WEAVEFFI_LIBRARY. + +$LOAD_PATH.unshift(File.join(ENV.fetch("WV_RB"), "lib")) +require "codec" + +def expect(cond, msg) + raise "assertion failed: #{msg}" unless cond +end + +def negative_zero?(x) + x.zero? && (1.0 / x) == -Float::INFINITY +end + +I64_MIN = -(2**63) +I64_MAX = 2**63 - 1 +U64_MAX = 2**64 - 1 + +expect(Codec::ABI_VERSION == 2, "bindings target ABI revision 2") +expect(Codec::Color::RED.zero? && Codec::Color::GREEN == 1 && Codec::Color::BLUE == 7, "Color constants") + +# --- Scalars ------------------------------------------------------------- +scalars = Codec.sample_scalars +expect(scalars.is_a?(Codec::Scalars), "sample_scalars returns a Scalars") +expect(scalars.i8_value == -8, "i8 (got #{scalars.i8_value})") +expect(scalars.u8_value == 200, "u8 (got #{scalars.u8_value})") +expect(scalars.i16_value == -16_000, "i16 (got #{scalars.i16_value})") +expect(scalars.u16_value == 60_000, "u16 (got #{scalars.u16_value})") +expect(scalars.i32_value == -2_000_000_000, "i32 (got #{scalars.i32_value})") +expect(scalars.u32_value == 4_000_000_000, "u32 (got #{scalars.u32_value})") +expect(scalars.i64_value == -9_007_199_254_740_993, "i64 beyond 2^53 (got #{scalars.i64_value})") +expect(scalars.u64_value == U64_MAX, "u64::MAX (got #{scalars.u64_value})") +expect(scalars.f32_value == 1.5, "f32 (got #{scalars.f32_value})") +expect(scalars.f64_value == -2.25e100, "f64 (got #{scalars.f64_value})") +expect(scalars.flag == true, "flag") +expect(scalars.color == Codec::Color::BLUE, "color BLUE (got #{scalars.color})") + +expect(Codec.verify_scalars(scalars) == true, "producer accepts the re-encoded Scalars") +expect(Codec.roundtrip_scalars(scalars) == scalars, "roundtrip_scalars equals the sample") + +# The same fixture built from scratch in Ruby verifies; one changed field is a +# typed Mismatch (code 1). +rebuilt = Codec::Scalars.new( + i8_value: -8, u8_value: 200, i16_value: -16_000, u16_value: 60_000, + i32_value: -2_000_000_000, u32_value: 4_000_000_000, + i64_value: -9_007_199_254_740_993, u64_value: U64_MAX, + f32_value: 1.5, f64_value: -2.25e100, flag: true, color: Codec::Color::BLUE +) +expect(rebuilt == scalars, "Ruby-built Scalars equals the decoded sample") +expect(Codec.verify_scalars(rebuilt) == true, "Ruby-built Scalars verifies") +changed = Codec::Scalars.new( + i8_value: -8, u8_value: 200, i16_value: -16_000, u16_value: 60_000, + i32_value: -2_000_000_000, u32_value: 4_000_000_000, + i64_value: -9_007_199_254_740_993, u64_value: U64_MAX, + f32_value: 1.5, f64_value: -2.25e100, flag: false, color: Codec::Color::BLUE +) +begin + Codec.verify_scalars(changed) + raise "expected CodecError::Mismatch" +rescue Codec::CodecError::Mismatch => e + expect(e.code == 1, "Mismatch code == 1 (got #{e.code})") + expect(e.is_a?(Codec::CodecError) && e.is_a?(Codec::Error), "Mismatch subclasses the domain and brand errors") +end + +# Every integer extreme plus float specials round-trip bit for bit. +extremes = Codec::Scalars.new( + i8_value: -128, u8_value: 255, i16_value: -32_768, u16_value: 65_535, + i32_value: -(2**31), u32_value: 2**32 - 1, + i64_value: I64_MIN, u64_value: U64_MAX, + f32_value: -Float::INFINITY, f64_value: -0.0, flag: false, color: Codec::Color::RED +) +back = Codec.roundtrip_scalars(extremes) +expect(back == extremes, "extreme Scalars round-trip") +expect(back.i64_value == I64_MIN && back.u64_value == U64_MAX, "64-bit extremes inside a record") +expect(back.f32_value == -Float::INFINITY, "f32 -inf inside a record") +expect(negative_zero?(back.f64_value), "f64 -0.0 keeps its sign inside a record") +highs = Codec::Scalars.new( + i8_value: 127, u8_value: 0, i16_value: 32_767, u16_value: 0, + i32_value: 2**31 - 1, u32_value: 0, i64_value: I64_MAX, u64_value: 0, + f32_value: Float::NAN, f64_value: Float::INFINITY, flag: true, color: Codec::Color::GREEN +) +back = Codec.roundtrip_scalars(highs) +expect(back.i8_value == 127 && back.i16_value == 32_767 && back.i32_value == 2**31 - 1, "signed maxima") +expect(back.i64_value == I64_MAX, "i64::MAX inside a record") +expect(back.u8_value.zero? && back.u16_value.zero? && back.u32_value.zero? && back.u64_value.zero?, "unsigned zeros") +expect(back.f32_value.nan?, "f32 NaN survives inside a record") +expect(back.f64_value == Float::INFINITY, "f64 +inf inside a record") +expect(back.color == Codec::Color::GREEN, "enum inside a record") + +# --- Composite ----------------------------------------------------------- +composite = Codec.sample_composite +expect(composite.is_a?(Codec::Composite), "sample_composite returns a Composite") +expect(composite.name == "héllo wörld ✓", "unicode name (got #{composite.name.inspect})") +expect(composite.name.encoding == Encoding::UTF_8, "strings decode as UTF-8") +expect(composite.blob == "\x00\x01\x02\xFD\xFE\xFF".b, "blob bytes (got #{composite.blob.inspect})") +expect(composite.blob.encoding == Encoding::BINARY, "bytes decode as BINARY") +expect(composite.some_i64 == I64_MIN, "present optional i64::MIN (got #{composite.some_i64})") +expect(composite.none_i64.nil?, "absent optional is nil") +expect(composite.some_text == "", "present optional empty string (got #{composite.some_text.inspect})") +expect(composite.names == ["a", "", "ccc"], "list of strings (got #{composite.names})") +expect(composite.matrix == [[1, 2, 3], [], [-4]], "list of lists (got #{composite.matrix})") +expect(composite.empty == [], "empty list") +expect(composite.by_name == { "one" => 1, "two" => 2, "neg" => -3 }, "string-keyed map (got #{composite.by_name})") +expect(composite.by_id.keys.sort == [-1, 42], "int-keyed map keys (got #{composite.by_id.keys})") +expect(composite.by_id[-1] == scalars, "by_id[-1] is the canonical Scalars") +expect(composite.by_id[42].flag == false && composite.by_id[42].u64_value == U64_MAX, "by_id[42] differs only in flag") +expect(composite.scalars == scalars, "nested record equals sample_scalars") +expect(composite.shape == Codec::Shape::Labeled.new(label: "tag", count: 3), "rich enum field") +expect(composite.shape.tag == 3 && composite.shape.tag == Codec::Shape::Labeled::TAG, "variant tag") +expect(composite.shapes.length == 5, "one shape per variant") +expect(composite.shapes[0] == Codec::Shape::Empty.new, "shapes[0] Empty") +expect(composite.shapes[1] == Codec::Shape::Circle.new(radius: 2.5), "shapes[1] Circle") +expect(composite.shapes[2] == Codec::Shape::Rect.new(width: 1.0, height: 0.5), "shapes[2] Rect") +expect(composite.shapes[3] == Codec::Shape::Labeled.new(label: "", count: -1), "shapes[3] Labeled") +expect(composite.shapes[4] == Codec::Shape::Nested.new(inner: scalars, note: "n"), "shapes[4] Nested") +expect(composite.shapes.map(&:tag) == [0, 1, 2, 3, 4], "variant tags in order") +expect(composite.maybe_shape == Codec::Shape::Nested.new(inner: scalars, note: nil), "optional rich enum") +expect(composite.maybe_list == "\x09\x08".b, "optional bytes list (got #{composite.maybe_list.inspect})") +expect(composite.sparse == [true, nil, false], "list of optionals (got #{composite.sparse})") +expect(composite.colors == [Codec::Color::RED, Codec::Color::GREEN, Codec::Color::BLUE], "list of enums") + +expect(Codec.verify_composite(composite) == true, "producer accepts the re-encoded Composite") +expect(Codec.roundtrip_composite(composite) == composite, "roundtrip_composite equals the sample") +described = Codec.describe_composite(composite) +expect(described.include?("héllo wörld ✓") && described.include?("Labeled"), "describe_composite renders (got #{described[0, 60].inspect})") + +# Rebuilt in Ruby with hashes in a different insertion order: maps are +# unordered on the wire, so the producer still sees the canonical value. +rebuilt_composite = Codec::Composite.new( + name: "héllo wörld ✓", + blob: [0, 1, 2, 253, 254, 255].pack("C*"), + some_i64: I64_MIN, + none_i64: nil, + some_text: "", + names: ["a", "", "ccc"], + matrix: [[1, 2, 3], [], [-4]], + empty: [], + by_name: { "two" => 2, "neg" => -3, "one" => 1 }, + by_id: { 42 => changed, -1 => rebuilt }, + scalars: rebuilt, + shape: Codec::Shape::Labeled.new(label: "tag", count: 3), + shapes: [ + Codec::Shape::Empty.new, + Codec::Shape::Circle.new(radius: 2.5), + Codec::Shape::Rect.new(width: 1.0, height: 0.5), + Codec::Shape::Labeled.new(label: "", count: -1), + Codec::Shape::Nested.new(inner: rebuilt, note: "n") + ], + maybe_shape: Codec::Shape::Nested.new(inner: rebuilt, note: nil), + maybe_list: "\x09\x08".b, + sparse: [true, nil, false], + colors: [Codec::Color::RED, Codec::Color::GREEN, Codec::Color::BLUE] +) +expect(rebuilt_composite == composite, "Ruby-built Composite equals the decoded sample") +expect(Codec.verify_composite(rebuilt_composite) == true, "Ruby-built Composite verifies") +tweaked = Codec::Composite.new( + name: composite.name, blob: composite.blob, some_i64: composite.some_i64, none_i64: nil, + some_text: composite.some_text, names: composite.names, matrix: composite.matrix, empty: [], + by_name: composite.by_name, by_id: composite.by_id, scalars: composite.scalars, + shape: composite.shape, shapes: composite.shapes, maybe_shape: composite.maybe_shape, + maybe_list: composite.maybe_list, sparse: [true, true, false], colors: composite.colors +) +begin + Codec.verify_composite(tweaked) + raise "expected CodecError::Mismatch for a tweaked Composite" +rescue Codec::CodecError::Mismatch => e + expect(e.code == 1, "tweaked Composite -> Mismatch (got #{e.code})") +end + +# An edge-value Composite: everything empty or absent, plus float specials in +# a list and nested extremes. +edge = Codec::Composite.new( + name: "", + blob: "".b, + some_i64: I64_MAX, + none_i64: -1, + some_text: nil, + names: [], + matrix: [[], [-(2**31), 0, 2**31 - 1]], + empty: [1.5, Float::INFINITY, -Float::INFINITY, -0.0, 0.0], + by_name: {}, + by_id: { 0 => extremes }, + scalars: extremes, + shape: Codec::Shape::Empty.new, + shapes: [], + maybe_shape: nil, + maybe_list: "".b, + sparse: [nil, nil], + colors: [] +) +edge_back = Codec.roundtrip_composite(edge) +expect(edge_back == edge, "edge Composite round-trips") +expect(edge_back.name == "" && edge_back.blob == "".b && edge_back.names == [] && edge_back.by_name == {}, + "empties survive") +expect(edge_back.some_text.nil? && edge_back.maybe_shape.nil? && edge_back.some_i64 == I64_MAX && edge_back.none_i64 == -1, + "optionals in both states") +expect(edge_back.matrix == [[], [-(2**31), 0, 2**31 - 1]], "i32 extremes in a nested list (got #{edge_back.matrix})") +expect(edge_back.empty[1] == Float::INFINITY && edge_back.empty[2] == -Float::INFINITY, "infinities in a list") +expect(negative_zero?(edge_back.empty[3]) && !negative_zero?(edge_back.empty[4]), "-0.0 and 0.0 keep their signs") +expect(edge_back.maybe_list == "".b && !edge_back.maybe_list.nil?, "present empty optional bytes") +expect(edge_back.sparse == [nil, nil], "list of absent optionals") +expect(edge_back.by_id[0] == extremes, "record inside a map with an integer key") +expect(Codec.describe_composite(edge).start_with?("Composite {"), "describe_composite on the edge value") + +# --- Shape (rich enum) --------------------------------------------------- +[ + Codec::Shape::Empty.new, + Codec::Shape::Circle.new(radius: -0.0), + Codec::Shape::Rect.new(width: 0.25, height: -Float::INFINITY), + Codec::Shape::Labeled.new(label: "ünïcödé ✓", count: -(2**31)), + Codec::Shape::Nested.new(inner: extremes, note: ""), + Codec::Shape::Nested.new(inner: rebuilt, note: nil) +].each do |shape| + back = Codec.roundtrip_shape(shape) + expect(back == shape, "roundtrip_shape #{shape.class} (got #{Codec.describe_shape(back)})") + expect(back.class == shape.class && back.tag == shape.tag, "roundtrip_shape keeps the variant") +end +expect(negative_zero?(Codec.roundtrip_shape(Codec::Shape::Circle.new(radius: -0.0)).radius), "-0.0 in a variant") +expect(Codec.describe_shape(Codec::Shape::Empty.new) == "Empty", "describe_shape Empty") +expect(Codec.describe_shape(Codec::Shape::Circle.new(radius: 2.5)) == "Circle { radius: 2.5 }", "describe_shape Circle") +expect(Codec.describe_shape(Codec::Shape::Labeled.new(label: "x", count: 1)) == 'Labeled { label: "x", count: 1 }', + "describe_shape Labeled") +expect(Codec.roundtrip_shapes([]) == [], "empty list of shapes") +expect(Codec.roundtrip_shapes(composite.shapes) == composite.shapes, "list of every variant") +expect(Codec.roundtrip_shapes(composite.shapes).map(&:tag) == [0, 1, 2, 3, 4], "tags preserved in a list") + +# --- Standalone shapes: optionals, maps, strings, bytes, scalars --------- +expect(Codec.roundtrip_opt_i64(nil).nil?, "opt i64 absent") +expect(Codec.roundtrip_opt_i64(0).zero?, "opt i64 zero is present") +expect(Codec.roundtrip_opt_i64(I64_MIN) == I64_MIN, "opt i64::MIN") +expect(Codec.roundtrip_opt_i64(I64_MAX) == I64_MAX, "opt i64::MAX") +expect(Codec.roundtrip_map({}) == {}, "empty map") +unicode_map = { "" => 0, "ключ" => I64_MIN, "✓" => I64_MAX, "a" => -1 } +expect(Codec.roundtrip_map(unicode_map) == unicode_map, "unicode-keyed map with extremes") +expect(Codec.roundtrip_string("") == "", "empty string") +expect(Codec.roundtrip_string("héllo ✓ 日本") == "héllo ✓ 日本", "unicode string") +expect(Codec.roundtrip_string("héllo").encoding == Encoding::UTF_8, "returned string is UTF-8") +expect(Codec.roundtrip_bytes("".b) == "".b, "empty bytes") +all_bytes = (0..255).to_a.pack("C*") +expect(Codec.roundtrip_bytes(all_bytes) == all_bytes, "every byte value, including NUL") +expect(Codec.roundtrip_bytes(all_bytes).encoding == Encoding::BINARY, "returned bytes are BINARY") +expect(Codec.roundtrip_i64(I64_MIN) == I64_MIN, "direct i64::MIN") +expect(Codec.roundtrip_i64(I64_MAX) == I64_MAX, "direct i64::MAX") +expect(Codec.roundtrip_i64(-1) == -1, "direct -1") +expect(Codec.roundtrip_u64(U64_MAX) == U64_MAX, "direct u64::MAX") +expect(Codec.roundtrip_u64(2**63) == 2**63, "direct 2^63 stays unsigned") +expect(Codec.roundtrip_u64(0).zero?, "direct u64 zero") +expect(Codec.roundtrip_f64(Float::NAN).nan?, "direct NaN") +expect(Codec.roundtrip_f64(Float::INFINITY) == Float::INFINITY, "direct +inf") +expect(Codec.roundtrip_f64(-Float::INFINITY) == -Float::INFINITY, "direct -inf") +expect(negative_zero?(Codec.roundtrip_f64(-0.0)), "direct -0.0 keeps its sign") +expect(Codec.roundtrip_f64(Float::MAX) == Float::MAX, "direct f64::MAX") +expect(Codec.roundtrip_f64(Float::MIN) == Float::MIN, "direct smallest normal") +expect(Codec.roundtrip_f64(5e-324) == 5e-324, "direct subnormal") +expect(Codec.roundtrip_bool(true) == true && Codec.roundtrip_bool(false) == false, "direct bool") +expect(Codec.roundtrip_color(Codec::Color::BLUE) == Codec::Color::BLUE, "direct enum BLUE") +expect(Codec.roundtrip_color(Codec::Color::RED) == Codec::Color::RED, "direct enum RED") + +# --- Holder (objects inside buffers) --------------------------------------- +holder = Codec.make_holder(10, true) +expect(holder.is_a?(Codec::Holder), "make_holder returns a Holder") +expect(holder.primary.is_a?(Codec::Token) && !holder.primary.closed?, "primary is an open Token") +expect(holder.primary.value == 10, "primary value (got #{holder.primary.value})") +expect(holder.spare.is_a?(Codec::Token) && holder.spare.value == 11, "spare value") +expect(holder.many.length == 3 && holder.many.map(&:value) == [12, 13, 14], "many values (got #{holder.many.map(&:value)})") +addresses = ([holder.primary, holder.spare] + holder.many).map { |t| t.handle.address } +expect(addresses.uniq.length == 5, "five distinct token objects") + +# Encoding a Holder mints one reference per token, which the producer consumes. +expect(Codec.sum_holder(holder) == 10 + 11 + 12 + 13 + 14, "sum_holder (got #{Codec.sum_holder(holder)})") +expect(Codec.sum_holder(holder) == 60, "sum_holder is repeatable (tokens not consumed)") +expect(holder.primary.value == 10 && holder.spare.value == 11, "tokens usable after being encoded") + +# primary_of returns a wrapper to the SAME object as holder.primary. +primary = Codec.primary_of(holder) +expect(primary.is_a?(Codec::Token), "primary_of returns a Token") +expect(!primary.equal?(holder.primary), "primary_of returns a distinct wrapper") +expect(primary.handle.address == holder.primary.handle.address, "primary_of wraps the same object") +expect(primary.value == 10, "primary_of value") +primary.close +primary.close +expect(primary.closed?, "primary_of wrapper closed idempotently") +expect(holder.primary.value == 10, "original primary alive after closing the returned wrapper") + +# same_primary compares object identity through two encoded buffers. +expect(Codec.same_primary(holder, holder) == true, "same_primary(h, h)") +other = Codec.make_holder(10, true) +expect(other.primary.value == 10, "other holder has equal values") +expect(Codec.same_primary(holder, other) == false, "same_primary is identity, not value") +aliased = Codec::Holder.new(primary: holder.primary, spare: nil, many: []) +expect(Codec.same_primary(holder, aliased) == true, "a Ruby-built Holder sharing the wrapper is the same primary") +twin = holder.primary.dup +expect(twin.handle.address == holder.primary.handle.address, "dup of a Token wraps the same object") +expect(Codec.same_primary(holder, Codec::Holder.new(primary: twin, spare: nil, many: [])) == true, + "a dup'd wrapper is still the same object") +twin.close + +# A Holder built entirely in Ruby from constructor-made tokens. +t1 = Codec::Token.new(1) +t2 = Codec::Token.new(2) +t3 = Codec::Token.new(-3) +expect(t1.value == 1 && t3.value == -3, "Token.new values") +built = Codec::Holder.new(primary: t1, spare: t2, many: [t3, t1, t1]) +expect(Codec.sum_holder(built) == 1 + 2 + -3 + 1 + 1, "sum_holder over a Ruby-built Holder (repeated object)") +expect(Codec.primary_of(built).handle.address == t1.handle.address, "primary_of a Ruby-built Holder") +expect(Codec.sum_holder(Codec::Holder.new(primary: t1, spare: nil, many: [])) == 1, "Holder with nothing optional") +expect(Codec.sum_holder(Codec::Holder.new(primary: Codec::Token.new(I64_MAX), spare: nil, many: [])) == I64_MAX, + "i64::MAX through a token") + +# Absent optional object. +without = Codec.make_holder(0, false) +expect(without.spare.nil?, "make_holder(_, false) has no spare") +expect(without.primary.value.zero? && without.many.map(&:value) == [2, 3, 4], "values without spare") +expect(Codec.sum_holder(without) == 9, "sum without spare") + +# A closed token inside a Holder is refused before anything is encoded. +t2.close +begin + Codec.sum_holder(built) + raise "expected Error encoding a closed Token" +rescue Codec::Error => e + expect(e.message.include?("after close"), "closed token in a buffer (got #{e.message.inspect})") +end +begin + t2.value + raise "expected Error when using a closed Token" +rescue Codec::Error => e + expect(e.message.include?("after close"), "use-after-close message") +end + +# Release: every wrapper closes exactly once, double close is a no-op, and +# wrappers left to GC release without double frees. +[holder, other, without].each do |h| + h.primary.close + h.spare&.close + h.many.each(&:close) + h.primary.close + h.many.each(&:close) + expect(h.primary.closed? && h.many.all?(&:closed?), "holder tokens closed") +end +t1.close +t3.close +t1.close +200.times { |i| Codec.sum_holder(Codec.make_holder(i, i.odd?)) } +GC.start +GC.start +expect(Codec.roundtrip_i64(42) == 42, "library still healthy after GC of unclosed wrappers") + +puts "ruby/codec: OK" diff --git a/conformance/ruby/contacts.rb b/conformance/ruby/contacts.rb index de97fe1b..01e0bfe6 100644 --- a/conformance/ruby/contacts.rb +++ b/conformance/ruby/contacts.rb @@ -3,8 +3,10 @@ # # Drives the 0.7.0 value-type surface: Contact is a plain Ruby value class # decoded from the value buffer the producer returns (the optional email is -# nil-able and the list return is a native Array), while ContactBook stays an -# owned interface handle released through FFI::AutoPointer. Throwing methods +# nil-able and the list return is a native Array), while ContactBook is a +# reference-counted interface wrapper whose `close` releases its strong +# reference early (idempotent; the AutoPointer otherwise releases it at GC +# time), and whose `dup` mints an independent wrapper. Throwing methods # raise the typed ContactsError subclasses (InvalidName=1, NotFound=2); # non-throwing methods keep the generic WeaveFFI::Error for panics only. The # cdylib is selected via WEAVEFFI_LIBRARY. @@ -69,10 +71,32 @@ def expect(cond, msg) expect(e.code == 2, "domain rescue sees NotFound code (got #{e.code})") end -# Each book owns independent state; explicit destroy releases it early (the -# AutoPointer's GC release is then a no-op, so no double-free). +# Each book owns independent state. `dup` produces a second wrapper holding +# its own strong reference to the same object, so state is shared and closing +# one wrapper leaves the other usable. other = WeaveFFI::ContactBook.new expect(other.count.zero?, "fresh book empty") -other.destroy +twin = other.dup +expect(twin.handle.address == other.handle.address, "dup wraps the same object") +other.add("Carol", "White", nil, WeaveFFI::ContactType::PERSONAL) +expect(twin.count == 1, "state visible through the dup (got #{twin.count})") +twin.close +expect(twin.closed?, "dup reports closed") +expect(!other.closed?, "original still open after closing the dup") +expect(other.count == 1, "original usable after closing the dup") + +# Explicit close releases the wrapper's reference early; a second close is a +# no-op (the AutoPointer's GC release is also a no-op afterward, so no +# double-free), and using a closed wrapper raises the brand error. +other.close +other.close +expect(other.closed?, "closed? after close") +begin + other.count + raise "expected Error when using a closed ContactBook" +rescue WeaveFFI::Error => e + expect(e.message.include?("after close"), "use-after-close message (got #{e.message.inspect})") +end +book.close puts "ruby/contacts: OK" diff --git a/conformance/ruby/events.rb b/conformance/ruby/events.rb index 277a611e..d8449b82 100644 --- a/conformance/ruby/events.rb +++ b/conformance/ruby/events.rb @@ -1,9 +1,17 @@ # frozen_string_literal: true # Conformance consumer: events sample, Ruby target. # -# Exercises the FFI::Function listener trampoline (register -> fire -# synchronously on send -> unregister stops delivery) and the opaque-iterator -# ABI behind `get_messages`. The cdylib is selected via WEAVEFFI_LIBRARY. +# Drives the ABI 2 callback-interface surface: a Ruby class that includes the +# generated `WeaveFFI::Subscriber` mixin and overrides its three methods, a +# reference-counted `EventBus` object (constructor, `dup`, `close`, use after +# close), the bus handing a strong `EventBus` reference to the consumer +# through `on_attached`, `Delivery` return values steering `publish`'s +# accepted count, a Ruby exception inside a callback surfacing to the caller +# as `WeaveFFI::Error` with FOREIGN_ERROR_CODE (-4) without crashing the VM, +# the blocking async `publish_later` bridge, the iterator-backed `messages` +# Enumerator, the optional `last_message` record, and the release of every +# consumer implementation (the producer's `free` entry drops the registry +# slot). The cdylib is selected via WEAVEFFI_LIBRARY. $LOAD_PATH.unshift(File.join(ENV.fetch("WV_RB"), "lib")) require "events" @@ -12,21 +20,243 @@ def expect(cond, msg) raise "assertion failed: #{msg}" unless cond end -received = [] -sub = WeaveFFI.register_message_listener { |message| received << message } -expect(sub.positive?, "listener id positive (got #{sub})") +# The registry of live callback implementations is private generated state, +# but its size is the only observable proof that the producer's `free` entry +# ran (and that a consumer implementation is not leaked or freed twice). +def live_callbacks + WeaveFFI.instance_variable_get(:@wv_cb_registry).size +end + +# A subscriber that records every call so the arguments can be asserted. +class Recorder + include WeaveFFI::Subscriber + + attr_reader :routed, :messages, :attached + + def initialize(skip: nil, stop: nil, fail_on: nil, fail_in_message: nil) + @skip = skip + @stop = stop + @fail_on = fail_on + @fail_in_message = fail_in_message + @routed = [] + @messages = [] + @attached = [] + end + + def route(topic) + @routed << topic + raise "boom: #{topic}" if topic == @fail_on + return WeaveFFI::Delivery::SKIP if topic == @skip + return WeaveFFI::Delivery::ACCEPT_AND_STOP if topic == @stop + + WeaveFFI::Delivery::ACCEPT + end + + def on_message(message) + raise ArgumentError, "on_message rejected #{message.text}" if message.text == @fail_in_message + + @messages << message + @messages.length + end + + def on_attached(bus) + @attached << bus + end +end + +# The mixin supplies NotImplementedError defaults for anything not overridden. +class Bare + include WeaveFFI::Subscriber +end + +expect(WeaveFFI::FOREIGN_ERROR_CODE == -4, "FOREIGN_ERROR_CODE is -4") +expect(WeaveFFI::ABI_VERSION == 2, "bindings target ABI revision 2") +expect(WeaveFFI::Delivery::ACCEPT.zero?, "Delivery::ACCEPT == 0") +expect(WeaveFFI::Delivery::SKIP == 1, "Delivery::SKIP == 1") +expect(WeaveFFI::Delivery::ACCEPT_AND_STOP == 2, "Delivery::ACCEPT_AND_STOP == 2") + +baseline = live_callbacks + +bus = WeaveFFI::EventBus.new +expect(bus.is_a?(WeaveFFI::EventBus), "constructor returns an EventBus") +expect(!bus.closed?, "fresh bus is open") +expect(bus.subscriber_count.zero?, "fresh bus has no subscribers") +expect(bus.last_message.nil?, "last_message is nil before any publish") +expect(bus.messages.is_a?(Enumerator), "messages returns an Enumerator") +expect(bus.messages.to_a == [], "no messages before any publish") + +# subscribe: the bus calls on_attached synchronously with a strong reference +# to itself, which the consumer adopts as a fresh wrapper to the same object. +first = Recorder.new(skip: "quiet", stop: "stop") +expect(bus.subscribe(first) == 1, "first subscribe returns count 1") +expect(first.attached.length == 1, "on_attached called once (got #{first.attached.length})") +handed = first.attached[0] +expect(handed.is_a?(WeaveFFI::EventBus), "on_attached receives an EventBus wrapper") +expect(handed.handle.address == bus.handle.address, "handed bus is the same underlying object") +expect(handed.subscriber_count == 1, "handed bus is usable (got #{handed.subscriber_count})") +handed.close +handed.close +expect(handed.closed?, "handed bus wrapper closed (idempotently)") +expect(bus.subscriber_count == 1, "original bus survives closing the handed reference") + +second = Recorder.new +expect(bus.subscribe(second) == 2, "second subscribe returns count 2") +expect(second.attached.length == 1, "second on_attached called once") +second.attached[0].close +expect(live_callbacks == baseline + 2, "two live implementations registered") + +# publish: Accept from both subscribers -> 2 accepted; the Message record +# arrives fully decoded (seq, topic, text, tags). +expect(bus.publish("news", "hello", %w[x y]) == 2, "both subscribers accept news") +expect(first.routed == %w[news], "first.route saw news (got #{first.routed})") +expect(second.routed == %w[news], "second.route saw news (got #{second.routed})") +expect(first.messages.length == 1 && second.messages.length == 1, "each got one message") +msg = first.messages[0] +expect(msg.is_a?(WeaveFFI::Message), "on_message receives a Message") +expect(msg.seq == 1, "seq starts at 1 (got #{msg.seq})") +expect(msg.topic == "news", "message topic (got #{msg.topic.inspect})") +expect(msg.text == "hello", "message text (got #{msg.text.inspect})") +expect(msg.tags == %w[x y], "message tags (got #{msg.tags})") +expect(second.messages[0] == msg, "both subscribers saw the same message (structural equality)") -WeaveFFI.send_message("alpha") -WeaveFFI.send_message("beta") -expect(received == %w[alpha beta], "listener received sends (got #{received})") +# Skip from the first subscriber -> only the second accepts. +expect(bus.publish("quiet", "psst", []) == 1, "skip lowers the accepted count") +expect(first.routed == %w[news quiet], "first.route saw quiet") +expect(first.messages.length == 1, "skipped subscriber got no message") +expect(second.messages.length == 2, "second subscriber got psst") +expect(second.messages[1].seq == 2 && second.messages[1].tags == [], "second message seq 2, no tags") -msgs = WeaveFFI.get_messages.to_a -expect(msgs == %w[alpha beta], "iterator yields messages in order (got #{msgs})") +# AcceptAndStop from the first subscriber -> delivered to it, second not asked. +expect(bus.publish("stop", "last", ["t"]) == 1, "accept-and-stop halts delivery") +expect(first.messages.length == 2, "stopping subscriber still receives") +expect(first.messages[1].text == "last", "stopping subscriber got last") +expect(second.routed == %w[news quiet], "later subscriber not routed after stop (got #{second.routed})") +expect(second.messages.length == 2, "later subscriber not delivered after stop") + +# Async publish: blocks the caller until the producer-thread completion fires; +# the subscriber callbacks run from that thread through the FFI dispatcher. +expect(bus.publish_later("later", "async") == 2, "publish_later resolves with the accepted count") +expect(first.messages.length == 3 && second.messages.length == 3, "async publish delivered to both") +expect(first.messages[2].topic == "later" && first.messages[2].tags == [], "async message decoded") + +# Iterator: every text in order, lazily, with early termination releasing the +# producer iterator; and the optional last_message record. +expect(bus.messages.to_a == %w[hello psst last async], "messages iterator (got #{bus.messages.to_a})") +expect(bus.messages.first(2) == %w[hello psst], "early break through first(2)") +collected = [] +bus.messages.each { |t| collected << t.upcase } +expect(collected == %w[HELLO PSST LAST ASYNC], "block iteration") +expect(bus.messages.each_with_index.to_a.last == ["async", 3], "Enumerator composes with each_with_index") +last = bus.last_message +expect(last == WeaveFFI::Message.new(seq: 4, topic: "later", text: "async", tags: []), + "last_message (got seq=#{last.seq} topic=#{last.topic} text=#{last.text} tags=#{last.tags})") + +# A Ruby exception in route surfaces to the publishing caller as the brand +# error with FOREIGN_ERROR_CODE and the exception text; the VM and the bus +# both keep working, and the subscriber stays attached. +failing = Recorder.new(fail_on: "boom", fail_in_message: "bad-body") +expect(bus.subscribe(failing) == 3, "third subscribe returns count 3") +failing.attached[0].close +begin + bus.publish("boom", "x", []) + raise "expected WeaveFFI::Error from a raising route" +rescue WeaveFFI::Error => e + expect(e.code == WeaveFFI::FOREIGN_ERROR_CODE, "raising route -> code -4 (got #{e.code})") + expect(e.message.include?("boom: boom"), "foreign error carries the exception text (got #{e.message.inspect})") +end +expect(failing.routed == %w[boom], "failing subscriber's route ran") +expect(first.messages.length == 4, "earlier subscribers were delivered before the failure") + +# The same for an exception raised in on_message. +begin + bus.publish("body", "bad-body", []) + raise "expected WeaveFFI::Error from a raising on_message" +rescue WeaveFFI::Error => e + expect(e.code == -4, "raising on_message -> code -4 (got #{e.code})") + expect(e.message.include?("on_message rejected bad-body"), "on_message failure text (got #{e.message.inspect})") +end +expect(failing.messages.empty?, "raising on_message recorded nothing") + +expect(bus.publish("ok", "y", []) == 3, "bus still usable after foreign errors") +expect(failing.messages.length == 1 && failing.messages[0].text == "y", "failing subscriber accepts ok") +expect(bus.subscriber_count == 3, "subscriber_count == 3") + +# Non-ASCII text survives every string path: the C-string topic handed to +# route, the buffered Message, the iterator item, and the last_message record. +expect(bus.publish("тема ✓", "日本語", ["ü"]) == 3, "unicode publish accepted by all") +expect(first.routed.last == "тема ✓", "route received the unicode topic (got #{first.routed.last.inspect})") +expect(first.routed.last.encoding == Encoding::UTF_8, "route topic is UTF-8 (got #{first.routed.last.encoding})") +expect(first.messages.last.text == "日本語" && first.messages.last.tags == ["ü"], "unicode message fields") +expect(bus.messages.to_a.last == "日本語", "iterator item is unicode (got #{bus.messages.to_a.last.inspect})") +expect(bus.messages.to_a.last.encoding == Encoding::UTF_8, "iterator item is UTF-8") +expect(bus.last_message.topic == "тема ✓", "last_message topic is unicode") + +# The producer logs a message before routing, so failed publishes are logged. +expect(bus.messages.to_a == ["hello", "psst", "last", "async", "x", "bad-body", "y", "日本語"], + "log includes failed publishes (got #{bus.messages.to_a})") + +# route_once: a free function taking the callback interface; the producer +# drops its only reference on return, so `free` runs and the slot is released. +before = live_callbacks +probe = Recorder.new(skip: "quiet") +expect(WeaveFFI.route_once(probe, "quiet") == WeaveFFI::Delivery::SKIP, "route_once -> SKIP") +expect(WeaveFFI.route_once(probe, "news") == WeaveFFI::Delivery::ACCEPT, "route_once -> ACCEPT") +expect(probe.routed == %w[quiet news], "route_once called route with the topic") +expect(probe.attached.empty?, "route_once never attaches") +expect(live_callbacks == before, "route_once released its implementation (#{live_callbacks} vs #{before})") + +# A mixin default (NotImplementedError) is a foreign error too. +begin + WeaveFFI.route_once(Bare.new, "x") + raise "expected WeaveFFI::Error from the NotImplementedError default" +rescue WeaveFFI::Error => e + expect(e.code == -4, "NotImplementedError default -> code -4 (got #{e.code})") + expect(e.message.include?("route is not implemented"), "default message (got #{e.message.inspect})") +end +expect(live_callbacks == before, "failing route_once released its implementation") + +# dup mints an independent wrapper (own strong reference) to the same bus. +twin = bus.dup +expect(twin.handle.address == bus.handle.address, "dup shares the object") +expect(twin.subscriber_count == 3, "dup sees the subscribers") +twin.close +expect(bus.subscriber_count == 3, "bus survives closing the dup") + +# clear_subscribers drops every retained implementation; each `free` runs +# synchronously, and publishing to an empty bus accepts nothing. +bus.clear_subscribers +expect(bus.subscriber_count.zero?, "no subscribers after clear") +expect(live_callbacks == baseline, "every implementation released after clear (#{live_callbacks} vs #{baseline})") +expect(bus.publish("after", "nobody", []) == 0, "publish to an empty bus accepts 0") +expect(first.messages.length == 7, "cleared subscriber no longer delivered (got #{first.messages.length})") + +# A subscriber held only by the bus is freed when the bus's last reference +# goes away. The reference handed through on_attached counts, so the bus +# outlives `short.close` until that wrapper is closed too. +short = WeaveFFI::EventBus.new +tail = Recorder.new +short.subscribe(tail) +expect(live_callbacks == baseline + 1, "bus retains its subscriber") +short.close +expect(live_callbacks == baseline + 1, "the on_attached reference keeps the bus (and subscriber) alive") +expect(tail.attached[0].subscriber_count == 1, "bus usable through the on_attached reference alone") +tail.attached[0].close +expect(live_callbacks == baseline, "dropping the last bus reference frees its subscriber") + +# close is idempotent and a closed wrapper refuses further calls. +bus.close +bus.close +expect(bus.closed?, "closed? after close") +begin + bus.subscriber_count + raise "expected Error when using a closed EventBus" +rescue WeaveFFI::Error => e + expect(e.message.include?("after close"), "use-after-close message (got #{e.message.inspect})") +end -# Unregister stops delivery; the producer still records the message. -WeaveFFI.unregister_message_listener(sub) -WeaveFFI.send_message("gamma") -expect(received == %w[alpha beta], "no delivery after unregister (got #{received})") -expect(WeaveFFI.get_messages.to_a == %w[alpha beta gamma], "producer kept recording") +# Ruby-side wrappers that were never closed release at GC without double +# frees; force a couple of collections to shake that out. +GC.start +GC.start puts "ruby/events: OK" diff --git a/conformance/ruby/kvstore.rb b/conformance/ruby/kvstore.rb index 8898e30a..2fb960e7 100644 --- a/conformance/ruby/kvstore.rb +++ b/conformance/ruby/kvstore.rb @@ -1,17 +1,20 @@ # frozen_string_literal: true # Conformance consumer: kvstore sample, Ruby target. # -# Full-surface drive of the 0.7.0 wrapper: the Store interface (the open(path) -# factory constructor, sync methods, the iterator-backed list_keys, the -# blocking compact bridge over the async ABI, the deprecated legacy_put, and -# the default_capacity static), the typed KvError domain (codes 1001-1004) -# with per-code subclasses raised by throwing members, the Entry record as a -# plain value class decoded from a value buffer (bytes, nil-able optional -# scalar, native Array/Hash fields, structural equality), the FFI::Function -# eviction listener (register -> fire on delete -> unregister), and the -# cross-module `Kvstore.get_stats(store)` (Stats lives in kv.stats; the store -# passes as a borrowed interface pointer). The cdylib is selected via -# WEAVEFFI_LIBRARY. +# Full-surface drive of the ABI 2 wrapper: the reference-counted Store +# interface (the open(path) factory constructor, sync methods, the +# Enumerator-backed list_keys, the blocking compact bridge over the async +# ABI, the deprecated legacy_put, the default_capacity static, `close` and +# `dup` semantics), the typed KvError domain (codes 1001-1004) with per-code +# subclasses raised by throwing members, the Entry record as a plain value +# class decoded from a value buffer, a Ruby class including the generated +# `EvictionListener` mixin (attach, fire on delete and on expiry-on-read, +# detach by returning false, replace, clear, and a raising listener surfacing +# as the brand error with code -4), the object graph (share() wraps the SAME +# object, fork(), Store? both ways in larger(), a Store inside the StoreInfo +# record from describe(), [Store] from open_many(), and [Store] plus StoreInfo? +# as parameters to total_count()), and the cross-module +# `Kvstore.get_stats(store)`. The cdylib is selected via WEAVEFFI_LIBRARY. $LOAD_PATH.unshift(File.join(ENV.fetch("WV_RB"), "lib")) require "kvstore" @@ -20,6 +23,39 @@ def expect(cond, msg) raise "assertion failed: #{msg}" unless cond end +# Size of the generated registry of live callback implementations: the only +# observable proof that the producer's `free` entry ran exactly once. +def live_callbacks + Kvstore.instance_variable_get(:@wv_cb_registry).size +end + +# An eviction listener recording (key, reason) pairs; returns false (detach) +# once `keep_after` evictions were seen, and raises for `raise_on`. +class Listener + include Kvstore::EvictionListener + + attr_reader :seen, :entries + + def initialize(keep_after: nil, raise_on: nil) + @keep_after = keep_after + @raise_on = raise_on + @seen = [] + @entries = [] + end + + def on_evict(entry, reason) + @seen << [entry.key, reason] + @entries << entry + raise "listener exploded on #{entry.key}" if entry.key == @raise_on + + @keep_after.nil? || @seen.length < @keep_after + end +end + +expect(Kvstore::ABI_VERSION == 2, "bindings target ABI revision 2") +expect(Kvstore::EvictionReason::DELETED.zero?, "EvictionReason::DELETED == 0") +expect(Kvstore::EvictionReason::EXPIRED == 1, "EvictionReason::EXPIRED == 1") + # Interface static: a class method with no self slot. expect(Kvstore::Store.default_capacity == 1_000_000, "default_capacity") @@ -34,6 +70,7 @@ def expect(cond, msg) end store = Kvstore::Store.open("/tmp/conformance-kvstore-rb") +expect(store.is_a?(Kvstore::Store) && !store.closed?, "open returns an open Store") payload = "\x01\x02\x03".b # put takes a buffered `i64?` TTL: nil and a present value both encode. @@ -42,10 +79,17 @@ def expect(cond, msg) expect(store.count == 2, "count == 2") # Iterator-backed list-of-string return, optionally prefix-filtered (the -# `string?` prefix is a buffered optional parameter). -keys = store.list_keys(nil).to_a.sort -expect(keys == %w[alpha beta], "list_keys values (got #{keys})") +# `string?` prefix is a buffered optional parameter). The Enumerator is lazy +# and releases the producer iterator on early termination. +expect(store.list_keys(nil).is_a?(Enumerator), "list_keys returns an Enumerator") +keys = store.list_keys(nil).to_a +expect(keys == %w[alpha beta], "list_keys values in sorted order (got #{keys})") expect(store.list_keys("al").to_a == %w[alpha], "list_keys prefix filter") +expect(store.list_keys("zz").to_a == [], "list_keys unmatched prefix is empty") +expect(store.list_keys(nil).first(1) == %w[alpha], "early break through first(1)") +seen = [] +store.list_keys(nil).each { |k| seen << k.upcase } +expect(seen == %w[ALPHA BETA], "block iteration") # Optional record return: get decodes a buffered `Entry?` into a plain value # object covering every complex field type. @@ -64,6 +108,7 @@ def expect(cond, msg) beta = store.get("beta") expect(!beta.expires_at.nil? && beta.expires_at.positive?, "beta expires_at present") +expect(beta.expires_at - beta.created_at == 3600, "beta TTL applied (got #{beta.expires_at - beta.created_at})") # A missing key raises the typed KeyNotFound (1001). begin @@ -71,11 +116,21 @@ def expect(cond, msg) raise "expected KvError::KeyNotFound" rescue Kvstore::KvError::KeyNotFound => e expect(e.code == 1001, "KeyNotFound code == 1001 (got #{e.code})") + expect(e.message == "key not found", "KeyNotFound default message (got #{e.message.inspect})") +end + +# Rescuing the domain base class catches any code in the domain. +begin + store.get("missing") + raise "expected KvError" +rescue Kvstore::KvError => e + expect(e.code == 1001, "domain rescue sees KeyNotFound (got #{e.code})") end # The deprecated method still works (and warns on stderr). expect(store.legacy_put("legacy", payload) == true, "legacy_put") expect(store.delete("legacy") == true, "delete legacy") +expect(store.delete("legacy") == false, "second delete returns false") # Entry is a plain value class: keyword construction, non-empty native # list/map fields, a nil-able optional, and structural equality. @@ -97,41 +152,220 @@ def expect(cond, msg) metadata: { "source" => "test", "env" => "prod" } ) expect(built == same, "entries compare structurally") +expect(built != alpha, "different entries are unequal") # The cross-module call under test: kv.stats.get_stats borrows the kv.Store # interface pointer and returns the Stats record as a value. stats = Kvstore.get_stats(store) +expect(stats.is_a?(Kvstore::Stats), "get_stats returns a Stats value") expect(stats.total_entries == 2, "total_entries == 2 (got #{stats.total_entries})") expect(stats.total_bytes == 6, "total_bytes == 6 (got #{stats.total_bytes})") expect(stats.expired_entries == 0, "expired_entries == 0 (got #{stats.expired_entries})") -# Eviction listener: delete fires the FFI::Function trampoline synchronously. -evicted = [] -sub = Kvstore.register_eviction_listener { |key| evicted << key } -expect(sub.positive?, "listener id positive") +# --- Eviction listener (callback interface) ------------------------------ +baseline = live_callbacks + +listener = Listener.new +store.set_eviction_listener(listener) +expect(live_callbacks == baseline + 1, "listener registered") + +# delete fires on_evict synchronously with the removed Entry and DELETED. expect(store.delete("beta") == true, "delete beta") -expect(evicted == %w[beta], "eviction fired for beta (got #{evicted})") +expect(listener.seen == [["beta", Kvstore::EvictionReason::DELETED]], "eviction fired for beta (got #{listener.seen})") +evicted = listener.entries[0] +expect(evicted.is_a?(Kvstore::Entry), "on_evict receives an Entry value") +expect(evicted.value == payload && evicted.expires_at == beta.expires_at, "evicted entry matches the stored one") +expect(evicted == beta, "evicted entry equals the earlier snapshot") + +# An entry whose TTL already elapsed is evicted on read: get raises Expired +# (1002) and the listener sees EXPIRED. +expect(store.put("stale", "zz".b, Kvstore::EntryKind::VOLATILE, -1) == true, "put stale") +begin + store.get("stale") + raise "expected KvError::Expired" +rescue Kvstore::KvError::Expired => e + expect(e.code == 1002, "Expired code == 1002 (got #{e.code})") +end +expect(listener.seen.last == ["stale", Kvstore::EvictionReason::EXPIRED], "expiry eviction (got #{listener.seen.last})") +expect(listener.entries.last.value == "zz".b, "expired entry payload") +expect(store.count == 1, "stale entry gone after eviction") + +# Replacing the listener frees the previous one; clearing frees the current. +second = Listener.new +store.set_eviction_listener(second) +expect(live_callbacks == baseline + 1, "replaced listener freed, replacement registered") +store.put("gamma", payload, Kvstore::EntryKind::VOLATILE, nil) +store.delete("gamma") +expect(listener.seen.length == 2, "replaced listener no longer notified") +expect(second.seen == [["gamma", Kvstore::EvictionReason::DELETED]], "replacement notified (got #{second.seen})") +store.clear_eviction_listener +expect(live_callbacks == baseline, "clear_eviction_listener freed the listener") +store.put("delta", payload, Kvstore::EntryKind::VOLATILE, nil) +store.delete("delta") +expect(second.seen.length == 1, "no notification after clear") +store.clear_eviction_listener + +# Returning false detaches (and frees) the listener after that eviction. +brief = Listener.new(keep_after: 2) +store.set_eviction_listener(brief) +store.put("one", payload, Kvstore::EntryKind::VOLATILE, nil) +store.put("two", payload, Kvstore::EntryKind::VOLATILE, nil) +store.put("three", payload, Kvstore::EntryKind::VOLATILE, nil) +store.delete("one") +expect(live_callbacks == baseline + 1, "listener retained after returning true") +store.delete("two") +expect(live_callbacks == baseline, "listener freed after returning false") +store.delete("three") +expect(brief.seen.map(&:first) == %w[one two], "detached listener misses the third eviction (got #{brief.seen})") + +# A Ruby exception inside on_evict surfaces to the caller of delete as the +# brand error with FOREIGN_ERROR_CODE (-4), not as a KvError; the entry was +# already removed, the VM is fine, and the store keeps working. +angry = Listener.new(raise_on: "kaboom") +store.set_eviction_listener(angry) +store.put("kaboom", payload, Kvstore::EntryKind::VOLATILE, nil) +begin + store.delete("kaboom") + raise "expected Kvstore::Error from a raising listener" +rescue Kvstore::KvError + raise "a foreign error must not be mapped to a KvError" +rescue Kvstore::Error => e + expect(e.code == Kvstore::FOREIGN_ERROR_CODE, "raising listener -> code -4 (got #{e.code})") + expect(e.code == -4, "FOREIGN_ERROR_CODE is -4") + expect(e.message.include?("listener exploded on kaboom"), "foreign error text (got #{e.message.inspect})") +end +expect(store.count == 1, "entry removed despite the listener failure (got #{store.count})") +expect(store.delete("kaboom") == false, "kaboom is gone") +expect(store.put("calm", payload, Kvstore::EntryKind::VOLATILE, nil) && store.delete("calm"), + "store usable after a foreign error") +expect(angry.seen.map(&:first) == %w[kaboom calm], "raising listener stays attached (got #{angry.seen})") +store.clear_eviction_listener +expect(live_callbacks == baseline, "all listeners released") + +# --- Object graph -------------------------------------------------------- +# share() returns a wrapper to the SAME object: writes through one are visible +# through the other, and closing one wrapper leaves the object alive. +shared = store.share +expect(shared.is_a?(Kvstore::Store), "share returns a Store") +expect(!shared.equal?(store), "share returns a distinct wrapper") +expect(shared.handle.address == store.handle.address, "share wraps the same object") +expect(shared.put("via-shared", payload, Kvstore::EntryKind::VOLATILE, nil), "put through the shared wrapper") +expect(store.count == 2, "write through share visible in the original (got #{store.count})") +expect(store.get("via-shared").value == payload, "entry readable through the original") +shared.close +shared.close +expect(shared.closed?, "shared wrapper closed idempotently") +expect(store.count == 2, "object survives closing the shared wrapper") + +# dup and clone also mint independent wrappers with their own reference. +twin = store.dup +expect(twin.handle.address == store.handle.address, "dup wraps the same object") +expect(twin.count == 2, "dup sees the same entries") +twin.close +expect(store.count == 2, "object survives closing the dup") + +# fork() is a new, independent object. +forked = store.fork +expect(forked.handle.address != store.handle.address, "fork is a different object") +expect(forked.count == 2, "fork copied the live entries (got #{forked.count})") +forked.put("only-in-fork", payload, Kvstore::EntryKind::VOLATILE, nil) +expect(forked.count == 3 && store.count == 2, "fork is independent of the original") -# Unregister stops delivery. -Kvstore.unregister_eviction_listener(sub) -expect(store.delete("alpha") == true, "delete alpha") -expect(evicted == %w[beta], "no eviction after unregister (got #{evicted})") +# larger(): Store? in and out. +empty = Kvstore::Store.open("/tmp/conformance-kvstore-rb-empty") +expect(empty.larger(nil).nil?, "larger(nil) on an empty store is nil") +own = store.larger(nil) +expect(!own.nil? && own.handle.address == store.handle.address, "larger(nil) on a non-empty store is itself") +own.close +bigger = store.larger(forked) +expect(bigger.handle.address == forked.handle.address, "larger picks the fuller store") +bigger.close +still = forked.larger(empty) +expect(still.handle.address == forked.handle.address, "larger keeps self when other is smaller") +still.close +expect(forked.count == 3 && empty.count.zero?, "larger left both stores intact") -# Async: an immediately-expired entry gives compact 3 bytes to reclaim; the -# wrapper blocks on a Queue fed from the producer's worker-thread callback. +# describe(): a record carrying the object itself plus an optional object. +info = store.describe("primary", nil) +expect(info.is_a?(Kvstore::StoreInfo), "describe returns a StoreInfo") +expect(info.label == "primary", "info.label (got #{info.label.inspect})") +expect(info.count == 2, "info.count (got #{info.count})") +expect(info.mirror.nil?, "info.mirror nil when absent") +expect(info.store.is_a?(Kvstore::Store), "info.store is a Store wrapper") +expect(info.store.handle.address == store.handle.address, "info.store is the described object") +expect(info.store.count == 2, "info.store is usable") +mirrored = store.describe("mirrored", forked) +expect(!mirrored.mirror.nil? && mirrored.mirror.handle.address == forked.handle.address, "info.mirror is the passed store") +expect(mirrored.mirror.count == 3, "info.mirror is usable") +expect(forked.count == 3, "borrowed mirror parameter still alive") + +# open_many(): a list of objects as a return; a bad path raises the typed error +# for the whole call. +many = Kvstore::Store.open_many(["/tmp/a", "/tmp/b", "/tmp/c"]) +expect(many.is_a?(Array) && many.length == 3, "open_many returns 3 stores") +expect(many.all? { |s| s.is_a?(Kvstore::Store) && !s.closed? }, "every element is an open Store") +expect(many.map { |s| s.handle.address }.uniq.length == 3, "open_many stores are distinct objects") +many[0].put("m0", payload, Kvstore::EntryKind::VOLATILE, nil) +many[2].put("m2a", payload, Kvstore::EntryKind::VOLATILE, nil) +many[2].put("m2b", payload, Kvstore::EntryKind::VOLATILE, nil) +expect(many.map(&:count) == [1, 0, 2], "open_many stores hold independent state") +expect(Kvstore::Store.open_many([]) == [], "open_many of nothing is empty") +begin + Kvstore::Store.open_many(["/tmp/ok", ""]) + raise "expected KvError::IoError from open_many" +rescue Kvstore::KvError::IoError => e + expect(e.code == 1004, "open_many IoError code (got #{e.code})") +end + +# total_count(): a list of objects and an optional record holding an object as +# parameters (the encoder mints one reference per object token). +expect(Kvstore::Store.total_count([], nil).zero?, "total_count of nothing") +expect(Kvstore::Store.total_count(many, nil) == 3, "total_count over open_many (got #{Kvstore::Store.total_count(many, nil)})") +expect(Kvstore::Store.total_count(many, info) == 5, "total_count with the described store (got #{Kvstore::Store.total_count(many, info)})") +expect(Kvstore::Store.total_count([store, store, forked], nil) == 7, "the same object may appear repeatedly") +ruby_info = Kvstore::StoreInfo.new(label: "ruby-built", store: forked, mirror: store, count: 0) +expect(Kvstore::Store.total_count([empty], ruby_info) == 3, "Ruby-built StoreInfo encodes its object fields") +expect(Kvstore::Store.total_count([], mirrored) == 2, "StoreInfo with a mirror encodes both objects") +expect(store.count == 2 && forked.count == 3 && many.map(&:count) == [1, 0, 2], + "borrowed parameter objects survive total_count") + +# --- Async --------------------------------------------------------------- +# An immediately-expired entry gives compact 3 bytes to reclaim; the wrapper +# blocks on a Queue fed from the producer's worker-thread callback. expect(store.put("doomed", payload, Kvstore::EntryKind::VOLATILE, 0) == true, "put doomed") reclaimed = store.compact expect(reclaimed == 3, "compact reclaimed 3 bytes (got #{reclaimed})") -expect(store.count.zero?, "store empty after deletes + compact") +expect(store.compact.zero?, "second compact reclaims nothing") +expect(store.count == 2, "live entries untouched by compact") # clear drops everything left in one call. -expect(store.put("temp", payload, Kvstore::EntryKind::VOLATILE, nil) == true, "put temp") -expect(store.count == 1, "count == 1 before clear") store.clear expect(store.count.zero?, "count == 0 after clear") +expect(store.list_keys(nil).to_a == [], "no keys after clear") -# Explicit destroy releases the object early; the AutoPointer's GC release is -# then a no-op, so no double-free. -store.destroy +# --- Release ------------------------------------------------------------- +# Every wrapper closes exactly once; a second close is a no-op; the object +# itself lives until its last wrapper (info.store, mirrored.store, ...) goes. +store.close +store.close +expect(store.closed?, "store closed") +begin + store.count + raise "expected Error when using a closed Store" +rescue Kvstore::Error => e + expect(e.message.include?("after close"), "use-after-close message (got #{e.message.inspect})") +end +expect(info.store.count.zero?, "object still alive through the record's reference") +info.store.close +mirrored.store.close +mirrored.mirror.close +expect(forked.count == 3, "fork still alive after the mirror wrapper closed") +forked.close +empty.close +many.each(&:close) +many.each(&:close) +expect(many.all?(&:closed?), "every open_many store closed") +GC.start +GC.start puts "ruby/kvstore: OK" diff --git a/conformance/run.sh b/conformance/run.sh index 9a70bf8a..f761b7b5 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -154,6 +154,24 @@ c_shapes() { && "$OUT/c_shapes" } +c_inventory() { + clang -I "$GENROOT/inventory/c" "$ROOT/conformance/c/inventory.c" \ + -L "$LIBDIR" -linventory -o "$OUT/c_inventory" \ + && "$OUT/c_inventory" +} + +c_codec() { + clang -I "$GENROOT/codec/c" "$ROOT/conformance/c/codec.c" \ + -L "$LIBDIR" -lcodec -lm -o "$OUT/c_codec" \ + && "$OUT/c_codec" +} + +cpp_codec() { + clang++ -std=c++17 -I "$GENROOT/codec/cpp" "$ROOT/conformance/cpp/codec.cpp" \ + -L "$LIBDIR" -lcodec -o "$OUT/cpp_codec" \ + && "$OUT/cpp_codec" +} + cpp_shapes() { clang++ -std=c++17 -I "$GENROOT/shapes/cpp" "$ROOT/conformance/cpp/shapes.cpp" \ -L "$LIBDIR" -lshapes -o "$OUT/cpp_shapes" \ @@ -211,6 +229,8 @@ python_events() { py_consumer events events.py; } python_kvstore() { py_consumer kvstore kvstore.py; } python_shapes() { py_consumer shapes shapes.py; } python_async_demo() { py_consumer async-demo async_demo.py; } +python_codec() { py_consumer codec codec.py; } +python_inventory() { py_consumer inventory inventory.py; } # Run a Ruby consumer; same library-selection story as Python. rb_consumer() { @@ -225,6 +245,7 @@ ruby_events() { rb_consumer events events.rb; } ruby_kvstore() { rb_consumer kvstore kvstore.rb; } ruby_shapes() { rb_consumer shapes shapes.rb; } ruby_async_demo() { rb_consumer async-demo async_demo.rb; } +ruby_codec() { rb_consumer codec codec.rb; } # Run a Dart consumer from inside the generated package so `package:weaveffi` # and the cached `ffi` dependency resolve. The cdylib comes via WEAVEFFI_LIBRARY. @@ -248,6 +269,7 @@ dart_events() { dart_consumer events events.dart; } dart_kvstore() { dart_consumer kvstore kvstore.dart; } dart_shapes() { dart_consumer shapes shapes.dart; } dart_async_demo() { dart_consumer async-demo async_demo.dart; } +dart_codec() { dart_consumer codec codec.dart; } # A directory containing `libweaveffi.` symlinked to the sample cdylib, so # build-time `-lweaveffi` / `#cgo -lweaveffi` resolve. On Linux the consumer then @@ -294,6 +316,7 @@ go_events() { go_consumer events events.go; } go_kvstore() { go_consumer kvstore kvstore.go; } go_shapes() { go_consumer shapes shapes.go; } go_async_demo() { go_consumer async-demo async_demo.go; } +go_codec() { go_consumer codec codec.go; } # Swift: assemble a throwaway SwiftPM package that vendors the generated # WeaveFFI module plus a C shim whose module map points at the generated header @@ -344,6 +367,7 @@ swift_events() { swift_consumer events events.swift; } swift_kvstore() { swift_consumer kvstore kvstore.swift; } swift_shapes() { swift_consumer shapes shapes.swift; } swift_async_demo() { swift_consumer async-demo async_demo.swift; } +swift_codec() { swift_consumer codec codec.swift; } # .NET: compile the generated P/Invoke source together with the consumer into # one console app. The generated file is named after the resolved identity @@ -383,6 +407,7 @@ dotnet_events() { dotnet_consumer events Events.cs; } dotnet_kvstore() { dotnet_consumer kvstore Kvstore.cs; } dotnet_shapes() { dotnet_consumer shapes Shapes.cs; } dotnet_async_demo() { dotnet_consumer async-demo AsyncDemo.cs; } +dotnet_codec() { dotnet_consumer codec Codec.cs; } # Node: build the generated N-API addon with node-gyp against the producer # cdylib (via the `libweaveffi` link alias + include of the generated C header), @@ -416,8 +441,9 @@ node_events() { node_consumer events events.js; } node_kvstore() { node_consumer kvstore kvstore.js; } node_shapes() { node_consumer shapes shapes.js; } node_async_demo() { node_consumer async-demo async_demo.js; } +node_codec() { node_consumer codec codec.js; } -# Kotlin/Android: compile the generated JNI bridge into `libweaveffi.` (what +# Kotlin (JVM): compile the generated JNI bridge into `libweaveffi.` (what # `System.loadLibrary("weaveffi")` expects), linked against the producer cdylib; # then compile the generated WeaveFFI.kt together with the consumer in one module # (so the `internal` Entry/Stats constructors are reachable) against the @@ -454,11 +480,11 @@ kotlin_consumer() { /snap/kotlin/current/lib/kotlinx-coroutines-core-jvm.jar \ "$HOME/.sdkman/candidates/kotlin/current/lib/kotlinx-coroutines-core-jvm.jar" 2>/dev/null | head -1) [ -n "$coro" ] || { echo "kotlinx-coroutines-core-jvm.jar not found" >&2; return 1; } - cc -shared -fPIC "$GENROOT/$sample/android/src/main/cpp/weaveffi_jni.c" \ + cc -shared -fPIC "$GENROOT/$sample/kotlin/src/main/cpp/weaveffi_jni.c" \ -I"$jh/include" -I"$jni_os_inc" -I"$GENROOT/$sample/c" \ -L"$LIBDIR" -l"${sample//-/_}" -Wl,-rpath,"$LIBDIR" \ -o "$b/libweaveffi.$EXT" || { echo "JNI bridge compile failed" >&2; return 1; } - kotlinc "$GENROOT/$sample/android/src/main/kotlin/com/weaveffi/WeaveFFI.kt" \ + kotlinc "$GENROOT/$sample/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt" \ "$ROOT/conformance/kotlin/$src" -cp "$coro" -include-runtime -d "$b/app.jar" \ >/dev/null 2>&1 || { echo "kotlinc failed" >&2; return 1; } DYLD_LIBRARY_PATH="$LIBDIR:${DYLD_LIBRARY_PATH:-}" \ @@ -471,6 +497,7 @@ kotlin_events() { kotlin_consumer events events.kt; } kotlin_kvstore() { kotlin_consumer kvstore kvstore.kt; } kotlin_shapes() { kotlin_consumer shapes shapes.kt; } kotlin_async_demo() { kotlin_consumer async-demo async_demo.kt; } +kotlin_codec() { kotlin_consumer codec codec.kt; } # Wasm: compile the producer to wasm32-unknown-unknown and drive the generated # ESM bindings from Node. The generated JS glue expects weaveffi_alloc/dealloc @@ -500,6 +527,7 @@ wasm_events() { wasm_consumer events events.mjs; } wasm_kvstore() { wasm_consumer kvstore kvstore.mjs; } wasm_shapes() { wasm_consumer shapes shapes.mjs; } wasm_async_demo() { wasm_consumer async-demo async_demo.mjs; } +wasm_codec() { wasm_consumer codec codec.mjs; } # --------------------------------------------------------------------------- # Producers + generation @@ -514,15 +542,24 @@ build_producer shapes generate shapes build_producer async-demo generate async-demo +build_producer codec +generate codec +build_producer inventory +generate inventory # Header-only: the producer-export lane implements this in C, no Rust cdylib. generate calculator # --------------------------------------------------------------------------- -# Run matrix: every language runs the events lane (callbacks/listeners + -# iterators), the kvstore lane (handles, structs, builders, async, eviction -# listener), and the async-demo lane (module-level async functions, typed -# async errors, buffered async list-of-record returns); contacts covers the -# original struct/enum/optional surface. +# Run matrix: every language runs the events lane (a callback interface, a +# reference-counted object passed back through the callback, iterators), the +# kvstore lane (objects in records/lists/optionals, builders, async, an +# eviction callback interface), the async-demo lane (module-level async +# functions, typed async errors, buffered async list-of-record returns), the +# codec lane (every value-buffer wire shape round-tripped through the producer, +# including object tokens inside buffers), and shapes (rich enums); contacts +# covers the original struct/enum/optional surface. The inventory lanes (C and +# Python) cover the only multi-module sample: nested modules with cross-module +# type references and per-module error domains. # --------------------------------------------------------------------------- check c-contacts c_contacts check c-events c_events @@ -580,6 +617,19 @@ check dotnet-async-demo dotnet_async_demo check node-async-demo node_async_demo check kotlin-async-demo kotlin_async_demo check wasm-async-demo wasm_async_demo +check c-codec c_codec +check cpp-codec cpp_codec +check python-codec python_codec +check ruby-codec ruby_codec +check go-codec go_codec +check dart-codec dart_codec +check swift-codec swift_codec +check dotnet-codec dotnet_codec +check node-codec node_codec +check kotlin-codec kotlin_codec +check wasm-codec wasm_codec +check c-inventory c_inventory +check python-inventory python_inventory echo echo "conformance: $PASS passed, $FAIL failed" diff --git a/conformance/swift/codec.swift b/conformance/swift/codec.swift new file mode 100644 index 00000000..f9b8924f --- /dev/null +++ b/conformance/swift/codec.swift @@ -0,0 +1,355 @@ +// Conformance consumer: codec sample, Swift target. +// +// Binds through the generated `Codec` module and checks the value-buffer +// codec the Swift backend ships in both directions. `sample*` fixtures the +// producer encodes are decoded and checked field by field against the +// canonical values in `samples/codec/src/lib.rs`; `verify*` sends them back +// (the producer decodes what Swift encoded and compares against its own +// canonical value); `roundtrip*` returns the argument unchanged so consumer +// encoder and decoder are checked against each other, including a `Scalars` +// and a `Composite` built from scratch with edge values (empty strings, lists, +// and maps; unicode; Int64/UInt64 extremes; NaN, infinities, and negative +// zero). `Shape` covers every rich-enum variant, and `Holder` covers object +// tokens inside buffers: a field, an optional, and a list, each carrying one +// strong reference that the wrapper's deinit releases. Exits non-zero on any +// mismatch. + +import Foundation +import Codec + +func fail(_ msg: String) -> Never { + FileHandle.standardError.write(Data("assertion failed: \(msg)\n".utf8)) + exit(1) +} + +func expect(_ cond: Bool, _ msg: String) { + if !cond { fail(msg) } +} + +// --- Equality helpers (the generated structs and rich enum are not Equatable). + +func scalarsEqual(_ a: Scalars, _ b: Scalars) -> Bool { + a.i8Value == b.i8Value && a.u8Value == b.u8Value + && a.i16Value == b.i16Value && a.u16Value == b.u16Value + && a.i32Value == b.i32Value && a.u32Value == b.u32Value + && a.i64Value == b.i64Value && a.u64Value == b.u64Value + && a.f32Value.bitPattern == b.f32Value.bitPattern + && a.f64Value.bitPattern == b.f64Value.bitPattern + && a.flag == b.flag && a.color == b.color +} + +func shapeEqual(_ a: Shape, _ b: Shape) -> Bool { + switch (a, b) { + case (.empty, .empty): + return true + case let (.circle(r1), .circle(r2)): + return r1.bitPattern == r2.bitPattern + case let (.rect(w1, h1), .rect(w2, h2)): + return w1.bitPattern == w2.bitPattern && h1.bitPattern == h2.bitPattern + case let (.labeled(l1, c1), .labeled(l2, c2)): + return l1 == l2 && c1 == c2 + case let (.nested(i1, n1), .nested(i2, n2)): + return scalarsEqual(i1, i2) && n1 == n2 + default: + return false + } +} + +func shapesEqual(_ a: [Shape], _ b: [Shape]) -> Bool { + a.count == b.count && zip(a, b).allSatisfy { shapeEqual($0, $1) } +} + +func optionalShapeEqual(_ a: Shape?, _ b: Shape?) -> Bool { + switch (a, b) { + case (nil, nil): return true + case let (x?, y?): return shapeEqual(x, y) + default: return false + } +} + +func compositeEqual(_ a: Composite, _ b: Composite) -> Bool { + guard a.name == b.name, a.blob == b.blob, a.someI64 == b.someI64, a.noneI64 == b.noneI64, + a.someText == b.someText, a.names == b.names, a.matrix == b.matrix, + a.empty.map({ $0.bitPattern }) == b.empty.map({ $0.bitPattern }), + a.byName == b.byName, a.byId.count == b.byId.count, + scalarsEqual(a.scalars, b.scalars), shapeEqual(a.shape, b.shape), + shapesEqual(a.shapes, b.shapes), optionalShapeEqual(a.maybeShape, b.maybeShape), + a.maybeList == b.maybeList, a.sparse == b.sparse, a.colors == b.colors + else { return false } + for (k, v) in a.byId { + guard let w = b.byId[k], scalarsEqual(v, w) else { return false } + } + return true +} + +// --- Scalars ------------------------------------------------------------------ + +let canonical = Scalars( + i8Value: -8, u8Value: 200, i16Value: -16_000, u16Value: 60_000, + i32Value: -2_000_000_000, u32Value: 4_000_000_000, + i64Value: -9_007_199_254_740_993, u64Value: UInt64.max, + f32Value: 1.5, f64Value: -2.25e100, flag: true, color: .blue) + +let scalars = Codec.sampleScalars() +expect(scalars.i8Value == -8, "i8 (got \(scalars.i8Value))") +expect(scalars.u8Value == 200, "u8 (got \(scalars.u8Value))") +expect(scalars.i16Value == -16_000, "i16 (got \(scalars.i16Value))") +expect(scalars.u16Value == 60_000, "u16 (got \(scalars.u16Value))") +expect(scalars.i32Value == -2_000_000_000, "i32 (got \(scalars.i32Value))") +expect(scalars.u32Value == 4_000_000_000, "u32 (got \(scalars.u32Value))") +expect(scalars.i64Value == -9_007_199_254_740_993, "i64 (got \(scalars.i64Value))") +expect(scalars.u64Value == UInt64.max, "u64 (got \(scalars.u64Value))") +expect(scalars.f32Value == 1.5, "f32 (got \(scalars.f32Value))") +expect(scalars.f64Value == -2.25e100, "f64 (got \(scalars.f64Value))") +expect(scalars.flag == true, "flag") +expect(scalars.color == .blue, "color (got \(scalars.color))") +expect(scalarsEqual(scalars, canonical), "sampleScalars matches the canonical fixture") + +do { + expect(try Codec.verifyScalars(value: scalars) == true, "producer accepts the re-encoded sample") + expect(try Codec.verifyScalars(value: canonical) == true, "producer accepts a locally built canonical") +} catch { + fail("verifyScalars threw: \(error)") +} +expect(scalarsEqual(Codec.roundtripScalars(value: scalars), scalars), "roundtripScalars is the identity") + +// A single changed field is a typed CodecError.mismatch (code 1). +var tweaked = canonical +tweaked.u64Value -= 1 +do { + _ = try Codec.verifyScalars(value: tweaked) + fail("expected CodecError.mismatch for a tweaked Scalars") +} catch let e as CodecError { + guard case let .mismatch(message) = e else { fail("expected .mismatch, got \(e)") } + expect(e.errorCode == 1, "mismatch code == 1 (got \(e.errorCode))") + expect(message == "value does not match the canonical fixture", "mismatch message (got \(message))") +} catch { + fail("expected CodecError, got \(error)") +} + +// Scalars built from scratch at the extremes, with non-finite floats and a +// negative zero, survive a round trip bit for bit. +let extremes = Scalars( + i8Value: Int8.min, u8Value: UInt8.max, i16Value: Int16.max, u16Value: UInt16.max, + i32Value: Int32.min, u32Value: UInt32.max, i64Value: Int64.min, u64Value: 1 << 63, + f32Value: -0.0, f64Value: Double.nan, flag: false, color: .red) +let extremesBack = Codec.roundtripScalars(value: extremes) +expect(extremesBack.i64Value == Int64.min && extremesBack.u64Value == 1 << 63, "64-bit extremes") +expect(extremesBack.f32Value == 0 && extremesBack.f32Value.sign == .minus, "negative zero f32 keeps its sign") +expect(extremesBack.f64Value.isNaN, "NaN f64 round-trips") +expect(scalarsEqual(extremesBack, extremes), "extreme Scalars round-trip bit for bit") +let infinities = Scalars( + i8Value: Int8.max, u8Value: 0, i16Value: Int16.min, u16Value: 0, i32Value: Int32.max, u32Value: 0, + i64Value: Int64.max, u64Value: 0, f32Value: Float.infinity, f64Value: -Double.infinity, + flag: true, color: .green) +expect(scalarsEqual(Codec.roundtripScalars(value: infinities), infinities), "infinities round-trip") + +// --- Composite ---------------------------------------------------------------- + +let composite = Codec.sampleComposite() +expect(composite.name == "héllo wörld ✓", "name (got \(composite.name))") +expect(composite.blob == Data([0, 1, 2, 253, 254, 255]), "blob (got \(Array(composite.blob)))") +expect(composite.someI64 == Int64.min, "someI64 (got \(String(describing: composite.someI64)))") +expect(composite.noneI64 == nil, "noneI64 nil") +expect(composite.someText == "", "someText is a present empty string (got \(String(describing: composite.someText)))") +expect(composite.names == ["a", "", "ccc"], "names (got \(composite.names))") +expect(composite.matrix == [[1, 2, 3], [], [-4]], "matrix (got \(composite.matrix))") +expect(composite.empty.isEmpty, "empty list") +expect(composite.byName == ["one": 1, "two": 2, "neg": -3], "byName (got \(composite.byName))") +expect(composite.byId.count == 2, "byId has two entries (got \(composite.byId.count))") +expect(composite.byId[-1].map { scalarsEqual($0, canonical) } == true, "byId[-1] is the canonical Scalars") +expect(composite.byId[42]?.flag == false, "byId[42] has flag false") +var flagless = canonical +flagless.flag = false +expect(composite.byId[42].map { scalarsEqual($0, flagless) } == true, "byId[42] otherwise canonical") +expect(scalarsEqual(composite.scalars, canonical), "nested scalars") +expect(shapeEqual(composite.shape, .labeled(label: "tag", count: 3)), "shape (got \(composite.shape))") +expect(composite.shapes.count == 5, "five shapes (got \(composite.shapes.count))") +expect(shapesEqual(composite.shapes, [ + .empty, + .circle(radius: 2.5), + .rect(width: 1.0, height: 0.5), + .labeled(label: "", count: -1), + .nested(inner: canonical, note: "n"), +]), "shapes one of each variant (got \(composite.shapes))") +expect(optionalShapeEqual(composite.maybeShape, .nested(inner: canonical, note: nil)), + "maybeShape is a nested variant with an absent note (got \(String(describing: composite.maybeShape)))") +expect(composite.maybeList == Data([9, 8]), "maybeList (got \(String(describing: composite.maybeList)))") +expect(composite.sparse == [true, nil, false], "sparse (got \(composite.sparse))") +expect(composite.colors == [.red, .green, .blue], "colors (got \(composite.colors))") + +do { + expect(try Codec.verifyComposite(value: composite) == true, "producer accepts the re-encoded composite") +} catch { + fail("verifyComposite threw: \(error); producer saw: \(Codec.describeComposite(value: composite))") +} +expect(compositeEqual(Codec.roundtripComposite(value: composite), composite), "roundtripComposite is the identity") +let described = Codec.describeComposite(value: composite) +expect(described.hasPrefix("Composite {") && described.contains("héllo wörld ✓"), "describeComposite (got \(described))") + +// A changed nested value is rejected. +var changed = composite +changed.sparse[1] = true +do { + _ = try Codec.verifyComposite(value: changed) + fail("expected CodecError.mismatch for a changed composite") +} catch let e as CodecError { + expect(e.errorCode == 1, "composite mismatch code == 1") +} catch { + fail("expected CodecError, got \(error)") +} + +// A Composite built from scratch with edge values: empty text, bytes, lists, +// and maps; unicode beyond the BMP; present-but-empty optionals; and +// non-finite, negative-zero, and subnormal floats inside nested positions. +let homemade = Composite( + name: "", + blob: Data(), + someI64: Int64.max, + noneI64: -1, + someText: nil, + names: ["日本語", "🚀 rocket", "", "\u{0}nul"], + matrix: [[], [Int32.min, Int32.max], []], + empty: [Double.nan, -0.0, Double.infinity, -Double.infinity, Double.leastNonzeroMagnitude], + byName: [:], + byId: [Int32.min: extremes, 0: canonical, Int32.max: infinities], + scalars: extremes, + shape: .empty, + shapes: [], + maybeShape: nil, + maybeList: Data(), + sparse: [nil, nil, true], + colors: [.blue, .blue, .red, .green]) +let homemadeBack = Codec.roundtripComposite(value: homemade) +expect(homemadeBack.name.isEmpty && homemadeBack.blob.isEmpty, "empty string and bytes") +expect(homemadeBack.someText == nil, "absent optional string") +expect(homemadeBack.maybeList != nil && homemadeBack.maybeList!.isEmpty, "present empty optional bytes") +expect(homemadeBack.names == homemade.names, "unicode names (got \(homemadeBack.names))") +expect(homemadeBack.matrix == [[], [Int32.min, Int32.max], []], "matrix with empty rows") +expect(homemadeBack.empty[0].isNaN, "NaN inside a list") +expect(homemadeBack.empty[1] == 0 && homemadeBack.empty[1].sign == .minus, "negative zero inside a list") +expect(homemadeBack.empty[2] == Double.infinity && homemadeBack.empty[3] == -Double.infinity, "infinities inside a list") +expect(homemadeBack.empty[4] == Double.leastNonzeroMagnitude, "subnormal double inside a list") +expect(homemadeBack.byName.isEmpty && homemadeBack.byId.count == 3, "empty map, three-entry map") +expect(homemadeBack.byId[Int32.min].map { scalarsEqual($0, extremes) } == true, "Int32.min key") +expect(homemadeBack.shapes.isEmpty && homemadeBack.maybeShape == nil, "empty shapes, absent maybeShape") +expect(compositeEqual(homemadeBack, homemade), "homemade Composite round-trips") +expect(Codec.describeComposite(value: homemade).contains("日本語"), "describe renders unicode") +do { + _ = try Codec.verifyComposite(value: homemade) + fail("homemade composite is not the canonical one") +} catch let e as CodecError { + expect(e.errorCode == 1, "homemade composite mismatch code == 1") +} catch { + fail("expected CodecError, got \(error)") +} + +// --- Shape (rich enum) --------------------------------------------------------- + +let allShapes: [Shape] = [ + .empty, + .circle(radius: -0.0), + .rect(width: Float.nan, height: -Float.infinity), + .labeled(label: "ünïcödé ✓", count: Int32.min), + .nested(inner: extremes, note: ""), + .nested(inner: canonical, note: nil), +] +for s in allShapes { + expect(shapeEqual(Codec.roundtripShape(value: s), s), "roundtripShape identity for \(s)") +} +expect(shapesEqual(Codec.roundtripShapes(value: allShapes), allShapes), "roundtripShapes identity") +expect(Codec.roundtripShapes(value: []).isEmpty, "roundtripShapes of an empty list") +expect(Codec.describeShape(value: .empty) == "Empty", "describe Empty") +expect(Codec.describeShape(value: .circle(radius: 2.5)) == "Circle { radius: 2.5 }", "describe Circle") +expect(Codec.describeShape(value: .rect(width: 1.0, height: 0.5)) == "Rect { width: 1.0, height: 0.5 }", + "describe Rect") +expect(Codec.describeShape(value: .labeled(label: "tag", count: 3)) == "Labeled { label: \"tag\", count: 3 }", + "describe Labeled") +expect(Codec.describeShape(value: .nested(inner: canonical, note: nil)).hasPrefix("Nested { inner: Scalars {"), + "describe Nested") + +// --- Direct, string, bytes, and standalone buffered families -------------------- + +expect(Codec.roundtripOptI64(value: nil) == nil, "optional i64 absent") +expect(Codec.roundtripOptI64(value: Int64.min) == Int64.min, "optional i64 present") +expect(Codec.roundtripOptI64(value: 0) == 0, "optional i64 zero is present, not absent") +expect(Codec.roundtripMap(value: [:]).isEmpty, "empty map") +let bigMap: [String: Int64] = ["": 0, "a": Int64.max, "ключ": Int64.min, "z": -1] +expect(Codec.roundtripMap(value: bigMap) == bigMap, "map with empty and unicode keys") +expect(Codec.roundtripString(value: "") == "", "empty string") +expect(Codec.roundtripString(value: "héllo wörld ✓ 🚀") == "héllo wörld ✓ 🚀", "unicode string") +expect(Codec.roundtripBytes(value: Data()).isEmpty, "empty bytes") +let allBytes = Data((0...255).map { UInt8($0) }) +expect(Codec.roundtripBytes(value: allBytes) == allBytes, "every byte value") +expect(Codec.roundtripI64(value: Int64.min) == Int64.min, "i64 min") +expect(Codec.roundtripI64(value: Int64.max) == Int64.max, "i64 max") +expect(Codec.roundtripI64(value: -1) == -1, "i64 -1") +expect(Codec.roundtripU64(value: UInt64.max) == UInt64.max, "u64 max") +expect(Codec.roundtripU64(value: 1 << 63) == 1 << 63, "u64 above 2^63") +expect(Codec.roundtripU64(value: 0) == 0, "u64 zero") +expect(Codec.roundtripF64(value: Double.nan).isNaN, "f64 NaN") +expect(Codec.roundtripF64(value: Double.infinity) == Double.infinity, "f64 +inf") +expect(Codec.roundtripF64(value: -Double.infinity) == -Double.infinity, "f64 -inf") +let negZero = Codec.roundtripF64(value: -0.0) +expect(negZero == 0 && negZero.sign == .minus, "f64 negative zero keeps its sign") +expect(Codec.roundtripF64(value: Double.leastNonzeroMagnitude) == Double.leastNonzeroMagnitude, "f64 subnormal") +expect(Codec.roundtripF64(value: -2.25e100) == -2.25e100, "f64 large negative") +expect(Codec.roundtripBool(value: true) == true && Codec.roundtripBool(value: false) == false, "bool") +expect(Codec.roundtripColor(value: .blue) == .blue, "color blue (discriminant 7)") +expect(Codec.roundtripColor(value: .red) == .red, "color red") + +// --- Holder: objects inside buffers ------------------------------------------- + +let holder = Codec.makeHolder(base: 10, withSpare: true) +expect(holder.primary.value() == 10, "primary token (got \(holder.primary.value()))") +expect(holder.spare?.value() == 11, "spare token (got \(String(describing: holder.spare?.value())))") +expect(holder.many.map { $0.value() } == [12, 13, 14], "many tokens (got \(holder.many.map { $0.value() }))") +// Encoding clones every token; the producer adopts and drops those clones, +// and the wrappers remain valid for the next call. +expect(Codec.sumHolder(holder: holder) == 60, "sumHolder (got \(Codec.sumHolder(holder: holder)))") +expect(Codec.sumHolder(holder: holder) == 60, "sumHolder again: no reference was consumed") +expect(holder.primary.value() == 10 && holder.many[2].value() == 14, "tokens still alive after encoding") + +// primaryOf returns the very object stored in holder.primary. +let primary = Codec.primaryOf(holder: holder) +expect(primary.value() == 10, "primaryOf value") +let rebuilt = Holder(primary: primary, spare: nil, many: []) +expect(Codec.samePrimary(a: holder, b: rebuilt), "primaryOf returned the same object as holder.primary") +expect(Codec.samePrimary(a: holder, b: holder), "a holder shares its own primary") +expect(!Codec.samePrimary(a: holder, b: Codec.makeHolder(base: 10, withSpare: true)), + "a fresh holder with equal values is a different object") +expect(Codec.sumHolder(holder: rebuilt) == 10, "sumHolder over a consumer-built holder") + +let bare = Codec.makeHolder(base: 0, withSpare: false) +expect(bare.spare == nil, "withSpare false yields no spare") +expect(bare.primary.value() == 0 && bare.many.map { $0.value() } == [2, 3, 4], "bare tokens") +expect(Codec.sumHolder(holder: bare) == 9, "sumHolder without spare (got \(Codec.sumHolder(holder: bare)))") + +// Consumer-created tokens, including one object referenced twice from the +// same buffer (two clones, two adoptions). +let shared = Token(value: 100) +let mine = Holder(primary: shared, spare: Token(value: 5), many: [shared, Token(value: 1), shared]) +expect(Codec.sumHolder(holder: mine) == 100 + 5 + 100 + 1 + 100, "sumHolder over shared tokens") +expect(Codec.samePrimary(a: mine, b: Holder(primary: shared, spare: nil, many: [])), "shared primary identity") +expect(shared.value() == 100, "shared token alive after three encodings") +expect(Codec.sumHolder(holder: Holder(primary: Token(value: -7), spare: nil, many: [])) == -7, "minimal holder") + +// Release: every wrapper's deinit releases its strong reference, including +// the ones adopted from a decoded buffer and the one returned by primaryOf. +weak var weakPrimary: Token? +weak var weakSpare: Token? +weak var weakMany: Token? +weak var weakReturned: Token? +do { + let h = Codec.makeHolder(base: 20, withSpare: true) + weakPrimary = h.primary + weakSpare = h.spare + weakMany = h.many[0] + let p = Codec.primaryOf(holder: h) + weakReturned = p + expect(p.value() == 20 && Codec.sumHolder(holder: h) == 20 + 21 + 22 + 23 + 24, "scoped holder usable") +} +expect(weakPrimary == nil && weakSpare == nil && weakMany == nil && weakReturned == nil, + "every token wrapper was deinitialized when the holder left scope") + +print("swift/codec: OK") diff --git a/conformance/swift/events.swift b/conformance/swift/events.swift index df5d84e4..6f583350 100644 --- a/conformance/swift/events.swift +++ b/conformance/swift/events.swift @@ -1,11 +1,19 @@ // Conformance consumer: events sample, Swift target. // -// Exercises the context-boxed listener trampoline (register pins the closure -// via Unmanaged, the producer fires it synchronously on send, unregister -// releases it and stops delivery) and the opaque-iterator ABI behind -// getMessages. The events module declares no error domain and no function -// throws, so every call is made without `try` (failures would trap). Exits -// non-zero on any mismatch. +// Binds through the generated `Events` module and drives the ABI 2 surface: +// the `Subscriber` callback interface implemented as a Swift class (every +// method observed with its arguments, including the `EventBus` object the +// producer hands to `onAttached`, which is usable and released), `Delivery` +// return values steering `publish`'s accepted count, the reference-counted +// `EventBus` object, the `publishLater` async wrapper, `messages()` as a lazy +// Sequence, the `lastMessage()` optional record, and the free function +// `routeOnce`. Release is observed through weak references: a subscriber's +// box is freed when the bus drops it, and the bus wrapper handed to a callback +// is destroyed when the consumer lets go of it. The events module declares no +// error domain, so every wrapper is non-throwing and traps on a failure +// (including a subscriber that throws, which the producer reports as code +// -4); the throwing-callback path is exercised in the kvstore lane, whose +// wrappers do throw. Exits non-zero on any mismatch. import Foundation import Events @@ -19,36 +27,179 @@ func expect(_ cond: Bool, _ msg: String) { if !cond { fail(msg) } } -final class Recorder { - var received: [String] = [] +/// A consumer-side subscriber. Records every callback so the test can assert +/// the arguments the producer passed, and counts live instances so the +/// producer's `free` (which releases the generated box, and with it the last +/// strong reference to this object) is observable. +final class TestSubscriber: Subscriber { + static var live = 0 + + let name: String + let skipTopic: String + let keepBus: Bool + var routed: [String] = [] + var received: [Message] = [] + var attachedCount = 0 + /// Subscriber count observed through the bus object inside `onAttached`. + var countSeenOnAttach: Int64 = -1 + /// The bus adopted from `onAttached` when `keepBus` is set. + var attachedBus: EventBus? + /// A weak view of the bus wrapper handed to `onAttached`, to prove the + /// wrapper (and its strong reference) was released when not kept. + weak var weakAttachedBus: EventBus? + + init(name: String, skipTopic: String = "", keepBus: Bool = false) { + self.name = name + self.skipTopic = skipTopic + self.keepBus = keepBus + TestSubscriber.live += 1 + } + + deinit { TestSubscriber.live -= 1 } + + func route(topic: String) throws -> Delivery { + routed.append(topic) + if topic == skipTopic { return .skip } + if topic == "stop" { return .acceptAndStop } + return .accept + } + + func onMessage(message: Message) throws -> Int64 { + received.append(message) + return Int64(received.count) + } + + func onAttached(bus: EventBus) throws { + attachedCount += 1 + // The object is live and usable inside the callback: `subscribe` + // attaches before it pushes, so the count excludes this subscriber. + countSeenOnAttach = bus.subscriberCount() + weakAttachedBus = bus + if keepBus { attachedBus = bus } + } } -let recorder = Recorder() -let sub = Events.registerMessageListener { message in - recorder.received.append(message) +// --- Empty bus --------------------------------------------------------------- +var bus: EventBus? = EventBus() +expect(bus!.subscriberCount() == 0, "new bus has no subscribers") +expect(bus!.lastMessage() == nil, "new bus has no last message") +expect(Array(bus!.messages()).isEmpty, "new bus has no messages") + +// --- subscribe: onAttached receives a usable bus object ---------------------- +var a: TestSubscriber? = TestSubscriber(name: "a", skipTopic: "quiet") +var b: TestSubscriber? = TestSubscriber(name: "b", keepBus: true) +weak var weakA = a +weak var weakB = b + +expect(bus!.subscribe(subscriber: a!) == 1, "first subscribe returns 1") +expect(a!.attachedCount == 1, "a attached once") +expect(a!.countSeenOnAttach == 0, "a saw an empty bus on attach (got \(a!.countSeenOnAttach))") +expect(a!.weakAttachedBus == nil, "bus wrapper handed to a was released after onAttached") +expect(bus!.subscriberCount() == 1, "bus still alive after callback released its reference") + +expect(bus!.subscribe(subscriber: b!) == 2, "second subscribe returns 2") +expect(b!.attachedCount == 1, "b attached once") +expect(b!.countSeenOnAttach == 1, "b saw one subscriber on attach (got \(b!.countSeenOnAttach))") +expect(b!.attachedBus != nil && b!.weakAttachedBus != nil, "b kept the adopted bus") +expect(bus!.subscriberCount() == 2, "subscriberCount == 2") + +// --- publish: Delivery steers the accepted count ----------------------------- +expect(bus!.publish(topic: "news", text: "hello", tags: ["x", "y"]) == 2, + "both subscribers accept 'news'") +expect(a!.routed == ["news"] && b!.routed == ["news"], "route asked once per subscriber") +expect(a!.received.count == 1 && b!.received.count == 1, "one delivery each") +let first = a!.received[0] +expect(first.seq == 1, "seq starts at 1 (got \(first.seq))") +expect(first.topic == "news", "topic (got \(first.topic))") +expect(first.text == "hello", "text (got \(first.text))") +expect(first.tags == ["x", "y"], "tags (got \(first.tags))") + +// a skips 'quiet'; b accepts it. +expect(bus!.publish(topic: "quiet", text: "psst", tags: []) == 1, "skip lowers the accepted count") +expect(a!.routed == ["news", "quiet"], "a was asked about 'quiet' (got \(a!.routed))") +expect(a!.received.count == 1, "a did not receive the skipped message") +expect(b!.received.count == 2 && b!.received[1].seq == 2 && b!.received[1].tags.isEmpty, + "b received the second message with empty tags") + +// AcceptAndStop from the first subscriber stops delivery to the rest. +let c = TestSubscriber(name: "c") +expect(bus!.subscribe(subscriber: c) == 3, "third subscribe returns 3") +expect(bus!.publish(topic: "stop", text: "last", tags: ["z"]) == 1, "acceptAndStop delivers once") +expect(a!.received.count == 2 && a!.received[1].text == "last", "a took the stop message") +expect(b!.routed == ["news", "quiet"], "b was not asked after the stop (got \(b!.routed))") +expect(c.routed.isEmpty && c.received.isEmpty, "c was never reached") + +// --- messages(): iterator as a lazy Sequence --------------------------------- +let texts = Array(bus!.messages()) +expect(texts == ["hello", "psst", "last"], "messages in order (got \(texts))") +var joined: [String] = [] +for m in bus!.messages() { joined.append(m.uppercased()) } +expect(joined == ["HELLO", "PSST", "LAST"], "for-in over the sequence") +expect(bus!.messages().map { $0.count } == [5, 4, 4], "map over the sequence") +// Abandoning early releases the producer iterator through deinit. +expect(bus!.messages().first(where: { $0.hasPrefix("p") }) == "psst", "early exit") + +// --- lastMessage(): optional record ------------------------------------------ +let last = bus!.lastMessage() +expect(last != nil, "lastMessage present") +expect(last!.seq == 3 && last!.topic == "stop" && last!.text == "last" && last!.tags == ["z"], + "lastMessage fields (got \(last!))") + +// --- publishLater: async wrapper awaited from the producer's thread --------- +let later = await bus!.publishLater(topic: "news", text: "later") +expect(later == 3, "publishLater accepted by all three (got \(later))") +expect(bus!.lastMessage()?.text == "later", "async publish logged") +expect(bus!.lastMessage()?.seq == 4, "async publish took seq 4") +expect(c.received.count == 1 && c.received[0].tags.isEmpty, "c received the async message") + +// --- The bus adopted in onAttached is the same object -------------------------- +let kept = b!.attachedBus! +expect(kept.subscriberCount() == 3, "kept bus sees all three subscribers") +expect(kept.publish(topic: "via-kept", text: "same object", tags: []) == 3, + "publish through the kept wrapper reaches every subscriber") +expect(bus!.lastMessage()?.topic == "via-kept", "publish via kept wrapper visible through the original") +expect(Array(bus!.messages()).count == 5, "five messages logged") + +// --- routeOnce: a callback passed to a free function is freed on return ------- +weak var weakD: TestSubscriber? +do { + let d = TestSubscriber(name: "d", skipTopic: "quiet") + weakD = d + expect(Events.routeOnce(subscriber: d, topic: "quiet") == .skip, "routeOnce skip") + expect(Events.routeOnce(subscriber: d, topic: "stop") == .acceptAndStop, "routeOnce acceptAndStop") + expect(Events.routeOnce(subscriber: d, topic: "other") == .accept, "routeOnce accept") + expect(d.routed == ["quiet", "stop", "other"], "routeOnce asked route each time") + expect(d.attachedCount == 0, "routeOnce never attaches") } -expect(sub > 0, "listener id positive") - -Events.sendMessage(text: "alpha") -Events.sendMessage(text: "beta") -expect(recorder.received == ["alpha", "beta"], - "listener received sends (got \(recorder.received))") - -// getMessages returns a lazy single-pass Sequence: one producer `next` per -// consumer step, handle destroyed on exhaustion (or by deinit if abandoned). -let msgs = Array(Events.getMessages()) -expect(msgs == ["alpha", "beta"], - "iterator yields messages in order (got \(msgs))") - -// Unregister stops delivery; the producer still records the message. -Events.unregisterMessageListener(sub) -Events.sendMessage(text: "gamma") -expect(recorder.received == ["alpha", "beta"], - "no delivery after unregister (got \(recorder.received))") -expect(Array(Events.getMessages()).count == 3, "producer kept recording") - -// Abandoning the sequence early releases the handle through deinit. -let firstOnly = Events.getMessages().first(where: { _ in true }) -expect(firstOnly == "alpha", "early stop yields only the first message") +expect(weakD == nil, "routeOnce released its subscriber (free ran)") + +// --- clearSubscribers releases every subscriber ------------------------------ +a = nil +b = nil +expect(weakA != nil && weakB != nil, "the bus retains a and b after the consumer drops them") +bus!.clearSubscribers() +expect(bus!.subscriberCount() == 0, "no subscribers after clear") +expect(weakA == nil, "a freed by clearSubscribers") +expect(weakB == nil, "b (and the bus reference it kept) freed by clearSubscribers") +expect(bus!.lastMessage()?.topic == "via-kept", "bus still alive after b's kept reference was released") +expect(bus!.publish(topic: "news", text: "nobody", tags: []) == 0, "publish with no subscribers") +expect(c.received.count == 2, "c is no longer delivered to (got \(c.received.count))") + +// --- Destroying the bus frees a retained subscriber -------------------------- +weak var weakE: TestSubscriber? +weak var weakBus2: EventBus? +do { + let bus2 = EventBus() + weakBus2 = bus2 + let e = TestSubscriber(name: "e") + weakE = e + expect(bus2.subscribe(subscriber: e) == 1, "bus2 subscribe") + expect(bus2.publish(topic: "t", text: "m", tags: []) == 1, "bus2 publish") +} +expect(weakBus2 == nil, "bus2 wrapper deinitialized") +expect(weakE == nil, "dropping the last bus reference freed its subscriber") + +bus = nil +expect(TestSubscriber.live == 1, "only c is still alive (got \(TestSubscriber.live))") print("swift/events: OK") diff --git a/conformance/swift/kvstore.swift b/conformance/swift/kvstore.swift index 3102ec72..d5a73e19 100644 --- a/conformance/swift/kvstore.swift +++ b/conformance/swift/kvstore.swift @@ -1,18 +1,23 @@ // Conformance consumer: kvstore sample, Swift target. // -// Binds through the generated `Kvstore` module and exercises the 0.7.0 -// interface surface: `Store` as a final class opened via the throwing static -// factory `Store.open(path:)`, throwing methods raising the typed `KvError` -// domain enum (put/get/delete/listKeys), non-throwing methods without `try` -// (count/clear), the static `Store.defaultCapacity()`, the buffered optional -// parameter (`ttlSeconds`), `Entry` as a plain struct decoded from a value -// buffer whose optional, list, and map fields land as Swift `Int64?`, -// `[String]`, and `[String: String]` stored properties, the iterator lowering -// behind `listKeys`, the nested `Kv.Stats` submodule, the context-boxed -// eviction listener, and the CheckedContinuation-backed `compact()` async -// method (top-level await; resumed from the producer's worker thread). -// Typed-error asserts pin the case and the numeric code carried by -// `errorCode` (keyNotFound 1001, expired 1002, ioError 1004). +// Binds through the generated `Kvstore` module and exercises the ABI 2 +// surface: `Store` as a reference-counted final class opened via the throwing +// static factory `Store.open(path:)`, throwing methods raising the typed +// `KvError` domain enum (put/get/delete/listKeys), non-throwing methods +// without `try` (count/clear), the buffered optional parameter (`ttlSeconds`), +// `Entry` decoded from a value buffer, the `listKeys` iterator as a lazy +// Sequence, the nested `Kv.Stats` submodule, the CheckedContinuation-backed +// `compact()` async method, the `EvictionListener` callback interface +// implemented as a Swift class (arguments observed, detach by returning +// false, replace/clear releasing the previous listener, a thrown error +// surfacing to the caller as `WeaveFFIError` with code -4), and the object +// graph: `share()` returning a wrapper to the same object, `fork()`, +// `larger(other:)` with `Store?` both ways, `describe(label:mirror:)` +// returning a record that carries objects, `openMany` returning a list of +// objects, and `totalCount` taking objects inside a list and a record. +// Release is observed through weak references to the wrappers, whose deinit +// calls the generated destroy. Typed-error asserts pin the case and the +// numeric code (keyNotFound 1001, expired 1002, ioError 1004). import Foundation import Kvstore @@ -26,6 +31,33 @@ func expect(_ cond: Bool, _ msg: String) { if !cond { fail(msg) } } +struct ListenerFailure: Error, LocalizedError { + let key: String + var errorDescription: String? { "listener rejected \(key)" } +} + +/// A consumer-side eviction listener. Records every (key, reason) pair, +/// detaches itself by returning false once `keepAfter` evictions were seen, +/// and throws for `failKey` so the foreign-error path can be observed. +final class Listener: EvictionListener { + var evicted: [(key: String, reason: EvictionReason)] = [] + var lastEntry: Entry? + let keepAfter: Int + let failKey: String? + + init(keepAfter: Int = Int.max, failKey: String? = nil) { + self.keepAfter = keepAfter + self.failKey = failKey + } + + func onEvict(entry: Entry, reason: EvictionReason) throws -> Bool { + if entry.key == failKey { throw ListenerFailure(key: entry.key) } + evicted.append((entry.key, reason)) + lastEntry = entry + return evicted.count < keepAfter + } +} + do { let store = try Store.open(path: "/tmp/conformance-kvstore-swift") @@ -43,6 +75,14 @@ do { expect(fetched?.expiresAt == nil, "get alpha expiresAt nil") expect(fetched?.tags == [], "get alpha tags empty") expect(fetched?.metadata == [:], "get alpha metadata empty") + expect(fetched?.id == 1, "first entry id is 1 (got \(fetched?.id ?? -1))") + + // A TTL round-trips through the buffered optional in both directions. + _ = try store.put(key: "timed", value: payload, kind: .volatile, ttlSeconds: 3600) + let timed = try store.get(key: "timed") + expect(timed?.expiresAt != nil && timed!.expiresAt! - timed!.createdAt == 3600, + "ttl produces expiresAt = createdAt + 3600") + expect(try store.delete(key: "timed"), "delete timed") // Iterator lowering: a lazy single-pass Sequence pulled one element per // step, drained here into [String] (the BTreeMap's sorted order). A @@ -52,14 +92,19 @@ do { expect(keysIter.error == nil, "listKeys iteration error-free") expect(keys == ["alpha", "beta"], "listKeys sorted (got \(keys))") expect(Array(try store.listKeys(prefix: "al")) == ["alpha"], "listKeys prefix filter") + var seen: [String] = [] + for k in try store.listKeys(prefix: nil) { seen.append(k) } + expect(seen == ["alpha", "beta"], "for-in over listKeys") + expect(try store.listKeys(prefix: "zzz").next() == nil, "empty prefix match yields nothing") // A missing key raises the typed domain error's keyNotFound case (1001). do { _ = try store.get(key: "missing") fail("expected KvError.keyNotFound for missing key") } catch let e as KvError { - guard case .keyNotFound = e else { fail("expected .keyNotFound, got \(e)") } + guard case let .keyNotFound(message) = e else { fail("expected .keyNotFound, got \(e)") } expect(e.errorCode == 1001, "keyNotFound code == 1001 (got \(e.errorCode))") + expect(message == "key not found", "keyNotFound message (got \(message))") } // The memberwise init builds a local Entry value with its list and map @@ -68,22 +113,9 @@ do { id: 7, key: "alpha", value: payload, createdAt: 1000, expiresAt: nil, tags: ["hot", "fast"], metadata: ["source": "test", "env": "prod"]) expect(entry.id == 7, "entry id") - - // List field. - let tags = entry.tags - expect(tags.count == 2 && tags.contains("hot") && tags.contains("fast"), "tags") - - // Map field. - let md = entry.metadata - expect(md.count == 2, "metadata count") - expect(md["source"] == "test", "metadata source") - expect(md["env"] == "prod", "metadata env") - - // Empty collections stay empty. - let bare = Entry( - id: 8, key: "k", value: payload, createdAt: 1, expiresAt: nil, - tags: [], metadata: [:]) - expect(bare.metadata.isEmpty, "empty metadata") + expect(entry.tags == ["hot", "fast"], "tags") + expect(entry.metadata.count == 2 && entry.metadata["source"] == "test" && entry.metadata["env"] == "prod", + "metadata") // Nested Kv.Stats submodule (name collides with the module-level Stats // struct), passing the Store interface across the module boundary and @@ -91,23 +123,23 @@ do { let stats = try Kv.Stats.getStats(store: store) expect(stats.totalEntries == 2, "stats totalEntries") expect(stats.totalBytes == 6, "stats totalBytes") + expect(stats.expiredEntries == 0, "stats expiredEntries") - // Eviction listener: delete fires the context-boxed trampoline - // synchronously on the calling thread. - final class Recorder { var evicted: [String] = [] } - let recorder = Recorder() - let sub = Kv.registerEvictionListener { key in recorder.evicted.append(key) } - expect(sub > 0, "listener id positive") + // --- EvictionListener callback interface --------------------------------- + var listener: Listener? = Listener() + weak let weakListener = listener + store.setEvictionListener(listener: listener!) expect(try store.delete(key: "beta"), "delete beta") - expect(recorder.evicted == ["beta"], "eviction fired for beta (got \(recorder.evicted))") - - // Unregister stops delivery. - Kv.unregisterEvictionListener(sub) - expect(try store.delete(key: "alpha"), "delete alpha") - expect(recorder.evicted == ["beta"], "no eviction after unregister (got \(recorder.evicted))") + expect(listener!.evicted.count == 1, "one eviction observed") + expect(listener!.evicted[0].key == "beta" && listener!.evicted[0].reason == .deleted, + "delete evicts beta with reason .deleted (got \(listener!.evicted))") + expect(listener!.lastEntry?.value == payload && listener!.lastEntry?.id == 2, + "the evicted Entry carried its payload and id") + expect(!(try store.delete(key: "beta")), "second delete reports absent") + expect(listener!.evicted.count == 1, "absent delete does not notify") // TTL expiry: reading an already-expired entry raises the expired case - // (1002) and evicts it (silently; the listener is unregistered). + // (1002) and evicts it with reason .expired. _ = try store.put(key: "ttl", value: payload, kind: .volatile, ttlSeconds: -1) do { _ = try store.get(key: "ttl") @@ -116,19 +148,179 @@ do { guard case .expired = e else { fail("expected .expired, got \(e)") } expect(e.errorCode == 1002, "expired code == 1002 (got \(e.errorCode))") } - expect(recorder.evicted == ["beta"], "expiry eviction not delivered after unregister") + expect(listener!.evicted.count == 2 && listener!.evicted[1].key == "ttl" + && listener!.evicted[1].reason == .expired, + "expiry evicts ttl with reason .expired (got \(listener!.evicted))") + expect(store.count() == 1, "expired entry left the store") + + // The store retains the listener after the consumer drops it, and + // releases it (running the generated `free`) when it is replaced. + listener = nil + expect(weakListener != nil, "store retains the listener") + var second: Listener? = Listener(keepAfter: 2) + weak let weakSecond = second + store.setEvictionListener(listener: second!) + expect(weakListener == nil, "replaced listener is freed") + + // Returning false detaches (and frees) the listener; later evictions are + // not observed. + _ = try store.put(key: "one", value: payload, kind: .volatile, ttlSeconds: nil) + _ = try store.put(key: "two", value: payload, kind: .volatile, ttlSeconds: nil) + _ = try store.put(key: "three", value: payload, kind: .volatile, ttlSeconds: nil) + expect(try store.delete(key: "one"), "delete one") + expect(try store.delete(key: "two"), "delete two") + expect(second!.evicted.map { $0.key } == ["one", "two"], "second saw two evictions") + second = nil + expect(weakSecond == nil, "listener that returned false was detached and freed") + expect(try store.delete(key: "three"), "delete three after detach") + + // A listener that throws surfaces to the caller as the foreign error + // (code -4) carrying the thrown description; the eviction itself already + // happened, and the store stays usable. + let thrower = Listener(failKey: "boom") + store.setEvictionListener(listener: thrower) + _ = try store.put(key: "boom", value: payload, kind: .volatile, ttlSeconds: nil) + _ = try store.put(key: "fine", value: payload, kind: .volatile, ttlSeconds: nil) + do { + _ = try store.delete(key: "boom") + fail("expected a foreign error from the throwing listener") + } catch let e as WeaveFFIError { + expect(e.errorCode == -4, "foreign error code == -4 (got \(e.errorCode))") + expect(e.localizedDescription.contains("listener rejected boom"), + "foreign error carries the thrown message (got \(e.localizedDescription))") + } + expect(store.count() == 2, "boom was removed before the listener ran") + expect(try store.delete(key: "fine"), "store usable after a foreign error") + expect(thrower.evicted.map { $0.key } == ["fine"], "non-failing eviction still observed") + // An expired read that trips the listener also surfaces as -4 rather + // than the domain code. + _ = try store.put(key: "boom", value: payload, kind: .volatile, ttlSeconds: -1) + do { + _ = try store.get(key: "boom") + fail("expected a foreign error from the throwing listener on expiry") + } catch let e as WeaveFFIError { + expect(e.errorCode == -4, "expiry foreign error code == -4 (got \(e.errorCode))") + } + store.clearEvictionListener() + _ = try store.put(key: "boom", value: payload, kind: .volatile, ttlSeconds: nil) + expect(try store.delete(key: "boom"), "delete boom with no listener attached") + expect(thrower.evicted.count == 1, "cleared listener no longer notified") + store.clearEvictionListener() + + // --- Object graph: share, fork, larger, describe, openMany, totalCount --- + expect(store.count() == 1, "only alpha remains (got \(store.count()))") + + // share(): a second wrapper to the SAME object; mutation through one is + // visible through the other, and dropping one leaves the other usable. + var shared: Store? = store.share() + weak let weakShared = shared + _ = try shared!.put(key: "via-share", value: payload, kind: .volatile, ttlSeconds: nil) + expect(store.count() == 2, "put through share() visible through the original") + expect(Array(try shared!.listKeys(prefix: nil)) == ["alpha", "via-share"], "share() reads the same entries") + shared = nil + expect(weakShared == nil, "shared wrapper deinitialized") + expect(store.count() == 2, "original still alive after releasing the shared wrapper") + + // fork(): an independent copy. + let forked = store.fork() + expect(forked.count() == 2, "fork copies live entries") + _ = try forked.put(key: "fork-only", value: payload, kind: .volatile, ttlSeconds: nil) + expect(forked.count() == 3 && store.count() == 2, "fork is independent of the original") + do { + _ = try store.get(key: "fork-only") + fail("expected KvError.keyNotFound: a fork's insert must not reach the original") + } catch let e as KvError { + guard case .keyNotFound = e else { fail("expected .keyNotFound from the fork probe, got \(e)") } + } +} catch { + fail("threw: \(error)") +} + +do { + let payload = Data([1, 2, 3]) + let small = try Store.open(path: "/tmp/small") + let big = try Store.open(path: "/tmp/big") + _ = try big.put(key: "k", value: payload, kind: .volatile, ttlSeconds: nil) + + // larger(): Store? as parameter and return. + expect(small.larger(other: nil) == nil, "larger(nil) on an empty store is nil") + let bigger = small.larger(other: big) + expect(bigger != nil && bigger!.count() == 1, "larger picks the fuller store") + _ = try bigger!.put(key: "k2", value: payload, kind: .volatile, ttlSeconds: nil) + expect(big.count() == 2, "the returned wrapper is the same object as `big`") + let own = big.larger(other: nil) + expect(own != nil && own!.count() == 2, "larger(nil) on a non-empty store returns itself") + _ = try own!.put(key: "k3", value: payload, kind: .volatile, ttlSeconds: nil) + expect(big.count() == 3, "self returned from larger(nil) is the same object") + expect(small.larger(other: small)?.count() == 0, "larger(self) returns a store") + + // describe(): a record carrying an object field and an optional object. + let info = big.describe(label: "primary", mirror: nil) + expect(info.label == "primary", "describe label") + expect(info.count == 3, "describe count (got \(info.count))") + expect(info.mirror == nil, "describe mirror absent") + expect(info.store.count() == 3, "describe().store is usable") + _ = try info.store.put(key: "k4", value: payload, kind: .volatile, ttlSeconds: nil) + expect(big.count() == 4, "describe().store is the same object as the receiver") + let mirrored = big.describe(label: "with-mirror", mirror: small) + expect(mirrored.mirror != nil && mirrored.mirror!.count() == 0, "describe mirror present") + _ = try mirrored.mirror!.put(key: "s", value: payload, kind: .volatile, ttlSeconds: nil) + expect(small.count() == 1, "describe().mirror is the same object as the argument") + + // openMany(): a list of objects as a return; each is a distinct store. + let many = try Store.openMany(paths: ["/a", "/b", "/c"]) + expect(many.count == 3, "openMany returns three stores (got \(many.count))") + _ = try many[0].put(key: "m", value: payload, kind: .volatile, ttlSeconds: nil) + expect(many[0].count() == 1 && many[1].count() == 0 && many[2].count() == 0, + "openMany stores are independent") + do { + _ = try Store.openMany(paths: ["/ok", ""]) + fail("expected KvError.ioError from openMany with an empty path") + } catch let e as KvError { + guard case .ioError = e else { fail("expected .ioError, got \(e)") } + expect(e.errorCode == 1004, "openMany ioError code == 1004") + } + + // totalCount(): objects inside a list parameter and inside an optional + // record parameter. Every object is cloned into the buffer, so the + // wrappers stay valid afterwards. + expect(Store.totalCount(stores: many, extra: nil) == 1, "totalCount over the list only") + expect(Store.totalCount(stores: [big, small, many[0]], extra: nil) == 6, "totalCount mixed (4 + 1 + 1)") + let extra = StoreInfo(label: "extra", store: big, mirror: small, count: -1) + expect(Store.totalCount(stores: many, extra: extra) == 5, "totalCount adds extra.store (1 + 4)") + expect(Store.totalCount(stores: [], extra: info) == 4, "totalCount with an empty list and a record") + expect(Store.totalCount(stores: [], extra: nil) == 0, "totalCount of nothing") + expect(big.count() == 4 && small.count() == 1 && many[0].count() == 1, + "every store is still usable after being encoded into buffers") + + // Wrappers release through deinit; a weak reference proves it ran. + weak var weakTmp: Store? + do { + let tmp = try Store.open(path: "/tmp/tmp") + weakTmp = tmp + expect(tmp.count() == 0, "tmp usable") + } + expect(weakTmp == nil, "temporary store wrapper deinitialized") + // A wrapper adopted from a record keeps the object alive after the + // wrapper it came from is gone. + var owner: Store? = try Store.open(path: "/tmp/owner") + _ = try owner!.put(key: "x", value: payload, kind: .volatile, ttlSeconds: nil) + let ownerInfo = owner!.describe(label: "o", mirror: nil) + owner = nil + expect(ownerInfo.store.count() == 1, "object outlives the wrapper that described it") // Async: an immediately-expired entry gives compact 3 bytes to reclaim; // the continuation resumes from the producer's worker thread. - _ = try store.put(key: "doomed", value: payload, kind: .volatile, ttlSeconds: 0) - let reclaimed = try await store.compact() + _ = try big.put(key: "doomed", value: payload, kind: .volatile, ttlSeconds: 0) + expect(try Kv.Stats.getStats(store: big).expiredEntries == 1, "stats counts the doomed entry as expired") + let reclaimed = try await big.compact() expect(reclaimed == 3, "compact reclaimed 3 bytes (got \(reclaimed))") - expect(store.count() == 0, "store empty after deletes + compact") + expect(big.count() == 4, "compact dropped only the expired entry") + expect(try await small.compact() == 0, "compact with nothing expired reclaims 0") // Non-throwing void method plus the static, neither needing `try`. - _ = try store.put(key: "gone", value: payload, kind: .volatile, ttlSeconds: nil) - store.clear() - expect(store.count() == 0, "clear drops everything") + big.clear() + expect(big.count() == 0, "clear drops everything") expect(Store.defaultCapacity() == 1_000_000, "defaultCapacity") // The throwing constructor rejects an empty path with ioError (1004). @@ -136,11 +328,12 @@ do { _ = try Store.open(path: "") fail("expected KvError.ioError for empty path") } catch let e as KvError { - guard case .ioError = e else { fail("expected .ioError, got \(e)") } + guard case let .ioError(message) = e else { fail("expected .ioError, got \(e)") } expect(e.errorCode == 1004, "ioError code == 1004 (got \(e.errorCode))") + expect(message == "I/O failure", "ioError message (got \(message))") } - // No explicit close: the Store deinit calls the generated destroy symbol. + // No explicit close: every Store deinit calls the generated destroy. print("swift/kvstore: OK") } catch { fail("threw: \(error)") diff --git a/conformance/wasm/async_demo.mjs b/conformance/wasm/async_demo.mjs index 71dc4cce..67aebd84 100644 --- a/conformance/wasm/async_demo.mjs +++ b/conformance/wasm/async_demo.mjs @@ -7,8 +7,14 @@ // WeaveFFIError) for an empty name, the buffered list-of-records round trip // through `runBatch`, the direct-scalar `runNTasks`, the sync `cancelTask`, // and `activeCallbacks` settling to zero once every task body has completed. -// Unlike the cdylib lanes the .wasm is fully self-contained; the async -// completion fires on the same thread via the exported function table. +// +// A wasm32-unknown-unknown build has no threads, so the runtime's default +// spawner drives each future to completion inline, inside the `_async` +// export: the completion trampoline has already fired, and the Promise is +// already settled, by the time the wrapper returns it. Nothing needs to be +// pumped; `await` only hops the microtask queue. This consumer pins that +// down (completion observed before `await`, ordering preserved, many +// concurrent Promises) while staying correct under a spawner that defers. // // Inputs come from the harness: // WV_WASM: path to the compiled async_demo.wasm @@ -24,10 +30,11 @@ if (!WASM || !JS) { process.exit(2); } +let failures = 0; function expect(cond, msg) { if (!cond) { console.error('assertion failed: ' + msg); - process.exit(1); + failures++; } } @@ -35,40 +42,76 @@ globalThis.fetch = async (url) => ({ arrayBuffer: async () => fs.readFileSync(ur const mod = await import(JS); const api = await mod.loadWeaveffiWasm(WASM); +const { WeaveFFIError, TaskError, InvalidName } = mod; +expect(typeof TaskError === 'function' && InvalidName.CODE === 1 && TaskError.InvalidName === InvalidName, 'TaskError domain exported'); // Async record return: the Promise resolves with a plain object whose i64 // field decodes as BigInt. -const result = await api.tasks.runTask('alpha'); -expect(result.id > 0n, 'runTask assigns an id'); +const pending = api.tasks.runTask('alpha'); +expect(pending instanceof Promise, 'runTask returns a Promise'); +// Inline completion: the task body already ran before the wrapper returned. +expect(api.tasks.activeCallbacks() === 0n, 'inline completion: no callback in flight after launch'); +const result = await pending; +expect(typeof result.id === 'bigint' && result.id > 0n, 'runTask assigns an id (BigInt)'); expect(result.value === 'completed: alpha', `runTask value (got ${result.value})`); expect(result.success === true, 'runTask success flag'); // Typed async rejection: the empty name settles with the InvalidName subclass. +let rejected = null; try { await api.tasks.runTask(''); - expect(false, 'expected InvalidName for empty name'); } catch (e) { - expect(e instanceof mod.InvalidName, `typed subclass (got ${e.constructor.name})`); - expect(e instanceof mod.TaskError, 'subclass of TaskError'); - expect(e instanceof mod.WeaveFFIError, 'subclass of the brand error'); - expect(e.code === 1, `InvalidName carries code 1 (got ${e.code})`); + rejected = e; } +expect(rejected !== null, 'expected InvalidName for empty name'); +expect(rejected instanceof InvalidName, `typed subclass (got ${rejected && rejected.constructor.name})`); +expect(rejected instanceof TaskError && rejected instanceof WeaveFFIError, 'subclass of TaskError and the brand error'); +expect(rejected && rejected.code === 1, `InvalidName carries code 1 (got ${rejected && rejected.code})`); +// A rejection through `.catch` too (no await involved). +let caught = null; +await api.tasks.runTask('').catch((e) => { caught = e; }); +expect(caught instanceof InvalidName, 'rejection observable through .catch'); + +// The module keeps working after a rejection. +const beta = await api.tasks.runTask('beta'); +expect(beta.value === 'completed: beta' && beta.id > result.id, 'ids keep increasing after a rejection'); // Buffered list-of-records both ways. const batch = await api.tasks.runBatch(['a', 'b', 'c']); +expect(Array.isArray(batch) && batch.length === 3, 'runBatch returns three records'); expect( batch.map((r) => r.value).join('|') === 'completed: a|completed: b|completed: c', 'runBatch values' ); -expect(batch.every((r) => r.success), 'runBatch success flags'); +expect(batch.every((r) => r.success && typeof r.id === 'bigint'), 'runBatch success flags and BigInt ids'); +expect((await api.tasks.runBatch([])).length === 0, 'runBatch of nothing'); +const unicodeBatch = await api.tasks.runBatch(['héllo ✓', '']); +expect(unicodeBatch[0].value === 'completed: héllo ✓', 'unicode names survive the async buffer'); +expect(unicodeBatch[1].value === 'completed: ' && unicodeBatch[1].success === true, 'runBatch does not validate names'); // Direct scalar through the async callback. expect((await api.tasks.runNTasks(7)) === 7, 'runNTasks echoes n'); +expect((await api.tasks.runNTasks(0)) === 0, 'runNTasks(0)'); +expect((await api.tasks.runNTasks(-3)) === -3, 'runNTasks keeps the sign of an i32'); + +// Many launches at once: every Promise settles independently and in order. +const many = await Promise.all(Array.from({ length: 50 }, (_, i) => api.tasks.runTask('t' + i))); +expect(many.length === 50 && many.every((r, i) => r.value === 'completed: t' + i), 'fifty concurrent runTask calls settle with their own results'); +expect(many.every((r, i) => i === 0 || r.id > many[i - 1].id), 'ids assigned in launch order'); +const mixed = await Promise.allSettled([api.tasks.runTask('ok'), api.tasks.runTask(''), api.tasks.runNTasks(2)]); +expect(mixed[0].status === 'fulfilled' && mixed[1].status === 'rejected' && mixed[2].status === 'fulfilled', 'interleaved fulfilments and rejections'); +expect(mixed[1].reason instanceof InvalidName, 'interleaved rejection keeps its type'); // Sync functions beside the async ones. expect(api.tasks.cancelTask(1) === false, 'cancelTask reports not cancelled'); +expect(api.tasks.cancelTask(1n) === false, 'cancelTask accepts a BigInt id'); +expect(typeof api.tasks.cancelTask(99) === 'boolean', 'cancelTask returns a boolean'); // Every spawned task body has completed by the time its callback fires. expect(api.tasks.activeCallbacks() === 0n, 'activeCallbacks settles to zero'); -console.log('wasm async-demo conformance: OK'); +if (failures > 0) { + console.error(`wasm/async-demo: ${failures} failure(s)`); + process.exit(1); +} +console.log('wasm/async-demo: OK'); diff --git a/conformance/wasm/codec.mjs b/conformance/wasm/codec.mjs new file mode 100644 index 00000000..ef1ca869 --- /dev/null +++ b/conformance/wasm/codec.mjs @@ -0,0 +1,443 @@ +// Conformance consumer: codec sample, Wasm (wasm32-unknown-unknown) target. +// +// Checks the generated value-buffer codec (weaveffi_wasm.js) against the +// producer's round-trip oracle in both directions: `sample*` fixtures decoded +// field by field against concrete expected values (producer encodes, consumer +// decodes), `verify*` accepting the same fixture back (consumer encodes, +// producer decodes), `roundtrip*` returning consumer-built values with edge +// cases (empty strings, lists, and maps; unicode; i64/u64 extremes as BigInt, +// both by value across the wasm boundary and inside buffers; NaN, +/-Infinity, +// and -0 doubles; every Shape variant), the typed `Mismatch` error, and +// objects inside buffers through `Holder` (a Token field, an optional Token, +// a list of Tokens; `primaryOf` returning a wrapper to the same object; +// `samePrimary`; every wrapper released with an idempotent `close()`). +// +// Inputs come from the harness: +// WV_WASM: path to the compiled codec.wasm +// WV_JS: path to the generated weaveffi_wasm.js (ESM) +// Run with: node --experimental-wasm-type-reflection (for WebAssembly.Function). + +import fs from 'fs'; +import assert from 'node:assert'; + +const WASM = process.env.WV_WASM; +const JS = process.env.WV_JS; +if (!WASM || !JS) { + console.error('WV_WASM and WV_JS must be set'); + process.exit(2); +} + +// Node has no file:// fetch; shim it so the generated loader can read the .wasm. +globalThis.fetch = async (url) => ({ arrayBuffer: async () => fs.readFileSync(url) }); + +const mod = await import(JS); +const api = await mod.loadWeaveffiWasm(WASM); +const wv = api.codec; +const { WeaveFFIError, CodecError, Mismatch, Color } = mod; +const { Token } = wv; + +let failures = 0; +function expect(cond, msg) { + if (!cond) { + console.error('assertion failed: ' + msg); + failures++; + } +} +// Structural equality with SameValue semantics for primitives: BigInt by +// value, NaN equal to NaN, -0 distinct from +0, Uint8Array by contents. +function same(actual, expected, msg) { + try { + assert.deepStrictEqual(actual, expected); + } catch (e) { + console.error('assertion failed: ' + msg); + console.error(e.message); + failures++; + } +} +function throwsMismatch(fn, msg) { + try { + fn(); + expect(false, msg + ': expected Mismatch'); + } catch (e) { + expect(e instanceof Mismatch, msg + ': Mismatch instance (got ' + (e && e.constructor.name) + ')'); + expect(e instanceof CodecError, msg + ': extends CodecError'); + expect(e instanceof WeaveFFIError, msg + ': extends WeaveFFIError'); + expect(e.code === 1 && Mismatch.CODE === 1 && CodecError.Mismatch === Mismatch, msg + ': code 1'); + } +} +const u8 = (...bytes) => new Uint8Array(bytes); + +// The C-style enum is a frozen object of declared values. +expect(Color.Red === 0 && Color.Green === 1 && Color.Blue === 7, 'Color values'); +expect(Object.isFrozen(Color), 'Color is frozen'); +expect(typeof Token === 'function', 'Token class exposed on api.codec'); + +// --- Scalars ---------------------------------------------------------------- +const canonicalScalars = { + i8_value: -8, + u8_value: 200, + i16_value: -16000, + u16_value: 60000, + i32_value: -2000000000, + u32_value: 4000000000, + i64_value: -9007199254740993n, + u64_value: 18446744073709551615n, + f32_value: 1.5, + f64_value: -2.25e100, + flag: true, + color: Color.Blue, +}; +const scalars = wv.sampleScalars(); +same(scalars, canonicalScalars, 'sampleScalars matches the canonical fixture'); +expect(typeof scalars.i64_value === 'bigint', 'i64 field is a BigInt'); +expect(typeof scalars.u64_value === 'bigint', 'u64 field is a BigInt'); +expect(typeof scalars.i32_value === 'number' && typeof scalars.u32_value === 'number', '32-bit fields are numbers'); +expect(typeof scalars.flag === 'boolean', 'bool field is a boolean'); +expect(scalars.i64_value === -9007199254740993n, 'i64 beyond 2^53 is exact'); +expect(scalars.u64_value === 18446744073709551615n, 'u64::MAX is exact'); +expect(wv.verifyScalars(scalars) === true, 'verifyScalars accepts the decoded fixture'); +expect(wv.verifyScalars(canonicalScalars) === true, 'verifyScalars accepts a hand-built fixture'); +same(wv.roundtripScalars(scalars), canonicalScalars, 'roundtripScalars preserves every field'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, u64_value: 18446744073709551614n }), 'u64 off by one'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, i64_value: -9007199254740992n }), 'i64 off by one'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, flag: false }), 'flag flipped'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, color: Color.Red }), 'color changed'); +throwsMismatch(() => wv.verifyScalars({ ...canonicalScalars, f32_value: 1.5000001 }), 'f32 changed'); + +// Consumer-built extremes, including doubles JS can express but JSON can't. +const extremes = { + i8_value: -128, + u8_value: 255, + i16_value: -32768, + u16_value: 65535, + i32_value: -2147483648, + u32_value: 4294967295, + i64_value: -9223372036854775808n, + u64_value: 0n, + f32_value: -0, + f64_value: NaN, + flag: false, + color: Color.Red, +}; +const extremesBack = wv.roundtripScalars(extremes); +same(extremesBack, extremes, 'extreme scalars round-trip'); +expect(Object.is(extremesBack.f32_value, -0), 'f32 -0 keeps its sign'); +expect(Number.isNaN(extremesBack.f64_value), 'f64 NaN survives'); +const maxes = { + ...extremes, + i8_value: 127, + i16_value: 32767, + i32_value: 2147483647, + i64_value: 9223372036854775807n, + u64_value: 18446744073709551615n, + f32_value: Infinity, + f64_value: -Infinity, + color: Color.Green, +}; +same(wv.roundtripScalars(maxes), maxes, 'max scalars round-trip'); +// A number for a 64-bit field is accepted by the encoder (BigInt(v)). +same( + wv.roundtripScalars({ ...extremes, i64_value: 42, u64_value: 7 }), + { ...extremes, i64_value: 42n, u64_value: 7n }, + 'number literals widen to BigInt on the way back' +); +// A non-integral number can't become a BigInt: the encoder throws before +// anything crosses the boundary. +try { + wv.roundtripScalars({ ...extremes, i64_value: 1.5 }); + expect(false, 'fractional i64 should throw'); +} catch (e) { + expect(e instanceof RangeError, `fractional i64 is a RangeError (got ${e && e.constructor.name})`); +} + +// Direct-family 64-bit params and returns are BigInt end to end: wasm i64 +// maps to BigInt in both directions with no precision loss. +for (const v of [0n, 1n, -1n, 9007199254740993n, -9007199254740993n, 9223372036854775807n, -9223372036854775808n]) { + const back = wv.roundtripI64(v); + expect(typeof back === 'bigint' && back === v, `roundtripI64(${v}) (got ${back})`); +} +expect(wv.roundtripI64(5) === 5n, 'roundtripI64 accepts a number and returns a BigInt'); +expect(wv.roundtripI64(-5) === -5n, 'roundtripI64 accepts a negative number'); +for (const v of [0n, 1n, 9223372036854775808n, 18446744073709551615n]) { + const back = wv.roundtripU64(v); + expect(typeof back === 'bigint' && back === v, `roundtripU64(${v}) (got ${back})`); +} +// Out-of-range BigInts follow the JS-to-wasm i64 coercion (ToBigInt64, +// modulo 2^64) rather than throwing; pin that down so a change is visible. +expect(wv.roundtripI64(9223372036854775808n) === -9223372036854775808n, 'i64 wraps modulo 2^64 at the wasm boundary'); +expect(wv.roundtripU64(-1n) === 18446744073709551615n, 'u64 wraps modulo 2^64 at the wasm boundary'); +try { + wv.roundtripI64(1.5); + expect(false, 'fractional i64 param should throw'); +} catch (e) { + expect(e instanceof RangeError, `fractional i64 param is a RangeError (got ${e && e.constructor.name})`); +} +try { + wv.roundtripI64('nope'); + expect(false, 'non-numeric i64 param should throw'); +} catch (e) { + expect(e instanceof SyntaxError, `non-numeric i64 param is a SyntaxError (got ${e && e.constructor.name})`); +} + +// Doubles by value: wasm f64 is a JS number, so every bit pattern survives. +for (const v of [0, -0, 1.5, -2.25e100, 5e-324, Number.MAX_VALUE, Number.MIN_VALUE, Number.EPSILON, Infinity, -Infinity]) { + expect(Object.is(wv.roundtripF64(v), v), `roundtripF64(${v})`); +} +expect(Number.isNaN(wv.roundtripF64(NaN)), 'roundtripF64(NaN)'); +expect(wv.roundtripBool(true) === true && wv.roundtripBool(false) === false, 'roundtripBool'); +expect(typeof wv.roundtripBool(true) === 'boolean', 'roundtripBool returns a boolean, not an i32'); +expect(wv.roundtripColor(Color.Blue) === 7, 'roundtripColor(Blue) == 7'); +expect(wv.roundtripColor(Color.Red) === 0, 'roundtripColor(Red) == 0'); +expect(wv.roundtripColor(Color.Green) === Color.Green, 'roundtripColor(Green)'); + +// Strings and bytes at the top level: NUL-terminated UTF-8 in, owned +// C string out (freed by the glue), and byte buffers as Uint8Array. +for (const s of ['', 'ascii', 'héllo wörld ✓', '日本語', 'emoji 🎉 pair', 'a'.repeat(70000), '\u{10FFFF}']) { + expect(wv.roundtripString(s) === s, `roundtripString(${JSON.stringify(s.slice(0, 20))}...)`); +} +const allBytes = new Uint8Array(Array.from({ length: 256 }, (_, i) => i)); +const allBack = wv.roundtripBytes(allBytes); +expect(allBack instanceof Uint8Array, 'roundtripBytes returns a Uint8Array'); +same(allBack, allBytes, 'roundtripBytes(0..255)'); +const emptyBytes = wv.roundtripBytes(new Uint8Array(0)); +expect(emptyBytes instanceof Uint8Array && emptyBytes.length === 0, 'roundtripBytes(empty) is an empty Uint8Array'); +same(wv.roundtripBytes([1, 2, 3]), u8(1, 2, 3), 'roundtripBytes accepts a plain array'); +same(wv.roundtripBytes(Buffer.from([7, 8])), u8(7, 8), 'roundtripBytes accepts a Buffer'); +const big = new Uint8Array(200000); +for (let i = 0; i < big.length; i++) big[i] = (i * 31) & 0xff; +same(wv.roundtripBytes(big), big, 'roundtripBytes(200000 bytes) (memory growth)'); +// The returned copy is detached from linear memory: mutating it doesn't +// affect later calls, and it survives memory growth. +const copy = wv.roundtripBytes(u8(1, 2, 3)); +copy[0] = 99; +same(wv.roundtripBytes(u8(1, 2, 3)), u8(1, 2, 3), 'returned bytes are an owned copy'); + +// Optionals and maps at the top level. +expect(wv.roundtripOptI64(null) === null, 'roundtripOptI64(null)'); +expect(wv.roundtripOptI64(undefined) === null, 'roundtripOptI64(undefined) is null'); +expect(wv.roundtripOptI64(-9223372036854775808n) === -9223372036854775808n, 'roundtripOptI64(i64::MIN)'); +expect(wv.roundtripOptI64(9223372036854775807n) === 9223372036854775807n, 'roundtripOptI64(i64::MAX)'); +expect(wv.roundtripOptI64(0n) === 0n, 'roundtripOptI64(0n) is present'); +expect(wv.roundtripOptI64(0) === 0n, 'roundtripOptI64(0) is present (a number widens)'); +same(wv.roundtripMap({}), {}, 'roundtripMap({})'); +same( + wv.roundtripMap({ a: 1n, '': -2n, 'ключ': 9223372036854775807n, z: -9223372036854775808n }), + { a: 1n, '': -2n, 'ключ': 9223372036854775807n, z: -9223372036854775808n }, + 'roundtripMap with odd keys and extremes' +); +same(wv.roundtripMap({ n: 3 }), { n: 3n }, 'roundtripMap widens number values to BigInt'); + +// --- Shape (rich enum) ------------------------------------------------------ +const shapeCases = [ + { tag: 'Empty' }, + { tag: 'Circle', radius: 2.5 }, + { tag: 'Circle', radius: -0 }, + { tag: 'Circle', radius: NaN }, + { tag: 'Circle', radius: -Infinity }, + { tag: 'Rect', width: 1.0, height: 0.5 }, + { tag: 'Rect', width: -0, height: Infinity }, + { tag: 'Labeled', label: 'tag', count: 3 }, + { tag: 'Labeled', label: '', count: -2147483648 }, + { tag: 'Labeled', label: 'ünïcödé ✓', count: 2147483647 }, + { tag: 'Nested', inner: canonicalScalars, note: 'n' }, + { tag: 'Nested', inner: extremes, note: null }, + { tag: 'Nested', inner: maxes, note: '' }, +]; +for (const s of shapeCases) { + same(wv.roundtripShape(s), s, `roundtripShape(${JSON.stringify(s, (_, v) => (typeof v === 'bigint' ? v.toString() : v)).slice(0, 60)})`); +} +same(wv.roundtripShapes(shapeCases), shapeCases, 'roundtripShapes(all variants)'); +same(wv.roundtripShapes([]), [], 'roundtripShapes([])'); +expect(wv.describeShape({ tag: 'Empty' }) === 'Empty', 'describeShape(Empty)'); +expect(wv.describeShape({ tag: 'Circle', radius: 2.5 }) === 'Circle { radius: 2.5 }', `describeShape(Circle) (got ${wv.describeShape({ tag: 'Circle', radius: 2.5 })})`); +expect(wv.describeShape({ tag: 'Labeled', label: 'tag', count: 3 }) === 'Labeled { label: "tag", count: 3 }', 'describeShape(Labeled)'); +const nestedBack = wv.roundtripShape({ tag: 'Nested', inner: extremes, note: null }); +expect(Number.isNaN(nestedBack.inner.f64_value) && nestedBack.note === null, 'nested NaN and absent note'); +expect(Object.is(nestedBack.inner.f32_value, -0), 'nested f32 -0 keeps its sign'); +// An unknown tag is rejected by the encoder before anything is sent. +try { + wv.roundtripShape({ tag: 'Hexagon' }); + expect(false, 'unknown tag should throw'); +} catch (e) { + expect(e instanceof WeaveFFIError && e.code === -3, `unknown tag is a marshalling error (got ${e && e.code})`); + expect(e.message.includes('Hexagon'), 'unknown tag error names the tag'); +} +// The module keeps working after a consumer-side encode failure. +same(wv.roundtripShape({ tag: 'Empty' }), { tag: 'Empty' }, 'roundtripShape works after an encode failure'); + +// --- Composite -------------------------------------------------------------- +const canonicalComposite = { + name: 'héllo wörld ✓', + blob: u8(0, 1, 2, 253, 254, 255), + some_i64: -9223372036854775808n, + none_i64: null, + some_text: '', + names: ['a', '', 'ccc'], + matrix: [[1, 2, 3], [], [-4]], + empty: [], + by_name: { one: 1n, two: 2n, neg: -3n }, + by_id: { '-1': canonicalScalars, '42': { ...canonicalScalars, flag: false } }, + scalars: canonicalScalars, + shape: { tag: 'Labeled', label: 'tag', count: 3 }, + shapes: [ + { tag: 'Empty' }, + { tag: 'Circle', radius: 2.5 }, + { tag: 'Rect', width: 1.0, height: 0.5 }, + { tag: 'Labeled', label: '', count: -1 }, + { tag: 'Nested', inner: canonicalScalars, note: 'n' }, + ], + maybe_shape: { tag: 'Nested', inner: canonicalScalars, note: null }, + maybe_list: u8(9, 8), + sparse: [true, null, false], + colors: [Color.Red, Color.Green, Color.Blue], +}; +const composite = wv.sampleComposite(); +same(composite, canonicalComposite, 'sampleComposite matches the canonical fixture'); +expect(composite.blob instanceof Uint8Array, 'blob is a Uint8Array'); +expect(composite.maybe_list instanceof Uint8Array, 'maybe_list is a Uint8Array'); +expect(typeof composite.some_i64 === 'bigint', 'some_i64 is a BigInt'); +expect(composite.none_i64 === null, 'absent optional is null'); +expect(composite.some_text === '', 'present empty optional string is ""'); +expect(typeof composite.by_name.one === 'bigint', 'map values are BigInt'); +expect(composite.by_id[42].flag === false && composite.by_id[-1].flag === true, 'i32-keyed map indexes by number'); +expect(composite.sparse[1] === null && composite.sparse[0] === true, 'list of optionals'); +expect(wv.verifyComposite(composite) === true, 'verifyComposite accepts the decoded fixture'); +expect(wv.verifyComposite(canonicalComposite) === true, 'verifyComposite accepts a hand-built fixture'); +same(wv.roundtripComposite(composite), canonicalComposite, 'roundtripComposite preserves every field'); +const described = wv.describeComposite(composite); +expect( + typeof described === 'string' && described.startsWith('Composite {') && described.includes('name: "héllo wörld ✓"'), + `describeComposite renders (got ${described.slice(0, 40)}...)` +); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, sparse: [true, true, false] }), 'sparse changed'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, none_i64: 0n }), 'absent optional made present'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, by_name: { one: 1n, two: 2n } }), 'map entry missing'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, name: 'hello world' }), 'unicode name changed'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, blob: u8(0, 1, 2, 253, 254) }), 'blob shortened'); +throwsMismatch(() => wv.verifyComposite({ ...canonicalComposite, maybe_shape: null }), 'optional shape removed'); +// The module keeps working after a typed error. +expect(wv.verifyComposite(composite) === true, 'verifyComposite works after Mismatch errors'); + +// A consumer-built composite with edge values in every position. +const edge = { + name: '', + blob: new Uint8Array(0), + some_i64: 9223372036854775807n, + none_i64: -1n, + some_text: null, + names: [], + matrix: [[], [-2147483648, 2147483647]], + empty: [NaN, -0, Infinity, -Infinity, 5e-324], + by_name: {}, + by_id: { '-2147483648': extremes, '0': maxes, '2147483647': canonicalScalars }, + scalars: extremes, + shape: { tag: 'Empty' }, + shapes: [], + maybe_shape: null, + maybe_list: null, + sparse: [null, null], + colors: [], +}; +const edgeBack = wv.roundtripComposite(edge); +same(edgeBack, edge, 'edge composite round-trips'); +expect(Number.isNaN(edgeBack.empty[0]) && Object.is(edgeBack.empty[1], -0), 'NaN and -0 inside a list'); +expect(edgeBack.by_id[-2147483648].i64_value === -9223372036854775808n, 'i32::MIN map key with i64::MIN value'); +expect(edgeBack.by_id[0].u64_value === 18446744073709551615n, 'u64::MAX inside a map value'); +const bigList = { + ...edge, + names: Array.from({ length: 1000 }, (_, i) => 'name-' + i), + blob: allBytes, + maybe_list: u8(255), + sparse: Array.from({ length: 333 }, (_, i) => (i % 3 === 0 ? null : i % 3 === 1)), + by_name: Object.fromEntries(Array.from({ length: 500 }, (_, i) => ['k' + i, BigInt(i) * 1000000007n])), +}; +same(wv.roundtripComposite(bigList), bigList, 'large composite round-trips (writer growth)'); + +// --- Holder: objects inside buffers ----------------------------------------- +const holder = wv.makeHolder(10n, true); +expect(holder.primary instanceof Token, 'holder.primary is a Token'); +expect(holder.primary._handle > 0, 'holder.primary wraps a live handle'); +expect(holder.primary.value() === 10n, `primary value 10n (got ${holder.primary.value()})`); +expect(holder.spare instanceof Token && holder.spare.value() === 11n, 'spare is Token(11)'); +expect(Array.isArray(holder.many) && holder.many.length === 3, 'many has 3 tokens'); +same(holder.many.map((t) => t.value()), [12n, 13n, 14n], 'many values 12..14'); +expect(new Set([holder.primary, holder.spare, ...holder.many].map((t) => t._handle)).size === 5, 'five distinct objects'); +expect(wv.sumHolder(holder) === 60n, `sumHolder == 60n (got ${wv.sumHolder(holder)})`); +// Encoding clones each handle, so the wrappers are still usable afterwards. +expect(holder.primary.value() === 10n && holder.many[2].value() === 14n, 'tokens usable after sumHolder'); +expect(wv.sumHolder(holder) === 60n, 'sumHolder again (fresh clones each call)'); + +// primaryOf returns a new wrapper over the same underlying object. +const primary = wv.primaryOf(holder); +expect(primary instanceof Token && primary !== holder.primary, 'primaryOf returns a distinct wrapper'); +expect(primary._handle === holder.primary._handle, 'primaryOf points at the same object'); +expect(primary.value() === 10n, 'primaryOf value 10n'); +expect(wv.samePrimary(holder, { primary, spare: null, many: [] }) === true, 'primaryOf aliases holder.primary'); +expect(wv.samePrimary(holder, holder) === true, 'samePrimary(holder, holder)'); +const other = wv.makeHolder(10n, true); +expect(wv.samePrimary(holder, other) === false, 'distinct holders with equal values are different objects'); +expect(wv.sumHolder(other) === 60n, 'other sums to 60n as well'); + +const noSpare = wv.makeHolder(-5n, false); +expect(noSpare.spare === null, 'spare absent is null'); +expect(noSpare.primary.value() === -5n, 'negative base'); +expect(wv.sumHolder(noSpare) === -5n + -3n + -2n + -1n, 'sumHolder without spare'); +const wrapped = wv.makeHolder(9223372036854775807n, true); +expect(wrapped.primary.value() === 9223372036854775807n, 'i64::MAX base'); +expect(wrapped.spare.value() === -9223372036854775808n, 'spare wraps to i64::MIN'); + +// Consumer-constructed tokens in every buffered position. +const t1 = new Token(100n); +const t2 = new Token(200); +const t3 = new Token(-9223372036854775808n); +expect(t1.value() === 100n && t2.value() === 200n && t3.value() === -9223372036854775808n, 'constructed token values'); +const mine = { primary: t1, spare: t2, many: [t3, t1, t1] }; +expect(wv.sumHolder(mine) === 100n + 200n + -9223372036854775808n + 100n + 100n, 'sumHolder over consumer tokens'); +expect(wv.sumHolder({ primary: t1, spare: null, many: [] }) === 100n, 'sumHolder with no spare and empty many'); +expect(wv.sumHolder({ primary: t1, spare: undefined, many: undefined }) === 100n, 'undefined spare and many are absent/empty'); +expect(wv.samePrimary(mine, { primary: t1, spare: null, many: [] }) === true, 'same consumer token in two holders'); +expect(wv.samePrimary(mine, { primary: t2, spare: null, many: [] }) === false, 'different consumer tokens'); +const primaryMine = wv.primaryOf(mine); +expect(primaryMine._handle === t1._handle && primaryMine.value() === 100n, 'primaryOf a consumer holder'); +expect(t1.value() === 100n, 't1 alive after being sent three times and returned once'); + +// Release everything: close is idempotent, and a closed token can't be +// encoded (borrow after close is -3) or used. +const everyToken = [ + holder.primary, holder.spare, ...holder.many, primary, + other.primary, other.spare, ...other.many, + noSpare.primary, ...noSpare.many, + wrapped.primary, wrapped.spare, ...wrapped.many, + t1, t2, t3, primaryMine, +]; +for (const t of everyToken) t.close(); +for (const t of everyToken) expect(t._handle === 0, 'closed token is zeroed'); +for (const t of everyToken) t.close(); +try { + holder.primary.value(); + expect(false, 'expected throw for use after close'); +} catch (e) { + expect(e instanceof WeaveFFIError && e.code === -3, `use after close is code -3 (got ${e && e.code})`); +} +try { + wv.sumHolder(holder); + expect(false, 'expected throw for encoding a closed object'); +} catch (e) { + expect(e instanceof WeaveFFIError && e.code === -3, `closed object in a buffer is code -3 (got ${e && e.code})`); +} +// The module is unaffected by those consumer-side failures. +const fresh = wv.makeHolder(1n, false); +expect(wv.sumHolder(fresh) === 1n + 3n + 4n + 5n, 'module usable after closed-object errors'); +for (const t of [fresh.primary, ...fresh.many]) t.close(); +const disposeSym = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); +const disposable = new Token(1n); +expect(typeof disposable[disposeSym] === 'function', 'Token implements Symbol.dispose'); +disposable[disposeSym](); +expect(disposable._handle === 0, 'Symbol.dispose zeroes the handle'); +disposable[disposeSym](); + +if (failures > 0) { + console.error(`wasm/codec: ${failures} failure(s)`); + process.exit(1); +} +console.log('wasm/codec: OK'); diff --git a/conformance/wasm/contacts.mjs b/conformance/wasm/contacts.mjs index 80aeec57..3768d975 100644 --- a/conformance/wasm/contacts.mjs +++ b/conformance/wasm/contacts.mjs @@ -2,9 +2,10 @@ // // Drives the generated ESM bindings (loadWeaveffiWasm) against the real // producer compiled to wasm. Exercises the original struct/enum/optional -// surface: the `ContactBook` interface class (canonical `new` constructor, -// instance methods passing the handle as the implicit self argument, and -// `free()` through the destroy symbol), records decoded from value buffers +// surface: the reference-counted `ContactBook` interface class (canonical +// `new` constructor, instance methods passing the handle as the implicit self +// argument, idempotent `close()` and `Symbol.dispose` releasing the wrapper's +// reference, use after close as a -3 error), records decoded from value buffers // into plain objects (`Contact` with its i64 id as BigInt, optional email, // and enum-as-int contact_type), NUL-terminated string args, the buffered // `string?` parameter (null email), list-of-record returns, the bool return, @@ -112,11 +113,35 @@ expect(addErr instanceof ContactsError, 'empty name -> instanceof ContactsError' expect(addErr && addErr.code === 1, 'empty name -> code 1'); expect(book.count() === 1, 'failed add leaves count == 1'); -// Disposal: free() releases the handle via the destroy symbol exactly once. -other.free(); -book.free(); -expect(book._handle === 0, 'free() zeroes the handle'); -book.free(); // second call is a no-op +// A number id widens to the i64 BigInt the ABI expects. +expect(book.get(bob.id).first_name === 'Bob', 'get by BigInt id'); +expect(book.get(Number(bob.id)).first_name === 'Bob', 'get by number id widens to i64'); + +// Disposal: close() releases this wrapper's reference exactly once; later +// calls are no-ops, and any use of the closed wrapper is a -3 error. +other.close(); +expect(other._handle === 0, 'close() zeroes the handle'); +other.close(); +book.close(); +book.close(); +let closedErr = null; +try { book.count(); } catch (e) { closedErr = e; } +expect(closedErr instanceof WeaveFFIError && closedErr.code === -3, `use after close is code -3 (got ${closedErr && closedErr.code})`); +expect(!(closedErr instanceof ContactsError), 'use after close is not a domain error'); + +// Symbol.dispose is the same release path (for `using` declarations). +const disposeSym = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); +const disposable = new ContactBook(); +disposable.add('Carol', 'King', null, ContactType.Other); +expect(typeof disposable[disposeSym] === 'function', 'wrapper implements Symbol.dispose'); +disposable[disposeSym](); +expect(disposable._handle === 0, 'Symbol.dispose zeroes the handle'); +disposable[disposeSym](); + +// The module keeps working after use-after-close errors. +const again = new ContactBook(); +expect(again.count() === 0 && again.add('Dan', 'Lee', 'dan@example.com', ContactType.Work).id > 0n, 'fresh book after closes'); +again.close(); if (failures === 0) { console.log('wasm/contacts: OK'); diff --git a/conformance/wasm/events.mjs b/conformance/wasm/events.mjs index a26baf77..a0005b1f 100644 --- a/conformance/wasm/events.mjs +++ b/conformance/wasm/events.mjs @@ -1,11 +1,20 @@ // Conformance consumer: events sample, Wasm (wasm32-unknown-unknown) target. // -// Exercises the function-table trampoline listener path (register -> the -// producer's emit fires synchronously back into JS during sendMessage -> -// unregister stops delivery) and the lazy iterable getMessages (one producer -// next per step, drained here via spread). Unlike the native targets there -// is no event-loop hop: wasm delivery is same-thread and synchronous, so -// assertions run immediately after each send. +// Drives the ABI 2 callback-interface surface of the generated ESM glue +// (loadWeaveffiWasm): a `Subscriber` implemented as a plain JS object whose +// methods the producer reaches through function-table trampolines (route -> +// Delivery, onMessage -> running count as BigInt, onAttached receiving the +// EventBus object the producer hands over), the reference-counted `EventBus` +// class (constructor, methods, idempotent `close()`, `Symbol.dispose`, +// use-after-close trap), the `Delivery` return steering `publish`'s accepted +// count, the Promise-backed `publishLater` (the default wasm32 spawner drives +// the future inline, so the Promise is settled before `await`), the lazy +// iterable `messages()`, the nullable `lastMessage()` record, the free +// function `routeOnce`, and subscribers that throw: wasm32 has no unwinding, +// so the runtime records the failure, the producer runs to completion on the +// callback's default return, the glue refuses any further callback during that +// call, and the wrapper throws a WeaveFFIError with code -4 carrying the JS +// message, leaving the bus usable. // // Inputs come from the harness: // WV_WASM: path to the compiled events.wasm @@ -21,6 +30,7 @@ if (!WASM || !JS) { process.exit(2); } +// Node has no file:// fetch; shim it so the generated loader can read the .wasm. globalThis.fetch = async (url) => ({ arrayBuffer: async () => fs.readFileSync(url) }); const mod = await import(JS); @@ -34,46 +44,276 @@ function expect(cond, msg) { } } -// Listener registration returns a plain numeric subscription id. -const received = []; -const sub = api.events.registerMessageListener((message) => received.push(message)); -expect(typeof sub === 'number' && sub > 0, 'listener id positive'); +const { WeaveFFIError, Delivery } = mod; +const { EventBus } = api.events; +expect(typeof WeaveFFIError === 'function', 'WeaveFFIError exported'); +expect(typeof EventBus === 'function', 'EventBus class exposed on api.events'); +expect(Delivery.Accept === 0 && Delivery.Skip === 1 && Delivery.AcceptAndStop === 2, 'Delivery enum values'); -// Functions surface in lowerCamelCase (module-prefix-free names under the -// module object). Delivery is synchronous: the callback runs inside each -// sendMessage call. -api.events.sendMessage('alpha'); +// A subscriber as a class: `route` steers delivery per topic, `onMessage` +// records what it saw and returns its running count (an i64, so a BigInt), +// and `onAttached` adopts the bus object the producer hands over. +class RecordingSubscriber { + constructor(name, opts) { + this.name = name; + this.skipTopic = (opts && opts.skip) || null; + this.stopTopic = (opts && opts.stop) || null; + this.throwTopic = (opts && opts.throwOn) || null; + this.routed = []; + this.received = []; + this.attachedBuses = []; + this.attachedCounts = []; + } + route(topic) { + this.routed.push(topic); + if (topic === this.throwTopic) { + throw new Error('subscriber ' + this.name + ' rejects ' + topic); + } + if (topic === this.skipTopic) return Delivery.Skip; + if (topic === this.stopTopic) return Delivery.AcceptAndStop; + return Delivery.Accept; + } + onMessage(message) { + this.received.push(message); + return BigInt(this.received.length); + } + onAttached(bus) { + // The object is usable right away (a re-entrant call into the module + // while `subscribe` is on the stack): `subscribe` attaches before it + // appends, so the count seen here is the pre-subscribe count. + expect(bus instanceof EventBus, this.name + ': onAttached receives an EventBus'); + expect(bus._handle > 0, this.name + ': adopted bus has a live handle'); + this.attachedCounts.push(bus.subscriberCount()); + this.attachedBuses.push(bus); + } +} + +const bus = new EventBus(); +expect(bus instanceof EventBus, 'constructor returns an EventBus'); +expect(bus._handle > 0, 'constructor adopts a non-null handle'); +expect(bus.subscriberCount() === 0n, 'fresh bus has no subscribers'); +expect(bus.lastMessage() === null, 'lastMessage is null before any publish'); +expect([...bus.messages()].length === 0, 'messages() empty before any publish'); + +// subscribe() returns the new subscriber count and calls onAttached with +// the bus object (one strong reference transferred to the consumer). +const a = new RecordingSubscriber('a', { skip: 'quiet', stop: 'stop' }); +const b = new RecordingSubscriber('b'); +expect(bus.subscribe(a) === 1n, 'subscribe a returns 1n'); +expect(bus.subscribe(b) === 2n, 'subscribe b returns 2n'); +expect(bus.subscriberCount() === 2n, 'subscriberCount == 2n'); +expect(a.attachedBuses.length === 1, 'a attached once'); +expect(a.attachedCounts[0] === 0n, `a saw 0 subscribers at attach (got ${a.attachedCounts[0]})`); +expect(b.attachedCounts[0] === 1n, `b saw 1 subscriber at attach (got ${b.attachedCounts[0]})`); +// The adopted wrapper is a distinct reference to the same producer object. +expect(a.attachedBuses[0] !== bus, 'adopted wrapper is a distinct JS object'); +expect(a.attachedBuses[0]._handle === bus._handle, 'adopted wrapper points at the same object'); + +// Both accept: two deliveries, one Message each with every field decoded. +const n1 = bus.publish('news', 'hello', ['x', 'y']); +expect(typeof n1 === 'bigint', 'publish returns a BigInt'); +expect(n1 === 2n, `publish(news) accepted by 2 (got ${n1})`); +expect(a.received.length === 1 && b.received.length === 1, 'both received news'); +const m = a.received[0]; +expect(m.seq === 1n, `message seq is 1n (got ${m.seq})`); +expect(m.topic === 'news', 'message topic'); +expect(m.text === 'hello', 'message text'); expect( - received.length === 1 && received[0] === 'alpha', - `listener received first send synchronously (got ${JSON.stringify(received)})` + Array.isArray(m.tags) && m.tags.length === 2 && m.tags[0] === 'x' && m.tags[1] === 'y', + `message tags (got ${JSON.stringify(m.tags)})` ); -api.events.sendMessage('beta'); +expect(a.routed.length === 1 && a.routed[0] === 'news', 'route called with topic'); + +// Skip steers the count: a skips "quiet", only b accepts. +const n2 = bus.publish('quiet', 'psst', []); +expect(n2 === 1n, `publish(quiet) accepted by 1 (got ${n2})`); +expect(a.received.length === 1, 'a skipped quiet'); +expect(b.received.length === 2 && b.received[1].seq === 2n, 'b received quiet with seq 2n'); +expect(b.received[1].tags.length === 0, 'empty tags list decodes as []'); + +// AcceptAndStop: a accepts and the bus stops before reaching b. +const n3 = bus.publish('stop', 'last', ['z']); +expect(n3 === 1n, `publish(stop) accepted by 1 (got ${n3})`); +expect(a.received.length === 2 && a.received[1].text === 'last', 'a received stop'); +expect(b.received.length === 2, 'b not reached after AcceptAndStop'); +expect(b.routed.length === 2, 'b.route not consulted after stop'); + +// The adopted bus is the same underlying object: it observes the same log. +const viaA = a.attachedBuses[0]; +expect(viaA.subscriberCount() === 2n, 'adopted bus sees both subscribers'); +const viaAMsgs = [...viaA.messages()]; expect( - received.length === 2 && received[1] === 'beta', - `listener received sends in order (got ${JSON.stringify(received)})` + viaAMsgs.join('|') === 'hello|psst|last', + `adopted bus shares the log (got ${JSON.stringify(viaAMsgs)})` ); +const lastViaA = viaA.lastMessage(); +expect(lastViaA !== null && lastViaA.seq === 3n && lastViaA.text === 'last', 'adopted bus lastMessage'); +// Releasing the adopted references (twice each) leaves the original alive. +for (const adopted of a.attachedBuses.concat(b.attachedBuses)) { + adopted.close(); + expect(adopted._handle === 0, 'close() zeroes the adopted handle'); + adopted.close(); +} +expect(bus.subscriberCount() === 2n, 'bus alive after adopted wrappers closed'); +expect(bus.publish('news', 'still', []) === 2n, 'bus still delivers after adopted wrappers closed'); -// getMessages returns a lazy iterable (one producer next per step); spread -// drains it here. -const msgs = [...api.events.getMessages()]; +// Async publish: the wasm32 default spawner drives the future inline, so the +// subscribers have already run when the Promise is handed back; `await` +// only hops the microtask queue. +const later = bus.publishLater('news', 'later'); +expect(later instanceof Promise, 'publishLater returns a Promise'); +expect(a.received.length === 4 && a.received[3].text === 'later', 'inline completion: a received before await'); +const n4 = await later; +expect(n4 === 2n, `publishLater accepted by 2 (got ${n4})`); +expect(b.received.length === 4 && b.received[3].seq === 5n, 'b received the async message'); +expect(a.received[3].tags.length === 0, 'publishLater sends no tags'); + +// Iterator and nullable record. +const msgs = [...bus.messages()]; expect( - msgs.length === 2 && msgs[0] === 'alpha' && msgs[1] === 'beta', - `iterator yields messages in order (got ${JSON.stringify(msgs)})` + msgs.join('|') === 'hello|psst|last|still|later', + `messages() in order (got ${JSON.stringify(msgs)})` ); +const last = bus.lastMessage(); +expect(last !== null && typeof last === 'object', 'lastMessage present'); +expect(last.seq === 5n && last.topic === 'news' && last.text === 'later', 'lastMessage fields'); +expect(Array.isArray(last.tags) && last.tags.length === 0, 'lastMessage tags empty'); -// This module declares no error domain, so the generic brand error class is -// still exported for panic/marshalling failures. -expect(typeof mod.WeaveFFIError === 'function', 'WeaveFFIError exported'); +// Lazy iteration: one producer step per next(); early return() closes the +// producer iterator without draining, and for...of break does the same. +const it = bus.messages()[Symbol.iterator](); +const first = it.next(); +expect(!first.done && first.value === 'hello', 'lazy first element'); +expect(typeof it.return === 'function', 'iterator has return()'); +it.return(); +expect(it.next().done === true, 'iterator done after return()'); +let seen = 0; +for (const text of bus.messages()) { + seen++; + if (text === 'psst') break; +} +expect(seen === 2, `for...of break after two steps (got ${seen})`); -// Unregister stops delivery; the producer still records the message. -api.events.unregisterMessageListener(sub); -api.events.sendMessage('gamma'); -expect(received.length === 2, `no delivery after unregister (got ${JSON.stringify(received)})`); -expect([...api.events.getMessages()].length === 3, 'producer kept recording'); +// A subscriber whose route throws. There is no unwinding on wasm32, so the +// producer's `publish` keeps running on route's default return (0 = Accept) +// and would call `onMessage` next; the glue refuses that call with the same +// foreign error, and the thunk reports code -4 with the thrown message in +// place of the result. +const bad = new RecordingSubscriber('bad', { throwOn: 'boom' }); +expect(bus.subscribe(bad) === 3n, 'subscribe bad returns 3n'); +let foreign = null; +try { + bus.publish('boom', 'x', []); +} catch (e) { + foreign = e; +} +expect(foreign !== null, 'publish throws when a subscriber throws'); +expect(foreign instanceof WeaveFFIError, `foreign error is a WeaveFFIError (got ${foreign && foreign.constructor.name})`); +expect(!(foreign instanceof WebAssembly.RuntimeError), 'no trap leaks out of the call'); +expect(foreign && foreign.code === -4, `foreign error code is -4 (got ${foreign && foreign.code})`); +expect( + foreign && typeof foreign.message === 'string' && foreign.message.includes('subscriber bad rejects boom'), + `foreign error carries the JS message (got ${JSON.stringify(foreign && foreign.message)})` +); +// a and b ran before bad and still accepted; the bus is intact and keeps +// working (no lock is held across the callback, so nothing stays locked). +expect(a.received.length === 5 && b.received.length === 5, 'earlier subscribers still delivered'); +expect(bus.subscriberCount() === 3n, 'bus intact after foreign error'); +expect(bus.publish('ok', 'y', []) === 3n, 'publish works again after foreign error'); +expect(bad.received.length === 1 && bad.received[0].text === 'y', `throwing subscriber still subscribed and was not consulted again after failing (received ${bad.received.map((m) => m.text).join(',')})`); +expect(bus.lastMessage().text === 'y', 'lastMessage works after foreign error'); + +// The same through the async path: the failure is recorded during the inline +// launch, and the Promise rejects with code -4. +let asyncForeign = null; +try { + await bus.publishLater('boom', 'z'); +} catch (e) { + asyncForeign = e; +} +expect(asyncForeign instanceof WeaveFFIError && asyncForeign.code === -4, `async foreign error code -4 (got ${asyncForeign && asyncForeign.code})`); +expect(asyncForeign && asyncForeign.message.includes('rejects boom'), 'async foreign error carries the JS message'); +expect((await bus.publishLater('ok', 'again')) === 3n, 'publishLater works again after the rejection'); + +// A non-BigInt return from onMessage (an i64 slot) fails the coercion inside +// the trampoline, which is a foreign error too. +const weird = { route: () => Delivery.Accept, onMessage: () => undefined, onAttached: () => {} }; +expect(bus.subscribe(weird) === 4n, 'subscribe weird returns 4n'); +let badReturn = null; +try { + bus.publish('any', 'w', []); +} catch (e) { + badReturn = e; +} +expect(badReturn instanceof WeaveFFIError && badReturn.code === -4, `bad return type is code -4 (got ${badReturn && badReturn.code})`); +expect(bus.subscriberCount() === 4n, 'bus intact after bad return'); -// Unregistering an unknown id is a harmless no-op. -api.events.unregisterMessageListener(sub); -api.events.unregisterMessageListener(999999); +// routeOnce: a free function taking the callback interface; no bus involved. +const solo = new RecordingSubscriber('solo', { skip: 'quiet' }); +expect(api.events.routeOnce(solo, 'quiet') === Delivery.Skip, 'routeOnce(quiet) == Skip'); +expect(api.events.routeOnce(solo, 'news') === Delivery.Accept, 'routeOnce(news) == Accept'); +expect(solo.routed.length === 2 && solo.attachedBuses.length === 0, 'routeOnce calls route only'); +// A plain object literal works as an implementation too. +const literal = { route: () => Delivery.AcceptAndStop, onMessage: () => 1n, onAttached: () => {} }; +expect(api.events.routeOnce(literal, 'x') === Delivery.AcceptAndStop, 'object literal subscriber'); +// A throwing route through the free function is the same -4. +let onceErr = null; +try { + api.events.routeOnce(new RecordingSubscriber('once', { throwOn: 't' }), 't'); +} catch (e) { + onceErr = e; +} +expect(onceErr instanceof WeaveFFIError && onceErr.code === -4, `routeOnce foreign error is -4 (got ${onceErr && onceErr.code})`); -if (failures > 0) process.exit(1); +// clearSubscribers drops every retained subscriber (each `free(ctx)` entry +// runs synchronously); publishing then accepts nothing. +bus.clearSubscribers(); +expect(bus.subscriberCount() === 0n, 'subscriberCount == 0n after clear'); +expect(bus.publish('news', 'nobody', []) === 0n, 'publish with no subscribers accepts 0'); +expect( + a.received.length === 9 && b.received.length === 9 && bad.received.length === 3, + `cleared subscribers no longer receive (got ${a.received.length}/${b.received.length}/${bad.received.length})` +); +const all = [...bus.messages()]; +expect(all.length === 11, `log records every publish, aborted ones included (got ${all.length})`); +expect(all[5] === 'x' && all[7] === 'z' && all[9] === 'w', 'aborted publishes were logged before delivery'); + +// Idempotent close; use after close is a marshalling error (-3), including +// through the async path. +bus.close(); +expect(bus._handle === 0, 'close() zeroes the handle'); +bus.close(); +let closedErr = null; +try { + bus.subscriberCount(); +} catch (e) { + closedErr = e; +} +expect(closedErr instanceof WeaveFFIError && closedErr.code === -3, `use after close is code -3 (got ${closedErr && closedErr.code})`); +let closedAsync = null; +try { + await bus.publishLater('x', 'y'); +} catch (e) { + closedAsync = e; +} +expect(closedAsync instanceof WeaveFFIError && closedAsync.code === -3, `async use after close is code -3 (got ${closedAsync && closedAsync.code})`); + +// Symbol.dispose releases the same way (twice is safe). +const disposeSym = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); +const disposable = new EventBus(); +expect(typeof disposable[disposeSym] === 'function', 'wrapper implements Symbol.dispose'); +disposable[disposeSym](); +expect(disposable._handle === 0, 'Symbol.dispose zeroes the handle'); +disposable[disposeSym](); + +// A bus that still holds subscribers when its last reference is released +// frees them without calling back into JS in a broken way. +const shortLived = new EventBus(); +shortLived.subscribe(new RecordingSubscriber('short')); +shortLived.close(); + +if (failures > 0) { + console.error(`wasm/events: ${failures} failure(s)`); + process.exit(1); +} console.log('wasm/events: OK'); diff --git a/conformance/wasm/kvstore.mjs b/conformance/wasm/kvstore.mjs index 5c15e105..15acf96f 100644 --- a/conformance/wasm/kvstore.mjs +++ b/conformance/wasm/kvstore.mjs @@ -1,20 +1,25 @@ // Conformance consumer: kvstore sample, Wasm (wasm32-unknown-unknown) target. // -// Drives the generated ESM bindings (loadWeaveffiWasm) against the real producer -// compiled to wasm. Exercises the 0.7.0 surface end to end: the `Store` -// interface class (static `open` factory, instance methods passing the handle -// as the implicit self argument, the `defaultCapacity` static, and `free()` -// through the destroy symbol), the per-module typed error domain (`KvError` -// subclasses with stable codes, thrown by `throws` wrappers), plus every -// marshalling path the generator emits: NUL-terminated string args, records -// decoded from value buffers into plain objects (`Entry` with its bytes, -// optional-scalar, list, and map fields; `Stats` from the kv.stats -// submodule), buffered optional parameters in (`i64?` TTL, `string?` -// prefix), a lazy iterator-backed string stream (listKeys), the async, -// i64-returning compact via the callback trampoline, and the eviction -// listener via the long-lived function-table trampoline (synchronous, -// same-thread delivery). Records are value types now: there is no Entry -// wrapper class, builder, or create factory. +// Drives the generated ESM bindings (loadWeaveffiWasm) against the real +// producer compiled to wasm. Exercises the ABI 2 surface end to end: the +// reference-counted `Store` interface class (static `open` factory, instance +// methods, idempotent `close()`, use-after-close trap, `Symbol.dispose`), the +// typed `KvError` domain (subclasses with stable codes thrown by `throws` +// wrappers, including through a rejected Promise), objects travelling every +// way the schema allows (`share()` returning a wrapper to the same object, +// `fork()` returning a new one, `Store?` in and out of `larger`, a record that +// carries an object and a nullable object from `describe`, a list of objects +// from `openMany`, a list of objects and an optional record holding an object +// into `totalCount`), records decoded from value buffers into plain objects +// (`Entry` with its bytes, optional-scalar, list, and map fields; `Stats` +// from the kv.stats submodule), buffered optional parameters (`i64?` TTL, +// `string?` prefix), a lazy iterator-backed string stream (listKeys), the +// Promise-backed `compact` (the wasm32 default spawner drives the future +// inline), and the `EvictionListener` callback interface implemented as a +// JS object reached through function-table trampolines: `Deleted` and +// `Expired` evictions, detaching by returning false, replacement, and a +// listener that throws, whose abort reaches JS as a translated -4 error while +// the store stays usable. // // Inputs come from the harness: // WV_WASM: path to the compiled kvstore.wasm @@ -44,123 +49,331 @@ function expect(cond, msg) { } } -const EntryKind = mod.EntryKind; -expect(EntryKind && EntryKind.Persistent === 1, 'enum EntryKind exported'); - -// Typed error surface: module-scope classes with the domain hierarchy. -const { WeaveFFIError, KvError, KeyNotFound, IoError } = mod; -expect(typeof WeaveFFIError === 'function', 'WeaveFFIError exported'); -expect(typeof KvError === 'function', 'KvError exported'); -expect(KeyNotFound.CODE === 1001, 'KeyNotFound.CODE === 1001'); -expect(KvError.KeyNotFound === KeyNotFound, 'per-code class aliased on the domain'); - -// The interface class hangs off the module object. -const Store = api.kv.Store; +const { WeaveFFIError, KvError, KeyNotFound, Expired, StoreFull, IoError, EntryKind, EvictionReason } = mod; +const { Store } = api.kv; +const { getStats } = api.kv.stats; expect(typeof Store === 'function', 'Store class exposed on api.kv'); +expect(typeof getStats === 'function', 'kv.stats.getStats exposed as a nested namespace'); +expect(typeof WeaveFFIError === 'function' && typeof KvError === 'function', 'error classes exported'); + +// The error domain: a hierarchy with stable codes, reachable both as named +// exports and as statics on the domain class. +expect(Object.getPrototypeOf(KvError) === WeaveFFIError, 'KvError extends WeaveFFIError'); +expect(KeyNotFound.CODE === 1001 && KvError.KeyNotFound === KeyNotFound, 'KeyNotFound code 1001'); +expect(Expired.CODE === 1002 && KvError.Expired === Expired, 'Expired code 1002'); +expect(StoreFull.CODE === 1003 && KvError.StoreFull === StoreFull, 'StoreFull code 1003'); +expect(IoError.CODE === 1004 && KvError.IoError === IoError, 'IoError code 1004'); +const probe = new KeyNotFound(); +expect(probe instanceof KvError && probe instanceof WeaveFFIError && probe instanceof Error, 'error prototype chain'); +expect(probe.code === 1001 && probe.message === 'WeaveFFI error 1001: key not found', `default message from the schema doc (got ${JSON.stringify(probe.message)})`); +expect(EntryKind.Volatile === 0 && EntryKind.Persistent === 1 && EntryKind.Encrypted === 2, 'EntryKind values'); +expect(EvictionReason.Deleted === 0 && EvictionReason.Expired === 1, 'EvictionReason values'); +expect(Object.isFrozen(EntryKind) && Object.isFrozen(EvictionReason), 'enums are frozen'); -// Constructor `open` throws the typed domain error on an empty path. +// Fallible factory: an empty path is the typed IoError. let openErr = null; -try { Store.open(''); } catch (e) { openErr = e; } -expect(openErr instanceof IoError, 'open("") -> instanceof IoError'); -expect(openErr instanceof KvError, 'open("") -> instanceof KvError (domain)'); -expect(openErr instanceof WeaveFFIError, 'open("") -> instanceof WeaveFFIError (base)'); -expect(openErr && openErr.code === 1004, 'open("") -> code 1004'); -expect(openErr && /I\/O failure/.test(openErr.message), 'open("") -> default message'); - -// Static factory returns a wrapped owned handle. -const store = Store.open('/tmp/conformance-kvstore-wasm'); -expect(store instanceof Store, 'open -> instanceof Store'); -expect(store._handle > 0, 'open -> non-null handle'); - -// The optional TTL parameter is a buffered `i64?`: null omits, a value -// encodes the flag byte plus payload. -const payload = new Uint8Array([1, 2, 3]); -expect(store.put('alpha', payload, EntryKind.Persistent, null) === true, 'put alpha (no ttl)'); -expect(store.put('beta', payload, EntryKind.Volatile, 3600) === true, 'put beta (with ttl)'); - -// i64 return -> BigInt, via the implicit self argument. -expect(store.count() === 2n, 'count == 2'); - -// Static method on the interface class. -expect(Store.defaultCapacity() === 1000000n, 'Store.defaultCapacity == 1_000_000'); - -// Iterator-backed string stream, drained eagerly into an array. The -// optional prefix is a buffered `string?`. -const keys = [...store.listKeys(null)].sort(); -expect(keys.length === 2 && keys[0] === 'alpha' && keys[1] === 'beta', 'listKeys values'); -const filtered = [...store.listKeys('al')]; -expect(filtered.length === 1 && filtered[0] === 'alpha', 'listKeys prefix filter'); - -// get -> buffered `Entry?` decoded into a plain object covering every -// complex field type: bytes, optional scalar, list, and map. -const alpha = store.get('alpha'); -expect(alpha && alpha.key === 'alpha', 'get alpha.key'); -expect(alpha.id > 0n, 'alpha.id positive (i64)'); -expect(alpha.value instanceof Uint8Array && alpha.value.length === 3 && alpha.value[0] === 1 && alpha.value[2] === 3, 'alpha.value bytes'); -expect(alpha.created_at > 0n, 'alpha.created_at positive (i64)'); -expect(alpha.expires_at === null, 'alpha.expires_at null (optional scalar absent)'); -expect(Array.isArray(alpha.tags) && alpha.tags.length === 0, 'alpha.tags empty list'); -expect(alpha.metadata && typeof alpha.metadata === 'object' && Object.keys(alpha.metadata).length === 0, 'alpha.metadata empty map'); +try { + Store.open(''); +} catch (e) { + openErr = e; +} +expect(openErr instanceof IoError, `Store.open('') throws IoError (got ${openErr && openErr.constructor.name})`); +expect(openErr && openErr.code === 1004 && openErr instanceof KvError, 'IoError has code 1004 and is a KvError'); +expect(openErr && openErr.message === 'WeaveFFI error 1004: I/O failure', `IoError carries the code and schema message (got ${JSON.stringify(openErr && openErr.message)})`); +const store = Store.open('/tmp/conformance.db'); +expect(store instanceof Store, 'open returns a Store'); +expect(store._handle > 0, 'open adopts a non-null handle'); +expect(Store.defaultCapacity() === 1000000n, `defaultCapacity is 1000000n (got ${Store.defaultCapacity()})`); +expect(store.count() === 0n, 'fresh store is empty'); + +// put / get with every Entry field decoded. +const enc = new TextEncoder(); +expect(store.put('alpha', enc.encode('one'), EntryKind.Persistent, null) === true, 'put alpha'); +expect(store.put('beta', enc.encode('two'), EntryKind.Volatile, 3600n) === true, 'put beta with TTL'); +expect(store.put('alpha', enc.encode('uno'), EntryKind.Persistent, undefined) === true, 'replace alpha'); +expect(store.count() === 2n, `count is 2n (got ${store.count()})`); +const alpha = store.get('alpha'); +expect(alpha !== null && typeof alpha === 'object', 'get returns a plain record'); +expect(typeof alpha.id === 'bigint' && alpha.id === 3n, `entry id is the third generated id (got ${alpha.id})`); +expect(alpha.key === 'alpha', 'entry key'); +expect(alpha.value instanceof Uint8Array && new TextDecoder().decode(alpha.value) === 'uno', 'entry value bytes'); +expect(typeof alpha.created_at === 'bigint' && alpha.created_at > 0n, 'created_at populated'); +expect(alpha.expires_at === null, 'no TTL decodes as null expires_at'); +expect(Array.isArray(alpha.tags) && alpha.tags.length === 0, 'tags is an empty list'); +expect(alpha.metadata !== null && typeof alpha.metadata === 'object' && Object.keys(alpha.metadata).length === 0, 'metadata is an empty map'); const beta = store.get('beta'); -expect(typeof beta.expires_at === 'bigint' && beta.expires_at > 0n, 'beta.expires_at present (optional scalar)'); - -// Missing key: the throwing wrapper raises the typed per-code subclass. -let getErr = null; -try { store.get('nope'); } catch (e) { getErr = e; } -expect(getErr instanceof KeyNotFound, 'get missing -> instanceof KeyNotFound'); -expect(getErr instanceof KvError, 'get missing -> instanceof KvError'); -expect(getErr && getErr.code === 1001, 'get missing -> code 1001'); -expect(getErr && /key not found/.test(getErr.message), 'get missing -> message'); - -// Deprecated method still works. -expect(store.legacyPut('legacy', new Uint8Array([7])) === true, 'legacyPut inserts'); -expect(store.delete('legacy') === true, 'delete existing -> true'); -expect(store.delete('legacy') === false, 'delete missing -> false'); - -// Submodule function taking the interface as a borrowed parameter; the -// Stats record decodes into a plain object of BigInt fields. -const st = api.kv.stats.getStats(store); -expect(st.total_entries === 2n, 'stats.total_entries == 2'); -expect(st.total_bytes === 6n, 'stats.total_bytes == 6'); -expect(st.expired_entries === 0n, 'stats.expired_entries == 0'); - -// Async i64 return via the registered callback trampoline. The wasm build -// pins the clock, so a zero TTL is expired immediately and compact -// deterministically reclaims that entry's 3 value bytes. -expect(store.put('expired', payload, EntryKind.Volatile, 0) === true, 'put expired (ttl 0)'); -const reclaimed = await store.compact(); -expect(reclaimed === 3n, `compact reclaimed 3 bytes (got ${reclaimed})`); -expect(store.count() === 2n, 'count == 2 after compact'); - -// clear drops every entry. +expect(beta.expires_at !== null && beta.expires_at === beta.created_at + 3600n, `TTL decodes into expires_at (got ${beta.expires_at})`); +expect(beta.id === 2n, 'beta kept its id'); + +// Typed errors from a method: missing key, then the same via delete's bool. +let notFound = null; +try { + store.get('missing'); +} catch (e) { + notFound = e; +} +expect(notFound instanceof KeyNotFound && notFound.code === 1001, `get(missing) throws KeyNotFound (got ${notFound && notFound.constructor.name})`); +expect(notFound && notFound.message.endsWith('key not found'), `KeyNotFound carries the schema message (got ${JSON.stringify(notFound && notFound.message)})`); +expect(store.delete('missing') === false, 'delete(missing) is false, not an error'); + +// Iterators: sorted, prefix-filtered, lazy, with early exit. +expect(store.put('alpine', enc.encode('x'), EntryKind.Persistent, null), 'put alpine'); +expect([...store.listKeys(null)].join(',') === 'alpha,alpine,beta', `listKeys(null) sorted (got ${[...store.listKeys(null)]})`); +expect([...store.listKeys(undefined)].join(',') === 'alpha,alpine,beta', 'listKeys(undefined) is the same as null'); +expect([...store.listKeys('alp')].join(',') === 'alpha,alpine', `listKeys('alp') (got ${[...store.listKeys('alp')]})`); +expect([...store.listKeys('zzz')].length === 0, 'listKeys with no match is empty'); +const keysIt = store.listKeys(null)[Symbol.iterator](); +expect(keysIt.next().value === 'alpha', 'lazy first key'); +keysIt.return(); +expect(keysIt.next().done === true, 'iterator done after return()'); +let stepped = 0; +for (const _k of store.listKeys(null)) { + stepped++; + if (stepped === 2) break; +} +expect(stepped === 2, 'for...of break'); + +// Stats from the nested submodule take the object as a borrowed parameter. +const stats = getStats(store); +expect(stats.total_entries === 3n, `total_entries 3n (got ${stats.total_entries})`); +expect(stats.total_bytes === 7n, `total_bytes 7n (got ${stats.total_bytes})`); +expect(stats.expired_entries === 0n, 'no expired entries'); + +// share(): a second wrapper to the same producer object. fork(): a copy. +const shared = store.share(); +expect(shared instanceof Store && shared !== store, 'share returns a distinct wrapper'); +expect(shared._handle === store._handle, 'share points at the same object'); +expect(shared.count() === 3n, 'shared sees the same entries'); +expect(shared.put('gamma', enc.encode('g'), EntryKind.Volatile, null), 'put through shared'); +expect(store.count() === 4n, 'original observes the put through shared'); +const forked = store.fork(); +expect(forked instanceof Store && forked._handle !== store._handle, 'fork returns a new object'); +expect(forked.count() === 4n, 'fork copies live entries'); +expect(forked.put('delta', enc.encode('d'), EntryKind.Volatile, null), 'put into fork'); +expect(forked.count() === 5n && store.count() === 4n, 'fork is independent'); +expect(forked.get('gamma').id === store.get('gamma').id, 'fork preserved ids'); +shared.close(); +shared.close(); +expect(shared._handle === 0, 'closed shared wrapper is zeroed'); +expect(store.count() === 4n, 'original alive after the shared wrapper is closed'); + +// Nullable objects both ways. +const empty = Store.open('/tmp/empty.db'); +expect(empty.larger(null) === null, 'larger(null) on an empty store is null'); +expect(empty.larger(undefined) === null, 'larger(undefined) same as null'); +const bigger = store.larger(forked); +expect(bigger instanceof Store && bigger._handle === forked._handle, 'larger returns the other when it has more'); +const own = store.larger(empty); +expect(own instanceof Store && own._handle === store._handle, 'larger returns self when it has more'); +const alone = store.larger(null); +expect(alone instanceof Store && alone._handle === store._handle, 'larger(null) returns self when non-empty'); +expect(bigger.count() === 5n && own.count() === 4n, 'returned wrappers are usable'); +bigger.close(); +own.close(); +alone.close(); +expect(store.count() === 4n && forked.count() === 5n, 'closing returned wrappers keeps the originals alive'); + +// A record carrying an object and a nullable object. +const info = store.describe('primary', forked); +expect(info.label === 'primary', 'describe label'); +expect(info.store instanceof Store && info.store._handle === store._handle, 'record.store is a wrapper to self'); +expect(info.mirror instanceof Store && info.mirror._handle === forked._handle, 'record.mirror is the passed object'); +expect(info.count === 4n, `record.count (got ${info.count})`); +const infoNoMirror = empty.describe('secondary', null); +expect(infoNoMirror.mirror === null, 'absent mirror decodes as null'); +expect(infoNoMirror.store._handle === empty._handle && infoNoMirror.count === 0n, 'secondary record'); + +// A list of objects out, a list of objects and an optional record in. +const many = Store.openMany(['/tmp/a.db', '/tmp/b.db', '/tmp/c.db']); +expect(Array.isArray(many) && many.length === 3, `openMany returns three stores (got ${many && many.length})`); +expect(many.every((s) => s instanceof Store && s._handle > 0), 'each opened store is a live wrapper'); +expect(new Set(many.map((s) => s._handle)).size === 3, 'opened stores are distinct objects'); +expect(many[0].put('k', enc.encode('v'), EntryKind.Persistent, null), 'put into many[0]'); +expect(many[1].put('k', enc.encode('v'), EntryKind.Persistent, null), 'put into many[1]'); +expect(Store.totalCount(many, null) === 2n, `totalCount(many, null) is 2n (got ${Store.totalCount(many, null)})`); +expect(Store.totalCount([], null) === 0n, 'totalCount of nothing'); +expect(Store.totalCount([store, forked], info) === 13n, `totalCount with an extra record (got ${Store.totalCount([store, forked], info)})`); +expect(Store.totalCount([store, forked], { label: 'x', store: empty, mirror: null, count: 0 }) === 9n, 'extra record with a null mirror and a number count'); +let openManyErr = null; +try { + Store.openMany(['/tmp/ok.db', '']); +} catch (e) { + openManyErr = e; +} +expect(openManyErr instanceof IoError, 'openMany propagates the typed error'); +expect(store.count() === 4n && forked.count() === 5n && empty.count() === 0n, 'stores alive after being marshalled as parameters'); +for (const s of many) s.close(); +info.store.close(); +info.mirror.close(); +infoNoMirror.store.close(); +expect(store.count() === 4n && forked.count() === 5n && empty.count() === 0n, 'stores alive after closing every borrowed wrapper'); + +// Eviction listener: a JS object with `onEvict(entry, reason) -> bool`. +class Listener { + constructor(name) { + this.name = name; + this.evictions = []; + this.keepGoing = true; + this.throwOn = null; + } + onEvict(entry, reason) { + this.evictions.push({ key: entry.key, reason, id: entry.id }); + if (entry.key === this.throwOn) { + throw new Error('listener ' + this.name + ' refuses ' + entry.key); + } + return this.keepGoing; + } +} +const listener = new Listener('first'); +store.setEvictionListener(listener); +expect(store.delete('alpine') === true, 'delete alpine'); +expect(listener.evictions.length === 1, 'delete notified the listener'); +expect(listener.evictions[0].key === 'alpine' && listener.evictions[0].reason === EvictionReason.Deleted, 'Deleted eviction carries the entry'); +expect(typeof listener.evictions[0].id === 'bigint', 'entry inside the callback is fully decoded'); +// An already-expired entry is evicted on read: Expired error plus notification. +expect(store.put('stale', enc.encode('old'), EntryKind.Volatile, -1n), 'put stale with negative TTL'); +expect(getStats(store).expired_entries === 1n, 'stats count the expired entry before eviction'); +let expired = null; +try { + store.get('stale'); +} catch (e) { + expired = e; +} +expect(expired instanceof Expired && expired.code === 1002, `get(stale) throws Expired (got ${expired && expired.constructor.name})`); +expect(listener.evictions.length === 2 && listener.evictions[1].key === 'stale' && listener.evictions[1].reason === EvictionReason.Expired, 'Expired eviction notified'); +expect(store.count() === 3n, 'expired entry evicted'); +// Returning false detaches the listener (the producer frees it). +listener.keepGoing = false; +expect(store.delete('gamma') === true, 'delete gamma'); +expect(listener.evictions.length === 3, 'listener saw the eviction that detached it'); +expect(store.put('tmp', enc.encode('t'), EntryKind.Volatile, null) && store.delete('tmp'), 'delete after detach'); +expect(listener.evictions.length === 3, 'detached listener no longer notified'); +// Replacement: the second listener takes over. +const second = new Listener('second'); +const third = new Listener('third'); +store.setEvictionListener(second); +store.setEvictionListener(third); +expect(store.put('tmp2', enc.encode('t'), EntryKind.Volatile, null) && store.delete('tmp2'), 'delete after replacement'); +expect(second.evictions.length === 0 && third.evictions.length === 1, 'only the current listener is notified'); +// A plain object literal works too, and clearEvictionListener detaches it. +let literalHits = 0; +store.setEvictionListener({ onEvict: () => { literalHits++; return true; } }); +expect(store.put('tmp3', enc.encode('t'), EntryKind.Volatile, null) && store.delete('tmp3'), 'delete with literal listener'); +expect(literalHits === 1, 'object-literal listener notified'); +store.clearEvictionListener(); +expect(store.put('tmp4', enc.encode('t'), EntryKind.Volatile, null) && store.delete('tmp4'), 'delete after clear'); +expect(literalHits === 1, 'cleared listener no longer notified'); +store.clearEvictionListener(); + +// A throwing listener: no unwinding on wasm32, so the runtime records the +// failure, `delete` runs to completion, and the thunk reports the foreign +// error (-4) carrying the JS message instead of the result. The entry was +// already removed and the store keeps working. +const thrower = new Listener('thrower'); +thrower.throwOn = 'doomed'; +store.setEvictionListener(thrower); +expect(store.put('doomed', enc.encode('x'), EntryKind.Volatile, null), 'put doomed'); +let foreign = null; +try { + store.delete('doomed'); +} catch (e) { + foreign = e; +} +expect(foreign instanceof WeaveFFIError, `throwing listener surfaces a WeaveFFIError (got ${foreign && foreign.constructor.name})`); +expect(!(foreign instanceof WebAssembly.RuntimeError) && !(foreign instanceof KvError), 'foreign error is neither a trap nor a domain error'); +expect(foreign && foreign.code === -4, `foreign error code is -4 (got ${foreign && foreign.code})`); +expect(foreign && foreign.message.includes('listener thrower refuses doomed'), `foreign error carries the JS message (got ${JSON.stringify(foreign && foreign.message)})`); +expect(thrower.evictions.length === 1, 'listener ran once'); +expect(store.count() === 2n, `store intact after foreign error (got ${store.count()})`); +let afterNotFound = null; +try { + store.get('doomed'); +} catch (e) { + afterNotFound = e; +} +expect(afterNotFound instanceof KeyNotFound, 'entry was removed before the listener threw'); +expect(store.put('again', enc.encode('y'), EntryKind.Persistent, null) && store.get('again').key === 'again', 'store usable after foreign error'); +// Without unwinding the producer observed `onEvict`'s default return (false, +// "detach me") after the throw, so unlike a native build the listener is no +// longer attached. This is the one place the abort-build semantics differ. +expect(store.delete('again') === true && thrower.evictions.length === 1, `throwing listener was detached by its default return on wasm32 (evictions ${thrower.evictions.length})`); +store.setEvictionListener(thrower); +expect(store.put('again2', enc.encode('y'), EntryKind.Persistent, null) && store.delete('again2') === true && thrower.evictions.length === 2, 'listener re-attached and delivering after the foreign error'); +// Expired path through the same throwing listener. +thrower.throwOn = 'stale2'; +expect(store.put('stale2', enc.encode('z'), EntryKind.Volatile, -5n), 'put stale2'); +let foreignExpired = null; +try { + store.get('stale2'); +} catch (e) { + foreignExpired = e; +} +expect(foreignExpired instanceof WeaveFFIError && foreignExpired.code === -4, `foreign error on the expiry path is -4 (got ${foreignExpired && foreignExpired.code})`); +expect([...store.listKeys(null)].join(',') === 'alpha,beta', `keys after the foreign errors (got ${[...store.listKeys(null)]})`); +store.clearEvictionListener(); + +// Async compact: the wasm32 default spawner completes inline, so the Promise +// is already settled when returned; `await` just observes it. +expect(store.put('exp1', enc.encode('12345'), EntryKind.Volatile, -10n), 'put exp1'); +expect(store.put('exp2', enc.encode('678'), EntryKind.Volatile, -10n), 'put exp2'); +expect(getStats(store).expired_entries === 2n, 'two expired entries before compact'); +const compacting = store.compact(); +expect(compacting instanceof Promise, 'compact returns a Promise'); +expect(getStats(store).expired_entries === 0n, 'inline completion: compacted before await'); +const reclaimed = await compacting; +expect(reclaimed === 8n, `compact reclaims 8n bytes (got ${reclaimed})`); +expect((await store.compact()) === 0n, 'second compact reclaims nothing'); +expect(store.count() === 2n, 'live entries untouched by compact'); + +// Deprecated method still works (documented as @deprecated in the glue). +expect(store.legacyPut('legacy', enc.encode('l')) === true, 'legacyPut'); +expect(store.get('legacy').expires_at === null, 'legacyPut stores a non-expiring entry'); store.clear(); -expect(store.count() === 0n, 'count == 0 after clear'); - -// Eviction listener: delete fires emit_eviction_listener synchronously (wasm -// delivery is same-thread; the callback runs inside the delete call). -const evicted = []; -const evictionSub = api.kv.registerEvictionListener((key) => evicted.push(key)); -expect(typeof evictionSub === 'number' && evictionSub > 0, 'eviction listener id positive'); -expect(store.put('doomed', new Uint8Array([1]), EntryKind.Volatile, null) === true, 'put doomed'); -expect(store.delete('doomed') === true, 'delete doomed -> true'); -expect( - evicted.length === 1 && evicted[0] === 'doomed', - `eviction listener fired synchronously (got ${JSON.stringify(evicted)})` -); -api.kv.unregisterEvictionListener(evictionSub); -expect(store.put('doomed2', new Uint8Array([1]), EntryKind.Volatile, null) === true, 'put doomed2'); -expect(store.delete('doomed2') === true, 'delete doomed2 -> true'); -expect(evicted.length === 1, `no delivery after unregister (got ${JSON.stringify(evicted)})`); - -// Disposal: free() releases the handle via the destroy symbol exactly once. -store.free(); -expect(store._handle === 0, 'free() zeroes the handle'); -store.free(); // second call is a no-op - -if (failures === 0) { - console.log('wasm/kvstore: OK'); -} else { +expect(store.count() === 0n && [...store.listKeys(null)].length === 0, 'clear empties the store'); + +// Idempotent close; use after close is a marshalling error (-3), including +// through the Promise path. +forked.close(); +forked.close(); +empty.close(); +store.close(); +expect(store._handle === 0, 'close() zeroes the handle'); +store.close(); +let closedErr = null; +try { + store.count(); +} catch (e) { + closedErr = e; +} +expect(closedErr instanceof WeaveFFIError && closedErr.code === -3, `use after close is code -3 (got ${closedErr && closedErr.code})`); +let closedAsync = null; +try { + await store.compact(); +} catch (e) { + closedAsync = e; +} +expect(closedAsync instanceof WeaveFFIError && closedAsync.code === -3, `async use after close is code -3 (got ${closedAsync && closedAsync.code})`); +let closedStats = null; +try { + getStats(store); +} catch (e) { + closedStats = e; +} +expect(closedStats instanceof WeaveFFIError && closedStats.code === -3, 'borrowing a closed object as a parameter is code -3'); + +// Symbol.dispose releases the same way (twice is safe); a store closed while +// a listener is attached frees that listener. +const disposeSym = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); +const disposable = Store.open('/tmp/disposable.db'); +disposable.setEvictionListener(new Listener('dangling')); +expect(typeof disposable[disposeSym] === 'function', 'wrapper implements Symbol.dispose'); +disposable[disposeSym](); +expect(disposable._handle === 0, 'Symbol.dispose zeroes the handle'); +disposable[disposeSym](); + +if (failures > 0) { console.error(`wasm/kvstore: ${failures} failure(s)`); process.exit(1); } +console.log('wasm/kvstore: OK'); diff --git a/conformance/wasm/shapes.mjs b/conformance/wasm/shapes.mjs index 6bfb930c..9e63affd 100644 --- a/conformance/wasm/shapes.mjs +++ b/conformance/wasm/shapes.mjs @@ -82,10 +82,25 @@ expect(empty2.tag === 'Empty', "empty2.tag === 'Empty'"); let tagErr = null; try { api.shapes.describe({ tag: 'Pentagon' }); } catch (e) { tagErr = e; } expect(tagErr instanceof mod.WeaveFFIError, 'unknown tag -> WeaveFFIError'); +expect(tagErr && tagErr.code === -3, `unknown tag -> marshalling code -3 (got ${tagErr && tagErr.code})`); +expect(tagErr && tagErr.message.includes('Pentagon'), 'unknown tag error names the tag'); +expect(api.shapes.describe({ tag: 'Empty' }) === 'empty', 'module usable after the rejected tag'); + +// Floating-point edge values inside the rich enum payloads survive the round +// trip: NaN, infinities, and -0 in the f64 and f32 slots. +const nanCircle = api.shapes.scale({ tag: 'Circle', radius: NaN }, 1.0); +expect(Number.isNaN(nanCircle.radius), 'NaN radius survives'); +const negZero = api.shapes.scale({ tag: 'Circle', radius: -0 }, 1.0); +expect(Object.is(negZero.radius, -0), '-0 radius keeps its sign'); +const inf = api.shapes.scale({ tag: 'Rectangle', width: Infinity, height: -Infinity }, 1.0); +expect(inf.width === Infinity && inf.height === -Infinity, 'infinities survive in f32 slots'); // numerics: [u8] in (canonicalized to bytes), u64 out (BigInt); // lowerCamelCase wrapper name. expect(api.shapes.sumBytes([250, 250, 250, 250]) === 1000n, 'sumBytes == 1000n'); +expect(typeof api.shapes.sumBytes([1]) === 'bigint', 'sumBytes returns a BigInt'); +expect(api.shapes.sumBytes(new Uint8Array([255, 255])) === 510n, 'sumBytes accepts a Uint8Array'); +expect(api.shapes.sumBytes([]) === 0n, 'sumBytes of nothing is 0n'); if (failures === 0) { console.log('wasm/shapes: OK'); diff --git a/crates/weaveffi-abi/Cargo.toml b/crates/weaveffi-abi/Cargo.toml index 7a75e7f2..ac7b5c10 100644 --- a/crates/weaveffi-abi/Cargo.toml +++ b/crates/weaveffi-abi/Cargo.toml @@ -11,3 +11,6 @@ categories = ["development-tools::ffi"] [lints] workspace = true + +[dev-dependencies] +proptest = "1.11.0" diff --git a/crates/weaveffi-abi/src/arena.rs b/crates/weaveffi-abi/src/arena.rs deleted file mode 100644 index 7b266b45..00000000 --- a/crates/weaveffi-abi/src/arena.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Batch handle management via an arena that tracks pointers and their destructors. - -use std::ffi::c_void; - -/// Collects handles and their destructors so they can be freed in one batch. -pub struct HandleArena { - handles: Vec<*mut c_void>, - destructors: Vec, -} - -impl HandleArena { - /// Create an empty arena with no registered handles. - pub fn new() -> Self { - Self { - handles: Vec::new(), - destructors: Vec::new(), - } - } - - /// Register a handle and its destructor for later batch destruction. - pub fn register(&mut self, ptr: *mut c_void, dtor: unsafe extern "C" fn(*mut c_void)) { - self.handles.push(ptr); - self.destructors.push(dtor); - } - - /// Call every registered destructor and clear the arena. - pub fn destroy_all(&mut self) { - for (ptr, dtor) in self.handles.drain(..).zip(self.destructors.drain(..)) { - unsafe { dtor(ptr) }; - } - } -} - -impl Default for HandleArena { - fn default() -> Self { - Self::new() - } -} - -// The `weaveffi_arena_*` C ABI entry points are *not* defined here with -// `#[no_mangle]`. Like every other runtime symbol, the logic lives as a plain -// `pub fn` and the `#[no_mangle] extern "C"` thunk is emitted in the consumer -// cdylib by [`crate::export_runtime!`]. `#[no_mangle]` symbols buried in a -// transitive rlib are not guaranteed to be re-exported from a cdylib, so -// defining them here would make the documented arena ABI unreachable. - -/// Create a new arena, returning an owned pointer. Free with `weaveffi_arena_destroy`. -pub fn arena_create() -> *mut HandleArena { - Box::into_raw(Box::new(HandleArena::new())) -} - -/// Register a handle and its destructor with the given arena. -/// -/// # Safety -/// -/// `arena` must be a valid pointer returned by `arena_create`. -/// `ptr` and `dtor` must remain valid until `arena_destroy` is called. -pub fn arena_register( - arena: *mut HandleArena, - ptr: *mut c_void, - dtor: unsafe extern "C" fn(*mut c_void), -) { - if arena.is_null() { - return; - } - let arena = unsafe { &mut *arena }; - arena.register(ptr, dtor); -} - -/// Destroy all handles in the arena, then free the arena itself. -/// -/// # Safety -/// -/// `arena` must be a valid pointer returned by `arena_create` and must not be -/// used after this call. -pub fn arena_destroy(arena: *mut HandleArena) { - if arena.is_null() { - return; - } - let mut arena = unsafe { *Box::from_raw(arena) }; - arena.destroy_all(); -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// Destructor that increments the `AtomicUsize` counter at the given pointer. - unsafe extern "C" fn counting_dtor(ptr: *mut c_void) { - let counter = ptr as *const AtomicUsize; - (*counter).fetch_add(1, Ordering::SeqCst); - } - - #[test] - fn arena_destroys_all_handles() { - let counter = AtomicUsize::new(0); - let counter_ptr = &counter as *const AtomicUsize as *mut c_void; - - let mut arena = HandleArena::new(); - for _ in 0..5 { - arena.register(counter_ptr, counting_dtor); - } - - assert_eq!(counter.load(Ordering::SeqCst), 0); - arena.destroy_all(); - assert_eq!(counter.load(Ordering::SeqCst), 5); - } - - #[test] - fn arena_destroy_all_is_idempotent() { - let counter = AtomicUsize::new(0); - let counter_ptr = &counter as *const AtomicUsize as *mut c_void; - - let mut arena = HandleArena::new(); - arena.register(counter_ptr, counting_dtor); - arena.destroy_all(); - arena.destroy_all(); - assert_eq!(counter.load(Ordering::SeqCst), 1); - } - - #[test] - fn arena_c_api_roundtrip() { - let counter = AtomicUsize::new(0); - let counter_ptr = &counter as *const AtomicUsize as *mut c_void; - - let arena = arena_create(); - assert!(!arena.is_null()); - - arena_register(arena, counter_ptr, counting_dtor); - arena_register(arena, counter_ptr, counting_dtor); - - arena_destroy(arena); - assert_eq!(counter.load(Ordering::SeqCst), 2); - } - - #[test] - fn arena_null_register_is_safe() { - unsafe extern "C" fn noop(_ptr: *mut c_void) {} - arena_register( - std::ptr::null_mut(), - std::ptr::dangling_mut::(), - noop, - ); - } - - #[test] - fn arena_null_destroy_is_safe() { - arena_destroy(std::ptr::null_mut()); - } -} diff --git a/crates/weaveffi-abi/src/buffer.rs b/crates/weaveffi-abi/src/buffer.rs index 32732234..09a1e2e0 100644 --- a/crates/weaveffi-abi/src/buffer.rs +++ b/crates/weaveffi-abi/src/buffer.rs @@ -24,7 +24,7 @@ //! | `f32` | 4 bytes (IEEE 754 bits) | //! | `f64` | 8 bytes (IEEE 754 bits) | //! | enum (C-style) | `i32` discriminant | -//! | `handle`/`handle`| `u64` | +//! | interface | `u64` object token carrying one strong reference | //! | `string` | `u32` byte length + UTF-8 bytes (no NUL terminator) | //! | `bytes` | `u32` length + raw bytes | //! | `T?` | 1 byte flag (`0` absent, `1` present) + value | @@ -35,14 +35,21 @@ //! | error payload | the matched code's fields in declaration order | //! //! Because the format is compositional, arbitrary nesting (`{string:[T?]}`, -//! records containing records, and so on) works with no per-shape special -//! cases. Interfaces, iterators, and borrowed views never appear inside a -//! buffer; validation rejects them in buffered positions. +//! records containing records, objects inside lists, and so on) works with +//! no per-shape special cases. Iterators and callback interfaces never appear +//! inside a buffer; validation rejects them in buffered positions. +//! +//! An interface object is encoded as a token (see [`object`](crate::object)) +//! that carries one strong reference: the writer clones the object before +//! encoding and the reader adopts the reference. Because adopting is a +//! side effect, a buffer holding object tokens must be decoded exactly once. //! //! Encoded lengths and counts are `u32`, capping any single string, byte //! buffer, or collection at `u32::MAX` entries; [`BufferWriter`] panics past //! that bound rather than truncating. +use std::sync::Arc; + /// An error produced while decoding a value buffer. /// /// Consumers treat a decode failure as a producer/consumer contract violation @@ -129,7 +136,7 @@ impl BufferWriter { self.buf.extend_from_slice(&v.to_le_bytes()); } - /// Write a `u64` little-endian. Also the encoding of handles. + /// Write a `u64` little-endian. Also the encoding of object tokens. pub fn write_u64(&mut self, v: u64) { self.buf.extend_from_slice(&v.to_le_bytes()); } @@ -296,7 +303,7 @@ impl<'a> BufferReader<'a> { ])) } - /// Read a `u64` little-endian. Also decodes handles. + /// Read a `u64` little-endian. Also decodes object tokens. /// /// # Errors /// @@ -411,8 +418,8 @@ impl<'a> BufferReader<'a> { /// The `#[weaveffi::record]`, `#[weaveffi::enumeration]`, and /// `#[weaveffi::error]` expansions implement this for annotated types, and /// blanket implementations below cover primitives, `String`, `Vec`, -/// `Option`, `Vec`, and the map types, so nested composites compose -/// automatically. +/// `Option`, `Vec`, the map types, and `Arc` (an interface object +/// token), so nested composites compose automatically. pub trait BufferValue: Sized { /// Append this value's encoding to `w`. fn write_value(&self, w: &mut BufferWriter); @@ -464,6 +471,26 @@ impl BufferValue for String { } } +/// An interface object inside a buffer: a `u64` token carrying one strong +/// reference. Writing clones the `Arc`; reading adopts the reference, so a +/// buffer holding tokens must be decoded exactly once (the generated thunks +/// guarantee this). A zero token is a contract violation and decodes as an +/// error. +impl BufferValue for Arc { + fn write_value(&self, w: &mut BufferWriter) { + w.write_u64(crate::object::object_to_token(self)); + } + fn read_value(r: &mut BufferReader<'_>) -> Result { + let token = r.read_u64()?; + // SAFETY: by the ABI contract every non-zero token in a buffer was + // produced by `object_to_token` (or a consumer `_clone`) for this + // type and has not been adopted yet. + unsafe { crate::object::object_from_token(token) }.ok_or(BufferDecodeError { + context: "null object token", + }) + } +} + impl BufferValue for Option { fn write_value(&self, w: &mut BufferWriter) { match self { @@ -619,6 +646,21 @@ mod tests { roundtrip(m); } + #[test] + fn object_tokens_transfer_one_reference() { + let obj = Arc::new(String::from("shared")); + let bytes = encode_value(&vec![Some(Arc::clone(&obj)), None, Some(Arc::clone(&obj))]); + assert_eq!(Arc::strong_count(&obj), 3); + let back: Vec>> = decode_value(&bytes).unwrap(); + assert_eq!(Arc::strong_count(&obj), 3); + assert!(Arc::ptr_eq(back[0].as_ref().unwrap(), &obj)); + assert!(back[1].is_none()); + drop(back); + assert_eq!(Arc::strong_count(&obj), 1); + let zero = [0u8; 8]; + assert!(decode_value::>(&zero).is_err()); + } + #[test] fn known_byte_layout() { // Lock the wire format: [count=2][len=1]'a'[len=0] for `["a", ""]`. diff --git a/crates/weaveffi-abi/src/callback.rs b/crates/weaveffi-abi/src/callback.rs new file mode 100644 index 00000000..34f26c97 --- /dev/null +++ b/crates/weaveffi-abi/src/callback.rs @@ -0,0 +1,392 @@ +//! Consumer-implemented callback interfaces: the producer half of the ABI's +//! vtable contract. +//! +//! A callback-interface parameter arrives as two slots, a `void* ctx` the +//! consumer owns and a pointer to a process-wide static vtable with one +//! `extern "C"` function pointer per method plus a trailing `free(ctx)`. The +//! `#[weaveffi::module]` expansion wraps the pair in a [`ForeignCallback`], +//! implements the producer's trait on top of it (each method lowers its +//! arguments, calls the vtable entry, then checks `out_err`), and hands the +//! producer an `Arc`. When the last `Arc` drops, [`ForeignCallback`] +//! calls `free(ctx)` exactly once. +//! +//! A consumer implementation that fails reports through the method's +//! `out_err` slot with [`FOREIGN_ERROR_CODE`](crate::FOREIGN_ERROR_CODE). The +//! generated trait method can't return that error (callback methods never +//! `throws`), so [`check_foreign_error`] hands it to [`raise_foreign_error`], +//! which delivers it to the enclosing thunk by one of two routes: +//! +//! * On a `panic = "unwind"` build (every native target by default) it +//! unwinds with a [`ForeignError`] payload, aborting the producer's call at +//! the point of failure. The thunk's `catch_unwind` recognizes the payload +//! and reports it with the consumer's message. +//! * On a `panic = "abort"` build (notably `wasm32-unknown-unknown`, where +//! unwinding is unavailable) it records the failure in a thread-local slot +//! and returns, so the producer's code keeps running on the vtable entry's +//! default return value. Every thunk checks [`take_foreign_error`] after the +//! producer returns and reports the recorded failure instead of the result. +//! The first failure recorded wins; later ones on the same call are dropped. +//! +//! Both routes surface the same `out_err` code and message to the original +//! caller, so consumers see identical behaviour. Producers that need the +//! abort-build route to be correct must tolerate a callback method returning +//! its type's zero value once the consumer has failed; well-behaved producers +//! already do, because the consumer could return that value on purpose. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::sync::Arc; + +use crate::weaveffi_error; + +/// Implemented by every generated vtable struct so [`ForeignCallback`] can +/// find the trailing `free` entry without knowing the method layout. +pub trait Vtable: 'static { + /// The consumer's release hook, called exactly once when the producer + /// drops its last reference to the callback. + fn free(&self) -> unsafe extern "C" fn(*mut c_void); +} + +/// Ties a callback interface's `dyn Trait` to its generated vtable and +/// foreign wrapper. +/// +/// The `#[weaveffi::module]` expansion implements this for `dyn Trait` of +/// every `#[weaveffi::callback_interface]`, which lets a thunk that only knows +/// the producer's written type (`Arc`) name the vtable slot type +/// (`::Vtable`) and lift the pair into a +/// shared trait object with [`lift_callback`]. +pub trait CallbackInterface { + /// The generated `#[repr(C)]` vtable struct for this interface. + type Vtable: Vtable; + + /// Wrap a lifted foreign callback as a shared trait object. + fn from_foreign(cb: ForeignCallback) -> Arc; +} + +/// Lift a callback-interface parameter's `(ctx, vtable)` slots into the +/// `Arc` the producer's function takes. A null vtable yields +/// `None`, which the thunk reports as a marshalling failure. +/// +/// # Safety +/// +/// Same contract as [`ForeignCallback::from_raw`]: `vtable` must be null or +/// point to a fully initialized, immutable vtable that outlives every clone of +/// the returned `Arc`, and `ctx` must stay valid until the vtable's `free` +/// entry is called with it. +#[must_use] +pub unsafe fn lift_callback( + ctx: *mut c_void, + vtable: *const C::Vtable, +) -> Option> { + // SAFETY: forwarded from the caller. + unsafe { ForeignCallback::from_raw(ctx, vtable) }.map(C::from_foreign) +} + +/// A consumer-implemented callback interface held by the producer. +/// +/// Holds the consumer's `ctx` and a pointer to its static vtable `V`. The +/// generated `impl Trait for ForeignX` calls +/// [`vtable`](Self::vtable)`().method(ctx, ...)` per method; dropping the +/// last owner calls `free(ctx)`. +pub struct ForeignCallback { + ctx: *mut c_void, + vtable: *const V, +} + +// SAFETY: the ABI contract obliges the consumer to make every vtable entry +// callable from any thread, and `ctx` is only ever handed back to those +// entries. The producer never dereferences `ctx` itself. +unsafe impl Send for ForeignCallback {} +// SAFETY: see the `Send` impl; the vtable is immutable static consumer data. +unsafe impl Sync for ForeignCallback {} + +impl ForeignCallback { + /// Adopt a `(ctx, vtable)` pair lifted from a callback-interface + /// parameter's slots. Returns `None` when the vtable pointer is null, + /// which the thunk reports as a marshalling failure. + /// + /// # Safety + /// + /// `vtable` must be null or point to a fully initialized `V` that stays + /// valid and unmodified for the life of the returned value (consumers + /// use a process-wide static). `ctx` must remain valid until the + /// vtable's `free` entry is called with it. + #[must_use] + pub unsafe fn from_raw(ctx: *mut c_void, vtable: *const V) -> Option { + if vtable.is_null() { + return None; + } + Some(Self { ctx, vtable }) + } + + /// The consumer's context pointer, passed as the first argument of every + /// vtable entry. + #[must_use] + pub fn ctx(&self) -> *mut c_void { + self.ctx + } + + /// The consumer's vtable. + #[must_use] + pub fn vtable(&self) -> &V { + // SAFETY: `from_raw` rejected null and its contract keeps the vtable + // alive and immutable for the life of `self`. + unsafe { &*self.vtable } + } +} + +impl Drop for ForeignCallback { + fn drop(&mut self) { + let free = self.vtable().free(); + // SAFETY: `ctx` is handed back to the consumer's own release hook + // exactly once, as the ABI contract requires. + unsafe { free(self.ctx) }; + } +} + +/// The unwind payload [`check_foreign_error`] raises when a consumer +/// callback-interface implementation reports a failure. +/// +/// The `#[weaveffi::module]` thunks catch it and report +/// [`FOREIGN_ERROR_CODE`](crate::FOREIGN_ERROR_CODE) with `message` to the +/// original caller, so the consumer's own error text round-trips through the +/// producer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignError { + /// The code the consumer wrote to `out_err` (normally + /// [`FOREIGN_ERROR_CODE`](crate::FOREIGN_ERROR_CODE)). + pub code: i32, + /// The consumer's message, copied out of `out_err` before it was cleared. + pub message: String, +} + +impl std::fmt::Display for ForeignError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ForeignError {} + +thread_local! { + /// The failure recorded by [`raise_foreign_error`] on a `panic = "abort"` + /// build, waiting for the enclosing thunk to pick it up. + static PENDING_FOREIGN: RefCell> = const { RefCell::new(None) }; +} + +/// Inspect the `out_err` slot a vtable entry wrote and abort the producer +/// call if the consumer reported a failure. +/// +/// On success (`code == 0`) this is a no-op. Otherwise it copies the message, +/// releases the error's allocations, and raises a [`ForeignError`] through +/// [`raise_foreign_error`]. +/// +/// # Panics +/// +/// On a `panic = "unwind"` build this unwinds with a [`ForeignError`] payload +/// whenever `err.code != 0`. Generated thunks always run inside +/// `catch_unwind`, which converts the payload into an `out_err` report. +pub fn check_foreign_error(mut err: weaveffi_error) { + if err.code == 0 { + return; + } + // Consumers report with `FOREIGN_ERROR_CODE`; any other trap code is kept, + // but a positive code must not masquerade as one of the producer's domain + // errors on the outer call. + let code = if err.code < 0 { + err.code + } else { + crate::FOREIGN_ERROR_CODE + }; + let message = crate::c_ptr_to_string(err.message) + .unwrap_or_else(|| "callback interface implementation failed".to_string()); + crate::error_clear(&mut err); + raise_foreign_error(ForeignError { code, message }); +} + +/// Deliver a consumer-side failure to the thunk that is running the current +/// producer call. +/// +/// On a `panic = "unwind"` build this never returns: it unwinds with `err` as +/// the payload via [`std::panic::resume_unwind`], so the panic hook does not +/// fire (this is control flow, not a bug report). On a `panic = "abort"` build +/// it records `err` for [`take_foreign_error`] and returns, keeping the first +/// failure if one is already pending. Generated code always follows a call to +/// this function with a fallback value so that both builds type-check. +/// +/// # Panics +/// +/// Unwinds with a [`ForeignError`] payload on a `panic = "unwind"` build. +pub fn raise_foreign_error(err: ForeignError) { + #[cfg(panic = "unwind")] + { + std::panic::resume_unwind(Box::new(err)); + } + #[cfg(panic = "abort")] + { + defer_foreign_error(err); + } +} + +/// Record `err` as this thread's pending foreign failure without unwinding, +/// keeping an already-pending failure if there is one. +/// +/// This is the `panic = "abort"` half of [`raise_foreign_error`]; it is public +/// so that tests and unusual producers can exercise the deferred route on any +/// build. +pub fn defer_foreign_error(err: ForeignError) { + PENDING_FOREIGN.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some(err); + } + }); +} + +/// Take the foreign failure recorded on this thread by [`defer_foreign_error`], +/// if any. +/// +/// Generated thunks call this after the producer's code returns and, when it +/// yields `Some`, report the failure through `out_err` instead of the result. +/// On a `panic = "unwind"` build this is normally `None`, because the failure +/// unwound past the producer's code instead of being recorded. +#[must_use] +pub fn take_foreign_error() -> Option { + PENDING_FOREIGN.with(|slot| slot.borrow_mut().take()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[repr(C)] + struct TestVtable { + ping: unsafe extern "C" fn(*mut c_void, i32, *mut weaveffi_error) -> i32, + free: unsafe extern "C" fn(*mut c_void), + } + + impl Vtable for TestVtable { + fn free(&self) -> unsafe extern "C" fn(*mut c_void) { + self.free + } + } + + static FREED: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "C" fn ping(_ctx: *mut c_void, x: i32, out_err: *mut weaveffi_error) -> i32 { + if x < 0 { + crate::error_set(out_err, crate::FOREIGN_ERROR_CODE, "negative"); + return 0; + } + crate::error_set_ok(out_err); + x * 2 + } + + unsafe extern "C" fn free(_ctx: *mut c_void) { + FREED.fetch_add(1, Ordering::SeqCst); + } + + static VTABLE: TestVtable = TestVtable { ping, free }; + + fn call(cb: &ForeignCallback, x: i32) -> i32 { + let mut err = weaveffi_error::default(); + let out = unsafe { (cb.vtable().ping)(cb.ctx(), x, &mut err) }; + check_foreign_error(err); + out + } + + #[test] + fn calls_through_and_frees_once() { + let before = FREED.load(Ordering::SeqCst); + let cb = + Arc::new(unsafe { ForeignCallback::from_raw(std::ptr::null_mut(), &VTABLE) }.unwrap()); + assert_eq!(call(&cb, 21), 42); + let second = Arc::clone(&cb); + drop(cb); + assert_eq!(FREED.load(Ordering::SeqCst), before); + drop(second); + assert_eq!(FREED.load(Ordering::SeqCst), before + 1); + } + + #[test] + fn foreign_failure_unwinds_with_the_message() { + let cb = unsafe { ForeignCallback::from_raw(std::ptr::null_mut(), &VTABLE) }.unwrap(); + let payload = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| call(&cb, -1))).unwrap_err(); + let fe = payload + .downcast_ref::() + .expect("ForeignError payload"); + assert_eq!(fe.code, crate::FOREIGN_ERROR_CODE); + assert_eq!(fe.message, "negative"); + } + + trait Pinger: Send + Sync { + fn ping(&self, x: i32) -> i32; + } + + struct ForeignPinger(ForeignCallback); + + impl Pinger for ForeignPinger { + fn ping(&self, x: i32) -> i32 { + call(&self.0, x) + } + } + + impl CallbackInterface for dyn Pinger { + type Vtable = TestVtable; + fn from_foreign(cb: ForeignCallback) -> Arc { + Arc::new(ForeignPinger(cb)) + } + } + + #[test] + fn lift_callback_builds_the_trait_object() { + let before = FREED.load(Ordering::SeqCst); + let pinger: Arc = + unsafe { lift_callback(std::ptr::null_mut(), &VTABLE) }.expect("non-null vtable"); + assert_eq!(pinger.ping(4), 8); + drop(pinger); + assert_eq!(FREED.load(Ordering::SeqCst), before + 1); + assert!( + unsafe { lift_callback::(std::ptr::null_mut(), std::ptr::null()) } + .is_none() + ); + } + + #[test] + fn deferred_failures_keep_the_first_and_are_taken_once() { + assert!(take_foreign_error().is_none()); + defer_foreign_error(ForeignError { + code: crate::FOREIGN_ERROR_CODE, + message: "first".into(), + }); + defer_foreign_error(ForeignError { + code: crate::MARSHAL_ERROR_CODE, + message: "second".into(), + }); + let taken = take_foreign_error().expect("a pending failure"); + assert_eq!(taken.code, crate::FOREIGN_ERROR_CODE); + assert_eq!(taken.message, "first"); + assert!(take_foreign_error().is_none()); + } + + #[test] + fn deferred_failures_are_thread_local() { + defer_foreign_error(ForeignError { + code: crate::FOREIGN_ERROR_CODE, + message: "mine".into(), + }); + let other = std::thread::spawn(take_foreign_error).join().unwrap(); + assert!(other.is_none()); + assert_eq!(take_foreign_error().map(|e| e.message), Some("mine".into())); + } + + #[test] + fn null_vtable_is_rejected() { + assert!(unsafe { + ForeignCallback::::from_raw(std::ptr::null_mut(), std::ptr::null()) + } + .is_none()); + } +} diff --git a/crates/weaveffi-abi/src/lib.rs b/crates/weaveffi-abi/src/lib.rs index 89703ed0..a066ee03 100644 --- a/crates/weaveffi-abi/src/lib.rs +++ b/crates/weaveffi-abi/src/lib.rs @@ -1,4 +1,8 @@ -//! C ABI runtime: error struct, memory helpers, and utility functions. +//! C ABI runtime: error struct, memory helpers, reference-counted objects, +//! callback-interface vtables, the value-buffer codec, and the async spawner. +//! +//! The normative description of the contract this crate implements is +//! `docs/src/reference/abi.md` in the WeaveFFI repository. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] @@ -7,15 +11,26 @@ #![allow(unsafe_code)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -pub mod arena; pub mod buffer; +pub mod callback; pub mod convert; mod macros; +pub mod object; +pub mod spawn; pub use buffer::{ decode_value, encode_value, BufferDecodeError, BufferReader, BufferValue, BufferWriter, }; +pub use callback::{ + check_foreign_error, defer_foreign_error, lift_callback, raise_foreign_error, + take_foreign_error, CallbackInterface, ForeignCallback, ForeignError, Vtable, +}; pub use convert::{lift_byte_slice, lift_bytes, lower_bytes}; +pub use object::{ + lower_object, lower_object_opt, object_arc, object_clone, object_destroy, object_from_token, + object_ref, object_to_token, +}; +pub use spawn::{set_spawner, spawn, BoxFuture, CatchUnwind, Spawner, SpawnerAlreadySet}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -31,10 +46,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// revision, turning a silent memory-layout mismatch into a clear error. /// /// The number only changes when the runtime surface (the `weaveffi_error` -/// layout, the value-buffer encoding, or the set and signatures of the -/// `weaveffi_*` runtime symbols) changes incompatibly. It is independent of -/// the crate version and of the IDL schema version. -pub const ABI_VERSION: u32 = 1; +/// layout, the value-buffer encoding, the object or callback-interface +/// conventions, or the set and signatures of the `weaveffi_*` runtime +/// symbols) changes incompatibly. It is independent of the crate version and +/// of the IDL schema version. +/// +/// Revision 2 introduced reference-counted objects (`_clone`), object tokens +/// inside value buffers, callback-interface vtables, `weaveffi_error_set`, +/// and the foreign error code, and removed handles and the arena. +pub const ABI_VERSION: u32 = 2; /// Return [`ABI_VERSION`]; the body behind the exported /// `weaveffi_abi_version` thunk. @@ -43,16 +63,6 @@ pub const fn abi_version() -> u32 { ABI_VERSION } -/// Public opaque handle type exposed to foreign callers. -pub type weaveffi_handle_t = u64; - -/// The producer-side spelling of the IDL's opaque `handle` type. -/// -/// An alias of `u64`; the token's value is opaque to consumers and round-trips -/// unchanged. Spell a parameter or return as `weaveffi::Handle` to extract it -/// as `handle`; a bare `u64` extracts as the `u64` scalar instead. -pub type Handle = u64; - /// Error struct passed across the C ABI boundary. /// /// # Safety @@ -135,6 +145,18 @@ pub fn error_set(out_err: *mut weaveffi_error, code: i32, message: &str) { err.message = cstr.into_raw(); } +/// The body of the exported `weaveffi_error_set` thunk: fill `out_err` from a +/// borrowed C string the consumer owns. +/// +/// Callback-interface implementations call this to report a failure without +/// allocating the message with a foreign allocator (the producer frees +/// `message` with its own). A null or non-UTF-8 `message` yields an empty +/// message; the code is always recorded. +pub fn error_set_c(out_err: *mut weaveffi_error, code: i32, message: *const c_char) { + let message = c_ptr_to_string(message).unwrap_or_default(); + error_set(out_err, code, &message); +} + /// Populate an error with a code, message, and an owned payload buffer (the /// matched error code's fields serialized in the [`buffer`] format). /// @@ -198,9 +220,9 @@ pub fn error_set_with_payload( /// ``` pub trait ErrorReport { /// The non-zero status code written to [`weaveffi_error::code`]. Defaults to - /// the generic error code `-1`. + /// [`GENERIC_ERROR_CODE`]. fn code(&self) -> i32 { - -1 + GENERIC_ERROR_CODE } /// The human-readable message written to [`weaveffi_error::message`]. @@ -260,6 +282,11 @@ pub fn result_to_out_err( } } +/// The reserved error code for an **untyped producer error**: the default +/// [`ErrorReport::code`], used by `Result` and other error types +/// that don't map onto a declared domain code. +pub const GENERIC_ERROR_CODE: i32 = -1; + /// The reserved error code reporting a producer **panic**. /// /// Generated thunks wrap the producer call in `catch_unwind`; a panic is @@ -276,27 +303,43 @@ pub const PANIC_ERROR_CODE: i32 = -2; /// /// Both sides are generated from the same IDL, so a marshalling failure is a /// producer/consumer contract violation, not a domain error; wrappers surface -/// it through the same trap channel as [`PANIC_ERROR_CODE`]. Before this code -/// existed, generated thunks reported lift failures with code `1`, which any -/// error domain could legally claim as a typed code. +/// it through the same trap channel as [`PANIC_ERROR_CODE`]. pub const MARSHAL_ERROR_CODE: i32 = -3; -/// Best-effort extraction of a panic payload's message (`&str` and `String` -/// payloads; anything else yields a fixed placeholder). +/// The reserved error code reporting that a **consumer callback-interface +/// implementation failed**. +/// +/// A consumer's method implementation that raises reports it through the +/// vtable entry's `out_err` slot with this code (via `weaveffi_error_set`); +/// the producer aborts the call it was making and the original caller +/// observes this code with the consumer's message. See [`callback`]. +pub const FOREIGN_ERROR_CODE: i32 = -4; + +/// Best-effort extraction of a panic payload's message (`&str`, `String`, +/// and [`ForeignError`] payloads; anything else yields a fixed placeholder). pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { if let Some(s) = payload.downcast_ref::<&str>() { (*s).to_string() } else if let Some(s) = payload.downcast_ref::() { s.clone() + } else if let Some(f) = payload.downcast_ref::() { + f.message.clone() } else { "producer panicked".to_string() } } -/// Report a caught panic through `out_err` with [`PANIC_ERROR_CODE`] and the -/// payload's message. Generated thunks call this from their `catch_unwind` -/// error arm. +/// Report a caught unwind through `out_err`. A [`ForeignError`] payload (a +/// consumer callback-interface implementation raised, or returned a value +/// the producer could not lift) is reported with the payload's own code +/// (normally [`FOREIGN_ERROR_CODE`]) and message; any other payload is a +/// producer panic and is reported as [`PANIC_ERROR_CODE`]. Generated thunks +/// call this from their `catch_unwind` error arm. pub fn error_set_panic(out_err: *mut weaveffi_error, payload: &(dyn std::any::Any + Send)) { + if let Some(f) = payload.downcast_ref::() { + error_set(out_err, f.code, &f.message); + return; + } error_set( out_err, PANIC_ERROR_CODE, @@ -530,14 +573,14 @@ impl Iterator for Iter { /// Drive a future to completion on the current thread, blocking until it /// resolves. /// -/// This is the minimal, dependency-free executor the `#[weaveffi::module]` -/// expansion uses to run an exported `async fn` on the worker thread it spawns -/// for each async launch, then invoke the completion callback with the result. -/// It parks the thread between polls and wakes on `Waker::wake`, so a future +/// This is the minimal, dependency-free executor behind the default +/// [`Spawner`]: each async launch runs its future on a fresh thread through +/// this function, then invokes the completion callback with the result. It +/// parks the thread between polls and wakes on `Waker::wake`, so a future /// that yields (for example, one awaiting a channel woken from another thread) /// makes progress without busy-spinning. There is no reactor, so a future that -/// depends on an external runtime's I/O driver (such as Tokio's) will not be -/// driven by this helper. +/// depends on an external runtime's I/O driver (such as Tokio's) needs that +/// runtime installed with [`set_spawner`] instead. /// /// # Examples /// @@ -585,9 +628,9 @@ pub fn c_ptr_to_string(ptr: *const c_char) -> Option { /// Borrow a NUL-terminated C string as a `&str` without copying. /// Returns `None` if `ptr` is null or the bytes aren't valid UTF-8. /// -/// This is the zero-copy lift for a `&str` parameter: the generated thunk -/// borrows the caller's buffer for the duration of the call instead of -/// allocating an owned `String`. +/// This is the zero-copy lift for a producer parameter spelled `&str`: the +/// generated thunk borrows the caller's buffer for the duration of the call +/// instead of allocating an owned `String`. /// /// # Safety /// @@ -726,10 +769,47 @@ mod tests { } } + #[test] + fn error_set_c_copies_a_borrowed_message() { + let mut err = weaveffi_error::default(); + let msg = CString::new("from the consumer").unwrap(); + error_set_c(&mut err, FOREIGN_ERROR_CODE, msg.as_ptr()); + drop(msg); + assert_eq!(err.code, FOREIGN_ERROR_CODE); + assert_eq!(c_ptr_to_string(err.message).unwrap(), "from the consumer"); + error_clear(&mut err); + error_set_c(&mut err, 3, ptr::null()); + assert_eq!(err.code, 3); + assert_eq!(c_ptr_to_string(err.message).unwrap(), ""); + error_clear(&mut err); + } + + #[test] + fn foreign_error_payload_is_reported_as_foreign_code() { + let mut err = weaveffi_error::default(); + let payload: Box = Box::new(ForeignError { + code: FOREIGN_ERROR_CODE, + message: "listener threw".into(), + }); + error_set_panic(&mut err, &*payload); + assert_eq!(err.code, FOREIGN_ERROR_CODE); + assert_eq!(c_ptr_to_string(err.message).unwrap(), "listener threw"); + error_clear(&mut err); + + let payload: Box = Box::new("bug"); + error_set_panic(&mut err, &*payload); + assert_eq!(err.code, PANIC_ERROR_CODE); + assert_eq!( + c_ptr_to_string(err.message).unwrap(), + "producer panicked: bug" + ); + error_clear(&mut err); + } + #[test] fn error_report_string_uses_generic_code() { let e = "boom".to_string(); - assert_eq!(ErrorReport::code(&e), -1); + assert_eq!(ErrorReport::code(&e), GENERIC_ERROR_CODE); assert_eq!(ErrorReport::message(&e), "boom"); assert_eq!(ErrorReport::code(&"boom"), -1); assert_eq!(ErrorReport::message(&"boom"), "boom"); diff --git a/crates/weaveffi-abi/src/macros.rs b/crates/weaveffi-abi/src/macros.rs index af8e96e9..3ff4debe 100644 --- a/crates/weaveffi-abi/src/macros.rs +++ b/crates/weaveffi-abi/src/macros.rs @@ -3,8 +3,8 @@ //! //! Every Rust cdylib that hosts generated WeaveFFI bindings must expose //! a small set of `#[no_mangle] extern "C"` functions that the language -//! wrappers call into: string/byte deallocation, error clearing, and -//! the cancel-token lifecycle. The wrappers themselves live in +//! wrappers call into: string/byte deallocation, error setting and +//! clearing, and the cancel-token lifecycle. The wrappers themselves live in //! `weaveffi-abi` as ordinary `pub fn`s; the `extern "C"` thunks must //! be emitted in the *consumer's* crate because `#[no_mangle]` symbols //! in a transitive `rlib` are not guaranteed to be re-exported from a @@ -22,15 +22,13 @@ /// - `weaveffi_abi_version` /// - `weaveffi_free_string` /// - `weaveffi_free_bytes` +/// - `weaveffi_error_set` /// - `weaveffi_error_clear` /// - `weaveffi_error_free` /// - `weaveffi_cancel_token_create` /// - `weaveffi_cancel_token_cancel` /// - `weaveffi_cancel_token_is_cancelled` /// - `weaveffi_cancel_token_destroy` -/// - `weaveffi_arena_create` -/// - `weaveffi_arena_register` -/// - `weaveffi_arena_destroy` /// /// On `wasm32` targets it additionally emits a linear-memory allocator that the /// generated JS bindings call to stage inputs and return slots: @@ -88,6 +86,18 @@ macro_rules! export_runtime { $crate::wasm_dealloc(ptr, size as usize) } + // Callback-interface implementations report a failure through this + // thunk so the message is copied with the producer's allocator (the + // one `weaveffi_error_clear` frees with). + #[no_mangle] + pub extern "C" fn weaveffi_error_set( + err: *mut $crate::weaveffi_error, + code: i32, + message: *const ::std::os::raw::c_char, + ) { + $crate::error_set_c(err, code, message) + } + #[no_mangle] pub extern "C" fn weaveffi_error_clear(err: *mut $crate::weaveffi_error) { $crate::error_clear(err) @@ -119,27 +129,5 @@ macro_rules! export_runtime { pub extern "C" fn weaveffi_cancel_token_destroy(token: *mut $crate::weaveffi_cancel_token) { $crate::cancel_token_destroy(token) } - - // The batch-free arena is part of the documented stable runtime ABI, so - // its thunks are re-exported here for the same reason as the rest: an - // rlib-local `#[no_mangle]` is not guaranteed to survive into the cdylib. - #[no_mangle] - pub extern "C" fn weaveffi_arena_create() -> *mut $crate::arena::HandleArena { - $crate::arena::arena_create() - } - - #[no_mangle] - pub extern "C" fn weaveffi_arena_register( - arena: *mut $crate::arena::HandleArena, - ptr: *mut ::std::ffi::c_void, - dtor: unsafe extern "C" fn(*mut ::std::ffi::c_void), - ) { - $crate::arena::arena_register(arena, ptr, dtor) - } - - #[no_mangle] - pub extern "C" fn weaveffi_arena_destroy(arena: *mut $crate::arena::HandleArena) { - $crate::arena::arena_destroy(arena) - } }; } diff --git a/crates/weaveffi-abi/src/object.rs b/crates/weaveffi-abi/src/object.rs new file mode 100644 index 00000000..7a23c77b --- /dev/null +++ b/crates/weaveffi-abi/src/object.rs @@ -0,0 +1,205 @@ +//! Reference-counted interface objects: the producer half of the ABI's object +//! model. +//! +//! An interface value crosses the C ABI as a `{tag}*` that is really an +//! [`Arc`] turned into a raw pointer with [`Arc::into_raw`]. The count +//! lives inside the producer's allocation, so every consumer wrapper, every +//! value buffer holding an object token, and every in-flight async call can +//! own its own strong reference: `{tag}_clone` bumps the count and +//! `{tag}_destroy` releases one reference. The object is dropped when the last +//! reference goes. +//! +//! The `#[weaveffi::module]` expansion calls these helpers from its generated +//! thunks; producers never touch them directly. They exist so every `unsafe` +//! pointer-to-`Arc` conversion has one audited home. + +use std::sync::Arc; + +/// Hand one strong reference to the consumer. +/// +/// Accepts either an owned `T` (wrapped in a fresh [`Arc`]) or an existing +/// `Arc` (whose reference is transferred), so a constructor returning +/// `Self` and a method returning `Arc` lower through the same call. +/// The consumer releases the reference with `{tag}_destroy`. +#[must_use] +pub fn lower_object(value: impl Into>) -> *mut T { + Arc::into_raw(value.into()).cast_mut() +} + +/// Lower an optional object: `None` becomes null, `Some` transfers one strong +/// reference exactly like [`lower_object`]. +#[must_use] +pub fn lower_object_opt(value: Option>>) -> *mut T { + match value { + Some(v) => lower_object(v), + None => std::ptr::null_mut(), + } +} + +/// Borrow an object for the duration of a call without touching its count. +/// Null yields `None`. +/// +/// # Safety +/// +/// `ptr` must be null or a pointer produced by [`lower_object`] (or +/// `{tag}_clone`) whose reference the caller keeps alive for the chosen +/// lifetime `'a`. Generated thunks bound `'a` by the call, matching the ABI +/// contract that object parameters are borrowed for the call's duration. +#[must_use] +pub unsafe fn object_ref<'a, T>(ptr: *const T) -> Option<&'a T> { + if ptr.is_null() { + None + } else { + // SAFETY: the caller guarantees `ptr` is a live `Arc` allocation + // for `'a`. + Some(unsafe { &*ptr }) + } +} + +/// Take a new strong reference to a borrowed object, so the producer can +/// retain it past the call. Null yields `None`. +/// +/// # Safety +/// +/// `ptr` must be null or a pointer produced by [`lower_object`] (or +/// `{tag}_clone`) whose reference is still live. +#[must_use] +pub unsafe fn object_arc(ptr: *const T) -> Option> { + if ptr.is_null() { + return None; + } + // SAFETY: the caller guarantees `ptr` came from `Arc::into_raw` and is + // live, so bumping the count and rebuilding an `Arc` is sound. + unsafe { + Arc::increment_strong_count(ptr); + Some(Arc::from_raw(ptr)) + } +} + +/// The body of every `{tag}_clone` symbol: return a new strong reference to +/// the same object. The pointer value is unchanged; null is a no-op returning +/// null. +/// +/// # Safety +/// +/// `ptr` must be null or a live pointer produced by [`lower_object`]. +#[must_use] +pub unsafe fn object_clone(ptr: *const T) -> *mut T { + if ptr.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: as documented on the function. + unsafe { Arc::increment_strong_count(ptr) }; + ptr.cast_mut() +} + +/// The body of every `{tag}_destroy` symbol: release one strong reference. +/// The object is dropped when the last reference goes. Null is a no-op, and +/// a panicking `Drop` is swallowed because a destructor has no `out_err` +/// slot and must never unwind into C. A foreign failure that `Drop` deferred +/// by calling back into the consumer is discarded for the same reason, so it +/// can't leak into the next unrelated thunk. +/// +/// # Safety +/// +/// `ptr` must be null or a live pointer produced by [`lower_object`] whose +/// reference the caller owns and will not use again. +pub unsafe fn object_destroy(ptr: *mut T) { + if ptr.is_null() { + return; + } + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: as documented on the function. + unsafe { Arc::decrement_strong_count(ptr) }; + })); + let _ = crate::take_foreign_error(); +} + +/// The object token an interface value takes inside a value buffer: the +/// [`lower_object`] pointer widened to `u64`. The token carries one strong +/// reference; whoever decodes it adopts that reference. +#[must_use] +pub fn object_to_token(value: &Arc) -> u64 { + Arc::into_raw(Arc::clone(value)) as usize as u64 +} + +/// Adopt the strong reference carried by an object token read from a value +/// buffer. A zero token is a contract violation and yields `None`. +/// +/// # Safety +/// +/// `token` must be zero or a token written by [`object_to_token`] (or by a +/// consumer that cloned the object before encoding) for a `T` allocation, +/// exactly once: adopting the same token twice double-frees. +#[must_use] +pub unsafe fn object_from_token(token: u64) -> Option> { + let ptr = token as usize as *const T; + if ptr.is_null() { + return None; + } + // SAFETY: the caller guarantees the token carries one live strong + // reference that has not been adopted yet. + Some(unsafe { Arc::from_raw(ptr) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Probe<'a>(&'a AtomicUsize); + impl Drop for Probe<'_> { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn clone_and_destroy_track_the_count() { + let drops = AtomicUsize::new(0); + let ptr = lower_object(Probe(&drops)); + let again = unsafe { object_clone(ptr) }; + assert_eq!(ptr, again); + unsafe { object_destroy(ptr) }; + assert_eq!(drops.load(Ordering::SeqCst), 0); + unsafe { object_destroy(again) }; + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn borrowing_does_not_change_the_count() { + let drops = AtomicUsize::new(0); + let ptr: *mut Probe<'_> = lower_object(Arc::new(Probe(&drops))); + { + let r = unsafe { object_ref(ptr) }.unwrap(); + assert!(std::ptr::eq(r, ptr)); + } + let retained = unsafe { object_arc(ptr) }.unwrap(); + unsafe { object_destroy(ptr) }; + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(retained); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn tokens_carry_one_reference() { + let drops = AtomicUsize::new(0); + let arc = Arc::new(Probe(&drops)); + let token = object_to_token(&arc); + assert_eq!(Arc::strong_count(&arc), 2); + let back: Arc> = unsafe { object_from_token(token) }.unwrap(); + assert!(Arc::ptr_eq(&arc, &back)); + drop(back); + assert_eq!(Arc::strong_count(&arc), 1); + assert!(unsafe { object_from_token::>(0) }.is_none()); + } + + #[test] + fn null_is_a_no_op_everywhere() { + assert!(unsafe { object_ref::(std::ptr::null()) }.is_none()); + assert!(unsafe { object_arc::(std::ptr::null()) }.is_none()); + assert!(unsafe { object_clone::(std::ptr::null()) }.is_null()); + unsafe { object_destroy::(std::ptr::null_mut()) }; + assert!(lower_object_opt::(None::).is_null()); + } +} diff --git a/crates/weaveffi-abi/src/spawn.rs b/crates/weaveffi-abi/src/spawn.rs new file mode 100644 index 00000000..ffd7f03a --- /dev/null +++ b/crates/weaveffi-abi/src/spawn.rs @@ -0,0 +1,201 @@ +//! The pluggable executor async exports run on. +//! +//! An exported `async fn` lowers to a launcher that returns immediately and a +//! completion callback that fires when the future resolves. Something has to +//! drive the future in between. By default WeaveFFI drives each one on a +//! dedicated thread with [`block_on`](crate::block_on), which needs no runtime +//! and is enough for CPU-bound work and futures woken from other threads. A +//! producer whose futures depend on a reactor (Tokio's I/O and timers, for +//! example) installs its own [`Spawner`] once at startup with [`set_spawner`]: +//! +//! ``` +//! # fn spawn_on_my_runtime(_f: weaveffi_abi::BoxFuture) {} +//! // e.g. `|fut| { tokio_handle.spawn(fut); }` +//! let _ = weaveffi_abi::set_spawner(|fut| spawn_on_my_runtime(fut)); +//! ``` +//! +//! Generated launchers call [`spawn`], which routes to the installed spawner +//! or the default. Every future handed to a spawner is `Send + 'static` and +//! already wrapped so a panic inside it is caught and reported through the +//! completion callback; a spawner never observes an unwinding future. + +use std::future::Future; +use std::pin::Pin; +use std::sync::OnceLock; +use std::task::{Context, Poll}; + +/// The type-erased future a [`Spawner`] receives. +pub type BoxFuture = Pin + Send + 'static>>; + +/// An executor hook that drives the futures produced by async exports. +/// +/// Implemented for any `Fn(BoxFuture) + Send + Sync + 'static`, so a closure +/// that forwards to a runtime handle is enough. +pub trait Spawner: Send + Sync + 'static { + /// Schedule `fut` to run to completion. Must not block the caller: the + /// launcher that invokes it is on the consumer's thread. + fn spawn(&self, fut: BoxFuture); +} + +impl Spawner for F +where + F: Fn(BoxFuture) + Send + Sync + 'static, +{ + fn spawn(&self, fut: BoxFuture) { + self(fut); + } +} + +static SPAWNER: OnceLock> = OnceLock::new(); + +/// The error returned when [`set_spawner`] is called a second time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpawnerAlreadySet; + +impl std::fmt::Display for SpawnerAlreadySet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a WeaveFFI spawner has already been installed") + } +} + +impl std::error::Error for SpawnerAlreadySet {} + +/// Install the process-wide spawner async exports run on. +/// +/// Call once, before the first async export is launched (an initialization +/// export or a library constructor is the natural place). Until it's called, +/// and forever after if it never is, the default thread-per-future spawner +/// is used. +/// +/// # Errors +/// +/// Returns [`SpawnerAlreadySet`] if a spawner was installed already; the +/// first one wins. +pub fn set_spawner(spawner: impl Spawner) -> Result<(), SpawnerAlreadySet> { + SPAWNER + .set(Box::new(spawner)) + .map_err(|_| SpawnerAlreadySet) +} + +/// Run `fut` on the installed spawner, or on the default one. +/// +/// The default drives the future on a freshly spawned thread with +/// [`block_on`](crate::block_on). On `wasm32`, which has no threads, the +/// future is driven inline before this returns. +pub fn spawn(fut: impl Future + Send + 'static) { + let fut: BoxFuture = Box::pin(fut); + match SPAWNER.get() { + Some(spawner) => spawner.spawn(fut), + None => default_spawn(fut), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn default_spawn(fut: BoxFuture) { + std::thread::spawn(move || crate::block_on(fut)); +} + +#[cfg(target_arch = "wasm32")] +fn default_spawn(fut: BoxFuture) { + crate::block_on(fut); +} + +/// A future adapter that catches a panic from the inner future's `poll` and +/// resolves to `Err(payload)` instead of unwinding into the executor. +/// +/// This is how generated async launchers keep the "callback fires exactly +/// once" promise even when the producer's future panics: the launcher awaits +/// `CatchUnwind::new(user_future)` and reports an `Err` through the +/// completion callback with the reserved panic code. +/// +/// It also closes the deferred foreign-error route for async producers: when +/// the inner future completes on a `panic = "abort"` build with a failure +/// recorded by [`defer_foreign_error`](crate::defer_foreign_error), the +/// adapter resolves to `Err` carrying that +/// [`ForeignError`](crate::ForeignError) instead of the producer's value. +pub struct CatchUnwind { + inner: Option>>, +} + +impl CatchUnwind { + /// Wrap `fut`. + pub fn new(fut: F) -> Self { + Self { + inner: Some(Box::pin(fut)), + } + } +} + +impl Future for CatchUnwind { + type Output = Result>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let Some(inner) = self.inner.as_mut() else { + // Polled after completion; a well-behaved executor never does + // this, and there is nothing left to drive. + return Poll::Pending; + }; + let polled = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| inner.as_mut().poll(cx))); + match polled { + Ok(Poll::Pending) => Poll::Pending, + Ok(Poll::Ready(out)) => { + self.inner = None; + match crate::take_foreign_error() { + Some(foreign) => Poll::Ready(Err(Box::new(foreign))), + None => Poll::Ready(Ok(out)), + } + } + Err(payload) => { + self.inner = None; + Poll::Ready(Err(payload)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn default_spawner_runs_the_future() { + let (tx, rx) = mpsc::channel(); + spawn(async move { + tx.send(7).unwrap(); + }); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(7)); + } + + #[test] + fn catch_unwind_reports_panics_and_values() { + let ok = crate::block_on(CatchUnwind::new(async { 5 })); + assert_eq!(ok.unwrap(), 5); + let err = crate::block_on(CatchUnwind::new(async { + if true { + panic!("boom"); + } + 1 + })); + let payload = err.unwrap_err(); + assert_eq!(crate::panic_message(&*payload), "boom"); + } + + #[test] + fn catch_unwind_surfaces_a_deferred_foreign_error() { + let err = crate::block_on(CatchUnwind::new(async { + crate::defer_foreign_error(crate::ForeignError { + code: crate::FOREIGN_ERROR_CODE, + message: "consumer failed".into(), + }); + 9 + })); + let payload = err.unwrap_err(); + let foreign = payload + .downcast_ref::() + .expect("ForeignError payload"); + assert_eq!(foreign.code, crate::FOREIGN_ERROR_CODE); + assert_eq!(foreign.message, "consumer failed"); + } +} diff --git a/crates/weaveffi-abi/tests/buffer_proptest.rs b/crates/weaveffi-abi/tests/buffer_proptest.rs new file mode 100644 index 00000000..7021e02c --- /dev/null +++ b/crates/weaveffi-abi/tests/buffer_proptest.rs @@ -0,0 +1,310 @@ +//! Property tests for the value-buffer codec. +//! +//! Every `BufferValue` implementation must satisfy two laws that the +//! generated thunks and every language runtime rely on: +//! +//! 1. **Round trip**: `decode(encode(v)) == v` for every well-typed value. +//! 2. **Self-delimiting**: a value's encoding is consumed exactly, so two +//! encodings concatenated decode back into the two original values, and +//! a decode that is handed extra trailing bytes reports an error rather +//! than silently ignoring them. +//! +//! A third family checks that arbitrary byte soup never panics the reader: +//! it either decodes or returns `BufferDecodeError`. + +use std::collections::BTreeMap; + +use proptest::prelude::*; +use weaveffi_abi::{decode_value, encode_value, BufferReader, BufferValue, BufferWriter}; + +/// A hand-rolled record covering every scalar, the two byte-oriented shapes, +/// and the three containers, nested two deep. Mirrors what +/// `#[weaveffi::record]` expands to, without depending on the macro crate. +#[derive(Debug, Clone, PartialEq)] +struct Composite { + flag: bool, + tiny: i8, + byte: u8, + short: i16, + ushort: u16, + int: i32, + uint: u32, + long: i64, + ulong: u64, + single: f32, + double: f64, + text: String, + blob: Vec, + maybe: Option, + list: Vec, + map: BTreeMap, + nested: Vec>>, + deep: BTreeMap>>, +} + +impl BufferValue for Composite { + fn write_value(&self, w: &mut BufferWriter) { + self.flag.write_value(w); + self.tiny.write_value(w); + self.byte.write_value(w); + self.short.write_value(w); + self.ushort.write_value(w); + self.int.write_value(w); + self.uint.write_value(w); + self.long.write_value(w); + self.ulong.write_value(w); + self.single.write_value(w); + self.double.write_value(w); + self.text.write_value(w); + w.write_bytes(&self.blob); + self.maybe.write_value(w); + self.list.write_value(w); + self.map.write_value(w); + w.write_len(self.nested.len()); + for item in &self.nested { + match item { + Some(b) => { + w.write_option_flag(true); + w.write_bytes(b); + } + None => w.write_option_flag(false), + } + } + self.deep.write_value(w); + } + + fn read_value(r: &mut BufferReader<'_>) -> Result { + let flag = bool::read_value(r)?; + let tiny = i8::read_value(r)?; + let byte = u8::read_value(r)?; + let short = i16::read_value(r)?; + let ushort = u16::read_value(r)?; + let int = i32::read_value(r)?; + let uint = u32::read_value(r)?; + let long = i64::read_value(r)?; + let ulong = u64::read_value(r)?; + let single = f32::read_value(r)?; + let double = f64::read_value(r)?; + let text = String::read_value(r)?; + let blob = r.read_bytes()?; + let maybe = Option::::read_value(r)?; + let list = Vec::::read_value(r)?; + let map = BTreeMap::::read_value(r)?; + let count = r.read_len()?; + let mut nested = Vec::with_capacity(count.min(r.remaining())); + for _ in 0..count { + nested.push(if r.read_option_flag()? { + Some(r.read_bytes()?) + } else { + None + }); + } + let deep = BTreeMap::>>::read_value(r)?; + Ok(Self { + flag, + tiny, + byte, + short, + ushort, + int, + uint, + long, + ulong, + single, + double, + text, + blob, + maybe, + list, + map, + nested, + deep, + }) + } +} + +/// Floats are compared bitwise so NaN payloads survive the round trip too. +fn finite_or_special_f32() -> impl Strategy { + prop_oneof![ + any::(), + Just(f32::NAN), + Just(f32::INFINITY), + Just(f32::NEG_INFINITY), + Just(-0.0), + ] +} + +fn finite_or_special_f64() -> impl Strategy { + prop_oneof![ + any::(), + Just(f64::NAN), + Just(f64::INFINITY), + Just(f64::NEG_INFINITY), + Just(-0.0), + ] +} + +fn composite() -> impl Strategy { + ( + ( + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + finite_or_special_f32(), + finite_or_special_f64(), + ), + ( + ".*", + proptest::collection::vec(any::(), 0..64), + proptest::option::of(any::()), + proptest::collection::vec(".*", 0..8), + proptest::collection::btree_map(".{0,12}", any::(), 0..8), + proptest::collection::vec( + proptest::option::of(proptest::collection::vec(any::(), 0..16)), + 0..6, + ), + proptest::collection::btree_map( + any::(), + proptest::collection::vec(proptest::option::of(".{0,6}"), 0..4), + 0..4, + ), + ), + ) + .prop_map( + |( + (flag, tiny, byte, short, ushort, int, uint, long, ulong, single, double), + (text, blob, maybe, list, map, nested, deep), + )| Composite { + flag, + tiny, + byte, + short, + ushort, + int, + uint, + long, + ulong, + single, + double, + text, + blob, + maybe, + list, + map, + nested, + deep, + }, + ) +} + +/// Bitwise float equality so `NaN == NaN` and `0.0 != -0.0`. +fn same_composite(a: &Composite, b: &Composite) -> bool { + a.flag == b.flag + && a.tiny == b.tiny + && a.byte == b.byte + && a.short == b.short + && a.ushort == b.ushort + && a.int == b.int + && a.uint == b.uint + && a.long == b.long + && a.ulong == b.ulong + && a.single.to_bits() == b.single.to_bits() + && a.double.to_bits() == b.double.to_bits() + && a.text == b.text + && a.blob == b.blob + && a.maybe == b.maybe + && a.list == b.list + && a.map == b.map + && a.nested == b.nested + && a.deep == b.deep +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn composite_round_trips(value in composite()) { + let bytes = encode_value(&value); + let back: Composite = decode_value(&bytes).expect("decode"); + prop_assert!(same_composite(&value, &back), "{value:?} != {back:?}"); + } + + #[test] + fn encodings_are_self_delimiting(a in composite(), b in composite()) { + let mut bytes = encode_value(&a); + bytes.extend(encode_value(&b)); + let mut r = BufferReader::new(&bytes); + let first = Composite::read_value(&mut r).expect("first"); + let second = Composite::read_value(&mut r).expect("second"); + r.expect_end().expect("exactly two values"); + prop_assert!(same_composite(&a, &first)); + prop_assert!(same_composite(&b, &second)); + } + + #[test] + fn trailing_bytes_are_rejected(value in composite(), extra in 1usize..8) { + let mut bytes = encode_value(&value); + bytes.extend(std::iter::repeat_n(0xAB, extra)); + prop_assert!(decode_value::(&bytes).is_err()); + } + + #[test] + fn truncation_is_an_error_not_a_panic(value in composite(), cut in 0usize..4096) { + let bytes = encode_value(&value); + if bytes.is_empty() { + return Ok(()); + } + let cut = cut % bytes.len(); + // Any strict prefix must fail to decode. It can never succeed because + // every scalar encoding is fixed width and every container announces + // its length up front, so removing bytes always starves a read. + prop_assert!(decode_value::(&bytes[..cut]).is_err()); + } + + #[test] + fn garbage_never_panics(bytes in proptest::collection::vec(any::(), 0..256)) { + let _ = decode_value::(&bytes); + let _ = decode_value::>(&bytes); + let _ = decode_value::>>>(&bytes); + let _ = decode_value::>>(&bytes); + let _ = decode_value::(&bytes); + } + + #[test] + fn strings_round_trip_any_unicode(s in "\\PC*") { + let bytes = encode_value(&s); + prop_assert_eq!(decode_value::(&bytes).unwrap(), s); + } + + #[test] + fn invalid_utf8_is_rejected(prefix in ".{0,8}", bad in proptest::collection::vec(0x80u8..0xC0, 1..4)) { + // A lone continuation byte can never start a valid UTF-8 sequence. + let mut payload = prefix.into_bytes(); + payload.extend(bad); + let mut w = BufferWriter::new(); + w.write_bytes(&payload); + let bytes = w.finish(); + prop_assert!(decode_value::(&bytes).is_err()); + } + + #[test] + fn scalar_encodings_are_little_endian(x in any::(), y in any::(), z in any::()) { + prop_assert_eq!(encode_value(&x), x.to_le_bytes().to_vec()); + prop_assert_eq!(encode_value(&y), y.to_le_bytes().to_vec()); + prop_assert_eq!(encode_value(&z), z.to_bits().to_le_bytes().to_vec()); + } + + #[test] + fn list_length_prefix_is_u32(items in proptest::collection::vec(any::(), 0..32)) { + let bytes = encode_value(&items); + let declared = u32::from_le_bytes(bytes[..4].try_into().unwrap()); + prop_assert_eq!(declared as usize, items.len()); + prop_assert_eq!(bytes.len(), 4 + 4 * items.len()); + } +} diff --git a/crates/weaveffi-abi/tests/export_runtime.rs b/crates/weaveffi-abi/tests/export_runtime.rs index decf4492..1ecdd8b9 100644 --- a/crates/weaveffi-abi/tests/export_runtime.rs +++ b/crates/weaveffi-abi/tests/export_runtime.rs @@ -27,22 +27,34 @@ export_runtime!(); fn macro_emits_all_runtime_symbols() { // Compile-time guarantee: every symbol the C ABI generators expect // must be addressable from this test crate. + let _f_version: extern "C" fn() -> u32 = weaveffi_abi_version; let _f_string: extern "C" fn(*const std::os::raw::c_char) = weaveffi_free_string; let _f_bytes: extern "C" fn(*mut u8, usize) = weaveffi_free_bytes; + let _f_err_set: extern "C" fn(*mut weaveffi_error, i32, *const std::os::raw::c_char) = + weaveffi_error_set; let _f_err_clear: extern "C" fn(*mut weaveffi_error) = weaveffi_error_clear; + let _f_err_free: extern "C" fn(*mut weaveffi_error) = weaveffi_error_free; let _f_tok_create: extern "C" fn() -> *mut weaveffi_cancel_token = weaveffi_cancel_token_create; let _f_tok_cancel: extern "C" fn(*mut weaveffi_cancel_token) = weaveffi_cancel_token_cancel; let _f_tok_is_cancelled: extern "C" fn(*const weaveffi_cancel_token) -> bool = weaveffi_cancel_token_is_cancelled; let _f_tok_destroy: extern "C" fn(*mut weaveffi_cancel_token) = weaveffi_cancel_token_destroy; + assert_eq!(weaveffi_abi_version(), abi::ABI_VERSION); +} - let _f_arena_create: extern "C" fn() -> *mut abi::arena::HandleArena = weaveffi_arena_create; - let _f_arena_register: extern "C" fn( - *mut abi::arena::HandleArena, - *mut std::ffi::c_void, - unsafe extern "C" fn(*mut std::ffi::c_void), - ) = weaveffi_arena_register; - let _f_arena_destroy: extern "C" fn(*mut abi::arena::HandleArena) = weaveffi_arena_destroy; +#[test] +fn macro_error_set_thunk_copies_the_message() { + let mut err = weaveffi_error::default(); + let msg = CString::new("consumer failure").unwrap(); + weaveffi_error_set(&mut err, abi::FOREIGN_ERROR_CODE, msg.as_ptr()); + drop(msg); + assert_eq!(err.code, abi::FOREIGN_ERROR_CODE); + assert_eq!( + abi::c_ptr_to_string(err.message).as_deref(), + Some("consumer failure") + ); + weaveffi_error_clear(&mut err); + weaveffi_error_set(ptr::null_mut(), 1, ptr::null()); } #[test] @@ -92,36 +104,6 @@ fn macro_cancel_token_thunks_round_trip() { weaveffi_cancel_token_destroy(ptr::null_mut()); } -#[test] -fn macro_arena_thunks_round_trip() { - use std::ffi::c_void; - use std::sync::atomic::{AtomicUsize, Ordering}; - - unsafe extern "C" fn counting_dtor(ptr: *mut c_void) { - let counter = ptr as *const AtomicUsize; - (*counter).fetch_add(1, Ordering::SeqCst); - } - - let counter = AtomicUsize::new(0); - let counter_ptr = &counter as *const AtomicUsize as *mut c_void; - - let arena = weaveffi_arena_create(); - assert!(!arena.is_null()); - - weaveffi_arena_register(arena, counter_ptr, counting_dtor); - weaveffi_arena_register(arena, counter_ptr, counting_dtor); - - weaveffi_arena_destroy(arena); - assert_eq!(counter.load(Ordering::SeqCst), 2); - - weaveffi_arena_register( - ptr::null_mut(), - ptr::dangling_mut::(), - counting_dtor, - ); - weaveffi_arena_destroy(ptr::null_mut()); -} - #[test] fn macro_thunks_keep_weaveffi_prefix_even_when_user_renames_imports() { // Re-import everything under a different alias to make sure the diff --git a/crates/weaveffi-bridge/src/lib.rs b/crates/weaveffi-bridge/src/lib.rs index bb2e39a2..e728258c 100644 --- a/crates/weaveffi-bridge/src/lib.rs +++ b/crates/weaveffi-bridge/src/lib.rs @@ -20,29 +20,33 @@ //! an asynchronous symbol; a `fn -> Result` is fallible (`throws` in //! the IDL; the return type is `T` and errors are reported through the ABI's //! `out_err`). -//! * `#[weaveffi::interface]` on a `struct` declares an interface (opaque -//! object type). Its `impl` block's `pub fn`s become the interface's -//! members: an associated function returning `Self` is a constructor, a -//! `&self` function is a method, and any other associated function is a -//! static. +//! * `#[weaveffi::interface]` on a `struct` declares an interface (opaque, +//! reference-counted object type). Its `impl` block's `pub fn`s become the +//! interface's members: an associated function returning `Self` (or +//! `Arc`) is a constructor, a `&self` or `self: Arc` function is +//! a method, and any other associated function is a static. //! * `#[weaveffi::record]` on a `struct` declares a by-value record. //! * `#[weaveffi::enumeration]` on a `#[repr(i32)]` `enum` declares a C-style -//! enum. -//! * `#[weaveffi::callback]` on a `fn` declares a callback signature, and -//! `#[weaveffi::listener(event = "Name")]` declares an event listener. +//! enum; an enum with data-carrying variants declares a rich enum. +//! * `#[weaveffi::error]` on an enum declares the module's error domain. +//! * `#[weaveffi::callback_interface]` on a `trait` declares a callback +//! interface: a method set the consumer implements. Producers accept one as +//! `Arc`. //! -//! The type mapping mirrors the IDL: `String`/`&str` are strings, `Vec` and -//! `&[u8]` are byte buffers, every integer primitive (including `u64`) is the -//! matching scalar, `weaveffi::Handle` is an opaque `handle`, `*mut T`/`*const T` -//! is a typed handle, and other named paths are records, enums, or interfaces -//! resolved later. +//! The type mapping mirrors the IDL: `String` (or `&str`) is a string, `Vec` +//! (or `&[u8]`) is a byte buffer, every integer primitive (including `u64`) is +//! the matching scalar, `Vec`, `Option`, and `HashMap`/`BTreeMap` are the +//! list, optional, and map types, `weaveffi::Iter` is an `iter` return, +//! `Arc` names the interface `T` (an object reference), `Arc` +//! names the callback interface `Trait`, and any other named path is a record, +//! enum, or interface resolved later. #![deny(missing_docs)] use syn::spanned::Spanned; use weaveffi_ir::ir::{ - Api, CallbackDef, EnumDef, EnumVariant, ErrorCode, ErrorDomain, Function, InterfaceDef, - ListenerDef, Module, Param, StructDef, StructField, TypeRef, CURRENT_SCHEMA_VERSION, + Api, CallbackInterfaceDef, EnumDef, EnumVariant, ErrorCode, ErrorDomain, Function, + InterfaceDef, Module, Param, StructDef, StructField, TypeRef, CURRENT_SCHEMA_VERSION, }; /// Match a WeaveFFI marker attribute by its final path segment. @@ -59,10 +63,6 @@ pub fn has_marker(attrs: &[syn::Attribute], name: &str) -> bool { attrs.iter().any(|a| is_marker(a, name)) } -fn find_marker<'a>(attrs: &'a [syn::Attribute], name: &str) -> Option<&'a syn::Attribute> { - attrs.iter().find(|a| is_marker(a, name)) -} - fn has_repr_i32(attrs: &[syn::Attribute]) -> bool { attrs.iter().any(|a| { a.path().is_ident("repr") && a.parse_args::().is_ok_and(|id| id == "i32") @@ -98,33 +98,6 @@ fn parse_deprecated(attrs: &[syn::Attribute]) -> (Option, Option (since, note) } -/// Parse the listener's referenced callback from -/// `#[weaveffi::listener(event = "Name")]` (the legacy `event_callback` key is -/// also accepted). -fn parse_listener_event(attr: &syn::Attribute) -> syn::Result { - if matches!(attr.meta, syn::Meta::Path(_)) { - return Err(syn::Error::new_spanned( - attr, - "#[weaveffi::listener] requires `event = \"\"`", - )); - } - let mut callback = None; - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("event") || meta.path.is_ident("event_callback") { - let value = meta.value()?; - let lit: syn::LitStr = value.parse()?; - callback = Some(lit.value()); - } - Ok(()) - })?; - callback.ok_or_else(|| { - syn::Error::new_spanned( - attr, - "#[weaveffi::listener] requires `event = \"\"`", - ) - }) -} - fn extract_doc(attrs: &[syn::Attribute]) -> Option { let lines: Vec = attrs .iter() @@ -199,6 +172,41 @@ fn type_path_ident(ty: &syn::Type) -> Option { p.path.segments.last().map(|s| s.ident.to_string()) } +/// The bare name of the single trait a `dyn Trait` object names, ignoring +/// auto-trait and lifetime bounds (`dyn Listener + Send + Sync + 'static`). +fn trait_object_name(ty: &syn::Type) -> Option { + let syn::Type::TraitObject(obj) = ty else { + return None; + }; + let mut names = obj.bounds.iter().filter_map(|b| match b { + syn::TypeParamBound::Trait(t) => { + let name = t.path.segments.last()?.ident.to_string(); + (!matches!(name.as_str(), "Send" | "Sync" | "Unpin")).then_some(name) + } + _ => None, + }); + let first = names.next()?; + names.next().is_none().then_some(first) +} + +/// Peel `Arc` to its `T`, returning any other type unchanged. +/// +/// `Arc` is how a producer spells an interface object it holds or returns +/// (`Arc`, `Arc`) and a callback interface it accepts +/// (`Arc`); the IR names the pointee type in both cases. +pub fn peel_arc(ty: &syn::Type) -> &syn::Type { + if let syn::Type::Path(p) = ty { + if let Some(seg) = p.path.segments.last() { + if seg.ident == "Arc" { + if let Ok(inner) = single_generic_arg(seg) { + return inner; + } + } + } + } + ty +} + /// Whether a parameter's type is the reserved `weaveffi::CancelToken`. /// /// A `#[weaveffi::cancellable]` `async fn` accepts a `CancelToken` as its final @@ -214,42 +222,52 @@ fn is_cancel_token(ty: &syn::Type) -> bool { /// /// This is the canonical mapping every caller shares. Notable conventions: /// -/// * `String` is an owned `string`, `&str` a borrowed one; `Vec` is owned -/// `bytes`, `&[u8]` borrowed. -/// * every integer primitive maps to its matching scalar (`u64` included); -/// `weaveffi::Handle` is the opaque `handle`, and `*mut T` / `*const T` is a -/// typed `handle`. -/// * `Vec`, `Option`, and `HashMap`/`BTreeMap` map to list, optional, and -/// map types; any other named path is a record or enum reference. +/// * `String` and `&str` are both the `string` type; `Vec` and `&[u8]` +/// are both `bytes`. A reference is a producer-side calling convention (the +/// thunk lifts an owned value and lends it), not an IDL distinction. +/// * every integer primitive maps to its matching scalar (`u64` included). +/// * `Vec` and `&[T]` are lists, `Option` is an optional, and +/// `HashMap`/`BTreeMap` are maps. +/// * `weaveffi::Iter` is an `iter` return. +/// * `Arc` and `&T` name the interface `T`; `Arc` names the +/// callback interface `Trait`. Any other named path is a record, enum, +/// interface, or callback interface reference resolved by the validator. /// /// # Errors /// /// Returns a spanned error for type syntax WeaveFFI cannot express across the -/// FFI boundary (for example a slice that is not `&[u8]`, or a generic with the -/// wrong arity). +/// FFI boundary (raw pointers, `Box`, tuples, a generic with the +/// wrong arity, and so on). pub fn type_ref_from_syn(ty: &syn::Type) -> syn::Result { match ty { syn::Type::Reference(r) => { if let syn::Type::Path(p) = r.elem.as_ref() { if p.path.is_ident("str") { - return Ok(TypeRef::BorrowedStr); + return Ok(TypeRef::StringUtf8); } } if let syn::Type::Slice(slice) = r.elem.as_ref() { if is_ident(&slice.elem, "u8") { - return Ok(TypeRef::BorrowedBytes); + return Ok(TypeRef::Bytes); } + return Ok(TypeRef::List(Box::new(type_ref_from_syn(&slice.elem)?))); } type_ref_from_syn(&r.elem) } - syn::Type::Ptr(p) => { - let name = type_path_ident(&p.elem).ok_or_else(|| { + syn::Type::Ptr(_) => Err(syn::Error::new( + ty.span(), + "weaveffi: raw pointers cannot cross the FFI boundary; declare the pointee as a \ + #[weaveffi::interface] and pass it as `&T` or `Arc`", + )), + syn::Type::TraitObject(_) => { + let name = trait_object_name(ty).ok_or_else(|| { syn::Error::new( ty.span(), - "unsupported pointer target; expected a named type", + "weaveffi: a trait object must name exactly one #[weaveffi::callback_interface] \ + trait (plus optional `Send`/`Sync` bounds)", ) })?; - Ok(TypeRef::TypedHandle(name)) + Ok(TypeRef::Named(name)) } syn::Type::Path(type_path) => { let seg = type_path @@ -271,9 +289,17 @@ pub fn type_ref_from_syn(ty: &syn::Type) -> syn::Result { "bool" => Ok(TypeRef::Bool), "String" => Ok(TypeRef::StringUtf8), "u64" => Ok(TypeRef::U64), - // `weaveffi::Handle` (an alias of `u64`) is the explicit - // opaque-handle spelling; a bare `u64` is a real scalar. - "Handle" => Ok(TypeRef::Handle), + // `Arc` is a reference to the interface object `T`, and + // `Arc` a consumer-implemented callback interface; + // the IR names the pointee in both cases. + "Arc" => type_ref_from_syn(single_generic_arg(seg)?), + "Box" | "Rc" => Err(syn::Error::new( + ty.span(), + format!( + "weaveffi: `{ident}` cannot cross the FFI boundary; objects and callback \ + interfaces are shared, so spell them as `Arc` / `Arc`" + ), + )), "Vec" => { let inner = single_generic_arg(seg)?; if is_ident(inner, "u8") { @@ -395,41 +421,90 @@ fn extract_params( syn::Pat::Ident(id) => id.ident.to_string(), _ => return Err(syn::Error::new(pt.span(), "unsupported parameter pattern")), }; - let mutable = - matches!(pt.ty.as_ref(), syn::Type::Reference(r) if r.mutability.is_some()); + if matches!(pt.ty.as_ref(), syn::Type::Reference(r) if r.mutability.is_some()) { + return Err(syn::Error::new( + pt.ty.span(), + "weaveffi: `&mut` parameters cannot cross the FFI boundary; take the value \ + by `&T` or by value and return the updated result", + )); + } + let ty = type_ref_from_syn(&pt.ty)?; + // The proc macro path skips `validate_api` (see the macro crate), + // so the one type shape lowering can't represent as a parameter + // must be caught here with a span instead of panicking later. + let mut has_iterator = false; + ty.walk(&mut |t| has_iterator |= matches!(t, TypeRef::Iterator(_))); + if has_iterator { + return Err(syn::Error::new( + pt.ty.span(), + "weaveffi: `Iter` is a pull handle the consumer drives and is only \ + valid as a function's outermost return type, not as a parameter", + )); + } Ok(Param { name: param_name, - ty: type_ref_from_syn(&pt.ty)?, - mutable, + ty, doc: extract_doc(&pt.attrs), }) }) .collect() } -fn extract_function(item: &syn::ItemFn) -> syn::Result { - let (since, deprecated) = parse_deprecated(&item.attrs); +/// Map one `fn` signature (free function, interface member, or callback +/// method) to the IR [`Function`]. `returns` is supplied by the caller because +/// constructors and `Self` returns need interface-aware handling. +fn function_from_sig( + sig: &syn::Signature, + attrs: &[syn::Attribute], + returns: Option, +) -> syn::Result { + if let Some(ret) = &returns { + let nested_iterator = |t: &TypeRef| { + let mut found = false; + t.walk(&mut |inner| found |= matches!(inner, TypeRef::Iterator(_))); + found + }; + let invalid = match ret { + TypeRef::Iterator(elem) => nested_iterator(elem), + other => nested_iterator(other), + }; + if invalid { + let span = match &sig.output { + syn::ReturnType::Type(_, ty) => ty.span(), + other => other.span(), + }; + return Err(syn::Error::new( + span, + "weaveffi: `Iter` is only valid as the outermost return type; it can't be \ + nested in an `Option`, `Vec`, map, or another `Iter`", + )); + } + } Ok(Function { - name: item.sig.ident.to_string(), - params: extract_params(&item.sig.inputs)?, - returns: return_type_from_syn(&item.sig.output)?, - doc: extract_doc(&item.attrs), - throws: output_is_result(&item.sig.output), - r#async: item.sig.asyncness.is_some(), - cancellable: has_marker(&item.attrs, "cancellable"), - deprecated, - since, + name: sig.ident.to_string(), + params: extract_params(&sig.inputs)?, + returns, + doc: extract_doc(attrs), + throws: output_is_result(&sig.output), + r#async: sig.asyncness.is_some(), + cancellable: has_marker(attrs, "cancellable"), + deprecated: parse_deprecated(attrs).1, }) } -/// Whether the (peeled) return type names the interface itself (`Self` or the -/// interface's own name), which classifies an associated function as a -/// constructor. +fn extract_function(item: &syn::ItemFn) -> syn::Result { + let returns = return_type_from_syn(&item.sig.output)?; + function_from_sig(&item.sig, &item.attrs, returns) +} + +/// Whether the (peeled) return type names the interface itself (`Self`, +/// `Arc`, or the interface's own name), which classifies an associated +/// function as a constructor. fn returns_self(output: &syn::ReturnType, iface: &str) -> bool { let syn::ReturnType::Type(_, ty) = output else { return false; }; - match type_path_ident(peel_result(ty)) { + match type_path_ident(peel_arc(peel_result(ty))) { Some(name) => name == "Self" || name == iface, None => false, } @@ -442,35 +517,40 @@ fn returns_self(output: &syn::ReturnType, iface: &str) -> bool { /// interface-named) return on a constructor is dropped: the IR leaves a /// constructor's `return` empty because the instance is implicit. fn extract_member(item: &syn::ImplItemFn, iface: &str, is_ctor: bool) -> syn::Result { - let (since, deprecated) = parse_deprecated(&item.attrs); let returns = if is_ctor { None } else { match return_type_from_syn(&item.sig.output)? { - // `fn hand(&self) -> Self` style returns name the interface. + // `fn hand(&self) -> Arc` style returns name the interface. Some(TypeRef::Named(name)) if name == "Self" => Some(TypeRef::Named(iface.to_string())), other => other, } }; - Ok(Function { - name: item.sig.ident.to_string(), - params: extract_params(&item.sig.inputs)?, - returns, - doc: extract_doc(&item.attrs), - throws: output_is_result(&item.sig.output), - r#async: item.sig.asyncness.is_some(), - cancellable: has_marker(&item.attrs, "cancellable"), - deprecated, - since, - }) + function_from_sig(&item.sig, &item.attrs, returns) +} + +/// Whether a method receiver is one WeaveFFI can lift: `&self` (a borrow for +/// the call) or `self: Arc` (a retained reference). +/// +/// `&mut self` and by-value `self` are rejected: the object is shared across +/// the FFI boundary (and may be in use on other threads), so mutable state +/// needs interior mutability. +pub fn receiver_is_supported(recv: &syn::Receiver) -> bool { + if recv.mutability.is_some() { + return false; + } + if recv.reference.is_some() { + return true; + } + recv.colon_token.is_some() && type_path_ident(&recv.ty).as_deref() == Some("Arc") } /// Classify and extract every `pub fn` of an interface's `impl` block into /// the interface's constructors, methods, and statics. /// -/// * a function with a `&self` receiver is a **method**; -/// * an associated function returning `Self` (or the interface type, -/// optionally inside `Result`) is a **constructor**; +/// * a function with a `&self` or `self: Arc` receiver is a **method**; +/// * an associated function returning `Self` or `Arc` (or the interface +/// type, optionally inside `Result`) is a **constructor**; /// * any other associated function is a **static**. /// /// Non-`pub` items are private helpers and stay unexported. @@ -484,12 +564,12 @@ fn members_from_impl(item_impl: &syn::ItemImpl, iface: &mut InterfaceDef) -> syn } match f.sig.receiver() { Some(recv) => { - if recv.mutability.is_some() || recv.reference.is_none() { + if !receiver_is_supported(recv) { return Err(syn::Error::new( recv.span(), - "weaveffi: interface methods must take `&self`; use interior \ - mutability (Mutex, RwLock, Cell) for mutable state, because the \ - object is shared across the FFI boundary", + "weaveffi: interface methods must take `&self` or `self: Arc`; \ + use interior mutability (Mutex, RwLock, atomics) for mutable state, \ + because the object is shared across the FFI boundary", )); } iface.methods.push(extract_member(f, &iface.name, false)?); @@ -507,21 +587,43 @@ fn members_from_impl(item_impl: &syn::ItemImpl, iface: &mut InterfaceDef) -> syn Ok(()) } -fn extract_callback(item: &syn::ItemFn) -> syn::Result { - Ok(CallbackDef { - name: item.sig.ident.to_string(), - params: extract_params(&item.sig.inputs)?, - doc: extract_doc(&item.attrs), - }) -} - -fn extract_listener(item: &syn::ItemFn) -> syn::Result { - let attr = find_marker(&item.attrs, "listener") - .ok_or_else(|| syn::Error::new(item.span(), "missing #[weaveffi::listener] attribute"))?; - Ok(ListenerDef { - name: item.sig.ident.to_string(), - event_callback: parse_listener_event(attr)?, +/// Extract a `#[weaveffi::callback_interface]` trait into a +/// [`CallbackInterfaceDef`]. +/// +/// Every trait method is a callback method the consumer implements. Methods +/// must take `&self`; the validator additionally rejects `async`, `Result` +/// returns, and non-direct return types, so those are mapped faithfully here +/// and reported with the IDL-level diagnostics. +fn extract_callback_interface(item: &syn::ItemTrait) -> syn::Result { + let mut methods = Vec::new(); + for trait_item in &item.items { + let syn::TraitItem::Fn(f) = trait_item else { + continue; + }; + match f.sig.receiver() { + Some(recv) if recv.reference.is_some() && recv.mutability.is_none() => {} + Some(recv) => { + return Err(syn::Error::new( + recv.span(), + "weaveffi: callback interface methods must take `&self`", + )); + } + None => { + return Err(syn::Error::new( + f.sig.span(), + "weaveffi: callback interface methods must take `&self` (associated \ + functions can't be implemented by the consumer)", + )); + } + } + let returns = return_type_from_syn(&f.sig.output)?; + methods.push(function_from_sig(&f.sig, &f.attrs, returns)?); + } + Ok(CallbackInterfaceDef { + name: item.ident.to_string(), doc: extract_doc(&item.attrs), + deprecated: parse_deprecated(&item.attrs).1, + methods, }) } @@ -722,27 +824,23 @@ fn extract_error_domain(item: &syn::ItemEnum) -> syn::Result { /// # Errors /// /// Returns a spanned error when an annotated item cannot be mapped to the IR -/// (an unsupported type, an enum without `#[repr(i32)]`, a listener missing its -/// `event`, and so on). +/// (an unsupported type, an enum without `#[repr(i32)]`, a callback interface +/// method without `&self`, and so on). pub fn module_from_item_mod(item_mod: &syn::ItemMod) -> syn::Result { let name = item_mod.ident.to_string(); let mut functions = Vec::new(); let mut interfaces: Vec = Vec::new(); let mut structs = Vec::new(); let mut enums = Vec::new(); - let mut callbacks = Vec::new(); - let mut listeners = Vec::new(); + let mut callback_interfaces = Vec::new(); let mut modules = Vec::new(); let mut errors: Option = None; if let Some((_, items)) = &item_mod.content { for item in items { match item { - syn::Item::Fn(f) if has_marker(&f.attrs, "listener") => { - listeners.push(extract_listener(f)?); - } - syn::Item::Fn(f) if has_marker(&f.attrs, "callback") => { - callbacks.push(extract_callback(f)?); + syn::Item::Trait(t) if has_marker(&t.attrs, "callback_interface") => { + callback_interfaces.push(extract_callback_interface(t)?); } syn::Item::Fn(f) if has_marker(&f.attrs, "export") => { functions.push(extract_function(f)?); @@ -806,8 +904,7 @@ pub fn module_from_item_mod(item_mod: &syn::ItemMod) -> syn::Result { interfaces, structs, enums, - callbacks, - listeners, + callback_interfaces, errors, modules, }) @@ -991,41 +1088,68 @@ mod tests { } #[test] - fn handle_and_typed_handle() { + fn references_and_arcs_name_the_pointee() { let m = one_module( r#" #[weaveffi::module] mod m { #[weaveffi::export] - pub fn open() -> weaveffi::Handle { 0 } + pub fn open(name: &str, data: &[u8], tags: &[String]) -> Arc { todo!() } #[weaveffi::export] - pub fn close(token: *mut Token) {} + pub fn peek(store: &Store, other: Option>) -> Option> { None } } "#, ); - assert_eq!(m.functions[0].returns, Some(TypeRef::Handle)); + let open = &m.functions[0]; + assert_eq!(open.params[0].ty, TypeRef::StringUtf8); + assert_eq!(open.params[1].ty, TypeRef::Bytes); assert_eq!( - m.functions[1].params[0].ty, - TypeRef::TypedHandle("Token".into()) + open.params[2].ty, + TypeRef::List(Box::new(TypeRef::StringUtf8)) ); + assert_eq!(open.returns, Some(TypeRef::Named("Store".into()))); + let peek = &m.functions[1]; + assert_eq!(peek.params[0].ty, TypeRef::Named("Store".into())); + let opt_store = TypeRef::Optional(Box::new(TypeRef::Named("Store".into()))); + assert_eq!(peek.params[1].ty, opt_store); + assert_eq!(peek.returns, Some(opt_store)); } #[test] - fn bare_u64_is_a_scalar_not_a_handle() { - let m = one_module( + fn raw_pointers_and_boxes_are_rejected() { + let err = api_from_src( r#" #[weaveffi::module] mod m { #[weaveffi::export] - pub fn sum(values: Vec) -> u64 { 0 } + pub fn close(token: *mut Token) {} + } + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("raw pointers")); + let err = api_from_src( + r#" + #[weaveffi::module] + mod m { #[weaveffi::export] - pub fn token(h: Handle) -> Handle { h } + pub fn listen(l: Box) {} } "#, - ); - assert_eq!(m.functions[0].returns, Some(TypeRef::U64)); - assert_eq!(m.functions[1].params[0].ty, TypeRef::Handle); - assert_eq!(m.functions[1].returns, Some(TypeRef::Handle)); + ) + .unwrap_err(); + assert!(err.to_string().contains("Arc")); + let err = api_from_src( + r#" + #[weaveffi::module] + mod m { + #[weaveffi::export] + pub fn bump(counter: &mut i32) {} + } + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("&mut")); } #[test] @@ -1036,7 +1160,7 @@ mod tests { mod m { #[weaveffi::export] #[weaveffi::cancellable] - pub async fn compact(store: *mut Store, cancel: weaveffi::CancelToken) -> i64 { 0 } + pub async fn compact(store: Arc, cancel: weaveffi::CancelToken) -> i64 { 0 } } "#, ); @@ -1046,26 +1170,106 @@ mod tests { // Only `store` survives; the `CancelToken` is dropped from the IDL. assert_eq!(f.params.len(), 1); assert_eq!(f.params[0].name, "store"); - assert_eq!(f.params[0].ty, TypeRef::TypedHandle("Store".into())); + assert_eq!(f.params[0].ty, TypeRef::Named("Store".into())); } #[test] - fn callback_and_listener() { + fn interface_members_and_receivers() { let m = one_module( r#" #[weaveffi::module] mod m { - #[weaveffi::callback] - pub fn on_message(text: String) {} - #[weaveffi::listener(event = "on_message")] - pub fn messages() {} + #[weaveffi::interface] + pub struct Store; + impl Store { + pub fn open(path: String) -> Result, StoreError> { todo!() } + pub fn new() -> Self { Store } + pub fn len(&self) -> u64 { 0 } + pub fn share(self: Arc) -> Arc { self } + pub fn default_path() -> String { String::new() } + fn private(&self) {} + } } "#, ); - assert_eq!(m.callbacks.len(), 1); - assert_eq!(m.callbacks[0].name, "on_message"); - assert_eq!(m.listeners.len(), 1); - assert_eq!(m.listeners[0].event_callback, "on_message"); + let s = &m.interfaces[0]; + assert_eq!(s.constructors.len(), 2); + assert!(s.constructors[0].throws); + assert_eq!(s.constructors[0].returns, None); + assert_eq!(s.methods.len(), 2); + assert_eq!(s.methods[1].returns, Some(TypeRef::Named("Store".into()))); + assert_eq!(s.statics.len(), 1); + } + + #[test] + fn mut_self_receiver_is_rejected() { + let err = api_from_src( + r#" + #[weaveffi::module] + mod m { + #[weaveffi::interface] + pub struct Store; + impl Store { + pub fn bump(&mut self) {} + } + } + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("&self")); + } + + #[test] + fn callback_interface_trait() { + let m = one_module( + r#" + #[weaveffi::module] + mod m { + /// Receives messages. + #[weaveffi::callback_interface] + pub trait Listener: Send + Sync { + fn on_message(&self, text: String, meta: Option); + fn level(&self) -> i32; + } + #[weaveffi::export] + pub fn subscribe(listener: Arc) {} + #[weaveffi::export] + pub fn subscribe_bounded(listener: Arc) {} + } + "#, + ); + assert_eq!(m.callback_interfaces.len(), 1); + let cb = &m.callback_interfaces[0]; + assert_eq!(cb.name, "Listener"); + assert_eq!(cb.doc.as_deref(), Some("Receives messages.")); + assert_eq!(cb.methods.len(), 2); + assert_eq!(cb.methods[0].params.len(), 2); + assert_eq!(cb.methods[1].returns, Some(TypeRef::I32)); + assert_eq!( + m.functions[0].params[0].ty, + TypeRef::Named("Listener".into()) + ); + assert_eq!( + m.functions[1].params[0].ty, + TypeRef::Named("Listener".into()) + ); + } + + #[test] + fn callback_interface_methods_need_self() { + let err = api_from_src( + r#" + #[weaveffi::module] + mod m { + #[weaveffi::callback_interface] + pub trait Listener { + fn make() -> i32; + } + } + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("&self")); } #[test] diff --git a/crates/weaveffi-cli/Cargo.toml b/crates/weaveffi-cli/Cargo.toml index ca27887b..e2742570 100644 --- a/crates/weaveffi-cli/Cargo.toml +++ b/crates/weaveffi-cli/Cargo.toml @@ -30,7 +30,7 @@ weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } weaveffi-bridge = { path = "../weaveffi-bridge", version = "0.22.0" } weaveffi-gen-c = { path = "../weaveffi-gen-c", version = "0.22.0" } weaveffi-gen-swift = { path = "../weaveffi-gen-swift", version = "0.22.0" } -weaveffi-gen-android = { path = "../weaveffi-gen-android", version = "0.22.0" } +weaveffi-gen-kotlin = { path = "../weaveffi-gen-kotlin", version = "0.22.0" } weaveffi-gen-node = { path = "../weaveffi-gen-node", version = "0.22.0" } weaveffi-gen-wasm = { path = "../weaveffi-gen-wasm", version = "0.22.0" } weaveffi-gen-python = { path = "../weaveffi-gen-python", version = "0.22.0" } diff --git a/crates/weaveffi-cli/benches/generate_bench.rs b/crates/weaveffi-cli/benches/generate_bench.rs index c56cb3d5..d6b255b3 100644 --- a/crates/weaveffi-cli/benches/generate_bench.rs +++ b/crates/weaveffi-cli/benches/generate_bench.rs @@ -5,12 +5,12 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use weaveffi_core::codegen::{ConfiguredBackend, Orchestrator, Target}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::validate::validate_api; -use weaveffi_gen_android::{AndroidConfig, AndroidGenerator}; use weaveffi_gen_c::{CConfig, CGenerator}; use weaveffi_gen_cpp::{CppConfig, CppGenerator}; use weaveffi_gen_dart::{DartConfig, DartGenerator}; use weaveffi_gen_dotnet::{DotnetConfig, DotnetGenerator}; use weaveffi_gen_go::{GoConfig, GoGenerator}; +use weaveffi_gen_kotlin::{KotlinConfig, KotlinGenerator}; use weaveffi_gen_node::{NodeConfig, NodeGenerator}; use weaveffi_gen_python::{PythonConfig, PythonGenerator}; use weaveffi_gen_ruby::{RubyConfig, RubyGenerator}; @@ -33,7 +33,6 @@ fn param(name: &str, ty: TypeRef) -> Param { Param { name: name.into(), ty, - mutable: false, doc: None, } } @@ -91,7 +90,6 @@ fn build_large_api() -> ResolvedApi { r#async: false, cancellable: false, deprecated: None, - since: None, }) .collect(); @@ -102,8 +100,7 @@ fn build_large_api() -> ResolvedApi { interfaces: vec![], structs, enums, - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], } @@ -112,7 +109,7 @@ fn build_large_api() -> ResolvedApi { validate_api( Api { - version: "0.8.0".into(), + version: "0.9.0".into(), modules, }, None, @@ -130,8 +127,8 @@ fn all_default_targets() -> Vec> { SwiftConfig::default(), )), Box::new(ConfiguredBackend::new( - AndroidGenerator, - AndroidConfig::default(), + KotlinGenerator, + KotlinConfig::default(), )), Box::new(ConfiguredBackend::new(NodeGenerator, NodeConfig::default())), Box::new(ConfiguredBackend::new(WasmGenerator, WasmConfig::default())), diff --git a/crates/weaveffi-cli/src/commands/diff.rs b/crates/weaveffi-cli/src/commands/diff.rs index affb4170..6a9732d2 100644 --- a/crates/weaveffi-cli/src/commands/diff.rs +++ b/crates/weaveffi-cli/src/commands/diff.rs @@ -151,7 +151,7 @@ mod tests { std::fs::write( &yml, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "modules:\n", " - name: math\n", " functions:\n", diff --git a/crates/weaveffi-cli/src/commands/generate.rs b/crates/weaveffi-cli/src/commands/generate.rs index 7e925c20..a7d3b92e 100644 --- a/crates/weaveffi-cli/src/commands/generate.rs +++ b/crates/weaveffi-cli/src/commands/generate.rs @@ -64,7 +64,7 @@ mod tests { std::fs::write( &yml, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "modules:\n", " - name: math\n", " functions:\n", @@ -96,7 +96,7 @@ mod tests { for expected in [ "c/weaveffi.h", "swift/Package.swift", - "android/build.gradle", + "kotlin/build.gradle.kts", "node/types.d.ts", "wasm/weaveffi_wasm.js", "python/weaveffi/__init__.py", diff --git a/crates/weaveffi-cli/src/commands/package.rs b/crates/weaveffi-cli/src/commands/package.rs index 08c93446..3247a73f 100644 --- a/crates/weaveffi-cli/src/commands/package.rs +++ b/crates/weaveffi-cli/src/commands/package.rs @@ -115,14 +115,19 @@ pub(crate) fn cmd_package( if !skipped.is_empty() && !quiet { eprintln!( - "note: these targets do not support binary packaging yet and were skipped: {}. \ - Run `weaveffi generate` for their source bindings.", + "note: these targets produced no package and were skipped: {}. A target is \ + skipped when it has no binary packaging or when no binary was found for the \ + platforms it ships (for example `wasm32` for wasm). Run `weaveffi generate` \ + for their source bindings.", skipped.join(", ") ); } if packaged == 0 { - bail!("none of the selected targets support binary packaging yet"); + bail!( + "none of the selected targets produced a package: check that --binaries (or \ + --build) provides a library for a platform each target ships" + ); } if !quiet { diff --git a/crates/weaveffi-cli/src/commands/validate.rs b/crates/weaveffi-cli/src/commands/validate.rs index 14a674e8..ff6eb22d 100644 --- a/crates/weaveffi-cli/src/commands/validate.rs +++ b/crates/weaveffi-cli/src/commands/validate.rs @@ -92,13 +92,14 @@ fn validation_error_code(err: &ValidationError) -> &'static str { ValidationError::DuplicateEnumValue { .. } => "DuplicateEnumValue", ValidationError::UnknownTypeRef { .. } => "UnknownTypeRef", ValidationError::InvalidMapKey { .. } => "InvalidMapKey", - ValidationError::BorrowedTypeInInvalidPosition { .. } => "BorrowedTypeInInvalidPosition", - ValidationError::DuplicateCallbackName { .. } => "DuplicateCallbackName", - ValidationError::UnsupportedCallbackParamType { .. } => "UnsupportedCallbackParamType", - ValidationError::ListenerCallbackNotFound { .. } => "ListenerCallbackNotFound", - ValidationError::DuplicateListenerName { .. } => "DuplicateListenerName", + ValidationError::DuplicateCallbackInterfaceName { .. } => "DuplicateCallbackInterfaceName", + ValidationError::EmptyCallbackInterface { .. } => "EmptyCallbackInterface", + ValidationError::DuplicateCallbackMethod { .. } => "DuplicateCallbackMethod", + ValidationError::InvalidCallbackMethod { .. } => "InvalidCallbackMethod", + ValidationError::CallbackInterfaceInInvalidPosition { .. } => { + "CallbackInterfaceInInvalidPosition" + } ValidationError::IteratorInInvalidPosition { .. } => "IteratorInInvalidPosition", - ValidationError::MutableParamUnsupported { .. } => "MutableParamUnsupported", ValidationError::AsyncIteratorReturn { .. } => "AsyncIteratorReturn", ValidationError::UnsupportedSchemaVersion { .. } => "UnsupportedSchemaVersion", ValidationError::DuplicateInterfaceName { .. } => "DuplicateInterfaceName", @@ -158,8 +159,8 @@ fn validation_error_to_json(err: &ValidationError) -> serde_json::Value { | ValidationError::EmptyStruct { module, name } | ValidationError::DuplicateEnumName { module, name } | ValidationError::EmptyEnum { module, name } - | ValidationError::DuplicateCallbackName { module, name } - | ValidationError::DuplicateListenerName { module, name } => { + | ValidationError::DuplicateCallbackInterfaceName { module, name } + | ValidationError::EmptyCallbackInterface { module, name } => { obj.insert("module".into(), Value::String(module.clone())); obj.insert("name".into(), Value::String(name.clone())); } @@ -194,42 +195,22 @@ fn validation_error_to_json(err: &ValidationError) -> serde_json::Value { ValidationError::InvalidMapKey { key_type } => { obj.insert("key_type".into(), Value::String(key_type.clone())); } - ValidationError::BorrowedTypeInInvalidPosition { ty, location } => { - obj.insert("type".into(), Value::String(ty.clone())); - obj.insert("location".into(), Value::String(location.clone())); - } - ValidationError::ListenerCallbackNotFound { - module, - listener, - callback, - } => { - obj.insert("module".into(), Value::String(module.clone())); - obj.insert("listener".into(), Value::String(listener.clone())); - obj.insert("callback".into(), Value::String(callback.clone())); + ValidationError::DuplicateCallbackMethod { interface, name } => { + obj.insert("interface".into(), Value::String(interface.clone())); + obj.insert("name".into(), Value::String(name.clone())); } - ValidationError::UnsupportedCallbackParamType { - module, - callback, - param, - ty, + ValidationError::InvalidCallbackMethod { + interface, + method, + reason, } => { - obj.insert("module".into(), Value::String(module.clone())); - obj.insert("callback".into(), Value::String(callback.clone())); - obj.insert("param".into(), Value::String(param.clone())); - obj.insert("type".into(), Value::String(ty.clone())); + obj.insert("interface".into(), Value::String(interface.clone())); + obj.insert("method".into(), Value::String(method.clone())); + obj.insert("reason".into(), Value::String((*reason).into())); } ValidationError::IteratorInInvalidPosition { location } => { obj.insert("location".into(), Value::String(location.clone())); } - ValidationError::MutableParamUnsupported { - function, - param, - ty, - } => { - obj.insert("function".into(), Value::String(function.clone())); - obj.insert("param".into(), Value::String(param.clone())); - obj.insert("type".into(), Value::String(ty.clone())); - } ValidationError::AsyncIteratorReturn { module, function } => { obj.insert("module".into(), Value::String(module.clone())); obj.insert("function".into(), Value::String(function.clone())); @@ -258,7 +239,8 @@ fn validation_error_to_json(err: &ValidationError) -> serde_json::Value { obj.insert("interface".into(), Value::String(interface.clone())); obj.insert("constructor".into(), Value::String(constructor.clone())); } - ValidationError::InterfaceInInvalidPosition { name, location } => { + ValidationError::InterfaceInInvalidPosition { name, location } + | ValidationError::CallbackInterfaceInInvalidPosition { name, location } => { obj.insert("name".into(), Value::String(name.clone())); obj.insert("location".into(), Value::String(location.clone())); } diff --git a/crates/weaveffi-cli/src/config.rs b/crates/weaveffi-cli/src/config.rs index 79fe51e9..2a81c7ec 100644 --- a/crates/weaveffi-cli/src/config.rs +++ b/crates/weaveffi-cli/src/config.rs @@ -17,7 +17,7 @@ //! [generators.swift] //! module_name = "KVStore" //! -//! [generators.android] +//! [generators.kotlin] //! package = "com.example.kvstore" //! ``` //! @@ -30,12 +30,12 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use serde::Deserialize; use weaveffi_core::codegen::{ConfiguredBackend, OrchestratorHooks, Target}; use weaveffi_core::pkg::Package; -use weaveffi_gen_android::{AndroidConfig, AndroidGenerator}; use weaveffi_gen_c::{CConfig, CGenerator}; use weaveffi_gen_cpp::{CppConfig, CppGenerator}; use weaveffi_gen_dart::{DartConfig, DartGenerator}; use weaveffi_gen_dotnet::{DotnetConfig, DotnetGenerator}; use weaveffi_gen_go::{GoConfig, GoGenerator}; +use weaveffi_gen_kotlin::{KotlinConfig, KotlinGenerator}; use weaveffi_gen_node::{NodeConfig, NodeGenerator}; use weaveffi_gen_python::{PythonConfig, PythonGenerator}; use weaveffi_gen_ruby::{RubyConfig, RubyGenerator}; @@ -71,7 +71,7 @@ macro_rules! cli_targets { /// knob to a generator only requires changing that generator's crate. /// Generated by `cli_targets!`. #[derive(Debug, Default, Clone, Deserialize)] - #[serde(default)] + #[serde(default, deny_unknown_fields)] pub(crate) struct Targets { $( pub(crate) $field: $cfg, )* } @@ -124,7 +124,7 @@ cli_targets! { "c" => c: CConfig via CGenerator, "cpp" => cpp: CppConfig via CppGenerator, "swift" => swift: SwiftConfig via SwiftGenerator, strip, - "android" => android: AndroidConfig via AndroidGenerator, strip, + "kotlin" => kotlin: KotlinConfig via KotlinGenerator, strip, "node" => node: NodeConfig via NodeGenerator, strip, "wasm" => wasm: WasmConfig via WasmGenerator, "python" => python: PythonConfig via PythonGenerator, strip, @@ -331,7 +331,7 @@ mod tests { "c_prefix = \"kv\"\n", "[generators.swift]\n", "module_name = \"MyApp\"\n", - "[generators.android]\n", + "[generators.kotlin]\n", "package = \"com.example.myapp\"\n", ), ); @@ -339,7 +339,7 @@ mod tests { assert_eq!(cfg.package.name.as_deref(), Some("kvstore")); assert_eq!(cfg.package.version.as_deref(), Some("1.2.0")); assert_eq!(cfg.generators.swift.module_name(), "MyApp"); - assert_eq!(cfg.generators.android.package(), "com.example.myapp"); + assert_eq!(cfg.generators.kotlin.package(), "com.example.myapp"); assert_eq!(cfg.global.strip_module_prefix, Some(true)); assert_eq!(cfg.source.as_deref(), Some(cfg_path.as_path())); @@ -375,7 +375,7 @@ mod tests { CONFIG_FILE_NAME, "[package]\nname = \"found\"\n", ); - let input = write(&nested, "api.yml", "version: \"0.8.0\"\nmodules: []\n"); + let input = write(&nested, "api.yml", "version: \"0.9.0\"\nmodules: []\n"); let cfg = ProjectConfig::load(None, &input).unwrap(); assert_eq!(cfg.package.name.as_deref(), Some("found")); @@ -384,11 +384,11 @@ mod tests { #[test] fn defaults_when_no_config_exists() { let dir = tempfile::tempdir().unwrap(); - let input = write(dir.path(), "api.yml", "version: \"0.8.0\"\nmodules: []\n"); + let input = write(dir.path(), "api.yml", "version: \"0.9.0\"\nmodules: []\n"); let cfg = ProjectConfig::load(None, &input).unwrap(); assert!(cfg.source.is_none(), "unexpected config: {:?}", cfg.source); assert_eq!(cfg.generators.swift.module_name(), "WeaveFFI"); - assert_eq!(cfg.generators.android.package(), "com.weaveffi"); + assert_eq!(cfg.generators.kotlin.package(), "com.weaveffi"); assert_eq!(cfg.global.strip_module_prefix, None); assert!(cfg.package.name.is_none()); } diff --git a/crates/weaveffi-cli/src/main.rs b/crates/weaveffi-cli/src/main.rs index d8674237..bf76065d 100644 --- a/crates/weaveffi-cli/src/main.rs +++ b/crates/weaveffi-cli/src/main.rs @@ -49,7 +49,7 @@ enum Commands { /// Output directory for generated artifacts #[arg(short, long, default_value = "./generated")] out: String, - /// Comma-separated list of targets to generate (c, cpp, swift, android, node, wasm, python, dotnet, dart, go, ruby) + /// Comma-separated list of targets to generate (c, cpp, swift, kotlin, node, wasm, python, dotnet, dart, go, ruby) #[arg(short, long)] target: Option, /// Path to weaveffi.toml (default: the nearest one at or above the input file) @@ -90,7 +90,8 @@ enum Commands { #[arg(long)] config: Option, /// Directory of prebuilt native libraries laid out as `//` - /// (platform ids: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64) + /// (platform ids: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, + /// android-arm64, android-x64, wasm32) #[arg(long)] binaries: Option, /// Cargo package to cross-compile as the native producer (one cdylib per platform) @@ -115,7 +116,7 @@ enum Commands { format: Option, /// Downgrade validation errors to warnings and emit the IDL anyway. /// Useful for bootstrapping from source that references types it does - /// not yet declare (e.g. opaque handle targets you will define later). + /// not yet declare (e.g. an interface you will define later). #[arg(long)] warn: bool, }, diff --git a/crates/weaveffi-cli/tests/cli_check.rs b/crates/weaveffi-cli/tests/cli_check.rs index ec5f4ed2..1dd09f79 100644 --- a/crates/weaveffi-cli/tests/cli_check.rs +++ b/crates/weaveffi-cli/tests/cli_check.rs @@ -76,7 +76,7 @@ fn diff_check_fails_when_idl_changed() { write_file( &idl, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "modules:\n", " - name: calc\n", " functions:\n", @@ -102,7 +102,7 @@ fn diff_check_fails_when_idl_changed() { write_file( &idl, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "modules:\n", " - name: calc\n", " functions:\n", @@ -189,7 +189,7 @@ fn validate_warn_json_format_outputs_warnings_array() { write_file( &path, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "modules:\n", " - name: nodocs\n", " functions:\n", diff --git a/crates/weaveffi-cli/tests/cli_diagnostics.rs b/crates/weaveffi-cli/tests/cli_diagnostics.rs index 0c6ed7f3..947bdac9 100644 --- a/crates/weaveffi-cli/tests/cli_diagnostics.rs +++ b/crates/weaveffi-cli/tests/cli_diagnostics.rs @@ -25,7 +25,7 @@ fn write_temp_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> std:: #[test] fn parse_yaml_error_includes_line() { let dir = tempfile::tempdir().unwrap(); - let yaml = "version: \"0.8.0\"\nmodules:\n - name: [bad\n"; + let yaml = "version: \"0.9.0\"\nmodules:\n - name: [bad\n"; let path = write_temp_file(&dir, "broken.yml", yaml); let output = cargo_bin() @@ -56,7 +56,7 @@ fn parse_yaml_error_includes_line() { #[test] fn validate_unknown_typeref_includes_offending_name() { let dir = tempfile::tempdir().unwrap(); - let yaml = r#"version: "0.8.0" + let yaml = r#"version: "0.9.0" modules: - name: m functions: @@ -94,7 +94,7 @@ modules: #[test] fn validate_duplicate_module_includes_offending_name() { let dir = tempfile::tempdir().unwrap(); - let yaml = r#"version: "0.8.0" + let yaml = r#"version: "0.9.0" modules: - name: alpha functions: diff --git a/crates/weaveffi-cli/tests/cli_errors.rs b/crates/weaveffi-cli/tests/cli_errors.rs index d7589ede..f20e6db3 100644 --- a/crates/weaveffi-cli/tests/cli_errors.rs +++ b/crates/weaveffi-cli/tests/cli_errors.rs @@ -85,7 +85,7 @@ fn parse_error_toml_shows_filename() { fn validation_error_duplicate_module_shows_suggestion() { let dir = tempfile::tempdir().unwrap(); let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: foo functions: @@ -123,7 +123,7 @@ modules: fn async_function_validates_successfully() { let dir = tempfile::tempdir().unwrap(); let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: mymod functions: @@ -149,7 +149,7 @@ modules: fn validation_error_duplicate_function_shows_suggestion() { let dir = tempfile::tempdir().unwrap(); let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: mymod functions: @@ -209,7 +209,7 @@ fn generate_parse_error_shows_filename_and_suggestion() { fn generate_validation_error_shows_suggestion() { let dir = tempfile::tempdir().unwrap(); let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: dup functions: @@ -249,7 +249,7 @@ modules: fn validate_with_warnings() { let dir = tempfile::tempdir().unwrap(); let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: nodocs functions: diff --git a/crates/weaveffi-cli/tests/cli_extract.rs b/crates/weaveffi-cli/tests/cli_extract.rs index f70f807a..7d99cb81 100644 --- a/crates/weaveffi-cli/tests/cli_extract.rs +++ b/crates/weaveffi-cli/tests/cli_extract.rs @@ -280,51 +280,20 @@ mod async_demo { ); } -#[test] -fn extract_typed_handle_param() { - let (_dir, src_path) = write_src( - r#" -#[weaveffi::module] -mod sessions { - #[weaveffi::export] - fn close(session: *mut Session) { - todo!() - } -} -"#, - ); - - // `Session` is an opaque handle target the source never declares, so the - // extracted IDL does not validate; `--warn` emits it anyway for bootstrapping. - let output = assert_cmd::Command::cargo_bin("weaveffi") - .expect("binary not found") - .args(["extract", src_path.to_str().unwrap(), "--warn"]) - .output() - .expect("failed to run extract"); - - assert!(output.status.success(), "extract command failed"); - let stdout = String::from_utf8(output.stdout).unwrap(); - let api: serde_yaml::Value = - serde_yaml::from_str(&stdout).expect("output should be valid YAML"); - let param = &api["modules"].as_sequence().unwrap()[0]["functions"] - .as_sequence() - .unwrap()[0]["params"] - .as_sequence() - .unwrap()[0]; - assert_eq!(param["name"].as_str().unwrap(), "session"); - assert_eq!(param["type"].as_str().unwrap(), "handle"); -} - #[test] fn extract_fails_loud_on_invalid_api_without_warn() { - // An undeclared handle target makes the extracted API fail validation. + // An undeclared object type makes the extracted API fail validation. // Without `--warn`, `extract` must abort instead of emitting broken IDL. let (_dir, src_path) = write_src( r#" +use std::sync::Arc; + #[weaveffi::module] mod sessions { + use std::sync::Arc; + #[weaveffi::export] - fn close(session: *mut Session) { + fn close(session: Arc) { todo!() } } @@ -349,18 +318,33 @@ mod sessions { } #[test] -fn extract_listener_definition() { +fn extract_callback_interface_definition() { let (_dir, src_path) = write_src( r#" +use std::sync::Arc; + #[weaveffi::module] mod events { - /// Fired when data arrives. - #[weaveffi::callback] - fn OnData(payload: String) {} + use std::sync::Arc; + + #[weaveffi::interface] + struct Ticker; + + impl Ticker { + pub fn new() -> Self { Ticker } + } - /// Subscribe to OnData events. - #[weaveffi::listener(event = "OnData")] - fn data_listener() {} + /// Consumer-implemented sink for data events. + #[weaveffi::callback_interface] + trait DataListener: Send + Sync { + /// Fired when data arrives. + fn on_data(&self, payload: String, source: Arc); + /// Asks whether to keep delivering. + fn keep_going(&self, n: i32) -> bool; + } + + #[weaveffi::export] + fn subscribe(listener: Arc) {} } "#, ); @@ -371,31 +355,46 @@ mod events { .output() .expect("failed to run extract"); - assert!(output.status.success(), "extract command failed"); + assert!( + output.status.success(), + "extract command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8(output.stdout).unwrap(); let api: serde_yaml::Value = serde_yaml::from_str(&stdout).expect("output should be valid YAML"); let module = &api["modules"].as_sequence().unwrap()[0]; - let callbacks = module["callbacks"].as_sequence().unwrap(); - assert_eq!(callbacks.len(), 1); - assert_eq!(callbacks[0]["name"].as_str().unwrap(), "OnData"); + let interfaces = module["callback_interfaces"].as_sequence().unwrap(); + assert_eq!(interfaces.len(), 1); + let listener = &interfaces[0]; + assert_eq!(listener["name"].as_str().unwrap(), "DataListener"); assert_eq!( - callbacks[0]["doc"].as_str(), - Some("Fired when data arrives.") + listener["doc"].as_str(), + Some("Consumer-implemented sink for data events.") ); - let listeners = module["listeners"].as_sequence().unwrap(); - assert_eq!(listeners.len(), 1); - assert_eq!(listeners[0]["name"].as_str().unwrap(), "data_listener"); + let methods = listener["methods"].as_sequence().unwrap(); + assert_eq!(methods.len(), 2); + assert_eq!(methods[0]["name"].as_str().unwrap(), "on_data"); + assert_eq!(methods[0]["doc"].as_str(), Some("Fired when data arrives.")); + let params = methods[0]["params"].as_sequence().unwrap(); + assert_eq!(params[0]["type"].as_str().unwrap(), "string"); assert_eq!( - listeners[0]["event_callback"].as_str().unwrap(), - "OnData", - "listener should reference its callback by name" + params[1]["type"].as_str().unwrap(), + "Ticker", + "an `Arc` parameter extracts as the bare interface type" ); + assert_eq!(methods[1]["name"].as_str().unwrap(), "keep_going"); + assert_eq!(methods[1]["return"].as_str().unwrap(), "bool"); + + let subscribe = &module["functions"].as_sequence().unwrap()[0]; assert_eq!( - listeners[0]["doc"].as_str(), - Some("Subscribe to OnData events.") + subscribe["params"].as_sequence().unwrap()[0]["type"] + .as_str() + .unwrap(), + "DataListener", + "an `Arc` parameter extracts as the callback interface" ); } @@ -495,7 +494,7 @@ mod store { } #[test] -fn extract_deprecated_attribute_to_since() { +fn extract_deprecated_attribute_to_note() { let (_dir, src_path) = write_src( r#" #[weaveffi::module] @@ -524,15 +523,18 @@ mod legacy { .as_sequence() .unwrap()[0]; assert_eq!(func["name"].as_str().unwrap(), "add_old"); - assert_eq!(func["since"].as_str().unwrap(), "0.2.0"); assert_eq!(func["deprecated"].as_str().unwrap(), "Use add_v2 instead"); + assert!( + func.get("since").is_none(), + "schema 0.9.0 has no `since`; the version rides in the deprecation note only" + ); } #[test] -fn extract_mutable_reference_to_mutable_flag() { - // Only string and bytes parameters support `mutable: true` (buffered - // types cross the ABI as borrowed serialized buffers with no write-back - // lowering), so the fixture uses `&mut String`. +fn extract_rejects_mutable_reference_params() { + // Write-back parameters were removed with schema 0.9.0: every parameter + // is borrowed or moved across the boundary, so `&mut T` has no lowering + // and the extractor must refuse it with an actionable message. let (_dir, src_path) = write_src( r#" #[weaveffi::module] @@ -551,25 +553,120 @@ mod buffers { .output() .expect("failed to run extract"); - assert!(output.status.success(), "extract command failed"); + assert!( + !output.status.success(), + "extract should reject `&mut` parameters" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("`&mut` parameters cannot cross the FFI boundary"), + "unexpected diagnostic: {stderr}" + ); +} + +#[test] +fn extract_rejects_raw_pointers_and_box() { + for (ty, needle) in [ + ("*mut Widget", "raw pointers cannot cross the FFI boundary"), + ("Box", "`Box` cannot cross the FFI boundary"), + ] { + let (_dir, src_path) = write_src(&format!( + r#" +#[weaveffi::module] +mod objects {{ + #[weaveffi::interface] + struct Widget; + + impl Widget {{ + pub fn new() -> Self {{ Widget }} + }} + + #[weaveffi::export] + fn take(w: {ty}) {{}} +}} +"# + )); + + let output = assert_cmd::Command::cargo_bin("weaveffi") + .expect("binary not found") + .args(["extract", src_path.to_str().unwrap()]) + .output() + .expect("failed to run extract"); + + assert!(!output.status.success(), "extract should reject `{ty}`"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(needle), + "unexpected diagnostic for `{ty}`: {stderr}" + ); + } +} + +#[test] +fn extract_arc_object_shapes() { + // `Arc` in every position lowers to the bare interface type, with + // `Option`, `Vec`, and record fields composing on top. + let (_dir, src_path) = write_src( + r#" +use std::sync::Arc; + +#[weaveffi::module] +mod graph { + use std::sync::Arc; + + #[weaveffi::interface] + struct Node; + + #[weaveffi::record] + struct Edge { + from: Arc, + to: Option>, + } + + impl Node { + pub fn new() -> Self { Node } + pub fn me(self: Arc) -> Arc { self } + pub fn parent(&self) -> Option> { None } + pub fn children(&self) -> Vec> { vec![] } + pub fn edges(&self) -> weaveffi::Iter> { weaveffi::Iter::new(vec![]) } + } +} +"#, + ); + + let output = assert_cmd::Command::cargo_bin("weaveffi") + .expect("binary not found") + .args(["extract", src_path.to_str().unwrap()]) + .output() + .expect("failed to run extract"); + + assert!( + output.status.success(), + "extract command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8(output.stdout).unwrap(); let api: serde_yaml::Value = serde_yaml::from_str(&stdout).expect("output should be valid YAML"); - let params = &api["modules"].as_sequence().unwrap()[0]["functions"] - .as_sequence() - .unwrap()[0]["params"] - .as_sequence() - .unwrap(); - assert_eq!(params[0]["name"].as_str().unwrap(), "buf"); - assert_eq!(params[0]["mutable"].as_bool(), Some(true)); - assert_eq!(params[1]["name"].as_str().unwrap(), "value"); - // value is not &mut, so mutable is false (omitted from YAML by serde - // default skip). - assert!( - params[1].get("mutable").is_none() || params[1]["mutable"].as_bool() == Some(false), - "non-mut param should not have mutable=true: {:?}", - params[1] - ); + let module = &api["modules"].as_sequence().unwrap()[0]; + + let edge = &module["structs"].as_sequence().unwrap()[0]; + let fields = edge["fields"].as_sequence().unwrap(); + assert_eq!(fields[0]["type"].as_str().unwrap(), "Node"); + assert_eq!(fields[1]["type"].as_str().unwrap(), "Node?"); + + let node = &module["interfaces"].as_sequence().unwrap()[0]; + let methods = node["methods"].as_sequence().unwrap(); + let by_name = |n: &str| { + methods + .iter() + .find(|m| m["name"].as_str() == Some(n)) + .unwrap_or_else(|| panic!("missing method {n}")) + }; + assert_eq!(by_name("me")["return"].as_str().unwrap(), "Node"); + assert_eq!(by_name("parent")["return"].as_str().unwrap(), "Node?"); + assert_eq!(by_name("children")["return"].as_str().unwrap(), "[Node]"); + assert_eq!(by_name("edges")["return"].as_str().unwrap(), "iter"); } #[test] diff --git a/crates/weaveffi-cli/tests/cli_generate.rs b/crates/weaveffi-cli/tests/cli_generate.rs index 8f6bfeee..a25d8e60 100644 --- a/crates/weaveffi-cli/tests/cli_generate.rs +++ b/crates/weaveffi-cli/tests/cli_generate.rs @@ -30,8 +30,8 @@ fn generate_produces_expected_files() { "missing swift/Package.swift" ); assert!( - out_path.join("android/build.gradle").exists(), - "missing android/build.gradle" + out_path.join("kotlin/build.gradle.kts").exists(), + "missing kotlin/build.gradle.kts" ); assert!( out_path.join("node/types.d.ts").exists(), diff --git a/crates/weaveffi-cli/tests/cli_package.rs b/crates/weaveffi-cli/tests/cli_package.rs index f748a81a..39e491bb 100644 --- a/crates/weaveffi-cli/tests/cli_package.rs +++ b/crates/weaveffi-cli/tests/cli_package.rs @@ -92,8 +92,9 @@ fn package_skips_unsupported_targets() { let out_dir = tempfile::tempdir().expect("temp out dir"); - // wasm has no native-matrix packaging, so packaging only wasm fails with a - // clear message rather than producing an empty artifact. + // The prebuilt tree holds only desktop cdylibs, so a wasm-only package has + // no `wasm32` module to carry and must fail with a clear message rather + // than producing an empty artifact. assert_cmd::Command::cargo_bin("weaveffi") .expect("binary not found") .args([ diff --git a/crates/weaveffi-cli/tests/cli_project_config.rs b/crates/weaveffi-cli/tests/cli_project_config.rs index 60441cd9..1068583a 100644 --- a/crates/weaveffi-cli/tests/cli_project_config.rs +++ b/crates/weaveffi-cli/tests/cli_project_config.rs @@ -52,7 +52,7 @@ fn generate(input: &Path, out: &Path, extra: &[&str]) { } const ALL_TARGETS: [&str; 11] = [ - "c", "cpp", "swift", "android", "node", "wasm", "python", "dotnet", "dart", "go", "ruby", + "c", "cpp", "swift", "kotlin", "node", "wasm", "python", "dotnet", "dart", "go", "ruby", ]; /// The kvstore sample's `weaveffi.toml` sits beside its `src/lib.rs`; running @@ -162,7 +162,7 @@ fn inline_package_block_in_idl_is_an_error() { fs::write( &idl, concat!( - "version: \"0.8.0\"\n", + "version: \"0.9.0\"\n", "package:\n", " name: legacy\n", "modules:\n", @@ -274,7 +274,7 @@ fn c_prefix_from_the_c_table_reaches_cpp() { let header = read(&out.join("c/myffi.h")); for alias in [ "#define myffi_error weaveffi_error", - "#define myffi_handle_t weaveffi_handle_t", + "#define myffi_error_set weaveffi_error_set", "#define myffi_free_string weaveffi_free_string", "#define myffi_cancel_token_create weaveffi_cancel_token_create", ] { diff --git a/crates/weaveffi-cli/tests/cli_schema.rs b/crates/weaveffi-cli/tests/cli_schema.rs index 02ca481d..1b8e9c69 100644 --- a/crates/weaveffi-cli/tests/cli_schema.rs +++ b/crates/weaveffi-cli/tests/cli_schema.rs @@ -39,8 +39,7 @@ fn schema_emits_valid_json_schema() { "EnumDef", "EnumVariant", "InterfaceDef", - "CallbackDef", - "ListenerDef", + "CallbackInterfaceDef", "ErrorDomain", "ErrorCode", ] { diff --git a/crates/weaveffi-cli/tests/cli_schema_version.rs b/crates/weaveffi-cli/tests/cli_schema_version.rs index 3fac11cb..7f120257 100644 --- a/crates/weaveffi-cli/tests/cli_schema_version.rs +++ b/crates/weaveffi-cli/tests/cli_schema_version.rs @@ -11,5 +11,5 @@ fn schema_version_command_prints_0_4_0() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "0.8.0"); + assert_eq!(stdout.trim(), "0.9.0"); } diff --git a/crates/weaveffi-cli/tests/determinism.rs b/crates/weaveffi-cli/tests/determinism.rs index 850f2af6..66477831 100644 --- a/crates/weaveffi-cli/tests/determinism.rs +++ b/crates/weaveffi-cli/tests/determinism.rs @@ -12,12 +12,12 @@ use camino::Utf8Path; use weaveffi_core::codegen::{ConfiguredBackend, Target}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::validate::validate_api; -use weaveffi_gen_android::{AndroidConfig, AndroidGenerator}; use weaveffi_gen_c::{CConfig, CGenerator}; use weaveffi_gen_cpp::{CppConfig, CppGenerator}; use weaveffi_gen_dart::{DartConfig, DartGenerator}; use weaveffi_gen_dotnet::{DotnetConfig, DotnetGenerator}; use weaveffi_gen_go::{GoConfig, GoGenerator}; +use weaveffi_gen_kotlin::{KotlinConfig, KotlinGenerator}; use weaveffi_gen_node::{NodeConfig, NodeGenerator}; use weaveffi_gen_python::{PythonConfig, PythonGenerator}; use weaveffi_gen_ruby::{RubyConfig, RubyGenerator}; @@ -122,8 +122,8 @@ fn generator_output_is_byte_identical_across_runs() { SwiftConfig::default(), )), Box::new(ConfiguredBackend::new( - AndroidGenerator, - AndroidConfig::default(), + KotlinGenerator, + KotlinConfig::default(), )), Box::new(ConfiguredBackend::new(NodeGenerator, NodeConfig::default())), Box::new(ConfiguredBackend::new(WasmGenerator, WasmConfig::default())), diff --git a/crates/weaveffi-cli/tests/docs_examples.rs b/crates/weaveffi-cli/tests/docs_examples.rs index 27dd5683..cfa50ca9 100644 --- a/crates/weaveffi-cli/tests/docs_examples.rs +++ b/crates/weaveffi-cli/tests/docs_examples.rs @@ -1,5 +1,5 @@ const GENERATOR_DOCS_YAML: &str = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -205,10 +205,10 @@ fn doc_node_dts_optional_and_list_return() { } #[test] -fn doc_android_kotlin_wrapper() { +fn doc_kotlin_wrapper() { let (_dir, out) = generate_all_for_docs(); let kt = std::fs::read_to_string( - out.join("generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.kt"), + out.join("generated/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt"), ) .unwrap(); @@ -231,10 +231,10 @@ fn doc_android_kotlin_wrapper() { } #[test] -fn doc_android_jni_shim() { +fn doc_kotlin_jni_shim() { let (_dir, out) = generate_all_for_docs(); let jni = - std::fs::read_to_string(out.join("generated/android/src/main/cpp/weaveffi_jni.c")).unwrap(); + std::fs::read_to_string(out.join("generated/kotlin/src/main/cpp/weaveffi_jni.c")).unwrap(); assert!( jni.contains("#include \"weaveffi.h\""), @@ -251,10 +251,10 @@ fn doc_android_jni_shim() { } #[test] -fn doc_android_cmake_exists() { +fn doc_kotlin_cmake_exists() { let (_dir, out) = generate_all_for_docs(); let cmake = - std::fs::read_to_string(out.join("generated/android/src/main/cpp/CMakeLists.txt")).unwrap(); + std::fs::read_to_string(out.join("generated/kotlin/src/main/cpp/CMakeLists.txt")).unwrap(); assert!(cmake.contains("add_library(weaveffi SHARED weaveffi_jni.c)")); assert!(cmake.contains("target_include_directories(weaveffi PRIVATE ../../../../c)")); @@ -266,7 +266,7 @@ fn doc_wasm_loader_generated() { let js = std::fs::read_to_string(out.join("generated/wasm/weaveffi_wasm.js")).unwrap(); assert!( - js.contains("export async function loadWeaveffiWasm(url)"), + js.contains("export async function loadWeaveffiWasm(source)"), "loader function missing: {js}" ); assert!( @@ -327,7 +327,7 @@ fn summary_md_all_links_resolve() { /// The README quickstart IDL, kept in sync with the `kvstore.yml` snippet in /// `README.md` step 2 (trimmed `Store` interface plus `KvError` domain). const README_QUICKSTART_YAML: &str = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: kv errors: @@ -436,7 +436,7 @@ fn readme_quickstart_generates_c_header() { } const GETTING_STARTED_YAML: &str = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: math structs: @@ -573,8 +573,8 @@ fn readme_uses_only_current_schema_version() { // Only the IDL `version:` key is a schema version; the `weaveffi.toml` // example's `version = "0.1.0"` is the package version and is fine. assert!( - readme.contains("version: \"0.8.0\""), - "README should reference schema version 0.8.0" + readme.contains("version: \"0.9.0\""), + "README should reference schema version 0.9.0" ); for old in [ "0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", @@ -593,7 +593,7 @@ fn readme_lists_all_eleven_targets_in_table() { "**C**", "**C++**", "**Swift**", - "**Android**", + "**Kotlin**", "**Node.js**", "**Wasm**", "**Python**", diff --git a/crates/weaveffi-cli/tests/extract_roundtrip.rs b/crates/weaveffi-cli/tests/extract_roundtrip.rs index b153a479..b18505c5 100644 --- a/crates/weaveffi-cli/tests/extract_roundtrip.rs +++ b/crates/weaveffi-cli/tests/extract_roundtrip.rs @@ -4,11 +4,10 @@ //! as the original kitchen-sink IDL when run on the hand-annotated Rust //! file at `crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs`, //! including the full surface: the `Gadget` interface, the `KitchenErrors` -//! domain, and per-function `throws`. Lossy fields (struct field defaults, -//! iterator returns, standalone `since` without `#[deprecated]`, callback -//! param docs, error-code `doc:` separate from `message:`) are documented -//! in `docs/src/guides/extract.md` and skipped by name in the assertions -//! below. +//! domain, the `ReadyListener` callback interface, and per-function +//! `throws`. Lossy fields (struct field defaults, callback param docs, +//! error-code `doc:` separate from `message:`) are documented in +//! `docs/src/guides/extract.md` and skipped by name in the assertions below. use std::collections::BTreeMap; @@ -24,7 +23,6 @@ fn normalize(ty: &TypeRef) -> TypeRef { } match ty { TypeRef::Named(name) => TypeRef::Named(last_segment(name)), - TypeRef::TypedHandle(name) => TypeRef::TypedHandle(last_segment(name)), TypeRef::Optional(inner) => TypeRef::Optional(Box::new(normalize(inner))), TypeRef::List(inner) => TypeRef::List(Box::new(normalize(inner))), TypeRef::Iterator(inner) => TypeRef::Iterator(Box::new(normalize(inner))), @@ -188,34 +186,23 @@ fn roundtrip_kitchen_sink() { // struct field defaults from Rust syntax (documented gap). } - // Callbacks - assert_eq!(kitchen_ex.callbacks.len(), kitchen_orig.callbacks.len()); - let on_ready_orig = &kitchen_orig.callbacks[0]; - let on_ready_ex = &kitchen_ex.callbacks[0]; - assert_eq!(on_ready_ex.name, on_ready_orig.name); - assert_eq!(on_ready_ex.doc, on_ready_orig.doc); - assert_eq!(on_ready_ex.params.len(), on_ready_orig.params.len()); - for (a, b) in on_ready_ex.params.iter().zip(on_ready_orig.params.iter()) { - assert_eq!(a.name, b.name); - assert_types_equivalent(&a.ty, &b.ty, &format!("callback param {}", b.name)); - } - - // Listeners - assert_eq!(kitchen_ex.listeners.len(), kitchen_orig.listeners.len()); - let listener_orig = &kitchen_orig.listeners[0]; - let listener_ex = &kitchen_ex.listeners[0]; + // Callback interfaces + assert_eq!( + kitchen_ex.callback_interfaces.len(), + kitchen_orig.callback_interfaces.len() + ); + let listener_orig = &kitchen_orig.callback_interfaces[0]; + let listener_ex = &kitchen_ex.callback_interfaces[0]; assert_eq!(listener_ex.name, listener_orig.name); - assert_eq!(listener_ex.event_callback, listener_orig.event_callback); assert_eq!(listener_ex.doc, listener_orig.doc); + assert_eq!(listener_ex.methods.len(), listener_orig.methods.len()); + for (a, b) in listener_ex.methods.iter().zip(listener_orig.methods.iter()) { + assert_functions_equivalent(a, b, &format!("ReadyListener method {}", b.name)); + } // Functions: every original IDL function must reappear in extracted - // output with matching shape, except for `stream_items` which has - // no Rust syntax for `iter` (documented gap). - let lossy_functions: &[&str] = &["stream_items"]; + // output with matching shape. for orig in &kitchen_orig.functions { - if lossy_functions.contains(&orig.name.as_str()) { - continue; - } let extracted = function_by_name(kitchen_ex, &orig.name); assert_eq!( extracted.params.len(), @@ -226,11 +213,6 @@ fn roundtrip_kitchen_sink() { for (a, b) in extracted.params.iter().zip(orig.params.iter()) { assert_eq!(a.name, b.name, "{} param name mismatch", orig.name); assert_types_equivalent(&a.ty, &b.ty, &format!("{} param {}", orig.name, b.name)); - assert_eq!( - a.mutable, b.mutable, - "{} param {} mutable mismatch", - orig.name, b.name - ); // Param.doc is allowed to differ (documented gap). } match (&extracted.returns, &orig.returns) { @@ -254,17 +236,11 @@ fn roundtrip_kitchen_sink() { "{} throws mismatch", orig.name ); - // `since` without an accompanying `#[deprecated(since = ...)]` is - // not recoverable: `new_op` has `since: 0.3.0` in the IDL but no - // way to express that in Rust syntax alone. - if orig.deprecated.is_some() { - assert_eq!( - extracted.deprecated, orig.deprecated, - "{} deprecated mismatch", - orig.name - ); - assert_eq!(extracted.since, orig.since, "{} since mismatch", orig.name); - } + assert_eq!( + extracted.deprecated, orig.deprecated, + "{} deprecated mismatch", + orig.name + ); } // Error domain: `#[weaveffi::error]` makes the domain derivable, so name, diff --git a/crates/weaveffi-cli/tests/fixtures/docs_everywhere.yml b/crates/weaveffi-cli/tests/fixtures/docs_everywhere.yml index 9781741a..b5728218 100644 --- a/crates/weaveffi-cli/tests/fixtures/docs_everywhere.yml +++ b/crates/weaveffi-cli/tests/fixtures/docs_everywhere.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" modules: - name: docs enums: @@ -22,16 +22,18 @@ modules: - { name: title, type: string, doc: Human-readable title shown in lists } - { name: status, type: Status, doc: Current lifecycle status } - { name: tags, type: "[string]", doc: Free-form tags for grouping } - callbacks: - - name: OnSaved - doc: Fires after a document has been persisted successfully - params: - - { name: doc_id, type: i64, doc: Identifier of the saved document } - - { name: revision, type: i32, doc: Monotonically increasing revision } - listeners: - - name: saved_listener - event_callback: OnSaved - doc: Subscribe to document-saved notifications + callback_interfaces: + - name: SavedListener + doc: | + Consumer-implemented observer of document-saved notifications. + + Implementations must be safe to call from any thread. + methods: + - name: on_saved + doc: Fires after a document has been persisted successfully + params: + - { name: doc_id, type: i64, doc: Identifier of the saved document } + - { name: revision, type: i32, doc: Monotonically increasing revision } errors: name: DocsErrors codes: @@ -90,3 +92,7 @@ modules: - { name: id, type: i64, doc: Identifier of the document to publish } return: bool async: true + - name: watch + doc: Register a listener for saved notifications + params: + - { name: listener, type: SavedListener, doc: Receiver of saved notifications } diff --git a/crates/weaveffi-cli/tests/fixtures/edge_cases.yml b/crates/weaveffi-cli/tests/fixtures/edge_cases.yml index 50557517..30d0d9a8 100644 --- a/crates/weaveffi-cli/tests/fixtures/edge_cases.yml +++ b/crates/weaveffi-cli/tests/fixtures/edge_cases.yml @@ -1,9 +1,10 @@ -version: "0.8.0" +version: "0.9.0" # Shapes the mainstream fixtures do not reach: identifiers that are reserved -# words in one or more target languages, deeply nested composites, optional and -# mutable parameters, interfaces in every legal position, async functions with -# non-string results, type-level deprecation, scalar and string iterators, and -# callbacks carrying composite payloads. Snapshots pin how every generator +# words in one or more target languages, deeply nested composites, optional +# parameters, interfaces in every legal position (including inside records, +# lists, maps, and iterators), async functions with non-string results, +# type-level deprecation, scalar and string iterators, and callback interfaces +# carrying composite payloads and objects. Snapshots pin how every generator # renders each of these, so a regression in keyword escaping or a codec edge # shows up as a diff instead of as a compile error in a downstream project. modules: @@ -109,21 +110,50 @@ modules: deprecated: "Use Outer instead" fields: - { name: id, type: i64 } - callbacks: - - name: OnBatch - doc: Callback carrying composite payloads - params: - - { name: items, type: "[Outer]" } - - { name: raw, type: bytes } - - { name: first, type: "Outer?" } - - { name: mode, type: Mode } - - name: OnTick - params: [] - listeners: - - name: batch_listener - event_callback: OnBatch - - name: tick_listener - event_callback: OnTick + - name: WidgetHolder + doc: Record carrying objects in every buffered position + fields: + - { name: primary, type: Widget } + - { name: fallback, type: "Widget?" } + - { name: extras, type: "[Widget]" } + - { name: by_name, type: "{string:Widget}" } + callback_interfaces: + - name: BatchListener + doc: Callback interface carrying composite payloads and objects + methods: + - name: on_batch + doc: Composite payloads cross as value buffers + params: + - { name: items, type: "[Outer]" } + - { name: raw, type: bytes } + - { name: first, type: "Outer?" } + - { name: mode, type: Mode } + - name: on_widget + doc: Objects cross as strong references the consumer must release + params: + - { name: widget, type: Widget } + - { name: fallback, type: "Widget?" } + - { name: children, type: "[Widget]" } + - name: on_tick + params: [] + - name: should_continue + doc: Scalar return travels back through the vtable + params: + - { name: class, type: i32 } + return: bool + - name: pick_mode + doc: Enum return travels back as its integer discriminant + params: [] + return: Mode + - name: Keyword + doc: Callback interface whose name and methods are keywords + methods: + - name: delete + params: + - { name: self, type: i32 } + - name: import + params: [] + return: i32 errors: name: EdgeErrors codes: @@ -198,11 +228,29 @@ modules: - { name: blob, type: "bytes?" } - { name: mode, type: "Mode?" } return: "Mode?" - - name: mutable_params - doc: Write-back parameters + - name: register_batch + doc: Two callback interface parameters on one function params: - - { name: text, type: string, mutable: true } - - { name: blob, type: bytes, mutable: true } + - { name: listener, type: BatchListener } + - { name: keyword, type: Keyword } + - name: widgets_everywhere + doc: Objects in every buffered position + params: + - { name: list, type: "[Widget]" } + - { name: by_name, type: "{string:Widget}" } + - { name: sparse, type: "[Widget?]" } + - { name: holder, type: WidgetHolder } + return: WidgetHolder + - name: widget_stream + doc: Iterator of objects + params: [] + return: "iter" + - name: async_widget + doc: Async returning an object + params: [] + return: Widget + async: true + throws: true - name: make_widget doc: Factory for an interface with no constructor params: @@ -220,11 +268,6 @@ modules: - { name: widget, type: Widget } - { name: fallback, type: "Widget?" } return: string - - name: typed_handle_param - params: - - { name: token, type: "handle" } - - { name: raw, type: handle } - return: "handle" - name: string_stream doc: Iterator of strings params: [] diff --git a/crates/weaveffi-cli/tests/fixtures/kitchen_sink.yml b/crates/weaveffi-cli/tests/fixtures/kitchen_sink.yml index da9bdb61..458285f6 100644 --- a/crates/weaveffi-cli/tests/fixtures/kitchen_sink.yml +++ b/crates/weaveffi-cli/tests/fixtures/kitchen_sink.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" modules: - name: shared structs: @@ -36,16 +36,22 @@ modules: - { name: parent, type: "i64?" } - { name: token, type: Token, doc: Cross-module struct reference } - { name: priority, type: Priority } - callbacks: - - name: OnReady - doc: Fires when an item is ready - params: - - { name: code, type: i32 } - - { name: msg, type: string } - listeners: - - name: ready_listener - event_callback: OnReady - doc: Subscribe to OnReady events + - { name: gadget, type: "Gadget?", doc: An optional object inside a record } + callback_interfaces: + - name: ReadyListener + doc: Consumer-implemented observer of ready items + methods: + - name: on_ready + doc: Fires when an item is ready + params: + - { name: code, type: i32 } + - { name: msg, type: string } + - name: on_item + doc: Receives the item itself and says whether to keep listening + params: + - { name: item, type: Item } + - { name: gadget, type: Gadget } + return: bool errors: name: KitchenErrors codes: @@ -105,24 +111,24 @@ modules: params: - { name: b, type: bytes } return: bytes - - name: echo_borrowed_str - doc: Borrowed string parameter + - name: subscribe + doc: Register a listener, returning how many are attached params: - - { name: s, type: "&str" } - return: string - - name: echo_borrowed_bytes - doc: Borrowed bytes parameter + - { name: listener, type: ReadyListener } + return: i32 + - name: maybe_gadget + doc: Optional object in both directions params: - - { name: b, type: "&[u8]" } - return: bytes - - name: open_handle - doc: Returns an opaque handle + - { name: fallback, type: "Gadget?" } + return: "Gadget?" + - name: gadgets + doc: A list of objects params: [] - return: handle - - name: open_typed_handle - doc: Returns a typed handle + return: "[Gadget]" + - name: stream_gadgets + doc: An iterator over objects params: [] - return: "handle" + return: "iter" - name: maybe_item doc: Optional struct return params: @@ -163,12 +169,10 @@ modules: params: [] return: i32 deprecated: "Use new_op instead" - since: "0.1.0" - name: new_op doc: Replacement for legacy_op params: [] return: i32 - since: "0.3.0" modules: - name: nested functions: diff --git a/crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs b/crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs index fe92d9ee..63d2ed2d 100644 --- a/crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs +++ b/crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs @@ -8,10 +8,6 @@ //! syntactic markers here match the same names the proc-macro re-emits. //! //! Round-trip gaps documented in `docs/src/guides/extract.md`: -//! * The original `stream_items` IDL function (an `iter` return) is -//! omitted from this fixture. -//! * Standalone `since:` without `#[deprecated]`: dropped, so `new_op` -//! loses its `since: "0.3.0"`. //! * An error code's yml `doc:` cannot be expressed separately from its //! `message:` in Rust (the message is the first doc line), so the //! round-trip test compares codes by name/code/message only. @@ -20,6 +16,8 @@ #![allow(unused_attributes)] #![allow(unused_variables)] +use std::sync::Arc; + #[weaveffi::module] mod shared { /// A reusable token referenced across modules @@ -71,15 +69,18 @@ mod kitchen { /// Cross-module struct reference token: Token, priority: Priority, + /// An optional object inside a record + gadget: Option>, } - /// Fires when an item is ready - #[weaveffi::callback] - fn OnReady(code: i32, msg: String) {} - - /// Subscribe to OnReady events - #[weaveffi::listener(event = "OnReady")] - fn ready_listener() {} + /// Consumer-implemented observer of ready items + #[weaveffi::callback_interface] + trait ReadyListener: Send + Sync { + /// Fires when an item is ready + fn on_ready(&self, code: i32, msg: String); + /// Receives the item itself and says whether to keep listening + fn on_item(&self, item: &Item, gadget: Arc) -> bool; + } #[weaveffi::error] enum KitchenErrors { @@ -156,28 +157,34 @@ mod kitchen { b } - /// Borrowed string parameter + /// Register a listener, returning how many are attached #[weaveffi::export] - fn echo_borrowed_str(s: &str) -> String { - s.to_string() + fn subscribe(listener: Arc) -> i32 { + 1 } - /// Borrowed bytes parameter + /// Optional object in both directions #[weaveffi::export] - fn echo_borrowed_bytes(b: &[u8]) -> Vec { - b.to_vec() + fn maybe_gadget(fallback: Option>) -> Option> { + fallback } - /// Returns an opaque handle + /// A list of objects #[weaveffi::export] - fn open_handle() -> weaveffi::Handle { - 0 + fn gadgets() -> Vec> { + Vec::new() + } + + /// An iterator over objects + #[weaveffi::export] + fn stream_gadgets() -> weaveffi::Iter> { + weaveffi::Iter::new(Vec::new()) } - /// Returns a typed handle + /// Iterator return type #[weaveffi::export] - fn open_typed_handle() -> *mut Token { - std::ptr::null_mut() + fn stream_items() -> weaveffi::Iter { + weaveffi::Iter::new(Vec::new()) } /// Optional struct return diff --git a/crates/weaveffi-cli/tests/fixtures/nested_modules.yml b/crates/weaveffi-cli/tests/fixtures/nested_modules.yml index 6dd87059..3b4934ef 100644 --- a/crates/weaveffi-cli/tests/fixtures/nested_modules.yml +++ b/crates/weaveffi-cli/tests/fixtures/nested_modules.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" # Exercises multi-level nested modules and type references that cross nesting # boundaries in every direction: # * parent -> nested child (graphics.Canvas.root : shapes.Shape) diff --git a/crates/weaveffi-cli/tests/fixtures/shapes.yml b/crates/weaveffi-cli/tests/fixtures/shapes.yml index 028e252b..7d4bccf2 100644 --- a/crates/weaveffi-cli/tests/fixtures/shapes.yml +++ b/crates/weaveffi-cli/tests/fixtures/shapes.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" modules: - name: shapes doc: Rich (algebraic) enums and the expanded numeric primitives. diff --git a/crates/weaveffi-cli/tests/no_silent_stubs.rs b/crates/weaveffi-cli/tests/no_silent_stubs.rs index f58fdfe9..bab13228 100644 --- a/crates/weaveffi-cli/tests/no_silent_stubs.rs +++ b/crates/weaveffi-cli/tests/no_silent_stubs.rs @@ -79,9 +79,19 @@ fn generator_sources_ban_unimplemented_and_todo() { ); } +/// The Ruby callback-interface mixin's abstract-method default, which is the +/// one legitimate "not implemented" in generated output (see the exemption in +/// [`generated_output_has_no_stub_markers`]). +fn is_abstract_callback_default(line: &str) -> bool { + let t = line.trim_start(); + t.starts_with("raise NotImplementedError, \"#{self.class}#") + || t.starts_with("# include this module to inherit NotImplementedError defaults") +} + /// Every file generated for the feature-complete samples (structs, builders, -/// enums, optionals, lists, maps, bytes, handles, callbacks, listeners, async, -/// iterators, submodules) must be free of stub markers across all targets. +/// enums, optionals, lists, maps, bytes, objects in every position, callback +/// interfaces, async, iterators, submodules) must be free of stub markers +/// across all targets. #[test] fn generated_output_has_no_stub_markers() { let root = workspace_root(); @@ -89,6 +99,7 @@ fn generated_output_has_no_stub_markers() { root.join("samples/contacts/src/lib.rs"), root.join("samples/events/src/lib.rs"), root.join("samples/kvstore/src/lib.rs"), + root.join("samples/codec/src/lib.rs"), ]; // Case-insensitive marker list. Bare "not supported" is deliberately @@ -98,7 +109,7 @@ fn generated_output_has_no_stub_markers() { // "yet" in "not yet supported" is what distinguishes capability drift: a // target that declares a feature `true` (so the gate lets generation // through) but then emits a runtime-throwing TODO stub for it. That is - // exactly the Android `iter` regression the capability gate exists to + // exactly the Kotlin `iter` regression the capability gate exists to // prevent, so it must stay a hard build failure. let banned = [ "unimplemented", @@ -139,7 +150,18 @@ fn generated_output_has_no_stub_markers() { let Ok(text) = fs::read_to_string(&file) else { continue; // non-UTF-8 artifacts have no text stubs to check }; - let lower = text.to_lowercase(); + // A callback interface is implemented by the *consumer*, and the + // idiomatic way to publish its method set in dynamic languages is + // an abstract default that raises until overridden (Ruby's + // `raise NotImplementedError, "#{self.class}#m is not implemented"`). + // That is an API contract the consumer sees, not a generator + // stub, so those lines are exempt from the scan. + let lower: String = text + .lines() + .filter(|l| !is_abstract_callback_default(l)) + .map(str::to_lowercase) + .collect::>() + .join("\n"); for marker in banned { if lower.contains(marker) { violations.push(format!( diff --git a/crates/weaveffi-cli/tests/publish_script.rs b/crates/weaveffi-cli/tests/publish_script.rs index 44a49659..3f1429cb 100644 --- a/crates/weaveffi-cli/tests/publish_script.rs +++ b/crates/weaveffi-cli/tests/publish_script.rs @@ -37,7 +37,7 @@ fn publish_script_contains_all_publishable_crates() { "weaveffi", "weaveffi-gen-c", "weaveffi-gen-swift", - "weaveffi-gen-android", + "weaveffi-gen-kotlin", "weaveffi-gen-node", "weaveffi-gen-wasm", "weaveffi-gen-python", diff --git a/crates/weaveffi-cli/tests/safety_audit.rs b/crates/weaveffi-cli/tests/safety_audit.rs index 598d0dd6..f0d74379 100644 --- a/crates/weaveffi-cli/tests/safety_audit.rs +++ b/crates/weaveffi-cli/tests/safety_audit.rs @@ -1,5 +1,5 @@ fn audit_api_yaml() -> &'static str { - r#"version: "0.8.0" + r#"version: "0.9.0" modules: - name: inventory structs: @@ -28,15 +28,15 @@ modules: params: [] return: string functions: - - name: create_widget + - name: create_store params: - name: name type: string - return: handle - - name: get_widget_name + return: Store + - name: store_name params: - - name: id - type: handle + - name: store + type: Store return: string - name: list_widgets params: [] @@ -183,28 +183,32 @@ fn audit_swift_deinit_calls_destroy() { #[test] fn audit_kotlin_closeable() { - let out = generate_target("android"); - let kt = read_generated(&out, "android/src/main/kotlin/com/weaveffi/WeaveFFI.kt"); + let out = generate_target("kotlin"); + let kt = read_generated(&out, "kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt"); assert!( - kt.contains(": java.io.Closeable"), - "Store must implement java.io.Closeable" + kt.contains("class Store private constructor(handle: Long) : AutoCloseable"), + "Store must implement AutoCloseable" ); assert!( kt.contains("override fun close()"), "Store must override close()" ); assert!( - kt.contains("nativeDestroy(handle)"), - "close() must call nativeDestroy(handle)" + kt.contains("WeaveNativeRef(handle) { h -> nativeDestroy(h) }"), + "the native reference must release through nativeDestroy exactly once" + ); + assert!( + kt.contains("handle.getAndSet(0L)") && kt.contains("if (h != 0L) release(h)"), + "release must atomically zero the handle so close() and the cleaner can't double-free" ); assert!( - kt.contains("handle = 0L"), - "close() must zero handle after destroy to prevent double-free" + kt.contains("weaveCleaner.register(this, ref)"), + "Store must register with a java.lang.ref.Cleaner as the GC safety net" ); assert!( - kt.contains("protected fun finalize()"), - "Store must override finalize() as GC safety net" + kt.contains("internal fun cloneHandle(): Long = nativeClone(ref.get())"), + "Store must expose cloneHandle() so a wrapper can hand a fresh strong reference to the producer" ); } @@ -255,8 +259,12 @@ fn audit_dotnet_idisposable() { "Dispose must call weaveffi_inventory_Store_destroy" ); assert!( - cs.contains("_disposed"), - "Store must track disposed state to prevent double-dispose" + cs.contains("Interlocked.Exchange(ref _released, 1)"), + "Store must release at most once, even when Dispose races the finalizer" + ); + assert!( + cs.contains("throw new ObjectDisposedException(nameof(Store))"), + "Store must refuse use after Dispose instead of passing a dangling pointer" ); assert!( cs.contains("GC.SuppressFinalize(this)"), @@ -280,13 +288,24 @@ fn audit_cpp_raii() { "Store destructor must call _destroy" ); + // ABI 2 objects are reference counted, so copies are legal: each copy + // takes its own strong reference via `_clone` and releases it in its own + // destructor, which is what makes value semantics safe. assert!( - hpp.contains("Store(const Store&) = delete"), - "Store must delete copy constructor" + hpp.contains( + "Store(const Store& other) : handle_(weaveffi_inventory_Store_clone(other.handle_)) {}" + ), + "Store copy constructor must take a fresh strong reference via _clone" ); assert!( - hpp.contains("Store& operator=(const Store&) = delete"), - "Store must delete copy assignment" + hpp.contains("Store& operator=(const Store& other) {"), + "Store must have copy assignment" + ); + assert!( + hpp.contains( + "weaveffi_inventory_Store* h = weaveffi_inventory_Store_clone(other.handle_);" + ), + "copy assignment must clone before destroying the old reference (self-assignment safe)" ); assert!( @@ -327,10 +346,16 @@ fn audit_no_raw_pointer_leaks() { h.contains("weaveffi_inventory_Store_destroy("), "C: missing Store_destroy declaration" ); + assert!( + h.contains("weaveffi_inventory_Store_clone("), + "C: missing Store_clone declaration" + ); + // `_clone` and `_destroy` are the reference-count primitives: they + // never fail, so they are the only object symbols without `out_err`. let fn_lines: Vec<&str> = h .lines() .filter(|l| l.contains("weaveffi_inventory_") && l.contains('(') && l.ends_with(';')) - .filter(|l| !l.contains("destroy")) + .filter(|l| !l.contains("_destroy(") && !l.contains("_clone(")) .filter(|l| !l.contains("typedef")) .collect(); for line in &fn_lines { @@ -361,10 +386,10 @@ fn audit_no_raw_pointer_leaks() { // Kotlin JNI: owned strings freed after JNI copy, interface destroy in // nativeDestroy { - let jni = read_generated(&out, "android/src/main/cpp/weaveffi_jni.c"); + let jni = read_generated(&out, "kotlin/src/main/cpp/weaveffi_jni.c"); assert!( jni.contains("weaveffi_free_string("), - "Kotlin JNI: missing weaveffi_free_string after NewStringUTF" + "Kotlin JNI: missing weaveffi_free_string after the jstring copy" ); assert!( jni.contains("_destroy("), diff --git a/crates/weaveffi-cli/tests/snapshots.rs b/crates/weaveffi-cli/tests/snapshots.rs index 0d96d8d3..32ab48fb 100644 --- a/crates/weaveffi-cli/tests/snapshots.rs +++ b/crates/weaveffi-cli/tests/snapshots.rs @@ -2,16 +2,17 @@ //! IDL corpus. //! //! Five fixtures cover the whole IDL surface between them: `kitchen_sink` -//! (every scalar and composite type, borrowed params, handles, an interface, -//! callbacks and a listener, an error domain, iterators, async and -//! cancellable functions, deprecation, and a nested submodule), `shapes` -//! (rich enums and the full numeric primitive set), `nested_modules` (a -//! three-deep module tree with cross-module references), `docs_everywhere` -//! (doc comments on every declaration kind), and `edge_cases` (identifiers -//! that are reserved words in some target, deeply nested composites, -//! optional and mutable parameters, interfaces in every legal position, -//! async functions with non-string results, type-level deprecation, and -//! scalar and string iterators). One test per generator runs it over all +//! (every scalar and composite type, an interface with objects in optional, +//! list, iterator, and record positions, a callback interface, an error +//! domain, iterators, async and cancellable functions, deprecation, and a +//! nested submodule), `shapes` (rich enums and the full numeric primitive +//! set), `nested_modules` (a three-deep module tree with cross-module +//! references), `docs_everywhere` (doc comments on every declaration kind), +//! and `edge_cases` (identifiers that are reserved words in some target, +//! deeply nested composites, optional parameters, interfaces and callback +//! interfaces in every legal position, async functions with non-string +//! results, type-level deprecation, and scalar and string iterators). One +//! test per generator runs it over all //! five fixtures into a fresh tempdir, walks the //! resulting files in sorted order, and snapshots each file under //! `tests/snapshots/`. Regressions in any generator's output fail the @@ -25,12 +26,12 @@ use camino::Utf8Path; use weaveffi_core::codegen::{ConfiguredBackend, Target}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::validate::validate_api; -use weaveffi_gen_android::{AndroidConfig, AndroidGenerator}; use weaveffi_gen_c::{CConfig, CGenerator}; use weaveffi_gen_cpp::{CppConfig, CppGenerator}; use weaveffi_gen_dart::{DartConfig, DartGenerator}; use weaveffi_gen_dotnet::{DotnetConfig, DotnetGenerator}; use weaveffi_gen_go::{GoConfig, GoGenerator}; +use weaveffi_gen_kotlin::{KotlinConfig, KotlinGenerator}; use weaveffi_gen_node::{NodeConfig, NodeGenerator}; use weaveffi_gen_python::{PythonConfig, PythonGenerator}; use weaveffi_gen_ruby::{RubyConfig, RubyGenerator}; @@ -162,7 +163,7 @@ snapshot_tests! { snapshot_c => CGenerator, CConfig; snapshot_cpp => CppGenerator, CppConfig; snapshot_swift => SwiftGenerator, SwiftConfig; - snapshot_android => AndroidGenerator, AndroidConfig; + snapshot_kotlin => KotlinGenerator, KotlinConfig; snapshot_node => NodeGenerator, NodeConfig; snapshot_wasm => WasmGenerator, WasmConfig; snapshot_python => PythonGenerator, PythonConfig; diff --git a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__build_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__build_gradle.snap deleted file mode 100644 index 5cf5c482..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__build_gradle.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -} - -android { - namespace 'com.weaveffi' - compileSdk 34 - defaultConfig { - minSdk 24 - externalNativeBuild { - cmake { - cppFlags "" - } - } - } - externalNativeBuild { - cmake { - path "src/main/cpp/CMakeLists.txt" - } - } -} - -// END build.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap deleted file mode 100644 index 8c7a5ad4..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap +++ /dev/null @@ -1,285 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -#include -#include -#include -#include -#include -#include -#include "weaveffi.h" - -static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { - const char* msg = err->message ? err->message : "WeaveFFI error"; - jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); - if (exClass != NULL) { - jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); - jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); - if (ex != NULL) { (*env)->Throw(env, ex); } - } - weaveffi_error_clear(err); -} - -static void throw_weaveffi_docs_DocsErrors(JNIEnv* env, weaveffi_error* err) { - jclass exClass = (*env)->FindClass(env, "com/weaveffi/DocsErrorsException"); - jmethodID fromCode = exClass ? (*env)->GetStaticMethodID(env, exClass, "fromCode", "(ILjava/lang/String;[B)Lcom/weaveffi/WeaveFFIException;") : NULL; - if (fromCode == NULL) { - (*env)->ExceptionClear(env); - throw_weaveffi_error(env, err); - return; - } - const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); - jbyteArray jpayload = NULL; - if (err->payload_ptr != NULL) { - jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); - if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); } - } - jthrowable ex = (jthrowable)(*env)->CallStaticObjectMethod(env, exClass, fromCode, (jint)err->code, jmsg, jpayload); - if (ex != NULL && !(*env)->ExceptionCheck(env)) { (*env)->Throw(env, ex); } - weaveffi_error_clear(err); -} - -typedef struct { - JavaVM* jvm; - jobject callback; -} weaveffi_jni_async_ctx; - -static jclass weaveffi_jni_entry_class = NULL; -static jmethodID weaveffi_jni_dispatch_exc = NULL; - -JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { - (void)reserved; - JNIEnv* env = NULL; - if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } - jclass cls = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); - if (cls == NULL) { (*env)->ExceptionClear(env); return JNI_VERSION_1_6; } - weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, cls); - weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, "dispatchCallbackException", "(Ljava/lang/Throwable;)V"); - if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); } - return JNI_VERSION_1_6; -} - -static void weaveffi_jni_handle_uncaught(JNIEnv* env) { - if (!(*env)->ExceptionCheck(env)) { return; } - jthrowable ex = (*env)->ExceptionOccurred(env); - (*env)->ExceptionClear(env); - if (weaveffi_jni_entry_class != NULL && weaveffi_jni_dispatch_exc != NULL) { - (*env)->CallStaticVoidMethod(env, weaveffi_jni_entry_class, weaveffi_jni_dispatch_exc, ex); - if (!(*env)->ExceptionCheck(env)) { (*env)->DeleteLocalRef(env, ex); return; } - (*env)->ExceptionClear(env); - } - (*env)->Throw(env, ex); - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, ex); -} - -typedef struct weaveffi_jni_listener_ctx { - JavaVM* jvm; - jobject callback; - uint64_t id; - struct weaveffi_jni_listener_ctx* next; -} weaveffi_jni_listener_ctx; - -static weaveffi_jni_listener_ctx* weaveffi_jni_listeners = NULL; -static pthread_mutex_t weaveffi_jni_listener_lock = PTHREAD_MUTEX_INITIALIZER; - -static void weaveffi_docs_OnSaved_fn_jni_tramp(int64_t doc_id, int32_t revision, void* context) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context; - JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return; - attached = 1; - } - if ((*env)->PushLocalFrame(env, 32) != 0) { - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); - return; - } - jclass _a0_cls = (*env)->FindClass(env, "java/lang/Long"); - jobject _a0 = (*env)->CallStaticObjectMethod(env, _a0_cls, (*env)->GetStaticMethodID(env, _a0_cls, "valueOf", "(J)Ljava/lang/Long;"), (jlong)doc_id); - jclass _a1_cls = (*env)->FindClass(env, "java/lang/Integer"); - jobject _a1 = (*env)->CallStaticObjectMethod(env, _a1_cls, (*env)->GetStaticMethodID(env, _a1_cls, "valueOf", "(I)Ljava/lang/Integer;"), (jint)revision); - jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); - (*env)->CallObjectMethod(env, ctx->callback, invoke, _a0, _a1); - weaveffi_jni_handle_uncaught(env); - (*env)->PopLocalFrame(env, NULL); - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); -} - -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerSavedListener(JNIEnv* env, jclass clazz, jobject callback) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - uint64_t id = weaveffi_docs_register_saved_listener(weaveffi_docs_OnSaved_fn_jni_tramp, ctx); - ctx->id = id; - pthread_mutex_lock(&weaveffi_jni_listener_lock); - ctx->next = weaveffi_jni_listeners; - weaveffi_jni_listeners = ctx; - pthread_mutex_unlock(&weaveffi_jni_listener_lock); - return (jlong)id; -} - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_unregisterSavedListener(JNIEnv* env, jclass clazz, jlong id) { - weaveffi_docs_unregister_saved_listener((uint64_t)id); - pthread_mutex_lock(&weaveffi_jni_listener_lock); - weaveffi_jni_listener_ctx** link = &weaveffi_jni_listeners; - while (*link != NULL) { - if ((*link)->id == (uint64_t)id) { - weaveffi_jni_listener_ctx* found = *link; - *link = found->next; - (*env)->DeleteGlobalRef(env, found->callback); - free(found); - break; - } - link = &(*link)->next; - } - pthread_mutex_unlock(&weaveffi_jni_listener_lock); -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_createDocumentJni(JNIEnv* env, jclass clazz, jstring title, jbyteArray tags) { - weaveffi_error err = {0, NULL, NULL, 0}; - const char* title_chars = (*env)->GetStringUTFChars(env, title, NULL); - jbyte* tags_elems = (*env)->GetByteArrayElements(env, tags, NULL); - jsize tags_len = (*env)->GetArrayLength(env, tags); - size_t out_len = 0; - const uint8_t* rv = weaveffi_docs_create_document(title_chars, (const uint8_t*)tags_elems, (size_t)tags_len, &out_len, &err); - (*env)->ReleaseStringUTFChars(env, title, title_chars); - (*env)->ReleaseByteArrayElements(env, tags, tags_elems, JNI_ABORT); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_getDocumentJni(JNIEnv* env, jclass clazz, jlong id) { - weaveffi_error err = {0, NULL, NULL, 0}; - size_t out_len = 0; - const uint8_t* rv = weaveffi_docs_get_document((int64_t)id, &out_len, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; -} - -static void weaveffi_docs_publish_async_jni_cb(void* context, weaveffi_error* err, bool result) { - weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; - JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } - if (err != NULL && err->code != 0) { - const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); - jbyteArray jpayload = NULL; - if (err->payload_ptr != NULL) { - jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); - if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); } - } - jint jcode = (jint)err->code; - weaveffi_error_free(err); - jclass cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); - (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); - } else { - jclass boxCls = (*env)->FindClass(env, "java/lang/Boolean"); - jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, "valueOf", "(Z)Ljava/lang/Boolean;"); - jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, result ? JNI_TRUE : JNI_FALSE); - jclass cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); - (*env)->CallVoidMethod(env, ctx->callback, mid, boxed); - } - weaveffi_jni_handle_uncaught(env); - (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; - free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); -} - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_publishAsyncAsync(JNIEnv* env, jclass clazz, jlong id, jobject callback) { - weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - weaveffi_docs_publish_async_async((int64_t)id, weaveffi_docs_publish_async_jni_cb, ctx); -} - -JNIEXPORT jlong JNICALL Java_com_weaveffi_Notebook_nativeNew(JNIEnv* env, jclass clazz) { - weaveffi_error err = {0, NULL, NULL, 0}; - void* rv = weaveffi_docs_Notebook_new(&err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jlong)(intptr_t) rv; -} - -JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeMaxDocuments(JNIEnv* env, jclass clazz) { - weaveffi_error err = {0, NULL, NULL, 0}; - int32_t rv = weaveffi_docs_Notebook_max_documents(&err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jint) rv; -} - -JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeInsert(JNIEnv* env, jclass clazz, jlong selfHandle, jbyteArray document) { - weaveffi_error err = {0, NULL, NULL, 0}; - jbyte* document_elems = (*env)->GetByteArrayElements(env, document, NULL); - jsize document_len = (*env)->GetArrayLength(env, document); - int32_t rv = weaveffi_docs_Notebook_insert((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, (const uint8_t*)document_elems, (size_t)document_len, &err); - (*env)->ReleaseByteArrayElements(env, document, document_elems, JNI_ABORT); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jint) rv; -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_Notebook_nativeFind(JNIEnv* env, jclass clazz, jlong selfHandle, jlong id) { - weaveffi_error err = {0, NULL, NULL, 0}; - size_t out_len = 0; - const uint8_t* rv = weaveffi_docs_Notebook_find((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, (int64_t)id, &out_len, &err); - if (err.code != 0) { - throw_weaveffi_docs_DocsErrors(env, &err); - return NULL; - } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; -} - -JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeSize(JNIEnv* env, jclass clazz, jlong selfHandle) { - weaveffi_error err = {0, NULL, NULL, 0}; - int32_t rv = weaveffi_docs_Notebook_size((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jint) rv; -} - -JNIEXPORT void JNICALL Java_com_weaveffi_Notebook_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { - weaveffi_docs_Notebook_destroy((weaveffi_docs_Notebook*)(intptr_t)handle); -} - - -// END weaveffi_jni.c diff --git a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__build_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_edge_cases__build_gradle.snap deleted file mode 100644 index 5cf5c482..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__build_gradle.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -} - -android { - namespace 'com.weaveffi' - compileSdk 34 - defaultConfig { - minSdk 24 - externalNativeBuild { - cmake { - cppFlags "" - } - } - } - externalNativeBuild { - cmake { - path "src/main/cpp/CMakeLists.txt" - } - } -} - -// END build.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__build_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__build_gradle.snap deleted file mode 100644 index 5cf5c482..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__build_gradle.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -} - -android { - namespace 'com.weaveffi' - compileSdk 34 - defaultConfig { - minSdk 24 - externalNativeBuild { - cmake { - cppFlags "" - } - } - } - externalNativeBuild { - cmake { - path "src/main/cpp/CMakeLists.txt" - } - } -} - -// END build.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__build_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_nested_modules__build_gradle.snap deleted file mode 100644 index 5cf5c482..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__build_gradle.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -} - -android { - namespace 'com.weaveffi' - compileSdk 34 - defaultConfig { - minSdk 24 - externalNativeBuild { - cmake { - cppFlags "" - } - } - } - externalNativeBuild { - cmake { - path "src/main/cpp/CMakeLists.txt" - } - } -} - -// END build.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_shapes__build_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_shapes__build_gradle.snap deleted file mode 100644 index 5cf5c482..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_shapes__build_gradle.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -plugins { - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -} - -android { - namespace 'com.weaveffi' - compileSdk 34 - defaultConfig { - minSdk 24 - externalNativeBuild { - cmake { - cppFlags "" - } - } - } - externalNativeBuild { - cmake { - path "src/main/cpp/CMakeLists.txt" - } - } -} - -// END build.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_shapes__settings_gradle.snap b/crates/weaveffi-cli/tests/snapshots/android_shapes__settings_gradle.snap deleted file mode 100644 index f10a8b92..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_shapes__settings_gradle.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -rootProject.name = 'weaveffi' - -// END settings.gradle diff --git a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_CMakeLists_txt.snap deleted file mode 100644 index e2c5c99f..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_CMakeLists_txt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -# Generated by WeaveFFI [VERSION] from weaveffi.yml -# DO NOT EDIT. Your changes will be overwritten. -# To regenerate: weaveffi generate weaveffi.yml -o - -cmake_minimum_required(VERSION 3.22) -project(weaveffi) -add_library(weaveffi SHARED weaveffi_jni.c) -target_include_directories(weaveffi PRIVATE ../../../../c) - -# END CMakeLists.txt diff --git a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_weaveffi_jni_c.snap deleted file mode 100644 index c45fec51..00000000 --- a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_cpp_weaveffi_jni_c.snap +++ /dev/null @@ -1,88 +0,0 @@ ---- -source: crates/weaveffi-cli/tests/snapshots.rs ---- -// Generated by WeaveFFI [VERSION] from weaveffi.yml -// DO NOT EDIT. Your changes will be overwritten. -// To regenerate: weaveffi generate weaveffi.yml -o - -#include -#include -#include -#include -#include -#include "weaveffi.h" - -static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { - const char* msg = err->message ? err->message : "WeaveFFI error"; - jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); - if (exClass != NULL) { - jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); - jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); - if (ex != NULL) { (*env)->Throw(env, ex); } - } - weaveffi_error_clear(err); -} - -JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_describeJni(JNIEnv* env, jclass clazz, jbyteArray shape) { - weaveffi_error err = {0, NULL, NULL, 0}; - jbyte* shape_elems = (*env)->GetByteArrayElements(env, shape, NULL); - jsize shape_len = (*env)->GetArrayLength(env, shape); - const char* rv = weaveffi_shapes_describe((const uint8_t*)shape_elems, (size_t)shape_len, &err); - (*env)->ReleaseByteArrayElements(env, shape, shape_elems, JNI_ABORT); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); - weaveffi_free_string(rv); - return out; -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_scaleJni(JNIEnv* env, jclass clazz, jbyteArray shape, jdouble factor) { - weaveffi_error err = {0, NULL, NULL, 0}; - jbyte* shape_elems = (*env)->GetByteArrayElements(env, shape, NULL); - jsize shape_len = (*env)->GetArrayLength(env, shape); - size_t out_len = 0; - const uint8_t* rv = weaveffi_shapes_scale((const uint8_t*)shape_elems, (size_t)shape_len, (double)factor, &out_len, &err); - (*env)->ReleaseByteArrayElements(env, shape, shape_elems, JNI_ABORT); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; -} - -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_sumBytes(JNIEnv* env, jclass clazz, jbyteArray values) { - weaveffi_error err = {0, NULL, NULL, 0}; - jboolean values_is_copy = 0; - jbyte* values_elems = (*env)->GetByteArrayElements(env, values, &values_is_copy); - jsize values_len = (*env)->GetArrayLength(env, values); - uint64_t rv = weaveffi_shapes_sum_bytes((const uint8_t*)values_elems, (size_t)values_len, &err); - (*env)->ReleaseByteArrayElements(env, values, values_elems, 0); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jlong) rv; -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_makeNumericsJni(JNIEnv* env, jclass clazz, jbyte a, jshort b, jint c, jlong d, jbyte e, jshort f, jlong g, jlong h, jfloat i, jdouble j) { - weaveffi_error err = {0, NULL, NULL, 0}; - size_t out_len = 0; - const uint8_t* rv = weaveffi_shapes_make_numerics((int8_t)a, (int16_t)b, (int32_t)c, (int64_t)d, (uint8_t)e, (uint16_t)f, (uint32_t)g, (uint64_t)h, (float)i, (double)j, &out_len, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; -} - - -// END weaveffi_jni.c diff --git a/crates/weaveffi-cli/tests/snapshots/c_docs_everywhere__weaveffi_h.snap b/crates/weaveffi-cli/tests/snapshots/c_docs_everywhere__weaveffi_h.snap index 1aa6311a..69dd7ae3 100644 --- a/crates/weaveffi-cli/tests/snapshots/c_docs_everywhere__weaveffi_h.snap +++ b/crates/weaveffi-cli/tests/snapshots/c_docs_everywhere__weaveffi_h.snap @@ -46,15 +46,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -62,6 +63,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -117,8 +122,19 @@ typedef enum { weaveffi_docs_DocsErrors_invalid_status = 2 } weaveffi_docs_DocsErrors; typedef struct weaveffi_docs_Notebook weaveffi_docs_Notebook; -/** Fires after a document has been persisted successfully */ -typedef void (*weaveffi_docs_OnSaved_fn)(int64_t doc_id, int32_t revision, void* context); +/** + * Consumer-implemented observer of document-saved notifications. + * + * Implementations must be safe to call from any thread. + * + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_docs_SavedListener_vtable { + /** Fires after a document has been persisted successfully */ + void (*on_saved)(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_docs_SavedListener_vtable; typedef void (*weaveffi_docs_publish_async_callback)(void* context, weaveffi_error* err, bool result); // Module: docs @@ -137,12 +153,11 @@ WEAVEFFI_API int32_t weaveffi_docs_Notebook_insert(const weaveffi_docs_Notebook* WEAVEFFI_API const uint8_t* weaveffi_docs_Notebook_find(const weaveffi_docs_Notebook* self, int64_t id, size_t* out_len, weaveffi_error* out_err); /** The number of documents currently held */ WEAVEFFI_API int32_t weaveffi_docs_Notebook_size(const weaveffi_docs_Notebook* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_docs_Notebook* weaveffi_docs_Notebook_clone(const weaveffi_docs_Notebook* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_docs_Notebook_destroy(weaveffi_docs_Notebook* self); -/** Subscribe to document-saved notifications */ -WEAVEFFI_API uint64_t weaveffi_docs_register_saved_listener(weaveffi_docs_OnSaved_fn callback, void* context); -/** Subscribe to document-saved notifications */ -WEAVEFFI_API void weaveffi_docs_unregister_saved_listener(uint64_t id); /** * Create a brand new document. * @@ -154,6 +169,8 @@ WEAVEFFI_API const uint8_t* weaveffi_docs_create_document(const char* title, con WEAVEFFI_API const uint8_t* weaveffi_docs_get_document(int64_t id, size_t* out_len, weaveffi_error* out_err); /** Publish a document in the background */ WEAVEFFI_API void weaveffi_docs_publish_async_async(int64_t id, weaveffi_docs_publish_async_callback callback, void* context); +/** Register a listener for saved notifications */ +WEAVEFFI_API void weaveffi_docs_watch(void* listener_ctx, const weaveffi_docs_SavedListener_vtable* listener_vtable, weaveffi_error* out_err); #ifdef __cplusplus diff --git a/crates/weaveffi-cli/tests/snapshots/c_edge_cases__weaveffi_h.snap b/crates/weaveffi-cli/tests/snapshots/c_edge_cases__weaveffi_h.snap index 4652fefa..4b4e647b 100644 --- a/crates/weaveffi-cli/tests/snapshots/c_edge_cases__weaveffi_h.snap +++ b/crates/weaveffi-cli/tests/snapshots/c_edge_cases__weaveffi_h.snap @@ -46,15 +46,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -62,6 +63,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -103,13 +108,38 @@ typedef enum { weaveffi_edge_cases_Mode_Fast = 1, weaveffi_edge_cases_Mode_Safe typedef enum { weaveffi_edge_cases_EdgeErrors_Busy = 7 } weaveffi_edge_cases_EdgeErrors; typedef struct weaveffi_edge_cases_Widget weaveffi_edge_cases_Widget; typedef struct weaveffi_edge_cases_Vault weaveffi_edge_cases_Vault; +typedef struct weaveffi_edge_cases_WidgetStreamIterator weaveffi_edge_cases_WidgetStreamIterator; typedef struct weaveffi_edge_cases_StringStreamIterator weaveffi_edge_cases_StringStreamIterator; typedef struct weaveffi_edge_cases_ScalarStreamIterator weaveffi_edge_cases_ScalarStreamIterator; typedef struct weaveffi_edge_cases_ModeStreamIterator weaveffi_edge_cases_ModeStreamIterator; -typedef struct weaveffi_edge_cases_Leaf weaveffi_edge_cases_Leaf; -/** Callback carrying composite payloads */ -typedef void (*weaveffi_edge_cases_OnBatch_fn)(const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context); -typedef void (*weaveffi_edge_cases_OnTick_fn)(void* context); +/** + * Callback interface carrying composite payloads and objects + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_edge_cases_BatchListener_vtable { + /** Composite payloads cross as value buffers */ + void (*on_batch)(void* ctx, const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err); + /** Objects cross as strong references the consumer must release */ + void (*on_widget)(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, const uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err); + void (*on_tick)(void* ctx, weaveffi_error* out_err); + /** Scalar return travels back through the vtable */ + bool (*should_continue)(void* ctx, int32_t class_, weaveffi_error* out_err); + /** Enum return travels back as its integer discriminant */ + weaveffi_edge_cases_Mode (*pick_mode)(void* ctx, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_edge_cases_BatchListener_vtable; +/** + * Callback interface whose name and methods are keywords + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_edge_cases_Keyword_vtable { + void (*delete_)(void* ctx, int32_t self, weaveffi_error* out_err); + int32_t (*import)(void* ctx, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_edge_cases_Keyword_vtable; +typedef void (*weaveffi_edge_cases_async_widget_callback)(void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result); typedef void (*weaveffi_edge_cases_async_void_callback)(void* context, weaveffi_error* err); typedef void (*weaveffi_edge_cases_async_bytes_callback)(void* context, weaveffi_error* err, const uint8_t* result, size_t result_len); typedef void (*weaveffi_edge_cases_async_struct_callback)(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len); @@ -128,6 +158,9 @@ WEAVEFFI_API const char* weaveffi_edge_cases_Widget_rename(const weaveffi_edge_c WEAVEFFI_API void weaveffi_edge_cases_Widget_fetch_async(const weaveffi_edge_cases_Widget* self, int64_t id, weaveffi_edge_cases_Widget_fetch_callback callback, void* context); WEAVEFFI_DEPRECATED("Use rename instead") WEAVEFFI_API const char* weaveffi_edge_cases_Widget_old_name(const weaveffi_edge_cases_Widget* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_Widget_clone(const weaveffi_edge_cases_Widget* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_edge_cases_Widget_destroy(weaveffi_edge_cases_Widget* self); /** Deprecated interface with a throwing constructor */ @@ -136,12 +169,11 @@ WEAVEFFI_API weaveffi_edge_cases_Vault* weaveffi_edge_cases_Vault_open(const cha /** Throwing static */ WEAVEFFI_API bool weaveffi_edge_cases_Vault_probe(weaveffi_error* out_err); WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_Vault_peek(const weaveffi_edge_cases_Vault* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_edge_cases_Vault* weaveffi_edge_cases_Vault_clone(const weaveffi_edge_cases_Vault* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_edge_cases_Vault_destroy(weaveffi_edge_cases_Vault* self); -WEAVEFFI_API uint64_t weaveffi_edge_cases_register_batch_listener(weaveffi_edge_cases_OnBatch_fn callback, void* context); -WEAVEFFI_API void weaveffi_edge_cases_unregister_batch_listener(uint64_t id); -WEAVEFFI_API uint64_t weaveffi_edge_cases_register_tick_listener(weaveffi_edge_cases_OnTick_fn callback, void* context); -WEAVEFFI_API void weaveffi_edge_cases_unregister_tick_listener(uint64_t id); /** No parameters and no result */ WEAVEFFI_API void weaveffi_edge_cases_noop(weaveffi_error* out_err); /** Function named after a keyword whose parameters are all keywords */ @@ -149,15 +181,22 @@ WEAVEFFI_API int32_t weaveffi_edge_cases_select(int32_t class_, const char* from /** Optional list of optional structs, the deepest legal nesting */ WEAVEFFI_API const uint8_t* weaveffi_edge_cases_nested_optional(const uint8_t* items_ptr, size_t items_len, size_t* out_len, weaveffi_error* out_err); WEAVEFFI_API const uint8_t* weaveffi_edge_cases_optional_params(const uint8_t* text_ptr, size_t text_len, const uint8_t* count_ptr, size_t count_len, const uint8_t* outer_ptr, size_t outer_len, const uint8_t* tags_ptr, size_t tags_len, const uint8_t* blob_ptr, size_t blob_len, const uint8_t* mode_ptr, size_t mode_len, size_t* out_len, weaveffi_error* out_err); -/** Write-back parameters */ -WEAVEFFI_API void weaveffi_edge_cases_mutable_params(char* text, uint8_t* blob_ptr, size_t blob_len, weaveffi_error* out_err); +/** Two callback interface parameters on one function */ +WEAVEFFI_API void weaveffi_edge_cases_register_batch(void* listener_ctx, const weaveffi_edge_cases_BatchListener_vtable* listener_vtable, void* keyword_ctx, const weaveffi_edge_cases_Keyword_vtable* keyword_vtable, weaveffi_error* out_err); +/** Objects in every buffered position */ +WEAVEFFI_API const uint8_t* weaveffi_edge_cases_widgets_everywhere(const uint8_t* list_ptr, size_t list_len, const uint8_t* by_name_ptr, size_t by_name_len, const uint8_t* sparse_ptr, size_t sparse_len, const uint8_t* holder_ptr, size_t holder_len, size_t* out_len, weaveffi_error* out_err); +/** Iterator of objects */ +WEAVEFFI_API weaveffi_edge_cases_WidgetStreamIterator* weaveffi_edge_cases_widget_stream(weaveffi_error* out_err); +WEAVEFFI_API int32_t weaveffi_edge_cases_WidgetStreamIterator_next(weaveffi_edge_cases_WidgetStreamIterator* iter, weaveffi_edge_cases_Widget** out_item, weaveffi_error* out_err); +WEAVEFFI_API void weaveffi_edge_cases_WidgetStreamIterator_destroy(weaveffi_edge_cases_WidgetStreamIterator* iter); +/** Async returning an object */ +WEAVEFFI_API void weaveffi_edge_cases_async_widget_async(weaveffi_edge_cases_async_widget_callback callback, void* context); /** Factory for an interface with no constructor */ WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_make_widget(const char* label, weaveffi_error* out_err); /** Optional interface return */ WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_find_widget(int64_t id, weaveffi_error* out_err); /** Interface parameter, including an optional one */ WEAVEFFI_API const char* weaveffi_edge_cases_inspect_widget(const weaveffi_edge_cases_Widget* widget, const weaveffi_edge_cases_Widget* fallback, weaveffi_error* out_err); -WEAVEFFI_API weaveffi_edge_cases_Leaf* weaveffi_edge_cases_typed_handle_param(weaveffi_edge_cases_Leaf* token, weaveffi_handle_t raw, weaveffi_error* out_err); /** Iterator of strings */ WEAVEFFI_API weaveffi_edge_cases_StringStreamIterator* weaveffi_edge_cases_string_stream(weaveffi_error* out_err); WEAVEFFI_API int32_t weaveffi_edge_cases_StringStreamIterator_next(weaveffi_edge_cases_StringStreamIterator* iter, const char** out_item, weaveffi_error* out_err); diff --git a/crates/weaveffi-cli/tests/snapshots/c_kitchen_sink__weaveffi_h.snap b/crates/weaveffi-cli/tests/snapshots/c_kitchen_sink__weaveffi_h.snap index 1e1598b9..7490435b 100644 --- a/crates/weaveffi-cli/tests/snapshots/c_kitchen_sink__weaveffi_h.snap +++ b/crates/weaveffi-cli/tests/snapshots/c_kitchen_sink__weaveffi_h.snap @@ -46,15 +46,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -62,6 +63,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -108,10 +113,20 @@ typedef enum { /** Error codes reported by throwing functions in the `kitchen` module tree. */ typedef enum { weaveffi_kitchen_KitchenErrors_NotFound = 1, weaveffi_kitchen_KitchenErrors_InvalidInput = 2 } weaveffi_kitchen_KitchenErrors; typedef struct weaveffi_kitchen_Gadget weaveffi_kitchen_Gadget; +typedef struct weaveffi_kitchen_StreamGadgetsIterator weaveffi_kitchen_StreamGadgetsIterator; typedef struct weaveffi_kitchen_StreamItemsIterator weaveffi_kitchen_StreamItemsIterator; -typedef struct weaveffi_shared_Token weaveffi_shared_Token; -/** Fires when an item is ready */ -typedef void (*weaveffi_kitchen_OnReady_fn)(int32_t code, const char* msg, void* context); +/** + * Consumer-implemented observer of ready items + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_kitchen_ReadyListener_vtable { + /** Fires when an item is ready */ + void (*on_ready)(void* ctx, int32_t code, const char* msg, weaveffi_error* out_err); + /** Receives the item itself and says whether to keep listening */ + bool (*on_item)(void* ctx, const uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_kitchen_ReadyListener_vtable; typedef void (*weaveffi_kitchen_do_async_callback)(void* context, weaveffi_error* err, const char* result); typedef void (*weaveffi_kitchen_do_cancellable_callback)(void* context, weaveffi_error* err, const char* result); @@ -129,12 +144,11 @@ WEAVEFFI_API const char* weaveffi_kitchen_Gadget_version(weaveffi_error* out_err WEAVEFFI_API const char* weaveffi_kitchen_Gadget_describe(const weaveffi_kitchen_Gadget* self, weaveffi_error* out_err); /** Poke the gadget a number of times, failing on a negative count */ WEAVEFFI_API int32_t weaveffi_kitchen_Gadget_poke(const weaveffi_kitchen_Gadget* self, int32_t times, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_kitchen_Gadget* weaveffi_kitchen_Gadget_clone(const weaveffi_kitchen_Gadget* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_kitchen_Gadget_destroy(weaveffi_kitchen_Gadget* self); -/** Subscribe to OnReady events */ -WEAVEFFI_API uint64_t weaveffi_kitchen_register_ready_listener(weaveffi_kitchen_OnReady_fn callback, void* context); -/** Subscribe to OnReady events */ -WEAVEFFI_API void weaveffi_kitchen_unregister_ready_listener(uint64_t id); /** Identity for bool */ WEAVEFFI_API bool weaveffi_kitchen_bool_id(bool x, weaveffi_error* out_err); WEAVEFFI_API int32_t weaveffi_kitchen_i32_id(int32_t x, weaveffi_error* out_err); @@ -143,14 +157,16 @@ WEAVEFFI_API int64_t weaveffi_kitchen_i64_id(int64_t x, weaveffi_error* out_err) WEAVEFFI_API double weaveffi_kitchen_f64_id(double x, weaveffi_error* out_err); WEAVEFFI_API const char* weaveffi_kitchen_echo_string(const char* s, weaveffi_error* out_err); WEAVEFFI_API const uint8_t* weaveffi_kitchen_echo_bytes(const uint8_t* b_ptr, size_t b_len, size_t* out_len, weaveffi_error* out_err); -/** Borrowed string parameter */ -WEAVEFFI_API const char* weaveffi_kitchen_echo_borrowed_str(const char* s, weaveffi_error* out_err); -/** Borrowed bytes parameter */ -WEAVEFFI_API const uint8_t* weaveffi_kitchen_echo_borrowed_bytes(const uint8_t* b_ptr, size_t b_len, size_t* out_len, weaveffi_error* out_err); -/** Returns an opaque handle */ -WEAVEFFI_API weaveffi_handle_t weaveffi_kitchen_open_handle(weaveffi_error* out_err); -/** Returns a typed handle */ -WEAVEFFI_API weaveffi_shared_Token* weaveffi_kitchen_open_typed_handle(weaveffi_error* out_err); +/** Register a listener, returning how many are attached */ +WEAVEFFI_API int32_t weaveffi_kitchen_subscribe(void* listener_ctx, const weaveffi_kitchen_ReadyListener_vtable* listener_vtable, weaveffi_error* out_err); +/** Optional object in both directions */ +WEAVEFFI_API weaveffi_kitchen_Gadget* weaveffi_kitchen_maybe_gadget(const weaveffi_kitchen_Gadget* fallback, weaveffi_error* out_err); +/** A list of objects */ +WEAVEFFI_API const uint8_t* weaveffi_kitchen_gadgets(size_t* out_len, weaveffi_error* out_err); +/** An iterator over objects */ +WEAVEFFI_API weaveffi_kitchen_StreamGadgetsIterator* weaveffi_kitchen_stream_gadgets(weaveffi_error* out_err); +WEAVEFFI_API int32_t weaveffi_kitchen_StreamGadgetsIterator_next(weaveffi_kitchen_StreamGadgetsIterator* iter, weaveffi_kitchen_Gadget** out_item, weaveffi_error* out_err); +WEAVEFFI_API void weaveffi_kitchen_StreamGadgetsIterator_destroy(weaveffi_kitchen_StreamGadgetsIterator* iter); /** Optional struct return */ WEAVEFFI_API const uint8_t* weaveffi_kitchen_maybe_item(int64_t id, size_t* out_len, weaveffi_error* out_err); /** List of structs */ diff --git a/crates/weaveffi-cli/tests/snapshots/c_nested_modules__weaveffi_h.snap b/crates/weaveffi-cli/tests/snapshots/c_nested_modules__weaveffi_h.snap index 75a21d3f..3f78f7e9 100644 --- a/crates/weaveffi-cli/tests/snapshots/c_nested_modules__weaveffi_h.snap +++ b/crates/weaveffi-cli/tests/snapshots/c_nested_modules__weaveffi_h.snap @@ -46,15 +46,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -62,6 +63,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer diff --git a/crates/weaveffi-cli/tests/snapshots/c_shapes__weaveffi_h.snap b/crates/weaveffi-cli/tests/snapshots/c_shapes__weaveffi_h.snap index c782b656..3d9b9736 100644 --- a/crates/weaveffi-cli/tests/snapshots/c_shapes__weaveffi_h.snap +++ b/crates/weaveffi-cli/tests/snapshots/c_shapes__weaveffi_h.snap @@ -46,15 +46,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -62,6 +63,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer diff --git a/crates/weaveffi-cli/tests/snapshots/cpp_docs_everywhere__weaveffi_hpp.snap b/crates/weaveffi-cli/tests/snapshots/cpp_docs_everywhere__weaveffi_hpp.snap index 47214c26..2aa4e27f 100644 --- a/crates/weaveffi-cli/tests/snapshots/cpp_docs_everywhere__weaveffi_hpp.snap +++ b/crates/weaveffi-cli/tests/snapshots/cpp_docs_everywhere__weaveffi_hpp.snap @@ -15,11 +15,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include #include +#include #include -#include -#include #ifndef WEAVEFFI_API # if defined(_WIN32) || defined(__CYGWIN__) @@ -53,15 +51,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -69,6 +68,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -112,8 +115,19 @@ typedef enum { weaveffi_docs_DocsErrors_invalid_status = 2 } weaveffi_docs_DocsErrors; typedef struct weaveffi_docs_Notebook weaveffi_docs_Notebook; -/** Fires after a document has been persisted successfully */ -typedef void (*weaveffi_docs_OnSaved_fn)(int64_t doc_id, int32_t revision, void* context); +/** + * Consumer-implemented observer of document-saved notifications. + * + * Implementations must be safe to call from any thread. + * + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_docs_SavedListener_vtable { + /** Fires after a document has been persisted successfully */ + void (*on_saved)(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_docs_SavedListener_vtable; typedef void (*weaveffi_docs_publish_async_callback)(void* context, weaveffi_error* err, bool result); /** @@ -131,12 +145,11 @@ WEAVEFFI_API int32_t weaveffi_docs_Notebook_insert(const weaveffi_docs_Notebook* WEAVEFFI_API const uint8_t* weaveffi_docs_Notebook_find(const weaveffi_docs_Notebook* self, int64_t id, size_t* out_len, weaveffi_error* out_err); /** The number of documents currently held */ WEAVEFFI_API int32_t weaveffi_docs_Notebook_size(const weaveffi_docs_Notebook* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_docs_Notebook* weaveffi_docs_Notebook_clone(const weaveffi_docs_Notebook* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_docs_Notebook_destroy(weaveffi_docs_Notebook* self); -/** Subscribe to document-saved notifications */ -WEAVEFFI_API uint64_t weaveffi_docs_register_saved_listener(weaveffi_docs_OnSaved_fn callback, void* context); -/** Subscribe to document-saved notifications */ -WEAVEFFI_API void weaveffi_docs_unregister_saved_listener(uint64_t id); /** * Create a brand new document. * @@ -148,12 +161,21 @@ WEAVEFFI_API const uint8_t* weaveffi_docs_create_document(const char* title, con WEAVEFFI_API const uint8_t* weaveffi_docs_get_document(int64_t id, size_t* out_len, weaveffi_error* out_err); /** Publish a document in the background */ WEAVEFFI_API void weaveffi_docs_publish_async_async(int64_t id, weaveffi_docs_publish_async_callback callback, void* context); +/** Register a listener for saved notifications */ +WEAVEFFI_API void weaveffi_docs_watch(void* listener_ctx, const weaveffi_docs_SavedListener_vtable* listener_vtable, weaveffi_error* out_err); } // extern "C" namespace weaveffi { -/** Base exception for every error reported through the C ABI. */ +/** + * Base exception for every error reported through the C ABI. + * + * Positive codes are the module's declared error codes (see the typed + * domain exceptions); negative codes are runtime traps: -1 a generic + * producer error, -2 a producer panic, -3 a marshalling failure, -4 a + * callback-interface implementation that threw. + */ class WeaveFFIError : public std::runtime_error { int32_t code_; @@ -162,6 +184,17 @@ public: int32_t code() const { return code_; } }; +/** + * Throws WeaveFFIError when the loaded producer library was built for a + * different WeaveFFI ABI revision than this header. Call once at startup. + */ +inline void check_abi_version() { + uint32_t actual = weaveffi_abi_version(); + if (actual != WEAVEFFI_ABI_VERSION) { + throw WeaveFFIError(-1, "WeaveFFI ABI revision mismatch: header " + std::to_string(WEAVEFFI_ABI_VERSION) + ", library " + std::to_string(actual)); + } +} + namespace detail { /** Throw the generic WeaveFFIError if `err` carries a nonzero code. */ @@ -246,7 +279,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -368,6 +402,75 @@ enum class Status : int32_t { Archived = 2 }; +struct Document; +class Notebook; +class SavedListener; + +/** + * An ordered collection of documents. + * + * Notebooks own their documents and release them when destroyed. + */ +class Notebook { + weaveffi_docs_Notebook* handle_; + +public: + /** Adopts one strong reference to a producer object. */ + explicit Notebook(weaveffi_docs_Notebook* h) : handle_(h) {} + + /** Releases this wrapper's reference; the object is dropped with its last one. */ + ~Notebook() { + if (handle_) weaveffi_docs_Notebook_destroy(handle_); + } + + /** Copies share the object: the copy takes a new strong reference. */ + Notebook(const Notebook& other) : handle_(weaveffi_docs_Notebook_clone(other.handle_)) {} + + Notebook& operator=(const Notebook& other) { + if (this != &other) { + weaveffi_docs_Notebook* h = weaveffi_docs_Notebook_clone(other.handle_); + if (handle_) weaveffi_docs_Notebook_destroy(handle_); + handle_ = h; + } + return *this; + } + + Notebook(Notebook&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + Notebook& operator=(Notebook&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_docs_Notebook_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** The wrapped pointer, borrowed: this wrapper keeps its reference. */ + const weaveffi_docs_Notebook* handle() const { return handle_; } + + /** A new strong reference the caller owns (for example to write into a value buffer). */ + weaveffi_docs_Notebook* clone_handle() const { return weaveffi_docs_Notebook_clone(handle_); } + + /** Create an empty notebook */ + Notebook(); + + /** Add a document, returning its position in the notebook */ + int32_t insert(const Document& document) const; + + /** Look up a document by identifier, failing when none matches */ + Document find(int64_t id) const; + + /** The number of documents currently held */ + int32_t size() const; + + /** The largest number of documents one notebook will hold */ + static int32_t max_documents(); + +}; + /** * Represents a single document tracked by the system. * @@ -399,19 +502,19 @@ inline void write_Document(BufferWriter& w, const Document& v) { /** Decodes a `Document` from the WeaveFFI value-buffer format. */ inline Document read_Document(BufferReader& r) { - Document out{}; - out.id = r.read_i64(); - out.title = r.read_string(); - out.status = static_cast(r.read_i32()); + int64_t f_id = r.read_i64(); + std::string f_title = r.read_string(); + Status f_status = static_cast(r.read_i32()); + std::vector f_tags{}; { - size_t v_tags_n = r.read_len(); - out.tags.reserve(v_tags_n); - for (size_t v_tags_i = 0; v_tags_i < v_tags_n; ++v_tags_i) { - std::string v_tags_item = r.read_string(); - out.tags.push_back(std::move(v_tags_item)); + size_t f_tags_n = r.read_len(); + f_tags.reserve(f_tags_n); + for (size_t f_tags_i = 0; f_tags_i < f_tags_n; ++f_tags_i) { + std::string f_tags_item = r.read_string(); + f_tags.push_back(std::move(f_tags_item)); } } - return out; + return Document{std::move(f_id), std::move(f_title), std::move(f_status), std::move(f_tags)}; } } // namespace detail @@ -439,6 +542,8 @@ namespace detail { /** Map a `docs` error code and payload to its typed exception (WeaveFFIError for runtime codes, DocsErrorsError for unknown codes). */ inline std::exception_ptr make_docs_error(int32_t code, const std::string& msg, const uint8_t* payload_ptr, size_t payload_len) { if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg)); + (void)payload_ptr; + (void)payload_len; switch (code) { case 1: return std::make_exception_ptr(NotFoundError(msg)); case 2: return std::make_exception_ptr(InvalidStatusError(msg)); @@ -457,126 +562,99 @@ inline void check_docs(weaveffi_error& err) { } // namespace detail -namespace detail { - -inline std::mutex& wv_listener_mutex() { - static std::mutex m; - return m; -} - -inline std::unordered_map>& wv_listener_registry() { - static std::unordered_map> registry; - return registry; -} - -} // namespace detail - /** - * An ordered collection of documents. + * Consumer-implemented observer of document-saved notifications. * - * Notebooks own their documents and release them when destroyed. + * Implementations must be safe to call from any thread. + * + * + * Implement this interface and pass a `std::shared_ptr` to any function that accepts one. The producer may call the methods from any thread until it releases its last reference to the implementation. An exception thrown by a method is reported to the producer as a foreign error (code -4) and aborts the call that triggered it. */ -class Notebook { - void* handle_; - +class SavedListener { public: - explicit Notebook(void* h) : handle_(h) {} - - ~Notebook() { - if (handle_) weaveffi_docs_Notebook_destroy(static_cast(handle_)); - } + virtual ~SavedListener() = default; - Notebook(const Notebook&) = delete; - Notebook& operator=(const Notebook&) = delete; + /** Fires after a document has been persisted successfully */ + virtual void on_saved(int64_t doc_id, int32_t revision) = 0; +}; - Notebook(Notebook&& other) noexcept : handle_(other.handle_) { - other.handle_ = nullptr; - } +namespace detail { - Notebook& operator=(Notebook&& other) noexcept { - if (this != &other) { - if (handle_) weaveffi_docs_Notebook_destroy(static_cast(handle_)); - handle_ = other.handle_; - other.handle_ = nullptr; +/** Vtable trampolines adapting a `SavedListener` implementation to `weaveffi_docs_SavedListener_vtable`. */ +struct SavedListener_trampolines { + static void on_saved(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err) { + try { + SavedListener& impl = **static_cast*>(ctx); + impl.on_saved(doc_id, revision); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "SavedListener::on_saved threw a non-standard exception"); } - return *this; } - void* handle() const { return handle_; } - - /** Create an empty notebook */ - Notebook() : handle_(nullptr) { - weaveffi_error err{}; - auto result = weaveffi_docs_Notebook_new(&err); - detail::check(err); - handle_ = result; - } - - /** Add a document, returning its position in the notebook */ - int32_t insert(const Document& document) const { - detail::BufferWriter document_buf; - detail::write_Document(document_buf, document); - weaveffi_error err{}; - auto result = weaveffi_docs_Notebook_insert(static_cast(handle_), document_buf.data(), document_buf.size(), &err); - detail::check(err); - return result; + /** Releases the implementation box once the producer drops its last reference. */ + static void free_ctx(void* ctx) { + delete static_cast*>(ctx); } +}; - /** Look up a document by identifier, failing when none matches */ - Document find(int64_t id) const { - size_t out_len = 0; - weaveffi_error err{}; - auto result = weaveffi_docs_Notebook_find(static_cast(handle_), id, &out_len, &err); - detail::check_docs(err); - detail::BufferGuard result_guard{result, out_len}; - detail::BufferReader result_r(result, out_len); - Document ret = detail::read_Document(result_r); - result_r.expect_end(); - return ret; - } +/** The process-wide vtable every `SavedListener` implementation is passed with. */ +inline const weaveffi_docs_SavedListener_vtable& SavedListener_vtable() { + static const weaveffi_docs_SavedListener_vtable vtable = { + &SavedListener_trampolines::on_saved, + &SavedListener_trampolines::free_ctx, + }; + return vtable; +} - /** The number of documents currently held */ - int32_t size() const { - weaveffi_error err{}; - auto result = weaveffi_docs_Notebook_size(static_cast(handle_), &err); - detail::check(err); - return result; - } +} // namespace detail - /** The largest number of documents one notebook will hold */ - static int32_t max_documents() { - weaveffi_error err{}; - auto result = weaveffi_docs_Notebook_max_documents(&err); - detail::check(err); - return result; - } +inline Notebook::Notebook() : handle_(nullptr) { + weaveffi_error err{}; + auto result = weaveffi_docs_Notebook_new(&err); + detail::check(err); + handle_ = result; +} -}; +inline int32_t Notebook::insert(const Document& document) const { + detail::BufferWriter document_buf; + detail::write_Document(document_buf, document); + weaveffi_error err{}; + auto result = weaveffi_docs_Notebook_insert(handle_, document_buf.data(), document_buf.size(), &err); + detail::check(err); + return result; +} -namespace docs { +inline Document Notebook::find(int64_t id) const { + size_t out_len = 0; + weaveffi_error err{}; + auto result = weaveffi_docs_Notebook_find(handle_, id, &out_len, &err); + detail::check_docs(err); + detail::BufferGuard result_guard{result, out_len}; + detail::BufferReader result_r(result, out_len); + Document ret = detail::read_Document(result_r); + result_r.expect_end(); + return ret; +} -/** Subscribe to document-saved notifications */ -/** @return A subscription id for unregister_saved_listener(). */ -inline uint64_t register_saved_listener(std::function callback) { - auto fn = std::make_shared>(std::move(callback)); - uint64_t id = weaveffi_docs_register_saved_listener( - [](int64_t doc_id, int32_t revision, void* context) { - auto& cb = *static_cast*>(context); - cb(doc_id, revision); - }, - fn.get()); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry()[id] = fn; - return id; +inline int32_t Notebook::size() const { + weaveffi_error err{}; + auto result = weaveffi_docs_Notebook_size(handle_, &err); + detail::check(err); + return result; } -/** Unregisters a listener previously registered with register_saved_listener(). */ -inline void unregister_saved_listener(uint64_t id) { - weaveffi_docs_unregister_saved_listener(id); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry().erase(id); +inline int32_t Notebook::max_documents() { + weaveffi_error err{}; + auto result = weaveffi_docs_Notebook_max_documents(&err); + detail::check(err); + return result; } +namespace docs { + /** * Create a brand new document. * @@ -623,18 +701,31 @@ inline std::future publish_async(int64_t id) { auto future = promise_ptr->get_future(); weaveffi_docs_publish_async_async(id, [](void* context, weaveffi_error* err, bool result) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - p->set_value(result); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + p->set_value(result); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; } +/** Register a listener for saved notifications */ +inline void watch(std::shared_ptr listener) { + if (!listener) throw std::invalid_argument("listener: null callback interface"); + auto* listener_ctx = new std::shared_ptr(std::move(listener)); + weaveffi_error err{}; + weaveffi_docs_watch(static_cast(listener_ctx), &detail::SavedListener_vtable(), &err); + detail::check(err); +} + } // namespace docs } // namespace weaveffi diff --git a/crates/weaveffi-cli/tests/snapshots/cpp_edge_cases__weaveffi_hpp.snap b/crates/weaveffi-cli/tests/snapshots/cpp_edge_cases__weaveffi_hpp.snap index 3489b4a2..7b7a2295 100644 --- a/crates/weaveffi-cli/tests/snapshots/cpp_edge_cases__weaveffi_hpp.snap +++ b/crates/weaveffi-cli/tests/snapshots/cpp_edge_cases__weaveffi_hpp.snap @@ -15,13 +15,11 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include #include +#include #include #include #include -#include -#include #ifndef WEAVEFFI_API # if defined(_WIN32) || defined(__CYGWIN__) @@ -55,15 +53,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -71,6 +70,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -100,13 +103,38 @@ typedef enum { weaveffi_edge_cases_Mode_Fast = 1, weaveffi_edge_cases_Mode_Safe typedef enum { weaveffi_edge_cases_EdgeErrors_Busy = 7 } weaveffi_edge_cases_EdgeErrors; typedef struct weaveffi_edge_cases_Widget weaveffi_edge_cases_Widget; typedef struct weaveffi_edge_cases_Vault weaveffi_edge_cases_Vault; +typedef struct weaveffi_edge_cases_WidgetStreamIterator weaveffi_edge_cases_WidgetStreamIterator; typedef struct weaveffi_edge_cases_StringStreamIterator weaveffi_edge_cases_StringStreamIterator; typedef struct weaveffi_edge_cases_ScalarStreamIterator weaveffi_edge_cases_ScalarStreamIterator; typedef struct weaveffi_edge_cases_ModeStreamIterator weaveffi_edge_cases_ModeStreamIterator; -typedef struct weaveffi_edge_cases_Leaf weaveffi_edge_cases_Leaf; -/** Callback carrying composite payloads */ -typedef void (*weaveffi_edge_cases_OnBatch_fn)(const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context); -typedef void (*weaveffi_edge_cases_OnTick_fn)(void* context); +/** + * Callback interface carrying composite payloads and objects + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_edge_cases_BatchListener_vtable { + /** Composite payloads cross as value buffers */ + void (*on_batch)(void* ctx, const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err); + /** Objects cross as strong references the consumer must release */ + void (*on_widget)(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, const uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err); + void (*on_tick)(void* ctx, weaveffi_error* out_err); + /** Scalar return travels back through the vtable */ + bool (*should_continue)(void* ctx, int32_t class_, weaveffi_error* out_err); + /** Enum return travels back as its integer discriminant */ + weaveffi_edge_cases_Mode (*pick_mode)(void* ctx, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_edge_cases_BatchListener_vtable; +/** + * Callback interface whose name and methods are keywords + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_edge_cases_Keyword_vtable { + void (*delete_)(void* ctx, int32_t self, weaveffi_error* out_err); + int32_t (*import)(void* ctx, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_edge_cases_Keyword_vtable; +typedef void (*weaveffi_edge_cases_async_widget_callback)(void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result); typedef void (*weaveffi_edge_cases_async_void_callback)(void* context, weaveffi_error* err); typedef void (*weaveffi_edge_cases_async_bytes_callback)(void* context, weaveffi_error* err, const uint8_t* result, size_t result_len); typedef void (*weaveffi_edge_cases_async_struct_callback)(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len); @@ -124,6 +152,9 @@ WEAVEFFI_API const char* weaveffi_edge_cases_Widget_rename(const weaveffi_edge_c WEAVEFFI_API void weaveffi_edge_cases_Widget_fetch_async(const weaveffi_edge_cases_Widget* self, int64_t id, weaveffi_edge_cases_Widget_fetch_callback callback, void* context); WEAVEFFI_DEPRECATED("Use rename instead") WEAVEFFI_API const char* weaveffi_edge_cases_Widget_old_name(const weaveffi_edge_cases_Widget* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_Widget_clone(const weaveffi_edge_cases_Widget* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_edge_cases_Widget_destroy(weaveffi_edge_cases_Widget* self); /** Deprecated interface with a throwing constructor */ @@ -132,12 +163,11 @@ WEAVEFFI_API weaveffi_edge_cases_Vault* weaveffi_edge_cases_Vault_open(const cha /** Throwing static */ WEAVEFFI_API bool weaveffi_edge_cases_Vault_probe(weaveffi_error* out_err); WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_Vault_peek(const weaveffi_edge_cases_Vault* self, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_edge_cases_Vault* weaveffi_edge_cases_Vault_clone(const weaveffi_edge_cases_Vault* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_edge_cases_Vault_destroy(weaveffi_edge_cases_Vault* self); -WEAVEFFI_API uint64_t weaveffi_edge_cases_register_batch_listener(weaveffi_edge_cases_OnBatch_fn callback, void* context); -WEAVEFFI_API void weaveffi_edge_cases_unregister_batch_listener(uint64_t id); -WEAVEFFI_API uint64_t weaveffi_edge_cases_register_tick_listener(weaveffi_edge_cases_OnTick_fn callback, void* context); -WEAVEFFI_API void weaveffi_edge_cases_unregister_tick_listener(uint64_t id); /** No parameters and no result */ WEAVEFFI_API void weaveffi_edge_cases_noop(weaveffi_error* out_err); /** Function named after a keyword whose parameters are all keywords */ @@ -145,15 +175,22 @@ WEAVEFFI_API int32_t weaveffi_edge_cases_select(int32_t class_, const char* from /** Optional list of optional structs, the deepest legal nesting */ WEAVEFFI_API const uint8_t* weaveffi_edge_cases_nested_optional(const uint8_t* items_ptr, size_t items_len, size_t* out_len, weaveffi_error* out_err); WEAVEFFI_API const uint8_t* weaveffi_edge_cases_optional_params(const uint8_t* text_ptr, size_t text_len, const uint8_t* count_ptr, size_t count_len, const uint8_t* outer_ptr, size_t outer_len, const uint8_t* tags_ptr, size_t tags_len, const uint8_t* blob_ptr, size_t blob_len, const uint8_t* mode_ptr, size_t mode_len, size_t* out_len, weaveffi_error* out_err); -/** Write-back parameters */ -WEAVEFFI_API void weaveffi_edge_cases_mutable_params(char* text, uint8_t* blob_ptr, size_t blob_len, weaveffi_error* out_err); +/** Two callback interface parameters on one function */ +WEAVEFFI_API void weaveffi_edge_cases_register_batch(void* listener_ctx, const weaveffi_edge_cases_BatchListener_vtable* listener_vtable, void* keyword_ctx, const weaveffi_edge_cases_Keyword_vtable* keyword_vtable, weaveffi_error* out_err); +/** Objects in every buffered position */ +WEAVEFFI_API const uint8_t* weaveffi_edge_cases_widgets_everywhere(const uint8_t* list_ptr, size_t list_len, const uint8_t* by_name_ptr, size_t by_name_len, const uint8_t* sparse_ptr, size_t sparse_len, const uint8_t* holder_ptr, size_t holder_len, size_t* out_len, weaveffi_error* out_err); +/** Iterator of objects */ +WEAVEFFI_API weaveffi_edge_cases_WidgetStreamIterator* weaveffi_edge_cases_widget_stream(weaveffi_error* out_err); +WEAVEFFI_API int32_t weaveffi_edge_cases_WidgetStreamIterator_next(weaveffi_edge_cases_WidgetStreamIterator* iter, weaveffi_edge_cases_Widget** out_item, weaveffi_error* out_err); +WEAVEFFI_API void weaveffi_edge_cases_WidgetStreamIterator_destroy(weaveffi_edge_cases_WidgetStreamIterator* iter); +/** Async returning an object */ +WEAVEFFI_API void weaveffi_edge_cases_async_widget_async(weaveffi_edge_cases_async_widget_callback callback, void* context); /** Factory for an interface with no constructor */ WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_make_widget(const char* label, weaveffi_error* out_err); /** Optional interface return */ WEAVEFFI_API weaveffi_edge_cases_Widget* weaveffi_edge_cases_find_widget(int64_t id, weaveffi_error* out_err); /** Interface parameter, including an optional one */ WEAVEFFI_API const char* weaveffi_edge_cases_inspect_widget(const weaveffi_edge_cases_Widget* widget, const weaveffi_edge_cases_Widget* fallback, weaveffi_error* out_err); -WEAVEFFI_API weaveffi_edge_cases_Leaf* weaveffi_edge_cases_typed_handle_param(weaveffi_edge_cases_Leaf* token, weaveffi_handle_t raw, weaveffi_error* out_err); /** Iterator of strings */ WEAVEFFI_API weaveffi_edge_cases_StringStreamIterator* weaveffi_edge_cases_string_stream(weaveffi_error* out_err); WEAVEFFI_API int32_t weaveffi_edge_cases_StringStreamIterator_next(weaveffi_edge_cases_StringStreamIterator* iter, const char** out_item, weaveffi_error* out_err); @@ -182,7 +219,14 @@ WEAVEFFI_API const uint8_t* weaveffi_edge_cases_legacy_value(weaveffi_edge_cases namespace weaveffi { -/** Base exception for every error reported through the C ABI. */ +/** + * Base exception for every error reported through the C ABI. + * + * Positive codes are the module's declared error codes (see the typed + * domain exceptions); negative codes are runtime traps: -1 a generic + * producer error, -2 a producer panic, -3 a marshalling failure, -4 a + * callback-interface implementation that threw. + */ class WeaveFFIError : public std::runtime_error { int32_t code_; @@ -191,6 +235,17 @@ public: int32_t code() const { return code_; } }; +/** + * Throws WeaveFFIError when the loaded producer library was built for a + * different WeaveFFI ABI revision than this header. Call once at startup. + */ +inline void check_abi_version() { + uint32_t actual = weaveffi_abi_version(); + if (actual != WEAVEFFI_ABI_VERSION) { + throw WeaveFFIError(-1, "WeaveFFI ABI revision mismatch: header " + std::to_string(WEAVEFFI_ABI_VERSION) + ", library " + std::to_string(actual)); + } +} + namespace detail { /** Throw the generic WeaveFFIError if `err` carries a nonzero code. */ @@ -275,7 +330,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -395,6 +451,128 @@ enum class Mode : int32_t { Safe = 2 }; +struct Leaf; +struct Inner; +struct Outer; +struct Keywords; +struct Retired; +struct WidgetHolder; +class Widget; +class Vault; +class BatchListener; +class Keyword; + +/** Interface with no constructor; instances come from make_widget */ +class Widget { + weaveffi_edge_cases_Widget* handle_; + +public: + /** Adopts one strong reference to a producer object. */ + explicit Widget(weaveffi_edge_cases_Widget* h) : handle_(h) {} + + /** Releases this wrapper's reference; the object is dropped with its last one. */ + ~Widget() { + if (handle_) weaveffi_edge_cases_Widget_destroy(handle_); + } + + /** Copies share the object: the copy takes a new strong reference. */ + Widget(const Widget& other) : handle_(weaveffi_edge_cases_Widget_clone(other.handle_)) {} + + Widget& operator=(const Widget& other) { + if (this != &other) { + weaveffi_edge_cases_Widget* h = weaveffi_edge_cases_Widget_clone(other.handle_); + if (handle_) weaveffi_edge_cases_Widget_destroy(handle_); + handle_ = h; + } + return *this; + } + + Widget(Widget&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + Widget& operator=(Widget&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_edge_cases_Widget_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** The wrapped pointer, borrowed: this wrapper keeps its reference. */ + const weaveffi_edge_cases_Widget* handle() const { return handle_; } + + /** A new strong reference the caller owns (for example to write into a value buffer). */ + weaveffi_edge_cases_Widget* clone_handle() const { return weaveffi_edge_cases_Widget_clone(handle_); } + + /** Void method that can fail */ + void refresh() const; + + std::string rename(const std::string& class_, const std::optional& import) const; + + /** Async method with a struct result */ + std::future fetch(int64_t id) const; + + [[deprecated("Use rename instead")]] + std::string old_name() const; + +}; + +/** Deprecated interface with a throwing constructor */ +class Vault { + weaveffi_edge_cases_Vault* handle_; + +public: + /** Adopts one strong reference to a producer object. */ + explicit Vault(weaveffi_edge_cases_Vault* h) : handle_(h) {} + + /** Releases this wrapper's reference; the object is dropped with its last one. */ + ~Vault() { + if (handle_) weaveffi_edge_cases_Vault_destroy(handle_); + } + + /** Copies share the object: the copy takes a new strong reference. */ + Vault(const Vault& other) : handle_(weaveffi_edge_cases_Vault_clone(other.handle_)) {} + + Vault& operator=(const Vault& other) { + if (this != &other) { + weaveffi_edge_cases_Vault* h = weaveffi_edge_cases_Vault_clone(other.handle_); + if (handle_) weaveffi_edge_cases_Vault_destroy(handle_); + handle_ = h; + } + return *this; + } + + Vault(Vault&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + Vault& operator=(Vault&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_edge_cases_Vault_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** The wrapped pointer, borrowed: this wrapper keeps its reference. */ + const weaveffi_edge_cases_Vault* handle() const { return handle_; } + + /** A new strong reference the caller owns (for example to write into a value buffer). */ + weaveffi_edge_cases_Vault* clone_handle() const { return weaveffi_edge_cases_Vault_clone(handle_); } + + /** Fails when the vault is busy */ + static Vault open(const std::string& path); + + std::optional peek() const; + + /** Throwing static */ + static bool probe(); + +}; + struct Leaf { float weight; }; @@ -487,6 +665,14 @@ struct Retired { int64_t id; }; +/** Record carrying objects in every buffered position */ +struct WidgetHolder { + Widget primary; + std::optional fallback; + std::vector extras; + std::unordered_map by_name; +}; + namespace detail { /** Encodes a `Leaf` in the WeaveFFI value-buffer format. */ @@ -496,9 +682,8 @@ inline void write_Leaf(BufferWriter& w, const Leaf& v) { /** Decodes a `Leaf` from the WeaveFFI value-buffer format. */ inline Leaf read_Leaf(BufferReader& r) { - Leaf out{}; - out.weight = r.read_f32(); - return out; + float f_weight = r.read_f32(); + return Leaf{std::move(f_weight)}; } /** Encodes a `Inner` in the WeaveFFI value-buffer format. */ @@ -512,17 +697,17 @@ inline void write_Inner(BufferWriter& w, const Inner& v) { /** Decodes a `Inner` from the WeaveFFI value-buffer format. */ inline Inner read_Inner(BufferReader& r) { - Inner out{}; - out.leaf = detail::read_Leaf(r); + Leaf f_leaf = detail::read_Leaf(r); + std::vector f_leaves{}; { - size_t v_leaves_n = r.read_len(); - out.leaves.reserve(v_leaves_n); - for (size_t v_leaves_i = 0; v_leaves_i < v_leaves_n; ++v_leaves_i) { - Leaf v_leaves_item = detail::read_Leaf(r); - out.leaves.push_back(std::move(v_leaves_item)); + size_t f_leaves_n = r.read_len(); + f_leaves.reserve(f_leaves_n); + for (size_t f_leaves_i = 0; f_leaves_i < f_leaves_n; ++f_leaves_i) { + Leaf f_leaves_item = detail::read_Leaf(r); + f_leaves.push_back(std::move(f_leaves_item)); } } - return out; + return Inner{std::move(f_leaf), std::move(f_leaves)}; } /** Encodes a `Outer` in the WeaveFFI value-buffer format. */ @@ -574,75 +759,82 @@ inline void write_Outer(BufferWriter& w, const Outer& v) { /** Decodes a `Outer` from the WeaveFFI value-buffer format. */ inline Outer read_Outer(BufferReader& r) { - Outer out{}; - out.inner = detail::read_Inner(r); + Inner f_inner = detail::read_Inner(r); + std::optional f_maybe_inner{}; if (r.read_option_flag()) { - Inner v_maybe_inner_v = detail::read_Inner(r); - out.maybe_inner = std::move(v_maybe_inner_v); + Inner f_maybe_inner_v = detail::read_Inner(r); + f_maybe_inner = std::move(f_maybe_inner_v); } + std::unordered_map f_by_name{}; { - size_t v_by_name_n = r.read_len(); - for (size_t v_by_name_i = 0; v_by_name_i < v_by_name_n; ++v_by_name_i) { - std::string v_by_name_key = r.read_string(); - Inner v_by_name_val = detail::read_Inner(r); - out.by_name.emplace(std::move(v_by_name_key), std::move(v_by_name_val)); + size_t f_by_name_n = r.read_len(); + for (size_t f_by_name_i = 0; f_by_name_i < f_by_name_n; ++f_by_name_i) { + std::string f_by_name_key = r.read_string(); + Inner f_by_name_val = detail::read_Inner(r); + f_by_name.emplace(std::move(f_by_name_key), std::move(f_by_name_val)); } } + std::vector f_modes{}; { - size_t v_modes_n = r.read_len(); - out.modes.reserve(v_modes_n); - for (size_t v_modes_i = 0; v_modes_i < v_modes_n; ++v_modes_i) { - Mode v_modes_item = static_cast(r.read_i32()); - out.modes.push_back(std::move(v_modes_item)); + size_t f_modes_n = r.read_len(); + f_modes.reserve(f_modes_n); + for (size_t f_modes_i = 0; f_modes_i < f_modes_n; ++f_modes_i) { + Mode f_modes_item = static_cast(r.read_i32()); + f_modes.push_back(std::move(f_modes_item)); } } + std::vector> f_matrix{}; { - size_t v_matrix_n = r.read_len(); - out.matrix.reserve(v_matrix_n); - for (size_t v_matrix_i = 0; v_matrix_i < v_matrix_n; ++v_matrix_i) { - std::vector v_matrix_item{}; + size_t f_matrix_n = r.read_len(); + f_matrix.reserve(f_matrix_n); + for (size_t f_matrix_i = 0; f_matrix_i < f_matrix_n; ++f_matrix_i) { + std::vector f_matrix_item{}; { - size_t v_matrix_item_n = r.read_len(); - v_matrix_item.reserve(v_matrix_item_n); - for (size_t v_matrix_item_i = 0; v_matrix_item_i < v_matrix_item_n; ++v_matrix_item_i) { - int32_t v_matrix_item_item = r.read_i32(); - v_matrix_item.push_back(std::move(v_matrix_item_item)); + size_t f_matrix_item_n = r.read_len(); + f_matrix_item.reserve(f_matrix_item_n); + for (size_t f_matrix_item_i = 0; f_matrix_item_i < f_matrix_item_n; ++f_matrix_item_i) { + int32_t f_matrix_item_item = r.read_i32(); + f_matrix_item.push_back(std::move(f_matrix_item_item)); } } - out.matrix.push_back(std::move(v_matrix_item)); + f_matrix.push_back(std::move(f_matrix_item)); } } + std::vector> f_sparse{}; { - size_t v_sparse_n = r.read_len(); - out.sparse.reserve(v_sparse_n); - for (size_t v_sparse_i = 0; v_sparse_i < v_sparse_n; ++v_sparse_i) { - std::optional v_sparse_item{}; + size_t f_sparse_n = r.read_len(); + f_sparse.reserve(f_sparse_n); + for (size_t f_sparse_i = 0; f_sparse_i < f_sparse_n; ++f_sparse_i) { + std::optional f_sparse_item{}; if (r.read_option_flag()) { - int64_t v_sparse_item_v = r.read_i64(); - v_sparse_item = std::move(v_sparse_item_v); + int64_t f_sparse_item_v = r.read_i64(); + f_sparse_item = std::move(f_sparse_item_v); } - out.sparse.push_back(std::move(v_sparse_item)); + f_sparse.push_back(std::move(f_sparse_item)); } } + std::optional> f_blob{}; if (r.read_option_flag()) { - std::vector v_blob_v = r.read_bytes(); - out.blob = std::move(v_blob_v); + std::vector f_blob_v = r.read_bytes(); + f_blob = std::move(f_blob_v); } + std::optional f_note{}; if (r.read_option_flag()) { - std::string v_note_v = r.read_string(); - out.note = std::move(v_note_v); + std::string f_note_v = r.read_string(); + f_note = std::move(f_note_v); } + std::unordered_map f_by_id{}; { - size_t v_by_id_n = r.read_len(); - for (size_t v_by_id_i = 0; v_by_id_i < v_by_id_n; ++v_by_id_i) { - int64_t v_by_id_key = r.read_i64(); - std::string v_by_id_val = r.read_string(); - out.by_id.emplace(std::move(v_by_id_key), std::move(v_by_id_val)); + size_t f_by_id_n = r.read_len(); + for (size_t f_by_id_i = 0; f_by_id_i < f_by_id_n; ++f_by_id_i) { + int64_t f_by_id_key = r.read_i64(); + std::string f_by_id_val = r.read_string(); + f_by_id.emplace(std::move(f_by_id_key), std::move(f_by_id_val)); } } - out._hidden = r.read_bool(); - out.v2 = r.read_u8(); - return out; + bool f__hidden = r.read_bool(); + uint8_t f_v2 = r.read_u8(); + return Outer{std::move(f_inner), std::move(f_maybe_inner), std::move(f_by_name), std::move(f_modes), std::move(f_matrix), std::move(f_sparse), std::move(f_blob), std::move(f_note), std::move(f_by_id), std::move(f__hidden), std::move(f_v2)}; } /** Encodes a `Keywords` in the WeaveFFI value-buffer format. */ @@ -710,67 +902,66 @@ inline void write_Keywords(BufferWriter& w, const Keywords& v) { /** Decodes a `Keywords` from the WeaveFFI value-buffer format. */ inline Keywords read_Keywords(BufferReader& r) { - Keywords out{}; - out.class_ = r.read_i32(); - out.def = r.read_i32(); - out.import = r.read_i32(); - out.from = r.read_i32(); - out.func = r.read_i32(); - out.range = r.read_i32(); - out.register_ = r.read_i32(); - out.volatile_ = r.read_i32(); - out.new_ = r.read_i32(); - out.default_ = r.read_i32(); - out.end = r.read_i32(); - out.in = r.read_i32(); - out.is = r.read_i32(); - out.int_ = r.read_i32(); - out.var = r.read_i32(); - out.val = r.read_i32(); - out.self = r.read_i32(); - out.this_ = r.read_i32(); - out.object = r.read_i32(); - out.string = r.read_i32(); - out.case_ = r.read_i32(); - out.select = r.read_i32(); - out.switch_ = r.read_i32(); - out.void_ = r.read_i32(); - out.union_ = r.read_i32(); - out.namespace_ = r.read_i32(); - out.operator_ = r.read_i32(); - out.template_ = r.read_i32(); - out.package = r.read_i32(); - out.protocol = r.read_i32(); - out.guard = r.read_i32(); - out.defer = r.read_i32(); - out.inout = r.read_i32(); - out.internal = r.read_i32(); - out.when = r.read_i32(); - out.fun = r.read_i32(); - out.data = r.read_i32(); - out.late = r.read_i32(); - out.part = r.read_i32(); - out.out = r.read_i32(); - out.ref = r.read_i32(); - out.event = r.read_i32(); - out.base = r.read_i32(); - out.lambda = r.read_i32(); - out.yield = r.read_i32(); - out.nil = r.read_i32(); - out.null = r.read_i32(); - out.begin = r.read_i32(); - out.rescue = r.read_i32(); - out.module = r.read_i32(); - out.chan = r.read_i32(); - out.go = r.read_i32(); - out.let = r.read_i32(); - out.export_ = r.read_i32(); - out.delete_ = r.read_i32(); - out.typedef_ = r.read_i32(); - out.sizeof_ = r.read_i32(); - out.static_ = r.read_i32(); - out.super = r.read_i32(); - return out; + int32_t f_class = r.read_i32(); + int32_t f_def = r.read_i32(); + int32_t f_import = r.read_i32(); + int32_t f_from = r.read_i32(); + int32_t f_func = r.read_i32(); + int32_t f_range = r.read_i32(); + int32_t f_register = r.read_i32(); + int32_t f_volatile = r.read_i32(); + int32_t f_new = r.read_i32(); + int32_t f_default = r.read_i32(); + int32_t f_end = r.read_i32(); + int32_t f_in = r.read_i32(); + int32_t f_is = r.read_i32(); + int32_t f_int = r.read_i32(); + int32_t f_var = r.read_i32(); + int32_t f_val = r.read_i32(); + int32_t f_self = r.read_i32(); + int32_t f_this = r.read_i32(); + int32_t f_object = r.read_i32(); + int32_t f_string = r.read_i32(); + int32_t f_case = r.read_i32(); + int32_t f_select = r.read_i32(); + int32_t f_switch = r.read_i32(); + int32_t f_void = r.read_i32(); + int32_t f_union = r.read_i32(); + int32_t f_namespace = r.read_i32(); + int32_t f_operator = r.read_i32(); + int32_t f_template = r.read_i32(); + int32_t f_package = r.read_i32(); + int32_t f_protocol = r.read_i32(); + int32_t f_guard = r.read_i32(); + int32_t f_defer = r.read_i32(); + int32_t f_inout = r.read_i32(); + int32_t f_internal = r.read_i32(); + int32_t f_when = r.read_i32(); + int32_t f_fun = r.read_i32(); + int32_t f_data = r.read_i32(); + int32_t f_late = r.read_i32(); + int32_t f_part = r.read_i32(); + int32_t f_out = r.read_i32(); + int32_t f_ref = r.read_i32(); + int32_t f_event = r.read_i32(); + int32_t f_base = r.read_i32(); + int32_t f_lambda = r.read_i32(); + int32_t f_yield = r.read_i32(); + int32_t f_nil = r.read_i32(); + int32_t f_null = r.read_i32(); + int32_t f_begin = r.read_i32(); + int32_t f_rescue = r.read_i32(); + int32_t f_module = r.read_i32(); + int32_t f_chan = r.read_i32(); + int32_t f_go = r.read_i32(); + int32_t f_let = r.read_i32(); + int32_t f_export = r.read_i32(); + int32_t f_delete = r.read_i32(); + int32_t f_typedef = r.read_i32(); + int32_t f_sizeof = r.read_i32(); + int32_t f_static = r.read_i32(); + int32_t f_super = r.read_i32(); + return Keywords{std::move(f_class), std::move(f_def), std::move(f_import), std::move(f_from), std::move(f_func), std::move(f_range), std::move(f_register), std::move(f_volatile), std::move(f_new), std::move(f_default), std::move(f_end), std::move(f_in), std::move(f_is), std::move(f_int), std::move(f_var), std::move(f_val), std::move(f_self), std::move(f_this), std::move(f_object), std::move(f_string), std::move(f_case), std::move(f_select), std::move(f_switch), std::move(f_void), std::move(f_union), std::move(f_namespace), std::move(f_operator), std::move(f_template), std::move(f_package), std::move(f_protocol), std::move(f_guard), std::move(f_defer), std::move(f_inout), std::move(f_internal), std::move(f_when), std::move(f_fun), std::move(f_data), std::move(f_late), std::move(f_part), std::move(f_out), std::move(f_ref), std::move(f_event), std::move(f_base), std::move(f_lambda), std::move(f_yield), std::move(f_nil), std::move(f_null), std::move(f_begin), std::move(f_rescue), std::move(f_module), std::move(f_chan), std::move(f_go), std::move(f_let), std::move(f_export), std::move(f_delete), std::move(f_typedef), std::move(f_sizeof), std::move(f_static), std::move(f_super)}; } /** Encodes a `Retired` in the WeaveFFI value-buffer format. */ @@ -780,9 +971,55 @@ inline void write_Retired(BufferWriter& w, const Retired& v) { /** Decodes a `Retired` from the WeaveFFI value-buffer format. */ inline Retired read_Retired(BufferReader& r) { - Retired out{}; - out.id = r.read_i64(); - return out; + int64_t f_id = r.read_i64(); + return Retired{std::move(f_id)}; +} + +/** Encodes a `WidgetHolder` in the WeaveFFI value-buffer format. */ +inline void write_WidgetHolder(BufferWriter& w, const WidgetHolder& v) { + w.write_u64(static_cast(reinterpret_cast(v.primary.clone_handle()))); + w.write_option_flag(v.fallback.has_value()); + if (v.fallback.has_value()) { + w.write_u64(static_cast(reinterpret_cast((*v.fallback).clone_handle()))); + } + w.write_len(v.extras.size()); + for (const auto& item0 : v.extras) { + w.write_u64(static_cast(reinterpret_cast(item0.clone_handle()))); + } + w.write_len(v.by_name.size()); + for (const auto& kv0 : v.by_name) { + w.write_string(kv0.first); + w.write_u64(static_cast(reinterpret_cast(kv0.second.clone_handle()))); + } +} + +/** Decodes a `WidgetHolder` from the WeaveFFI value-buffer format. */ +inline WidgetHolder read_WidgetHolder(BufferReader& r) { + Widget f_primary = Widget(reinterpret_cast(static_cast(r.read_u64()))); + std::optional f_fallback{}; + if (r.read_option_flag()) { + Widget f_fallback_v = Widget(reinterpret_cast(static_cast(r.read_u64()))); + f_fallback = std::move(f_fallback_v); + } + std::vector f_extras{}; + { + size_t f_extras_n = r.read_len(); + f_extras.reserve(f_extras_n); + for (size_t f_extras_i = 0; f_extras_i < f_extras_n; ++f_extras_i) { + Widget f_extras_item = Widget(reinterpret_cast(static_cast(r.read_u64()))); + f_extras.push_back(std::move(f_extras_item)); + } + } + std::unordered_map f_by_name{}; + { + size_t f_by_name_n = r.read_len(); + for (size_t f_by_name_i = 0; f_by_name_i < f_by_name_n; ++f_by_name_i) { + std::string f_by_name_key = r.read_string(); + Widget f_by_name_val = Widget(reinterpret_cast(static_cast(r.read_u64()))); + f_by_name.emplace(std::move(f_by_name_key), std::move(f_by_name_val)); + } + } + return WidgetHolder{std::move(f_primary), std::move(f_fallback), std::move(f_extras), std::move(f_by_name)}; } } // namespace detail @@ -804,6 +1041,8 @@ namespace detail { /** Map a `edge_cases` error code and payload to its typed exception (WeaveFFIError for runtime codes, EdgeErrorsError for unknown codes). */ inline std::exception_ptr make_edge_cases_error(int32_t code, const std::string& msg, const uint8_t* payload_ptr, size_t payload_len) { if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg)); + (void)payload_ptr; + (void)payload_len; switch (code) { case 7: return std::make_exception_ptr(BusyError(msg)); default: return std::make_exception_ptr(EdgeErrorsError(code, msg)); @@ -821,225 +1060,281 @@ inline void check_edge_cases(weaveffi_error& err) { } // namespace detail -namespace detail { - -inline std::mutex& wv_listener_mutex() { - static std::mutex m; - return m; -} - -inline std::unordered_map>& wv_listener_registry() { - static std::unordered_map> registry; - return registry; -} - -} // namespace detail - -/** Interface with no constructor; instances come from make_widget */ -class Widget { - void* handle_; - +/** + * Callback interface carrying composite payloads and objects + * + * Implement this interface and pass a `std::shared_ptr` to any function that accepts one. The producer may call the methods from any thread until it releases its last reference to the implementation. An exception thrown by a method is reported to the producer as a foreign error (code -4) and aborts the call that triggered it. + */ +class BatchListener { public: - explicit Widget(void* h) : handle_(h) {} - - ~Widget() { - if (handle_) weaveffi_edge_cases_Widget_destroy(static_cast(handle_)); - } + virtual ~BatchListener() = default; + + /** Composite payloads cross as value buffers */ + virtual void on_batch(const std::vector& items, const std::vector& raw, const std::optional& first, Mode mode) = 0; + /** Objects cross as strong references the consumer must release */ + virtual void on_widget(Widget widget, std::optional fallback, const std::vector& children) = 0; + virtual void on_tick() = 0; + /** Scalar return travels back through the vtable */ + virtual bool should_continue(int32_t class_) = 0; + /** Enum return travels back as its integer discriminant */ + virtual Mode pick_mode() = 0; +}; - Widget(const Widget&) = delete; - Widget& operator=(const Widget&) = delete; +namespace detail { - Widget(Widget&& other) noexcept : handle_(other.handle_) { - other.handle_ = nullptr; +/** Vtable trampolines adapting a `BatchListener` implementation to `weaveffi_edge_cases_BatchListener_vtable`. */ +struct BatchListener_trampolines { + static void on_batch(void* ctx, const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err) { + try { + BatchListener& impl = **static_cast*>(ctx); + detail::BufferReader items_r(items_ptr, items_len); + std::vector items_val{}; + { + size_t items_val_n = items_r.read_len(); + items_val.reserve(items_val_n); + for (size_t items_val_i = 0; items_val_i < items_val_n; ++items_val_i) { + Outer items_val_item = detail::read_Outer(items_r); + items_val.push_back(std::move(items_val_item)); + } + } + items_r.expect_end(); + std::vector raw_val(raw_ptr, raw_ptr + raw_len); + detail::BufferReader first_r(first_ptr, first_len); + std::optional first_val{}; + if (first_r.read_option_flag()) { + Outer first_val_v = detail::read_Outer(first_r); + first_val = std::move(first_val_v); + } + first_r.expect_end(); + impl.on_batch(items_val, raw_val, first_val, static_cast(static_cast(mode))); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "BatchListener::on_batch threw a non-standard exception"); + } } - Widget& operator=(Widget&& other) noexcept { - if (this != &other) { - if (handle_) weaveffi_edge_cases_Widget_destroy(static_cast(handle_)); - handle_ = other.handle_; - other.handle_ = nullptr; + static void on_widget(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, const uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err) { + Widget widget_val(widget); + std::optional fallback_val; + if (fallback) fallback_val.emplace(fallback); + try { + BatchListener& impl = **static_cast*>(ctx); + detail::BufferReader children_r(children_ptr, children_len); + std::vector children_val{}; + { + size_t children_val_n = children_r.read_len(); + children_val.reserve(children_val_n); + for (size_t children_val_i = 0; children_val_i < children_val_n; ++children_val_i) { + Widget children_val_item = Widget(reinterpret_cast(static_cast(children_r.read_u64()))); + children_val.push_back(std::move(children_val_item)); + } + } + children_r.expect_end(); + impl.on_widget(std::move(widget_val), std::move(fallback_val), children_val); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "BatchListener::on_widget threw a non-standard exception"); } - return *this; } - void* handle() const { return handle_; } - - /** Void method that can fail */ - void refresh() const { - weaveffi_error err{}; - weaveffi_edge_cases_Widget_refresh(static_cast(handle_), &err); - detail::check_edge_cases(err); + static void on_tick(void* ctx, weaveffi_error* out_err) { + try { + BatchListener& impl = **static_cast*>(ctx); + impl.on_tick(); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "BatchListener::on_tick threw a non-standard exception"); + } } - std::string rename(const std::string& class_, const std::optional& import) const { - detail::BufferWriter import_buf; - import_buf.write_option_flag(import.has_value()); - if (import.has_value()) { - import_buf.write_string((*import)); + static bool should_continue(void* ctx, int32_t class_, weaveffi_error* out_err) { + try { + BatchListener& impl = **static_cast*>(ctx); + return impl.should_continue(class_); + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "BatchListener::should_continue threw a non-standard exception"); } - weaveffi_error err{}; - auto result = weaveffi_edge_cases_Widget_rename(static_cast(handle_), class_.c_str(), import_buf.data(), import_buf.size(), &err); - detail::check(err); - std::string ret(result); - weaveffi_free_string(result); - return ret; + return bool{}; } - /** Async method with a struct result */ - std::future fetch(int64_t id) const { - auto* promise_ptr = new std::promise(); - auto future = promise_ptr->get_future(); - weaveffi_edge_cases_Widget_fetch_async(static_cast(handle_), id, [](void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { - auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); - weaveffi_error_free(err); - } else { - detail::BufferReader result_r(result_ptr, result_len); - Outer value = detail::read_Outer(result_r); - result_r.expect_end(); - weaveffi_free_bytes(const_cast(result_ptr), result_len); - p->set_value(std::move(value)); - } - delete p; - }, static_cast(promise_ptr)); - return future; + static weaveffi_edge_cases_Mode pick_mode(void* ctx, weaveffi_error* out_err) { + try { + BatchListener& impl = **static_cast*>(ctx); + return static_cast(static_cast(impl.pick_mode())); + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "BatchListener::pick_mode threw a non-standard exception"); + } + return weaveffi_edge_cases_Mode{}; } - [[deprecated("Use rename instead")]] - std::string old_name() const { - weaveffi_error err{}; - auto result = weaveffi_edge_cases_Widget_old_name(static_cast(handle_), &err); - detail::check(err); - std::string ret(result); - weaveffi_free_string(result); - return ret; + /** Releases the implementation box once the producer drops its last reference. */ + static void free_ctx(void* ctx) { + delete static_cast*>(ctx); } - }; -/** Deprecated interface with a throwing constructor */ -class Vault { - void* handle_; +/** The process-wide vtable every `BatchListener` implementation is passed with. */ +inline const weaveffi_edge_cases_BatchListener_vtable& BatchListener_vtable() { + static const weaveffi_edge_cases_BatchListener_vtable vtable = { + &BatchListener_trampolines::on_batch, + &BatchListener_trampolines::on_widget, + &BatchListener_trampolines::on_tick, + &BatchListener_trampolines::should_continue, + &BatchListener_trampolines::pick_mode, + &BatchListener_trampolines::free_ctx, + }; + return vtable; +} + +} // namespace detail +/** + * Callback interface whose name and methods are keywords + * + * Implement this interface and pass a `std::shared_ptr` to any function that accepts one. The producer may call the methods from any thread until it releases its last reference to the implementation. An exception thrown by a method is reported to the producer as a foreign error (code -4) and aborts the call that triggered it. + */ +class Keyword { public: - explicit Vault(void* h) : handle_(h) {} + virtual ~Keyword() = default; - ~Vault() { - if (handle_) weaveffi_edge_cases_Vault_destroy(static_cast(handle_)); - } + virtual void delete_(int32_t self) = 0; + virtual int32_t import() = 0; +}; - Vault(const Vault&) = delete; - Vault& operator=(const Vault&) = delete; +namespace detail { - Vault(Vault&& other) noexcept : handle_(other.handle_) { - other.handle_ = nullptr; +/** Vtable trampolines adapting a `Keyword` implementation to `weaveffi_edge_cases_Keyword_vtable`. */ +struct Keyword_trampolines { + static void delete_(void* ctx, int32_t self, weaveffi_error* out_err) { + try { + Keyword& impl = **static_cast*>(ctx); + impl.delete_(self); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "Keyword::delete_ threw a non-standard exception"); + } } - Vault& operator=(Vault&& other) noexcept { - if (this != &other) { - if (handle_) weaveffi_edge_cases_Vault_destroy(static_cast(handle_)); - handle_ = other.handle_; - other.handle_ = nullptr; + static int32_t import(void* ctx, weaveffi_error* out_err) { + try { + Keyword& impl = **static_cast*>(ctx); + return impl.import(); + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "Keyword::import threw a non-standard exception"); } - return *this; + return int32_t{}; } - void* handle() const { return handle_; } - - /** Fails when the vault is busy */ - static Vault open(const std::string& path) { - weaveffi_error err{}; - auto result = weaveffi_edge_cases_Vault_open(path.c_str(), &err); - detail::check_edge_cases(err); - return Vault(result); + /** Releases the implementation box once the producer drops its last reference. */ + static void free_ctx(void* ctx) { + delete static_cast*>(ctx); } +}; - std::optional peek() const { - weaveffi_error err{}; - auto result = weaveffi_edge_cases_Vault_peek(static_cast(handle_), &err); - detail::check(err); - if (!result) return std::nullopt; - return Widget(result); - } +/** The process-wide vtable every `Keyword` implementation is passed with. */ +inline const weaveffi_edge_cases_Keyword_vtable& Keyword_vtable() { + static const weaveffi_edge_cases_Keyword_vtable vtable = { + &Keyword_trampolines::delete_, + &Keyword_trampolines::import, + &Keyword_trampolines::free_ctx, + }; + return vtable; +} - /** Throwing static */ - static bool probe() { - weaveffi_error err{}; - auto result = weaveffi_edge_cases_Vault_probe(&err); - detail::check_edge_cases(err); - return result; - } +} // namespace detail -}; +inline void Widget::refresh() const { + weaveffi_error err{}; + weaveffi_edge_cases_Widget_refresh(handle_, &err); + detail::check_edge_cases(err); +} -namespace edge_cases { +inline std::string Widget::rename(const std::string& class_, const std::optional& import) const { + detail::BufferWriter import_buf; + import_buf.write_option_flag(import.has_value()); + if (import.has_value()) { + import_buf.write_string((*import)); + } + weaveffi_error err{}; + auto result = weaveffi_edge_cases_Widget_rename(handle_, class_.c_str(), import_buf.data(), import_buf.size(), &err); + detail::check(err); + std::string ret(result); + weaveffi_free_string(result); + return ret; +} -/** @return A subscription id for unregister_batch_listener(). */ -inline uint64_t register_batch_listener(std::function, std::vector, std::optional, Mode)> callback) { - auto fn = std::make_shared, std::vector, std::optional, Mode)>>(std::move(callback)); - uint64_t id = weaveffi_edge_cases_register_batch_listener( - [](const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context) { - auto& cb = *static_cast, std::vector, std::optional, Mode)>*>(context); - std::vector items_val{}; - if (items_ptr != nullptr) { - detail::BufferReader items_r(items_ptr, items_len); - { - size_t items_val_n = items_r.read_len(); - items_val.reserve(items_val_n); - for (size_t items_val_i = 0; items_val_i < items_val_n; ++items_val_i) { - Outer items_val_item = detail::read_Outer(items_r); - items_val.push_back(std::move(items_val_item)); - } - } - items_r.expect_end(); - } - std::optional first_val{}; - if (first_ptr != nullptr) { - detail::BufferReader first_r(first_ptr, first_len); - if (first_r.read_option_flag()) { - Outer first_val_v = detail::read_Outer(first_r); - first_val = std::move(first_val_v); - } - first_r.expect_end(); +inline std::future Widget::fetch(int64_t id) const { + auto* promise_ptr = new std::promise(); + auto future = promise_ptr->get_future(); + weaveffi_edge_cases_Widget_fetch_async(handle_, id, [](void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { + auto* p = static_cast*>(context); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); + } else { + detail::BufferGuard result_guard{result_ptr, result_len}; + detail::BufferReader result_r(result_ptr, result_len); + Outer value = detail::read_Outer(result_r); + result_r.expect_end(); + p->set_value(std::move(value)); } - cb(std::move(items_val), raw_ptr ? std::vector(raw_ptr, raw_ptr + raw_len) : std::vector{}, std::move(first_val), static_cast(static_cast(mode))); - }, - fn.get()); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry()[id] = fn; - return id; + } catch (...) { + p->set_exception(std::current_exception()); + } + weaveffi_error_free(err); + delete p; + }, static_cast(promise_ptr)); + return future; } -/** Unregisters a listener previously registered with register_batch_listener(). */ -inline void unregister_batch_listener(uint64_t id) { - weaveffi_edge_cases_unregister_batch_listener(id); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry().erase(id); +inline std::string Widget::old_name() const { + weaveffi_error err{}; + auto result = weaveffi_edge_cases_Widget_old_name(handle_, &err); + detail::check(err); + std::string ret(result); + weaveffi_free_string(result); + return ret; +} + +inline Vault Vault::open(const std::string& path) { + weaveffi_error err{}; + auto result = weaveffi_edge_cases_Vault_open(path.c_str(), &err); + detail::check_edge_cases(err); + return Vault(result); } -/** @return A subscription id for unregister_tick_listener(). */ -inline uint64_t register_tick_listener(std::function callback) { - auto fn = std::make_shared>(std::move(callback)); - uint64_t id = weaveffi_edge_cases_register_tick_listener( - [](void* context) { - auto& cb = *static_cast*>(context); - cb(); - }, - fn.get()); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry()[id] = fn; - return id; +inline std::optional Vault::peek() const { + weaveffi_error err{}; + auto result = weaveffi_edge_cases_Vault_peek(handle_, &err); + detail::check(err); + if (!result) return std::nullopt; + return Widget(result); } -/** Unregisters a listener previously registered with register_tick_listener(). */ -inline void unregister_tick_listener(uint64_t id) { - weaveffi_edge_cases_unregister_tick_listener(id); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry().erase(id); +inline bool Vault::probe() { + weaveffi_error err{}; + auto result = weaveffi_edge_cases_Vault_probe(&err); + detail::check_edge_cases(err); + return result; } +namespace edge_cases { + /** No parameters and no result */ inline void noop() { weaveffi_error err{}; @@ -1154,13 +1449,168 @@ inline std::optional optional_params(const std::optional& tex return ret; } -/** Write-back parameters */ -inline void mutable_params(std::string& text, std::vector& blob) { +/** Two callback interface parameters on one function */ +inline void register_batch(std::shared_ptr listener, std::shared_ptr keyword) { + if (!listener) throw std::invalid_argument("listener: null callback interface"); + auto* listener_ctx = new std::shared_ptr(std::move(listener)); + if (!keyword) throw std::invalid_argument("keyword: null callback interface"); + auto* keyword_ctx = new std::shared_ptr(std::move(keyword)); weaveffi_error err{}; - weaveffi_edge_cases_mutable_params(text.data(), blob.data(), blob.size(), &err); + weaveffi_edge_cases_register_batch(static_cast(listener_ctx), &detail::BatchListener_vtable(), static_cast(keyword_ctx), &detail::Keyword_vtable(), &err); detail::check(err); } +/** Objects in every buffered position */ +inline WidgetHolder widgets_everywhere(const std::vector& list, const std::unordered_map& by_name, const std::vector>& sparse, const WidgetHolder& holder) { + detail::BufferWriter list_buf; + list_buf.write_len(list.size()); + for (const auto& item0 : list) { + list_buf.write_u64(static_cast(reinterpret_cast(item0.clone_handle()))); + } + detail::BufferWriter by_name_buf; + by_name_buf.write_len(by_name.size()); + for (const auto& kv0 : by_name) { + by_name_buf.write_string(kv0.first); + by_name_buf.write_u64(static_cast(reinterpret_cast(kv0.second.clone_handle()))); + } + detail::BufferWriter sparse_buf; + sparse_buf.write_len(sparse.size()); + for (const auto& item0 : sparse) { + sparse_buf.write_option_flag(item0.has_value()); + if (item0.has_value()) { + sparse_buf.write_u64(static_cast(reinterpret_cast((*item0).clone_handle()))); + } + } + detail::BufferWriter holder_buf; + detail::write_WidgetHolder(holder_buf, holder); + size_t out_len = 0; + weaveffi_error err{}; + auto result = weaveffi_edge_cases_widgets_everywhere(list_buf.data(), list_buf.size(), by_name_buf.data(), by_name_buf.size(), sparse_buf.data(), sparse_buf.size(), holder_buf.data(), holder_buf.size(), &out_len, &err); + detail::check(err); + detail::BufferGuard result_guard{result, out_len}; + detail::BufferReader result_r(result, out_len); + WidgetHolder ret = detail::read_WidgetHolder(result_r); + result_r.expect_end(); + return ret; +} + +/** + * A lazy, move-only range over the `Widget` elements produced by `widget_stream()`. + * + * Each iteration step pulls exactly one element from the producer, so results stream in constant memory. The range owns the producer-side iterator and releases it exactly once: eagerly when the range is exhausted, or from the destructor when iteration is abandoned early. + */ +class WidgetStreamIterator { + weaveffi_edge_cases_WidgetStreamIterator* handle_; + +public: + /** Adopts ownership of the raw producer iterator. */ + explicit WidgetStreamIterator(weaveffi_edge_cases_WidgetStreamIterator* h) : handle_(h) {} + + ~WidgetStreamIterator() { + if (handle_) weaveffi_edge_cases_WidgetStreamIterator_destroy(handle_); + } + + WidgetStreamIterator(const WidgetStreamIterator&) = delete; + WidgetStreamIterator& operator=(const WidgetStreamIterator&) = delete; + + WidgetStreamIterator(WidgetStreamIterator&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + WidgetStreamIterator& operator=(WidgetStreamIterator&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_edge_cases_WidgetStreamIterator_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** Pulls the next element from the producer, or `std::nullopt` once exhausted (which releases the producer iterator eagerly). A producer error throws the generic WeaveFFIError after releasing the iterator. */ + std::optional next() { + if (!handle_) return std::nullopt; + weaveffi_error err{}; + weaveffi_edge_cases_Widget* item{}; + int32_t has_item = weaveffi_edge_cases_WidgetStreamIterator_next(handle_, &item, &err); + if (err.code != 0) { + weaveffi_edge_cases_WidgetStreamIterator_destroy(handle_); + handle_ = nullptr; + detail::check(err); + } + if (has_item == 0) { + weaveffi_edge_cases_WidgetStreamIterator_destroy(handle_); + handle_ = nullptr; + return std::nullopt; + } + return Widget(item); + } + + /** Sentinel type marking the end of the range. */ + struct sentinel {}; + + /** Single-pass input iterator; each increment pulls one element from the producer. */ + class iterator { + WidgetStreamIterator* range_; + std::optional current_; + + public: + using iterator_category = std::input_iterator_tag; + using value_type = Widget; + using difference_type = std::ptrdiff_t; + using pointer = Widget*; + using reference = Widget&; + + /** Binds to `range` and pulls the first element. */ + explicit iterator(WidgetStreamIterator* range) : range_(range), current_(range->next()) {} + reference operator*() { return *current_; } + pointer operator->() { return &*current_; } + iterator& operator++() { current_ = range_->next(); return *this; } + void operator++(int) { current_ = range_->next(); } + bool operator==(sentinel) const { return !current_.has_value(); } + bool operator!=(sentinel) const { return current_.has_value(); } + }; + + /** Begins iteration by pulling the first element. */ + iterator begin() { return iterator(this); } + + /** The past-the-end sentinel. */ + sentinel end() const { return sentinel{}; } +}; + +/** + * Iterator of objects + * + * @return A lazy `WidgetStreamIterator` range that streams one element per iteration step and releases the producer iterator when exhausted or destroyed. + */ +inline WidgetStreamIterator widget_stream() { + weaveffi_error err{}; + weaveffi_edge_cases_WidgetStreamIterator* iter = weaveffi_edge_cases_widget_stream(&err); + detail::check(err); + return WidgetStreamIterator(iter); +} + +/** Async returning an object */ +inline std::future async_widget() { + auto* promise_ptr = new std::promise(); + auto future = promise_ptr->get_future(); + weaveffi_edge_cases_async_widget_async([](void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result) { + auto* p = static_cast*>(context); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); + } else { + p->set_value(Widget(result)); + } + } catch (...) { + p->set_exception(std::current_exception()); + } + weaveffi_error_free(err); + delete p; + }, static_cast(promise_ptr)); + return future; +} + /** Factory for an interface with no constructor */ inline Widget make_widget(const std::string& label) { weaveffi_error err{}; @@ -1181,20 +1631,13 @@ inline std::optional find_widget(int64_t id) { /** Interface parameter, including an optional one */ inline std::string inspect_widget(const Widget& widget, const std::optional& fallback) { weaveffi_error err{}; - auto result = weaveffi_edge_cases_inspect_widget(static_cast(widget.handle()), fallback.has_value() ? static_cast(fallback.value().handle()) : nullptr, &err); + auto result = weaveffi_edge_cases_inspect_widget(widget.handle(), fallback.has_value() ? fallback->handle() : nullptr, &err); detail::check(err); std::string ret(result); weaveffi_free_string(result); return ret; } -inline weaveffi_edge_cases_Leaf* typed_handle_param(weaveffi_edge_cases_Leaf* token, void* raw) { - weaveffi_error err{}; - auto result = weaveffi_edge_cases_typed_handle_param(token, static_cast(reinterpret_cast(raw)), &err); - detail::check(err); - return result; -} - /** * A lazy, move-only range over the `std::string` elements produced by `string_stream()`. * @@ -1204,7 +1647,7 @@ class StringStreamIterator { weaveffi_edge_cases_StringStreamIterator* handle_; public: - /** Adopts ownership of the raw producer iterator handle. */ + /** Adopts ownership of the raw producer iterator. */ explicit StringStreamIterator(weaveffi_edge_cases_StringStreamIterator* h) : handle_(h) {} ~StringStreamIterator() { @@ -1301,7 +1744,7 @@ class ScalarStreamIterator { weaveffi_edge_cases_ScalarStreamIterator* handle_; public: - /** Adopts ownership of the raw producer iterator handle. */ + /** Adopts ownership of the raw producer iterator. */ explicit ScalarStreamIterator(weaveffi_edge_cases_ScalarStreamIterator* h) : handle_(h) {} ~ScalarStreamIterator() { @@ -1396,7 +1839,7 @@ class ModeStreamIterator { weaveffi_edge_cases_ModeStreamIterator* handle_; public: - /** Adopts ownership of the raw producer iterator handle. */ + /** Adopts ownership of the raw producer iterator. */ explicit ModeStreamIterator(weaveffi_edge_cases_ModeStreamIterator* h) : handle_(h) {} ~ModeStreamIterator() { @@ -1488,13 +1931,17 @@ inline std::future async_void() { auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_void_async([](void* context, weaveffi_error* err) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - p->set_value(); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + p->set_value(); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; @@ -1505,15 +1952,19 @@ inline std::future> async_bytes(uint32_t size) { auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_bytes_async(size, [](void* context, weaveffi_error* err, const uint8_t* result, size_t result_len) { auto* p = static_cast>*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - std::vector value(result, result + result_len); - weaveffi_free_bytes(const_cast(result), result_len); - p->set_value(std::move(value)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + std::vector value(result, result + result_len); + weaveffi_free_bytes(const_cast(result), result_len); + p->set_value(std::move(value)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; @@ -1524,46 +1975,54 @@ inline std::future async_struct() { auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_struct_async([](void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); - weaveffi_error_free(err); - } else { - detail::BufferReader result_r(result_ptr, result_len); - Outer value = detail::read_Outer(result_r); - result_r.expect_end(); - weaveffi_free_bytes(const_cast(result_ptr), result_len); - p->set_value(std::move(value)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); + } else { + detail::BufferGuard result_guard{result_ptr, result_len}; + detail::BufferReader result_r(result_ptr, result_len); + Outer value = detail::read_Outer(result_r); + result_r.expect_end(); + p->set_value(std::move(value)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; } -inline std::future> async_list(weaveffi_cancel_token* cancel_token = nullptr) { +inline std::future> async_list(weaveffi_cancel_token* cancel_token) { auto* promise_ptr = new std::promise>(); auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_list_async(cancel_token, [](void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { auto* p = static_cast>*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); - weaveffi_error_free(err); - } else { - detail::BufferReader result_r(result_ptr, result_len); - std::vector value{}; - { - size_t value_n = result_r.read_len(); - value.reserve(value_n); - for (size_t value_i = 0; value_i < value_n; ++value_i) { - Outer value_item = detail::read_Outer(result_r); - value.push_back(std::move(value_item)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_edge_cases_error(err->code, msg, err->payload_ptr, err->payload_len)); + } else { + detail::BufferGuard result_guard{result_ptr, result_len}; + detail::BufferReader result_r(result_ptr, result_len); + std::vector value{}; + { + size_t value_n = result_r.read_len(); + value.reserve(value_n); + for (size_t value_i = 0; value_i < value_n; ++value_i) { + Outer value_item = detail::read_Outer(result_r); + value.push_back(std::move(value_item)); + } } + result_r.expect_end(); + p->set_value(std::move(value)); } - result_r.expect_end(); - weaveffi_free_bytes(const_cast(result_ptr), result_len); - p->set_value(std::move(value)); + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; @@ -1574,21 +2033,25 @@ inline std::future> async_optional() { auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_optional_async([](void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { auto* p = static_cast>*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - detail::BufferReader result_r(result_ptr, result_len); - std::optional value{}; - if (result_r.read_option_flag()) { - int64_t value_v = result_r.read_i64(); - value = std::move(value_v); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + detail::BufferGuard result_guard{result_ptr, result_len}; + detail::BufferReader result_r(result_ptr, result_len); + std::optional value{}; + if (result_r.read_option_flag()) { + int64_t value_v = result_r.read_i64(); + value = std::move(value_v); + } + result_r.expect_end(); + p->set_value(std::move(value)); } - result_r.expect_end(); - weaveffi_free_bytes(const_cast(result_ptr), result_len); - p->set_value(std::move(value)); + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; @@ -1599,13 +2062,17 @@ inline std::future async_enum() { auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_enum_async([](void* context, weaveffi_error* err, weaveffi_edge_cases_Mode result) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - p->set_value(static_cast(result)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + p->set_value(static_cast(result)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; @@ -1616,15 +2083,19 @@ inline std::future async_keywords(const std::string& class_, int32_ auto future = promise_ptr->get_future(); weaveffi_edge_cases_async_keywords_async(class_.c_str(), import, [](void* context, weaveffi_error* err, const char* result) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - std::string value(result ? result : ""); - weaveffi_free_string(result); - p->set_value(std::move(value)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + std::string value(result ? result : ""); + weaveffi_free_string(result); + p->set_value(std::move(value)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; diff --git a/crates/weaveffi-cli/tests/snapshots/cpp_kitchen_sink__weaveffi_hpp.snap b/crates/weaveffi-cli/tests/snapshots/cpp_kitchen_sink__weaveffi_hpp.snap index cfc2233e..35bf650a 100644 --- a/crates/weaveffi-cli/tests/snapshots/cpp_kitchen_sink__weaveffi_hpp.snap +++ b/crates/weaveffi-cli/tests/snapshots/cpp_kitchen_sink__weaveffi_hpp.snap @@ -15,13 +15,11 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include #include +#include #include #include #include -#include -#include #ifndef WEAVEFFI_API # if defined(_WIN32) || defined(__CYGWIN__) @@ -55,15 +53,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -71,6 +70,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -105,10 +108,20 @@ typedef enum { /** Error codes reported by throwing functions in the `kitchen` module tree. */ typedef enum { weaveffi_kitchen_KitchenErrors_NotFound = 1, weaveffi_kitchen_KitchenErrors_InvalidInput = 2 } weaveffi_kitchen_KitchenErrors; typedef struct weaveffi_kitchen_Gadget weaveffi_kitchen_Gadget; +typedef struct weaveffi_kitchen_StreamGadgetsIterator weaveffi_kitchen_StreamGadgetsIterator; typedef struct weaveffi_kitchen_StreamItemsIterator weaveffi_kitchen_StreamItemsIterator; -typedef struct weaveffi_shared_Token weaveffi_shared_Token; -/** Fires when an item is ready */ -typedef void (*weaveffi_kitchen_OnReady_fn)(int32_t code, const char* msg, void* context); +/** + * Consumer-implemented observer of ready items + * + * Consumer-implemented callback interface. The consumer passes a context pointer plus a pointer to a static instance of this vtable; the producer may call any entry from any thread until it calls `free(ctx)` exactly once. + */ +typedef struct weaveffi_kitchen_ReadyListener_vtable { + /** Fires when an item is ready */ + void (*on_ready)(void* ctx, int32_t code, const char* msg, weaveffi_error* out_err); + /** Receives the item itself and says whether to keep listening */ + bool (*on_item)(void* ctx, const uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_kitchen_ReadyListener_vtable; typedef void (*weaveffi_kitchen_do_async_callback)(void* context, weaveffi_error* err, const char* result); typedef void (*weaveffi_kitchen_do_cancellable_callback)(void* context, weaveffi_error* err, const char* result); @@ -124,12 +137,11 @@ WEAVEFFI_API const char* weaveffi_kitchen_Gadget_version(weaveffi_error* out_err WEAVEFFI_API const char* weaveffi_kitchen_Gadget_describe(const weaveffi_kitchen_Gadget* self, weaveffi_error* out_err); /** Poke the gadget a number of times, failing on a negative count */ WEAVEFFI_API int32_t weaveffi_kitchen_Gadget_poke(const weaveffi_kitchen_Gadget* self, int32_t times, weaveffi_error* out_err); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_kitchen_Gadget* weaveffi_kitchen_Gadget_clone(const weaveffi_kitchen_Gadget* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ WEAVEFFI_API void weaveffi_kitchen_Gadget_destroy(weaveffi_kitchen_Gadget* self); -/** Subscribe to OnReady events */ -WEAVEFFI_API uint64_t weaveffi_kitchen_register_ready_listener(weaveffi_kitchen_OnReady_fn callback, void* context); -/** Subscribe to OnReady events */ -WEAVEFFI_API void weaveffi_kitchen_unregister_ready_listener(uint64_t id); /** Identity for bool */ WEAVEFFI_API bool weaveffi_kitchen_bool_id(bool x, weaveffi_error* out_err); WEAVEFFI_API int32_t weaveffi_kitchen_i32_id(int32_t x, weaveffi_error* out_err); @@ -138,14 +150,16 @@ WEAVEFFI_API int64_t weaveffi_kitchen_i64_id(int64_t x, weaveffi_error* out_err) WEAVEFFI_API double weaveffi_kitchen_f64_id(double x, weaveffi_error* out_err); WEAVEFFI_API const char* weaveffi_kitchen_echo_string(const char* s, weaveffi_error* out_err); WEAVEFFI_API const uint8_t* weaveffi_kitchen_echo_bytes(const uint8_t* b_ptr, size_t b_len, size_t* out_len, weaveffi_error* out_err); -/** Borrowed string parameter */ -WEAVEFFI_API const char* weaveffi_kitchen_echo_borrowed_str(const char* s, weaveffi_error* out_err); -/** Borrowed bytes parameter */ -WEAVEFFI_API const uint8_t* weaveffi_kitchen_echo_borrowed_bytes(const uint8_t* b_ptr, size_t b_len, size_t* out_len, weaveffi_error* out_err); -/** Returns an opaque handle */ -WEAVEFFI_API weaveffi_handle_t weaveffi_kitchen_open_handle(weaveffi_error* out_err); -/** Returns a typed handle */ -WEAVEFFI_API weaveffi_shared_Token* weaveffi_kitchen_open_typed_handle(weaveffi_error* out_err); +/** Register a listener, returning how many are attached */ +WEAVEFFI_API int32_t weaveffi_kitchen_subscribe(void* listener_ctx, const weaveffi_kitchen_ReadyListener_vtable* listener_vtable, weaveffi_error* out_err); +/** Optional object in both directions */ +WEAVEFFI_API weaveffi_kitchen_Gadget* weaveffi_kitchen_maybe_gadget(const weaveffi_kitchen_Gadget* fallback, weaveffi_error* out_err); +/** A list of objects */ +WEAVEFFI_API const uint8_t* weaveffi_kitchen_gadgets(size_t* out_len, weaveffi_error* out_err); +/** An iterator over objects */ +WEAVEFFI_API weaveffi_kitchen_StreamGadgetsIterator* weaveffi_kitchen_stream_gadgets(weaveffi_error* out_err); +WEAVEFFI_API int32_t weaveffi_kitchen_StreamGadgetsIterator_next(weaveffi_kitchen_StreamGadgetsIterator* iter, weaveffi_kitchen_Gadget** out_item, weaveffi_error* out_err); +WEAVEFFI_API void weaveffi_kitchen_StreamGadgetsIterator_destroy(weaveffi_kitchen_StreamGadgetsIterator* iter); /** Optional struct return */ WEAVEFFI_API const uint8_t* weaveffi_kitchen_maybe_item(int64_t id, size_t* out_len, weaveffi_error* out_err); /** List of structs */ @@ -175,7 +189,14 @@ WEAVEFFI_API const char* weaveffi_kitchen_nested_hello(weaveffi_error* out_err); namespace weaveffi { -/** Base exception for every error reported through the C ABI. */ +/** + * Base exception for every error reported through the C ABI. + * + * Positive codes are the module's declared error codes (see the typed + * domain exceptions); negative codes are runtime traps: -1 a generic + * producer error, -2 a producer panic, -3 a marshalling failure, -4 a + * callback-interface implementation that threw. + */ class WeaveFFIError : public std::runtime_error { int32_t code_; @@ -184,6 +205,17 @@ public: int32_t code() const { return code_; } }; +/** + * Throws WeaveFFIError when the loaded producer library was built for a + * different WeaveFFI ABI revision than this header. Call once at startup. + */ +inline void check_abi_version() { + uint32_t actual = weaveffi_abi_version(); + if (actual != WEAVEFFI_ABI_VERSION) { + throw WeaveFFIError(-1, "WeaveFFI ABI revision mismatch: header " + std::to_string(WEAVEFFI_ABI_VERSION) + ", library " + std::to_string(actual)); + } +} + namespace detail { /** Throw the generic WeaveFFIError if `err` carries a nonzero code. */ @@ -268,7 +300,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -386,6 +419,69 @@ enum class Priority : int32_t { High = 2 }; +struct Token; +struct Item; +class Gadget; +class ReadyListener; + +/** A pocket-sized interface exercising the object surface */ +class Gadget { + weaveffi_kitchen_Gadget* handle_; + +public: + /** Adopts one strong reference to a producer object. */ + explicit Gadget(weaveffi_kitchen_Gadget* h) : handle_(h) {} + + /** Releases this wrapper's reference; the object is dropped with its last one. */ + ~Gadget() { + if (handle_) weaveffi_kitchen_Gadget_destroy(handle_); + } + + /** Copies share the object: the copy takes a new strong reference. */ + Gadget(const Gadget& other) : handle_(weaveffi_kitchen_Gadget_clone(other.handle_)) {} + + Gadget& operator=(const Gadget& other) { + if (this != &other) { + weaveffi_kitchen_Gadget* h = weaveffi_kitchen_Gadget_clone(other.handle_); + if (handle_) weaveffi_kitchen_Gadget_destroy(handle_); + handle_ = h; + } + return *this; + } + + Gadget(Gadget&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + Gadget& operator=(Gadget&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_kitchen_Gadget_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** The wrapped pointer, borrowed: this wrapper keeps its reference. */ + const weaveffi_kitchen_Gadget* handle() const { return handle_; } + + /** A new strong reference the caller owns (for example to write into a value buffer). */ + weaveffi_kitchen_Gadget* clone_handle() const { return weaveffi_kitchen_Gadget_clone(handle_); } + + /** Create a gadget with the given id */ + Gadget(int64_t id); + + /** Render the gadget as a human-readable string */ + std::string describe() const; + + /** Poke the gadget a number of times, failing on a negative count */ + int32_t poke(int32_t times) const; + + /** The gadget subsystem version string */ + static std::string version(); + +}; + /** A reusable token referenced across modules */ struct Token { int64_t id; @@ -412,6 +508,8 @@ struct Item { /** Cross-module struct reference */ Token token; Priority priority; + /** An optional object inside a record */ + std::optional gadget; }; namespace detail { @@ -424,10 +522,9 @@ inline void write_Token(BufferWriter& w, const Token& v) { /** Decodes a `Token` from the WeaveFFI value-buffer format. */ inline Token read_Token(BufferReader& r) { - Token out{}; - out.id = r.read_i64(); - out.label = r.read_string(); - return out; + int64_t f_id = r.read_i64(); + std::string f_label = r.read_string(); + return Token{std::move(f_id), std::move(f_label)}; } /** Encodes a `Item` in the WeaveFFI value-buffer format. */ @@ -454,41 +551,52 @@ inline void write_Item(BufferWriter& w, const Item& v) { } detail::write_Token(w, v.token); w.write_i32(static_cast(v.priority)); + w.write_option_flag(v.gadget.has_value()); + if (v.gadget.has_value()) { + w.write_u64(static_cast(reinterpret_cast((*v.gadget).clone_handle()))); + } } /** Decodes a `Item` from the WeaveFFI value-buffer format. */ inline Item read_Item(BufferReader& r) { - Item out{}; - out.id = r.read_i64(); - out.name = r.read_string(); - out.count = r.read_i32(); - out.enabled = r.read_bool(); - out.ratio = r.read_f64(); - out.nick = r.read_u32(); - out.payload = r.read_bytes(); + int64_t f_id = r.read_i64(); + std::string f_name = r.read_string(); + int32_t f_count = r.read_i32(); + bool f_enabled = r.read_bool(); + double f_ratio = r.read_f64(); + uint32_t f_nick = r.read_u32(); + std::vector f_payload = r.read_bytes(); + std::vector f_tags{}; { - size_t v_tags_n = r.read_len(); - out.tags.reserve(v_tags_n); - for (size_t v_tags_i = 0; v_tags_i < v_tags_n; ++v_tags_i) { - std::string v_tags_item = r.read_string(); - out.tags.push_back(std::move(v_tags_item)); + size_t f_tags_n = r.read_len(); + f_tags.reserve(f_tags_n); + for (size_t f_tags_i = 0; f_tags_i < f_tags_n; ++f_tags_i) { + std::string f_tags_item = r.read_string(); + f_tags.push_back(std::move(f_tags_item)); } } + std::unordered_map f_attrs{}; { - size_t v_attrs_n = r.read_len(); - for (size_t v_attrs_i = 0; v_attrs_i < v_attrs_n; ++v_attrs_i) { - std::string v_attrs_key = r.read_string(); - std::string v_attrs_val = r.read_string(); - out.attrs.emplace(std::move(v_attrs_key), std::move(v_attrs_val)); + size_t f_attrs_n = r.read_len(); + for (size_t f_attrs_i = 0; f_attrs_i < f_attrs_n; ++f_attrs_i) { + std::string f_attrs_key = r.read_string(); + std::string f_attrs_val = r.read_string(); + f_attrs.emplace(std::move(f_attrs_key), std::move(f_attrs_val)); } } + std::optional f_parent{}; + if (r.read_option_flag()) { + int64_t f_parent_v = r.read_i64(); + f_parent = std::move(f_parent_v); + } + Token f_token = detail::read_Token(r); + Priority f_priority = static_cast(r.read_i32()); + std::optional f_gadget{}; if (r.read_option_flag()) { - int64_t v_parent_v = r.read_i64(); - out.parent = std::move(v_parent_v); + Gadget f_gadget_v = Gadget(reinterpret_cast(static_cast(r.read_u64()))); + f_gadget = std::move(f_gadget_v); } - out.token = detail::read_Token(r); - out.priority = static_cast(r.read_i32()); - return out; + return Item{std::move(f_id), std::move(f_name), std::move(f_count), std::move(f_enabled), std::move(f_ratio), std::move(f_nick), std::move(f_payload), std::move(f_tags), std::move(f_attrs), std::move(f_parent), std::move(f_token), std::move(f_priority), std::move(f_gadget)}; } } // namespace detail @@ -516,6 +624,8 @@ namespace detail { /** Map a `kitchen` error code and payload to its typed exception (WeaveFFIError for runtime codes, KitchenErrorsError for unknown codes). */ inline std::exception_ptr make_kitchen_error(int32_t code, const std::string& msg, const uint8_t* payload_ptr, size_t payload_len) { if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg)); + (void)payload_ptr; + (void)payload_len; switch (code) { case 1: return std::make_exception_ptr(NotFoundError(msg)); case 2: return std::make_exception_ptr(InvalidInputError(msg)); @@ -534,86 +644,103 @@ inline void check_kitchen(weaveffi_error& err) { } // namespace detail -namespace detail { - -inline std::mutex& wv_listener_mutex() { - static std::mutex m; - return m; -} - -inline std::unordered_map>& wv_listener_registry() { - static std::unordered_map> registry; - return registry; -} - -} // namespace detail - -/** A pocket-sized interface exercising the object surface */ -class Gadget { - void* handle_; - +/** + * Consumer-implemented observer of ready items + * + * Implement this interface and pass a `std::shared_ptr` to any function that accepts one. The producer may call the methods from any thread until it releases its last reference to the implementation. An exception thrown by a method is reported to the producer as a foreign error (code -4) and aborts the call that triggered it. + */ +class ReadyListener { public: - explicit Gadget(void* h) : handle_(h) {} + virtual ~ReadyListener() = default; - ~Gadget() { - if (handle_) weaveffi_kitchen_Gadget_destroy(static_cast(handle_)); - } + /** Fires when an item is ready */ + virtual void on_ready(int32_t code, const std::string& msg) = 0; + /** Receives the item itself and says whether to keep listening */ + virtual bool on_item(const Item& item, Gadget gadget) = 0; +}; - Gadget(const Gadget&) = delete; - Gadget& operator=(const Gadget&) = delete; +namespace detail { - Gadget(Gadget&& other) noexcept : handle_(other.handle_) { - other.handle_ = nullptr; +/** Vtable trampolines adapting a `ReadyListener` implementation to `weaveffi_kitchen_ReadyListener_vtable`. */ +struct ReadyListener_trampolines { + static void on_ready(void* ctx, int32_t code, const char* msg, weaveffi_error* out_err) { + try { + ReadyListener& impl = **static_cast*>(ctx); + std::string msg_val(msg ? msg : ""); + impl.on_ready(code, msg_val); + return; + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "ReadyListener::on_ready threw a non-standard exception"); + } } - Gadget& operator=(Gadget&& other) noexcept { - if (this != &other) { - if (handle_) weaveffi_kitchen_Gadget_destroy(static_cast(handle_)); - handle_ = other.handle_; - other.handle_ = nullptr; + static bool on_item(void* ctx, const uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err) { + Gadget gadget_val(gadget); + try { + ReadyListener& impl = **static_cast*>(ctx); + detail::BufferReader item_r(item_ptr, item_len); + Item item_val = detail::read_Item(item_r); + item_r.expect_end(); + return impl.on_item(item_val, std::move(gadget_val)); + } catch (const std::exception& e) { + weaveffi_error_set(out_err, -4, e.what()); + } catch (...) { + weaveffi_error_set(out_err, -4, "ReadyListener::on_item threw a non-standard exception"); } - return *this; + return bool{}; } - void* handle() const { return handle_; } - - /** Create a gadget with the given id */ - Gadget(int64_t id) : handle_(nullptr) { - weaveffi_error err{}; - auto result = weaveffi_kitchen_Gadget_new(id, &err); - detail::check(err); - handle_ = result; + /** Releases the implementation box once the producer drops its last reference. */ + static void free_ctx(void* ctx) { + delete static_cast*>(ctx); } +}; - /** Render the gadget as a human-readable string */ - std::string describe() const { - weaveffi_error err{}; - auto result = weaveffi_kitchen_Gadget_describe(static_cast(handle_), &err); - detail::check(err); - std::string ret(result); - weaveffi_free_string(result); - return ret; - } +/** The process-wide vtable every `ReadyListener` implementation is passed with. */ +inline const weaveffi_kitchen_ReadyListener_vtable& ReadyListener_vtable() { + static const weaveffi_kitchen_ReadyListener_vtable vtable = { + &ReadyListener_trampolines::on_ready, + &ReadyListener_trampolines::on_item, + &ReadyListener_trampolines::free_ctx, + }; + return vtable; +} - /** Poke the gadget a number of times, failing on a negative count */ - int32_t poke(int32_t times) const { - weaveffi_error err{}; - auto result = weaveffi_kitchen_Gadget_poke(static_cast(handle_), times, &err); - detail::check_kitchen(err); - return result; - } +} // namespace detail - /** The gadget subsystem version string */ - static std::string version() { - weaveffi_error err{}; - auto result = weaveffi_kitchen_Gadget_version(&err); - detail::check(err); - std::string ret(result); - weaveffi_free_string(result); - return ret; - } +inline Gadget::Gadget(int64_t id) : handle_(nullptr) { + weaveffi_error err{}; + auto result = weaveffi_kitchen_Gadget_new(id, &err); + detail::check(err); + handle_ = result; +} -}; +inline std::string Gadget::describe() const { + weaveffi_error err{}; + auto result = weaveffi_kitchen_Gadget_describe(handle_, &err); + detail::check(err); + std::string ret(result); + weaveffi_free_string(result); + return ret; +} + +inline int32_t Gadget::poke(int32_t times) const { + weaveffi_error err{}; + auto result = weaveffi_kitchen_Gadget_poke(handle_, times, &err); + detail::check_kitchen(err); + return result; +} + +inline std::string Gadget::version() { + weaveffi_error err{}; + auto result = weaveffi_kitchen_Gadget_version(&err); + detail::check(err); + std::string ret(result); + weaveffi_free_string(result); + return ret; +} namespace shared { @@ -631,28 +758,6 @@ inline std::string ping() { namespace kitchen { -/** Subscribe to OnReady events */ -/** @return A subscription id for unregister_ready_listener(). */ -inline uint64_t register_ready_listener(std::function callback) { - auto fn = std::make_shared>(std::move(callback)); - uint64_t id = weaveffi_kitchen_register_ready_listener( - [](int32_t code, const char* msg, void* context) { - auto& cb = *static_cast*>(context); - cb(code, std::string(msg ? msg : "")); - }, - fn.get()); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry()[id] = fn; - return id; -} - -/** Unregisters a listener previously registered with register_ready_listener(). */ -inline void unregister_ready_listener(uint64_t id) { - weaveffi_kitchen_unregister_ready_listener(id); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry().erase(id); -} - /** Identity for bool */ inline bool bool_id(bool x) { weaveffi_error err{}; @@ -708,41 +813,139 @@ inline std::vector echo_bytes(const std::vector& b) { return ret; } -/** Borrowed string parameter */ -inline std::string echo_borrowed_str(const std::string& s) { +/** Register a listener, returning how many are attached */ +inline int32_t subscribe(std::shared_ptr listener) { + if (!listener) throw std::invalid_argument("listener: null callback interface"); + auto* listener_ctx = new std::shared_ptr(std::move(listener)); weaveffi_error err{}; - auto result = weaveffi_kitchen_echo_borrowed_str(s.c_str(), &err); + auto result = weaveffi_kitchen_subscribe(static_cast(listener_ctx), &detail::ReadyListener_vtable(), &err); detail::check(err); - std::string ret(result); - weaveffi_free_string(result); - return ret; + return result; } -/** Borrowed bytes parameter */ -inline std::vector echo_borrowed_bytes(const std::vector& b) { - size_t out_len = 0; +/** Optional object in both directions */ +inline std::optional maybe_gadget(const std::optional& fallback) { weaveffi_error err{}; - auto result = weaveffi_kitchen_echo_borrowed_bytes(b.data(), b.size(), &out_len, &err); + auto result = weaveffi_kitchen_maybe_gadget(fallback.has_value() ? fallback->handle() : nullptr, &err); detail::check(err); - std::vector ret(result, result + out_len); - weaveffi_free_bytes(const_cast(result), out_len); - return ret; + if (!result) return std::nullopt; + return Gadget(result); } -/** Returns an opaque handle */ -inline void* open_handle() { +/** A list of objects */ +inline std::vector gadgets() { + size_t out_len = 0; weaveffi_error err{}; - auto result = weaveffi_kitchen_open_handle(&err); + auto result = weaveffi_kitchen_gadgets(&out_len, &err); detail::check(err); - return reinterpret_cast(static_cast(result)); + detail::BufferGuard result_guard{result, out_len}; + detail::BufferReader result_r(result, out_len); + std::vector ret{}; + { + size_t ret_n = result_r.read_len(); + ret.reserve(ret_n); + for (size_t ret_i = 0; ret_i < ret_n; ++ret_i) { + Gadget ret_item = Gadget(reinterpret_cast(static_cast(result_r.read_u64()))); + ret.push_back(std::move(ret_item)); + } + } + result_r.expect_end(); + return ret; } -/** Returns a typed handle */ -inline weaveffi_shared_Token* open_typed_handle() { +/** + * A lazy, move-only range over the `Gadget` elements produced by `stream_gadgets()`. + * + * Each iteration step pulls exactly one element from the producer, so results stream in constant memory. The range owns the producer-side iterator and releases it exactly once: eagerly when the range is exhausted, or from the destructor when iteration is abandoned early. + */ +class StreamGadgetsIterator { + weaveffi_kitchen_StreamGadgetsIterator* handle_; + +public: + /** Adopts ownership of the raw producer iterator. */ + explicit StreamGadgetsIterator(weaveffi_kitchen_StreamGadgetsIterator* h) : handle_(h) {} + + ~StreamGadgetsIterator() { + if (handle_) weaveffi_kitchen_StreamGadgetsIterator_destroy(handle_); + } + + StreamGadgetsIterator(const StreamGadgetsIterator&) = delete; + StreamGadgetsIterator& operator=(const StreamGadgetsIterator&) = delete; + + StreamGadgetsIterator(StreamGadgetsIterator&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + StreamGadgetsIterator& operator=(StreamGadgetsIterator&& other) noexcept { + if (this != &other) { + if (handle_) weaveffi_kitchen_StreamGadgetsIterator_destroy(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + /** Pulls the next element from the producer, or `std::nullopt` once exhausted (which releases the producer iterator eagerly). A producer error throws the generic WeaveFFIError after releasing the iterator. */ + std::optional next() { + if (!handle_) return std::nullopt; + weaveffi_error err{}; + weaveffi_kitchen_Gadget* item{}; + int32_t has_item = weaveffi_kitchen_StreamGadgetsIterator_next(handle_, &item, &err); + if (err.code != 0) { + weaveffi_kitchen_StreamGadgetsIterator_destroy(handle_); + handle_ = nullptr; + detail::check(err); + } + if (has_item == 0) { + weaveffi_kitchen_StreamGadgetsIterator_destroy(handle_); + handle_ = nullptr; + return std::nullopt; + } + return Gadget(item); + } + + /** Sentinel type marking the end of the range. */ + struct sentinel {}; + + /** Single-pass input iterator; each increment pulls one element from the producer. */ + class iterator { + StreamGadgetsIterator* range_; + std::optional current_; + + public: + using iterator_category = std::input_iterator_tag; + using value_type = Gadget; + using difference_type = std::ptrdiff_t; + using pointer = Gadget*; + using reference = Gadget&; + + /** Binds to `range` and pulls the first element. */ + explicit iterator(StreamGadgetsIterator* range) : range_(range), current_(range->next()) {} + reference operator*() { return *current_; } + pointer operator->() { return &*current_; } + iterator& operator++() { current_ = range_->next(); return *this; } + void operator++(int) { current_ = range_->next(); } + bool operator==(sentinel) const { return !current_.has_value(); } + bool operator!=(sentinel) const { return current_.has_value(); } + }; + + /** Begins iteration by pulling the first element. */ + iterator begin() { return iterator(this); } + + /** The past-the-end sentinel. */ + sentinel end() const { return sentinel{}; } +}; + +/** + * An iterator over objects + * + * @return A lazy `StreamGadgetsIterator` range that streams one element per iteration step and releases the producer iterator when exhausted or destroyed. + */ +inline StreamGadgetsIterator stream_gadgets() { weaveffi_error err{}; - auto result = weaveffi_kitchen_open_typed_handle(&err); + weaveffi_kitchen_StreamGadgetsIterator* iter = weaveffi_kitchen_stream_gadgets(&err); detail::check(err); - return result; + return StreamGadgetsIterator(iter); } /** Optional struct return */ @@ -813,7 +1016,7 @@ class StreamItemsIterator { weaveffi_kitchen_StreamItemsIterator* handle_; public: - /** Adopts ownership of the raw producer iterator handle. */ + /** Adopts ownership of the raw producer iterator. */ explicit StreamItemsIterator(weaveffi_kitchen_StreamItemsIterator* h) : handle_(h) {} ~StreamItemsIterator() { @@ -923,35 +1126,43 @@ inline std::future do_async(const std::string& input) { auto future = promise_ptr->get_future(); weaveffi_kitchen_do_async_async(input.c_str(), [](void* context, weaveffi_error* err, const char* result) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - std::string value(result ? result : ""); - weaveffi_free_string(result); - p->set_value(std::move(value)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + std::string value(result ? result : ""); + weaveffi_free_string(result); + p->set_value(std::move(value)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; } /** Cancellable async operation */ -inline std::future do_cancellable(const std::string& input, weaveffi_cancel_token* cancel_token = nullptr) { +inline std::future do_cancellable(const std::string& input, weaveffi_cancel_token* cancel_token) { auto* promise_ptr = new std::promise(); auto future = promise_ptr->get_future(); weaveffi_kitchen_do_cancellable_async(input.c_str(), cancel_token, [](void* context, weaveffi_error* err, const char* result) { auto* p = static_cast*>(context); - if (err && err->code != 0) { - std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); - weaveffi_error_free(err); - } else { - std::string value(result ? result : ""); - weaveffi_free_string(result); - p->set_value(std::move(value)); + try { + if (err && err->code != 0) { + std::string msg(err->message ? err->message : "unknown error"); + p->set_exception(detail::make_error(err->code, msg)); + } else { + std::string value(result ? result : ""); + weaveffi_free_string(result); + p->set_value(std::move(value)); + } + } catch (...) { + p->set_exception(std::current_exception()); } + weaveffi_error_free(err); delete p; }, static_cast(promise_ptr)); return future; diff --git a/crates/weaveffi-cli/tests/snapshots/cpp_nested_modules__weaveffi_hpp.snap b/crates/weaveffi-cli/tests/snapshots/cpp_nested_modules__weaveffi_hpp.snap index 15a42adf..595b1c70 100644 --- a/crates/weaveffi-cli/tests/snapshots/cpp_nested_modules__weaveffi_hpp.snap +++ b/crates/weaveffi-cli/tests/snapshots/cpp_nested_modules__weaveffi_hpp.snap @@ -15,8 +15,8 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include #include +#include #ifndef WEAVEFFI_API # if defined(_WIN32) || defined(__CYGWIN__) @@ -50,15 +50,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -66,6 +67,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -108,7 +113,14 @@ WEAVEFFI_API const uint8_t* weaveffi_graphics_shapes_geometry_compute(const uint namespace weaveffi { -/** Base exception for every error reported through the C ABI. */ +/** + * Base exception for every error reported through the C ABI. + * + * Positive codes are the module's declared error codes (see the typed + * domain exceptions); negative codes are runtime traps: -1 a generic + * producer error, -2 a producer panic, -3 a marshalling failure, -4 a + * callback-interface implementation that threw. + */ class WeaveFFIError : public std::runtime_error { int32_t code_; @@ -117,6 +129,17 @@ public: int32_t code() const { return code_; } }; +/** + * Throws WeaveFFIError when the loaded producer library was built for a + * different WeaveFFI ABI revision than this header. Call once at startup. + */ +inline void check_abi_version() { + uint32_t actual = weaveffi_abi_version(); + if (actual != WEAVEFFI_ABI_VERSION) { + throw WeaveFFIError(-1, "WeaveFFI ABI revision mismatch: header " + std::to_string(WEAVEFFI_ABI_VERSION) + ", library " + std::to_string(actual)); + } +} + namespace detail { /** Throw the generic WeaveFFIError if `err` carries a nonzero code. */ @@ -201,7 +224,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -315,6 +339,10 @@ enum class Unit : int32_t { points = 1 }; +struct Canvas; +struct Shape; +struct Detail; + /** A struct two levels deep. */ struct Detail { double area; @@ -345,9 +373,8 @@ inline void write_Detail(BufferWriter& w, const Detail& v) { /** Decodes a `Detail` from the WeaveFFI value-buffer format. */ inline Detail read_Detail(BufferReader& r) { - Detail out{}; - out.area = r.read_f64(); - return out; + double f_area = r.read_f64(); + return Detail{std::move(f_area)}; } /** Encodes a `Shape` in the WeaveFFI value-buffer format. */ @@ -359,11 +386,10 @@ inline void write_Shape(BufferWriter& w, const Shape& v) { /** Decodes a `Shape` from the WeaveFFI value-buffer format. */ inline Shape read_Shape(BufferReader& r) { - Shape out{}; - out.sides = r.read_i32(); - out.unit = static_cast(r.read_i32()); - out.detail = detail::read_Detail(r); - return out; + int32_t f_sides = r.read_i32(); + Unit f_unit = static_cast(r.read_i32()); + Detail f_detail = detail::read_Detail(r); + return Shape{std::move(f_sides), std::move(f_unit), std::move(f_detail)}; } /** Encodes a `Canvas` in the WeaveFFI value-buffer format. */ @@ -374,10 +400,9 @@ inline void write_Canvas(BufferWriter& w, const Canvas& v) { /** Decodes a `Canvas` from the WeaveFFI value-buffer format. */ inline Canvas read_Canvas(BufferReader& r) { - Canvas out{}; - out.width = r.read_i32(); - out.root = detail::read_Shape(r); - return out; + int32_t f_width = r.read_i32(); + Shape f_root = detail::read_Shape(r); + return Canvas{std::move(f_width), std::move(f_root)}; } } // namespace detail @@ -399,6 +424,8 @@ namespace detail { /** Map a `graphics` error code and payload to its typed exception (WeaveFFIError for runtime codes, GraphicsError for unknown codes). */ inline std::exception_ptr make_graphics_error(int32_t code, const std::string& msg, const uint8_t* payload_ptr, size_t payload_len) { if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg)); + (void)payload_ptr; + (void)payload_len; switch (code) { case 1: return std::make_exception_ptr(OutOfBoundsError(msg)); default: return std::make_exception_ptr(GraphicsError(code, msg)); diff --git a/crates/weaveffi-cli/tests/snapshots/cpp_shapes__weaveffi_hpp.snap b/crates/weaveffi-cli/tests/snapshots/cpp_shapes__weaveffi_hpp.snap index fe78da8b..b2eda08a 100644 --- a/crates/weaveffi-cli/tests/snapshots/cpp_shapes__weaveffi_hpp.snap +++ b/crates/weaveffi-cli/tests/snapshots/cpp_shapes__weaveffi_hpp.snap @@ -15,9 +15,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include +#include #include #include -#include #ifndef WEAVEFFI_API # if defined(_WIN32) || defined(__CYGWIN__) @@ -51,15 +51,16 @@ extern "C" { producer exports weaveffi_abi_version() so a consumer can refuse to load a library built for a different revision instead of misreading its error struct or value buffers. */ -#define WEAVEFFI_ABI_VERSION 1u +#define WEAVEFFI_ABI_VERSION 2u WEAVEFFI_API uint32_t weaveffi_abi_version(void); -typedef uint64_t weaveffi_handle_t; - /* Error slot written by every fallible call. `payload_ptr`/`payload_len` hold the matched error code's fields serialized in the WeaveFFI value buffer format (null when the code declares no fields); both the message - and the payload are released by weaveffi_error_clear. */ + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -67,6 +68,10 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); /* Async completion callbacks receive a heap-boxed error the consumer @@ -118,7 +123,14 @@ WEAVEFFI_API const uint8_t* weaveffi_shapes_make_numerics(int8_t a, int16_t b, i namespace weaveffi { -/** Base exception for every error reported through the C ABI. */ +/** + * Base exception for every error reported through the C ABI. + * + * Positive codes are the module's declared error codes (see the typed + * domain exceptions); negative codes are runtime traps: -1 a generic + * producer error, -2 a producer panic, -3 a marshalling failure, -4 a + * callback-interface implementation that threw. + */ class WeaveFFIError : public std::runtime_error { int32_t code_; @@ -127,6 +139,17 @@ public: int32_t code() const { return code_; } }; +/** + * Throws WeaveFFIError when the loaded producer library was built for a + * different WeaveFFI ABI revision than this header. Call once at startup. + */ +inline void check_abi_version() { + uint32_t actual = weaveffi_abi_version(); + if (actual != WEAVEFFI_ABI_VERSION) { + throw WeaveFFIError(-1, "WeaveFFI ABI revision mismatch: header " + std::to_string(WEAVEFFI_ABI_VERSION) + ", library " + std::to_string(actual)); + } +} + namespace detail { /** Throw the generic WeaveFFIError if `err` carries a nonzero code. */ @@ -211,7 +234,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -326,6 +350,9 @@ enum class Channel : int32_t { Blue = 2 }; +struct Numerics; +struct Shape; + /** One field per supported numeric primitive */ struct Numerics { int8_t a; @@ -414,18 +441,17 @@ inline void write_Numerics(BufferWriter& w, const Numerics& v) { /** Decodes a `Numerics` from the WeaveFFI value-buffer format. */ inline Numerics read_Numerics(BufferReader& r) { - Numerics out{}; - out.a = r.read_i8(); - out.b = r.read_i16(); - out.c = r.read_i32(); - out.d = r.read_i64(); - out.e = r.read_u8(); - out.f = r.read_u16(); - out.g = r.read_u32(); - out.h = r.read_u64(); - out.i = r.read_f32(); - out.j = r.read_f64(); - return out; + int8_t f_a = r.read_i8(); + int16_t f_b = r.read_i16(); + int32_t f_c = r.read_i32(); + int64_t f_d = r.read_i64(); + uint8_t f_e = r.read_u8(); + uint16_t f_f = r.read_u16(); + uint32_t f_g = r.read_u32(); + uint64_t f_h = r.read_u64(); + float f_i = r.read_f32(); + double f_j = r.read_f64(); + return Numerics{std::move(f_a), std::move(f_b), std::move(f_c), std::move(f_d), std::move(f_e), std::move(f_f), std::move(f_g), std::move(f_h), std::move(f_i), std::move(f_j)}; } /** Encodes a `Shape` in the WeaveFFI value-buffer format. */ @@ -474,28 +500,24 @@ inline Shape read_Shape(BufferReader& r) { return Shape{Shape::Empty{}}; } case 1: { - Shape::Circle p{}; - p.radius = r.read_f64(); - return Shape{std::move(p)}; + double f_radius = r.read_f64(); + return Shape{Shape::Circle{std::move(f_radius)}}; } case 2: { - Shape::Rectangle p{}; - p.width = r.read_f32(); - p.height = r.read_f32(); - return Shape{std::move(p)}; + float f_width = r.read_f32(); + float f_height = r.read_f32(); + return Shape{Shape::Rectangle{std::move(f_width), std::move(f_height)}}; } case 3: { - Shape::Bounded p{}; - p.min = r.read_i16(); - p.max = r.read_i16(); - p.weight = r.read_u64(); - return Shape{std::move(p)}; + int16_t f_min = r.read_i16(); + int16_t f_max = r.read_i16(); + uint64_t f_weight = r.read_u64(); + return Shape{Shape::Bounded{std::move(f_min), std::move(f_max), std::move(f_weight)}}; } case 4: { - Shape::Labeled p{}; - p.label = r.read_string(); - p.count = r.read_u8(); - return Shape{std::move(p)}; + std::string f_label = r.read_string(); + uint8_t f_count = r.read_u8(); + return Shape{Shape::Labeled{std::move(f_label), std::move(f_count)}}; } default: break; diff --git a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__README_md.snap b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__README_md.snap index daa40993..732894ed 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__README_md.snap @@ -24,9 +24,23 @@ import 'package:weaveffi/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. diff --git a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__lib_weaveffi_dart.snap b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__lib_weaveffi_dart.snap index bd975f26..5a63c7c3 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__lib_weaveffi_dart.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__lib_weaveffi_dart.snap @@ -27,7 +27,7 @@ DynamicLibrary _openLibrary() { } // The ABI revision these bindings were generated against. -const int _abiVersion = 1; +const int _abiVersion = 2; DynamicLibrary _checkAbiVersion(DynamicLibrary lib) { final int Function() abiVersion; @@ -60,6 +60,11 @@ final class _WeaveFFIError extends Struct { external int payloadLen; } +typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer); +typedef _DartWeaveffiErrorSet = void Function(Pointer<_WeaveFFIError>, int, Pointer); +final _weaveffiErrorSet = _lib.lookupFunction< + _NativeWeaveffiErrorSet, _DartWeaveffiErrorSet>('weaveffi_error_set'); + typedef _NativeWeaveffiErrorClear = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiErrorClear = void Function(Pointer<_WeaveFFIError>); final _weaveffiErrorClear = _lib.lookupFunction< @@ -80,8 +85,19 @@ typedef _DartWeaveffiFreeBytes = void Function(Pointer, int); final _weaveffiFreeBytes = _lib.lookupFunction< _NativeWeaveffiFreeBytes, _DartWeaveffiFreeBytes>('weaveffi_free_bytes'); -/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. +/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling +/// error ([marshalCode]), a callback-interface implementation that threw +/// ([foreignCode], carrying the Dart exception's text), or an unknown code. class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; final int code; final String message; WeaveFFIException(this.code, this.message); @@ -341,9 +357,57 @@ final class _BufferReader { } } -// Live listener trampolines by subscription id. Holding the -// NativeCallable here keeps its native thunk alive until unregistered. -final Map _listenerCallables = {}; +// ── WeaveFFI callback-interface runtime ── +// A Dart implementation passed to the producer is stored here under an +// integer key; the key (widened to a pointer) is what crosses as `ctx`, so the +// producer never holds a Dart object and the GC never sees a raw pointer. The +// entry lives until the producer calls the vtable's `free(ctx)`. +final Map _callbackTable = {}; +int _nextCallbackKey = 1; + +Pointer _registerCallback(Object impl) { + final key = _nextCallbackKey++; + _callbackTable[key] = impl; + return Pointer.fromAddress(key); +} + +Object _callbackFor(Pointer ctx) { + final impl = _callbackTable[ctx.address]; + if (impl == null) { + throw StateError('callback interface context ${ctx.address} is not registered'); + } + return impl; +} + +// Vtable trampolines are process-wide: their NativeCallables are never closed, +// so they are anchored here to keep the native thunks alive. `keepIsolateAlive` +// is cleared on each so an idle isolate can still exit. +final List> _callbackCallables = []; + +Pointer> _pinCallable( + NativeCallable callable) { + callable.keepIsolateAlive = false; + _callbackCallables.add(callable); + return callable.nativeFunction; +} + +// Reports a Dart exception thrown by a callback-interface implementation +// through `weaveffi_error_set` with the reserved foreign error code (-4). +// The producer copies the message, so the temporary is freed right away. This +// must never throw: it runs in the catch path of a trampoline whose C frame +// an exception must not unwind through. +void _foreignError(Pointer<_WeaveFFIError> outErr, Object error) { + if (outErr == nullptr) return; + Pointer message = nullptr; + try { + message = error.toString().toNativeUtf8(); + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, message); + } catch (_) { + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, nullptr); + } finally { + if (message != nullptr) calloc.free(message); + } +} /// Typed error domain `DocsErrors` declared by module `docs`. class DocsErrorsException extends WeaveFFIException { @@ -435,10 +499,78 @@ Document _unpackDocument(_BufferReader r) { ); } +/// Consumer-implemented observer of document-saved notifications. +/// +/// Implementations must be safe to call from any thread. +/// +/// A callback interface: implement this class and pass an instance to any +/// function taking a [SavedListener]. The producer keeps the instance alive +/// (through a private handle table) until it releases the callback, so no +/// other reference needs to be held. +/// +/// Object arguments are owned by the implementation: call `dispose()` on +/// them (or let the finalizer run) when done. A thrown exception is reported +/// to the producer, which aborts the call it was making; the original Dart +/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode] +/// carrying the exception's text. +/// +/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so +/// the producer may only invoke them on the thread of the isolate that +/// passed the callback. That holds when the producer calls them +/// synchronously during a call from Dart. A producer that invokes a method +/// from another thread (an async task, a background worker) is unsupported: +/// a `NativeCallable.listener` cannot return a value or read borrowed +/// arguments after the native frame returns. +abstract class SavedListener { + /// Fires after a document has been persisted successfully + void onSaved(int docId, int revision); +} + +typedef _NativeSavedListenerVtOnSaved = Void Function(Pointer, Int64, Int32, Pointer<_WeaveFFIError>); +typedef _NativeSavedListenerVtFree = Void Function(Pointer); + +// The C `weaveffi_docs_SavedListener_vtable` layout. +final class _SavedListenerVtableStruct extends Struct { + external Pointer> onSaved; + external Pointer> free; +} + +// Vtable entry `weaveffi_docs_SavedListener_vtable.on_saved`. +void _savedListenerVtOnSaved(Pointer ctx, int docId, int revision, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as SavedListener; + impl.onSaved(docId, revision); + } catch (e) { + _foreignError(outErr, e); + } +} + +void _savedListenerVtFree(Pointer ctx) { + _callbackTable.remove(ctx.address); +} + +// The process-wide static `weaveffi_docs_SavedListener_vtable` every SavedListener passed to the producer +// shares; the per-instance state travels in `ctx`. +final Pointer<_SavedListenerVtableStruct> _SavedListenerVtable = () { + final vt = calloc<_SavedListenerVtableStruct>(); + vt.ref.onSaved = _pinCallable(NativeCallable<_NativeSavedListenerVtOnSaved>.isolateLocal( + _savedListenerVtOnSaved)); + vt.ref.free = _pinCallable(NativeCallable<_NativeSavedListenerVtFree>.listener( + _savedListenerVtFree)); + return vt; +}(); + +typedef _NativeWeaveffiDocsNotebookClone = Pointer Function(Pointer); +typedef _DartWeaveffiDocsNotebookClone = Pointer Function(Pointer); +final _weaveffiDocsNotebookClone = _lib.lookupFunction< + _NativeWeaveffiDocsNotebookClone, _DartWeaveffiDocsNotebookClone>('weaveffi_docs_Notebook_clone'); + typedef _NativeWeaveffiDocsNotebookDestroy = Void Function(Pointer); typedef _DartWeaveffiDocsNotebookDestroy = void Function(Pointer); final _weaveffiDocsNotebookDestroy = _lib.lookupFunction< _NativeWeaveffiDocsNotebookDestroy, _DartWeaveffiDocsNotebookDestroy>('weaveffi_docs_Notebook_destroy'); +final _weaveffiDocsNotebookDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_docs_Notebook_destroy')); typedef _NativeWeaveffiDocsNotebookNew = Pointer Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiDocsNotebookNew = Pointer Function(Pointer<_WeaveFFIError>); @@ -468,16 +600,45 @@ final _weaveffiDocsNotebookMaxDocuments = _lib.lookupFunction< /// An ordered collection of documents. /// /// Notebooks own their documents and release them when destroyed. -class Notebook { - final Pointer _handle; - Notebook._(this._handle); +/// +/// A reference-counted native object. Each instance holds one strong +/// reference, released by [dispose] or, if the instance is collected +/// without being disposed, by a GC finalizer. Instances stay valid for as +/// long as the Dart object is reachable; the producer keeps its own +/// references to any object it retains. +class Notebook implements Finalizable { + final Pointer _ptr; + bool _disposed = false; + + /// Adopts one strong reference to the native object. + Notebook._(this._ptr) { + _weaveffiDocsNotebookDestroyFinalizer.attach(this, _ptr, detach: this); + } + + /// The borrowed native pointer for the duration of a call. + Pointer get _handle { + if (_disposed) throw StateError('Notebook used after dispose()'); + return _ptr; + } + + /// Mints a second strong reference (the interface's `_clone` symbol) for + /// an object token written into a value buffer. + Pointer _cloneRef() => _weaveffiDocsNotebookClone(_handle); - /// Releases the native object reference. + /// Releases this instance's native reference. Safe to call more than + /// once; the native object is dropped when its last reference (this + /// one, any other wrapper's, or the producer's) is released. void dispose() { - _weaveffiDocsNotebookDestroy(_handle); + if (_disposed) return; + _disposed = true; + _weaveffiDocsNotebookDestroyFinalizer.detach(this); + _weaveffiDocsNotebookDestroy(_ptr); } /// Create an empty notebook + /// + /// The returned object is owned by the caller: call its `dispose()` when + /// you are done with it. factory Notebook() { final err = calloc<_WeaveFFIError>(); try { @@ -553,35 +714,6 @@ class Notebook { } } -typedef _NativeCb_weaveffi_docs_OnSaved_fn = Void Function(Int64, Int32, Pointer); - -typedef _NativeWeaveffiDocsRegisterSavedListener = Uint64 Function(Pointer>, Pointer); -typedef _DartWeaveffiDocsRegisterSavedListener = int Function(Pointer>, Pointer); -final _weaveffiDocsRegisterSavedListener = _lib.lookupFunction< - _NativeWeaveffiDocsRegisterSavedListener, _DartWeaveffiDocsRegisterSavedListener>('weaveffi_docs_register_saved_listener'); - -typedef _NativeWeaveffiDocsUnregisterSavedListener = Void Function(Uint64); -typedef _DartWeaveffiDocsUnregisterSavedListener = void Function(int); -final _weaveffiDocsUnregisterSavedListener = _lib.lookupFunction< - _NativeWeaveffiDocsUnregisterSavedListener, _DartWeaveffiDocsUnregisterSavedListener>('weaveffi_docs_unregister_saved_listener'); - -/// Subscribe to document-saved notifications -/// Registers a OnSaved listener. Returns a subscription id for unregisterSavedListener(). -int registerSavedListener(void Function(int docId, int revision) callback) { - final callable = NativeCallable<_NativeCb_weaveffi_docs_OnSaved_fn>.isolateLocal((int docId, int revision, Pointer context) { - callback(docId, revision); - }); - final id = _weaveffiDocsRegisterSavedListener(callable.nativeFunction, nullptr); - _listenerCallables[id] = callable; - return id; -} - -/// Unregisters a listener previously registered with registerSavedListener(). -void unregisterSavedListener(int id) { - _weaveffiDocsUnregisterSavedListener(id); - _listenerCallables.remove(id)?.close(); -} - typedef _NativeWeaveffiDocsCreateDocument = Pointer Function(Pointer, Pointer, Size, Pointer, Pointer<_WeaveFFIError>); typedef _DartWeaveffiDocsCreateDocument = Pointer Function(Pointer, Pointer, int, Pointer, Pointer<_WeaveFFIError>); final _weaveffiDocsCreateDocument = _lib.lookupFunction< @@ -682,4 +814,20 @@ Future publishAsync(int id) { return completer.future; } +typedef _NativeWeaveffiDocsWatch = Void Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiDocsWatch = void Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +final _weaveffiDocsWatch = _lib.lookupFunction< + _NativeWeaveffiDocsWatch, _DartWeaveffiDocsWatch>('weaveffi_docs_watch'); + +/// Register a listener for saved notifications +void watch(SavedListener listener) { + final err = calloc<_WeaveFFIError>(); + try { + _weaveffiDocsWatch(_registerCallback(listener), _SavedListenerVtable.cast(), err); + _checkError(err); + } finally { + calloc.free(err); + } +} + // END weaveffi.dart diff --git a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__pubspec_yaml.snap b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__pubspec_yaml.snap index 6c4a1026..020ab447 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__pubspec_yaml.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_docs_everywhere__pubspec_yaml.snap @@ -9,7 +9,7 @@ name: weaveffi version: 0.1.0 description: weaveffi bindings generated by WeaveFFI environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: ffi: ^2.0.0 diff --git a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__README_md.snap b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__README_md.snap index daa40993..732894ed 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__README_md.snap @@ -24,9 +24,23 @@ import 'package:weaveffi/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. diff --git a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__lib_weaveffi_dart.snap b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__lib_weaveffi_dart.snap index abf658e6..7992dbaf 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__lib_weaveffi_dart.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__lib_weaveffi_dart.snap @@ -27,7 +27,7 @@ DynamicLibrary _openLibrary() { } // The ABI revision these bindings were generated against. -const int _abiVersion = 1; +const int _abiVersion = 2; DynamicLibrary _checkAbiVersion(DynamicLibrary lib) { final int Function() abiVersion; @@ -60,6 +60,11 @@ final class _WeaveFFIError extends Struct { external int payloadLen; } +typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer); +typedef _DartWeaveffiErrorSet = void Function(Pointer<_WeaveFFIError>, int, Pointer); +final _weaveffiErrorSet = _lib.lookupFunction< + _NativeWeaveffiErrorSet, _DartWeaveffiErrorSet>('weaveffi_error_set'); + typedef _NativeWeaveffiErrorClear = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiErrorClear = void Function(Pointer<_WeaveFFIError>); final _weaveffiErrorClear = _lib.lookupFunction< @@ -80,8 +85,19 @@ typedef _DartWeaveffiFreeBytes = void Function(Pointer, int); final _weaveffiFreeBytes = _lib.lookupFunction< _NativeWeaveffiFreeBytes, _DartWeaveffiFreeBytes>('weaveffi_free_bytes'); -/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. +/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling +/// error ([marshalCode]), a callback-interface implementation that threw +/// ([foreignCode], carrying the Dart exception's text), or an unknown code. class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; final int code; final String message; WeaveFFIException(this.code, this.message); @@ -341,9 +357,57 @@ final class _BufferReader { } } -// Live listener trampolines by subscription id. Holding the -// NativeCallable here keeps its native thunk alive until unregistered. -final Map _listenerCallables = {}; +// ── WeaveFFI callback-interface runtime ── +// A Dart implementation passed to the producer is stored here under an +// integer key; the key (widened to a pointer) is what crosses as `ctx`, so the +// producer never holds a Dart object and the GC never sees a raw pointer. The +// entry lives until the producer calls the vtable's `free(ctx)`. +final Map _callbackTable = {}; +int _nextCallbackKey = 1; + +Pointer _registerCallback(Object impl) { + final key = _nextCallbackKey++; + _callbackTable[key] = impl; + return Pointer.fromAddress(key); +} + +Object _callbackFor(Pointer ctx) { + final impl = _callbackTable[ctx.address]; + if (impl == null) { + throw StateError('callback interface context ${ctx.address} is not registered'); + } + return impl; +} + +// Vtable trampolines are process-wide: their NativeCallables are never closed, +// so they are anchored here to keep the native thunks alive. `keepIsolateAlive` +// is cleared on each so an idle isolate can still exit. +final List> _callbackCallables = []; + +Pointer> _pinCallable( + NativeCallable callable) { + callable.keepIsolateAlive = false; + _callbackCallables.add(callable); + return callable.nativeFunction; +} + +// Reports a Dart exception thrown by a callback-interface implementation +// through `weaveffi_error_set` with the reserved foreign error code (-4). +// The producer copies the message, so the temporary is freed right away. This +// must never throw: it runs in the catch path of a trampoline whose C frame +// an exception must not unwind through. +void _foreignError(Pointer<_WeaveFFIError> outErr, Object error) { + if (outErr == nullptr) return; + Pointer message = nullptr; + try { + message = error.toString().toNativeUtf8(); + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, message); + } catch (_) { + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, nullptr); + } finally { + if (message != nullptr) calloc.free(message); + } +} // Anchors one live native iteration for its GC-finalizer backstop. // A suspended `sync*` frame keeps the anchor reachable; abandoning the @@ -749,10 +813,270 @@ Retired _unpackRetired(_BufferReader r) { ); } +/// Record carrying objects in every buffered position +class WidgetHolder { + final Widget primary; + final Widget? fallback; + final List extras; + final Map byName; + + WidgetHolder({required this.primary, this.fallback, required this.extras, required this.byName}); +} + +void _packWidgetHolder(_BufferWriter w, WidgetHolder v) { + w.writeUint64(v.primary._cloneRef().address); + final t0 = v.fallback; + if (t0 == null) { + w.writeOptionFlag(false); + } else { + w.writeOptionFlag(true); + w.writeUint64(t0._cloneRef().address); + } + final t1 = v.extras; + w.writeLength(t1.length); + for (final t2 in t1) { + w.writeUint64(t2._cloneRef().address); + } + final t3 = v.byName; + w.writeLength(t3.length); + for (final t4 in t3.entries) { + w.writeString(t4.key); + w.writeUint64(t4.value._cloneRef().address); + } +} + +WidgetHolder _unpackWidgetHolder(_BufferReader r) { + return WidgetHolder( + primary: Widget._(Pointer.fromAddress(r.readUint64())), + fallback: (r.readOptionFlag() ? Widget._(Pointer.fromAddress(r.readUint64())) : null), + extras: List.generate(r.readLength(), (_) => Widget._(Pointer.fromAddress(r.readUint64()))), + byName: { for (var i = r.readLength(); i > 0; i--) r.readString(): Widget._(Pointer.fromAddress(r.readUint64())) }, + ); +} + +/// Callback interface carrying composite payloads and objects +/// +/// A callback interface: implement this class and pass an instance to any +/// function taking a [BatchListener]. The producer keeps the instance alive +/// (through a private handle table) until it releases the callback, so no +/// other reference needs to be held. +/// +/// Object arguments are owned by the implementation: call `dispose()` on +/// them (or let the finalizer run) when done. A thrown exception is reported +/// to the producer, which aborts the call it was making; the original Dart +/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode] +/// carrying the exception's text. +/// +/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so +/// the producer may only invoke them on the thread of the isolate that +/// passed the callback. That holds when the producer calls them +/// synchronously during a call from Dart. A producer that invokes a method +/// from another thread (an async task, a background worker) is unsupported: +/// a `NativeCallable.listener` cannot return a value or read borrowed +/// arguments after the native frame returns. +abstract class BatchListener { + /// Composite payloads cross as value buffers + void onBatch(List items, List raw, Outer? first, Mode mode); + /// Objects cross as strong references the consumer must release + void onWidget(Widget widget, Widget? fallback, List children); + void onTick(); + /// Scalar return travels back through the vtable + bool shouldContinue(int class_); + /// Enum return travels back as its integer discriminant + Mode pickMode(); +} + +typedef _NativeBatchListenerVtOnBatch = Void Function(Pointer, Pointer, Size, Pointer, Size, Pointer, Size, Int32, Pointer<_WeaveFFIError>); +typedef _NativeBatchListenerVtOnWidget = Void Function(Pointer, Pointer, Pointer, Pointer, Size, Pointer<_WeaveFFIError>); +typedef _NativeBatchListenerVtOnTick = Void Function(Pointer, Pointer<_WeaveFFIError>); +typedef _NativeBatchListenerVtShouldContinue = Bool Function(Pointer, Int32, Pointer<_WeaveFFIError>); +typedef _NativeBatchListenerVtPickMode = Int32 Function(Pointer, Pointer<_WeaveFFIError>); +typedef _NativeBatchListenerVtFree = Void Function(Pointer); + +// The C `weaveffi_edge_cases_BatchListener_vtable` layout. +final class _BatchListenerVtableStruct extends Struct { + external Pointer> onBatch; + external Pointer> onWidget; + external Pointer> onTick; + external Pointer> shouldContinue; + external Pointer> pickMode; + external Pointer> free; +} + +// Vtable entry `weaveffi_edge_cases_BatchListener_vtable.on_batch`. +void _batchListenerVtOnBatch(Pointer ctx, Pointer itemsPtr, int itemsLen, Pointer rawPtr, int rawLen, Pointer firstPtr, int firstLen, int mode, Pointer<_WeaveFFIError> outErr) { + try { + final itemsData = _copyNativeBytes(itemsPtr, itemsLen); + final itemsReader = _BufferReader(itemsData); + final itemsValue = List.generate(itemsReader.readLength(), (_) => _unpackOuter(itemsReader)); + itemsReader.expectEnd(); + final firstData = _copyNativeBytes(firstPtr, firstLen); + final firstReader = _BufferReader(firstData); + final firstValue = (firstReader.readOptionFlag() ? _unpackOuter(firstReader) : null); + firstReader.expectEnd(); + final impl = _callbackFor(ctx) as BatchListener; + impl.onBatch(itemsValue, rawPtr == nullptr ? [] : rawPtr.asTypedList(rawLen).toList(), firstValue, Mode.fromValue(mode)); + } catch (e) { + _foreignError(outErr, e); + } +} + +// Vtable entry `weaveffi_edge_cases_BatchListener_vtable.on_widget`. +void _batchListenerVtOnWidget(Pointer ctx, Pointer widget, Pointer fallback, Pointer childrenPtr, int childrenLen, Pointer<_WeaveFFIError> outErr) { + try { + final widgetValue = Widget._(widget); + final fallbackValue = fallback == nullptr ? null : Widget._(fallback); + final childrenData = _copyNativeBytes(childrenPtr, childrenLen); + final childrenReader = _BufferReader(childrenData); + final childrenValue = List.generate(childrenReader.readLength(), (_) => Widget._(Pointer.fromAddress(childrenReader.readUint64()))); + childrenReader.expectEnd(); + final impl = _callbackFor(ctx) as BatchListener; + impl.onWidget(widgetValue, fallbackValue, childrenValue); + } catch (e) { + _foreignError(outErr, e); + } +} + +// Vtable entry `weaveffi_edge_cases_BatchListener_vtable.on_tick`. +void _batchListenerVtOnTick(Pointer ctx, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as BatchListener; + impl.onTick(); + } catch (e) { + _foreignError(outErr, e); + } +} + +// Vtable entry `weaveffi_edge_cases_BatchListener_vtable.should_continue`. +bool _batchListenerVtShouldContinue(Pointer ctx, int class_, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as BatchListener; + return impl.shouldContinue(class_); + } catch (e) { + _foreignError(outErr, e); + return false; + } +} + +// Vtable entry `weaveffi_edge_cases_BatchListener_vtable.pick_mode`. +int _batchListenerVtPickMode(Pointer ctx, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as BatchListener; + return impl.pickMode().value; + } catch (e) { + _foreignError(outErr, e); + return 0; + } +} + +void _batchListenerVtFree(Pointer ctx) { + _callbackTable.remove(ctx.address); +} + +// The process-wide static `weaveffi_edge_cases_BatchListener_vtable` every BatchListener passed to the producer +// shares; the per-instance state travels in `ctx`. +final Pointer<_BatchListenerVtableStruct> _BatchListenerVtable = () { + final vt = calloc<_BatchListenerVtableStruct>(); + vt.ref.onBatch = _pinCallable(NativeCallable<_NativeBatchListenerVtOnBatch>.isolateLocal( + _batchListenerVtOnBatch)); + vt.ref.onWidget = _pinCallable(NativeCallable<_NativeBatchListenerVtOnWidget>.isolateLocal( + _batchListenerVtOnWidget)); + vt.ref.onTick = _pinCallable(NativeCallable<_NativeBatchListenerVtOnTick>.isolateLocal( + _batchListenerVtOnTick)); + vt.ref.shouldContinue = _pinCallable(NativeCallable<_NativeBatchListenerVtShouldContinue>.isolateLocal( + _batchListenerVtShouldContinue, exceptionalReturn: false)); + vt.ref.pickMode = _pinCallable(NativeCallable<_NativeBatchListenerVtPickMode>.isolateLocal( + _batchListenerVtPickMode, exceptionalReturn: 0)); + vt.ref.free = _pinCallable(NativeCallable<_NativeBatchListenerVtFree>.listener( + _batchListenerVtFree)); + return vt; +}(); + +/// Callback interface whose name and methods are keywords +/// +/// A callback interface: implement this class and pass an instance to any +/// function taking a [Keyword]. The producer keeps the instance alive +/// (through a private handle table) until it releases the callback, so no +/// other reference needs to be held. +/// +/// Object arguments are owned by the implementation: call `dispose()` on +/// them (or let the finalizer run) when done. A thrown exception is reported +/// to the producer, which aborts the call it was making; the original Dart +/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode] +/// carrying the exception's text. +/// +/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so +/// the producer may only invoke them on the thread of the isolate that +/// passed the callback. That holds when the producer calls them +/// synchronously during a call from Dart. A producer that invokes a method +/// from another thread (an async task, a background worker) is unsupported: +/// a `NativeCallable.listener` cannot return a value or read borrowed +/// arguments after the native frame returns. +abstract class Keyword { + void delete(int self); + int import(); +} + +typedef _NativeKeywordVtDelete = Void Function(Pointer, Int32, Pointer<_WeaveFFIError>); +typedef _NativeKeywordVtImport = Int32 Function(Pointer, Pointer<_WeaveFFIError>); +typedef _NativeKeywordVtFree = Void Function(Pointer); + +// The C `weaveffi_edge_cases_Keyword_vtable` layout. +final class _KeywordVtableStruct extends Struct { + external Pointer> delete; + external Pointer> import; + external Pointer> free; +} + +// Vtable entry `weaveffi_edge_cases_Keyword_vtable.delete`. +void _keywordVtDelete(Pointer ctx, int self, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as Keyword; + impl.delete(self); + } catch (e) { + _foreignError(outErr, e); + } +} + +// Vtable entry `weaveffi_edge_cases_Keyword_vtable.import`. +int _keywordVtImport(Pointer ctx, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as Keyword; + return impl.import(); + } catch (e) { + _foreignError(outErr, e); + return 0; + } +} + +void _keywordVtFree(Pointer ctx) { + _callbackTable.remove(ctx.address); +} + +// The process-wide static `weaveffi_edge_cases_Keyword_vtable` every Keyword passed to the producer +// shares; the per-instance state travels in `ctx`. +final Pointer<_KeywordVtableStruct> _KeywordVtable = () { + final vt = calloc<_KeywordVtableStruct>(); + vt.ref.delete = _pinCallable(NativeCallable<_NativeKeywordVtDelete>.isolateLocal( + _keywordVtDelete)); + vt.ref.import = _pinCallable(NativeCallable<_NativeKeywordVtImport>.isolateLocal( + _keywordVtImport, exceptionalReturn: 0)); + vt.ref.free = _pinCallable(NativeCallable<_NativeKeywordVtFree>.listener( + _keywordVtFree)); + return vt; +}(); + +typedef _NativeWeaveffiEdgeCasesWidgetClone = Pointer Function(Pointer); +typedef _DartWeaveffiEdgeCasesWidgetClone = Pointer Function(Pointer); +final _weaveffiEdgeCasesWidgetClone = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesWidgetClone, _DartWeaveffiEdgeCasesWidgetClone>('weaveffi_edge_cases_Widget_clone'); + typedef _NativeWeaveffiEdgeCasesWidgetDestroy = Void Function(Pointer); typedef _DartWeaveffiEdgeCasesWidgetDestroy = void Function(Pointer); final _weaveffiEdgeCasesWidgetDestroy = _lib.lookupFunction< _NativeWeaveffiEdgeCasesWidgetDestroy, _DartWeaveffiEdgeCasesWidgetDestroy>('weaveffi_edge_cases_Widget_destroy'); +final _weaveffiEdgeCasesWidgetDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_edge_cases_Widget_destroy')); typedef _NativeWeaveffiEdgeCasesWidgetRefresh = Void Function(Pointer, Pointer<_WeaveFFIError>); typedef _DartWeaveffiEdgeCasesWidgetRefresh = void Function(Pointer, Pointer<_WeaveFFIError>); @@ -777,13 +1101,39 @@ final _weaveffiEdgeCasesWidgetOldName = _lib.lookupFunction< _NativeWeaveffiEdgeCasesWidgetOldName, _DartWeaveffiEdgeCasesWidgetOldName>('weaveffi_edge_cases_Widget_old_name'); /// Interface with no constructor; instances come from make_widget -class Widget { - final Pointer _handle; - Widget._(this._handle); +/// +/// A reference-counted native object. Each instance holds one strong +/// reference, released by [dispose] or, if the instance is collected +/// without being disposed, by a GC finalizer. Instances stay valid for as +/// long as the Dart object is reachable; the producer keeps its own +/// references to any object it retains. +class Widget implements Finalizable { + final Pointer _ptr; + bool _disposed = false; - /// Releases the native object reference. + /// Adopts one strong reference to the native object. + Widget._(this._ptr) { + _weaveffiEdgeCasesWidgetDestroyFinalizer.attach(this, _ptr, detach: this); + } + + /// The borrowed native pointer for the duration of a call. + Pointer get _handle { + if (_disposed) throw StateError('Widget used after dispose()'); + return _ptr; + } + + /// Mints a second strong reference (the interface's `_clone` symbol) for + /// an object token written into a value buffer. + Pointer _cloneRef() => _weaveffiEdgeCasesWidgetClone(_handle); + + /// Releases this instance's native reference. Safe to call more than + /// once; the native object is dropped when its last reference (this + /// one, any other wrapper's, or the producer's) is released. void dispose() { - _weaveffiEdgeCasesWidgetDestroy(_handle); + if (_disposed) return; + _disposed = true; + _weaveffiEdgeCasesWidgetDestroyFinalizer.detach(this); + _weaveffiEdgeCasesWidgetDestroy(_ptr); } /// Void method that can fail @@ -877,10 +1227,17 @@ class Widget { } } +typedef _NativeWeaveffiEdgeCasesVaultClone = Pointer Function(Pointer); +typedef _DartWeaveffiEdgeCasesVaultClone = Pointer Function(Pointer); +final _weaveffiEdgeCasesVaultClone = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesVaultClone, _DartWeaveffiEdgeCasesVaultClone>('weaveffi_edge_cases_Vault_clone'); + typedef _NativeWeaveffiEdgeCasesVaultDestroy = Void Function(Pointer); typedef _DartWeaveffiEdgeCasesVaultDestroy = void Function(Pointer); final _weaveffiEdgeCasesVaultDestroy = _lib.lookupFunction< _NativeWeaveffiEdgeCasesVaultDestroy, _DartWeaveffiEdgeCasesVaultDestroy>('weaveffi_edge_cases_Vault_destroy'); +final _weaveffiEdgeCasesVaultDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_edge_cases_Vault_destroy')); typedef _NativeWeaveffiEdgeCasesVaultOpen = Pointer Function(Pointer, Pointer<_WeaveFFIError>); typedef _DartWeaveffiEdgeCasesVaultOpen = Pointer Function(Pointer, Pointer<_WeaveFFIError>); @@ -898,17 +1255,47 @@ final _weaveffiEdgeCasesVaultProbe = _lib.lookupFunction< _NativeWeaveffiEdgeCasesVaultProbe, _DartWeaveffiEdgeCasesVaultProbe>('weaveffi_edge_cases_Vault_probe'); /// Deprecated interface with a throwing constructor -class Vault { - final Pointer _handle; - Vault._(this._handle); - - /// Releases the native object reference. +/// +/// A reference-counted native object. Each instance holds one strong +/// reference, released by [dispose] or, if the instance is collected +/// without being disposed, by a GC finalizer. Instances stay valid for as +/// long as the Dart object is reachable; the producer keeps its own +/// references to any object it retains. +@Deprecated('Use Widget instead') +class Vault implements Finalizable { + final Pointer _ptr; + bool _disposed = false; + + /// Adopts one strong reference to the native object. + Vault._(this._ptr) { + _weaveffiEdgeCasesVaultDestroyFinalizer.attach(this, _ptr, detach: this); + } + + /// The borrowed native pointer for the duration of a call. + Pointer get _handle { + if (_disposed) throw StateError('Vault used after dispose()'); + return _ptr; + } + + /// Mints a second strong reference (the interface's `_clone` symbol) for + /// an object token written into a value buffer. + Pointer _cloneRef() => _weaveffiEdgeCasesVaultClone(_handle); + + /// Releases this instance's native reference. Safe to call more than + /// once; the native object is dropped when its last reference (this + /// one, any other wrapper's, or the producer's) is released. void dispose() { - _weaveffiEdgeCasesVaultDestroy(_handle); + if (_disposed) return; + _disposed = true; + _weaveffiEdgeCasesVaultDestroyFinalizer.detach(this); + _weaveffiEdgeCasesVaultDestroy(_ptr); } /// Fails when the vault is busy /// + /// The returned object is owned by the caller: call its `dispose()` when + /// you are done with it. + /// /// Throws [EdgeErrorsException] on domain errors. factory Vault.open(String path) { final pathPtr = path.toNativeUtf8(); @@ -923,13 +1310,14 @@ class Vault { } } + /// Returns `null` when the producer reports no object. A non-null result + /// is owned by the caller: call its `dispose()` when you are done with it. Widget? peek() { final err = calloc<_WeaveFFIError>(); try { final result = _weaveffiEdgeCasesVaultPeek(_handle, err); _checkError(err); - if (result == nullptr) return null; - return Widget._(result); + return result == nullptr ? null : Widget._(result); } finally { calloc.free(err); } @@ -950,70 +1338,6 @@ class Vault { } } -typedef _NativeCb_weaveffi_edge_cases_OnBatch_fn = Void Function(Pointer, Size, Pointer, Size, Pointer, Size, Int32, Pointer); - -typedef _NativeCb_weaveffi_edge_cases_OnTick_fn = Void Function(Pointer); - -typedef _NativeWeaveffiEdgeCasesRegisterBatchListener = Uint64 Function(Pointer>, Pointer); -typedef _DartWeaveffiEdgeCasesRegisterBatchListener = int Function(Pointer>, Pointer); -final _weaveffiEdgeCasesRegisterBatchListener = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesRegisterBatchListener, _DartWeaveffiEdgeCasesRegisterBatchListener>('weaveffi_edge_cases_register_batch_listener'); - -typedef _NativeWeaveffiEdgeCasesUnregisterBatchListener = Void Function(Uint64); -typedef _DartWeaveffiEdgeCasesUnregisterBatchListener = void Function(int); -final _weaveffiEdgeCasesUnregisterBatchListener = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesUnregisterBatchListener, _DartWeaveffiEdgeCasesUnregisterBatchListener>('weaveffi_edge_cases_unregister_batch_listener'); - -/// Registers a OnBatch listener. Returns a subscription id for unregisterBatchListener(). -int registerBatchListener(void Function(List items, List raw, Outer? first, Mode mode) callback) { - final callable = NativeCallable<_NativeCb_weaveffi_edge_cases_OnBatch_fn>.isolateLocal((Pointer itemsPtr, int itemsLen, Pointer rawPtr, int rawLen, Pointer firstPtr, int firstLen, int mode, Pointer context) { - final itemsData = _copyNativeBytes(itemsPtr, itemsLen); - final itemsReader = _BufferReader(itemsData); - final itemsValue = List.generate(itemsReader.readLength(), (_) => _unpackOuter(itemsReader)); - itemsReader.expectEnd(); - final firstData = _copyNativeBytes(firstPtr, firstLen); - final firstReader = _BufferReader(firstData); - final firstValue = (firstReader.readOptionFlag() ? _unpackOuter(firstReader) : null); - firstReader.expectEnd(); - callback(itemsValue, rawPtr == nullptr ? [] : rawPtr.asTypedList(rawLen).toList(), firstValue, Mode.fromValue(mode)); - }); - final id = _weaveffiEdgeCasesRegisterBatchListener(callable.nativeFunction, nullptr); - _listenerCallables[id] = callable; - return id; -} - -/// Unregisters a listener previously registered with registerBatchListener(). -void unregisterBatchListener(int id) { - _weaveffiEdgeCasesUnregisterBatchListener(id); - _listenerCallables.remove(id)?.close(); -} - -typedef _NativeWeaveffiEdgeCasesRegisterTickListener = Uint64 Function(Pointer>, Pointer); -typedef _DartWeaveffiEdgeCasesRegisterTickListener = int Function(Pointer>, Pointer); -final _weaveffiEdgeCasesRegisterTickListener = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesRegisterTickListener, _DartWeaveffiEdgeCasesRegisterTickListener>('weaveffi_edge_cases_register_tick_listener'); - -typedef _NativeWeaveffiEdgeCasesUnregisterTickListener = Void Function(Uint64); -typedef _DartWeaveffiEdgeCasesUnregisterTickListener = void Function(int); -final _weaveffiEdgeCasesUnregisterTickListener = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesUnregisterTickListener, _DartWeaveffiEdgeCasesUnregisterTickListener>('weaveffi_edge_cases_unregister_tick_listener'); - -/// Registers a OnTick listener. Returns a subscription id for unregisterTickListener(). -int registerTickListener(void Function() callback) { - final callable = NativeCallable<_NativeCb_weaveffi_edge_cases_OnTick_fn>.isolateLocal((Pointer context) { - callback(); - }); - final id = _weaveffiEdgeCasesRegisterTickListener(callable.nativeFunction, nullptr); - _listenerCallables[id] = callable; - return id; -} - -/// Unregisters a listener previously registered with registerTickListener(). -void unregisterTickListener(int id) { - _weaveffiEdgeCasesUnregisterTickListener(id); - _listenerCallables.remove(id)?.close(); -} - typedef _NativeWeaveffiEdgeCasesNoop = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiEdgeCasesNoop = void Function(Pointer<_WeaveFFIError>); final _weaveffiEdgeCasesNoop = _lib.lookupFunction< @@ -1209,33 +1533,192 @@ Mode? optionalParams(String? text, int? count, Outer? outer, List? tags, } } -typedef _NativeWeaveffiEdgeCasesMutableParams = Void Function(Pointer, Pointer, Size, Pointer<_WeaveFFIError>); -typedef _DartWeaveffiEdgeCasesMutableParams = void Function(Pointer, Pointer, int, Pointer<_WeaveFFIError>); -final _weaveffiEdgeCasesMutableParams = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesMutableParams, _DartWeaveffiEdgeCasesMutableParams>('weaveffi_edge_cases_mutable_params'); +typedef _NativeWeaveffiEdgeCasesRegisterBatch = Void Function(Pointer, Pointer, Pointer, Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiEdgeCasesRegisterBatch = void Function(Pointer, Pointer, Pointer, Pointer, Pointer<_WeaveFFIError>); +final _weaveffiEdgeCasesRegisterBatch = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesRegisterBatch, _DartWeaveffiEdgeCasesRegisterBatch>('weaveffi_edge_cases_register_batch'); -/// Write-back parameters -void mutableParams(String text, List blob) { - final textPtr = text.toNativeUtf8(); - final blobPtr = blob.isEmpty ? nullptr : calloc(blob.length); - for (var i = 0; i < blob.length; i++) { blobPtr[i] = blob[i]; } +/// Two callback interface parameters on one function +void registerBatch(BatchListener listener, Keyword keyword) { final err = calloc<_WeaveFFIError>(); try { - _weaveffiEdgeCasesMutableParams(textPtr, blobPtr, blob.length, err); + _weaveffiEdgeCasesRegisterBatch(_registerCallback(listener), _BatchListenerVtable.cast(), _registerCallback(keyword), _KeywordVtable.cast(), err); _checkError(err); } finally { - calloc.free(textPtr); - if (blobPtr != nullptr) calloc.free(blobPtr); calloc.free(err); } } +typedef _NativeWeaveffiEdgeCasesWidgetsEverywhere = Pointer Function(Pointer, Size, Pointer, Size, Pointer, Size, Pointer, Size, Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiEdgeCasesWidgetsEverywhere = Pointer Function(Pointer, int, Pointer, int, Pointer, int, Pointer, int, Pointer, Pointer<_WeaveFFIError>); +final _weaveffiEdgeCasesWidgetsEverywhere = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesWidgetsEverywhere, _DartWeaveffiEdgeCasesWidgetsEverywhere>('weaveffi_edge_cases_widgets_everywhere'); + +/// Objects in every buffered position +WidgetHolder widgetsEverywhere(List list, Map byName, List sparse, WidgetHolder holder) { + final listWriter = _BufferWriter(); + final t0 = list; + listWriter.writeLength(t0.length); + for (final t1 in t0) { + listWriter.writeUint64(t1._cloneRef().address); + } + final listBuf = listWriter.takeBytes(); + final listPtr = _stageBytes(listBuf); + final byNameWriter = _BufferWriter(); + final t2 = byName; + byNameWriter.writeLength(t2.length); + for (final t3 in t2.entries) { + byNameWriter.writeString(t3.key); + byNameWriter.writeUint64(t3.value._cloneRef().address); + } + final byNameBuf = byNameWriter.takeBytes(); + final byNamePtr = _stageBytes(byNameBuf); + final sparseWriter = _BufferWriter(); + final t4 = sparse; + sparseWriter.writeLength(t4.length); + for (final t5 in t4) { + final t6 = t5; + if (t6 == null) { + sparseWriter.writeOptionFlag(false); + } else { + sparseWriter.writeOptionFlag(true); + sparseWriter.writeUint64(t6._cloneRef().address); + } + } + final sparseBuf = sparseWriter.takeBytes(); + final sparsePtr = _stageBytes(sparseBuf); + final holderWriter = _BufferWriter(); + _packWidgetHolder(holderWriter, holder); + final holderBuf = holderWriter.takeBytes(); + final holderPtr = _stageBytes(holderBuf); + final outLen = calloc(); + final err = calloc<_WeaveFFIError>(); + try { + final result = _weaveffiEdgeCasesWidgetsEverywhere(listPtr, listBuf.length, byNamePtr, byNameBuf.length, sparsePtr, sparseBuf.length, holderPtr, holderBuf.length, outLen, err); + _checkError(err); + final n = outLen.value; + final data = _copyNativeBytes(result, n); + if (result != nullptr) _weaveffiFreeBytes(result, n); + final reader = _BufferReader(data); + final decoded = _unpackWidgetHolder(reader); + reader.expectEnd(); + return decoded; + } finally { + calloc.free(listPtr); + calloc.free(byNamePtr); + calloc.free(sparsePtr); + calloc.free(holderPtr); + calloc.free(outLen); + calloc.free(err); + } +} + +typedef _NativeWeaveffiEdgeCasesWidgetStream = Pointer Function(Pointer<_WeaveFFIError>); +typedef _DartWeaveffiEdgeCasesWidgetStream = Pointer Function(Pointer<_WeaveFFIError>); +final _weaveffiEdgeCasesWidgetStream = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesWidgetStream, _DartWeaveffiEdgeCasesWidgetStream>('weaveffi_edge_cases_widget_stream'); + +typedef _NativeWeaveffiEdgeCasesWidgetStreamIteratorNext = Int32 Function(Pointer, Pointer>, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiEdgeCasesWidgetStreamIteratorNext = int Function(Pointer, Pointer>, Pointer<_WeaveFFIError>); +final _weaveffiEdgeCasesWidgetStreamIteratorNext = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesWidgetStreamIteratorNext, _DartWeaveffiEdgeCasesWidgetStreamIteratorNext>('weaveffi_edge_cases_WidgetStreamIterator_next'); + +typedef _NativeWeaveffiEdgeCasesWidgetStreamIteratorDestroy = Void Function(Pointer); +typedef _DartWeaveffiEdgeCasesWidgetStreamIteratorDestroy = void Function(Pointer); +final _weaveffiEdgeCasesWidgetStreamIteratorDestroy = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesWidgetStreamIteratorDestroy, _DartWeaveffiEdgeCasesWidgetStreamIteratorDestroy>('weaveffi_edge_cases_WidgetStreamIterator_destroy'); +final _weaveffiEdgeCasesWidgetStreamIteratorDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_edge_cases_WidgetStreamIterator_destroy')); + +/// Iterator of objects +/// +/// Returns a lazy [Iterable]: elements are pulled from the native +/// iterator one at a time (one native `next` call per element), and +/// iterating the result again launches a fresh native iterator. +/// +/// The native iterator handle is destroyed exactly once: eagerly when +/// the iteration completes or fails, or by a GC finalizer if the +/// iteration is abandoned before it is exhausted. +/// +/// Each yielded element is owned by the caller: call its `dispose()` +/// when you are done with it. +Iterable widgetStream() sync* { + final err = calloc<_WeaveFFIError>(); + final outItem = calloc>(); + Pointer iter = nullptr; + final anchor = _IteratorLifetime(); + try { + iter = _weaveffiEdgeCasesWidgetStream(err); + _checkError(err); + _weaveffiEdgeCasesWidgetStreamIteratorDestroyFinalizer.attach(anchor, iter, detach: anchor); + while (_weaveffiEdgeCasesWidgetStreamIteratorNext(iter, outItem, err) != 0) { + _checkError(err); + final itemPtr = outItem.value; + yield Widget._(itemPtr); + } + _checkError(err); + } finally { + if (iter != nullptr) { + _weaveffiEdgeCasesWidgetStreamIteratorDestroyFinalizer.detach(anchor); + _weaveffiEdgeCasesWidgetStreamIteratorDestroy(iter); + iter = nullptr; + } + calloc.free(outItem); + calloc.free(err); + } +} + +typedef _NativeAsyncCb_weaveffi_edge_cases_async_widget = Void Function(Pointer, Pointer<_WeaveFFIError>, Pointer); + +typedef _NativeWeaveffiEdgeCasesAsyncWidgetAsync = Void Function(Pointer>, Pointer); +typedef _DartWeaveffiEdgeCasesAsyncWidgetAsync = void Function(Pointer>, Pointer); +final _weaveffiEdgeCasesAsyncWidgetAsync = _lib.lookupFunction< + _NativeWeaveffiEdgeCasesAsyncWidgetAsync, _DartWeaveffiEdgeCasesAsyncWidgetAsync>('weaveffi_edge_cases_async_widget_async'); + +/// Async returning an object +/// +/// The returned object is owned by the caller: call its `dispose()` when +/// you are done with it. +/// +/// Throws [EdgeErrorsException] on domain errors. +Future asyncWidget() { + final completer = Completer(); + late NativeCallable<_NativeAsyncCb_weaveffi_edge_cases_async_widget> callable; + callable = NativeCallable<_NativeAsyncCb_weaveffi_edge_cases_async_widget>.listener((Pointer context, Pointer<_WeaveFFIError> err, Pointer result) { + try { + if (err.address != 0 && err.ref.code != 0) { + final code = err.ref.code; + final msg = err.ref.message.toDartString(); + final payload = _copyNativeBytes(err.ref.payloadPtr, err.ref.payloadLen); + _weaveffiErrorFree(err); + completer.completeError(_mapEdgeErrorsException(code, msg, payload)); + return; + } + completer.complete(Widget._(result)); + } catch (e) { + completer.completeError(e); + } finally { + callable.close(); + } + }); + try { + _weaveffiEdgeCasesAsyncWidgetAsync(callable.nativeFunction, nullptr); + } catch (e) { + callable.close(); + rethrow; + } + return completer.future; +} + typedef _NativeWeaveffiEdgeCasesMakeWidget = Pointer Function(Pointer, Pointer<_WeaveFFIError>); typedef _DartWeaveffiEdgeCasesMakeWidget = Pointer Function(Pointer, Pointer<_WeaveFFIError>); final _weaveffiEdgeCasesMakeWidget = _lib.lookupFunction< _NativeWeaveffiEdgeCasesMakeWidget, _DartWeaveffiEdgeCasesMakeWidget>('weaveffi_edge_cases_make_widget'); /// Factory for an interface with no constructor +/// +/// The returned object is owned by the caller: call its `dispose()` when +/// you are done with it. Widget makeWidget(String label) { final labelPtr = label.toNativeUtf8(); final err = calloc<_WeaveFFIError>(); @@ -1256,14 +1739,16 @@ final _weaveffiEdgeCasesFindWidget = _lib.lookupFunction< /// Optional interface return /// +/// Returns `null` when the producer reports no object. A non-null result +/// is owned by the caller: call its `dispose()` when you are done with it. +/// /// Throws [EdgeErrorsException] on domain errors. Widget? findWidget(int id) { final err = calloc<_WeaveFFIError>(); try { final result = _weaveffiEdgeCasesFindWidget(id, err); _checkEdgeErrorsException(err); - if (result == nullptr) return null; - return Widget._(result); + return result == nullptr ? null : Widget._(result); } finally { calloc.free(err); } @@ -1288,22 +1773,6 @@ String inspectWidget(Widget widget, Widget? fallback) { } } -typedef _NativeWeaveffiEdgeCasesTypedHandleParam = Pointer Function(Pointer, Int64, Pointer<_WeaveFFIError>); -typedef _DartWeaveffiEdgeCasesTypedHandleParam = Pointer Function(Pointer, int, Pointer<_WeaveFFIError>); -final _weaveffiEdgeCasesTypedHandleParam = _lib.lookupFunction< - _NativeWeaveffiEdgeCasesTypedHandleParam, _DartWeaveffiEdgeCasesTypedHandleParam>('weaveffi_edge_cases_typed_handle_param'); - -Leaf typedHandleParam(Leaf token, int raw) { - final err = calloc<_WeaveFFIError>(); - try { - final result = _weaveffiEdgeCasesTypedHandleParam(token._handle, raw, err); - _checkError(err); - return Leaf._(result); - } finally { - calloc.free(err); - } -} - typedef _NativeWeaveffiEdgeCasesStringStream = Pointer Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiEdgeCasesStringStream = Pointer Function(Pointer<_WeaveFFIError>); final _weaveffiEdgeCasesStringStream = _lib.lookupFunction< diff --git a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__pubspec_yaml.snap b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__pubspec_yaml.snap index 6c4a1026..020ab447 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__pubspec_yaml.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_edge_cases__pubspec_yaml.snap @@ -9,7 +9,7 @@ name: weaveffi version: 0.1.0 description: weaveffi bindings generated by WeaveFFI environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: ffi: ^2.0.0 diff --git a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__README_md.snap b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__README_md.snap index daa40993..732894ed 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__README_md.snap @@ -24,9 +24,23 @@ import 'package:weaveffi/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. diff --git a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__lib_weaveffi_dart.snap b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__lib_weaveffi_dart.snap index 7b307015..d7a8eefa 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__lib_weaveffi_dart.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__lib_weaveffi_dart.snap @@ -27,7 +27,7 @@ DynamicLibrary _openLibrary() { } // The ABI revision these bindings were generated against. -const int _abiVersion = 1; +const int _abiVersion = 2; DynamicLibrary _checkAbiVersion(DynamicLibrary lib) { final int Function() abiVersion; @@ -60,6 +60,11 @@ final class _WeaveFFIError extends Struct { external int payloadLen; } +typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer); +typedef _DartWeaveffiErrorSet = void Function(Pointer<_WeaveFFIError>, int, Pointer); +final _weaveffiErrorSet = _lib.lookupFunction< + _NativeWeaveffiErrorSet, _DartWeaveffiErrorSet>('weaveffi_error_set'); + typedef _NativeWeaveffiErrorClear = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiErrorClear = void Function(Pointer<_WeaveFFIError>); final _weaveffiErrorClear = _lib.lookupFunction< @@ -80,8 +85,19 @@ typedef _DartWeaveffiFreeBytes = void Function(Pointer, int); final _weaveffiFreeBytes = _lib.lookupFunction< _NativeWeaveffiFreeBytes, _DartWeaveffiFreeBytes>('weaveffi_free_bytes'); -/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. +/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling +/// error ([marshalCode]), a callback-interface implementation that threw +/// ([foreignCode], carrying the Dart exception's text), or an unknown code. class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; final int code; final String message; WeaveFFIException(this.code, this.message); @@ -341,9 +357,57 @@ final class _BufferReader { } } -// Live listener trampolines by subscription id. Holding the -// NativeCallable here keeps its native thunk alive until unregistered. -final Map _listenerCallables = {}; +// ── WeaveFFI callback-interface runtime ── +// A Dart implementation passed to the producer is stored here under an +// integer key; the key (widened to a pointer) is what crosses as `ctx`, so the +// producer never holds a Dart object and the GC never sees a raw pointer. The +// entry lives until the producer calls the vtable's `free(ctx)`. +final Map _callbackTable = {}; +int _nextCallbackKey = 1; + +Pointer _registerCallback(Object impl) { + final key = _nextCallbackKey++; + _callbackTable[key] = impl; + return Pointer.fromAddress(key); +} + +Object _callbackFor(Pointer ctx) { + final impl = _callbackTable[ctx.address]; + if (impl == null) { + throw StateError('callback interface context ${ctx.address} is not registered'); + } + return impl; +} + +// Vtable trampolines are process-wide: their NativeCallables are never closed, +// so they are anchored here to keep the native thunks alive. `keepIsolateAlive` +// is cleared on each so an idle isolate can still exit. +final List> _callbackCallables = []; + +Pointer> _pinCallable( + NativeCallable callable) { + callable.keepIsolateAlive = false; + _callbackCallables.add(callable); + return callable.nativeFunction; +} + +// Reports a Dart exception thrown by a callback-interface implementation +// through `weaveffi_error_set` with the reserved foreign error code (-4). +// The producer copies the message, so the temporary is freed right away. This +// must never throw: it runs in the catch path of a trampoline whose C frame +// an exception must not unwind through. +void _foreignError(Pointer<_WeaveFFIError> outErr, Object error) { + if (outErr == nullptr) return; + Pointer message = nullptr; + try { + message = error.toString().toNativeUtf8(); + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, message); + } catch (_) { + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, nullptr); + } finally { + if (message != nullptr) calloc.free(message); + } +} // Anchors one live native iteration for its GC-finalizer backstop. // A suspended `sync*` frame keeps the anchor reachable; abandoning the @@ -463,8 +527,10 @@ class Item { /// Cross-module struct reference final Token token; final Priority priority; + /// An optional object inside a record + final Gadget? gadget; - Item({required this.id, required this.name, required this.count, required this.enabled, required this.ratio, required this.nick, required this.payload, required this.tags, required this.attrs, this.parent, required this.token, required this.priority}); + Item({required this.id, required this.name, required this.count, required this.enabled, required this.ratio, required this.nick, required this.payload, required this.tags, required this.attrs, this.parent, required this.token, required this.priority, this.gadget}); } void _packItem(_BufferWriter w, Item v) { @@ -495,6 +561,13 @@ void _packItem(_BufferWriter w, Item v) { } _packToken(w, v.token); w.writeInt32(v.priority.value); + final t5 = v.gadget; + if (t5 == null) { + w.writeOptionFlag(false); + } else { + w.writeOptionFlag(true); + w.writeUint64(t5._cloneRef().address); + } } Item _unpackItem(_BufferReader r) { @@ -511,13 +584,102 @@ Item _unpackItem(_BufferReader r) { parent: (r.readOptionFlag() ? r.readInt64() : null), token: _unpackToken(r), priority: Priority.fromValue(r.readInt32()), + gadget: (r.readOptionFlag() ? Gadget._(Pointer.fromAddress(r.readUint64())) : null), ); } +/// Consumer-implemented observer of ready items +/// +/// A callback interface: implement this class and pass an instance to any +/// function taking a [ReadyListener]. The producer keeps the instance alive +/// (through a private handle table) until it releases the callback, so no +/// other reference needs to be held. +/// +/// Object arguments are owned by the implementation: call `dispose()` on +/// them (or let the finalizer run) when done. A thrown exception is reported +/// to the producer, which aborts the call it was making; the original Dart +/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode] +/// carrying the exception's text. +/// +/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so +/// the producer may only invoke them on the thread of the isolate that +/// passed the callback. That holds when the producer calls them +/// synchronously during a call from Dart. A producer that invokes a method +/// from another thread (an async task, a background worker) is unsupported: +/// a `NativeCallable.listener` cannot return a value or read borrowed +/// arguments after the native frame returns. +abstract class ReadyListener { + /// Fires when an item is ready + void onReady(int code, String msg); + /// Receives the item itself and says whether to keep listening + bool onItem(Item item, Gadget gadget); +} + +typedef _NativeReadyListenerVtOnReady = Void Function(Pointer, Int32, Pointer, Pointer<_WeaveFFIError>); +typedef _NativeReadyListenerVtOnItem = Bool Function(Pointer, Pointer, Size, Pointer, Pointer<_WeaveFFIError>); +typedef _NativeReadyListenerVtFree = Void Function(Pointer); + +// The C `weaveffi_kitchen_ReadyListener_vtable` layout. +final class _ReadyListenerVtableStruct extends Struct { + external Pointer> onReady; + external Pointer> onItem; + external Pointer> free; +} + +// Vtable entry `weaveffi_kitchen_ReadyListener_vtable.on_ready`. +void _readyListenerVtOnReady(Pointer ctx, int code, Pointer msg, Pointer<_WeaveFFIError> outErr) { + try { + final impl = _callbackFor(ctx) as ReadyListener; + impl.onReady(code, msg == nullptr ? '' : msg.toDartString()); + } catch (e) { + _foreignError(outErr, e); + } +} + +// Vtable entry `weaveffi_kitchen_ReadyListener_vtable.on_item`. +bool _readyListenerVtOnItem(Pointer ctx, Pointer itemPtr, int itemLen, Pointer gadget, Pointer<_WeaveFFIError> outErr) { + try { + final itemData = _copyNativeBytes(itemPtr, itemLen); + final itemReader = _BufferReader(itemData); + final itemValue = _unpackItem(itemReader); + itemReader.expectEnd(); + final gadgetValue = Gadget._(gadget); + final impl = _callbackFor(ctx) as ReadyListener; + return impl.onItem(itemValue, gadgetValue); + } catch (e) { + _foreignError(outErr, e); + return false; + } +} + +void _readyListenerVtFree(Pointer ctx) { + _callbackTable.remove(ctx.address); +} + +// The process-wide static `weaveffi_kitchen_ReadyListener_vtable` every ReadyListener passed to the producer +// shares; the per-instance state travels in `ctx`. +final Pointer<_ReadyListenerVtableStruct> _ReadyListenerVtable = () { + final vt = calloc<_ReadyListenerVtableStruct>(); + vt.ref.onReady = _pinCallable(NativeCallable<_NativeReadyListenerVtOnReady>.isolateLocal( + _readyListenerVtOnReady)); + vt.ref.onItem = _pinCallable(NativeCallable<_NativeReadyListenerVtOnItem>.isolateLocal( + _readyListenerVtOnItem, exceptionalReturn: false)); + vt.ref.free = _pinCallable(NativeCallable<_NativeReadyListenerVtFree>.listener( + _readyListenerVtFree)); + return vt; +}(); + +typedef _NativeWeaveffiKitchenGadgetClone = Pointer Function(Pointer); +typedef _DartWeaveffiKitchenGadgetClone = Pointer Function(Pointer); +final _weaveffiKitchenGadgetClone = _lib.lookupFunction< + _NativeWeaveffiKitchenGadgetClone, _DartWeaveffiKitchenGadgetClone>('weaveffi_kitchen_Gadget_clone'); + typedef _NativeWeaveffiKitchenGadgetDestroy = Void Function(Pointer); typedef _DartWeaveffiKitchenGadgetDestroy = void Function(Pointer); final _weaveffiKitchenGadgetDestroy = _lib.lookupFunction< _NativeWeaveffiKitchenGadgetDestroy, _DartWeaveffiKitchenGadgetDestroy>('weaveffi_kitchen_Gadget_destroy'); +final _weaveffiKitchenGadgetDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_kitchen_Gadget_destroy')); typedef _NativeWeaveffiKitchenGadgetNew = Pointer Function(Int64, Pointer<_WeaveFFIError>); typedef _DartWeaveffiKitchenGadgetNew = Pointer Function(int, Pointer<_WeaveFFIError>); @@ -540,16 +702,45 @@ final _weaveffiKitchenGadgetVersion = _lib.lookupFunction< _NativeWeaveffiKitchenGadgetVersion, _DartWeaveffiKitchenGadgetVersion>('weaveffi_kitchen_Gadget_version'); /// A pocket-sized interface exercising the object surface -class Gadget { - final Pointer _handle; - Gadget._(this._handle); +/// +/// A reference-counted native object. Each instance holds one strong +/// reference, released by [dispose] or, if the instance is collected +/// without being disposed, by a GC finalizer. Instances stay valid for as +/// long as the Dart object is reachable; the producer keeps its own +/// references to any object it retains. +class Gadget implements Finalizable { + final Pointer _ptr; + bool _disposed = false; + + /// Adopts one strong reference to the native object. + Gadget._(this._ptr) { + _weaveffiKitchenGadgetDestroyFinalizer.attach(this, _ptr, detach: this); + } + + /// The borrowed native pointer for the duration of a call. + Pointer get _handle { + if (_disposed) throw StateError('Gadget used after dispose()'); + return _ptr; + } + + /// Mints a second strong reference (the interface's `_clone` symbol) for + /// an object token written into a value buffer. + Pointer _cloneRef() => _weaveffiKitchenGadgetClone(_handle); - /// Releases the native object reference. + /// Releases this instance's native reference. Safe to call more than + /// once; the native object is dropped when its last reference (this + /// one, any other wrapper's, or the producer's) is released. void dispose() { - _weaveffiKitchenGadgetDestroy(_handle); + if (_disposed) return; + _disposed = true; + _weaveffiKitchenGadgetDestroyFinalizer.detach(this); + _weaveffiKitchenGadgetDestroy(_ptr); } /// Create a gadget with the given id + /// + /// The returned object is owned by the caller: call its `dispose()` when + /// you are done with it. factory Gadget(int id) { final err = calloc<_WeaveFFIError>(); try { @@ -604,35 +795,6 @@ class Gadget { } } -typedef _NativeCb_weaveffi_kitchen_OnReady_fn = Void Function(Int32, Pointer, Pointer); - -typedef _NativeWeaveffiKitchenRegisterReadyListener = Uint64 Function(Pointer>, Pointer); -typedef _DartWeaveffiKitchenRegisterReadyListener = int Function(Pointer>, Pointer); -final _weaveffiKitchenRegisterReadyListener = _lib.lookupFunction< - _NativeWeaveffiKitchenRegisterReadyListener, _DartWeaveffiKitchenRegisterReadyListener>('weaveffi_kitchen_register_ready_listener'); - -typedef _NativeWeaveffiKitchenUnregisterReadyListener = Void Function(Uint64); -typedef _DartWeaveffiKitchenUnregisterReadyListener = void Function(int); -final _weaveffiKitchenUnregisterReadyListener = _lib.lookupFunction< - _NativeWeaveffiKitchenUnregisterReadyListener, _DartWeaveffiKitchenUnregisterReadyListener>('weaveffi_kitchen_unregister_ready_listener'); - -/// Subscribe to OnReady events -/// Registers a OnReady listener. Returns a subscription id for unregisterReadyListener(). -int registerReadyListener(void Function(int code, String msg) callback) { - final callable = NativeCallable<_NativeCb_weaveffi_kitchen_OnReady_fn>.isolateLocal((int code, Pointer msg, Pointer context) { - callback(code, msg == nullptr ? '' : msg.toDartString()); - }); - final id = _weaveffiKitchenRegisterReadyListener(callable.nativeFunction, nullptr); - _listenerCallables[id] = callable; - return id; -} - -/// Unregisters a listener previously registered with registerReadyListener(). -void unregisterReadyListener(int id) { - _weaveffiKitchenUnregisterReadyListener(id); - _listenerCallables.remove(id)?.close(); -} - typedef _NativeWeaveffiKitchenBoolId = Bool Function(Bool, Pointer<_WeaveFFIError>); typedef _DartWeaveffiKitchenBoolId = bool Function(bool, Pointer<_WeaveFFIError>); final _weaveffiKitchenBoolId = _lib.lookupFunction< @@ -759,83 +921,119 @@ List echoBytes(List b) { } } -typedef _NativeWeaveffiKitchenEchoBorrowedStr = Pointer Function(Pointer, Pointer<_WeaveFFIError>); -typedef _DartWeaveffiKitchenEchoBorrowedStr = Pointer Function(Pointer, Pointer<_WeaveFFIError>); -final _weaveffiKitchenEchoBorrowedStr = _lib.lookupFunction< - _NativeWeaveffiKitchenEchoBorrowedStr, _DartWeaveffiKitchenEchoBorrowedStr>('weaveffi_kitchen_echo_borrowed_str'); +typedef _NativeWeaveffiKitchenSubscribe = Int32 Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKitchenSubscribe = int Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +final _weaveffiKitchenSubscribe = _lib.lookupFunction< + _NativeWeaveffiKitchenSubscribe, _DartWeaveffiKitchenSubscribe>('weaveffi_kitchen_subscribe'); -/// Borrowed string parameter -String echoBorrowedStr(String s) { - final sPtr = s.toNativeUtf8(); +/// Register a listener, returning how many are attached +int subscribe(ReadyListener listener) { final err = calloc<_WeaveFFIError>(); try { - final result = _weaveffiKitchenEchoBorrowedStr(sPtr, err); + final result = _weaveffiKitchenSubscribe(_registerCallback(listener), _ReadyListenerVtable.cast(), err); _checkError(err); - final decoded = result.toDartString(); - _weaveffiFreeString(result); - return decoded; + return result; } finally { - calloc.free(sPtr); calloc.free(err); } } -typedef _NativeWeaveffiKitchenEchoBorrowedBytes = Pointer Function(Pointer, Size, Pointer, Pointer<_WeaveFFIError>); -typedef _DartWeaveffiKitchenEchoBorrowedBytes = Pointer Function(Pointer, int, Pointer, Pointer<_WeaveFFIError>); -final _weaveffiKitchenEchoBorrowedBytes = _lib.lookupFunction< - _NativeWeaveffiKitchenEchoBorrowedBytes, _DartWeaveffiKitchenEchoBorrowedBytes>('weaveffi_kitchen_echo_borrowed_bytes'); +typedef _NativeWeaveffiKitchenMaybeGadget = Pointer Function(Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKitchenMaybeGadget = Pointer Function(Pointer, Pointer<_WeaveFFIError>); +final _weaveffiKitchenMaybeGadget = _lib.lookupFunction< + _NativeWeaveffiKitchenMaybeGadget, _DartWeaveffiKitchenMaybeGadget>('weaveffi_kitchen_maybe_gadget'); -/// Borrowed bytes parameter -List echoBorrowedBytes(List b) { - final bPtr = b.isEmpty ? nullptr : calloc(b.length); - for (var i = 0; i < b.length; i++) { bPtr[i] = b[i]; } - final outLen = calloc(); +/// Optional object in both directions +/// +/// Returns `null` when the producer reports no object. A non-null result +/// is owned by the caller: call its `dispose()` when you are done with it. +Gadget? maybeGadget(Gadget? fallback) { final err = calloc<_WeaveFFIError>(); try { - final result = _weaveffiKitchenEchoBorrowedBytes(bPtr, b.length, outLen, err); + final result = _weaveffiKitchenMaybeGadget(fallback?._handle ?? nullptr, err); _checkError(err); - final n = outLen.value; - if (result == nullptr) return []; - final bytes = List.generate(n, (i) => result[i]); - _weaveffiFreeBytes(result, n); - return bytes; + return result == nullptr ? null : Gadget._(result); } finally { - if (bPtr != nullptr) calloc.free(bPtr); - calloc.free(outLen); calloc.free(err); } } -typedef _NativeWeaveffiKitchenOpenHandle = Int64 Function(Pointer<_WeaveFFIError>); -typedef _DartWeaveffiKitchenOpenHandle = int Function(Pointer<_WeaveFFIError>); -final _weaveffiKitchenOpenHandle = _lib.lookupFunction< - _NativeWeaveffiKitchenOpenHandle, _DartWeaveffiKitchenOpenHandle>('weaveffi_kitchen_open_handle'); +typedef _NativeWeaveffiKitchenGadgets = Pointer Function(Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKitchenGadgets = Pointer Function(Pointer, Pointer<_WeaveFFIError>); +final _weaveffiKitchenGadgets = _lib.lookupFunction< + _NativeWeaveffiKitchenGadgets, _DartWeaveffiKitchenGadgets>('weaveffi_kitchen_gadgets'); -/// Returns an opaque handle -int openHandle() { +/// A list of objects +List gadgets() { + final outLen = calloc(); final err = calloc<_WeaveFFIError>(); try { - final result = _weaveffiKitchenOpenHandle(err); + final result = _weaveffiKitchenGadgets(outLen, err); _checkError(err); - return result; + final n = outLen.value; + final data = _copyNativeBytes(result, n); + if (result != nullptr) _weaveffiFreeBytes(result, n); + final reader = _BufferReader(data); + final decoded = List.generate(reader.readLength(), (_) => Gadget._(Pointer.fromAddress(reader.readUint64()))); + reader.expectEnd(); + return decoded; } finally { + calloc.free(outLen); calloc.free(err); } } -typedef _NativeWeaveffiKitchenOpenTypedHandle = Pointer Function(Pointer<_WeaveFFIError>); -typedef _DartWeaveffiKitchenOpenTypedHandle = Pointer Function(Pointer<_WeaveFFIError>); -final _weaveffiKitchenOpenTypedHandle = _lib.lookupFunction< - _NativeWeaveffiKitchenOpenTypedHandle, _DartWeaveffiKitchenOpenTypedHandle>('weaveffi_kitchen_open_typed_handle'); +typedef _NativeWeaveffiKitchenStreamGadgets = Pointer Function(Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKitchenStreamGadgets = Pointer Function(Pointer<_WeaveFFIError>); +final _weaveffiKitchenStreamGadgets = _lib.lookupFunction< + _NativeWeaveffiKitchenStreamGadgets, _DartWeaveffiKitchenStreamGadgets>('weaveffi_kitchen_stream_gadgets'); -/// Returns a typed handle -Token openTypedHandle() { +typedef _NativeWeaveffiKitchenStreamGadgetsIteratorNext = Int32 Function(Pointer, Pointer>, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKitchenStreamGadgetsIteratorNext = int Function(Pointer, Pointer>, Pointer<_WeaveFFIError>); +final _weaveffiKitchenStreamGadgetsIteratorNext = _lib.lookupFunction< + _NativeWeaveffiKitchenStreamGadgetsIteratorNext, _DartWeaveffiKitchenStreamGadgetsIteratorNext>('weaveffi_kitchen_StreamGadgetsIterator_next'); + +typedef _NativeWeaveffiKitchenStreamGadgetsIteratorDestroy = Void Function(Pointer); +typedef _DartWeaveffiKitchenStreamGadgetsIteratorDestroy = void Function(Pointer); +final _weaveffiKitchenStreamGadgetsIteratorDestroy = _lib.lookupFunction< + _NativeWeaveffiKitchenStreamGadgetsIteratorDestroy, _DartWeaveffiKitchenStreamGadgetsIteratorDestroy>('weaveffi_kitchen_StreamGadgetsIterator_destroy'); +final _weaveffiKitchenStreamGadgetsIteratorDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_kitchen_StreamGadgetsIterator_destroy')); + +/// An iterator over objects +/// +/// Returns a lazy [Iterable]: elements are pulled from the native +/// iterator one at a time (one native `next` call per element), and +/// iterating the result again launches a fresh native iterator. +/// +/// The native iterator handle is destroyed exactly once: eagerly when +/// the iteration completes or fails, or by a GC finalizer if the +/// iteration is abandoned before it is exhausted. +/// +/// Each yielded element is owned by the caller: call its `dispose()` +/// when you are done with it. +Iterable streamGadgets() sync* { final err = calloc<_WeaveFFIError>(); + final outItem = calloc>(); + Pointer iter = nullptr; + final anchor = _IteratorLifetime(); try { - final result = _weaveffiKitchenOpenTypedHandle(err); + iter = _weaveffiKitchenStreamGadgets(err); + _checkError(err); + _weaveffiKitchenStreamGadgetsIteratorDestroyFinalizer.attach(anchor, iter, detach: anchor); + while (_weaveffiKitchenStreamGadgetsIteratorNext(iter, outItem, err) != 0) { + _checkError(err); + final itemPtr = outItem.value; + yield Gadget._(itemPtr); + } _checkError(err); - return Token._(result); } finally { + if (iter != nullptr) { + _weaveffiKitchenStreamGadgetsIteratorDestroyFinalizer.detach(anchor); + _weaveffiKitchenStreamGadgetsIteratorDestroy(iter); + iter = nullptr; + } + calloc.free(outItem); calloc.free(err); } } diff --git a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__pubspec_yaml.snap b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__pubspec_yaml.snap index 6c4a1026..020ab447 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__pubspec_yaml.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_kitchen_sink__pubspec_yaml.snap @@ -9,7 +9,7 @@ name: weaveffi version: 0.1.0 description: weaveffi bindings generated by WeaveFFI environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: ffi: ^2.0.0 diff --git a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__README_md.snap b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__README_md.snap index daa40993..732894ed 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__README_md.snap @@ -24,9 +24,23 @@ import 'package:weaveffi/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. diff --git a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__lib_weaveffi_dart.snap b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__lib_weaveffi_dart.snap index b588f12c..2315e883 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__lib_weaveffi_dart.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__lib_weaveffi_dart.snap @@ -26,7 +26,7 @@ DynamicLibrary _openLibrary() { } // The ABI revision these bindings were generated against. -const int _abiVersion = 1; +const int _abiVersion = 2; DynamicLibrary _checkAbiVersion(DynamicLibrary lib) { final int Function() abiVersion; @@ -59,6 +59,11 @@ final class _WeaveFFIError extends Struct { external int payloadLen; } +typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer); +typedef _DartWeaveffiErrorSet = void Function(Pointer<_WeaveFFIError>, int, Pointer); +final _weaveffiErrorSet = _lib.lookupFunction< + _NativeWeaveffiErrorSet, _DartWeaveffiErrorSet>('weaveffi_error_set'); + typedef _NativeWeaveffiErrorClear = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiErrorClear = void Function(Pointer<_WeaveFFIError>); final _weaveffiErrorClear = _lib.lookupFunction< @@ -79,8 +84,19 @@ typedef _DartWeaveffiFreeBytes = void Function(Pointer, int); final _weaveffiFreeBytes = _lib.lookupFunction< _NativeWeaveffiFreeBytes, _DartWeaveffiFreeBytes>('weaveffi_free_bytes'); -/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. +/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling +/// error ([marshalCode]), a callback-interface implementation that threw +/// ([foreignCode], carrying the Dart exception's text), or an unknown code. class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; final int code; final String message; WeaveFFIException(this.code, this.message); diff --git a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__pubspec_yaml.snap b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__pubspec_yaml.snap index 6c4a1026..020ab447 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__pubspec_yaml.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_nested_modules__pubspec_yaml.snap @@ -9,7 +9,7 @@ name: weaveffi version: 0.1.0 description: weaveffi bindings generated by WeaveFFI environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: ffi: ^2.0.0 diff --git a/crates/weaveffi-cli/tests/snapshots/dart_shapes__README_md.snap b/crates/weaveffi-cli/tests/snapshots/dart_shapes__README_md.snap index daa40993..732894ed 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_shapes__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_shapes__README_md.snap @@ -24,9 +24,23 @@ import 'package:weaveffi/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. diff --git a/crates/weaveffi-cli/tests/snapshots/dart_shapes__lib_weaveffi_dart.snap b/crates/weaveffi-cli/tests/snapshots/dart_shapes__lib_weaveffi_dart.snap index 970a0b13..9c9fd2e0 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_shapes__lib_weaveffi_dart.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_shapes__lib_weaveffi_dart.snap @@ -26,7 +26,7 @@ DynamicLibrary _openLibrary() { } // The ABI revision these bindings were generated against. -const int _abiVersion = 1; +const int _abiVersion = 2; DynamicLibrary _checkAbiVersion(DynamicLibrary lib) { final int Function() abiVersion; @@ -59,6 +59,11 @@ final class _WeaveFFIError extends Struct { external int payloadLen; } +typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer); +typedef _DartWeaveffiErrorSet = void Function(Pointer<_WeaveFFIError>, int, Pointer); +final _weaveffiErrorSet = _lib.lookupFunction< + _NativeWeaveffiErrorSet, _DartWeaveffiErrorSet>('weaveffi_error_set'); + typedef _NativeWeaveffiErrorClear = Void Function(Pointer<_WeaveFFIError>); typedef _DartWeaveffiErrorClear = void Function(Pointer<_WeaveFFIError>); final _weaveffiErrorClear = _lib.lookupFunction< @@ -79,8 +84,19 @@ typedef _DartWeaveffiFreeBytes = void Function(Pointer, int); final _weaveffiFreeBytes = _lib.lookupFunction< _NativeWeaveffiFreeBytes, _DartWeaveffiFreeBytes>('weaveffi_free_bytes'); -/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. +/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling +/// error ([marshalCode]), a callback-interface implementation that threw +/// ([foreignCode], carrying the Dart exception's text), or an unknown code. class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; final int code; final String message; WeaveFFIException(this.code, this.message); diff --git a/crates/weaveffi-cli/tests/snapshots/dart_shapes__pubspec_yaml.snap b/crates/weaveffi-cli/tests/snapshots/dart_shapes__pubspec_yaml.snap index 6c4a1026..020ab447 100644 --- a/crates/weaveffi-cli/tests/snapshots/dart_shapes__pubspec_yaml.snap +++ b/crates/weaveffi-cli/tests/snapshots/dart_shapes__pubspec_yaml.snap @@ -9,7 +9,7 @@ name: weaveffi version: 0.1.0 description: weaveffi bindings generated by WeaveFFI environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: ffi: ^2.0.0 diff --git a/crates/weaveffi-cli/tests/snapshots/dotnet_docs_everywhere__WeaveFFI_cs.snap b/crates/weaveffi-cli/tests/snapshots/dotnet_docs_everywhere__WeaveFFI_cs.snap index f870da95..73b2ef2a 100644 --- a/crates/weaveffi-cli/tests/snapshots/dotnet_docs_everywhere__WeaveFFI_cs.snap +++ b/crates/weaveffi-cli/tests/snapshots/dotnet_docs_everywhere__WeaveFFI_cs.snap @@ -10,12 +10,26 @@ source: crates/weaveffi-cli/tests/snapshots.rs using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace WeaveFFI { + /// Raised for any WeaveFFI failure that isn't a typed domain + /// error: producer bugs, panics, marshalling failures, and exceptions + /// thrown by a callback-interface implementation. public class WeaveFFIException : Exception { + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + public int Code { get; } public WeaveFFIException(int code, string message) : base(message) @@ -96,6 +110,20 @@ namespace WeaveFFI } } + /// One strong reference to a native object, on its way into + /// a wrapper that adopts it. A distinct type (rather than a bare + /// IntPtr) so the adopting constructor never competes with a + /// public constructor taking an integer. + internal readonly struct WeaveFFIHandle + { + internal readonly IntPtr Value; + + internal WeaveFFIHandle(IntPtr value) + { + Value = value; + } + } + internal static class WeaveFFIHelpers { internal static IntPtr StringToPtr(string? s) @@ -227,6 +255,11 @@ namespace WeaveFFI _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -373,6 +406,16 @@ namespace WeaveFFI return outBuf; } + internal IntPtr ReadObject() + { + var token = ReadU64(); + if (token == 0) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); + } + return (nint)(nuint)token; + } + internal void ExpectEnd() { if (_pos != _data.Length) @@ -452,6 +495,79 @@ namespace WeaveFFI } } + /// + /// Consumer-implemented observer of document-saved notifications. + /// + /// Implementations must be safe to call from any thread. + /// + /// Implement this interface and pass an instance wherever the + /// native library expects it. The library may call any method from any + /// thread until it releases its last reference to the instance. Object + /// arguments are owned by the implementation, which should dispose them + /// when done. An exception thrown by a method is reported to the native + /// caller as . + public interface ISavedListener + { + /// Fires after a document has been persisted successfully + /// Identifier of the saved document + /// Monotonically increasing revision + void OnSaved(long docId, int revision); + } + + /// The process-wide weaveffi_docs_SavedListener_vtable vtable and the trampolines behind + /// it, adapting an to the C ABI. + internal static unsafe class WeaveFFIVtable_docs_SavedListener + { + [StructLayout(LayoutKind.Sequential)] + private struct Layout + { + public IntPtr on_saved; + public IntPtr free; + } + + /// Address of the one static vtable for this interface. It is + /// allocated once and never freed, so the producer may keep the pointer + /// for the process lifetime. + internal static readonly IntPtr Pointer = Allocate(); + + private static IntPtr Allocate() + { + var layout = new Layout + { + on_saved = (IntPtr)(delegate* unmanaged[Cdecl])&OnSavedTrampoline, + free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline, + }; + var mem = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(layout, mem, false); + return mem; + } + + private static ISavedListener Target(IntPtr ctx) + { + return (ISavedListener)GCHandle.FromIntPtr(ctx).Target!; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnSavedTrampoline(IntPtr ctx, long doc_id, int revision, IntPtr out_err) + { + try + { + var impl = Target(ctx); + impl.OnSaved(doc_id, revision); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void FreeTrampoline(IntPtr ctx) + { + GCHandle.FromIntPtr(ctx).Free(); + } + } + /// /// An ordered collection of documents. /// @@ -460,14 +576,35 @@ namespace WeaveFFI public class Notebook : IDisposable { private IntPtr _handle; - private bool _disposed; + private int _released; - internal Notebook(IntPtr handle) + /// Adopts one strong reference to a native object. + internal Notebook(WeaveFFIHandle handle) { - _handle = handle; + _handle = handle.Value; } - internal IntPtr Handle => _handle; + /// The borrowed native pointer for the duration of a call. + /// The wrapper was disposed. + internal IntPtr Handle + { + get + { + var h = _handle; + if (h == IntPtr.Zero) + { + throw new ObjectDisposedException(nameof(Notebook)); + } + return h; + } + } + + /// Mints a second strong reference the caller owns (for + /// example to write this object into a value buffer). + internal IntPtr CloneHandle() + { + return NativeMethods.weaveffi_docs_Notebook_clone(Handle); + } /// Create an empty notebook public Notebook() @@ -489,7 +626,7 @@ namespace WeaveFFI var documentPin = GCHandle.Alloc(documentBuf, GCHandleType.Pinned); try { - var result = NativeMethods.weaveffi_docs_Notebook_insert(_handle, documentPin.AddrOfPinnedObject(), (UIntPtr)documentBuf.Length, ref err); + var result = NativeMethods.weaveffi_docs_Notebook_insert(Handle, documentPin.AddrOfPinnedObject(), (UIntPtr)documentBuf.Length, ref err); WeaveFFIError.Check(err); return result; } @@ -505,7 +642,7 @@ namespace WeaveFFI public Document Find(long id) { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_docs_Notebook_find(_handle, id, out var outLen, ref err); + var result = NativeMethods.weaveffi_docs_Notebook_find(Handle, id, out var outLen, ref err); WeaveFFIError.CheckDocsErrors(err); var resultBuf = new byte[(int)outLen]; if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen); @@ -520,7 +657,7 @@ namespace WeaveFFI public int Size() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_docs_Notebook_size(_handle, ref err); + var result = NativeMethods.weaveffi_docs_Notebook_size(Handle, ref err); WeaveFFIError.Check(err); return result; } @@ -534,19 +671,32 @@ namespace WeaveFFI return result; } + /// Releases this wrapper's reference. The native object is + /// dropped when the producer releases its last reference; other + /// wrappers to the same object stay valid. public void Dispose() { - if (!_disposed) - { - NativeMethods.weaveffi_docs_Notebook_destroy(_handle); - _disposed = true; - } + Release(); GC.SuppressFinalize(this); } ~Notebook() { - Dispose(); + Release(); + } + + private void Release() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0) + { + return; + } + var h = _handle; + _handle = IntPtr.Zero; + if (h != IntPtr.Zero) + { + NativeMethods.weaveffi_docs_Notebook_destroy(h); + } } } @@ -555,7 +705,7 @@ namespace WeaveFFI private const string LibName = "weaveffi"; // The ABI revision these bindings were generated against. - internal const uint AbiVersion = 1; + internal const uint AbiVersion = 2; // Runs before the first P/Invoke through this class, so a producer built // for a different ABI revision fails loudly instead of misreading the @@ -582,6 +732,12 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern uint weaveffi_abi_version(); + // Fills a producer-owned error slot with a copy of `message`; callback + // trampolines report a thrown exception through it so no managed + // allocation is ever handed to the producer's allocator. + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_free_string(IntPtr ptr); @@ -594,38 +750,32 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_error_free(IntPtr err); + [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_clone", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_docs_Notebook_clone(IntPtr self); + [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_destroy", CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_docs_Notebook_destroy(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_new", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_docs_Notebook_new(ref WeaveFFIError err); + internal static extern IntPtr weaveffi_docs_Notebook_new(ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_insert", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_docs_Notebook_insert(IntPtr self, IntPtr document_ptr, UIntPtr document_len, ref WeaveFFIError err); + internal static extern int weaveffi_docs_Notebook_insert(IntPtr self, IntPtr document_ptr, UIntPtr document_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_find", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_docs_Notebook_find(IntPtr self, long id, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_docs_Notebook_find(IntPtr self, long id, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_size", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_docs_Notebook_size(IntPtr self, ref WeaveFFIError err); + internal static extern int weaveffi_docs_Notebook_size(IntPtr self, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_Notebook_max_documents", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_docs_Notebook_max_documents(ref WeaveFFIError err); - - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - internal delegate void Cb_weaveffi_docs_OnSaved_fn(long doc_id, int revision, IntPtr context); - - [DllImport(LibName, EntryPoint = "weaveffi_docs_register_saved_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_docs_register_saved_listener(Cb_weaveffi_docs_OnSaved_fn callback, IntPtr context); - - [DllImport(LibName, EntryPoint = "weaveffi_docs_unregister_saved_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_docs_unregister_saved_listener(ulong id); + internal static extern int weaveffi_docs_Notebook_max_documents(ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_create_document", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_docs_create_document(IntPtr title, IntPtr tags_ptr, UIntPtr tags_len, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_docs_create_document(IntPtr title, IntPtr tags_ptr, UIntPtr tags_len, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_docs_get_document", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_docs_get_document(long id, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_docs_get_document(long id, out UIntPtr out_len, ref WeaveFFIError out_err); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void AsyncCb_weaveffi_docs_publish_async(IntPtr context, IntPtr err, byte result); @@ -633,43 +783,13 @@ namespace WeaveFFI [DllImport(LibName, EntryPoint = "weaveffi_docs_publish_async_async", CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_docs_publish_async_async(long id, AsyncCb_weaveffi_docs_publish_async callback, IntPtr context); + [DllImport(LibName, EntryPoint = "weaveffi_docs_watch", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_docs_watch(IntPtr listener_ctx, IntPtr listener_vtable, ref WeaveFFIError out_err); + } public static class Docs { - private static readonly object _listenerLock = new object(); - // Live listener delegates by subscription id. Holding the delegate - // here keeps its native thunk alive until unregistered; without this - // the GC could collect a delegate the producer still calls. - private static readonly Dictionary _listenerRefs = new Dictionary(); - - /// Subscribe to document-saved notifications - /// A subscription id for UnregisterSavedListener(). - public static ulong RegisterSavedListener(Action callback) - { - NativeMethods.Cb_weaveffi_docs_OnSaved_fn trampoline = (doc_id, revision, context) => - { - callback(doc_id, revision); - }; - ulong id; - lock (_listenerLock) - { - id = NativeMethods.weaveffi_docs_register_saved_listener(trampoline, IntPtr.Zero); - _listenerRefs[id] = trampoline; - } - return id; - } - - /// Unregisters a listener previously registered with RegisterSavedListener(). - public static void UnregisterSavedListener(ulong id) - { - NativeMethods.weaveffi_docs_unregister_saved_listener(id); - lock (_listenerLock) - { - _listenerRefs.Remove(id); - } - } - /// /// Create a brand new document. /// @@ -774,6 +894,16 @@ namespace WeaveFFI return await tcs.Task; } + /// Register a listener for saved notifications + /// Receiver of saved notifications + public static void Watch(ISavedListener listener) + { + var err = new WeaveFFIError(); + var listenerCtx = GCHandle.ToIntPtr(GCHandle.Alloc(listener)); + NativeMethods.weaveffi_docs_watch(listenerCtx, WeaveFFIVtable_docs_SavedListener.Pointer, ref err); + WeaveFFIError.Check(err); + } + } } diff --git a/crates/weaveffi-cli/tests/snapshots/dotnet_edge_cases__WeaveFFI_cs.snap b/crates/weaveffi-cli/tests/snapshots/dotnet_edge_cases__WeaveFFI_cs.snap index 419ba86f..e438c25b 100644 --- a/crates/weaveffi-cli/tests/snapshots/dotnet_edge_cases__WeaveFFI_cs.snap +++ b/crates/weaveffi-cli/tests/snapshots/dotnet_edge_cases__WeaveFFI_cs.snap @@ -10,12 +10,26 @@ source: crates/weaveffi-cli/tests/snapshots.rs using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace WeaveFFI { + /// Raised for any WeaveFFI failure that isn't a typed domain + /// error: producer bugs, panics, marshalling failures, and exceptions + /// thrown by a callback-interface implementation. public class WeaveFFIException : Exception { + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + public int Code { get; } public WeaveFFIException(int code, string message) : base(message) @@ -92,6 +106,20 @@ namespace WeaveFFI } } + /// One strong reference to a native object, on its way into + /// a wrapper that adopts it. A distinct type (rather than a bare + /// IntPtr) so the adopting constructor never competes with a + /// public constructor taking an integer. + internal readonly struct WeaveFFIHandle + { + internal readonly IntPtr Value; + + internal WeaveFFIHandle(IntPtr value) + { + Value = value; + } + } + internal static class WeaveFFIHelpers { internal static IntPtr StringToPtr(string? s) @@ -223,6 +251,11 @@ namespace WeaveFFI _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -369,23 +402,22 @@ namespace WeaveFFI return outBuf; } - internal void ExpectEnd() + internal IntPtr ReadObject() { - if (_pos != _data.Length) + var token = ReadU64(); + if (token == 0) { - throw new InvalidOperationException("malformed WeaveFFI value buffer: trailing bytes"); + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); } + return (nint)(nuint)token; } - } - - /// A typed native handle referencing a Leaf. - public readonly struct LeafHandle - { - internal readonly IntPtr Raw; - internal LeafHandle(IntPtr raw) + internal void ExpectEnd() { - Raw = raw; + if (_pos != _data.Length) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: trailing bytes"); + } } } @@ -1011,25 +1043,376 @@ namespace WeaveFFI } } + /// Record carrying objects in every buffered position + public sealed class WidgetHolder + { + public Widget Primary { get; } + + public Widget? Fallback { get; } + + public Widget[] Extras { get; } + + public Dictionary ByName { get; } + + public WidgetHolder(Widget primary, Widget? fallback, Widget[] extras, Dictionary byName) + { + Primary = primary; + Fallback = fallback; + Extras = extras; + ByName = byName; + } + + internal void WriteTo(WeaveFFIBufferWriter writer) + { + writer.WriteObject(Primary.CloneHandle()); + if (Fallback != null) + { + writer.WriteOptionFlag(true); + writer.WriteObject(Fallback!.CloneHandle()); + } + else + { + writer.WriteOptionFlag(false); + } + writer.WriteLen(Extras.Length); + foreach (var item0 in Extras) + { + writer.WriteObject(item0.CloneHandle()); + } + writer.WriteLen(ByName.Count); + foreach (var entry0 in ByName) + { + writer.WriteString(entry0.Key); + writer.WriteObject(entry0.Value.CloneHandle()); + } + } + + internal static WidgetHolder ReadFrom(WeaveFFIBufferReader reader) + { + var fPrimary = new Widget(new WeaveFFIHandle(reader.ReadObject())); + Widget? fFallback = null; + if (reader.ReadOptionFlag()) + { + var fFallbackValue = new Widget(new WeaveFFIHandle(reader.ReadObject())); + fFallback = fFallbackValue; + } + var fExtrasCount = reader.ReadLen(); + var fExtras = new Widget[fExtrasCount]; + for (int i0 = 0; i0 < fExtrasCount; i0++) + { + var fExtrasItem = new Widget(new WeaveFFIHandle(reader.ReadObject())); + fExtras[i0] = fExtrasItem; + } + var fByNameCount = reader.ReadLen(); + var fByName = new Dictionary(fByNameCount); + for (int i0 = 0; i0 < fByNameCount; i0++) + { + var fByNameKey = reader.ReadString(); + var fByNameVal = new Widget(new WeaveFFIHandle(reader.ReadObject())); + fByName[fByNameKey] = fByNameVal; + } + return new WidgetHolder(fPrimary, fFallback, fExtras, fByName); + } + } + + /// Callback interface carrying composite payloads and objects + /// Implement this interface and pass an instance wherever the + /// native library expects it. The library may call any method from any + /// thread until it releases its last reference to the instance. Object + /// arguments are owned by the implementation, which should dispose them + /// when done. An exception thrown by a method is reported to the native + /// caller as . + public interface IBatchListener + { + /// Composite payloads cross as value buffers + void OnBatch(Outer[] items, byte[] raw, Outer? first, Mode mode); + /// Objects cross as strong references the consumer must release + void OnWidget(Widget widget, Widget? fallback, Widget[] children); + void OnTick(); + /// Scalar return travels back through the vtable + bool ShouldContinue(int @class); + /// Enum return travels back as its integer discriminant + Mode PickMode(); + } + + /// The process-wide weaveffi_edge_cases_BatchListener_vtable vtable and the trampolines behind + /// it, adapting an to the C ABI. + internal static unsafe class WeaveFFIVtable_edge_cases_BatchListener + { + [StructLayout(LayoutKind.Sequential)] + private struct Layout + { + public IntPtr on_batch; + public IntPtr on_widget; + public IntPtr on_tick; + public IntPtr should_continue; + public IntPtr pick_mode; + public IntPtr free; + } + + /// Address of the one static vtable for this interface. It is + /// allocated once and never freed, so the producer may keep the pointer + /// for the process lifetime. + internal static readonly IntPtr Pointer = Allocate(); + + private static IntPtr Allocate() + { + var layout = new Layout + { + on_batch = (IntPtr)(delegate* unmanaged[Cdecl])&OnBatchTrampoline, + on_widget = (IntPtr)(delegate* unmanaged[Cdecl])&OnWidgetTrampoline, + on_tick = (IntPtr)(delegate* unmanaged[Cdecl])&OnTickTrampoline, + should_continue = (IntPtr)(delegate* unmanaged[Cdecl])&ShouldContinueTrampoline, + pick_mode = (IntPtr)(delegate* unmanaged[Cdecl])&PickModeTrampoline, + free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline, + }; + var mem = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(layout, mem, false); + return mem; + } + + private static IBatchListener Target(IntPtr ctx) + { + return (IBatchListener)GCHandle.FromIntPtr(ctx).Target!; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnBatchTrampoline(IntPtr ctx, IntPtr items_ptr, UIntPtr items_len, IntPtr raw_ptr, UIntPtr raw_len, IntPtr first_ptr, UIntPtr first_len, int mode, IntPtr out_err) + { + try + { + var impl = Target(ctx); + var arg0Buf = new byte[(int)items_len]; + if (items_ptr != IntPtr.Zero && (int)items_len > 0) Marshal.Copy(items_ptr, arg0Buf, 0, (int)items_len); + var arg0Reader = new WeaveFFIBufferReader(arg0Buf); + var arg0Count = arg0Reader.ReadLen(); + var arg0 = new Outer[arg0Count]; + for (int i0 = 0; i0 < arg0Count; i0++) + { + var arg0Item = Outer.ReadFrom(arg0Reader); + arg0[i0] = arg0Item; + } + arg0Reader.ExpectEnd(); + var arg1 = new byte[(int)raw_len]; + if (raw_ptr != IntPtr.Zero && (int)raw_len > 0) Marshal.Copy(raw_ptr, arg1, 0, (int)raw_len); + var arg2Buf = new byte[(int)first_len]; + if (first_ptr != IntPtr.Zero && (int)first_len > 0) Marshal.Copy(first_ptr, arg2Buf, 0, (int)first_len); + var arg2Reader = new WeaveFFIBufferReader(arg2Buf); + Outer? arg2 = null; + if (arg2Reader.ReadOptionFlag()) + { + var arg2Value = Outer.ReadFrom(arg2Reader); + arg2 = arg2Value; + } + arg2Reader.ExpectEnd(); + impl.OnBatch(arg0, arg1, arg2, (Mode)mode); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnWidgetTrampoline(IntPtr ctx, IntPtr widget, IntPtr fallback, IntPtr children_ptr, UIntPtr children_len, IntPtr out_err) + { + try + { + var impl = Target(ctx); + var arg2Buf = new byte[(int)children_len]; + if (children_ptr != IntPtr.Zero && (int)children_len > 0) Marshal.Copy(children_ptr, arg2Buf, 0, (int)children_len); + var arg2Reader = new WeaveFFIBufferReader(arg2Buf); + var arg2Count = arg2Reader.ReadLen(); + var arg2 = new Widget[arg2Count]; + for (int i0 = 0; i0 < arg2Count; i0++) + { + var arg2Item = new Widget(new WeaveFFIHandle(arg2Reader.ReadObject())); + arg2[i0] = arg2Item; + } + arg2Reader.ExpectEnd(); + impl.OnWidget(new Widget(new WeaveFFIHandle(widget)), fallback == IntPtr.Zero ? null : new Widget(new WeaveFFIHandle(fallback)), arg2); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnTickTrampoline(IntPtr ctx, IntPtr out_err) + { + try + { + var impl = Target(ctx); + impl.OnTick(); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static byte ShouldContinueTrampoline(IntPtr ctx, int @class, IntPtr out_err) + { + try + { + var impl = Target(ctx); + return (byte)(impl.ShouldContinue(@class) ? 1 : 0); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + return default; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static int PickModeTrampoline(IntPtr ctx, IntPtr out_err) + { + try + { + var impl = Target(ctx); + return (int)impl.PickMode(); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + return default; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void FreeTrampoline(IntPtr ctx) + { + GCHandle.FromIntPtr(ctx).Free(); + } + } + + /// Callback interface whose name and methods are keywords + /// Implement this interface and pass an instance wherever the + /// native library expects it. The library may call any method from any + /// thread until it releases its last reference to the instance. Object + /// arguments are owned by the implementation, which should dispose them + /// when done. An exception thrown by a method is reported to the native + /// caller as . + public interface IKeyword + { + void Delete(int self); + int Import(); + } + + /// The process-wide weaveffi_edge_cases_Keyword_vtable vtable and the trampolines behind + /// it, adapting an to the C ABI. + internal static unsafe class WeaveFFIVtable_edge_cases_Keyword + { + [StructLayout(LayoutKind.Sequential)] + private struct Layout + { + public IntPtr delete; + public IntPtr import; + public IntPtr free; + } + + /// Address of the one static vtable for this interface. It is + /// allocated once and never freed, so the producer may keep the pointer + /// for the process lifetime. + internal static readonly IntPtr Pointer = Allocate(); + + private static IntPtr Allocate() + { + var layout = new Layout + { + delete = (IntPtr)(delegate* unmanaged[Cdecl])&DeleteTrampoline, + import = (IntPtr)(delegate* unmanaged[Cdecl])&ImportTrampoline, + free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline, + }; + var mem = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(layout, mem, false); + return mem; + } + + private static IKeyword Target(IntPtr ctx) + { + return (IKeyword)GCHandle.FromIntPtr(ctx).Target!; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void DeleteTrampoline(IntPtr ctx, int self, IntPtr out_err) + { + try + { + var impl = Target(ctx); + impl.Delete(self); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static int ImportTrampoline(IntPtr ctx, IntPtr out_err) + { + try + { + var impl = Target(ctx); + return impl.Import(); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + return default; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void FreeTrampoline(IntPtr ctx) + { + GCHandle.FromIntPtr(ctx).Free(); + } + } + /// Interface with no constructor; instances come from make_widget public class Widget : IDisposable { private IntPtr _handle; - private bool _disposed; + private int _released; - internal Widget(IntPtr handle) + /// Adopts one strong reference to a native object. + internal Widget(WeaveFFIHandle handle) { - _handle = handle; + _handle = handle.Value; + } + + /// The borrowed native pointer for the duration of a call. + /// The wrapper was disposed. + internal IntPtr Handle + { + get + { + var h = _handle; + if (h == IntPtr.Zero) + { + throw new ObjectDisposedException(nameof(Widget)); + } + return h; + } } - internal IntPtr Handle => _handle; + /// Mints a second strong reference the caller owns (for + /// example to write this object into a value buffer). + internal IntPtr CloneHandle() + { + return NativeMethods.weaveffi_edge_cases_Widget_clone(Handle); + } /// Void method that can fail /// Thrown when the call reports a EdgeErrorsError code. public void Refresh() { var err = new WeaveFFIError(); - NativeMethods.weaveffi_edge_cases_Widget_refresh(_handle, ref err); + NativeMethods.weaveffi_edge_cases_Widget_refresh(Handle, ref err); WeaveFFIError.CheckEdgeErrors(err); } @@ -1051,7 +1434,7 @@ namespace WeaveFFI var importPin = GCHandle.Alloc(importBuf, GCHandleType.Pinned); try { - var result = NativeMethods.weaveffi_edge_cases_Widget_rename(_handle, @classPtr, importPin.AddrOfPinnedObject(), (UIntPtr)importBuf.Length, ref err); + var result = NativeMethods.weaveffi_edge_cases_Widget_rename(Handle, @classPtr, importPin.AddrOfPinnedObject(), (UIntPtr)importBuf.Length, ref err); WeaveFFIError.Check(err); var str = Marshal.PtrToStringUTF8(result); NativeMethods.weaveffi_free_string(result); @@ -1105,7 +1488,7 @@ namespace WeaveFFI var ctx = GCHandle.ToIntPtr(gcHandle); try { - NativeMethods.weaveffi_edge_cases_Widget_fetch_async(_handle, id, callback, ctx); + NativeMethods.weaveffi_edge_cases_Widget_fetch_async(Handle, id, callback, ctx); } catch { @@ -1119,26 +1502,39 @@ namespace WeaveFFI public string OldName() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_edge_cases_Widget_old_name(_handle, ref err); + var result = NativeMethods.weaveffi_edge_cases_Widget_old_name(Handle, ref err); WeaveFFIError.Check(err); var str = Marshal.PtrToStringUTF8(result); NativeMethods.weaveffi_free_string(result); return str ?? ""; } + /// Releases this wrapper's reference. The native object is + /// dropped when the producer releases its last reference; other + /// wrappers to the same object stay valid. public void Dispose() { - if (!_disposed) - { - NativeMethods.weaveffi_edge_cases_Widget_destroy(_handle); - _disposed = true; - } + Release(); GC.SuppressFinalize(this); } ~Widget() { - Dispose(); + Release(); + } + + private void Release() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0) + { + return; + } + var h = _handle; + _handle = IntPtr.Zero; + if (h != IntPtr.Zero) + { + NativeMethods.weaveffi_edge_cases_Widget_destroy(h); + } } } @@ -1146,14 +1542,35 @@ namespace WeaveFFI public class Vault : IDisposable { private IntPtr _handle; - private bool _disposed; + private int _released; + + /// Adopts one strong reference to a native object. + internal Vault(WeaveFFIHandle handle) + { + _handle = handle.Value; + } - internal Vault(IntPtr handle) + /// The borrowed native pointer for the duration of a call. + /// The wrapper was disposed. + internal IntPtr Handle { - _handle = handle; + get + { + var h = _handle; + if (h == IntPtr.Zero) + { + throw new ObjectDisposedException(nameof(Vault)); + } + return h; + } } - internal IntPtr Handle => _handle; + /// Mints a second strong reference the caller owns (for + /// example to write this object into a value buffer). + internal IntPtr CloneHandle() + { + return NativeMethods.weaveffi_edge_cases_Vault_clone(Handle); + } /// Fails when the vault is busy /// Thrown when the call reports a EdgeErrorsError code. @@ -1165,7 +1582,7 @@ namespace WeaveFFI { var result = NativeMethods.weaveffi_edge_cases_Vault_open(pathPtr, ref err); WeaveFFIError.CheckEdgeErrors(err); - return new Vault(result); + return new Vault(new WeaveFFIHandle(result)); } finally { @@ -1176,9 +1593,9 @@ namespace WeaveFFI public Widget? Peek() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_edge_cases_Vault_peek(_handle, ref err); + var result = NativeMethods.weaveffi_edge_cases_Vault_peek(Handle, ref err); WeaveFFIError.Check(err); - return result == IntPtr.Zero ? null : new Widget(result); + return result == IntPtr.Zero ? null : new Widget(new WeaveFFIHandle(result)); } /// Throwing static @@ -1191,19 +1608,32 @@ namespace WeaveFFI return result != 0; } + /// Releases this wrapper's reference. The native object is + /// dropped when the producer releases its last reference; other + /// wrappers to the same object stay valid. public void Dispose() { - if (!_disposed) - { - NativeMethods.weaveffi_edge_cases_Vault_destroy(_handle); - _disposed = true; - } + Release(); GC.SuppressFinalize(this); } ~Vault() { - Dispose(); + Release(); + } + + private void Release() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0) + { + return; + } + var h = _handle; + _handle = IntPtr.Zero; + if (h != IntPtr.Zero) + { + NativeMethods.weaveffi_edge_cases_Vault_destroy(h); + } } } @@ -1212,7 +1642,7 @@ namespace WeaveFFI private const string LibName = "weaveffi"; // The ABI revision these bindings were generated against. - internal const uint AbiVersion = 1; + internal const uint AbiVersion = 2; // Runs before the first P/Invoke through this class, so a producer built // for a different ABI revision fails loudly instead of misreading the @@ -1239,6 +1669,12 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern uint weaveffi_abi_version(); + // Fills a producer-owned error slot with a copy of `message`; callback + // trampolines report a thrown exception through it so no managed + // allocation is ever handed to the producer's allocator. + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_free_string(IntPtr ptr); @@ -1251,14 +1687,17 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_error_free(IntPtr err); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Widget_clone", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_Widget_clone(IntPtr self); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Widget_destroy", CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_edge_cases_Widget_destroy(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Widget_refresh", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_edge_cases_Widget_refresh(IntPtr self, ref WeaveFFIError err); + internal static extern void weaveffi_edge_cases_Widget_refresh(IntPtr self, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Widget_rename", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_Widget_rename(IntPtr self, IntPtr @class, IntPtr import_ptr, UIntPtr import_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_Widget_rename(IntPtr self, IntPtr @class, IntPtr import_ptr, UIntPtr import_len, ref WeaveFFIError out_err); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void AsyncCb_weaveffi_edge_cases_Widget_fetch(IntPtr context, IntPtr err, IntPtr result, UIntPtr resultLen); @@ -1267,64 +1706,64 @@ namespace WeaveFFI internal static extern void weaveffi_edge_cases_Widget_fetch_async(IntPtr self, long id, AsyncCb_weaveffi_edge_cases_Widget_fetch callback, IntPtr context); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Widget_old_name", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_Widget_old_name(IntPtr self, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_Widget_old_name(IntPtr self, ref WeaveFFIError out_err); + + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Vault_clone", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_Vault_clone(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Vault_destroy", CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_edge_cases_Vault_destroy(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Vault_open", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_Vault_open(IntPtr path, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_Vault_open(IntPtr path, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Vault_peek", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_Vault_peek(IntPtr self, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_Vault_peek(IntPtr self, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_Vault_probe", CallingConvention = CallingConvention.Cdecl)] - internal static extern byte weaveffi_edge_cases_Vault_probe(ref WeaveFFIError err); + internal static extern byte weaveffi_edge_cases_Vault_probe(ref WeaveFFIError out_err); - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - internal delegate void Cb_weaveffi_edge_cases_OnBatch_fn(IntPtr items_ptr, UIntPtr items_len, IntPtr raw_ptr, UIntPtr raw_len, IntPtr first_ptr, UIntPtr first_len, int mode, IntPtr context); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_noop", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_edge_cases_noop(ref WeaveFFIError out_err); - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - internal delegate void Cb_weaveffi_edge_cases_OnTick_fn(IntPtr context); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_select", CallingConvention = CallingConvention.Cdecl)] + internal static extern int weaveffi_edge_cases_select(int @class, IntPtr from, IntPtr func_ptr, UIntPtr func_len, IntPtr range_ptr, UIntPtr range_len, byte type_, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_register_batch_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_edge_cases_register_batch_listener(Cb_weaveffi_edge_cases_OnBatch_fn callback, IntPtr context); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_nested_optional", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_nested_optional(IntPtr items_ptr, UIntPtr items_len, out UIntPtr out_len, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_unregister_batch_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_edge_cases_unregister_batch_listener(ulong id); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_optional_params", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_optional_params(IntPtr text_ptr, UIntPtr text_len, IntPtr count_ptr, UIntPtr count_len, IntPtr outer_ptr, UIntPtr outer_len, IntPtr tags_ptr, UIntPtr tags_len, IntPtr blob_ptr, UIntPtr blob_len, IntPtr mode_ptr, UIntPtr mode_len, out UIntPtr out_len, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_register_tick_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_edge_cases_register_tick_listener(Cb_weaveffi_edge_cases_OnTick_fn callback, IntPtr context); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_register_batch", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_edge_cases_register_batch(IntPtr listener_ctx, IntPtr listener_vtable, IntPtr keyword_ctx, IntPtr keyword_vtable, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_unregister_tick_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_edge_cases_unregister_tick_listener(ulong id); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_widgets_everywhere", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_widgets_everywhere(IntPtr list_ptr, UIntPtr list_len, IntPtr by_name_ptr, UIntPtr by_name_len, IntPtr sparse_ptr, UIntPtr sparse_len, IntPtr holder_ptr, UIntPtr holder_len, out UIntPtr out_len, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_noop", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_edge_cases_noop(ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_widget_stream", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_edge_cases_widget_stream(ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_select", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_edge_cases_select(int @class, IntPtr from, IntPtr func_ptr, UIntPtr func_len, IntPtr range_ptr, UIntPtr range_len, byte type_, ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_WidgetStreamIterator_next", CallingConvention = CallingConvention.Cdecl)] + internal static extern int weaveffi_edge_cases_WidgetStreamIterator_next(IntPtr iter, out IntPtr out_item, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_nested_optional", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_nested_optional(IntPtr items_ptr, UIntPtr items_len, out UIntPtr out_len, ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_WidgetStreamIterator_destroy", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_edge_cases_WidgetStreamIterator_destroy(IntPtr iter); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_optional_params", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_optional_params(IntPtr text_ptr, UIntPtr text_len, IntPtr count_ptr, UIntPtr count_len, IntPtr outer_ptr, UIntPtr outer_len, IntPtr tags_ptr, UIntPtr tags_len, IntPtr blob_ptr, UIntPtr blob_len, IntPtr mode_ptr, UIntPtr mode_len, out UIntPtr out_len, ref WeaveFFIError err); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate void AsyncCb_weaveffi_edge_cases_async_widget(IntPtr context, IntPtr err, IntPtr result); - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_mutable_params", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_edge_cases_mutable_params(IntPtr text, IntPtr blob_ptr, UIntPtr blob_len, ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_async_widget_async", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_edge_cases_async_widget_async(AsyncCb_weaveffi_edge_cases_async_widget callback, IntPtr context); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_make_widget", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_make_widget(IntPtr label, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_make_widget(IntPtr label, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_find_widget", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_find_widget(long id, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_find_widget(long id, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_inspect_widget", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_inspect_widget(IntPtr widget, IntPtr fallback, ref WeaveFFIError err); - - [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_typed_handle_param", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_typed_handle_param(IntPtr token, ulong raw, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_inspect_widget(IntPtr widget, IntPtr fallback, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_string_stream", CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr weaveffi_edge_cases_string_stream(ref WeaveFFIError out_err); @@ -1396,96 +1835,15 @@ namespace WeaveFFI internal static extern void weaveffi_edge_cases_async_keywords_async(IntPtr @class, int import, AsyncCb_weaveffi_edge_cases_async_keywords callback, IntPtr context); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_keywords_roundtrip", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_keywords_roundtrip(IntPtr value_ptr, UIntPtr value_len, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_keywords_roundtrip(IntPtr value_ptr, UIntPtr value_len, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_edge_cases_legacy_value", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_edge_cases_legacy_value(int value, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_edge_cases_legacy_value(int value, out UIntPtr out_len, ref WeaveFFIError out_err); } public static class EdgeCases { - private static readonly object _listenerLock = new object(); - // Live listener delegates by subscription id. Holding the delegate - // here keeps its native thunk alive until unregistered; without this - // the GC could collect a delegate the producer still calls. - private static readonly Dictionary _listenerRefs = new Dictionary(); - - /// A subscription id for UnregisterBatchListener(). - public static ulong RegisterBatchListener(Action callback) - { - NativeMethods.Cb_weaveffi_edge_cases_OnBatch_fn trampoline = (items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, context) => - { - var arg0Buf = new byte[(int)items_len]; - if (items_ptr != IntPtr.Zero && (int)items_len > 0) Marshal.Copy(items_ptr, arg0Buf, 0, (int)items_len); - var arg0Reader = new WeaveFFIBufferReader(arg0Buf); - var arg0Count = arg0Reader.ReadLen(); - var arg0 = new Outer[arg0Count]; - for (int i0 = 0; i0 < arg0Count; i0++) - { - var arg0Item = Outer.ReadFrom(arg0Reader); - arg0[i0] = arg0Item; - } - arg0Reader.ExpectEnd(); - var arg1 = new byte[(int)raw_len]; - if (raw_ptr != IntPtr.Zero && (int)raw_len > 0) Marshal.Copy(raw_ptr, arg1, 0, (int)raw_len); - var arg2Buf = new byte[(int)first_len]; - if (first_ptr != IntPtr.Zero && (int)first_len > 0) Marshal.Copy(first_ptr, arg2Buf, 0, (int)first_len); - var arg2Reader = new WeaveFFIBufferReader(arg2Buf); - Outer? arg2 = null; - if (arg2Reader.ReadOptionFlag()) - { - var arg2Value = Outer.ReadFrom(arg2Reader); - arg2 = arg2Value; - } - arg2Reader.ExpectEnd(); - callback(arg0, arg1, arg2, (Mode)mode); - }; - ulong id; - lock (_listenerLock) - { - id = NativeMethods.weaveffi_edge_cases_register_batch_listener(trampoline, IntPtr.Zero); - _listenerRefs[id] = trampoline; - } - return id; - } - - /// Unregisters a listener previously registered with RegisterBatchListener(). - public static void UnregisterBatchListener(ulong id) - { - NativeMethods.weaveffi_edge_cases_unregister_batch_listener(id); - lock (_listenerLock) - { - _listenerRefs.Remove(id); - } - } - - /// A subscription id for UnregisterTickListener(). - public static ulong RegisterTickListener(Action callback) - { - NativeMethods.Cb_weaveffi_edge_cases_OnTick_fn trampoline = (context) => - { - callback(); - }; - ulong id; - lock (_listenerLock) - { - id = NativeMethods.weaveffi_edge_cases_register_tick_listener(trampoline, IntPtr.Zero); - _listenerRefs[id] = trampoline; - } - return id; - } - - /// Unregisters a listener previously registered with RegisterTickListener(). - public static void UnregisterTickListener(ulong id) - { - NativeMethods.weaveffi_edge_cases_unregister_tick_listener(id); - lock (_listenerLock) - { - _listenerRefs.Remove(id); - } - } - /// No parameters and no result public static void Noop() { @@ -1702,22 +2060,157 @@ namespace WeaveFFI } } - /// Write-back parameters - public static void MutableParams(string text, byte[] blob) + /// Two callback interface parameters on one function + public static void RegisterBatch(IBatchListener listener, IKeyword keyword) { var err = new WeaveFFIError(); - var textPtr = Marshal.StringToCoTaskMemUTF8(text); - var blobPin = GCHandle.Alloc(blob, GCHandleType.Pinned); + var listenerCtx = GCHandle.ToIntPtr(GCHandle.Alloc(listener)); + var keywordCtx = GCHandle.ToIntPtr(GCHandle.Alloc(keyword)); + NativeMethods.weaveffi_edge_cases_register_batch(listenerCtx, WeaveFFIVtable_edge_cases_BatchListener.Pointer, keywordCtx, WeaveFFIVtable_edge_cases_Keyword.Pointer, ref err); + WeaveFFIError.Check(err); + } + + /// Objects in every buffered position + public static WidgetHolder WidgetsEverywhere(Widget[] list, Dictionary byName, Widget?[] sparse, WidgetHolder holder) + { + var err = new WeaveFFIError(); + var listWriter = new WeaveFFIBufferWriter(); + listWriter.WriteLen(list.Length); + foreach (var item0 in list) + { + listWriter.WriteObject(item0.CloneHandle()); + } + var listBuf = listWriter.ToArray(); + var listPin = GCHandle.Alloc(listBuf, GCHandleType.Pinned); + var byNameWriter = new WeaveFFIBufferWriter(); + byNameWriter.WriteLen(byName.Count); + foreach (var entry0 in byName) + { + byNameWriter.WriteString(entry0.Key); + byNameWriter.WriteObject(entry0.Value.CloneHandle()); + } + var byNameBuf = byNameWriter.ToArray(); + var byNamePin = GCHandle.Alloc(byNameBuf, GCHandleType.Pinned); + var sparseWriter = new WeaveFFIBufferWriter(); + sparseWriter.WriteLen(sparse.Length); + foreach (var item0 in sparse) + { + if (item0 != null) + { + sparseWriter.WriteOptionFlag(true); + sparseWriter.WriteObject(item0!.CloneHandle()); + } + else + { + sparseWriter.WriteOptionFlag(false); + } + } + var sparseBuf = sparseWriter.ToArray(); + var sparsePin = GCHandle.Alloc(sparseBuf, GCHandleType.Pinned); + var holderWriter = new WeaveFFIBufferWriter(); + holder.WriteTo(holderWriter); + var holderBuf = holderWriter.ToArray(); + var holderPin = GCHandle.Alloc(holderBuf, GCHandleType.Pinned); try { - NativeMethods.weaveffi_edge_cases_mutable_params(textPtr, blobPin.AddrOfPinnedObject(), (UIntPtr)blob.Length, ref err); + var result = NativeMethods.weaveffi_edge_cases_widgets_everywhere(listPin.AddrOfPinnedObject(), (UIntPtr)listBuf.Length, byNamePin.AddrOfPinnedObject(), (UIntPtr)byNameBuf.Length, sparsePin.AddrOfPinnedObject(), (UIntPtr)sparseBuf.Length, holderPin.AddrOfPinnedObject(), (UIntPtr)holderBuf.Length, out var outLen, ref err); WeaveFFIError.Check(err); + var resultBuf = new byte[(int)outLen]; + if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen); + NativeMethods.weaveffi_free_bytes(result, outLen); + var decodedReader = new WeaveFFIBufferReader(resultBuf); + var decoded = WidgetHolder.ReadFrom(decodedReader); + decodedReader.ExpectEnd(); + return decoded; } finally { - Marshal.FreeCoTaskMem(textPtr); - blobPin.Free(); + listPin.Free(); + byNamePin.Free(); + sparsePin.Free(); + holderPin.Free(); + } + } + + /// Iterator of objects + /// Streams lazily: each element is pulled from the native + /// iterator on demand, and the iterator is destroyed when enumeration + /// completes or the enumerator is disposed (a foreach disposes it + /// automatically, including on early exit). The returned sequence can be + /// enumerated only once. + public static IEnumerable WidgetStream() + { + var err = new WeaveFFIError(); + var iter = NativeMethods.weaveffi_edge_cases_widget_stream(ref err); + WeaveFFIError.Check(err); + return new WeaveFFIOnceEnumerable(EnumerateWidgetStream(iter)); + } + + private static IEnumerator EnumerateWidgetStream(IntPtr iter) + { + try + { + while (true) + { + var iterErr = new WeaveFFIError(); + if (NativeMethods.weaveffi_edge_cases_WidgetStreamIterator_next(iter, out var out_item, ref iterErr) == 0) + { + WeaveFFIError.Check(iterErr); + yield break; + } + WeaveFFIError.Check(iterErr); + yield return new Widget(new WeaveFFIHandle(out_item)); + } + } + finally + { + NativeMethods.weaveffi_edge_cases_WidgetStreamIterator_destroy(iter); + } + } + + /// Async returning an object + /// Thrown when the call reports a EdgeErrorsError code. + public static async Task AsyncWidget() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + NativeMethods.AsyncCb_weaveffi_edge_cases_async_widget callback = (context, err, result) => + { + try + { + if (err != IntPtr.Zero) + { + var wErr = Marshal.PtrToStructure(err); + if (wErr.Code != 0) + { + var msg = Marshal.PtrToStringUTF8(wErr.Message) ?? ""; + var payload = WeaveFFIError.CopyPayload(wErr); + NativeMethods.weaveffi_error_free(err); + tcs.SetException(EdgeErrorsException.FromCode(wErr.Code, msg, payload)); + return; + } + } + tcs.SetResult(new Widget(new WeaveFFIHandle(result))); + } + finally + { + if (context != IntPtr.Zero) + { + GCHandle.FromIntPtr(context).Free(); + } + } + }; + var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal); + var ctx = GCHandle.ToIntPtr(gcHandle); + try + { + NativeMethods.weaveffi_edge_cases_async_widget_async(callback, ctx); + } + catch + { + if (gcHandle.IsAllocated) gcHandle.Free(); + throw; } + return await tcs.Task; } /// Factory for an interface with no constructor @@ -1729,7 +2222,7 @@ namespace WeaveFFI { var result = NativeMethods.weaveffi_edge_cases_make_widget(labelPtr, ref err); WeaveFFIError.Check(err); - return new Widget(result); + return new Widget(new WeaveFFIHandle(result)); } finally { @@ -1744,7 +2237,7 @@ namespace WeaveFFI var err = new WeaveFFIError(); var result = NativeMethods.weaveffi_edge_cases_find_widget(id, ref err); WeaveFFIError.CheckEdgeErrors(err); - return result == IntPtr.Zero ? null : new Widget(result); + return result == IntPtr.Zero ? null : new Widget(new WeaveFFIHandle(result)); } /// Interface parameter, including an optional one @@ -1758,14 +2251,6 @@ namespace WeaveFFI return str ?? ""; } - public static LeafHandle TypedHandleParam(LeafHandle token, ulong raw) - { - var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_edge_cases_typed_handle_param(token.Raw, raw, ref err); - WeaveFFIError.Check(err); - return new LeafHandle(result); - } - /// Iterator of strings /// Streams lazily: each element is pulled from the native /// iterator on demand, and the iterator is destroyed when enumeration diff --git a/crates/weaveffi-cli/tests/snapshots/dotnet_kitchen_sink__WeaveFFI_cs.snap b/crates/weaveffi-cli/tests/snapshots/dotnet_kitchen_sink__WeaveFFI_cs.snap index 3e5e36a4..0df40fae 100644 --- a/crates/weaveffi-cli/tests/snapshots/dotnet_kitchen_sink__WeaveFFI_cs.snap +++ b/crates/weaveffi-cli/tests/snapshots/dotnet_kitchen_sink__WeaveFFI_cs.snap @@ -10,12 +10,26 @@ source: crates/weaveffi-cli/tests/snapshots.rs using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace WeaveFFI { + /// Raised for any WeaveFFI failure that isn't a typed domain + /// error: producer bugs, panics, marshalling failures, and exceptions + /// thrown by a callback-interface implementation. public class WeaveFFIException : Exception { + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + public int Code { get; } public WeaveFFIException(int code, string message) : base(message) @@ -96,6 +110,20 @@ namespace WeaveFFI } } + /// One strong reference to a native object, on its way into + /// a wrapper that adopts it. A distinct type (rather than a bare + /// IntPtr) so the adopting constructor never competes with a + /// public constructor taking an integer. + internal readonly struct WeaveFFIHandle + { + internal readonly IntPtr Value; + + internal WeaveFFIHandle(IntPtr value) + { + Value = value; + } + } + internal static class WeaveFFIHelpers { internal static IntPtr StringToPtr(string? s) @@ -227,6 +255,11 @@ namespace WeaveFFI _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -373,23 +406,22 @@ namespace WeaveFFI return outBuf; } - internal void ExpectEnd() + internal IntPtr ReadObject() { - if (_pos != _data.Length) + var token = ReadU64(); + if (token == 0) { - throw new InvalidOperationException("malformed WeaveFFI value buffer: trailing bytes"); + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); } + return (nint)(nuint)token; } - } - /// A typed native handle referencing a Token. - public readonly struct TokenHandle - { - internal readonly IntPtr Raw; - - internal TokenHandle(IntPtr raw) + internal void ExpectEnd() { - Raw = raw; + if (_pos != _data.Length) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: trailing bytes"); + } } } @@ -493,7 +525,10 @@ namespace WeaveFFI public Priority Priority { get; } - public Item(long id, string name, int count, bool enabled, double ratio, uint nick, byte[] payload, string[] tags, Dictionary attrs, long? parent, Token token, Priority priority) + /// An optional object inside a record + public Gadget? Gadget { get; } + + public Item(long id, string name, int count, bool enabled, double ratio, uint nick, byte[] payload, string[] tags, Dictionary attrs, long? parent, Token token, Priority priority, Gadget? gadget) { Id = id; Name = name; @@ -507,6 +542,7 @@ namespace WeaveFFI Parent = parent; Token = token; Priority = priority; + Gadget = gadget; } internal void WriteTo(WeaveFFIBufferWriter writer) @@ -540,6 +576,15 @@ namespace WeaveFFI } Token.WriteTo(writer); writer.WriteI32((int)Priority); + if (Gadget != null) + { + writer.WriteOptionFlag(true); + writer.WriteObject(Gadget!.CloneHandle()); + } + else + { + writer.WriteOptionFlag(false); + } } internal static Item ReadFrom(WeaveFFIBufferReader reader) @@ -574,7 +619,104 @@ namespace WeaveFFI } var fToken = Token.ReadFrom(reader); var fPriority = (Priority)reader.ReadI32(); - return new Item(fId, fName, fCount, fEnabled, fRatio, fNick, fPayload, fTags, fAttrs, fParent, fToken, fPriority); + Gadget? fGadget = null; + if (reader.ReadOptionFlag()) + { + var fGadgetValue = new Gadget(new WeaveFFIHandle(reader.ReadObject())); + fGadget = fGadgetValue; + } + return new Item(fId, fName, fCount, fEnabled, fRatio, fNick, fPayload, fTags, fAttrs, fParent, fToken, fPriority, fGadget); + } + } + + /// Consumer-implemented observer of ready items + /// Implement this interface and pass an instance wherever the + /// native library expects it. The library may call any method from any + /// thread until it releases its last reference to the instance. Object + /// arguments are owned by the implementation, which should dispose them + /// when done. An exception thrown by a method is reported to the native + /// caller as . + public interface IReadyListener + { + /// Fires when an item is ready + void OnReady(int code, string msg); + /// Receives the item itself and says whether to keep listening + bool OnItem(Item item, Gadget gadget); + } + + /// The process-wide weaveffi_kitchen_ReadyListener_vtable vtable and the trampolines behind + /// it, adapting an to the C ABI. + internal static unsafe class WeaveFFIVtable_kitchen_ReadyListener + { + [StructLayout(LayoutKind.Sequential)] + private struct Layout + { + public IntPtr on_ready; + public IntPtr on_item; + public IntPtr free; + } + + /// Address of the one static vtable for this interface. It is + /// allocated once and never freed, so the producer may keep the pointer + /// for the process lifetime. + internal static readonly IntPtr Pointer = Allocate(); + + private static IntPtr Allocate() + { + var layout = new Layout + { + on_ready = (IntPtr)(delegate* unmanaged[Cdecl])&OnReadyTrampoline, + on_item = (IntPtr)(delegate* unmanaged[Cdecl])&OnItemTrampoline, + free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline, + }; + var mem = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(layout, mem, false); + return mem; + } + + private static IReadyListener Target(IntPtr ctx) + { + return (IReadyListener)GCHandle.FromIntPtr(ctx).Target!; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnReadyTrampoline(IntPtr ctx, int code, IntPtr msg, IntPtr out_err) + { + try + { + var impl = Target(ctx); + impl.OnReady(code, Marshal.PtrToStringUTF8(msg) ?? ""); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static byte OnItemTrampoline(IntPtr ctx, IntPtr item_ptr, UIntPtr item_len, IntPtr gadget, IntPtr out_err) + { + try + { + var impl = Target(ctx); + var arg0Buf = new byte[(int)item_len]; + if (item_ptr != IntPtr.Zero && (int)item_len > 0) Marshal.Copy(item_ptr, arg0Buf, 0, (int)item_len); + var arg0Reader = new WeaveFFIBufferReader(arg0Buf); + var arg0 = Item.ReadFrom(arg0Reader); + arg0Reader.ExpectEnd(); + return (byte)(impl.OnItem(arg0, new Gadget(new WeaveFFIHandle(gadget))) ? 1 : 0); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + return default; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void FreeTrampoline(IntPtr ctx) + { + GCHandle.FromIntPtr(ctx).Free(); } } @@ -582,14 +724,35 @@ namespace WeaveFFI public class Gadget : IDisposable { private IntPtr _handle; - private bool _disposed; + private int _released; - internal Gadget(IntPtr handle) + /// Adopts one strong reference to a native object. + internal Gadget(WeaveFFIHandle handle) { - _handle = handle; + _handle = handle.Value; } - internal IntPtr Handle => _handle; + /// The borrowed native pointer for the duration of a call. + /// The wrapper was disposed. + internal IntPtr Handle + { + get + { + var h = _handle; + if (h == IntPtr.Zero) + { + throw new ObjectDisposedException(nameof(Gadget)); + } + return h; + } + } + + /// Mints a second strong reference the caller owns (for + /// example to write this object into a value buffer). + internal IntPtr CloneHandle() + { + return NativeMethods.weaveffi_kitchen_Gadget_clone(Handle); + } /// Create a gadget with the given id public Gadget(long id) @@ -604,7 +767,7 @@ namespace WeaveFFI public string Describe() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_kitchen_Gadget_describe(_handle, ref err); + var result = NativeMethods.weaveffi_kitchen_Gadget_describe(Handle, ref err); WeaveFFIError.Check(err); var str = Marshal.PtrToStringUTF8(result); NativeMethods.weaveffi_free_string(result); @@ -616,7 +779,7 @@ namespace WeaveFFI public int Poke(int times) { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_kitchen_Gadget_poke(_handle, times, ref err); + var result = NativeMethods.weaveffi_kitchen_Gadget_poke(Handle, times, ref err); WeaveFFIError.CheckKitchenErrors(err); return result; } @@ -632,19 +795,32 @@ namespace WeaveFFI return str ?? ""; } + /// Releases this wrapper's reference. The native object is + /// dropped when the producer releases its last reference; other + /// wrappers to the same object stay valid. public void Dispose() { - if (!_disposed) - { - NativeMethods.weaveffi_kitchen_Gadget_destroy(_handle); - _disposed = true; - } + Release(); GC.SuppressFinalize(this); } ~Gadget() { - Dispose(); + Release(); + } + + private void Release() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0) + { + return; + } + var h = _handle; + _handle = IntPtr.Zero; + if (h != IntPtr.Zero) + { + NativeMethods.weaveffi_kitchen_Gadget_destroy(h); + } } } @@ -653,7 +829,7 @@ namespace WeaveFFI private const string LibName = "weaveffi"; // The ABI revision these bindings were generated against. - internal const uint AbiVersion = 1; + internal const uint AbiVersion = 2; // Runs before the first P/Invoke through this class, so a producer built // for a different ABI revision fails loudly instead of misreading the @@ -680,6 +856,12 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern uint weaveffi_abi_version(); + // Fills a producer-owned error slot with a copy of `message`; callback + // trampolines report a thrown exception through it so no managed + // allocation is ever handed to the producer's allocator. + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_free_string(IntPtr ptr); @@ -693,73 +875,73 @@ namespace WeaveFFI internal static extern void weaveffi_error_free(IntPtr err); [DllImport(LibName, EntryPoint = "weaveffi_shared_ping", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_shared_ping(ref WeaveFFIError err); + internal static extern IntPtr weaveffi_shared_ping(ref WeaveFFIError out_err); + + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_clone", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_kitchen_Gadget_clone(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_destroy", CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_kitchen_Gadget_destroy(IntPtr self); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_new", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_Gadget_new(long id, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_Gadget_new(long id, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_describe", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_Gadget_describe(IntPtr self, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_Gadget_describe(IntPtr self, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_poke", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_kitchen_Gadget_poke(IntPtr self, int times, ref WeaveFFIError err); + internal static extern int weaveffi_kitchen_Gadget_poke(IntPtr self, int times, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_Gadget_version", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_Gadget_version(ref WeaveFFIError err); - - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - internal delegate void Cb_weaveffi_kitchen_OnReady_fn(int code, IntPtr msg, IntPtr context); - - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_register_ready_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_kitchen_register_ready_listener(Cb_weaveffi_kitchen_OnReady_fn callback, IntPtr context); - - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_unregister_ready_listener", CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_kitchen_unregister_ready_listener(ulong id); + internal static extern IntPtr weaveffi_kitchen_Gadget_version(ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_bool_id", CallingConvention = CallingConvention.Cdecl)] - internal static extern byte weaveffi_kitchen_bool_id(byte x, ref WeaveFFIError err); + internal static extern byte weaveffi_kitchen_bool_id(byte x, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_i32_id", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_kitchen_i32_id(int x, ref WeaveFFIError err); + internal static extern int weaveffi_kitchen_i32_id(int x, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_u32_id", CallingConvention = CallingConvention.Cdecl)] - internal static extern uint weaveffi_kitchen_u32_id(uint x, ref WeaveFFIError err); + internal static extern uint weaveffi_kitchen_u32_id(uint x, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_i64_id", CallingConvention = CallingConvention.Cdecl)] - internal static extern long weaveffi_kitchen_i64_id(long x, ref WeaveFFIError err); + internal static extern long weaveffi_kitchen_i64_id(long x, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_f64_id", CallingConvention = CallingConvention.Cdecl)] - internal static extern double weaveffi_kitchen_f64_id(double x, ref WeaveFFIError err); + internal static extern double weaveffi_kitchen_f64_id(double x, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_echo_string", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_echo_string(IntPtr s, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_echo_string(IntPtr s, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_echo_bytes", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_echo_bytes(IntPtr b_ptr, UIntPtr b_len, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_echo_bytes(IntPtr b_ptr, UIntPtr b_len, out UIntPtr out_len, ref WeaveFFIError out_err); + + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_subscribe", CallingConvention = CallingConvention.Cdecl)] + internal static extern int weaveffi_kitchen_subscribe(IntPtr listener_ctx, IntPtr listener_vtable, ref WeaveFFIError out_err); + + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_maybe_gadget", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_kitchen_maybe_gadget(IntPtr fallback, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_echo_borrowed_str", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_echo_borrowed_str(IntPtr s, ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_gadgets", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_kitchen_gadgets(out UIntPtr out_len, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_echo_borrowed_bytes", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_echo_borrowed_bytes(IntPtr b_ptr, UIntPtr b_len, out UIntPtr out_len, ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_stream_gadgets", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr weaveffi_kitchen_stream_gadgets(ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_open_handle", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_kitchen_open_handle(ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_StreamGadgetsIterator_next", CallingConvention = CallingConvention.Cdecl)] + internal static extern int weaveffi_kitchen_StreamGadgetsIterator_next(IntPtr iter, out IntPtr out_item, ref WeaveFFIError out_err); - [DllImport(LibName, EntryPoint = "weaveffi_kitchen_open_typed_handle", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_open_typed_handle(ref WeaveFFIError err); + [DllImport(LibName, EntryPoint = "weaveffi_kitchen_StreamGadgetsIterator_destroy", CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_kitchen_StreamGadgetsIterator_destroy(IntPtr iter); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_maybe_item", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_maybe_item(long id, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_maybe_item(long id, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_list_items", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_list_items(out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_list_items(out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_get_attrs", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_get_attrs(out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_get_attrs(out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_stream_items", CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr weaveffi_kitchen_stream_items(ref WeaveFFIError out_err); @@ -771,7 +953,7 @@ namespace WeaveFFI internal static extern void weaveffi_kitchen_StreamItemsIterator_destroy(IntPtr iter); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_cross_module_token", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_cross_module_token(out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_cross_module_token(out UIntPtr out_len, ref WeaveFFIError out_err); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void AsyncCb_weaveffi_kitchen_do_async(IntPtr context, IntPtr err, IntPtr result); @@ -786,13 +968,13 @@ namespace WeaveFFI internal static extern void weaveffi_kitchen_do_cancellable_async(IntPtr input, IntPtr cancel_token, AsyncCb_weaveffi_kitchen_do_cancellable callback, IntPtr context); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_legacy_op", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_kitchen_legacy_op(ref WeaveFFIError err); + internal static extern int weaveffi_kitchen_legacy_op(ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_new_op", CallingConvention = CallingConvention.Cdecl)] - internal static extern int weaveffi_kitchen_new_op(ref WeaveFFIError err); + internal static extern int weaveffi_kitchen_new_op(ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_kitchen_nested_hello", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_kitchen_nested_hello(ref WeaveFFIError err); + internal static extern IntPtr weaveffi_kitchen_nested_hello(ref WeaveFFIError out_err); } @@ -813,39 +995,6 @@ namespace WeaveFFI public static class Kitchen { - private static readonly object _listenerLock = new object(); - // Live listener delegates by subscription id. Holding the delegate - // here keeps its native thunk alive until unregistered; without this - // the GC could collect a delegate the producer still calls. - private static readonly Dictionary _listenerRefs = new Dictionary(); - - /// Subscribe to OnReady events - /// A subscription id for UnregisterReadyListener(). - public static ulong RegisterReadyListener(Action callback) - { - NativeMethods.Cb_weaveffi_kitchen_OnReady_fn trampoline = (code, msg, context) => - { - callback(code, Marshal.PtrToStringUTF8(msg) ?? ""); - }; - ulong id; - lock (_listenerLock) - { - id = NativeMethods.weaveffi_kitchen_register_ready_listener(trampoline, IntPtr.Zero); - _listenerRefs[id] = trampoline; - } - return id; - } - - /// Unregisters a listener previously registered with RegisterReadyListener(). - public static void UnregisterReadyListener(ulong id) - { - NativeMethods.weaveffi_kitchen_unregister_ready_listener(id); - lock (_listenerLock) - { - _listenerRefs.Remove(id); - } - } - /// Identity for bool public static bool BoolId(bool x) { @@ -925,62 +1074,80 @@ namespace WeaveFFI } } - /// Borrowed string parameter - public static string EchoBorrowedStr(string s) + /// Register a listener, returning how many are attached + public static int Subscribe(IReadyListener listener) { var err = new WeaveFFIError(); - var sPtr = Marshal.StringToCoTaskMemUTF8(s); - try - { - var result = NativeMethods.weaveffi_kitchen_echo_borrowed_str(sPtr, ref err); - WeaveFFIError.Check(err); - var str = Marshal.PtrToStringUTF8(result); - NativeMethods.weaveffi_free_string(result); - return str ?? ""; - } - finally - { - Marshal.FreeCoTaskMem(sPtr); - } + var listenerCtx = GCHandle.ToIntPtr(GCHandle.Alloc(listener)); + var result = NativeMethods.weaveffi_kitchen_subscribe(listenerCtx, WeaveFFIVtable_kitchen_ReadyListener.Pointer, ref err); + WeaveFFIError.Check(err); + return result; } - /// Borrowed bytes parameter - public static byte[] EchoBorrowedBytes(byte[] b) + /// Optional object in both directions + public static Gadget? MaybeGadget(Gadget? fallback) { var err = new WeaveFFIError(); - var bPin = GCHandle.Alloc(b, GCHandleType.Pinned); - try - { - var result = NativeMethods.weaveffi_kitchen_echo_borrowed_bytes(bPin.AddrOfPinnedObject(), (UIntPtr)b.Length, out var outLen, ref err); - WeaveFFIError.Check(err); - if (result == IntPtr.Zero) return Array.Empty(); - var arr = new byte[(int)outLen]; - Marshal.Copy(result, arr, 0, (int)outLen); - NativeMethods.weaveffi_free_bytes(result, outLen); - return arr; - } - finally - { - bPin.Free(); - } + var result = NativeMethods.weaveffi_kitchen_maybe_gadget(fallback?.Handle ?? IntPtr.Zero, ref err); + WeaveFFIError.Check(err); + return result == IntPtr.Zero ? null : new Gadget(new WeaveFFIHandle(result)); } - /// Returns an opaque handle - public static ulong OpenHandle() + /// A list of objects + public static Gadget[] Gadgets() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_kitchen_open_handle(ref err); + var result = NativeMethods.weaveffi_kitchen_gadgets(out var outLen, ref err); WeaveFFIError.Check(err); - return result; + var resultBuf = new byte[(int)outLen]; + if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen); + NativeMethods.weaveffi_free_bytes(result, outLen); + var decodedReader = new WeaveFFIBufferReader(resultBuf); + var decodedCount = decodedReader.ReadLen(); + var decoded = new Gadget[decodedCount]; + for (int i0 = 0; i0 < decodedCount; i0++) + { + var decodedItem = new Gadget(new WeaveFFIHandle(decodedReader.ReadObject())); + decoded[i0] = decodedItem; + } + decodedReader.ExpectEnd(); + return decoded; } - /// Returns a typed handle - public static TokenHandle OpenTypedHandle() + /// An iterator over objects + /// Streams lazily: each element is pulled from the native + /// iterator on demand, and the iterator is destroyed when enumeration + /// completes or the enumerator is disposed (a foreach disposes it + /// automatically, including on early exit). The returned sequence can be + /// enumerated only once. + public static IEnumerable StreamGadgets() { var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_kitchen_open_typed_handle(ref err); + var iter = NativeMethods.weaveffi_kitchen_stream_gadgets(ref err); WeaveFFIError.Check(err); - return new TokenHandle(result); + return new WeaveFFIOnceEnumerable(EnumerateStreamGadgets(iter)); + } + + private static IEnumerator EnumerateStreamGadgets(IntPtr iter) + { + try + { + while (true) + { + var iterErr = new WeaveFFIError(); + if (NativeMethods.weaveffi_kitchen_StreamGadgetsIterator_next(iter, out var out_item, ref iterErr) == 0) + { + WeaveFFIError.Check(iterErr); + yield break; + } + WeaveFFIError.Check(iterErr); + yield return new Gadget(new WeaveFFIHandle(out_item)); + } + } + finally + { + NativeMethods.weaveffi_kitchen_StreamGadgetsIterator_destroy(iter); + } } /// Optional struct return diff --git a/crates/weaveffi-cli/tests/snapshots/dotnet_nested_modules__WeaveFFI_cs.snap b/crates/weaveffi-cli/tests/snapshots/dotnet_nested_modules__WeaveFFI_cs.snap index 404b351b..56741e7d 100644 --- a/crates/weaveffi-cli/tests/snapshots/dotnet_nested_modules__WeaveFFI_cs.snap +++ b/crates/weaveffi-cli/tests/snapshots/dotnet_nested_modules__WeaveFFI_cs.snap @@ -13,8 +13,21 @@ using System.Runtime.InteropServices; namespace WeaveFFI { + /// Raised for any WeaveFFI failure that isn't a typed domain + /// error: producer bugs, panics, marshalling failures, and exceptions + /// thrown by a callback-interface implementation. public class WeaveFFIException : Exception { + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + public int Code { get; } public WeaveFFIException(int code, string message) : base(message) @@ -91,6 +104,20 @@ namespace WeaveFFI } } + /// One strong reference to a native object, on its way into + /// a wrapper that adopts it. A distinct type (rather than a bare + /// IntPtr) so the adopting constructor never competes with a + /// public constructor taking an integer. + internal readonly struct WeaveFFIHandle + { + internal readonly IntPtr Value; + + internal WeaveFFIHandle(IntPtr value) + { + Value = value; + } + } + internal static class WeaveFFIHelpers { internal static IntPtr StringToPtr(string? s) @@ -222,6 +249,11 @@ namespace WeaveFFI _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -368,6 +400,16 @@ namespace WeaveFFI return outBuf; } + internal IntPtr ReadObject() + { + var token = ReadU64(); + if (token == 0) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); + } + return (nint)(nuint)token; + } + internal void ExpectEnd() { if (_pos != _data.Length) @@ -473,7 +515,7 @@ namespace WeaveFFI private const string LibName = "weaveffi"; // The ABI revision these bindings were generated against. - internal const uint AbiVersion = 1; + internal const uint AbiVersion = 2; // Runs before the first P/Invoke through this class, so a producer built // for a different ABI revision fails loudly instead of misreading the @@ -500,6 +542,12 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern uint weaveffi_abi_version(); + // Fills a producer-owned error slot with a copy of `message`; callback + // trampolines report a thrown exception through it so no managed + // allocation is ever handed to the producer's allocator. + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_free_string(IntPtr ptr); @@ -513,16 +561,16 @@ namespace WeaveFFI internal static extern void weaveffi_error_free(IntPtr err); [DllImport(LibName, EntryPoint = "weaveffi_graphics_new_canvas", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_graphics_new_canvas(int width, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_graphics_new_canvas(int width, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_graphics_shapes_make_shape", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_graphics_shapes_make_shape(int sides, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_graphics_shapes_make_shape(int sides, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_graphics_shapes_describe", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_graphics_shapes_describe(out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_graphics_shapes_describe(out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_graphics_shapes_geometry_compute", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_graphics_shapes_geometry_compute(IntPtr shape_ptr, UIntPtr shape_len, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_graphics_shapes_geometry_compute(IntPtr shape_ptr, UIntPtr shape_len, out UIntPtr out_len, ref WeaveFFIError out_err); } diff --git a/crates/weaveffi-cli/tests/snapshots/dotnet_shapes__WeaveFFI_cs.snap b/crates/weaveffi-cli/tests/snapshots/dotnet_shapes__WeaveFFI_cs.snap index 299bdc8e..7a65341c 100644 --- a/crates/weaveffi-cli/tests/snapshots/dotnet_shapes__WeaveFFI_cs.snap +++ b/crates/weaveffi-cli/tests/snapshots/dotnet_shapes__WeaveFFI_cs.snap @@ -13,8 +13,21 @@ using System.Runtime.InteropServices; namespace WeaveFFI { + /// Raised for any WeaveFFI failure that isn't a typed domain + /// error: producer bugs, panics, marshalling failures, and exceptions + /// thrown by a callback-interface implementation. public class WeaveFFIException : Exception { + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + public int Code { get; } public WeaveFFIException(int code, string message) : base(message) @@ -54,6 +67,20 @@ namespace WeaveFFI } } + /// One strong reference to a native object, on its way into + /// a wrapper that adopts it. A distinct type (rather than a bare + /// IntPtr) so the adopting constructor never competes with a + /// public constructor taking an integer. + internal readonly struct WeaveFFIHandle + { + internal readonly IntPtr Value; + + internal WeaveFFIHandle(IntPtr value) + { + Value = value; + } + } + internal static class WeaveFFIHelpers { internal static IntPtr StringToPtr(string? s) @@ -185,6 +212,11 @@ namespace WeaveFFI _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -331,6 +363,16 @@ namespace WeaveFFI return outBuf; } + internal IntPtr ReadObject() + { + var token = ReadU64(); + if (token == 0) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); + } + return (nint)(nuint)token; + } + internal void ExpectEnd() { if (_pos != _data.Length) @@ -558,7 +600,7 @@ namespace WeaveFFI private const string LibName = "weaveffi"; // The ABI revision these bindings were generated against. - internal const uint AbiVersion = 1; + internal const uint AbiVersion = 2; // Runs before the first P/Invoke through this class, so a producer built // for a different ABI revision fails loudly instead of misreading the @@ -585,6 +627,12 @@ namespace WeaveFFI [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern uint weaveffi_abi_version(); + // Fills a producer-owned error slot with a copy of `message`; callback + // trampolines report a thrown exception through it so no managed + // allocation is ever handed to the producer's allocator. + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern void weaveffi_free_string(IntPtr ptr); @@ -598,16 +646,16 @@ namespace WeaveFFI internal static extern void weaveffi_error_free(IntPtr err); [DllImport(LibName, EntryPoint = "weaveffi_shapes_describe", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_shapes_describe(IntPtr shape_ptr, UIntPtr shape_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_shapes_describe(IntPtr shape_ptr, UIntPtr shape_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_shapes_scale", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_shapes_scale(IntPtr shape_ptr, UIntPtr shape_len, double factor, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_shapes_scale(IntPtr shape_ptr, UIntPtr shape_len, double factor, out UIntPtr out_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_shapes_sum_bytes", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_shapes_sum_bytes(IntPtr values_ptr, UIntPtr values_len, ref WeaveFFIError err); + internal static extern ulong weaveffi_shapes_sum_bytes(IntPtr values_ptr, UIntPtr values_len, ref WeaveFFIError out_err); [DllImport(LibName, EntryPoint = "weaveffi_shapes_make_numerics", CallingConvention = CallingConvention.Cdecl)] - internal static extern IntPtr weaveffi_shapes_make_numerics(sbyte a, short b, int c, long d, byte e, ushort f, uint g, ulong h, float i, double j, out UIntPtr out_len, ref WeaveFFIError err); + internal static extern IntPtr weaveffi_shapes_make_numerics(sbyte a, short b, int c, long d, byte e, ushort f, uint g, ulong h, float i, double j, out UIntPtr out_len, ref WeaveFFIError out_err); } diff --git a/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__README_md.snap b/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__README_md.snap index 2bc2b438..7d3c4f86 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__README_md.snap @@ -36,11 +36,18 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + diff --git a/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__weaveffi_go.snap b/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__weaveffi_go.snap index ded4cc7d..b41bc717 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__weaveffi_go.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_docs_everywhere__weaveffi_go.snap @@ -11,7 +11,14 @@ package weaveffi #cgo LDFLAGS: -lweaveffi #include "weaveffi.h" #include -extern void goWv_weaveffi_docs_OnSaved_fn(int64_t doc_id, int32_t revision, void* context); +static void* wvHandlePtr(uintptr_t h) { return (void*)h; } +extern void goWv_weaveffi_docs_SavedListener_on_saved(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err); +extern void goWv_weaveffi_docs_SavedListener_free(void* ctx); +static const weaveffi_docs_SavedListener_vtable wvVtable_weaveffi_docs_SavedListener = { + goWv_weaveffi_docs_SavedListener_on_saved, + goWv_weaveffi_docs_SavedListener_free, +}; +static const weaveffi_docs_SavedListener_vtable* wvVtablePtr_weaveffi_docs_SavedListener(void) { return &wvVtable_weaveffi_docs_SavedListener; } extern void goWv_weaveffi_docs_publish_async_callback(void* context, weaveffi_error* err, bool result); */ import "C" @@ -20,13 +27,14 @@ import ( "encoding/binary" "fmt" "math" - "sync" + "runtime" + "runtime/cgo" "unicode/utf8" "unsafe" ) // The ABI revision these bindings were generated against. -const wvABIVersion uint32 = 1 +const wvABIVersion uint32 = 2 func init() { if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { @@ -43,8 +51,9 @@ func cToBool(b C._Bool) bool { } // WeaveFFIError reports a failure crossing the C boundary that no typed -// error domain claims: an unknown code, a marshalling failure, or a -// producer panic. +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). type WeaveFFIError struct { // Code is the numeric ABI error code. Code int32 @@ -95,11 +104,12 @@ func wvBrandError(code int32, message string, _ []byte) error { // wvTrap panics when the C error slot reports a failure. Non-throwing // wrappers check their slot with it: a non-zero code there can only be -// a producer panic or a marshalling failure. +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. func wvTrap(cErr *C.weaveffi_error) { if cErr.code != 0 { - code, msg, _ := wvTakeError(cErr) - panic(fmt.Sprintf("weaveffi: %s (code %d)", msg, code)) + panic(wvBrandError(wvTakeError(cErr))) } } @@ -311,40 +321,13 @@ func wvBorrowBuffer(ptr *C.uint8_t, length C.size_t) []byte { return C.GoBytes(unsafe.Pointer(ptr), C.int(length)) } -var ( - wvCallbackMu sync.Mutex - wvCallbackSeq uint64 - wvCallbacks = map[uint64]interface{}{} - // Subscription id -> registry id, so unregister can release the Go callback. - wvListenerCtx = map[uint64]uint64{} -) - -func wvCallbackStore(v interface{}) uint64 { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - wvCallbackSeq++ - wvCallbacks[wvCallbackSeq] = v - return wvCallbackSeq -} - -func wvCallbackLoad(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - return wvCallbacks[id] -} - -func wvCallbackTake(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - v := wvCallbacks[id] - delete(wvCallbacks, id) - return v -} - -func wvCallbackDelete(id uint64) { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - delete(wvCallbacks, id) +// wvForeignError reports a panic recovered inside a callback-interface +// trampoline through the producer's error slot (code -4). The message is +// borrowed for the call: the producer copies it with its own allocator. +func wvForeignError(outErr *C.weaveffi_error, recovered any) { + msg := C.CString(fmt.Sprint(recovered)) + defer C.free(unsafe.Pointer(msg)) + C.weaveffi_error_set(outErr, -4, msg) } // DocsErrorsError is a typed error reported by the `docs` module. @@ -439,19 +422,88 @@ func wvUnpackDocument(r *wvReader) Document { return v } +// SavedListener: Consumer-implemented observer of document-saved notifications. +// +// Implementations must be safe to call from any thread. +// +// Implement this interface in Go and pass the value to native functions +// that accept it. +// +// The native library may call any method from any thread until it releases +// the implementation. A panic in a method is reported to the native caller +// as a foreign error (code -4) instead of crashing the process. +type SavedListener interface { + // OnSaved: Fires after a document has been persisted successfully + // + // Parameters: + // - doc_id: Identifier of the saved document + // - revision: Monotonically increasing revision + OnSaved(docId int64, revision int32) +} + +//export goWv_weaveffi_docs_SavedListener_on_saved +func goWv_weaveffi_docs_SavedListener_on_saved(ctx unsafe.Pointer, doc_id C.int64_t, revision C.int32_t, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(SavedListener) + arg0 := int64(doc_id) + arg1 := int32(revision) + impl.OnSaved(arg0, arg1) +} + +//export goWv_weaveffi_docs_SavedListener_free +func goWv_weaveffi_docs_SavedListener_free(ctx unsafe.Pointer) { + cgo.Handle(uintptr(ctx)).Delete() +} + // Notebook: An ordered collection of documents. // // Notebooks own their documents and release them when destroyed. +// +// Each wrapper holds one strong reference; Close releases it, and a +// finalizer releases it if the wrapper is garbage collected first. type Notebook struct { ptr *C.weaveffi_docs_Notebook } +// wvAdoptNotebook adopts one owned strong reference into a new wrapper. A null +// pointer adopts to nil. +func wvAdoptNotebook(ptr *C.weaveffi_docs_Notebook) *Notebook { + if ptr == nil { + return nil + } + s := &Notebook{ptr: ptr} + runtime.SetFinalizer(s, (*Notebook).Close) + return s +} + +// wvTokenNotebook clones o's reference into a value-buffer object token. The +// wrapper keeps its own reference; the token carries the new one. +func wvTokenNotebook(o *Notebook) uint64 { + if o == nil || o.ptr == nil { + panic("weaveffi: nil or closed Notebook cannot be encoded in a non-optional position") + } + return uint64(uintptr(unsafe.Pointer(C.weaveffi_docs_Notebook_clone(o.ptr)))) +} + +// wvUntokenNotebook adopts the strong reference carried by a value-buffer object +// token. +func wvUntokenNotebook(token uint64) *Notebook { + if token == 0 { + panic("weaveffi: malformed value buffer: null object token") + } + return wvAdoptNotebook((*C.weaveffi_docs_Notebook)(C.wvHandlePtr(C.uintptr_t(token)))) +} + // NewNotebook: Create an empty notebook func NewNotebook() *Notebook { var cErr C.weaveffi_error result := C.weaveffi_docs_Notebook_new(&cErr) wvTrap(&cErr) - return &Notebook{ptr: result} + return wvAdoptNotebook(result) } // Insert: Add a document, returning its position in the notebook @@ -459,6 +511,10 @@ func NewNotebook() *Notebook { // Parameters: // - document: The document to add func (s *Notebook) Insert(document Document) int32 { + if s.ptr == nil { + panic("weaveffi: Notebook used after Close") + } + defer runtime.KeepAlive(s) wDocument := &wvWriter{} wvPackDocument(wDocument, document) var cDocumentPtr *C.uint8_t @@ -476,6 +532,10 @@ func (s *Notebook) Insert(document Document) int32 { // Parameters: // - id: Identifier of the document to find func (s *Notebook) Find(id int64) (Document, error) { + if s.ptr == nil { + panic("weaveffi: Notebook used after Close") + } + defer runtime.KeepAlive(s) var cOutLen C.size_t var cErr C.weaveffi_error result := C.weaveffi_docs_Notebook_find(s.ptr, C.int64_t(id), &cOutLen, &cErr) @@ -491,6 +551,10 @@ func (s *Notebook) Find(id int64) (Document, error) { // Size: The number of documents currently held func (s *Notebook) Size() int32 { + if s.ptr == nil { + panic("weaveffi: Notebook used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error result := C.weaveffi_docs_Notebook_size(s.ptr, &cErr) wvTrap(&cErr) @@ -505,45 +569,14 @@ func NotebookMaxDocuments() int32 { return int32(result) } +// Close releases the wrapper's strong reference. Calling it more than once +// is harmless; the object itself is dropped by the native library when its +// last reference (from any wrapper, record, or producer) is released. func (s *Notebook) Close() { if s.ptr != nil { C.weaveffi_docs_Notebook_destroy(s.ptr) s.ptr = nil - } -} - -//export goWv_weaveffi_docs_OnSaved_fn -func goWv_weaveffi_docs_OnSaved_fn(doc_id C.int64_t, revision C.int32_t, context unsafe.Pointer) { - v := wvCallbackLoad(uint64(uintptr(context))) - if v == nil { - return - } - cb := v.(func(docId int64, revision int32)) - arg0 := int64(doc_id) - arg1 := int32(revision) - cb(arg0, arg1) -} - -// RegisterSavedListener: Subscribe to document-saved notifications -// Returns a subscription id for UnregisterSavedListener. -func RegisterSavedListener(callback func(docId int64, revision int32)) uint64 { - ctxID := wvCallbackStore(callback) - id := uint64(C.weaveffi_docs_register_saved_listener(C.weaveffi_docs_OnSaved_fn(unsafe.Pointer(C.goWv_weaveffi_docs_OnSaved_fn)), unsafe.Pointer(uintptr(ctxID)))) - wvCallbackMu.Lock() - wvListenerCtx[id] = ctxID - wvCallbackMu.Unlock() - return id -} - -// UnregisterSavedListener unregisters a listener previously registered with RegisterSavedListener. -func UnregisterSavedListener(id uint64) { - C.weaveffi_docs_unregister_saved_listener(C.uint64_t(id)) - wvCallbackMu.Lock() - ctxID, ok := wvListenerCtx[id] - delete(wvListenerCtx, id) - wvCallbackMu.Unlock() - if ok { - wvCallbackDelete(ctxID) + runtime.SetFinalizer(s, nil) } } @@ -605,11 +638,9 @@ type wvOutcomeDocsPublishAsync struct { //export goWv_weaveffi_docs_publish_async_callback func goWv_weaveffi_docs_publish_async_callback(context unsafe.Pointer, err *C.weaveffi_error, result C._Bool) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeDocsPublishAsync) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeDocsPublishAsync) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeDocsPublishAsync{err: wvBrandError(wvTakeBoxedError(err))} return @@ -624,8 +655,8 @@ func goWv_weaveffi_docs_publish_async_callback(context unsafe.Pointer, err *C.we // Blocks the calling goroutine until the async producer completes. func PublishAsync(id int64) bool { ch := make(chan wvOutcomeDocsPublishAsync, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_docs_publish_async_async(C.int64_t(id), C.weaveffi_docs_publish_async_callback(unsafe.Pointer(C.goWv_weaveffi_docs_publish_async_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_docs_publish_async_async(C.int64_t(id), C.weaveffi_docs_publish_async_callback(unsafe.Pointer(C.goWv_weaveffi_docs_publish_async_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -633,5 +664,15 @@ func PublishAsync(id int64) bool { return outcome.val } +// Watch: Register a listener for saved notifications +// +// Parameters: +// - listener: Receiver of saved notifications +func Watch(listener SavedListener) { + hListener := cgo.NewHandle(listener) + var cErr C.weaveffi_error + C.weaveffi_docs_watch(C.wvHandlePtr(C.uintptr_t(hListener)), C.wvVtablePtr_weaveffi_docs_SavedListener(), &cErr) + wvTrap(&cErr) +} // END weaveffi.go diff --git a/crates/weaveffi-cli/tests/snapshots/go_edge_cases__README_md.snap b/crates/weaveffi-cli/tests/snapshots/go_edge_cases__README_md.snap index 2bc2b438..7d3c4f86 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_edge_cases__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_edge_cases__README_md.snap @@ -36,11 +36,18 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + diff --git a/crates/weaveffi-cli/tests/snapshots/go_edge_cases__weaveffi_go.snap b/crates/weaveffi-cli/tests/snapshots/go_edge_cases__weaveffi_go.snap index 84829bad..d1d98c2a 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_edge_cases__weaveffi_go.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_edge_cases__weaveffi_go.snap @@ -11,8 +11,32 @@ package weaveffi #cgo LDFLAGS: -lweaveffi #include "weaveffi.h" #include -extern void goWv_weaveffi_edge_cases_OnBatch_fn(uint8_t* items_ptr, size_t items_len, uint8_t* raw_ptr, size_t raw_len, uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context); -extern void goWv_weaveffi_edge_cases_OnTick_fn(void* context); +static void* wvHandlePtr(uintptr_t h) { return (void*)h; } +extern void goWv_weaveffi_edge_cases_BatchListener_on_batch(void* ctx, uint8_t* items_ptr, size_t items_len, uint8_t* raw_ptr, size_t raw_len, uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err); +extern void goWv_weaveffi_edge_cases_BatchListener_on_widget(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err); +extern void goWv_weaveffi_edge_cases_BatchListener_on_tick(void* ctx, weaveffi_error* out_err); +extern bool goWv_weaveffi_edge_cases_BatchListener_should_continue(void* ctx, int32_t class, weaveffi_error* out_err); +extern weaveffi_edge_cases_Mode goWv_weaveffi_edge_cases_BatchListener_pick_mode(void* ctx, weaveffi_error* out_err); +extern void goWv_weaveffi_edge_cases_BatchListener_free(void* ctx); +static const weaveffi_edge_cases_BatchListener_vtable wvVtable_weaveffi_edge_cases_BatchListener = { + (void (*)(void*, const uint8_t*, size_t, const uint8_t*, size_t, const uint8_t*, size_t, weaveffi_edge_cases_Mode, weaveffi_error*))goWv_weaveffi_edge_cases_BatchListener_on_batch, + (void (*)(void*, weaveffi_edge_cases_Widget*, weaveffi_edge_cases_Widget*, const uint8_t*, size_t, weaveffi_error*))goWv_weaveffi_edge_cases_BatchListener_on_widget, + goWv_weaveffi_edge_cases_BatchListener_on_tick, + goWv_weaveffi_edge_cases_BatchListener_should_continue, + goWv_weaveffi_edge_cases_BatchListener_pick_mode, + goWv_weaveffi_edge_cases_BatchListener_free, +}; +static const weaveffi_edge_cases_BatchListener_vtable* wvVtablePtr_weaveffi_edge_cases_BatchListener(void) { return &wvVtable_weaveffi_edge_cases_BatchListener; } +extern void goWv_weaveffi_edge_cases_Keyword_delete(void* ctx, int32_t self, weaveffi_error* out_err); +extern int32_t goWv_weaveffi_edge_cases_Keyword_import(void* ctx, weaveffi_error* out_err); +extern void goWv_weaveffi_edge_cases_Keyword_free(void* ctx); +static const weaveffi_edge_cases_Keyword_vtable wvVtable_weaveffi_edge_cases_Keyword = { + goWv_weaveffi_edge_cases_Keyword_delete, + goWv_weaveffi_edge_cases_Keyword_import, + goWv_weaveffi_edge_cases_Keyword_free, +}; +static const weaveffi_edge_cases_Keyword_vtable* wvVtablePtr_weaveffi_edge_cases_Keyword(void) { return &wvVtable_weaveffi_edge_cases_Keyword; } +extern void goWv_weaveffi_edge_cases_async_widget_callback(void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result); extern void goWv_weaveffi_edge_cases_async_void_callback(void* context, weaveffi_error* err); extern void goWv_weaveffi_edge_cases_async_bytes_callback(void* context, weaveffi_error* err, uint8_t* result, size_t result_len); extern void goWv_weaveffi_edge_cases_async_struct_callback(void* context, weaveffi_error* err, uint8_t* result_ptr, size_t result_len); @@ -29,13 +53,14 @@ import ( "fmt" "iter" "math" - "sync" + "runtime" + "runtime/cgo" "unicode/utf8" "unsafe" ) // The ABI revision these bindings were generated against. -const wvABIVersion uint32 = 1 +const wvABIVersion uint32 = 2 func init() { if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { @@ -52,8 +77,9 @@ func cToBool(b C._Bool) bool { } // WeaveFFIError reports a failure crossing the C boundary that no typed -// error domain claims: an unknown code, a marshalling failure, or a -// producer panic. +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). type WeaveFFIError struct { // Code is the numeric ABI error code. Code int32 @@ -104,11 +130,12 @@ func wvBrandError(code int32, message string, _ []byte) error { // wvTrap panics when the C error slot reports a failure. Non-throwing // wrappers check their slot with it: a non-zero code there can only be -// a producer panic or a marshalling failure. +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. func wvTrap(cErr *C.weaveffi_error) { if cErr.code != 0 { - code, msg, _ := wvTakeError(cErr) - panic(fmt.Sprintf("weaveffi: %s (code %d)", msg, code)) + panic(wvBrandError(wvTakeError(cErr))) } } @@ -320,46 +347,13 @@ func wvBorrowBuffer(ptr *C.uint8_t, length C.size_t) []byte { return C.GoBytes(unsafe.Pointer(ptr), C.int(length)) } -var ( - wvCallbackMu sync.Mutex - wvCallbackSeq uint64 - wvCallbacks = map[uint64]interface{}{} - // Subscription id -> registry id, so unregister can release the Go callback. - wvListenerCtx = map[uint64]uint64{} -) - -func wvCallbackStore(v interface{}) uint64 { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - wvCallbackSeq++ - wvCallbacks[wvCallbackSeq] = v - return wvCallbackSeq -} - -func wvCallbackLoad(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - return wvCallbacks[id] -} - -func wvCallbackTake(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - v := wvCallbacks[id] - delete(wvCallbacks, id) - return v -} - -func wvCallbackDelete(id uint64) { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - delete(wvCallbacks, id) -} - -// LeafHandle is a typed handle naming a producer-owned resource. It wraps -// the opaque C pointer and owes no release call. -type LeafHandle struct { - ptr *C.weaveffi_edge_cases_Leaf +// wvForeignError reports a panic recovered inside a callback-interface +// trampoline through the producer's error slot (code -4). The message is +// borrowed for the call: the producer copies it with its own allocator. +func wvForeignError(outErr *C.weaveffi_error, recovered any) { + msg := C.CString(fmt.Sprint(recovered)) + defer C.free(unsafe.Pointer(msg)) + C.weaveffi_error_set(outErr, -4, msg) } // EdgeErrorsError is a typed error reported by the `edge_cases` module. @@ -398,9 +392,9 @@ func wvMapEdgeCases(code int32, message string, payload []byte) error { type Legacy int32 const ( - LegacyNone Legacy = 0 + LegacyNone Legacy = 0 LegacyDefault Legacy = 5 - LegacyClass Legacy = 10 + LegacyClass Legacy = 10 ) type Mode int32 @@ -428,7 +422,7 @@ func wvUnpackLeaf(r *wvReader) Leaf { // Inner: Middle layer of a three-deep struct nesting type Inner struct { - Leaf Leaf + Leaf Leaf Leaves []Leaf } @@ -454,17 +448,17 @@ func wvUnpackInner(r *wvReader) Inner { } type Outer struct { - Inner Inner + Inner Inner MaybeInner *Inner - ByName map[string]Inner - Modes []Mode - Matrix [][]int32 - Sparse []*int64 - Blob []byte - Note *string - ById map[int64]string - Hidden bool - V2 uint8 + ByName map[string]Inner + Modes []Mode + Matrix [][]int32 + Sparse []*int64 + Blob []byte + Note *string + ById map[int64]string + Hidden bool + V2 uint8 } // wvPackOuter appends v to w in the value-buffer wire format. @@ -589,65 +583,65 @@ func wvUnpackOuter(r *wvReader) Outer { // Keywords: Every field is a reserved word somewhere type Keywords struct { - Class int32 - Def int32 - Import int32 - From int32 - Func int32 - Range int32 - Register int32 - Volatile int32 - New int32 - Default int32 - End int32 - In int32 - Is int32 - Int int32 - Var int32 - Val int32 - Self int32 - This int32 - Object int32 - String int32 - Case int32 - Select int32 - Switch int32 - Void int32 - Union int32 + Class int32 + Def int32 + Import int32 + From int32 + Func int32 + Range int32 + Register int32 + Volatile int32 + New int32 + Default int32 + End int32 + In int32 + Is int32 + Int int32 + Var int32 + Val int32 + Self int32 + This int32 + Object int32 + String int32 + Case int32 + Select int32 + Switch int32 + Void int32 + Union int32 Namespace int32 - Operator int32 - Template int32 - Package int32 - Protocol int32 - Guard int32 - Defer int32 - Inout int32 - Internal int32 - When int32 - Fun int32 - Data int32 - Late int32 - Part int32 - Out int32 - Ref int32 - Event int32 - Base int32 - Lambda int32 - Yield int32 - Nil int32 - Null int32 - Begin int32 - Rescue int32 - Module int32 - Chan int32 - Go int32 - Let int32 - Export int32 - Delete int32 - Typedef int32 - Sizeof int32 - Static int32 - Super int32 + Operator int32 + Template int32 + Package int32 + Protocol int32 + Guard int32 + Defer int32 + Inout int32 + Internal int32 + When int32 + Fun int32 + Data int32 + Late int32 + Part int32 + Out int32 + Ref int32 + Event int32 + Base int32 + Lambda int32 + Yield int32 + Nil int32 + Null int32 + Begin int32 + Rescue int32 + Module int32 + Chan int32 + Go int32 + Let int32 + Export int32 + Delete int32 + Typedef int32 + Sizeof int32 + Static int32 + Super int32 } // wvPackKeywords appends v to w in the value-buffer wire format. @@ -795,13 +789,259 @@ func wvUnpackRetired(r *wvReader) Retired { return v } +// WidgetHolder: Record carrying objects in every buffered position +type WidgetHolder struct { + Primary *Widget + Fallback *Widget + Extras []*Widget + ByName map[string]*Widget +} + +// wvPackWidgetHolder appends v to w in the value-buffer wire format. +func wvPackWidgetHolder(w *wvWriter, v WidgetHolder) { + w.writeU64(wvTokenWidget(v.Primary)) + if v.Fallback == nil { + w.writeOptionFlag(false) + } else { + w.writeOptionFlag(true) + w.writeU64(wvTokenWidget(v.Fallback)) + } + w.writeLen(len(v.Extras)) + for _, eExtras0 := range v.Extras { + w.writeU64(wvTokenWidget(eExtras0)) + } + w.writeLen(len(v.ByName)) + for kByName0, vByName0 := range v.ByName { + w.writeString(kByName0) + w.writeU64(wvTokenWidget(vByName0)) + } +} + +// wvUnpackWidgetHolder decodes one WidgetHolder from r. +func wvUnpackWidgetHolder(r *wvReader) WidgetHolder { + var v WidgetHolder + v.Primary = wvUntokenWidget(r.readU64()) + if r.readOptionFlag() { + var oFallback0 *Widget + oFallback0 = wvUntokenWidget(r.readU64()) + v.Fallback = oFallback0 + } + nExtras0 := r.readLen() + v.Extras = make([]*Widget, nExtras0) + for iExtras0 := range v.Extras { + v.Extras[iExtras0] = wvUntokenWidget(r.readU64()) + } + nByName0 := r.readLen() + v.ByName = make(map[string]*Widget, nByName0) + for iByName0 := 0; iByName0 < nByName0; iByName0++ { + var kByName0 string + kByName0 = r.readString() + var vByName0 *Widget + vByName0 = wvUntokenWidget(r.readU64()) + v.ByName[kByName0] = vByName0 + } + return v +} + +// BatchListener: Callback interface carrying composite payloads and objects +// +// Implement this interface in Go and pass the value to native functions +// that accept it. +// +// The native library may call any method from any thread until it releases +// the implementation. A panic in a method is reported to the native caller +// as a foreign error (code -4) instead of crashing the process. +type BatchListener interface { + // OnBatch: Composite payloads cross as value buffers + OnBatch(items []Outer, raw []byte, first *Outer, mode Mode) + // OnWidget: Objects cross as strong references the consumer must release + OnWidget(widget *Widget, fallback *Widget, children []*Widget) + OnTick() + // ShouldContinue: Scalar return travels back through the vtable + ShouldContinue(class int32) bool + // PickMode: Enum return travels back as its integer discriminant + PickMode() Mode +} + +//export goWv_weaveffi_edge_cases_BatchListener_on_batch +func goWv_weaveffi_edge_cases_BatchListener_on_batch(ctx unsafe.Pointer, items_ptr *C.uint8_t, items_len C.size_t, raw_ptr *C.uint8_t, raw_len C.size_t, first_ptr *C.uint8_t, first_len C.size_t, mode C.weaveffi_edge_cases_Mode, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(BatchListener) + rArg0 := &wvReader{buf: wvBorrowBuffer(items_ptr, items_len)} + var arg0 []Outer + nArg00 := rArg0.readLen() + arg0 = make([]Outer, nArg00) + for iArg00 := range arg0 { + arg0[iArg00] = wvUnpackOuter(rArg0) + } + rArg0.expectEnd() + var arg1 []byte + if raw_ptr != nil { + arg1 = C.GoBytes(unsafe.Pointer(raw_ptr), C.int(raw_len)) + } + rArg2 := &wvReader{buf: wvBorrowBuffer(first_ptr, first_len)} + var arg2 *Outer + if rArg2.readOptionFlag() { + var oArg20 Outer + oArg20 = wvUnpackOuter(rArg2) + arg2 = &oArg20 + } + rArg2.expectEnd() + arg3 := Mode(mode) + impl.OnBatch(arg0, arg1, arg2, arg3) +} + +//export goWv_weaveffi_edge_cases_BatchListener_on_widget +func goWv_weaveffi_edge_cases_BatchListener_on_widget(ctx unsafe.Pointer, widget *C.weaveffi_edge_cases_Widget, fallback *C.weaveffi_edge_cases_Widget, children_ptr *C.uint8_t, children_len C.size_t, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(BatchListener) + arg0 := wvAdoptWidget(widget) + arg1 := wvAdoptWidget(fallback) + rArg2 := &wvReader{buf: wvBorrowBuffer(children_ptr, children_len)} + var arg2 []*Widget + nArg20 := rArg2.readLen() + arg2 = make([]*Widget, nArg20) + for iArg20 := range arg2 { + arg2[iArg20] = wvUntokenWidget(rArg2.readU64()) + } + rArg2.expectEnd() + impl.OnWidget(arg0, arg1, arg2) +} + +//export goWv_weaveffi_edge_cases_BatchListener_on_tick +func goWv_weaveffi_edge_cases_BatchListener_on_tick(ctx unsafe.Pointer, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(BatchListener) + impl.OnTick() +} + +//export goWv_weaveffi_edge_cases_BatchListener_should_continue +func goWv_weaveffi_edge_cases_BatchListener_should_continue(ctx unsafe.Pointer, class C.int32_t, out_err *C.weaveffi_error) (ret C._Bool) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(BatchListener) + arg0 := int32(class) + ret = boolToC(impl.ShouldContinue(arg0)) + return +} + +//export goWv_weaveffi_edge_cases_BatchListener_pick_mode +func goWv_weaveffi_edge_cases_BatchListener_pick_mode(ctx unsafe.Pointer, out_err *C.weaveffi_error) (ret C.weaveffi_edge_cases_Mode) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(BatchListener) + ret = C.weaveffi_edge_cases_Mode(impl.PickMode()) + return +} + +//export goWv_weaveffi_edge_cases_BatchListener_free +func goWv_weaveffi_edge_cases_BatchListener_free(ctx unsafe.Pointer) { + cgo.Handle(uintptr(ctx)).Delete() +} + +// Keyword: Callback interface whose name and methods are keywords +// +// Implement this interface in Go and pass the value to native functions +// that accept it. +// +// The native library may call any method from any thread until it releases +// the implementation. A panic in a method is reported to the native caller +// as a foreign error (code -4) instead of crashing the process. +type Keyword interface { + Delete(self int32) + Import() int32 +} + +//export goWv_weaveffi_edge_cases_Keyword_delete +func goWv_weaveffi_edge_cases_Keyword_delete(ctx unsafe.Pointer, self C.int32_t, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(Keyword) + arg0 := int32(self) + impl.Delete(arg0) +} + +//export goWv_weaveffi_edge_cases_Keyword_import +func goWv_weaveffi_edge_cases_Keyword_import(ctx unsafe.Pointer, out_err *C.weaveffi_error) (ret C.int32_t) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(Keyword) + ret = C.int32_t(impl.Import()) + return +} + +//export goWv_weaveffi_edge_cases_Keyword_free +func goWv_weaveffi_edge_cases_Keyword_free(ctx unsafe.Pointer) { + cgo.Handle(uintptr(ctx)).Delete() +} + // Widget: Interface with no constructor; instances come from make_widget +// +// Each wrapper holds one strong reference; Close releases it, and a +// finalizer releases it if the wrapper is garbage collected first. type Widget struct { ptr *C.weaveffi_edge_cases_Widget } +// wvAdoptWidget adopts one owned strong reference into a new wrapper. A null +// pointer adopts to nil. +func wvAdoptWidget(ptr *C.weaveffi_edge_cases_Widget) *Widget { + if ptr == nil { + return nil + } + s := &Widget{ptr: ptr} + runtime.SetFinalizer(s, (*Widget).Close) + return s +} + +// wvTokenWidget clones o's reference into a value-buffer object token. The +// wrapper keeps its own reference; the token carries the new one. +func wvTokenWidget(o *Widget) uint64 { + if o == nil || o.ptr == nil { + panic("weaveffi: nil or closed Widget cannot be encoded in a non-optional position") + } + return uint64(uintptr(unsafe.Pointer(C.weaveffi_edge_cases_Widget_clone(o.ptr)))) +} + +// wvUntokenWidget adopts the strong reference carried by a value-buffer object +// token. +func wvUntokenWidget(token uint64) *Widget { + if token == 0 { + panic("weaveffi: malformed value buffer: null object token") + } + return wvAdoptWidget((*C.weaveffi_edge_cases_Widget)(C.wvHandlePtr(C.uintptr_t(token)))) +} + // Refresh: Void method that can fail func (s *Widget) Refresh() error { + if s.ptr == nil { + panic("weaveffi: Widget used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error C.weaveffi_edge_cases_Widget_refresh(s.ptr, &cErr) if cErr.code != 0 { @@ -811,6 +1051,10 @@ func (s *Widget) Refresh() error { } func (s *Widget) Rename(class string, import_ *string) string { + if s.ptr == nil { + panic("weaveffi: Widget used after Close") + } + defer runtime.KeepAlive(s) cClass := C.CString(class) defer C.free(unsafe.Pointer(cClass)) wImport := &wvWriter{} @@ -839,11 +1083,9 @@ type wvOutcomeEdgeCasesWidgetFetch struct { //export goWv_weaveffi_edge_cases_Widget_fetch_callback func goWv_weaveffi_edge_cases_Widget_fetch_callback(context unsafe.Pointer, err *C.weaveffi_error, result_ptr *C.uint8_t, result_len C.size_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesWidgetFetch) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesWidgetFetch) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesWidgetFetch{err: wvMapEdgeCases(wvTakeBoxedError(err))} return @@ -858,9 +1100,13 @@ func goWv_weaveffi_edge_cases_Widget_fetch_callback(context unsafe.Pointer, err // Fetch: Async method with a struct result // Blocks the calling goroutine until the async producer completes. func (s *Widget) Fetch(id int64) (Outer, error) { + if s.ptr == nil { + panic("weaveffi: Widget used after Close") + } + defer runtime.KeepAlive(s) ch := make(chan wvOutcomeEdgeCasesWidgetFetch, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_Widget_fetch_async(s.ptr, C.int64_t(id), C.weaveffi_edge_cases_Widget_fetch_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_Widget_fetch_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_Widget_fetch_async(s.ptr, C.int64_t(id), C.weaveffi_edge_cases_Widget_fetch_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_Widget_fetch_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { return Outer{}, outcome.err @@ -870,6 +1116,10 @@ func (s *Widget) Fetch(id int64) (Outer, error) { // Deprecated: Use rename instead func (s *Widget) OldName() string { + if s.ptr == nil { + panic("weaveffi: Widget used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error result := C.weaveffi_edge_cases_Widget_old_name(s.ptr, &cErr) wvTrap(&cErr) @@ -878,18 +1128,56 @@ func (s *Widget) OldName() string { return goResult } +// Close releases the wrapper's strong reference. Calling it more than once +// is harmless; the object itself is dropped by the native library when its +// last reference (from any wrapper, record, or producer) is released. func (s *Widget) Close() { if s.ptr != nil { C.weaveffi_edge_cases_Widget_destroy(s.ptr) s.ptr = nil + runtime.SetFinalizer(s, nil) } } // Vault: Deprecated interface with a throwing constructor +// +// Each wrapper holds one strong reference; Close releases it, and a +// finalizer releases it if the wrapper is garbage collected first. +// +// Deprecated: Use Widget instead type Vault struct { ptr *C.weaveffi_edge_cases_Vault } +// wvAdoptVault adopts one owned strong reference into a new wrapper. A null +// pointer adopts to nil. +func wvAdoptVault(ptr *C.weaveffi_edge_cases_Vault) *Vault { + if ptr == nil { + return nil + } + s := &Vault{ptr: ptr} + runtime.SetFinalizer(s, (*Vault).Close) + return s +} + +// wvTokenVault clones o's reference into a value-buffer object token. The +// wrapper keeps its own reference; the token carries the new one. +func wvTokenVault(o *Vault) uint64 { + if o == nil || o.ptr == nil { + panic("weaveffi: nil or closed Vault cannot be encoded in a non-optional position") + } + return uint64(uintptr(unsafe.Pointer(C.weaveffi_edge_cases_Vault_clone(o.ptr)))) +} + +// wvUntokenVault adopts the strong reference carried by a value-buffer object +// token. +func wvUntokenVault(token uint64) *Vault { + if token == 0 { + panic("weaveffi: malformed value buffer: null object token") + } + return wvAdoptVault((*C.weaveffi_edge_cases_Vault)(C.wvHandlePtr(C.uintptr_t(token)))) +} + // OpenVault: Fails when the vault is busy func OpenVault(path string) (*Vault, error) { cPath := C.CString(path) @@ -899,17 +1187,18 @@ func OpenVault(path string) (*Vault, error) { if cErr.code != 0 { return nil, wvMapEdgeCases(wvTakeError(&cErr)) } - return &Vault{ptr: result}, nil + return wvAdoptVault(result), nil } func (s *Vault) Peek() *Widget { + if s.ptr == nil { + panic("weaveffi: Vault used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error result := C.weaveffi_edge_cases_Vault_peek(s.ptr, &cErr) wvTrap(&cErr) - if result == nil { - return nil - } - return &Widget{ptr: result} + return wvAdoptWidget(result) } // VaultProbe: Throwing static @@ -922,95 +1211,14 @@ func VaultProbe() (bool, error) { return cToBool(result), nil } +// Close releases the wrapper's strong reference. Calling it more than once +// is harmless; the object itself is dropped by the native library when its +// last reference (from any wrapper, record, or producer) is released. func (s *Vault) Close() { if s.ptr != nil { C.weaveffi_edge_cases_Vault_destroy(s.ptr) s.ptr = nil - } -} - -//export goWv_weaveffi_edge_cases_OnBatch_fn -func goWv_weaveffi_edge_cases_OnBatch_fn(items_ptr *C.uint8_t, items_len C.size_t, raw_ptr *C.uint8_t, raw_len C.size_t, first_ptr *C.uint8_t, first_len C.size_t, mode C.weaveffi_edge_cases_Mode, context unsafe.Pointer) { - v := wvCallbackLoad(uint64(uintptr(context))) - if v == nil { - return - } - cb := v.(func(items []Outer, raw []byte, first *Outer, mode Mode)) - rArg0 := &wvReader{buf: wvBorrowBuffer(items_ptr, items_len)} - var arg0 []Outer - nArg00 := rArg0.readLen() - arg0 = make([]Outer, nArg00) - for iArg00 := range arg0 { - arg0[iArg00] = wvUnpackOuter(rArg0) - } - rArg0.expectEnd() - var arg1 []byte - if raw_ptr != nil { - arg1 = C.GoBytes(unsafe.Pointer(raw_ptr), C.int(raw_len)) - } - rArg2 := &wvReader{buf: wvBorrowBuffer(first_ptr, first_len)} - var arg2 *Outer - if rArg2.readOptionFlag() { - var oArg20 Outer - oArg20 = wvUnpackOuter(rArg2) - arg2 = &oArg20 - } - rArg2.expectEnd() - arg3 := Mode(mode) - cb(arg0, arg1, arg2, arg3) -} - -//export goWv_weaveffi_edge_cases_OnTick_fn -func goWv_weaveffi_edge_cases_OnTick_fn(context unsafe.Pointer) { - v := wvCallbackLoad(uint64(uintptr(context))) - if v == nil { - return - } - cb := v.(func()) - cb() -} - -// Returns a subscription id for UnregisterBatchListener. -func RegisterBatchListener(callback func(items []Outer, raw []byte, first *Outer, mode Mode)) uint64 { - ctxID := wvCallbackStore(callback) - id := uint64(C.weaveffi_edge_cases_register_batch_listener(C.weaveffi_edge_cases_OnBatch_fn(unsafe.Pointer(C.goWv_weaveffi_edge_cases_OnBatch_fn)), unsafe.Pointer(uintptr(ctxID)))) - wvCallbackMu.Lock() - wvListenerCtx[id] = ctxID - wvCallbackMu.Unlock() - return id -} - -// UnregisterBatchListener unregisters a listener previously registered with RegisterBatchListener. -func UnregisterBatchListener(id uint64) { - C.weaveffi_edge_cases_unregister_batch_listener(C.uint64_t(id)) - wvCallbackMu.Lock() - ctxID, ok := wvListenerCtx[id] - delete(wvListenerCtx, id) - wvCallbackMu.Unlock() - if ok { - wvCallbackDelete(ctxID) - } -} - -// Returns a subscription id for UnregisterTickListener. -func RegisterTickListener(callback func()) uint64 { - ctxID := wvCallbackStore(callback) - id := uint64(C.weaveffi_edge_cases_register_tick_listener(C.weaveffi_edge_cases_OnTick_fn(unsafe.Pointer(C.goWv_weaveffi_edge_cases_OnTick_fn)), unsafe.Pointer(uintptr(ctxID)))) - wvCallbackMu.Lock() - wvListenerCtx[id] = ctxID - wvCallbackMu.Unlock() - return id -} - -// UnregisterTickListener unregisters a listener previously registered with RegisterTickListener. -func UnregisterTickListener(id uint64) { - C.weaveffi_edge_cases_unregister_tick_listener(C.uint64_t(id)) - wvCallbackMu.Lock() - ctxID, ok := wvListenerCtx[id] - delete(wvListenerCtx, id) - wvCallbackMu.Unlock() - if ok { - wvCallbackDelete(ctxID) + runtime.SetFinalizer(s, nil) } } @@ -1180,18 +1388,124 @@ func OptionalParams(text *string, count *int32, outer *Outer, tags []string, blo return goResult } -// MutableParams: Write-back parameters -func MutableParams(text string, blob []byte) { - cText := C.CString(text) - defer C.free(unsafe.Pointer(cText)) - var cBlobPtr *C.uint8_t - cBlobLen := C.size_t(len(blob)) - if len(blob) > 0 { - cBlobPtr = (*C.uint8_t)(unsafe.Pointer(&blob[0])) +// RegisterBatch: Two callback interface parameters on one function +func RegisterBatch(listener BatchListener, keyword Keyword) { + hListener := cgo.NewHandle(listener) + hKeyword := cgo.NewHandle(keyword) + var cErr C.weaveffi_error + C.weaveffi_edge_cases_register_batch(C.wvHandlePtr(C.uintptr_t(hListener)), C.wvVtablePtr_weaveffi_edge_cases_BatchListener(), C.wvHandlePtr(C.uintptr_t(hKeyword)), C.wvVtablePtr_weaveffi_edge_cases_Keyword(), &cErr) + wvTrap(&cErr) +} + +// WidgetsEverywhere: Objects in every buffered position +func WidgetsEverywhere(list []*Widget, byName map[string]*Widget, sparse []*Widget, holder WidgetHolder) WidgetHolder { + wList := &wvWriter{} + wList.writeLen(len(list)) + for _, eList0 := range list { + wList.writeU64(wvTokenWidget(eList0)) + } + var cListPtr *C.uint8_t + if len(wList.buf) > 0 { + cListPtr = (*C.uint8_t)(unsafe.Pointer(&wList.buf[0])) } + wByName := &wvWriter{} + wByName.writeLen(len(byName)) + for kByName0, vByName0 := range byName { + wByName.writeString(kByName0) + wByName.writeU64(wvTokenWidget(vByName0)) + } + var cByNamePtr *C.uint8_t + if len(wByName.buf) > 0 { + cByNamePtr = (*C.uint8_t)(unsafe.Pointer(&wByName.buf[0])) + } + wSparse := &wvWriter{} + wSparse.writeLen(len(sparse)) + for _, eSparse0 := range sparse { + if eSparse0 == nil { + wSparse.writeOptionFlag(false) + } else { + wSparse.writeOptionFlag(true) + wSparse.writeU64(wvTokenWidget(eSparse0)) + } + } + var cSparsePtr *C.uint8_t + if len(wSparse.buf) > 0 { + cSparsePtr = (*C.uint8_t)(unsafe.Pointer(&wSparse.buf[0])) + } + wHolder := &wvWriter{} + wvPackWidgetHolder(wHolder, holder) + var cHolderPtr *C.uint8_t + if len(wHolder.buf) > 0 { + cHolderPtr = (*C.uint8_t)(unsafe.Pointer(&wHolder.buf[0])) + } + var cOutLen C.size_t var cErr C.weaveffi_error - C.weaveffi_edge_cases_mutable_params(cText, cBlobPtr, cBlobLen, &cErr) + result := C.weaveffi_edge_cases_widgets_everywhere(cListPtr, C.size_t(len(wList.buf)), cByNamePtr, C.size_t(len(wByName.buf)), cSparsePtr, C.size_t(len(wSparse.buf)), cHolderPtr, C.size_t(len(wHolder.buf)), &cOutLen, &cErr) wvTrap(&cErr) + rRes := &wvReader{buf: wvCopyBuffer(result, cOutLen)} + var goResult WidgetHolder + goResult = wvUnpackWidgetHolder(rRes) + rRes.expectEnd() + return goResult +} + +// WidgetStream: Iterator of objects +// Returns a lazy sequence: the producer iterator is launched on first +// iteration and one producer next call runs per element. The iterator is +// destroyed exactly once, whether the sequence is exhausted or abandoned +// early; each range over the sequence launches a fresh producer iterator. +// A reported error can only be a producer bug and panics with the +// weaveffi message. +func WidgetStream() iter.Seq[*Widget] { + return func(yield func(*Widget) bool) { + var cErr C.weaveffi_error + it := C.weaveffi_edge_cases_widget_stream(&cErr) + wvTrap(&cErr) + defer C.weaveffi_edge_cases_WidgetStreamIterator_destroy(it) + for { + var outItem *C.weaveffi_edge_cases_Widget + var iterErr C.weaveffi_error + ok := C.weaveffi_edge_cases_WidgetStreamIterator_next(it, &outItem, &iterErr) != 0 + wvTrap(&iterErr) + if !ok { + return + } + item := wvAdoptWidget(outItem) + if !yield(item) { + return + } + } + } +} + +type wvOutcomeEdgeCasesAsyncWidget struct { + val *Widget + err error +} + +//export goWv_weaveffi_edge_cases_async_widget_callback +func goWv_weaveffi_edge_cases_async_widget_callback(context unsafe.Pointer, err *C.weaveffi_error, result *C.weaveffi_edge_cases_Widget) { + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncWidget) + h.Delete() + if err != nil && err.code != 0 { + ch <- wvOutcomeEdgeCasesAsyncWidget{err: wvMapEdgeCases(wvTakeBoxedError(err))} + return + } + ch <- wvOutcomeEdgeCasesAsyncWidget{val: wvAdoptWidget(result)} +} + +// AsyncWidget: Async returning an object +// Blocks the calling goroutine until the async producer completes. +func AsyncWidget() (*Widget, error) { + ch := make(chan wvOutcomeEdgeCasesAsyncWidget, 1) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_widget_async(C.weaveffi_edge_cases_async_widget_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_widget_callback)), C.wvHandlePtr(C.uintptr_t(h))) + outcome := <-ch + if outcome.err != nil { + return nil, outcome.err + } + return outcome.val, nil } // MakeWidget: Factory for an interface with no constructor @@ -1201,7 +1515,7 @@ func MakeWidget(label string) *Widget { var cErr C.weaveffi_error result := C.weaveffi_edge_cases_make_widget(cLabel, &cErr) wvTrap(&cErr) - return &Widget{ptr: result} + return wvAdoptWidget(result) } // FindWidget: Optional interface return @@ -1211,14 +1525,13 @@ func FindWidget(id int64) (*Widget, error) { if cErr.code != 0 { return nil, wvMapEdgeCases(wvTakeError(&cErr)) } - if result == nil { - return nil, nil - } - return &Widget{ptr: result}, nil + return wvAdoptWidget(result), nil } // InspectWidget: Interface parameter, including an optional one func InspectWidget(widget *Widget, fallback *Widget) string { + defer runtime.KeepAlive(widget) + defer runtime.KeepAlive(fallback) var cFallback *C.weaveffi_edge_cases_Widget if fallback != nil { cFallback = fallback.ptr @@ -1231,13 +1544,6 @@ func InspectWidget(widget *Widget, fallback *Widget) string { return goResult } -func TypedHandleParam(token *LeafHandle, raw int64) *LeafHandle { - var cErr C.weaveffi_error - result := C.weaveffi_edge_cases_typed_handle_param(token.ptr, C.weaveffi_handle_t(raw), &cErr) - wvTrap(&cErr) - return &LeafHandle{ptr: result} -} - // StringStream: Iterator of strings // Returns a lazy sequence: the producer iterator is launched on first // iteration and one producer next call runs per element. The iterator is @@ -1332,11 +1638,9 @@ type wvOutcomeEdgeCasesAsyncVoid struct { //export goWv_weaveffi_edge_cases_async_void_callback func goWv_weaveffi_edge_cases_async_void_callback(context unsafe.Pointer, err *C.weaveffi_error) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncVoid) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncVoid) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncVoid{err: wvBrandError(wvTakeBoxedError(err))} return @@ -1348,8 +1652,8 @@ func goWv_weaveffi_edge_cases_async_void_callback(context unsafe.Pointer, err *C // Blocks the calling goroutine until the async producer completes. func AsyncVoid() { ch := make(chan wvOutcomeEdgeCasesAsyncVoid, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_void_async(C.weaveffi_edge_cases_async_void_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_void_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_void_async(C.weaveffi_edge_cases_async_void_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_void_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -1363,11 +1667,9 @@ type wvOutcomeEdgeCasesAsyncBytes struct { //export goWv_weaveffi_edge_cases_async_bytes_callback func goWv_weaveffi_edge_cases_async_bytes_callback(context unsafe.Pointer, err *C.weaveffi_error, result *C.uint8_t, result_len C.size_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncBytes) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncBytes) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncBytes{err: wvBrandError(wvTakeBoxedError(err))} return @@ -1383,8 +1685,8 @@ func goWv_weaveffi_edge_cases_async_bytes_callback(context unsafe.Pointer, err * // Blocks the calling goroutine until the async producer completes. func AsyncBytes(size uint32) []byte { ch := make(chan wvOutcomeEdgeCasesAsyncBytes, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_bytes_async(C.uint32_t(size), C.weaveffi_edge_cases_async_bytes_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_bytes_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_bytes_async(C.uint32_t(size), C.weaveffi_edge_cases_async_bytes_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_bytes_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -1399,11 +1701,9 @@ type wvOutcomeEdgeCasesAsyncStruct struct { //export goWv_weaveffi_edge_cases_async_struct_callback func goWv_weaveffi_edge_cases_async_struct_callback(context unsafe.Pointer, err *C.weaveffi_error, result_ptr *C.uint8_t, result_len C.size_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncStruct) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncStruct) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncStruct{err: wvMapEdgeCases(wvTakeBoxedError(err))} return @@ -1418,8 +1718,8 @@ func goWv_weaveffi_edge_cases_async_struct_callback(context unsafe.Pointer, err // Blocks the calling goroutine until the async producer completes. func AsyncStruct() (Outer, error) { ch := make(chan wvOutcomeEdgeCasesAsyncStruct, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_struct_async(C.weaveffi_edge_cases_async_struct_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_struct_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_struct_async(C.weaveffi_edge_cases_async_struct_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_struct_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { return Outer{}, outcome.err @@ -1434,11 +1734,9 @@ type wvOutcomeEdgeCasesAsyncList struct { //export goWv_weaveffi_edge_cases_async_list_callback func goWv_weaveffi_edge_cases_async_list_callback(context unsafe.Pointer, err *C.weaveffi_error, result_ptr *C.uint8_t, result_len C.size_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncList) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncList) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncList{err: wvMapEdgeCases(wvTakeBoxedError(err))} return @@ -1457,8 +1755,8 @@ func goWv_weaveffi_edge_cases_async_list_callback(context unsafe.Pointer, err *C // Blocks the calling goroutine until the async producer completes. func AsyncList() ([]Outer, error) { ch := make(chan wvOutcomeEdgeCasesAsyncList, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_list_async(nil, C.weaveffi_edge_cases_async_list_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_list_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_list_async(nil, C.weaveffi_edge_cases_async_list_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_list_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { return nil, outcome.err @@ -1473,11 +1771,9 @@ type wvOutcomeEdgeCasesAsyncOptional struct { //export goWv_weaveffi_edge_cases_async_optional_callback func goWv_weaveffi_edge_cases_async_optional_callback(context unsafe.Pointer, err *C.weaveffi_error, result_ptr *C.uint8_t, result_len C.size_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncOptional) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncOptional) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncOptional{err: wvBrandError(wvTakeBoxedError(err))} return @@ -1496,8 +1792,8 @@ func goWv_weaveffi_edge_cases_async_optional_callback(context unsafe.Pointer, er // Blocks the calling goroutine until the async producer completes. func AsyncOptional() *int64 { ch := make(chan wvOutcomeEdgeCasesAsyncOptional, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_optional_async(C.weaveffi_edge_cases_async_optional_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_optional_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_optional_async(C.weaveffi_edge_cases_async_optional_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_optional_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -1512,11 +1808,9 @@ type wvOutcomeEdgeCasesAsyncEnum struct { //export goWv_weaveffi_edge_cases_async_enum_callback func goWv_weaveffi_edge_cases_async_enum_callback(context unsafe.Pointer, err *C.weaveffi_error, result C.weaveffi_edge_cases_Mode) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncEnum) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncEnum) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncEnum{err: wvBrandError(wvTakeBoxedError(err))} return @@ -1527,8 +1821,8 @@ func goWv_weaveffi_edge_cases_async_enum_callback(context unsafe.Pointer, err *C // Blocks the calling goroutine until the async producer completes. func AsyncEnum() Mode { ch := make(chan wvOutcomeEdgeCasesAsyncEnum, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_edge_cases_async_enum_async(C.weaveffi_edge_cases_async_enum_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_enum_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_edge_cases_async_enum_async(C.weaveffi_edge_cases_async_enum_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_enum_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -1543,11 +1837,9 @@ type wvOutcomeEdgeCasesAsyncKeywords struct { //export goWv_weaveffi_edge_cases_async_keywords_callback func goWv_weaveffi_edge_cases_async_keywords_callback(context unsafe.Pointer, err *C.weaveffi_error, result *C.char) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeEdgeCasesAsyncKeywords) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeEdgeCasesAsyncKeywords) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeEdgeCasesAsyncKeywords{err: wvBrandError(wvTakeBoxedError(err))} return @@ -1563,10 +1855,10 @@ func goWv_weaveffi_edge_cases_async_keywords_callback(context unsafe.Pointer, er // Blocks the calling goroutine until the async producer completes. func AsyncKeywords(class string, import_ int32) string { ch := make(chan wvOutcomeEdgeCasesAsyncKeywords, 1) - ctxID := wvCallbackStore(ch) + h := cgo.NewHandle(ch) cClass := C.CString(class) defer C.free(unsafe.Pointer(cClass)) - C.weaveffi_edge_cases_async_keywords_async(cClass, C.int32_t(import_), C.weaveffi_edge_cases_async_keywords_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_keywords_callback)), unsafe.Pointer(uintptr(ctxID))) + C.weaveffi_edge_cases_async_keywords_async(cClass, C.int32_t(import_), C.weaveffi_edge_cases_async_keywords_callback(unsafe.Pointer(C.goWv_weaveffi_edge_cases_async_keywords_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -1605,5 +1897,4 @@ func LegacyValue(value Legacy) Retired { return goResult } - // END weaveffi.go diff --git a/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__README_md.snap b/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__README_md.snap index 2bc2b438..7d3c4f86 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__README_md.snap @@ -36,11 +36,18 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + diff --git a/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__weaveffi_go.snap b/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__weaveffi_go.snap index 24e9d21f..e82fe20b 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__weaveffi_go.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_kitchen_sink__weaveffi_go.snap @@ -11,7 +11,16 @@ package weaveffi #cgo LDFLAGS: -lweaveffi #include "weaveffi.h" #include -extern void goWv_weaveffi_kitchen_OnReady_fn(int32_t code, char* msg, void* context); +static void* wvHandlePtr(uintptr_t h) { return (void*)h; } +extern void goWv_weaveffi_kitchen_ReadyListener_on_ready(void* ctx, int32_t code, char* msg, weaveffi_error* out_err); +extern bool goWv_weaveffi_kitchen_ReadyListener_on_item(void* ctx, uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err); +extern void goWv_weaveffi_kitchen_ReadyListener_free(void* ctx); +static const weaveffi_kitchen_ReadyListener_vtable wvVtable_weaveffi_kitchen_ReadyListener = { + (void (*)(void*, int32_t, const char*, weaveffi_error*))goWv_weaveffi_kitchen_ReadyListener_on_ready, + (bool (*)(void*, const uint8_t*, size_t, weaveffi_kitchen_Gadget*, weaveffi_error*))goWv_weaveffi_kitchen_ReadyListener_on_item, + goWv_weaveffi_kitchen_ReadyListener_free, +}; +static const weaveffi_kitchen_ReadyListener_vtable* wvVtablePtr_weaveffi_kitchen_ReadyListener(void) { return &wvVtable_weaveffi_kitchen_ReadyListener; } extern void goWv_weaveffi_kitchen_do_async_callback(void* context, weaveffi_error* err, char* result); extern void goWv_weaveffi_kitchen_do_cancellable_callback(void* context, weaveffi_error* err, char* result); */ @@ -22,13 +31,14 @@ import ( "fmt" "iter" "math" - "sync" + "runtime" + "runtime/cgo" "unicode/utf8" "unsafe" ) // The ABI revision these bindings were generated against. -const wvABIVersion uint32 = 1 +const wvABIVersion uint32 = 2 func init() { if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { @@ -45,8 +55,9 @@ func cToBool(b C._Bool) bool { } // WeaveFFIError reports a failure crossing the C boundary that no typed -// error domain claims: an unknown code, a marshalling failure, or a -// producer panic. +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). type WeaveFFIError struct { // Code is the numeric ABI error code. Code int32 @@ -97,11 +108,12 @@ func wvBrandError(code int32, message string, _ []byte) error { // wvTrap panics when the C error slot reports a failure. Non-throwing // wrappers check their slot with it: a non-zero code there can only be -// a producer panic or a marshalling failure. +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. func wvTrap(cErr *C.weaveffi_error) { if cErr.code != 0 { - code, msg, _ := wvTakeError(cErr) - panic(fmt.Sprintf("weaveffi: %s (code %d)", msg, code)) + panic(wvBrandError(wvTakeError(cErr))) } } @@ -313,51 +325,18 @@ func wvBorrowBuffer(ptr *C.uint8_t, length C.size_t) []byte { return C.GoBytes(unsafe.Pointer(ptr), C.int(length)) } -var ( - wvCallbackMu sync.Mutex - wvCallbackSeq uint64 - wvCallbacks = map[uint64]interface{}{} - // Subscription id -> registry id, so unregister can release the Go callback. - wvListenerCtx = map[uint64]uint64{} -) - -func wvCallbackStore(v interface{}) uint64 { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - wvCallbackSeq++ - wvCallbacks[wvCallbackSeq] = v - return wvCallbackSeq -} - -func wvCallbackLoad(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - return wvCallbacks[id] -} - -func wvCallbackTake(id uint64) interface{} { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - v := wvCallbacks[id] - delete(wvCallbacks, id) - return v -} - -func wvCallbackDelete(id uint64) { - wvCallbackMu.Lock() - defer wvCallbackMu.Unlock() - delete(wvCallbacks, id) -} - -// TokenHandle is a typed handle naming a producer-owned resource. It wraps -// the opaque C pointer and owes no release call. -type TokenHandle struct { - ptr *C.weaveffi_shared_Token +// wvForeignError reports a panic recovered inside a callback-interface +// trampoline through the producer's error slot (code -4). The message is +// borrowed for the call: the producer copies it with its own allocator. +func wvForeignError(outErr *C.weaveffi_error, recovered any) { + msg := C.CString(fmt.Sprint(recovered)) + defer C.free(unsafe.Pointer(msg)) + C.weaveffi_error_set(outErr, -4, msg) } // Token: A reusable token referenced across modules type Token struct { - Id int64 + Id int64 Label string } @@ -447,15 +426,17 @@ type Item struct { // Enabled: Whether the item is enabled Enabled bool // Ratio: Scaling ratio - Ratio float64 - Nick uint32 + Ratio float64 + Nick uint32 Payload []byte - Tags []string - Attrs map[string]string - Parent *int64 + Tags []string + Attrs map[string]string + Parent *int64 // Token: Cross-module struct reference - Token Token + Token Token Priority Priority + // Gadget: An optional object inside a record + Gadget *Gadget } // wvPackItem appends v to w in the value-buffer wire format. @@ -484,6 +465,12 @@ func wvPackItem(w *wvWriter, v Item) { } wvPackToken(w, v.Token) w.writeI32(int32(v.Priority)) + if v.Gadget == nil { + w.writeOptionFlag(false) + } else { + w.writeOptionFlag(true) + w.writeU64(wvTokenGadget(v.Gadget)) + } } // wvUnpackItem decodes one Item from r. @@ -517,24 +504,118 @@ func wvUnpackItem(r *wvReader) Item { } v.Token = wvUnpackToken(r) v.Priority = Priority(r.readI32()) + if r.readOptionFlag() { + var oGadget0 *Gadget + oGadget0 = wvUntokenGadget(r.readU64()) + v.Gadget = oGadget0 + } return v } +// ReadyListener: Consumer-implemented observer of ready items +// +// Implement this interface in Go and pass the value to native functions +// that accept it. +// +// The native library may call any method from any thread until it releases +// the implementation. A panic in a method is reported to the native caller +// as a foreign error (code -4) instead of crashing the process. +type ReadyListener interface { + // OnReady: Fires when an item is ready + OnReady(code int32, msg string) + // OnItem: Receives the item itself and says whether to keep listening + OnItem(item Item, gadget *Gadget) bool +} + +//export goWv_weaveffi_kitchen_ReadyListener_on_ready +func goWv_weaveffi_kitchen_ReadyListener_on_ready(ctx unsafe.Pointer, code C.int32_t, msg *C.char, out_err *C.weaveffi_error) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(ReadyListener) + arg0 := int32(code) + arg1 := "" + if msg != nil { + arg1 = C.GoString(msg) + } + impl.OnReady(arg0, arg1) +} + +//export goWv_weaveffi_kitchen_ReadyListener_on_item +func goWv_weaveffi_kitchen_ReadyListener_on_item(ctx unsafe.Pointer, item_ptr *C.uint8_t, item_len C.size_t, gadget *C.weaveffi_kitchen_Gadget, out_err *C.weaveffi_error) (ret C._Bool) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(ReadyListener) + rArg0 := &wvReader{buf: wvBorrowBuffer(item_ptr, item_len)} + var arg0 Item + arg0 = wvUnpackItem(rArg0) + rArg0.expectEnd() + arg1 := wvAdoptGadget(gadget) + ret = boolToC(impl.OnItem(arg0, arg1)) + return +} + +//export goWv_weaveffi_kitchen_ReadyListener_free +func goWv_weaveffi_kitchen_ReadyListener_free(ctx unsafe.Pointer) { + cgo.Handle(uintptr(ctx)).Delete() +} + // Gadget: A pocket-sized interface exercising the object surface +// +// Each wrapper holds one strong reference; Close releases it, and a +// finalizer releases it if the wrapper is garbage collected first. type Gadget struct { ptr *C.weaveffi_kitchen_Gadget } +// wvAdoptGadget adopts one owned strong reference into a new wrapper. A null +// pointer adopts to nil. +func wvAdoptGadget(ptr *C.weaveffi_kitchen_Gadget) *Gadget { + if ptr == nil { + return nil + } + s := &Gadget{ptr: ptr} + runtime.SetFinalizer(s, (*Gadget).Close) + return s +} + +// wvTokenGadget clones o's reference into a value-buffer object token. The +// wrapper keeps its own reference; the token carries the new one. +func wvTokenGadget(o *Gadget) uint64 { + if o == nil || o.ptr == nil { + panic("weaveffi: nil or closed Gadget cannot be encoded in a non-optional position") + } + return uint64(uintptr(unsafe.Pointer(C.weaveffi_kitchen_Gadget_clone(o.ptr)))) +} + +// wvUntokenGadget adopts the strong reference carried by a value-buffer object +// token. +func wvUntokenGadget(token uint64) *Gadget { + if token == 0 { + panic("weaveffi: malformed value buffer: null object token") + } + return wvAdoptGadget((*C.weaveffi_kitchen_Gadget)(C.wvHandlePtr(C.uintptr_t(token)))) +} + // NewGadget: Create a gadget with the given id func NewGadget(id int64) *Gadget { var cErr C.weaveffi_error result := C.weaveffi_kitchen_Gadget_new(C.int64_t(id), &cErr) wvTrap(&cErr) - return &Gadget{ptr: result} + return wvAdoptGadget(result) } // Describe: Render the gadget as a human-readable string func (s *Gadget) Describe() string { + if s.ptr == nil { + panic("weaveffi: Gadget used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error result := C.weaveffi_kitchen_Gadget_describe(s.ptr, &cErr) wvTrap(&cErr) @@ -545,6 +626,10 @@ func (s *Gadget) Describe() string { // Poke: Poke the gadget a number of times, failing on a negative count func (s *Gadget) Poke(times int32) (int32, error) { + if s.ptr == nil { + panic("weaveffi: Gadget used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error result := C.weaveffi_kitchen_Gadget_poke(s.ptr, C.int32_t(times), &cErr) if cErr.code != 0 { @@ -563,48 +648,14 @@ func GadgetVersion() string { return goResult } +// Close releases the wrapper's strong reference. Calling it more than once +// is harmless; the object itself is dropped by the native library when its +// last reference (from any wrapper, record, or producer) is released. func (s *Gadget) Close() { if s.ptr != nil { C.weaveffi_kitchen_Gadget_destroy(s.ptr) s.ptr = nil - } -} - -//export goWv_weaveffi_kitchen_OnReady_fn -func goWv_weaveffi_kitchen_OnReady_fn(code C.int32_t, msg *C.char, context unsafe.Pointer) { - v := wvCallbackLoad(uint64(uintptr(context))) - if v == nil { - return - } - cb := v.(func(code int32, msg string)) - arg0 := int32(code) - arg1 := "" - if msg != nil { - arg1 = C.GoString(msg) - } - cb(arg0, arg1) -} - -// RegisterReadyListener: Subscribe to OnReady events -// Returns a subscription id for UnregisterReadyListener. -func RegisterReadyListener(callback func(code int32, msg string)) uint64 { - ctxID := wvCallbackStore(callback) - id := uint64(C.weaveffi_kitchen_register_ready_listener(C.weaveffi_kitchen_OnReady_fn(unsafe.Pointer(C.goWv_weaveffi_kitchen_OnReady_fn)), unsafe.Pointer(uintptr(ctxID)))) - wvCallbackMu.Lock() - wvListenerCtx[id] = ctxID - wvCallbackMu.Unlock() - return id -} - -// UnregisterReadyListener unregisters a listener previously registered with RegisterReadyListener. -func UnregisterReadyListener(id uint64) { - C.weaveffi_kitchen_unregister_ready_listener(C.uint64_t(id)) - wvCallbackMu.Lock() - ctxID, ok := wvListenerCtx[id] - delete(wvListenerCtx, id) - wvCallbackMu.Unlock() - if ok { - wvCallbackDelete(ctxID) + runtime.SetFinalizer(s, nil) } } @@ -673,51 +724,72 @@ func EchoBytes(b []byte) []byte { return goResult } -// EchoBorrowedStr: Borrowed string parameter -func EchoBorrowedStr(s string) string { - cS := C.CString(s) - defer C.free(unsafe.Pointer(cS)) +// Subscribe: Register a listener, returning how many are attached +func Subscribe(listener ReadyListener) int32 { + hListener := cgo.NewHandle(listener) var cErr C.weaveffi_error - result := C.weaveffi_kitchen_echo_borrowed_str(cS, &cErr) + result := C.weaveffi_kitchen_subscribe(C.wvHandlePtr(C.uintptr_t(hListener)), C.wvVtablePtr_weaveffi_kitchen_ReadyListener(), &cErr) wvTrap(&cErr) - goResult := C.GoString(result) - C.weaveffi_free_string(result) - return goResult + return int32(result) } -// EchoBorrowedBytes: Borrowed bytes parameter -func EchoBorrowedBytes(b []byte) []byte { - var cBPtr *C.uint8_t - cBLen := C.size_t(len(b)) - if len(b) > 0 { - cBPtr = (*C.uint8_t)(unsafe.Pointer(&b[0])) +// MaybeGadget: Optional object in both directions +func MaybeGadget(fallback *Gadget) *Gadget { + defer runtime.KeepAlive(fallback) + var cFallback *C.weaveffi_kitchen_Gadget + if fallback != nil { + cFallback = fallback.ptr } - var cOutLen C.size_t var cErr C.weaveffi_error - result := C.weaveffi_kitchen_echo_borrowed_bytes(cBPtr, cBLen, &cOutLen, &cErr) + result := C.weaveffi_kitchen_maybe_gadget(cFallback, &cErr) wvTrap(&cErr) - if result == nil { - return nil - } - goResult := C.GoBytes(unsafe.Pointer(result), C.int(cOutLen)) - C.weaveffi_free_bytes(result, cOutLen) - return goResult + return wvAdoptGadget(result) } -// OpenHandle: Returns an opaque handle -func OpenHandle() int64 { +// Gadgets: A list of objects +func Gadgets() []*Gadget { + var cOutLen C.size_t var cErr C.weaveffi_error - result := C.weaveffi_kitchen_open_handle(&cErr) + result := C.weaveffi_kitchen_gadgets(&cOutLen, &cErr) wvTrap(&cErr) - return int64(result) + rRes := &wvReader{buf: wvCopyBuffer(result, cOutLen)} + var goResult []*Gadget + nRes0 := rRes.readLen() + goResult = make([]*Gadget, nRes0) + for iRes0 := range goResult { + goResult[iRes0] = wvUntokenGadget(rRes.readU64()) + } + rRes.expectEnd() + return goResult } -// OpenTypedHandle: Returns a typed handle -func OpenTypedHandle() *TokenHandle { - var cErr C.weaveffi_error - result := C.weaveffi_kitchen_open_typed_handle(&cErr) - wvTrap(&cErr) - return &TokenHandle{ptr: result} +// StreamGadgets: An iterator over objects +// Returns a lazy sequence: the producer iterator is launched on first +// iteration and one producer next call runs per element. The iterator is +// destroyed exactly once, whether the sequence is exhausted or abandoned +// early; each range over the sequence launches a fresh producer iterator. +// A reported error can only be a producer bug and panics with the +// weaveffi message. +func StreamGadgets() iter.Seq[*Gadget] { + return func(yield func(*Gadget) bool) { + var cErr C.weaveffi_error + it := C.weaveffi_kitchen_stream_gadgets(&cErr) + wvTrap(&cErr) + defer C.weaveffi_kitchen_StreamGadgetsIterator_destroy(it) + for { + var outItem *C.weaveffi_kitchen_Gadget + var iterErr C.weaveffi_error + ok := C.weaveffi_kitchen_StreamGadgetsIterator_next(it, &outItem, &iterErr) != 0 + wvTrap(&iterErr) + if !ok { + return + } + item := wvAdoptGadget(outItem) + if !yield(item) { + return + } + } + } } // MaybeItem: Optional struct return @@ -830,11 +902,9 @@ type wvOutcomeKitchenDoAsync struct { //export goWv_weaveffi_kitchen_do_async_callback func goWv_weaveffi_kitchen_do_async_callback(context unsafe.Pointer, err *C.weaveffi_error, result *C.char) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeKitchenDoAsync) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeKitchenDoAsync) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeKitchenDoAsync{err: wvBrandError(wvTakeBoxedError(err))} return @@ -851,10 +921,10 @@ func goWv_weaveffi_kitchen_do_async_callback(context unsafe.Pointer, err *C.weav // Blocks the calling goroutine until the async producer completes. func DoAsync(input string) string { ch := make(chan wvOutcomeKitchenDoAsync, 1) - ctxID := wvCallbackStore(ch) + h := cgo.NewHandle(ch) cInput := C.CString(input) defer C.free(unsafe.Pointer(cInput)) - C.weaveffi_kitchen_do_async_async(cInput, C.weaveffi_kitchen_do_async_callback(unsafe.Pointer(C.goWv_weaveffi_kitchen_do_async_callback)), unsafe.Pointer(uintptr(ctxID))) + C.weaveffi_kitchen_do_async_async(cInput, C.weaveffi_kitchen_do_async_callback(unsafe.Pointer(C.goWv_weaveffi_kitchen_do_async_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -869,11 +939,9 @@ type wvOutcomeKitchenDoCancellable struct { //export goWv_weaveffi_kitchen_do_cancellable_callback func goWv_weaveffi_kitchen_do_cancellable_callback(context unsafe.Pointer, err *C.weaveffi_error, result *C.char) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeKitchenDoCancellable) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeKitchenDoCancellable) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeKitchenDoCancellable{err: wvBrandError(wvTakeBoxedError(err))} return @@ -890,10 +958,10 @@ func goWv_weaveffi_kitchen_do_cancellable_callback(context unsafe.Pointer, err * // Blocks the calling goroutine until the async producer completes. func DoCancellable(input string) string { ch := make(chan wvOutcomeKitchenDoCancellable, 1) - ctxID := wvCallbackStore(ch) + h := cgo.NewHandle(ch) cInput := C.CString(input) defer C.free(unsafe.Pointer(cInput)) - C.weaveffi_kitchen_do_cancellable_async(cInput, nil, C.weaveffi_kitchen_do_cancellable_callback(unsafe.Pointer(C.goWv_weaveffi_kitchen_do_cancellable_callback)), unsafe.Pointer(uintptr(ctxID))) + C.weaveffi_kitchen_do_cancellable_async(cInput, nil, C.weaveffi_kitchen_do_cancellable_callback(unsafe.Pointer(C.goWv_weaveffi_kitchen_do_cancellable_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { panic(outcome.err) @@ -928,5 +996,4 @@ func Hello() string { return goResult } - // END weaveffi.go diff --git a/crates/weaveffi-cli/tests/snapshots/go_nested_modules__README_md.snap b/crates/weaveffi-cli/tests/snapshots/go_nested_modules__README_md.snap index 2bc2b438..7d3c4f86 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_nested_modules__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_nested_modules__README_md.snap @@ -36,11 +36,18 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + diff --git a/crates/weaveffi-cli/tests/snapshots/go_nested_modules__weaveffi_go.snap b/crates/weaveffi-cli/tests/snapshots/go_nested_modules__weaveffi_go.snap index 8d4e0641..92d3337e 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_nested_modules__weaveffi_go.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_nested_modules__weaveffi_go.snap @@ -23,7 +23,7 @@ import ( ) // The ABI revision these bindings were generated against. -const wvABIVersion uint32 = 1 +const wvABIVersion uint32 = 2 func init() { if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { @@ -32,8 +32,9 @@ func init() { } // WeaveFFIError reports a failure crossing the C boundary that no typed -// error domain claims: an unknown code, a marshalling failure, or a -// producer panic. +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). type WeaveFFIError struct { // Code is the numeric ABI error code. Code int32 @@ -84,11 +85,12 @@ func wvBrandError(code int32, message string, _ []byte) error { // wvTrap panics when the C error slot reports a failure. Non-throwing // wrappers check their slot with it: a non-zero code there can only be -// a producer panic or a marshalling failure. +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. func wvTrap(cErr *C.weaveffi_error) { if cErr.code != 0 { - code, msg, _ := wvTakeError(cErr) - panic(fmt.Sprintf("weaveffi: %s (code %d)", msg, code)) + panic(wvBrandError(wvTakeError(cErr))) } } @@ -463,5 +465,4 @@ func Compute(shape Shape) (Detail, error) { return goResult, nil } - // END weaveffi.go diff --git a/crates/weaveffi-cli/tests/snapshots/go_shapes__README_md.snap b/crates/weaveffi-cli/tests/snapshots/go_shapes__README_md.snap index 2bc2b438..7d3c4f86 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_shapes__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_shapes__README_md.snap @@ -36,11 +36,18 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + diff --git a/crates/weaveffi-cli/tests/snapshots/go_shapes__weaveffi_go.snap b/crates/weaveffi-cli/tests/snapshots/go_shapes__weaveffi_go.snap index 69cb1b41..79eafd73 100644 --- a/crates/weaveffi-cli/tests/snapshots/go_shapes__weaveffi_go.snap +++ b/crates/weaveffi-cli/tests/snapshots/go_shapes__weaveffi_go.snap @@ -23,7 +23,7 @@ import ( ) // The ABI revision these bindings were generated against. -const wvABIVersion uint32 = 1 +const wvABIVersion uint32 = 2 func init() { if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { @@ -32,8 +32,9 @@ func init() { } // WeaveFFIError reports a failure crossing the C boundary that no typed -// error domain claims: an unknown code, a marshalling failure, or a -// producer panic. +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). type WeaveFFIError struct { // Code is the numeric ABI error code. Code int32 @@ -84,11 +85,12 @@ func wvBrandError(code int32, message string, _ []byte) error { // wvTrap panics when the C error slot reports a failure. Non-throwing // wrappers check their slot with it: a non-zero code there can only be -// a producer panic or a marshalling failure. +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. func wvTrap(cErr *C.weaveffi_error) { if cErr.code != 0 { - code, msg, _ := wvTakeError(cErr) - panic(fmt.Sprintf("weaveffi: %s (code %d)", msg, code)) + panic(wvBrandError(wvTakeError(cErr))) } } @@ -323,7 +325,7 @@ func (ShapeCircle) isShape() {} // ShapeRectangle: An axis-aligned rectangle type ShapeRectangle struct { - Width float32 + Width float32 Height float32 } @@ -331,8 +333,8 @@ func (ShapeRectangle) isShape() {} // ShapeBounded: An integer-bounded shape (exercises narrow + wide ints) type ShapeBounded struct { - Min int16 - Max int16 + Min int16 + Max int16 Weight uint64 } @@ -406,9 +408,9 @@ func wvUnpackShape(r *wvReader) Shape { type Channel int32 const ( - ChannelRed Channel = 0 + ChannelRed Channel = 0 ChannelGreen Channel = 1 - ChannelBlue Channel = 2 + ChannelBlue Channel = 2 ) // Numerics: One field per supported numeric primitive @@ -516,5 +518,4 @@ func MakeNumerics(a int8, b int16, c int32, d int64, e uint8, f uint16, g uint32 return goResult } - // END weaveffi.go diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__build_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__build_gradle_kts.snap new file mode 100644 index 00000000..374b107b --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__build_gradle_kts.snap @@ -0,0 +1,36 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} + +// END build.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__settings_gradle.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__settings_gradle_kts.snap similarity index 79% rename from crates/weaveffi-cli/tests/snapshots/android_edge_cases__settings_gradle.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__settings_gradle_kts.snap index f10a8b92..69449d7c 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__settings_gradle.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__settings_gradle_kts.snap @@ -5,6 +5,6 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o -rootProject.name = 'weaveffi' +rootProject.name = "weaveffi" -// END settings.gradle +// END settings.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_CMakeLists_txt.snap similarity index 94% rename from crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_CMakeLists_txt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_CMakeLists_txt.snap index e2c5c99f..32fa1b44 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_CMakeLists_txt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_CMakeLists_txt.snap @@ -6,7 +6,7 @@ source: crates/weaveffi-cli/tests/snapshots.rs # To regenerate: weaveffi generate weaveffi.yml -o cmake_minimum_required(VERSION 3.22) -project(weaveffi) +project(weaveffi_jni C) add_library(weaveffi SHARED weaveffi_jni.c) target_include_directories(weaveffi PRIVATE ../../../../c) diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap new file mode 100644 index 00000000..1838ee51 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_cpp_weaveffi_jni_c.snap @@ -0,0 +1,378 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +#include +#include +#include +#include +#include +#include +#include "weaveffi.h" + +/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified + UTF-8 would encode supplementary characters as surrogate pairs the + producer rejects). Lone surrogates become U+FFFD. Release with free(). + Returns NULL for a NULL string, for a string holding U+0000 (which no + C string can carry), or on allocation failure; the producer then + reports the null argument through its marshalling error. */ +static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) { + if (s == NULL) { return NULL; } + jsize n = (*env)->GetStringLength(env, s); + const jchar* u = (*env)->GetStringChars(env, s, NULL); + if (u == NULL) { return NULL; } + char* out = (char*)malloc((size_t)n * 4u + 1u); + if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; } + size_t o = 0; + for (jsize i = 0; i < n; i++) { + uint32_t c = u[i]; + if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; } + if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) { + c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u); + i++; + } + else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; } + if (c < 0x80u) { out[o++] = (char)c; } + else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + } + out[o] = 0; + (*env)->ReleaseStringChars(env, s, u); + return out; +} + +/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8 + and mangles supplementary characters). NULL yields the empty string and + a malformed sequence yields U+FFFD, so this never fails on producer + output; it returns NULL only when the JVM could not allocate. */ +static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) { + if (s == NULL) { s = ""; } + size_t n = strlen(s); + jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar)); + if (buf == NULL) { return NULL; } + size_t i = 0, o = 0; + while (i < n) { + unsigned char b = (unsigned char)s[i]; + uint32_t c; size_t len; + if (b < 0x80u) { c = b; len = 1; } + else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; } + else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; } + else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; } + else { c = 0xFFFDu; len = 1; } + if (i + len > n) { c = 0xFFFDu; len = 1; } + for (size_t k = 1; k < len; k++) { + unsigned char cb = (unsigned char)s[i + k]; + if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; } + c = (c << 6) | (cb & 0x3Fu); + } + i += len; + if (c > 0x10FFFFu) { c = 0xFFFDu; } + if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); } + else { buf[o++] = (jchar)c; } + } + jstring out = (*env)->NewString(env, buf, (jsize)o); + free(buf); + return out; +} + +static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { + const char* msg = err->message ? err->message : "WeaveFFI error"; + jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); + if (exClass != NULL) { + jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); + jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); + if (ex != NULL) { (*env)->Throw(env, ex); } + } + weaveffi_error_clear(err); +} + +static void throw_weaveffi_docs_DocsErrors(JNIEnv* env, weaveffi_error* err) { + jclass exClass = (*env)->FindClass(env, "com/weaveffi/DocsErrorsException"); + jmethodID fromCode = exClass ? (*env)->GetStaticMethodID(env, exClass, "fromCode", "(ILjava/lang/String;[B)Lcom/weaveffi/WeaveFFIException;") : NULL; + if (fromCode == NULL) { + (*env)->ExceptionClear(env); + throw_weaveffi_error(env, err); + return; + } + const char* msg = err->message ? err->message : "WeaveFFI error"; + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); + jbyteArray jpayload = NULL; + if (err->payload_ptr != NULL) { + jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); + if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); } + } + jthrowable ex = (jthrowable)(*env)->CallStaticObjectMethod(env, exClass, fromCode, (jint)err->code, jmsg, jpayload); + if (ex != NULL && !(*env)->ExceptionCheck(env)) { (*env)->Throw(env, ex); } + weaveffi_error_clear(err); +} + +static JavaVM* weaveffi_jni_vm = NULL; +static jclass weaveffi_jni_entry_class = NULL; +static jmethodID weaveffi_jni_dispatch_exc = NULL; +static jmethodID weaveffi_jni_throwable_to_string = NULL; +static jclass weaveffi_docs_SavedListener_jni_cls = NULL; +static jmethodID weaveffi_docs_SavedListener_jni_mid_on_saved = NULL; + +static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) { + jclass cls = (*env)->FindClass(env, "java/lang/UnsatisfiedLinkError"); + if (cls != NULL) { (*env)->ThrowNew(env, cls, message); } + return JNI_ERR; +} + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)reserved; + weaveffi_jni_vm = vm; + JNIEnv* env = NULL; + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } + if (weaveffi_abi_version() != 2u) { + return weaveffi_jni_load_error(env, "WeaveFFI: the native library implements a different C ABI revision than these bindings (expected 2)"); + } + jclass entry = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); + if (entry == NULL) { return JNI_ERR; } + weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, entry); + weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, "dispatchCallbackException", "(Ljava/lang/Throwable;)V"); + if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); } + jclass throwable = (*env)->FindClass(env, "java/lang/Throwable"); + if (throwable == NULL) { return JNI_ERR; } + weaveffi_jni_throwable_to_string = (*env)->GetMethodID(env, throwable, "toString", "()Ljava/lang/String;"); + if (weaveffi_jni_throwable_to_string == NULL) { return JNI_ERR; } + jclass weaveffi_docs_SavedListener_jni_cls_local = (*env)->FindClass(env, "com/weaveffi/SavedListenerJni"); + if (weaveffi_docs_SavedListener_jni_cls_local == NULL) { return JNI_ERR; } + weaveffi_docs_SavedListener_jni_cls = (jclass)(*env)->NewGlobalRef(env, weaveffi_docs_SavedListener_jni_cls_local); + weaveffi_docs_SavedListener_jni_mid_on_saved = (*env)->GetStaticMethodID(env, weaveffi_docs_SavedListener_jni_cls, "onSaved", "(Lcom/weaveffi/SavedListener;JI)V"); + if (weaveffi_docs_SavedListener_jni_mid_on_saved == NULL) { return JNI_ERR; } + return JNI_VERSION_1_6; +} + +/* Returns 1 when this call attached the thread (the caller must detach), + 0 otherwise. *env is NULL when no JNIEnv could be obtained. */ +static int weaveffi_jni_attach(JNIEnv** env) { + *env = NULL; + if (weaveffi_jni_vm == NULL) { return 0; } + if ((*weaveffi_jni_vm)->GetEnv(weaveffi_jni_vm, (void**)env, JNI_VERSION_1_6) == JNI_OK) { return 0; } + if ((*weaveffi_jni_vm)->AttachCurrentThread(weaveffi_jni_vm, (void*)env, NULL) != JNI_OK) { *env = NULL; return 0; } + return 1; +} + +static void weaveffi_jni_detach(int attached) { + if (attached) { (*weaveffi_jni_vm)->DetachCurrentThread(weaveffi_jni_vm); } +} + +typedef struct { + jobject callback; +} weaveffi_jni_async_ctx; + +static void weaveffi_jni_handle_uncaught(JNIEnv* env) { + if (!(*env)->ExceptionCheck(env)) { return; } + jthrowable ex = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + if (weaveffi_jni_entry_class != NULL && weaveffi_jni_dispatch_exc != NULL) { + (*env)->CallStaticVoidMethod(env, weaveffi_jni_entry_class, weaveffi_jni_dispatch_exc, ex); + if (!(*env)->ExceptionCheck(env)) { (*env)->DeleteLocalRef(env, ex); return; } + (*env)->ExceptionClear(env); + } + (*env)->Throw(env, ex); + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, ex); +} + +static void weaveffi_jni_report_foreign(JNIEnv* env, weaveffi_error* out_err) { + jthrowable ex = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + jstring jmsg = NULL; + if (ex != NULL && weaveffi_jni_throwable_to_string != NULL) { + jmsg = (jstring)(*env)->CallObjectMethod(env, ex, weaveffi_jni_throwable_to_string); + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); jmsg = NULL; } + } + char* msg = weaveffi_jni_string_to_utf8(env, jmsg); + weaveffi_error_set(out_err, -4, msg != NULL ? msg : "callback interface implementation threw"); + free(msg); +} + +static void weaveffi_docs_SavedListener_jni_on_saved(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return; + } + jlong _a0 = (jlong)doc_id; + jint _a1 = (jint)revision; + (*env)->CallStaticVoidMethod(env, weaveffi_docs_SavedListener_jni_cls, weaveffi_docs_SavedListener_jni_mid_on_saved, (jobject)ctx, _a0, _a1); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); +} + +static void weaveffi_docs_SavedListener_jni_free(void* ctx) { + if (ctx == NULL) { return; } + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); } + weaveffi_jni_detach(attached); +} + +static const weaveffi_docs_SavedListener_vtable weaveffi_docs_SavedListener_jni_vtable = { weaveffi_docs_SavedListener_jni_on_saved, weaveffi_docs_SavedListener_jni_free }; + +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_createDocumentJni(JNIEnv* env, jclass clazz, jstring title, jbyteArray tags) { + weaveffi_error err = {0, NULL, NULL, 0}; + char* title_chars = weaveffi_jni_string_to_utf8(env, title); + jbyte* tags_elems = (*env)->GetByteArrayElements(env, tags, NULL); + jsize tags_len = (*env)->GetArrayLength(env, tags); + size_t out_len = 0; + const uint8_t* rv = weaveffi_docs_create_document(title_chars, (const uint8_t*)tags_elems, (size_t)tags_len, &out_len, &err); + free(title_chars); + (*env)->ReleaseByteArrayElements(env, tags, tags_elems, JNI_ABORT); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_getDocumentJni(JNIEnv* env, jclass clazz, jlong id) { + weaveffi_error err = {0, NULL, NULL, 0}; + size_t out_len = 0; + const uint8_t* rv = weaveffi_docs_get_document((int64_t)id, &out_len, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + +static void weaveffi_docs_publish_async_jni_cb(void* context, weaveffi_error* err, bool result) { + weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } + if (err != NULL && err->code != 0) { + const char* msg = err->message ? err->message : "WeaveFFI error"; + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); + jbyteArray jpayload = NULL; + if (err->payload_ptr != NULL) { + jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); + if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); } + } + jint jcode = (jint)err->code; + weaveffi_error_free(err); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); + (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); + } else { + jclass boxed_cls = (*env)->FindClass(env, "java/lang/Boolean"); + jobject boxed = (*env)->CallStaticObjectMethod(env, boxed_cls, (*env)->GetStaticMethodID(env, boxed_cls, "valueOf", "(Z)Ljava/lang/Boolean;"), result ? JNI_TRUE : JNI_FALSE); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); + (*env)->CallVoidMethod(env, ctx->callback, mid, boxed); + } + weaveffi_jni_handle_uncaught(env); + (*env)->DeleteGlobalRef(env, ctx->callback); + free(ctx); + weaveffi_jni_detach(attached); +} + +JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_publishAsyncAsync(JNIEnv* env, jclass clazz, jlong id, jobject callback) { + weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); + ctx->callback = (*env)->NewGlobalRef(env, callback); + weaveffi_docs_publish_async_async((int64_t)id, weaveffi_docs_publish_async_jni_cb, ctx); +} + +JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_watch(JNIEnv* env, jclass clazz, jobject listener) { + weaveffi_error err = {0, NULL, NULL, 0}; + jobject listener_ref = (*env)->NewGlobalRef(env, listener); + weaveffi_docs_watch((void*)listener_ref, &weaveffi_docs_SavedListener_jni_vtable, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + + } + return; +} + +JNIEXPORT jlong JNICALL Java_com_weaveffi_Notebook_nativeNew(JNIEnv* env, jclass clazz) { + weaveffi_error err = {0, NULL, NULL, 0}; + weaveffi_docs_Notebook* rv = weaveffi_docs_Notebook_new(&err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jlong)(intptr_t)rv; +} + +JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeMaxDocuments(JNIEnv* env, jclass clazz) { + weaveffi_error err = {0, NULL, NULL, 0}; + int32_t rv = weaveffi_docs_Notebook_max_documents(&err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jint) rv; +} + +JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeInsert(JNIEnv* env, jclass clazz, jlong selfHandle, jbyteArray document) { + weaveffi_error err = {0, NULL, NULL, 0}; + jbyte* document_elems = (*env)->GetByteArrayElements(env, document, NULL); + jsize document_len = (*env)->GetArrayLength(env, document); + int32_t rv = weaveffi_docs_Notebook_insert((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, (const uint8_t*)document_elems, (size_t)document_len, &err); + (*env)->ReleaseByteArrayElements(env, document, document_elems, JNI_ABORT); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jint) rv; +} + +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_Notebook_nativeFind(JNIEnv* env, jclass clazz, jlong selfHandle, jlong id) { + weaveffi_error err = {0, NULL, NULL, 0}; + size_t out_len = 0; + const uint8_t* rv = weaveffi_docs_Notebook_find((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, (int64_t)id, &out_len, &err); + if (err.code != 0) { + throw_weaveffi_docs_DocsErrors(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + +JNIEXPORT jint JNICALL Java_com_weaveffi_Notebook_nativeSize(JNIEnv* env, jclass clazz, jlong selfHandle) { + weaveffi_error err = {0, NULL, NULL, 0}; + int32_t rv = weaveffi_docs_Notebook_size((const weaveffi_docs_Notebook*)(intptr_t)selfHandle, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jint) rv; +} + +JNIEXPORT jlong JNICALL Java_com_weaveffi_Notebook_nativeClone(JNIEnv* env, jclass clazz, jlong handle) { + return (jlong)(intptr_t)weaveffi_docs_Notebook_clone((const weaveffi_docs_Notebook*)(intptr_t)handle); +} + +JNIEXPORT void JNICALL Java_com_weaveffi_Notebook_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { + weaveffi_docs_Notebook_destroy((weaveffi_docs_Notebook*)(intptr_t)handle); +} + + +// END weaveffi_jni.c diff --git a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap similarity index 73% rename from crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap index b4271bef..da3c4bf1 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_docs_everywhere__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap @@ -13,16 +13,18 @@ import kotlin.coroutines.resumeWithException class WeaveFFI { companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @Volatile private var callbackExceptionHandler: ((Throwable) -> Unit)? = null /** - * Installs a handler for exceptions thrown by listener callbacks and - * async continuations on native producer threads. These exceptions have - * no Kotlin caller to propagate to; when no handler is installed, they - * are logged with their stack trace and dropped. Pass `null` to - * restore the default logging behavior. + * Installs a handler for exceptions thrown while an async result is + * delivered on a native producer thread. These exceptions have no Kotlin + * caller to propagate to; when no handler is installed, they are logged + * with their stack trace and dropped. Pass `null` to restore the default + * logging behavior. Exceptions thrown by callback interface methods are + * not routed here: they abort the producer call that invoked them and + * surface to the original caller as a [WeaveFFIException] with code -4. */ @JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) { callbackExceptionHandler = handler @@ -34,13 +36,6 @@ class WeaveFFI { callbackExceptionHandler?.invoke(t) ?: throw t } - /** - * Subscribe to document-saved notifications - * - * Exceptions thrown by the callback cannot propagate to a caller (events fire on a producer thread); they are delivered to the handler installed via [setCallbackExceptionHandler], or logged with their stack trace and dropped when no handler is set. - */ - @JvmStatic external fun registerSavedListener(callback: (Long, Int) -> Unit): Long - @JvmStatic external fun unregisterSavedListener(id: Long) /** * Create a brand new document. * @@ -65,6 +60,11 @@ class WeaveFFI { @JvmStatic suspend fun publishAsync(id: Long): Boolean = suspendCancellableCoroutine { cont -> publishAsyncAsync(id, WeaveContinuation(cont) { code, message, _ -> WeaveFFIException(code, message) }) } + /** + * Register a listener for saved notifications + * @param listener Receiver of saved notifications + */ + @JvmStatic external fun watch(listener: SavedListener): Unit } } @@ -116,26 +116,57 @@ internal fun unpackDocument(r: WeaveBufferReader): Document = Document( r.readList { r.readString() }, ) +/** + * Consumer-implemented observer of document-saved notifications. + * + * Implementations must be safe to call from any thread. + */ +interface SavedListener { + /** + * Fires after a document has been persisted successfully + * @param docId Identifier of the saved document + * @param revision Monotonically increasing revision + */ + fun onSaved(docId: Long, revision: Int) +} + +/** JNI dispatch shims for [SavedListener]; the native vtable trampolines call these, nothing else does. */ +internal object SavedListenerJni { + @JvmStatic fun onSaved(impl: SavedListener, docId: Long, revision: Int) { impl.onSaved(docId, revision) } +} + /** * An ordered collection of documents. * * Notebooks own their documents and release them when destroyed. */ -class Notebook internal constructor(internal var handle: Long) : java.io.Closeable { +class Notebook private constructor(handle: Long) : AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + + /** The native pointer, borrowed for one call; throws after [close]. */ + internal val handle: Long get() = ref.get() + + /** A new strong reference, written into value buffers as an object token. */ + internal fun cloneHandle(): Long = nativeClone(ref.get()) + companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } + /** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */ + internal fun fromHandle(handle: Long): Notebook = Notebook(handle) @JvmStatic private external fun nativeNew(): Long @JvmStatic private external fun nativeMaxDocuments(): Int @JvmStatic private external fun nativeInsert(selfHandle: Long, document: ByteArray): Int @JvmStatic private external fun nativeFind(selfHandle: Long, id: Long): ByteArray @JvmStatic private external fun nativeSize(selfHandle: Long): Int + @JvmStatic private external fun nativeClone(handle: Long): Long @JvmStatic private external fun nativeDestroy(handle: Long) /** * Create an empty notebook */ - operator fun invoke(): Notebook = Notebook(nativeNew()) + operator fun invoke(): Notebook = Notebook.fromHandle(nativeNew()) /** * The largest number of documents one notebook will hold @@ -160,19 +191,13 @@ class Notebook internal constructor(internal var handle: Long) : java.io.Closeab */ fun size(): Int = nativeSize(handle) + /** Releases this wrapper's native reference; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } } -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ open class WeaveFFIException(val code: Int, message: String) : Exception(message) /** Typed error domain `DocsErrors` declared by module `docs`. */ @@ -192,6 +217,41 @@ sealed class DocsErrorsException(code: Int, message: String) : WeaveFFIException } } +/** Loads the native library once; every companion `init` calls [ensureLoaded]. */ +internal object WeaveNativeLibrary { + init { System.loadLibrary("weaveffi") } + + fun ensureLoaded() {} +} + +/** + * One strong native reference, released exactly once. Wrappers register an + * instance with [weaveCleaner] so an unreachable, never-closed wrapper still + * releases its reference; an explicit `close()` runs the same action first + * and the cleaner then does nothing. + */ +internal class WeaveNativeRef(handle: Long, private val release: (Long) -> Unit) : Runnable { + private val handle = java.util.concurrent.atomic.AtomicLong(handle) + + /** The live pointer, borrowed for the duration of one native call. */ + fun get(): Long { + val h = handle.get() + check(h != 0L) { "WeaveFFI object used after close()" } + return h + } + + /** The live pointer, or `0L` once released. */ + fun peek(): Long = handle.get() + + override fun run() { + val h = handle.getAndSet(0L) + if (h != 0L) release(h) + } +} + +/** The process-wide cleaner backing every generated wrapper's disposal backstop. */ +internal val weaveCleaner: java.lang.ref.Cleaner = java.lang.ref.Cleaner.create() + /** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ internal class WeaveBufferWriter { private var buf = ByteArray(32) @@ -287,6 +347,12 @@ internal class WeaveBufferReader(private val buf: ByteArray) { } fun readF32(): Float = Float.fromBits(readI32()) fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long { + val token = readI64() + if (token == 0L) malformed("null object token") + return token + } private fun readLen(): Int { val n = readI32() if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__build_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__build_gradle_kts.snap new file mode 100644 index 00000000..374b107b --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__build_gradle_kts.snap @@ -0,0 +1,36 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} + +// END build.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__settings_gradle.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__settings_gradle_kts.snap similarity index 79% rename from crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__settings_gradle.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__settings_gradle_kts.snap index f10a8b92..69449d7c 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__settings_gradle.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__settings_gradle_kts.snap @@ -5,6 +5,6 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o -rootProject.name = 'weaveffi' +rootProject.name = "weaveffi" -// END settings.gradle +// END settings.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_CMakeLists_txt.snap similarity index 94% rename from crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_CMakeLists_txt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_CMakeLists_txt.snap index e2c5c99f..32fa1b44 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_CMakeLists_txt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_CMakeLists_txt.snap @@ -6,7 +6,7 @@ source: crates/weaveffi-cli/tests/snapshots.rs # To regenerate: weaveffi generate weaveffi.yml -o cmake_minimum_required(VERSION 3.22) -project(weaveffi) +project(weaveffi_jni C) add_library(weaveffi SHARED weaveffi_jni.c) target_include_directories(weaveffi PRIVATE ../../../../c) diff --git a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_weaveffi_jni_c.snap similarity index 57% rename from crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_weaveffi_jni_c.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_weaveffi_jni_c.snap index 8c17d68f..89ee5663 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_cpp_weaveffi_jni_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_cpp_weaveffi_jni_c.snap @@ -10,15 +10,81 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include +#include #include "weaveffi.h" +/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified + UTF-8 would encode supplementary characters as surrogate pairs the + producer rejects). Lone surrogates become U+FFFD. Release with free(). + Returns NULL for a NULL string, for a string holding U+0000 (which no + C string can carry), or on allocation failure; the producer then + reports the null argument through its marshalling error. */ +static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) { + if (s == NULL) { return NULL; } + jsize n = (*env)->GetStringLength(env, s); + const jchar* u = (*env)->GetStringChars(env, s, NULL); + if (u == NULL) { return NULL; } + char* out = (char*)malloc((size_t)n * 4u + 1u); + if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; } + size_t o = 0; + for (jsize i = 0; i < n; i++) { + uint32_t c = u[i]; + if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; } + if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) { + c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u); + i++; + } + else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; } + if (c < 0x80u) { out[o++] = (char)c; } + else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + } + out[o] = 0; + (*env)->ReleaseStringChars(env, s, u); + return out; +} + +/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8 + and mangles supplementary characters). NULL yields the empty string and + a malformed sequence yields U+FFFD, so this never fails on producer + output; it returns NULL only when the JVM could not allocate. */ +static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) { + if (s == NULL) { s = ""; } + size_t n = strlen(s); + jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar)); + if (buf == NULL) { return NULL; } + size_t i = 0, o = 0; + while (i < n) { + unsigned char b = (unsigned char)s[i]; + uint32_t c; size_t len; + if (b < 0x80u) { c = b; len = 1; } + else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; } + else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; } + else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; } + else { c = 0xFFFDu; len = 1; } + if (i + len > n) { c = 0xFFFDu; len = 1; } + for (size_t k = 1; k < len; k++) { + unsigned char cb = (unsigned char)s[i + k]; + if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; } + c = (c << 6) | (cb & 0x3Fu); + } + i += len; + if (c > 0x10FFFFu) { c = 0xFFFDu; } + if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); } + else { buf[o++] = (jchar)c; } + } + jstring out = (*env)->NewString(env, buf, (jsize)o); + free(buf); + return out; +} + static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { const char* msg = err->message ? err->message : "WeaveFFI error"; jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); if (exClass != NULL) { jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); if (ex != NULL) { (*env)->Throw(env, ex); } } @@ -34,7 +100,7 @@ static void throw_weaveffi_edge_cases_EdgeErrors(JNIEnv* env, weaveffi_error* er return; } const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -45,26 +111,84 @@ static void throw_weaveffi_edge_cases_EdgeErrors(JNIEnv* env, weaveffi_error* er weaveffi_error_clear(err); } -typedef struct { - JavaVM* jvm; - jobject callback; -} weaveffi_jni_async_ctx; - +static JavaVM* weaveffi_jni_vm = NULL; static jclass weaveffi_jni_entry_class = NULL; static jmethodID weaveffi_jni_dispatch_exc = NULL; +static jmethodID weaveffi_jni_throwable_to_string = NULL; +static jclass weaveffi_edge_cases_BatchListener_jni_cls = NULL; +static jmethodID weaveffi_edge_cases_BatchListener_jni_mid_on_batch = NULL; +static jmethodID weaveffi_edge_cases_BatchListener_jni_mid_on_widget = NULL; +static jmethodID weaveffi_edge_cases_BatchListener_jni_mid_on_tick = NULL; +static jmethodID weaveffi_edge_cases_BatchListener_jni_mid_should_continue = NULL; +static jmethodID weaveffi_edge_cases_BatchListener_jni_mid_pick_mode = NULL; +static jclass weaveffi_edge_cases_Keyword_jni_cls = NULL; +static jmethodID weaveffi_edge_cases_Keyword_jni_mid_delete = NULL; +static jmethodID weaveffi_edge_cases_Keyword_jni_mid_import = NULL; + +static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) { + jclass cls = (*env)->FindClass(env, "java/lang/UnsatisfiedLinkError"); + if (cls != NULL) { (*env)->ThrowNew(env, cls, message); } + return JNI_ERR; +} JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { (void)reserved; + weaveffi_jni_vm = vm; JNIEnv* env = NULL; if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } - jclass cls = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); - if (cls == NULL) { (*env)->ExceptionClear(env); return JNI_VERSION_1_6; } - weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, cls); + if (weaveffi_abi_version() != 2u) { + return weaveffi_jni_load_error(env, "WeaveFFI: the native library implements a different C ABI revision than these bindings (expected 2)"); + } + jclass entry = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); + if (entry == NULL) { return JNI_ERR; } + weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, entry); weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, "dispatchCallbackException", "(Ljava/lang/Throwable;)V"); if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); } + jclass throwable = (*env)->FindClass(env, "java/lang/Throwable"); + if (throwable == NULL) { return JNI_ERR; } + weaveffi_jni_throwable_to_string = (*env)->GetMethodID(env, throwable, "toString", "()Ljava/lang/String;"); + if (weaveffi_jni_throwable_to_string == NULL) { return JNI_ERR; } + jclass weaveffi_edge_cases_BatchListener_jni_cls_local = (*env)->FindClass(env, "com/weaveffi/BatchListenerJni"); + if (weaveffi_edge_cases_BatchListener_jni_cls_local == NULL) { return JNI_ERR; } + weaveffi_edge_cases_BatchListener_jni_cls = (jclass)(*env)->NewGlobalRef(env, weaveffi_edge_cases_BatchListener_jni_cls_local); + weaveffi_edge_cases_BatchListener_jni_mid_on_batch = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_BatchListener_jni_cls, "onBatch", "(Lcom/weaveffi/BatchListener;[B[B[BI)V"); + if (weaveffi_edge_cases_BatchListener_jni_mid_on_batch == NULL) { return JNI_ERR; } + weaveffi_edge_cases_BatchListener_jni_mid_on_widget = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_BatchListener_jni_cls, "onWidget", "(Lcom/weaveffi/BatchListener;JJ[B)V"); + if (weaveffi_edge_cases_BatchListener_jni_mid_on_widget == NULL) { return JNI_ERR; } + weaveffi_edge_cases_BatchListener_jni_mid_on_tick = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_BatchListener_jni_cls, "onTick", "(Lcom/weaveffi/BatchListener;)V"); + if (weaveffi_edge_cases_BatchListener_jni_mid_on_tick == NULL) { return JNI_ERR; } + weaveffi_edge_cases_BatchListener_jni_mid_should_continue = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_BatchListener_jni_cls, "shouldContinue", "(Lcom/weaveffi/BatchListener;I)Z"); + if (weaveffi_edge_cases_BatchListener_jni_mid_should_continue == NULL) { return JNI_ERR; } + weaveffi_edge_cases_BatchListener_jni_mid_pick_mode = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_BatchListener_jni_cls, "pickMode", "(Lcom/weaveffi/BatchListener;)I"); + if (weaveffi_edge_cases_BatchListener_jni_mid_pick_mode == NULL) { return JNI_ERR; } + jclass weaveffi_edge_cases_Keyword_jni_cls_local = (*env)->FindClass(env, "com/weaveffi/KeywordJni"); + if (weaveffi_edge_cases_Keyword_jni_cls_local == NULL) { return JNI_ERR; } + weaveffi_edge_cases_Keyword_jni_cls = (jclass)(*env)->NewGlobalRef(env, weaveffi_edge_cases_Keyword_jni_cls_local); + weaveffi_edge_cases_Keyword_jni_mid_delete = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_Keyword_jni_cls, "delete", "(Lcom/weaveffi/Keyword;I)V"); + if (weaveffi_edge_cases_Keyword_jni_mid_delete == NULL) { return JNI_ERR; } + weaveffi_edge_cases_Keyword_jni_mid_import = (*env)->GetStaticMethodID(env, weaveffi_edge_cases_Keyword_jni_cls, "import", "(Lcom/weaveffi/Keyword;)I"); + if (weaveffi_edge_cases_Keyword_jni_mid_import == NULL) { return JNI_ERR; } return JNI_VERSION_1_6; } +/* Returns 1 when this call attached the thread (the caller must detach), + 0 otherwise. *env is NULL when no JNIEnv could be obtained. */ +static int weaveffi_jni_attach(JNIEnv** env) { + *env = NULL; + if (weaveffi_jni_vm == NULL) { return 0; } + if ((*weaveffi_jni_vm)->GetEnv(weaveffi_jni_vm, (void**)env, JNI_VERSION_1_6) == JNI_OK) { return 0; } + if ((*weaveffi_jni_vm)->AttachCurrentThread(weaveffi_jni_vm, (void*)env, NULL) != JNI_OK) { *env = NULL; return 0; } + return 1; +} + +static void weaveffi_jni_detach(int attached) { + if (attached) { (*weaveffi_jni_vm)->DetachCurrentThread(weaveffi_jni_vm); } +} + +typedef struct { + jobject callback; +} weaveffi_jni_async_ctx; + static void weaveffi_jni_handle_uncaught(JNIEnv* env) { if (!(*env)->ExceptionCheck(env)) { return; } jthrowable ex = (*env)->ExceptionOccurred(env); @@ -80,26 +204,30 @@ static void weaveffi_jni_handle_uncaught(JNIEnv* env) { (*env)->DeleteLocalRef(env, ex); } -typedef struct weaveffi_jni_listener_ctx { - JavaVM* jvm; - jobject callback; - uint64_t id; - struct weaveffi_jni_listener_ctx* next; -} weaveffi_jni_listener_ctx; - -static weaveffi_jni_listener_ctx* weaveffi_jni_listeners = NULL; -static pthread_mutex_t weaveffi_jni_listener_lock = PTHREAD_MUTEX_INITIALIZER; +static void weaveffi_jni_report_foreign(JNIEnv* env, weaveffi_error* out_err) { + jthrowable ex = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + jstring jmsg = NULL; + if (ex != NULL && weaveffi_jni_throwable_to_string != NULL) { + jmsg = (jstring)(*env)->CallObjectMethod(env, ex, weaveffi_jni_throwable_to_string); + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); jmsg = NULL; } + } + char* msg = weaveffi_jni_string_to_utf8(env, jmsg); + weaveffi_error_set(out_err, -4, msg != NULL ? msg : "callback interface implementation threw"); + free(msg); +} -static void weaveffi_edge_cases_OnBatch_fn_jni_tramp(const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context; +static void weaveffi_edge_cases_BatchListener_jni_on_batch(void* ctx, const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err) { JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return; - attached = 1; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; } - if ((*env)->PushLocalFrame(env, 32) != 0) { - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); return; } jbyteArray _a0 = (*env)->NewByteArray(env, (jsize)items_len); @@ -108,96 +236,173 @@ static void weaveffi_edge_cases_OnBatch_fn_jni_tramp(const uint8_t* items_ptr, s if (_a1 && raw_ptr) { (*env)->SetByteArrayRegion(env, _a1, 0, (jsize)raw_len, (const jbyte*)raw_ptr); } jbyteArray _a2 = (*env)->NewByteArray(env, (jsize)first_len); if (_a2 && first_ptr) { (*env)->SetByteArrayRegion(env, _a2, 0, (jsize)first_len, (const jbyte*)first_ptr); } - jclass _a3_cls = (*env)->FindClass(env, "java/lang/Integer"); - jobject _a3 = (*env)->CallStaticObjectMethod(env, _a3_cls, (*env)->GetStaticMethodID(env, _a3_cls, "valueOf", "(I)Ljava/lang/Integer;"), (jint)mode); - jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); - (*env)->CallObjectMethod(env, ctx->callback, invoke, _a0, _a1, _a2, _a3); - weaveffi_jni_handle_uncaught(env); + jint _a3 = (jint)mode; + (*env)->CallStaticVoidMethod(env, weaveffi_edge_cases_BatchListener_jni_cls, weaveffi_edge_cases_BatchListener_jni_mid_on_batch, (jobject)ctx, _a0, _a1, _a2, _a3); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + } (*env)->PopLocalFrame(env, NULL); - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + weaveffi_jni_detach(attached); } -static void weaveffi_edge_cases_OnTick_fn_jni_tramp(void* context) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context; +static void weaveffi_edge_cases_BatchListener_jni_on_widget(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, const uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err) { JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return; - attached = 1; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; } - if ((*env)->PushLocalFrame(env, 32) != 0) { - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); return; } - jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "()Ljava/lang/Object;"); - (*env)->CallObjectMethod(env, ctx->callback, invoke); - weaveffi_jni_handle_uncaught(env); + jlong _a0 = (jlong)(intptr_t)widget; + jlong _a1 = (jlong)(intptr_t)fallback; + jbyteArray _a2 = (*env)->NewByteArray(env, (jsize)children_len); + if (_a2 && children_ptr) { (*env)->SetByteArrayRegion(env, _a2, 0, (jsize)children_len, (const jbyte*)children_ptr); } + (*env)->CallStaticVoidMethod(env, weaveffi_edge_cases_BatchListener_jni_cls, weaveffi_edge_cases_BatchListener_jni_mid_on_widget, (jobject)ctx, _a0, _a1, _a2); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + } (*env)->PopLocalFrame(env, NULL); - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + weaveffi_jni_detach(attached); } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerBatchListenerJni(JNIEnv* env, jclass clazz, jobject callback) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - uint64_t id = weaveffi_edge_cases_register_batch_listener(weaveffi_edge_cases_OnBatch_fn_jni_tramp, ctx); - ctx->id = id; - pthread_mutex_lock(&weaveffi_jni_listener_lock); - ctx->next = weaveffi_jni_listeners; - weaveffi_jni_listeners = ctx; - pthread_mutex_unlock(&weaveffi_jni_listener_lock); - return (jlong)id; -} - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_unregisterBatchListener(JNIEnv* env, jclass clazz, jlong id) { - weaveffi_edge_cases_unregister_batch_listener((uint64_t)id); - pthread_mutex_lock(&weaveffi_jni_listener_lock); - weaveffi_jni_listener_ctx** link = &weaveffi_jni_listeners; - while (*link != NULL) { - if ((*link)->id == (uint64_t)id) { - weaveffi_jni_listener_ctx* found = *link; - *link = found->next; - (*env)->DeleteGlobalRef(env, found->callback); - free(found); - break; - } - link = &(*link)->next; +static void weaveffi_edge_cases_BatchListener_jni_on_tick(void* ctx, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; } - pthread_mutex_unlock(&weaveffi_jni_listener_lock); + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return; + } + (*env)->CallStaticVoidMethod(env, weaveffi_edge_cases_BatchListener_jni_cls, weaveffi_edge_cases_BatchListener_jni_mid_on_tick, (jobject)ctx); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerTickListener(JNIEnv* env, jclass clazz, jobject callback) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - uint64_t id = weaveffi_edge_cases_register_tick_listener(weaveffi_edge_cases_OnTick_fn_jni_tramp, ctx); - ctx->id = id; - pthread_mutex_lock(&weaveffi_jni_listener_lock); - ctx->next = weaveffi_jni_listeners; - weaveffi_jni_listeners = ctx; - pthread_mutex_unlock(&weaveffi_jni_listener_lock); - return (jlong)id; -} - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_unregisterTickListener(JNIEnv* env, jclass clazz, jlong id) { - weaveffi_edge_cases_unregister_tick_listener((uint64_t)id); - pthread_mutex_lock(&weaveffi_jni_listener_lock); - weaveffi_jni_listener_ctx** link = &weaveffi_jni_listeners; - while (*link != NULL) { - if ((*link)->id == (uint64_t)id) { - weaveffi_jni_listener_ctx* found = *link; - *link = found->next; - (*env)->DeleteGlobalRef(env, found->callback); - free(found); - break; - } - link = &(*link)->next; +static bool weaveffi_edge_cases_BatchListener_jni_should_continue(void* ctx, int32_t class, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return false; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return false; + } + jint _a0 = (jint)class; + jboolean _rv = (*env)->CallStaticBooleanMethod(env, weaveffi_edge_cases_BatchListener_jni_cls, weaveffi_edge_cases_BatchListener_jni_mid_should_continue, (jobject)ctx, _a0); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + _rv = (jboolean)false; + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); + return _rv == JNI_TRUE; +} + +static weaveffi_edge_cases_Mode weaveffi_edge_cases_BatchListener_jni_pick_mode(void* ctx, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return 0; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return 0; + } + jint _rv = (*env)->CallStaticIntMethod(env, weaveffi_edge_cases_BatchListener_jni_cls, weaveffi_edge_cases_BatchListener_jni_mid_pick_mode, (jobject)ctx); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + _rv = (jint)0; + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); + return (weaveffi_edge_cases_Mode)_rv; +} + +static void weaveffi_edge_cases_BatchListener_jni_free(void* ctx) { + if (ctx == NULL) { return; } + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); } + weaveffi_jni_detach(attached); +} + +static const weaveffi_edge_cases_BatchListener_vtable weaveffi_edge_cases_BatchListener_jni_vtable = { weaveffi_edge_cases_BatchListener_jni_on_batch, weaveffi_edge_cases_BatchListener_jni_on_widget, weaveffi_edge_cases_BatchListener_jni_on_tick, weaveffi_edge_cases_BatchListener_jni_should_continue, weaveffi_edge_cases_BatchListener_jni_pick_mode, weaveffi_edge_cases_BatchListener_jni_free }; + +static void weaveffi_edge_cases_Keyword_jni_delete(void* ctx, int32_t self, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return; + } + jint _a0 = (jint)self; + (*env)->CallStaticVoidMethod(env, weaveffi_edge_cases_Keyword_jni_cls, weaveffi_edge_cases_Keyword_jni_mid_delete, (jobject)ctx, _a0); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); } - pthread_mutex_unlock(&weaveffi_jni_listener_lock); + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); +} + +static int32_t weaveffi_edge_cases_Keyword_jni_import(void* ctx, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return 0; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return 0; + } + jint _rv = (*env)->CallStaticIntMethod(env, weaveffi_edge_cases_Keyword_jni_cls, weaveffi_edge_cases_Keyword_jni_mid_import, (jobject)ctx); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + _rv = (jint)0; + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); + return (int32_t)_rv; } +static void weaveffi_edge_cases_Keyword_jni_free(void* ctx) { + if (ctx == NULL) { return; } + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); } + weaveffi_jni_detach(attached); +} + +static const weaveffi_edge_cases_Keyword_vtable weaveffi_edge_cases_Keyword_jni_vtable = { weaveffi_edge_cases_Keyword_jni_delete, weaveffi_edge_cases_Keyword_jni_import, weaveffi_edge_cases_Keyword_jni_free }; + JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_noop(JNIEnv* env, jclass clazz) { weaveffi_error err = {0, NULL, NULL, 0}; weaveffi_edge_cases_noop(&err); @@ -210,13 +415,13 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_noop(JNIEnv* env, jclass clazz JNIEXPORT jint JNICALL Java_com_weaveffi_WeaveFFI_selectJni(JNIEnv* env, jclass clazz, jint class, jstring from, jbyteArray func, jbyteArray range, jboolean type_) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* from_chars = (*env)->GetStringUTFChars(env, from, NULL); + char* from_chars = weaveffi_jni_string_to_utf8(env, from); jbyte* func_elems = (*env)->GetByteArrayElements(env, func, NULL); jsize func_len = (*env)->GetArrayLength(env, func); jbyte* range_elems = (*env)->GetByteArrayElements(env, range, NULL); jsize range_len = (*env)->GetArrayLength(env, range); int32_t rv = weaveffi_edge_cases_select((int32_t)class, from_chars, (const uint8_t*)func_elems, (size_t)func_len, (const uint8_t*)range_elems, (size_t)range_len, (bool)(type_ == JNI_TRUE), &err); - (*env)->ReleaseStringUTFChars(env, from, from_chars); + free(from_chars); (*env)->ReleaseByteArrayElements(env, func, func_elems, JNI_ABORT); (*env)->ReleaseByteArrayElements(env, range, range_elems, JNI_ABORT); if (err.code != 0) { @@ -275,15 +480,11 @@ JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_optionalParamsJni(JNIEnv return out; } -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_mutableParams(JNIEnv* env, jclass clazz, jstring text, jbyteArray blob) { +JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_registerBatch(JNIEnv* env, jclass clazz, jobject listener, jobject keyword) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* text_chars = (*env)->GetStringUTFChars(env, text, NULL); - jboolean blob_is_copy = 0; - jbyte* blob_elems = (*env)->GetByteArrayElements(env, blob, &blob_is_copy); - jsize blob_len = (*env)->GetArrayLength(env, blob); - weaveffi_edge_cases_mutable_params(text_chars, (const uint8_t*)blob_elems, (size_t)blob_len, &err); - (*env)->ReleaseStringUTFChars(env, text, text_chars); - (*env)->ReleaseByteArrayElements(env, blob, blob_elems, 0); + jobject listener_ref = (*env)->NewGlobalRef(env, listener); + jobject keyword_ref = (*env)->NewGlobalRef(env, keyword); + weaveffi_edge_cases_register_batch((void*)listener_ref, &weaveffi_edge_cases_BatchListener_jni_vtable, (void*)keyword_ref, &weaveffi_edge_cases_Keyword_jni_vtable, &err); if (err.code != 0) { throw_weaveffi_error(env, &err); @@ -291,16 +492,89 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_mutableParams(JNIEnv* env, jcl return; } +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_widgetsEverywhereJni(JNIEnv* env, jclass clazz, jbyteArray list, jbyteArray by_name, jbyteArray sparse, jbyteArray holder) { + weaveffi_error err = {0, NULL, NULL, 0}; + jbyte* list_elems = (*env)->GetByteArrayElements(env, list, NULL); + jsize list_len = (*env)->GetArrayLength(env, list); + jbyte* by_name_elems = (*env)->GetByteArrayElements(env, by_name, NULL); + jsize by_name_len = (*env)->GetArrayLength(env, by_name); + jbyte* sparse_elems = (*env)->GetByteArrayElements(env, sparse, NULL); + jsize sparse_len = (*env)->GetArrayLength(env, sparse); + jbyte* holder_elems = (*env)->GetByteArrayElements(env, holder, NULL); + jsize holder_len = (*env)->GetArrayLength(env, holder); + size_t out_len = 0; + const uint8_t* rv = weaveffi_edge_cases_widgets_everywhere((const uint8_t*)list_elems, (size_t)list_len, (const uint8_t*)by_name_elems, (size_t)by_name_len, (const uint8_t*)sparse_elems, (size_t)sparse_len, (const uint8_t*)holder_elems, (size_t)holder_len, &out_len, &err); + (*env)->ReleaseByteArrayElements(env, list, list_elems, JNI_ABORT); + (*env)->ReleaseByteArrayElements(env, by_name, by_name_elems, JNI_ABORT); + (*env)->ReleaseByteArrayElements(env, sparse, sparse_elems, JNI_ABORT); + (*env)->ReleaseByteArrayElements(env, holder, holder_elems, JNI_ABORT); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + +JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_widgetStreamJni(JNIEnv* env, jclass clazz) { + weaveffi_error err = {0, NULL, NULL, 0}; + weaveffi_edge_cases_WidgetStreamIterator* _iter = weaveffi_edge_cases_widget_stream(&err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jlong)(intptr_t)_iter; +} + +static void weaveffi_edge_cases_async_widget_jni_cb(void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result) { + weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } + if (err != NULL && err->code != 0) { + const char* msg = err->message ? err->message : "WeaveFFI error"; + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); + jbyteArray jpayload = NULL; + if (err->payload_ptr != NULL) { + jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); + if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); } + } + jint jcode = (jint)err->code; + weaveffi_error_free(err); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); + (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); + } else { + jclass boxed_cls = (*env)->FindClass(env, "java/lang/Long"); + jobject boxed = (*env)->CallStaticObjectMethod(env, boxed_cls, (*env)->GetStaticMethodID(env, boxed_cls, "valueOf", "(J)Ljava/lang/Long;"), (jlong)(intptr_t)result); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); + (*env)->CallVoidMethod(env, ctx->callback, mid, boxed); + } + weaveffi_jni_handle_uncaught(env); + (*env)->DeleteGlobalRef(env, ctx->callback); + free(ctx); + weaveffi_jni_detach(attached); +} + +JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncWidgetAsync(JNIEnv* env, jclass clazz, jobject callback) { + weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); + ctx->callback = (*env)->NewGlobalRef(env, callback); + weaveffi_edge_cases_async_widget_async(weaveffi_edge_cases_async_widget_jni_cb, ctx); +} + JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_makeWidgetJni(JNIEnv* env, jclass clazz, jstring label) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* label_chars = (*env)->GetStringUTFChars(env, label, NULL); - void* rv = weaveffi_edge_cases_make_widget(label_chars, &err); - (*env)->ReleaseStringUTFChars(env, label, label_chars); + char* label_chars = weaveffi_jni_string_to_utf8(env, label); + weaveffi_edge_cases_Widget* rv = weaveffi_edge_cases_make_widget(label_chars, &err); + free(label_chars); if (err.code != 0) { throw_weaveffi_error(env, &err); return 0; } - return (jlong)(intptr_t) rv; + return (jlong)(intptr_t)rv; } JNIEXPORT jobject JNICALL Java_com_weaveffi_WeaveFFI_findWidgetJni(JNIEnv* env, jclass clazz, jlong id) { @@ -329,21 +603,11 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_inspectWidgetJni(JNIEnv* en throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_typedHandleParam(JNIEnv* env, jclass clazz, jlong token, jlong raw) { - weaveffi_error err = {0, NULL, NULL, 0}; - weaveffi_edge_cases_Leaf* rv = weaveffi_edge_cases_typed_handle_param((weaveffi_edge_cases_Leaf*)(intptr_t)token, (weaveffi_handle_t)raw, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return 0; - } - return (jlong)(intptr_t)rv; -} - JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_stringStreamJni(JNIEnv* env, jclass clazz) { weaveffi_error err = {0, NULL, NULL, 0}; weaveffi_edge_cases_StringStreamIterator* _iter = weaveffi_edge_cases_string_stream(&err); @@ -377,14 +641,11 @@ JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_modeStreamJni(JNIEnv* env, jc static void weaveffi_edge_cases_async_void_jni_cb(void* context, weaveffi_error* err) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -403,14 +664,12 @@ static void weaveffi_edge_cases_async_void_jni_cb(void* context, weaveffi_error* } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncVoidAsync(JNIEnv* env, jclass clazz, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_void_async(weaveffi_edge_cases_async_void_jni_cb, ctx); } @@ -418,14 +677,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncVoidAsync(JNIEnv* env, jc static void weaveffi_edge_cases_async_bytes_jni_cb(void* context, weaveffi_error* err, const uint8_t* result, size_t result_len) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -446,14 +702,12 @@ static void weaveffi_edge_cases_async_bytes_jni_cb(void* context, weaveffi_error } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncBytesAsync(JNIEnv* env, jclass clazz, jlong size, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_bytes_async((uint32_t)size, weaveffi_edge_cases_async_bytes_jni_cb, ctx); } @@ -461,14 +715,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncBytesAsync(JNIEnv* env, j static void weaveffi_edge_cases_async_struct_jni_cb(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -489,14 +740,12 @@ static void weaveffi_edge_cases_async_struct_jni_cb(void* context, weaveffi_erro } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncStructAsync(JNIEnv* env, jclass clazz, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_struct_async(weaveffi_edge_cases_async_struct_jni_cb, ctx); } @@ -504,14 +753,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncStructAsync(JNIEnv* env, static void weaveffi_edge_cases_async_list_jni_cb(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -532,14 +778,12 @@ static void weaveffi_edge_cases_async_list_jni_cb(void* context, weaveffi_error* } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncListAsync(JNIEnv* env, jclass clazz, jlong cancelToken, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_list_async((weaveffi_cancel_token*)(intptr_t)cancelToken, weaveffi_edge_cases_async_list_jni_cb, ctx); } @@ -547,14 +791,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncListAsync(JNIEnv* env, jc static void weaveffi_edge_cases_async_optional_jni_cb(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -575,14 +816,12 @@ static void weaveffi_edge_cases_async_optional_jni_cb(void* context, weaveffi_er } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncOptionalAsync(JNIEnv* env, jclass clazz, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_optional_async(weaveffi_edge_cases_async_optional_jni_cb, ctx); } @@ -590,14 +829,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncOptionalAsync(JNIEnv* env static void weaveffi_edge_cases_async_enum_jni_cb(void* context, weaveffi_error* err, weaveffi_edge_cases_Mode result) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -609,23 +845,20 @@ static void weaveffi_edge_cases_async_enum_jni_cb(void* context, weaveffi_error* jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); } else { - jclass boxCls = (*env)->FindClass(env, "java/lang/Integer"); - jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, "valueOf", "(I)Ljava/lang/Integer;"); - jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jint)result); + jclass boxed_cls = (*env)->FindClass(env, "java/lang/Integer"); + jobject boxed = (*env)->CallStaticObjectMethod(env, boxed_cls, (*env)->GetStaticMethodID(env, boxed_cls, "valueOf", "(I)Ljava/lang/Integer;"), (jint)result); jclass cls = (*env)->GetObjectClass(env, ctx->callback); jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); (*env)->CallVoidMethod(env, ctx->callback, mid, boxed); } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncEnumAsync(JNIEnv* env, jclass clazz, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_async_enum_async(weaveffi_edge_cases_async_enum_jni_cb, ctx); } @@ -633,14 +866,11 @@ JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncEnumAsync(JNIEnv* env, jc static void weaveffi_edge_cases_async_keywords_jni_cb(void* context, weaveffi_error* err, const char* result) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -652,7 +882,7 @@ static void weaveffi_edge_cases_async_keywords_jni_cb(void* context, weaveffi_er jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); } else { - jobject boxed = result ? (jobject)(*env)->NewStringUTF(env, result) : (jobject)(*env)->NewStringUTF(env, ""); + jobject boxed = (jobject)weaveffi_jni_utf8_to_string(env, result); weaveffi_free_string(result); jclass cls = (*env)->GetObjectClass(env, ctx->callback); jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); @@ -660,18 +890,16 @@ static void weaveffi_edge_cases_async_keywords_jni_cb(void* context, weaveffi_er } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_asyncKeywordsAsync(JNIEnv* env, jclass clazz, jstring class, jint import, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); - const char* class_chars = (*env)->GetStringUTFChars(env, class, NULL); + char* class_chars = weaveffi_jni_string_to_utf8(env, class); weaveffi_edge_cases_async_keywords_async(class_chars, (int32_t)import, weaveffi_edge_cases_async_keywords_jni_cb, ctx); - (*env)->ReleaseStringUTFChars(env, class, class_chars); + free(class_chars); } JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_keywordsRoundtripJni(JNIEnv* env, jclass clazz, jbyteArray value) { @@ -717,17 +945,17 @@ JNIEXPORT void JNICALL Java_com_weaveffi_Widget_nativeRefresh(JNIEnv* env, jclas JNIEXPORT jstring JNICALL Java_com_weaveffi_Widget_nativeRename(JNIEnv* env, jclass clazz, jlong selfHandle, jstring class, jbyteArray import) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* class_chars = (*env)->GetStringUTFChars(env, class, NULL); + char* class_chars = weaveffi_jni_string_to_utf8(env, class); jbyte* import_elems = (*env)->GetByteArrayElements(env, import, NULL); jsize import_len = (*env)->GetArrayLength(env, import); const char* rv = weaveffi_edge_cases_Widget_rename((const weaveffi_edge_cases_Widget*)(intptr_t)selfHandle, class_chars, (const uint8_t*)import_elems, (size_t)import_len, &err); - (*env)->ReleaseStringUTFChars(env, class, class_chars); + free(class_chars); (*env)->ReleaseByteArrayElements(env, import, import_elems, JNI_ABORT); if (err.code != 0) { throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } @@ -735,14 +963,11 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_Widget_nativeRename(JNIEnv* env, jcl static void weaveffi_edge_cases_Widget_fetch_jni_cb(void* context, weaveffi_error* err, const uint8_t* result_ptr, size_t result_len) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -763,14 +988,12 @@ static void weaveffi_edge_cases_Widget_fetch_jni_cb(void* context, weaveffi_erro } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_Widget_nativeFetchAsync(JNIEnv* env, jclass clazz, jlong selfHandle, jlong id, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); weaveffi_edge_cases_Widget_fetch_async((const weaveffi_edge_cases_Widget*)(intptr_t)selfHandle, (int64_t)id, weaveffi_edge_cases_Widget_fetch_jni_cb, ctx); } @@ -782,25 +1005,29 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_Widget_nativeOldName(JNIEnv* env, jc throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } +JNIEXPORT jlong JNICALL Java_com_weaveffi_Widget_nativeClone(JNIEnv* env, jclass clazz, jlong handle) { + return (jlong)(intptr_t)weaveffi_edge_cases_Widget_clone((const weaveffi_edge_cases_Widget*)(intptr_t)handle); +} + JNIEXPORT void JNICALL Java_com_weaveffi_Widget_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { weaveffi_edge_cases_Widget_destroy((weaveffi_edge_cases_Widget*)(intptr_t)handle); } JNIEXPORT jlong JNICALL Java_com_weaveffi_Vault_nativeOpen(JNIEnv* env, jclass clazz, jstring path) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* path_chars = (*env)->GetStringUTFChars(env, path, NULL); - void* rv = weaveffi_edge_cases_Vault_open(path_chars, &err); - (*env)->ReleaseStringUTFChars(env, path, path_chars); + char* path_chars = weaveffi_jni_string_to_utf8(env, path); + weaveffi_edge_cases_Vault* rv = weaveffi_edge_cases_Vault_open(path_chars, &err); + free(path_chars); if (err.code != 0) { throw_weaveffi_edge_cases_EdgeErrors(env, &err); return 0; } - return (jlong)(intptr_t) rv; + return (jlong)(intptr_t)rv; } JNIEXPORT jboolean JNICALL Java_com_weaveffi_Vault_nativeProbe(JNIEnv* env, jclass clazz) { @@ -826,10 +1053,36 @@ JNIEXPORT jobject JNICALL Java_com_weaveffi_Vault_nativePeek(JNIEnv* env, jclass return (*env)->CallStaticObjectMethod(env, box_cls, box_mid, (jlong)(intptr_t)rv); } +JNIEXPORT jlong JNICALL Java_com_weaveffi_Vault_nativeClone(JNIEnv* env, jclass clazz, jlong handle) { + return (jlong)(intptr_t)weaveffi_edge_cases_Vault_clone((const weaveffi_edge_cases_Vault*)(intptr_t)handle); +} + JNIEXPORT void JNICALL Java_com_weaveffi_Vault_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { weaveffi_edge_cases_Vault_destroy((weaveffi_edge_cases_Vault*)(intptr_t)handle); } +JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EdgeCasesWidgetStreamIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) { + weaveffi_edge_cases_WidgetStreamIterator* _iter = (weaveffi_edge_cases_WidgetStreamIterator*)(intptr_t)handle; + weaveffi_edge_cases_Widget* _item = (weaveffi_edge_cases_Widget*)0; + weaveffi_error err = {0, NULL, NULL, 0}; + int32_t _has = weaveffi_edge_cases_WidgetStreamIterator_next(_iter, &_item, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + if (_has == 0) { return NULL; } + jclass _jitem_cls = (*env)->FindClass(env, "java/lang/Long"); + jobject _jitem = (*env)->CallStaticObjectMethod(env, _jitem_cls, (*env)->GetStaticMethodID(env, _jitem_cls, "valueOf", "(J)Ljava/lang/Long;"), (jlong)(intptr_t)_item); + jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object"); + jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL); + (*env)->SetObjectArrayElement(env, _slot, 0, _jitem); + return _slot; +} + +JNIEXPORT void JNICALL Java_com_weaveffi_EdgeCasesWidgetStreamIterator_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { + weaveffi_edge_cases_WidgetStreamIterator_destroy((weaveffi_edge_cases_WidgetStreamIterator*)(intptr_t)handle); +} + JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EdgeCasesStringStreamIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) { weaveffi_edge_cases_StringStreamIterator* _iter = (weaveffi_edge_cases_StringStreamIterator*)(intptr_t)handle; const char* _item = (const char*)0; @@ -840,7 +1093,7 @@ JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EdgeCasesStringStreamIterator_n return NULL; } if (_has == 0) { return NULL; } - jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, ""); + jstring _jitem = weaveffi_jni_utf8_to_string(env, _item); weaveffi_free_string(_item); jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object"); jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL); diff --git a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap similarity index 67% rename from crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap index 12e39169..e107e3e8 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_edge_cases__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap @@ -13,16 +13,18 @@ import kotlin.coroutines.resumeWithException class WeaveFFI { companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @Volatile private var callbackExceptionHandler: ((Throwable) -> Unit)? = null /** - * Installs a handler for exceptions thrown by listener callbacks and - * async continuations on native producer threads. These exceptions have - * no Kotlin caller to propagate to; when no handler is installed, they - * are logged with their stack trace and dropped. Pass `null` to - * restore the default logging behavior. + * Installs a handler for exceptions thrown while an async result is + * delivered on a native producer thread. These exceptions have no Kotlin + * caller to propagate to; when no handler is installed, they are logged + * with their stack trace and dropped. Pass `null` to restore the default + * logging behavior. Exceptions thrown by callback interface methods are + * not routed here: they abort the producer call that invoked them and + * surface to the original caller as a [WeaveFFIException] with code -4. */ @JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) { callbackExceptionHandler = handler @@ -34,13 +36,6 @@ class WeaveFFI { callbackExceptionHandler?.invoke(t) ?: throw t } - /** Exceptions thrown by the callback cannot propagate to a caller (events fire on a producer thread); they are delivered to the handler installed via [setCallbackExceptionHandler], or logged with their stack trace and dropped when no handler is set. */ - @JvmStatic private external fun registerBatchListenerJni(callback: (ByteArray, ByteArray, ByteArray, Int) -> Unit): Long - @JvmStatic fun registerBatchListener(callback: (List, ByteArray, Outer?, Int) -> Unit): Long = registerBatchListenerJni { a0, a1, a2, a3 -> callback(weaveDecode(a0) { r -> r.readList { unpackOuter(r) } }, a1, weaveDecode(a2) { r -> r.readOptional { unpackOuter(r) } }, a3) } - @JvmStatic external fun unregisterBatchListener(id: Long) - /** Exceptions thrown by the callback cannot propagate to a caller (events fire on a producer thread); they are delivered to the handler installed via [setCallbackExceptionHandler], or logged with their stack trace and dropped when no handler is set. */ - @JvmStatic external fun registerTickListener(callback: () -> Unit): Long - @JvmStatic external fun unregisterTickListener(id: Long) /** * No parameters and no result */ @@ -58,25 +53,44 @@ class WeaveFFI { @JvmStatic private external fun optionalParamsJni(text: ByteArray, count: ByteArray, outer: ByteArray, tags: ByteArray, blob: ByteArray, mode: ByteArray): ByteArray @JvmStatic fun optionalParams(text: String?, count: Int?, outer: Outer?, tags: List?, blob: ByteArray?, mode: Mode?): Mode? = weaveDecode(optionalParamsJni(weaveEncode { w -> w.writeOptional(text) { v0 -> w.writeString(v0) } }, weaveEncode { w -> w.writeOptional(count) { v0 -> w.writeI32(v0) } }, weaveEncode { w -> w.writeOptional(outer) { v0 -> packOuter(w, v0) } }, weaveEncode { w -> w.writeOptional(tags) { v0 -> w.writeList(v0) { v1 -> w.writeString(v1) } } }, weaveEncode { w -> w.writeOptional(blob) { v0 -> w.writeBytes(v0) } }, weaveEncode { w -> w.writeOptional(mode) { v0 -> w.writeI32(v0.value) } })) { r -> r.readOptional { Mode.fromValue(r.readI32()) } } /** - * Write-back parameters + * Two callback interface parameters on one function */ - @JvmStatic external fun mutableParams(text: String, blob: ByteArray): Unit + @JvmStatic external fun registerBatch(listener: BatchListener, keyword: Keyword): Unit + /** + * Objects in every buffered position + */ + @JvmStatic private external fun widgetsEverywhereJni(list: ByteArray, byName: ByteArray, sparse: ByteArray, holder: ByteArray): ByteArray + @JvmStatic fun widgetsEverywhere(list: List, byName: Map, sparse: List, holder: WidgetHolder): WidgetHolder = weaveDecode(widgetsEverywhereJni(weaveEncode { w -> w.writeList(list) { v0 -> w.writeI64(v0.cloneHandle()) } }, weaveEncode { w -> w.writeMap(byName, { k0 -> w.writeString(k0) }, { v0 -> w.writeI64(v0.cloneHandle()) }) }, weaveEncode { w -> w.writeList(sparse) { v0 -> w.writeOptional(v0) { v1 -> w.writeI64(v1.cloneHandle()) } } }, weaveEncode { w -> packWidgetHolder(w, holder) })) { r -> unpackWidgetHolder(r) } + /** + * Iterator of objects + */ + @JvmStatic private external fun widgetStreamJni(): Long + @JvmStatic fun widgetStream(): Iterator = EdgeCasesWidgetStreamIterator(widgetStreamJni()) + /** + * Async returning an object + */ + @JvmStatic private external fun asyncWidgetAsync(callback: Any) + @JvmStatic suspend fun asyncWidget(): Widget { + val raw: Long = suspendCancellableCoroutine { cont -> + asyncWidgetAsync(WeaveContinuation(cont) { code, message, payload -> EdgeErrorsException.fromCode(code, message, payload) }) + } + return Widget.fromHandle(raw) + } /** * Factory for an interface with no constructor */ @JvmStatic private external fun makeWidgetJni(label: String): Long - @JvmStatic fun makeWidget(label: String): Widget = Widget(makeWidgetJni(label)) + @JvmStatic fun makeWidget(label: String): Widget = Widget.fromHandle(makeWidgetJni(label)) /** * Optional interface return */ @JvmStatic private external fun findWidgetJni(id: Long): Long? - @JvmStatic fun findWidget(id: Long): Widget? = findWidgetJni(id)?.let { Widget(it) } + @JvmStatic fun findWidget(id: Long): Widget? = findWidgetJni(id)?.let { Widget.fromHandle(it) } /** * Interface parameter, including an optional one */ @JvmStatic private external fun inspectWidgetJni(widget: Long, fallback: Long?): String @JvmStatic fun inspectWidget(widget: Widget, fallback: Widget?): String = inspectWidgetJni(widget.handle, fallback?.handle) - @JvmStatic external fun typedHandleParam(token: Long, raw: Long): Long /** * Iterator of strings */ @@ -354,15 +368,86 @@ internal fun unpackRetired(r: WeaveBufferReader): Retired = Retired( r.readI64(), ) +/** Record carrying objects in every buffered position */ +data class WidgetHolder(val primary: Widget, val fallback: Widget?, val extras: List, val by_name: Map) + +internal fun packWidgetHolder(w: WeaveBufferWriter, v: WidgetHolder) { + w.writeI64(v.primary.cloneHandle()) + w.writeOptional(v.fallback) { v0 -> w.writeI64(v0.cloneHandle()) } + w.writeList(v.extras) { v0 -> w.writeI64(v0.cloneHandle()) } + w.writeMap(v.by_name, { k0 -> w.writeString(k0) }, { v0 -> w.writeI64(v0.cloneHandle()) }) +} + +internal fun unpackWidgetHolder(r: WeaveBufferReader): WidgetHolder = WidgetHolder( + Widget.fromHandle(r.readObject()), + r.readOptional { Widget.fromHandle(r.readObject()) }, + r.readList { Widget.fromHandle(r.readObject()) }, + r.readMap({ r.readString() }, { Widget.fromHandle(r.readObject()) }), +) + +/** Callback interface carrying composite payloads and objects */ +interface BatchListener { + /** + * Composite payloads cross as value buffers + */ + fun onBatch(items: List, raw: ByteArray, first: Outer?, mode: Mode) + /** + * Objects cross as strong references the consumer must release + */ + fun onWidget(widget: Widget, fallback: Widget?, children: List) + fun onTick() + /** + * Scalar return travels back through the vtable + */ + fun shouldContinue(class_: Int): Boolean + /** + * Enum return travels back as its integer discriminant + */ + fun pickMode(): Mode +} + +/** JNI dispatch shims for [BatchListener]; the native vtable trampolines call these, nothing else does. */ +internal object BatchListenerJni { + @JvmStatic fun onBatch(impl: BatchListener, items: ByteArray, raw: ByteArray, first: ByteArray, mode: Int) { impl.onBatch(weaveDecode(items) { r -> r.readList { unpackOuter(r) } }, raw, weaveDecode(first) { r -> r.readOptional { unpackOuter(r) } }, Mode.fromValue(mode)) } + @JvmStatic fun onWidget(impl: BatchListener, widget: Long, fallback: Long, children: ByteArray) { impl.onWidget(Widget.fromHandle(widget), fallback.takeIf { it != 0L }?.let { Widget.fromHandle(it) }, weaveDecode(children) { r -> r.readList { Widget.fromHandle(r.readObject()) } }) } + @JvmStatic fun onTick(impl: BatchListener) { impl.onTick() } + @JvmStatic fun shouldContinue(impl: BatchListener, class_: Int): Boolean = impl.shouldContinue(class_) + @JvmStatic fun pickMode(impl: BatchListener): Int = impl.pickMode().value +} + +/** Callback interface whose name and methods are keywords */ +interface Keyword { + fun delete(self: Int) + fun import(): Int +} + +/** JNI dispatch shims for [Keyword]; the native vtable trampolines call these, nothing else does. */ +internal object KeywordJni { + @JvmStatic fun delete(impl: Keyword, self: Int) { impl.delete(self) } + @JvmStatic fun import(impl: Keyword): Int = impl.import() +} + /** Interface with no constructor; instances come from make_widget */ -class Widget internal constructor(internal var handle: Long) : java.io.Closeable { +class Widget private constructor(handle: Long) : AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + + /** The native pointer, borrowed for one call; throws after [close]. */ + internal val handle: Long get() = ref.get() + + /** A new strong reference, written into value buffers as an object token. */ + internal fun cloneHandle(): Long = nativeClone(ref.get()) + companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } + /** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */ + internal fun fromHandle(handle: Long): Widget = Widget(handle) @JvmStatic private external fun nativeRefresh(selfHandle: Long): Unit @JvmStatic private external fun nativeRename(selfHandle: Long, class_: String, import: ByteArray): String @JvmStatic private external fun nativeFetchAsync(selfHandle: Long, id: Long, callback: Any) @JvmStatic private external fun nativeOldName(selfHandle: Long): String + @JvmStatic private external fun nativeClone(handle: Long): Long @JvmStatic private external fun nativeDestroy(handle: Long) } @@ -386,32 +471,38 @@ class Widget internal constructor(internal var handle: Long) : java.io.Closeable @Deprecated("Use rename instead") fun oldName(): String = nativeOldName(handle) + /** Releases this wrapper's native reference; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } } /** Deprecated interface with a throwing constructor */ -class Vault internal constructor(internal var handle: Long) : java.io.Closeable { +class Vault private constructor(handle: Long) : AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + + /** The native pointer, borrowed for one call; throws after [close]. */ + internal val handle: Long get() = ref.get() + + /** A new strong reference, written into value buffers as an object token. */ + internal fun cloneHandle(): Long = nativeClone(ref.get()) + companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } + /** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */ + internal fun fromHandle(handle: Long): Vault = Vault(handle) @JvmStatic private external fun nativeOpen(path: String): Long @JvmStatic private external fun nativeProbe(): Boolean @JvmStatic private external fun nativePeek(selfHandle: Long): Long? + @JvmStatic private external fun nativeClone(handle: Long): Long @JvmStatic private external fun nativeDestroy(handle: Long) /** * Fails when the vault is busy */ - fun open(path: String): Vault = Vault(nativeOpen(path)) + fun open(path: String): Vault = Vault.fromHandle(nativeOpen(path)) /** * Throwing static @@ -419,17 +510,54 @@ class Vault internal constructor(internal var handle: Long) : java.io.Closeable fun probe(): Boolean = nativeProbe() } - fun peek(): Widget? = nativePeek(handle)?.let { Widget(it) } + fun peek(): Widget? = nativePeek(handle)?.let { Widget.fromHandle(it) } + /** Releases this wrapper's native reference; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L + cleanable.clean() + } +} + +/** + * A lazy iterator over the `Widget` elements streamed by [widgetStream]. Each step pulls + * exactly one element from the native producer. The native handle is + * released when the producer is exhausted, when [close] is called, or by + * the `Cleaner` if the iterator is abandoned, whichever comes first. + */ +class EdgeCasesWidgetStreamIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + private var nextSlot: Array? = null + + override fun hasNext(): Boolean { + if (nextSlot != null) return true + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) + if (slot == null) { + close() + return false } + nextSlot = slot + return true + } + + override fun next(): Widget { + if (!hasNext()) throw NoSuchElementException() + val raw = nextSlot!![0] + nextSlot = null + return Widget.fromHandle(raw as Long) } - protected fun finalize() { - close() + /** Releases the native iterator; safe to call more than once. */ + override fun close() { + cleanable.clean() + } + + companion object { + init { WeaveNativeLibrary.ensureLoaded() } + + @JvmStatic private external fun nativeNext(handle: Long): Array? + @JvmStatic private external fun nativeDestroy(handle: Long) } } @@ -437,15 +565,17 @@ class Vault internal constructor(internal var handle: Long) : java.io.Closeable * A lazy iterator over the `String` elements streamed by [stringStream]. Each step pulls * exactly one element from the native producer. The native handle is * released when the producer is exhausted, when [close] is called, or by - * the finalizer if the iterator is abandoned, whichever comes first. + * the `Cleaner` if the iterator is abandoned, whichever comes first. */ -class EdgeCasesStringStreamIterator internal constructor(private var handle: Long) : Iterator, java.io.Closeable { +class EdgeCasesStringStreamIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) private var nextSlot: Array? = null override fun hasNext(): Boolean { if (nextSlot != null) return true - if (handle == 0L) return false - val slot = nativeNext(handle) + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) if (slot == null) { close() return false @@ -461,19 +591,13 @@ class EdgeCasesStringStreamIterator internal constructor(private var handle: Lon return raw as String } + /** Releases the native iterator; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @JvmStatic private external fun nativeNext(handle: Long): Array? @JvmStatic private external fun nativeDestroy(handle: Long) @@ -484,15 +608,17 @@ class EdgeCasesStringStreamIterator internal constructor(private var handle: Lon * A lazy iterator over the `Int` elements streamed by [scalarStream]. Each step pulls * exactly one element from the native producer. The native handle is * released when the producer is exhausted, when [close] is called, or by - * the finalizer if the iterator is abandoned, whichever comes first. + * the `Cleaner` if the iterator is abandoned, whichever comes first. */ -class EdgeCasesScalarStreamIterator internal constructor(private var handle: Long) : Iterator, java.io.Closeable { +class EdgeCasesScalarStreamIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) private var nextSlot: Array? = null override fun hasNext(): Boolean { if (nextSlot != null) return true - if (handle == 0L) return false - val slot = nativeNext(handle) + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) if (slot == null) { close() return false @@ -508,19 +634,13 @@ class EdgeCasesScalarStreamIterator internal constructor(private var handle: Lon return raw as Int } + /** Releases the native iterator; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @JvmStatic private external fun nativeNext(handle: Long): Array? @JvmStatic private external fun nativeDestroy(handle: Long) @@ -531,15 +651,17 @@ class EdgeCasesScalarStreamIterator internal constructor(private var handle: Lon * A lazy iterator over the `Mode` elements streamed by [modeStream]. Each step pulls * exactly one element from the native producer. The native handle is * released when the producer is exhausted, when [close] is called, or by - * the finalizer if the iterator is abandoned, whichever comes first. + * the `Cleaner` if the iterator is abandoned, whichever comes first. */ -class EdgeCasesModeStreamIterator internal constructor(private var handle: Long) : Iterator, java.io.Closeable { +class EdgeCasesModeStreamIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) private var nextSlot: Array? = null override fun hasNext(): Boolean { if (nextSlot != null) return true - if (handle == 0L) return false - val slot = nativeNext(handle) + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) if (slot == null) { close() return false @@ -555,26 +677,20 @@ class EdgeCasesModeStreamIterator internal constructor(private var handle: Long) return Mode.fromValue(raw as Int) } + /** Releases the native iterator; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @JvmStatic private external fun nativeNext(handle: Long): Array? @JvmStatic private external fun nativeDestroy(handle: Long) } } -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ open class WeaveFFIException(val code: Int, message: String) : Exception(message) /** Typed error domain `EdgeErrors` declared by module `edge_cases`. */ @@ -590,6 +706,41 @@ sealed class EdgeErrorsException(code: Int, message: String) : WeaveFFIException } } +/** Loads the native library once; every companion `init` calls [ensureLoaded]. */ +internal object WeaveNativeLibrary { + init { System.loadLibrary("weaveffi") } + + fun ensureLoaded() {} +} + +/** + * One strong native reference, released exactly once. Wrappers register an + * instance with [weaveCleaner] so an unreachable, never-closed wrapper still + * releases its reference; an explicit `close()` runs the same action first + * and the cleaner then does nothing. + */ +internal class WeaveNativeRef(handle: Long, private val release: (Long) -> Unit) : Runnable { + private val handle = java.util.concurrent.atomic.AtomicLong(handle) + + /** The live pointer, borrowed for the duration of one native call. */ + fun get(): Long { + val h = handle.get() + check(h != 0L) { "WeaveFFI object used after close()" } + return h + } + + /** The live pointer, or `0L` once released. */ + fun peek(): Long = handle.get() + + override fun run() { + val h = handle.getAndSet(0L) + if (h != 0L) release(h) + } +} + +/** The process-wide cleaner backing every generated wrapper's disposal backstop. */ +internal val weaveCleaner: java.lang.ref.Cleaner = java.lang.ref.Cleaner.create() + /** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ internal class WeaveBufferWriter { private var buf = ByteArray(32) @@ -685,6 +836,12 @@ internal class WeaveBufferReader(private val buf: ByteArray) { } fun readF32(): Float = Float.fromBits(readI32()) fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long { + val token = readI64() + if (token == 0L) malformed("null object token") + return token + } private fun readLen(): Int { val n = readI32() if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__build_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__build_gradle_kts.snap new file mode 100644 index 00000000..374b107b --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__build_gradle_kts.snap @@ -0,0 +1,36 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} + +// END build.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__settings_gradle.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__settings_gradle_kts.snap similarity index 79% rename from crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__settings_gradle.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__settings_gradle_kts.snap index f10a8b92..69449d7c 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__settings_gradle.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__settings_gradle_kts.snap @@ -5,6 +5,6 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o -rootProject.name = 'weaveffi' +rootProject.name = "weaveffi" -// END settings.gradle +// END settings.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_CMakeLists_txt.snap similarity index 94% rename from crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_CMakeLists_txt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_CMakeLists_txt.snap index e2c5c99f..32fa1b44 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_CMakeLists_txt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_CMakeLists_txt.snap @@ -6,7 +6,7 @@ source: crates/weaveffi-cli/tests/snapshots.rs # To regenerate: weaveffi generate weaveffi.yml -o cmake_minimum_required(VERSION 3.22) -project(weaveffi) +project(weaveffi_jni C) add_library(weaveffi SHARED weaveffi_jni.c) target_include_directories(weaveffi PRIVATE ../../../../c) diff --git a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap similarity index 56% rename from crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap index 3fc50425..207da6c9 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_cpp_weaveffi_jni_c.snap @@ -10,15 +10,81 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include -#include +#include #include "weaveffi.h" +/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified + UTF-8 would encode supplementary characters as surrogate pairs the + producer rejects). Lone surrogates become U+FFFD. Release with free(). + Returns NULL for a NULL string, for a string holding U+0000 (which no + C string can carry), or on allocation failure; the producer then + reports the null argument through its marshalling error. */ +static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) { + if (s == NULL) { return NULL; } + jsize n = (*env)->GetStringLength(env, s); + const jchar* u = (*env)->GetStringChars(env, s, NULL); + if (u == NULL) { return NULL; } + char* out = (char*)malloc((size_t)n * 4u + 1u); + if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; } + size_t o = 0; + for (jsize i = 0; i < n; i++) { + uint32_t c = u[i]; + if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; } + if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) { + c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u); + i++; + } + else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; } + if (c < 0x80u) { out[o++] = (char)c; } + else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + } + out[o] = 0; + (*env)->ReleaseStringChars(env, s, u); + return out; +} + +/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8 + and mangles supplementary characters). NULL yields the empty string and + a malformed sequence yields U+FFFD, so this never fails on producer + output; it returns NULL only when the JVM could not allocate. */ +static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) { + if (s == NULL) { s = ""; } + size_t n = strlen(s); + jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar)); + if (buf == NULL) { return NULL; } + size_t i = 0, o = 0; + while (i < n) { + unsigned char b = (unsigned char)s[i]; + uint32_t c; size_t len; + if (b < 0x80u) { c = b; len = 1; } + else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; } + else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; } + else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; } + else { c = 0xFFFDu; len = 1; } + if (i + len > n) { c = 0xFFFDu; len = 1; } + for (size_t k = 1; k < len; k++) { + unsigned char cb = (unsigned char)s[i + k]; + if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; } + c = (c << 6) | (cb & 0x3Fu); + } + i += len; + if (c > 0x10FFFFu) { c = 0xFFFDu; } + if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); } + else { buf[o++] = (jchar)c; } + } + jstring out = (*env)->NewString(env, buf, (jsize)o); + free(buf); + return out; +} + static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { const char* msg = err->message ? err->message : "WeaveFFI error"; jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); if (exClass != NULL) { jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); if (ex != NULL) { (*env)->Throw(env, ex); } } @@ -34,7 +100,7 @@ static void throw_weaveffi_kitchen_KitchenErrors(JNIEnv* env, weaveffi_error* er return; } const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -45,26 +111,65 @@ static void throw_weaveffi_kitchen_KitchenErrors(JNIEnv* env, weaveffi_error* er weaveffi_error_clear(err); } -typedef struct { - JavaVM* jvm; - jobject callback; -} weaveffi_jni_async_ctx; - +static JavaVM* weaveffi_jni_vm = NULL; static jclass weaveffi_jni_entry_class = NULL; static jmethodID weaveffi_jni_dispatch_exc = NULL; +static jmethodID weaveffi_jni_throwable_to_string = NULL; +static jclass weaveffi_kitchen_ReadyListener_jni_cls = NULL; +static jmethodID weaveffi_kitchen_ReadyListener_jni_mid_on_ready = NULL; +static jmethodID weaveffi_kitchen_ReadyListener_jni_mid_on_item = NULL; + +static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) { + jclass cls = (*env)->FindClass(env, "java/lang/UnsatisfiedLinkError"); + if (cls != NULL) { (*env)->ThrowNew(env, cls, message); } + return JNI_ERR; +} JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { (void)reserved; + weaveffi_jni_vm = vm; JNIEnv* env = NULL; if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } - jclass cls = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); - if (cls == NULL) { (*env)->ExceptionClear(env); return JNI_VERSION_1_6; } - weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, cls); + if (weaveffi_abi_version() != 2u) { + return weaveffi_jni_load_error(env, "WeaveFFI: the native library implements a different C ABI revision than these bindings (expected 2)"); + } + jclass entry = (*env)->FindClass(env, "com/weaveffi/WeaveFFI"); + if (entry == NULL) { return JNI_ERR; } + weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, entry); weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, "dispatchCallbackException", "(Ljava/lang/Throwable;)V"); if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); } + jclass throwable = (*env)->FindClass(env, "java/lang/Throwable"); + if (throwable == NULL) { return JNI_ERR; } + weaveffi_jni_throwable_to_string = (*env)->GetMethodID(env, throwable, "toString", "()Ljava/lang/String;"); + if (weaveffi_jni_throwable_to_string == NULL) { return JNI_ERR; } + jclass weaveffi_kitchen_ReadyListener_jni_cls_local = (*env)->FindClass(env, "com/weaveffi/ReadyListenerJni"); + if (weaveffi_kitchen_ReadyListener_jni_cls_local == NULL) { return JNI_ERR; } + weaveffi_kitchen_ReadyListener_jni_cls = (jclass)(*env)->NewGlobalRef(env, weaveffi_kitchen_ReadyListener_jni_cls_local); + weaveffi_kitchen_ReadyListener_jni_mid_on_ready = (*env)->GetStaticMethodID(env, weaveffi_kitchen_ReadyListener_jni_cls, "onReady", "(Lcom/weaveffi/ReadyListener;ILjava/lang/String;)V"); + if (weaveffi_kitchen_ReadyListener_jni_mid_on_ready == NULL) { return JNI_ERR; } + weaveffi_kitchen_ReadyListener_jni_mid_on_item = (*env)->GetStaticMethodID(env, weaveffi_kitchen_ReadyListener_jni_cls, "onItem", "(Lcom/weaveffi/ReadyListener;[BJ)Z"); + if (weaveffi_kitchen_ReadyListener_jni_mid_on_item == NULL) { return JNI_ERR; } return JNI_VERSION_1_6; } +/* Returns 1 when this call attached the thread (the caller must detach), + 0 otherwise. *env is NULL when no JNIEnv could be obtained. */ +static int weaveffi_jni_attach(JNIEnv** env) { + *env = NULL; + if (weaveffi_jni_vm == NULL) { return 0; } + if ((*weaveffi_jni_vm)->GetEnv(weaveffi_jni_vm, (void**)env, JNI_VERSION_1_6) == JNI_OK) { return 0; } + if ((*weaveffi_jni_vm)->AttachCurrentThread(weaveffi_jni_vm, (void*)env, NULL) != JNI_OK) { *env = NULL; return 0; } + return 1; +} + +static void weaveffi_jni_detach(int attached) { + if (attached) { (*weaveffi_jni_vm)->DetachCurrentThread(weaveffi_jni_vm); } +} + +typedef struct { + jobject callback; +} weaveffi_jni_async_ctx; + static void weaveffi_jni_handle_uncaught(JNIEnv* env) { if (!(*env)->ExceptionCheck(env)) { return; } jthrowable ex = (*env)->ExceptionOccurred(env); @@ -80,69 +185,78 @@ static void weaveffi_jni_handle_uncaught(JNIEnv* env) { (*env)->DeleteLocalRef(env, ex); } -typedef struct weaveffi_jni_listener_ctx { - JavaVM* jvm; - jobject callback; - uint64_t id; - struct weaveffi_jni_listener_ctx* next; -} weaveffi_jni_listener_ctx; - -static weaveffi_jni_listener_ctx* weaveffi_jni_listeners = NULL; -static pthread_mutex_t weaveffi_jni_listener_lock = PTHREAD_MUTEX_INITIALIZER; +static void weaveffi_jni_report_foreign(JNIEnv* env, weaveffi_error* out_err) { + jthrowable ex = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + jstring jmsg = NULL; + if (ex != NULL && weaveffi_jni_throwable_to_string != NULL) { + jmsg = (jstring)(*env)->CallObjectMethod(env, ex, weaveffi_jni_throwable_to_string); + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); jmsg = NULL; } + } + char* msg = weaveffi_jni_string_to_utf8(env, jmsg); + weaveffi_error_set(out_err, -4, msg != NULL ? msg : "callback interface implementation threw"); + free(msg); +} -static void weaveffi_kitchen_OnReady_fn_jni_tramp(int32_t code, const char* msg, void* context) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context; +static void weaveffi_kitchen_ReadyListener_jni_on_ready(void* ctx, int32_t code, const char* msg, weaveffi_error* out_err) { JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return; - attached = 1; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return; } - if ((*env)->PushLocalFrame(env, 32) != 0) { - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); return; } - jclass _a0_cls = (*env)->FindClass(env, "java/lang/Integer"); - jobject _a0 = (*env)->CallStaticObjectMethod(env, _a0_cls, (*env)->GetStaticMethodID(env, _a0_cls, "valueOf", "(I)Ljava/lang/Integer;"), (jint)code); - jobject _a1 = msg ? (jobject)(*env)->NewStringUTF(env, msg) : (jobject)(*env)->NewStringUTF(env, ""); - jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); - (*env)->CallObjectMethod(env, ctx->callback, invoke, _a0, _a1); - weaveffi_jni_handle_uncaught(env); + jint _a0 = (jint)code; + jstring _a1 = weaveffi_jni_utf8_to_string(env, msg); + (*env)->CallStaticVoidMethod(env, weaveffi_kitchen_ReadyListener_jni_cls, weaveffi_kitchen_ReadyListener_jni_mid_on_ready, (jobject)ctx, _a0, _a1); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + } (*env)->PopLocalFrame(env, NULL); - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); + weaveffi_jni_detach(attached); } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerReadyListener(JNIEnv* env, jclass clazz, jobject callback) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - uint64_t id = weaveffi_kitchen_register_ready_listener(weaveffi_kitchen_OnReady_fn_jni_tramp, ctx); - ctx->id = id; - pthread_mutex_lock(&weaveffi_jni_listener_lock); - ctx->next = weaveffi_jni_listeners; - weaveffi_jni_listeners = ctx; - pthread_mutex_unlock(&weaveffi_jni_listener_lock); - return (jlong)id; -} - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_unregisterReadyListener(JNIEnv* env, jclass clazz, jlong id) { - weaveffi_kitchen_unregister_ready_listener((uint64_t)id); - pthread_mutex_lock(&weaveffi_jni_listener_lock); - weaveffi_jni_listener_ctx** link = &weaveffi_jni_listeners; - while (*link != NULL) { - if ((*link)->id == (uint64_t)id) { - weaveffi_jni_listener_ctx* found = *link; - *link = found->next; - (*env)->DeleteGlobalRef(env, found->callback); - free(found); - break; - } - link = &(*link)->next; +static bool weaveffi_kitchen_ReadyListener_jni_on_item(void* ctx, const uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return false; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { + (*env)->ExceptionClear(env); + weaveffi_error_set(out_err, -4, "WeaveFFI: JNI local frame exhausted"); + weaveffi_jni_detach(attached); + return false; + } + jbyteArray _a0 = (*env)->NewByteArray(env, (jsize)item_len); + if (_a0 && item_ptr) { (*env)->SetByteArrayRegion(env, _a0, 0, (jsize)item_len, (const jbyte*)item_ptr); } + jlong _a1 = (jlong)(intptr_t)gadget; + jboolean _rv = (*env)->CallStaticBooleanMethod(env, weaveffi_kitchen_ReadyListener_jni_cls, weaveffi_kitchen_ReadyListener_jni_mid_on_item, (jobject)ctx, _a0, _a1); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + _rv = (jboolean)false; } - pthread_mutex_unlock(&weaveffi_jni_listener_lock); + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); + return _rv == JNI_TRUE; } +static void weaveffi_kitchen_ReadyListener_jni_free(void* ctx) { + if (ctx == NULL) { return; } + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); } + weaveffi_jni_detach(attached); +} + +static const weaveffi_kitchen_ReadyListener_vtable weaveffi_kitchen_ReadyListener_jni_vtable = { weaveffi_kitchen_ReadyListener_jni_on_ready, weaveffi_kitchen_ReadyListener_jni_on_item, weaveffi_kitchen_ReadyListener_jni_free }; + JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_ping(JNIEnv* env, jclass clazz) { weaveffi_error err = {0, NULL, NULL, 0}; const char* rv = weaveffi_shared_ping(&err); @@ -150,7 +264,7 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_ping(JNIEnv* env, jclass cl throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } @@ -207,14 +321,14 @@ JNIEXPORT jdouble JNICALL Java_com_weaveffi_WeaveFFI_f64Id(JNIEnv* env, jclass c JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_echoString(JNIEnv* env, jclass clazz, jstring s) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* s_chars = (*env)->GetStringUTFChars(env, s, NULL); + char* s_chars = weaveffi_jni_string_to_utf8(env, s); const char* rv = weaveffi_kitchen_echo_string(s_chars, &err); - (*env)->ReleaseStringUTFChars(env, s, s_chars); + free(s_chars); if (err.code != 0) { throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } @@ -237,56 +351,58 @@ JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_echoBytes(JNIEnv* env, j return out; } -JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_echoBorrowedStr(JNIEnv* env, jclass clazz, jstring s) { +JNIEXPORT jint JNICALL Java_com_weaveffi_WeaveFFI_subscribe(JNIEnv* env, jclass clazz, jobject listener) { weaveffi_error err = {0, NULL, NULL, 0}; - const char* s_chars = (*env)->GetStringUTFChars(env, s, NULL); - const char* rv = weaveffi_kitchen_echo_borrowed_str(s_chars, &err); - (*env)->ReleaseStringUTFChars(env, s, s_chars); + jobject listener_ref = (*env)->NewGlobalRef(env, listener); + int32_t rv = weaveffi_kitchen_subscribe((void*)listener_ref, &weaveffi_kitchen_ReadyListener_jni_vtable, &err); if (err.code != 0) { throw_weaveffi_error(env, &err); - return NULL; + return 0; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); - weaveffi_free_string(rv); - return out; + return (jint) rv; } -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_echoBorrowedBytes(JNIEnv* env, jclass clazz, jbyteArray b) { +JNIEXPORT jobject JNICALL Java_com_weaveffi_WeaveFFI_maybeGadgetJni(JNIEnv* env, jclass clazz, jobject fallback) { weaveffi_error err = {0, NULL, NULL, 0}; - jboolean b_is_copy = 0; - jbyte* b_elems = (*env)->GetByteArrayElements(env, b, &b_is_copy); - jsize b_len = (*env)->GetArrayLength(env, b); - size_t out_len = 0; - const uint8_t* rv = weaveffi_kitchen_echo_borrowed_bytes((const uint8_t*)b_elems, (size_t)b_len, &out_len, &err); - (*env)->ReleaseByteArrayElements(env, b, b_elems, 0); + int64_t fallback_val = 0; + if (fallback != NULL) { + jclass fallback_cls = (*env)->FindClass(env, "java/lang/Long"); + jmethodID fallback_mid = (*env)->GetMethodID(env, fallback_cls, "longValue", "()J"); + fallback_val = (int64_t)(*env)->CallLongMethod(env, fallback, fallback_mid); + } + weaveffi_kitchen_Gadget* rv = weaveffi_kitchen_maybe_gadget((const weaveffi_kitchen_Gadget*)(intptr_t)fallback_val, &err); if (err.code != 0) { throw_weaveffi_error(env, &err); return NULL; } - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } - weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); - return out; + if (rv == NULL) { return NULL; } + jclass box_cls = (*env)->FindClass(env, "java/lang/Long"); + jmethodID box_mid = (*env)->GetStaticMethodID(env, box_cls, "valueOf", "(J)Ljava/lang/Long;"); + return (*env)->CallStaticObjectMethod(env, box_cls, box_mid, (jlong)(intptr_t)rv); } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_openHandle(JNIEnv* env, jclass clazz) { +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_gadgetsJni(JNIEnv* env, jclass clazz) { weaveffi_error err = {0, NULL, NULL, 0}; - weaveffi_handle_t rv = weaveffi_kitchen_open_handle(&err); + size_t out_len = 0; + const uint8_t* rv = weaveffi_kitchen_gadgets(&out_len, &err); if (err.code != 0) { throw_weaveffi_error(env, &err); - return 0; + return NULL; } - return (jlong) rv; + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; } -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_openTypedHandle(JNIEnv* env, jclass clazz) { +JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_streamGadgetsJni(JNIEnv* env, jclass clazz) { weaveffi_error err = {0, NULL, NULL, 0}; - weaveffi_shared_Token* rv = weaveffi_kitchen_open_typed_handle(&err); + weaveffi_kitchen_StreamGadgetsIterator* _iter = weaveffi_kitchen_stream_gadgets(&err); if (err.code != 0) { throw_weaveffi_error(env, &err); return 0; } - return (jlong)(intptr_t)rv; + return (jlong)(intptr_t)_iter; } JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_maybeItemJni(JNIEnv* env, jclass clazz, jlong id) { @@ -358,14 +474,11 @@ JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_crossModuleTokenJni(JNIE static void weaveffi_kitchen_do_async_jni_cb(void* context, weaveffi_error* err, const char* result) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -377,7 +490,7 @@ static void weaveffi_kitchen_do_async_jni_cb(void* context, weaveffi_error* err, jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); } else { - jobject boxed = result ? (jobject)(*env)->NewStringUTF(env, result) : (jobject)(*env)->NewStringUTF(env, ""); + jobject boxed = (jobject)weaveffi_jni_utf8_to_string(env, result); weaveffi_free_string(result); jclass cls = (*env)->GetObjectClass(env, ctx->callback); jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); @@ -385,31 +498,26 @@ static void weaveffi_kitchen_do_async_jni_cb(void* context, weaveffi_error* err, } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_doAsyncAsync(JNIEnv* env, jclass clazz, jstring input, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); - const char* input_chars = (*env)->GetStringUTFChars(env, input, NULL); + char* input_chars = weaveffi_jni_string_to_utf8(env, input); weaveffi_kitchen_do_async_async(input_chars, weaveffi_kitchen_do_async_jni_cb, ctx); - (*env)->ReleaseStringUTFChars(env, input, input_chars); + free(input_chars); } static void weaveffi_kitchen_do_cancellable_jni_cb(void* context, weaveffi_error* err, const char* result) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } if (err != NULL && err->code != 0) { const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -421,7 +529,7 @@ static void weaveffi_kitchen_do_cancellable_jni_cb(void* context, weaveffi_error jmethodID mid = (*env)->GetMethodID(env, cls, "onError", "(ILjava/lang/String;[B)V"); (*env)->CallVoidMethod(env, ctx->callback, mid, jcode, jmsg, jpayload); } else { - jobject boxed = result ? (jobject)(*env)->NewStringUTF(env, result) : (jobject)(*env)->NewStringUTF(env, ""); + jobject boxed = (jobject)weaveffi_jni_utf8_to_string(env, result); weaveffi_free_string(result); jclass cls = (*env)->GetObjectClass(env, ctx->callback); jmethodID mid = (*env)->GetMethodID(env, cls, "onSuccess", "(Ljava/lang/Object;)V"); @@ -429,18 +537,16 @@ static void weaveffi_kitchen_do_cancellable_jni_cb(void* context, weaveffi_error } weaveffi_jni_handle_uncaught(env); (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); + weaveffi_jni_detach(attached); } JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_doCancellableAsync(JNIEnv* env, jclass clazz, jstring input, jlong cancelToken, jobject callback) { weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); ctx->callback = (*env)->NewGlobalRef(env, callback); - const char* input_chars = (*env)->GetStringUTFChars(env, input, NULL); + char* input_chars = weaveffi_jni_string_to_utf8(env, input); weaveffi_kitchen_do_cancellable_async(input_chars, (weaveffi_cancel_token*)(intptr_t)cancelToken, weaveffi_kitchen_do_cancellable_jni_cb, ctx); - (*env)->ReleaseStringUTFChars(env, input, input_chars); + free(input_chars); } JNIEXPORT jint JNICALL Java_com_weaveffi_WeaveFFI_legacyOp(JNIEnv* env, jclass clazz) { @@ -470,19 +576,19 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_hello(JNIEnv* env, jclass c throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } JNIEXPORT jlong JNICALL Java_com_weaveffi_Gadget_nativeNew(JNIEnv* env, jclass clazz, jlong id) { weaveffi_error err = {0, NULL, NULL, 0}; - void* rv = weaveffi_kitchen_Gadget_new((int64_t)id, &err); + weaveffi_kitchen_Gadget* rv = weaveffi_kitchen_Gadget_new((int64_t)id, &err); if (err.code != 0) { throw_weaveffi_error(env, &err); return 0; } - return (jlong)(intptr_t) rv; + return (jlong)(intptr_t)rv; } JNIEXPORT jstring JNICALL Java_com_weaveffi_Gadget_nativeVersion(JNIEnv* env, jclass clazz) { @@ -492,7 +598,7 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_Gadget_nativeVersion(JNIEnv* env, jc throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } @@ -504,7 +610,7 @@ JNIEXPORT jstring JNICALL Java_com_weaveffi_Gadget_nativeDescribe(JNIEnv* env, j throw_weaveffi_error(env, &err); return NULL; } - jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, ""); + jstring out = weaveffi_jni_utf8_to_string(env, rv); weaveffi_free_string(rv); return out; } @@ -519,10 +625,36 @@ JNIEXPORT jint JNICALL Java_com_weaveffi_Gadget_nativePoke(JNIEnv* env, jclass c return (jint) rv; } +JNIEXPORT jlong JNICALL Java_com_weaveffi_Gadget_nativeClone(JNIEnv* env, jclass clazz, jlong handle) { + return (jlong)(intptr_t)weaveffi_kitchen_Gadget_clone((const weaveffi_kitchen_Gadget*)(intptr_t)handle); +} + JNIEXPORT void JNICALL Java_com_weaveffi_Gadget_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { weaveffi_kitchen_Gadget_destroy((weaveffi_kitchen_Gadget*)(intptr_t)handle); } +JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_KitchenStreamGadgetsIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) { + weaveffi_kitchen_StreamGadgetsIterator* _iter = (weaveffi_kitchen_StreamGadgetsIterator*)(intptr_t)handle; + weaveffi_kitchen_Gadget* _item = (weaveffi_kitchen_Gadget*)0; + weaveffi_error err = {0, NULL, NULL, 0}; + int32_t _has = weaveffi_kitchen_StreamGadgetsIterator_next(_iter, &_item, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + if (_has == 0) { return NULL; } + jclass _jitem_cls = (*env)->FindClass(env, "java/lang/Long"); + jobject _jitem = (*env)->CallStaticObjectMethod(env, _jitem_cls, (*env)->GetStaticMethodID(env, _jitem_cls, "valueOf", "(J)Ljava/lang/Long;"), (jlong)(intptr_t)_item); + jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object"); + jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL); + (*env)->SetObjectArrayElement(env, _slot, 0, _jitem); + return _slot; +} + +JNIEXPORT void JNICALL Java_com_weaveffi_KitchenStreamGadgetsIterator_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) { + weaveffi_kitchen_StreamGadgetsIterator_destroy((weaveffi_kitchen_StreamGadgetsIterator*)(intptr_t)handle); +} + JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_KitchenStreamItemsIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) { weaveffi_kitchen_StreamItemsIterator* _iter = (weaveffi_kitchen_StreamItemsIterator*)(intptr_t)handle; const uint8_t* _item = (const uint8_t*)0; diff --git a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap similarity index 67% rename from crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap index 34274066..2ad67787 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_kitchen_sink__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap @@ -13,16 +13,18 @@ import kotlin.coroutines.resumeWithException class WeaveFFI { companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @Volatile private var callbackExceptionHandler: ((Throwable) -> Unit)? = null /** - * Installs a handler for exceptions thrown by listener callbacks and - * async continuations on native producer threads. These exceptions have - * no Kotlin caller to propagate to; when no handler is installed, they - * are logged with their stack trace and dropped. Pass `null` to - * restore the default logging behavior. + * Installs a handler for exceptions thrown while an async result is + * delivered on a native producer thread. These exceptions have no Kotlin + * caller to propagate to; when no handler is installed, they are logged + * with their stack trace and dropped. Pass `null` to restore the default + * logging behavior. Exceptions thrown by callback interface methods are + * not routed here: they abort the producer call that invoked them and + * surface to the original caller as a [WeaveFFIException] with code -4. */ @JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) { callbackExceptionHandler = handler @@ -38,13 +40,6 @@ class WeaveFFI { * Trivial cross-module helper */ @JvmStatic external fun ping(): String - /** - * Subscribe to OnReady events - * - * Exceptions thrown by the callback cannot propagate to a caller (events fire on a producer thread); they are delivered to the handler installed via [setCallbackExceptionHandler], or logged with their stack trace and dropped when no handler is set. - */ - @JvmStatic external fun registerReadyListener(callback: (Int, String) -> Unit): Long - @JvmStatic external fun unregisterReadyListener(id: Long) /** * Identity for bool */ @@ -56,21 +51,24 @@ class WeaveFFI { @JvmStatic external fun echoString(s: String): String @JvmStatic external fun echoBytes(b: ByteArray): ByteArray /** - * Borrowed string parameter + * Register a listener, returning how many are attached */ - @JvmStatic external fun echoBorrowedStr(s: String): String + @JvmStatic external fun subscribe(listener: ReadyListener): Int /** - * Borrowed bytes parameter + * Optional object in both directions */ - @JvmStatic external fun echoBorrowedBytes(b: ByteArray): ByteArray + @JvmStatic private external fun maybeGadgetJni(fallback: Long?): Long? + @JvmStatic fun maybeGadget(fallback: Gadget?): Gadget? = maybeGadgetJni(fallback?.handle)?.let { Gadget.fromHandle(it) } /** - * Returns an opaque handle + * A list of objects */ - @JvmStatic external fun openHandle(): Long + @JvmStatic private external fun gadgetsJni(): ByteArray + @JvmStatic fun gadgets(): List = weaveDecode(gadgetsJni()) { r -> r.readList { Gadget.fromHandle(r.readObject()) } } /** - * Returns a typed handle + * An iterator over objects */ - @JvmStatic external fun openTypedHandle(): Long + @JvmStatic private external fun streamGadgetsJni(): Long + @JvmStatic fun streamGadgets(): Iterator = KitchenStreamGadgetsIterator(streamGadgetsJni()) /** * Optional struct return */ @@ -173,6 +171,8 @@ data class Item( /** Cross-module struct reference */ val token: Token, val priority: Priority, + /** An optional object inside a record */ + val gadget: Gadget?, ) internal fun packItem(w: WeaveBufferWriter, v: Item) { @@ -188,6 +188,7 @@ internal fun packItem(w: WeaveBufferWriter, v: Item) { w.writeOptional(v.parent) { v0 -> w.writeI64(v0) } packToken(w, v.token) w.writeI32(v.priority.value) + w.writeOptional(v.gadget) { v0 -> w.writeI64(v0.cloneHandle()) } } internal fun unpackItem(r: WeaveBufferReader): Item = Item( @@ -203,23 +204,54 @@ internal fun unpackItem(r: WeaveBufferReader): Item = Item( r.readOptional { r.readI64() }, unpackToken(r), Priority.fromValue(r.readI32()), + r.readOptional { Gadget.fromHandle(r.readObject()) }, ) +/** Consumer-implemented observer of ready items */ +interface ReadyListener { + /** + * Fires when an item is ready + */ + fun onReady(code: Int, msg: String) + /** + * Receives the item itself and says whether to keep listening + */ + fun onItem(item: Item, gadget: Gadget): Boolean +} + +/** JNI dispatch shims for [ReadyListener]; the native vtable trampolines call these, nothing else does. */ +internal object ReadyListenerJni { + @JvmStatic fun onReady(impl: ReadyListener, code: Int, msg: String) { impl.onReady(code, msg) } + @JvmStatic fun onItem(impl: ReadyListener, item: ByteArray, gadget: Long): Boolean = impl.onItem(weaveDecode(item) { r -> unpackItem(r) }, Gadget.fromHandle(gadget)) +} + /** A pocket-sized interface exercising the object surface */ -class Gadget internal constructor(internal var handle: Long) : java.io.Closeable { +class Gadget private constructor(handle: Long) : AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + + /** The native pointer, borrowed for one call; throws after [close]. */ + internal val handle: Long get() = ref.get() + + /** A new strong reference, written into value buffers as an object token. */ + internal fun cloneHandle(): Long = nativeClone(ref.get()) + companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } + /** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */ + internal fun fromHandle(handle: Long): Gadget = Gadget(handle) @JvmStatic private external fun nativeNew(id: Long): Long @JvmStatic private external fun nativeVersion(): String @JvmStatic private external fun nativeDescribe(selfHandle: Long): String @JvmStatic private external fun nativePoke(selfHandle: Long, times: Int): Int + @JvmStatic private external fun nativeClone(handle: Long): Long @JvmStatic private external fun nativeDestroy(handle: Long) /** * Create a gadget with the given id */ - operator fun invoke(id: Long): Gadget = Gadget(nativeNew(id)) + operator fun invoke(id: Long): Gadget = Gadget.fromHandle(nativeNew(id)) /** * The gadget subsystem version string @@ -237,15 +269,52 @@ class Gadget internal constructor(internal var handle: Long) : java.io.Closeable */ fun poke(times: Int): Int = nativePoke(handle, times) + /** Releases this wrapper's native reference; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L + cleanable.clean() + } +} + +/** + * A lazy iterator over the `Gadget` elements streamed by [streamGadgets]. Each step pulls + * exactly one element from the native producer. The native handle is + * released when the producer is exhausted, when [close] is called, or by + * the `Cleaner` if the iterator is abandoned, whichever comes first. + */ +class KitchenStreamGadgetsIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + private var nextSlot: Array? = null + + override fun hasNext(): Boolean { + if (nextSlot != null) return true + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) + if (slot == null) { + close() + return false } + nextSlot = slot + return true } - protected fun finalize() { - close() + override fun next(): Gadget { + if (!hasNext()) throw NoSuchElementException() + val raw = nextSlot!![0] + nextSlot = null + return Gadget.fromHandle(raw as Long) + } + + /** Releases the native iterator; safe to call more than once. */ + override fun close() { + cleanable.clean() + } + + companion object { + init { WeaveNativeLibrary.ensureLoaded() } + + @JvmStatic private external fun nativeNext(handle: Long): Array? + @JvmStatic private external fun nativeDestroy(handle: Long) } } @@ -253,15 +322,17 @@ class Gadget internal constructor(internal var handle: Long) : java.io.Closeable * A lazy iterator over the `Item` elements streamed by [streamItems]. Each step pulls * exactly one element from the native producer. The native handle is * released when the producer is exhausted, when [close] is called, or by - * the finalizer if the iterator is abandoned, whichever comes first. + * the `Cleaner` if the iterator is abandoned, whichever comes first. */ -class KitchenStreamItemsIterator internal constructor(private var handle: Long) : Iterator, java.io.Closeable { +class KitchenStreamItemsIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) private var nextSlot: Array? = null override fun hasNext(): Boolean { if (nextSlot != null) return true - if (handle == 0L) return false - val slot = nativeNext(handle) + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) if (slot == null) { close() return false @@ -277,26 +348,20 @@ class KitchenStreamItemsIterator internal constructor(private var handle: Long) return weaveDecode((raw as ByteArray)) { r -> unpackItem(r) } } + /** Releases the native iterator; safe to call more than once. */ override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() + cleanable.clean() } companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } @JvmStatic private external fun nativeNext(handle: Long): Array? @JvmStatic private external fun nativeDestroy(handle: Long) } } -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ open class WeaveFFIException(val code: Int, message: String) : Exception(message) /** Typed error domain `KitchenErrors` declared by module `kitchen`. */ @@ -314,6 +379,41 @@ sealed class KitchenErrorsException(code: Int, message: String) : WeaveFFIExcept } } +/** Loads the native library once; every companion `init` calls [ensureLoaded]. */ +internal object WeaveNativeLibrary { + init { System.loadLibrary("weaveffi") } + + fun ensureLoaded() {} +} + +/** + * One strong native reference, released exactly once. Wrappers register an + * instance with [weaveCleaner] so an unreachable, never-closed wrapper still + * releases its reference; an explicit `close()` runs the same action first + * and the cleaner then does nothing. + */ +internal class WeaveNativeRef(handle: Long, private val release: (Long) -> Unit) : Runnable { + private val handle = java.util.concurrent.atomic.AtomicLong(handle) + + /** The live pointer, borrowed for the duration of one native call. */ + fun get(): Long { + val h = handle.get() + check(h != 0L) { "WeaveFFI object used after close()" } + return h + } + + /** The live pointer, or `0L` once released. */ + fun peek(): Long = handle.get() + + override fun run() { + val h = handle.getAndSet(0L) + if (h != 0L) release(h) + } +} + +/** The process-wide cleaner backing every generated wrapper's disposal backstop. */ +internal val weaveCleaner: java.lang.ref.Cleaner = java.lang.ref.Cleaner.create() + /** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ internal class WeaveBufferWriter { private var buf = ByteArray(32) @@ -409,6 +509,12 @@ internal class WeaveBufferReader(private val buf: ByteArray) { } fun readF32(): Float = Float.fromBits(readI32()) fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long { + val token = readI64() + if (token == 0L) malformed("null object token") + return token + } private fun readLen(): Int { val n = readI32() if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__build_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__build_gradle_kts.snap new file mode 100644 index 00000000..374b107b --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__build_gradle_kts.snap @@ -0,0 +1,36 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} + +// END build.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__settings_gradle.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__settings_gradle_kts.snap similarity index 79% rename from crates/weaveffi-cli/tests/snapshots/android_nested_modules__settings_gradle.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__settings_gradle_kts.snap index f10a8b92..69449d7c 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__settings_gradle.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__settings_gradle_kts.snap @@ -5,6 +5,6 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o -rootProject.name = 'weaveffi' +rootProject.name = "weaveffi" -// END settings.gradle +// END settings.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_CMakeLists_txt.snap similarity index 94% rename from crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_CMakeLists_txt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_CMakeLists_txt.snap index e2c5c99f..32fa1b44 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_docs_everywhere__src_main_cpp_CMakeLists_txt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_CMakeLists_txt.snap @@ -6,7 +6,7 @@ source: crates/weaveffi-cli/tests/snapshots.rs # To regenerate: weaveffi generate weaveffi.yml -o cmake_minimum_required(VERSION 3.22) -project(weaveffi) +project(weaveffi_jni C) add_library(weaveffi SHARED weaveffi_jni.c) target_include_directories(weaveffi PRIVATE ../../../../c) diff --git a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_weaveffi_jni_c.snap similarity index 51% rename from crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_weaveffi_jni_c.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_weaveffi_jni_c.snap index 2dac5c8f..ef2bda33 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_cpp_weaveffi_jni_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_cpp_weaveffi_jni_c.snap @@ -10,14 +10,81 @@ source: crates/weaveffi-cli/tests/snapshots.rs #include #include #include +#include #include "weaveffi.h" +/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified + UTF-8 would encode supplementary characters as surrogate pairs the + producer rejects). Lone surrogates become U+FFFD. Release with free(). + Returns NULL for a NULL string, for a string holding U+0000 (which no + C string can carry), or on allocation failure; the producer then + reports the null argument through its marshalling error. */ +static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) { + if (s == NULL) { return NULL; } + jsize n = (*env)->GetStringLength(env, s); + const jchar* u = (*env)->GetStringChars(env, s, NULL); + if (u == NULL) { return NULL; } + char* out = (char*)malloc((size_t)n * 4u + 1u); + if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; } + size_t o = 0; + for (jsize i = 0; i < n; i++) { + uint32_t c = u[i]; + if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; } + if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) { + c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u); + i++; + } + else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; } + if (c < 0x80u) { out[o++] = (char)c; } + else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + } + out[o] = 0; + (*env)->ReleaseStringChars(env, s, u); + return out; +} + +/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8 + and mangles supplementary characters). NULL yields the empty string and + a malformed sequence yields U+FFFD, so this never fails on producer + output; it returns NULL only when the JVM could not allocate. */ +static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) { + if (s == NULL) { s = ""; } + size_t n = strlen(s); + jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar)); + if (buf == NULL) { return NULL; } + size_t i = 0, o = 0; + while (i < n) { + unsigned char b = (unsigned char)s[i]; + uint32_t c; size_t len; + if (b < 0x80u) { c = b; len = 1; } + else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; } + else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; } + else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; } + else { c = 0xFFFDu; len = 1; } + if (i + len > n) { c = 0xFFFDu; len = 1; } + for (size_t k = 1; k < len; k++) { + unsigned char cb = (unsigned char)s[i + k]; + if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; } + c = (c << 6) | (cb & 0x3Fu); + } + i += len; + if (c > 0x10FFFFu) { c = 0xFFFDu; } + if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); } + else { buf[o++] = (jchar)c; } + } + jstring out = (*env)->NewString(env, buf, (jsize)o); + free(buf); + return out; +} + static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { const char* msg = err->message ? err->message : "WeaveFFI error"; jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); if (exClass != NULL) { jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); if (ex != NULL) { (*env)->Throw(env, ex); } } @@ -33,7 +100,7 @@ static void throw_weaveffi_graphics_GraphicsError(JNIEnv* env, weaveffi_error* e return; } const char* msg = err->message ? err->message : "WeaveFFI error"; - jstring jmsg = (*env)->NewStringUTF(env, msg); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); jbyteArray jpayload = NULL; if (err->payload_ptr != NULL) { jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len); @@ -44,6 +111,25 @@ static void throw_weaveffi_graphics_GraphicsError(JNIEnv* env, weaveffi_error* e weaveffi_error_clear(err); } +static JavaVM* weaveffi_jni_vm = NULL; + +static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) { + jclass cls = (*env)->FindClass(env, "java/lang/UnsatisfiedLinkError"); + if (cls != NULL) { (*env)->ThrowNew(env, cls, message); } + return JNI_ERR; +} + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)reserved; + weaveffi_jni_vm = vm; + JNIEnv* env = NULL; + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } + if (weaveffi_abi_version() != 2u) { + return weaveffi_jni_load_error(env, "WeaveFFI: the native library implements a different C ABI revision than these bindings (expected 2)"); + } + return JNI_VERSION_1_6; +} + JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_newCanvasJni(JNIEnv* env, jclass clazz, jint width) { weaveffi_error err = {0, NULL, NULL, 0}; size_t out_len = 0; diff --git a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap similarity index 93% rename from crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap index f8010dac..f24ca48e 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_nested_modules__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap @@ -9,7 +9,7 @@ package com.weaveffi class WeaveFFI { companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } /** * Construct a Canvas. @@ -93,7 +93,7 @@ internal fun unpackDetail(r: WeaveBufferReader): Detail = Detail( r.readF64(), ) -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ open class WeaveFFIException(val code: Int, message: String) : Exception(message) /** Typed error domain `GraphicsError` declared by module `graphics`. */ @@ -109,6 +109,13 @@ sealed class GraphicsException(code: Int, message: String) : WeaveFFIException(c } } +/** Loads the native library once; every companion `init` calls [ensureLoaded]. */ +internal object WeaveNativeLibrary { + init { System.loadLibrary("weaveffi") } + + fun ensureLoaded() {} +} + /** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ internal class WeaveBufferWriter { private var buf = ByteArray(32) @@ -204,6 +211,12 @@ internal class WeaveBufferReader(private val buf: ByteArray) { } fun readF32(): Float = Float.fromBits(readI32()) fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long { + val token = readI64() + if (token == 0L) malformed("null object token") + return token + } private fun readLen(): Int { val n = readI32() if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__build_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__build_gradle_kts.snap new file mode 100644 index 00000000..374b107b --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__build_gradle_kts.snap @@ -0,0 +1,36 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} + +// END build.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__settings_gradle_kts.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__settings_gradle_kts.snap new file mode 100644 index 00000000..69449d7c --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__settings_gradle_kts.snap @@ -0,0 +1,10 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +rootProject.name = "weaveffi" + +// END settings.gradle.kts diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_CMakeLists_txt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_CMakeLists_txt.snap new file mode 100644 index 00000000..32fa1b44 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_CMakeLists_txt.snap @@ -0,0 +1,13 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o + +cmake_minimum_required(VERSION 3.22) +project(weaveffi_jni C) +add_library(weaveffi SHARED weaveffi_jni.c) +target_include_directories(weaveffi PRIVATE ../../../../c) + +# END CMakeLists.txt diff --git a/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_weaveffi_jni_c.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_weaveffi_jni_c.snap new file mode 100644 index 00000000..a3d43bd8 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_cpp_weaveffi_jni_c.snap @@ -0,0 +1,174 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +// Generated by WeaveFFI [VERSION] from weaveffi.yml +// DO NOT EDIT. Your changes will be overwritten. +// To regenerate: weaveffi generate weaveffi.yml -o + +#include +#include +#include +#include +#include +#include +#include "weaveffi.h" + +/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified + UTF-8 would encode supplementary characters as surrogate pairs the + producer rejects). Lone surrogates become U+FFFD. Release with free(). + Returns NULL for a NULL string, for a string holding U+0000 (which no + C string can carry), or on allocation failure; the producer then + reports the null argument through its marshalling error. */ +static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) { + if (s == NULL) { return NULL; } + jsize n = (*env)->GetStringLength(env, s); + const jchar* u = (*env)->GetStringChars(env, s, NULL); + if (u == NULL) { return NULL; } + char* out = (char*)malloc((size_t)n * 4u + 1u); + if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; } + size_t o = 0; + for (jsize i = 0; i < n; i++) { + uint32_t c = u[i]; + if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; } + if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) { + c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u); + i++; + } + else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; } + if (c < 0x80u) { out[o++] = (char)c; } + else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); } + } + out[o] = 0; + (*env)->ReleaseStringChars(env, s, u); + return out; +} + +/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8 + and mangles supplementary characters). NULL yields the empty string and + a malformed sequence yields U+FFFD, so this never fails on producer + output; it returns NULL only when the JVM could not allocate. */ +static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) { + if (s == NULL) { s = ""; } + size_t n = strlen(s); + jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar)); + if (buf == NULL) { return NULL; } + size_t i = 0, o = 0; + while (i < n) { + unsigned char b = (unsigned char)s[i]; + uint32_t c; size_t len; + if (b < 0x80u) { c = b; len = 1; } + else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; } + else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; } + else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; } + else { c = 0xFFFDu; len = 1; } + if (i + len > n) { c = 0xFFFDu; len = 1; } + for (size_t k = 1; k < len; k++) { + unsigned char cb = (unsigned char)s[i + k]; + if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; } + c = (c << 6) | (cb & 0x3Fu); + } + i += len; + if (c > 0x10FFFFu) { c = 0xFFFDu; } + if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); } + else { buf[o++] = (jchar)c; } + } + jstring out = (*env)->NewString(env, buf, (jsize)o); + free(buf); + return out; +} + +static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { + const char* msg = err->message ? err->message : "WeaveFFI error"; + jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); + if (exClass != NULL) { + jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); + jstring jmsg = weaveffi_jni_utf8_to_string(env, msg); + jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); + if (ex != NULL) { (*env)->Throw(env, ex); } + } + weaveffi_error_clear(err); +} + +static JavaVM* weaveffi_jni_vm = NULL; + +static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) { + jclass cls = (*env)->FindClass(env, "java/lang/UnsatisfiedLinkError"); + if (cls != NULL) { (*env)->ThrowNew(env, cls, message); } + return JNI_ERR; +} + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)reserved; + weaveffi_jni_vm = vm; + JNIEnv* env = NULL; + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } + if (weaveffi_abi_version() != 2u) { + return weaveffi_jni_load_error(env, "WeaveFFI: the native library implements a different C ABI revision than these bindings (expected 2)"); + } + return JNI_VERSION_1_6; +} + +JNIEXPORT jstring JNICALL Java_com_weaveffi_WeaveFFI_describeJni(JNIEnv* env, jclass clazz, jbyteArray shape) { + weaveffi_error err = {0, NULL, NULL, 0}; + jbyte* shape_elems = (*env)->GetByteArrayElements(env, shape, NULL); + jsize shape_len = (*env)->GetArrayLength(env, shape); + const char* rv = weaveffi_shapes_describe((const uint8_t*)shape_elems, (size_t)shape_len, &err); + (*env)->ReleaseByteArrayElements(env, shape, shape_elems, JNI_ABORT); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jstring out = weaveffi_jni_utf8_to_string(env, rv); + weaveffi_free_string(rv); + return out; +} + +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_scaleJni(JNIEnv* env, jclass clazz, jbyteArray shape, jdouble factor) { + weaveffi_error err = {0, NULL, NULL, 0}; + jbyte* shape_elems = (*env)->GetByteArrayElements(env, shape, NULL); + jsize shape_len = (*env)->GetArrayLength(env, shape); + size_t out_len = 0; + const uint8_t* rv = weaveffi_shapes_scale((const uint8_t*)shape_elems, (size_t)shape_len, (double)factor, &out_len, &err); + (*env)->ReleaseByteArrayElements(env, shape, shape_elems, JNI_ABORT); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + +JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_sumBytes(JNIEnv* env, jclass clazz, jbyteArray values) { + weaveffi_error err = {0, NULL, NULL, 0}; + jboolean values_is_copy = 0; + jbyte* values_elems = (*env)->GetByteArrayElements(env, values, &values_is_copy); + jsize values_len = (*env)->GetArrayLength(env, values); + uint64_t rv = weaveffi_shapes_sum_bytes((const uint8_t*)values_elems, (size_t)values_len, &err); + (*env)->ReleaseByteArrayElements(env, values, values_elems, 0); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return 0; + } + return (jlong) rv; +} + +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_makeNumericsJni(JNIEnv* env, jclass clazz, jbyte a, jshort b, jint c, jlong d, jbyte e, jshort f, jlong g, jlong h, jfloat i, jdouble j) { + weaveffi_error err = {0, NULL, NULL, 0}; + size_t out_len = 0; + const uint8_t* rv = weaveffi_shapes_make_numerics((int8_t)a, (int16_t)b, (int32_t)c, (int64_t)d, (uint8_t)e, (uint16_t)f, (uint32_t)g, (uint64_t)h, (float)i, (double)j, &out_len, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + if (out && rv) { (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); } + weaveffi_free_bytes((uint8_t*)rv, (size_t)out_len); + return out; +} + + +// END weaveffi_jni.c diff --git a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap similarity index 93% rename from crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap rename to crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap index 6424b8ca..c8ea8a98 100644 --- a/crates/weaveffi-cli/tests/snapshots/android_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap +++ b/crates/weaveffi-cli/tests/snapshots/kotlin_shapes__src_main_kotlin_com_weaveffi_WeaveFFI_kt.snap @@ -9,7 +9,7 @@ package com.weaveffi class WeaveFFI { companion object { - init { System.loadLibrary("weaveffi") } + init { WeaveNativeLibrary.ensureLoaded() } /** * Render a shape to a string @@ -125,9 +125,16 @@ internal fun unpackNumerics(r: WeaveBufferReader): Numerics = Numerics( r.readF64(), ) -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ open class WeaveFFIException(val code: Int, message: String) : Exception(message) +/** Loads the native library once; every companion `init` calls [ensureLoaded]. */ +internal object WeaveNativeLibrary { + init { System.loadLibrary("weaveffi") } + + fun ensureLoaded() {} +} + /** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ internal class WeaveBufferWriter { private var buf = ByteArray(32) @@ -223,6 +230,12 @@ internal class WeaveBufferReader(private val buf: ByteArray) { } fun readF32(): Float = Float.fromBits(readI32()) fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long { + val token = readI64() + if (token == 0L) malformed("null object token") + return token + } private fun readLen(): Int { val n = readI32() if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__index_js.snap b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__index_js.snap index f28b2d9d..339c9956 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__index_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__index_js.snap @@ -56,6 +56,18 @@ function __invokeAsync(fn, args, map) { }); } +// Borrow a live object wrapper's native handle for one call. The wrapper +// keeps its own reference; the producer clones if it retains the object. +function __borrow(o, cls) { + if (!(o instanceof cls)) { + throw new WeaveFFIError(-3, 'expected an instance of ' + cls.name); + } + if (!o._handle) { + throw new WeaveFFIError(-3, cls.name + ' used after close()'); + } + return o._handle; +} + // --- Private value-buffer runtime (WeaveFFI wire format) -------------------- // Little-endian, packed, no alignment. Decoders reject truncated buffers, // invalid bool/flag bytes, hostile length prefixes, and trailing bytes. @@ -185,7 +197,7 @@ function __packDocument(w, v) { } function __unpackDocument(r) { return { - id: Number(r.i64()), + id: r.i64(), title: r.str(), status: r.i32(), tags: __rList(r, (r) => r.str()), @@ -222,41 +234,66 @@ function __docsErrorFrom(code, message, payload) { return _err; } +wv.Status = Object.freeze({ + Draft: 0, + Published: 1, + Archived: 2, + 0: 'Draft', + 1: 'Published', + 2: 'Archived', +}); +function __adaptSavedListener(impl) { + if (impl === null || impl === undefined) { + throw new WeaveFFIError(-3, 'SavedListener implementation must be an object'); + } + return { + on_saved(docId, revision) { + return impl.onSaved(docId, revision); + }, + }; +} + class Notebook { constructor() { this._handle = __invoke(addon.Notebook_new, [], __generic); Notebook._cleanup.register(this, this._handle, this); } insert(document) { - return __invoke(addon.Notebook_insert, [this._handle, __encode(__packDocument, document)], __generic); + return __invoke(addon.Notebook_insert, [__borrow(this, Notebook), __encode(__packDocument, document)], __generic); } find(id) { - const _r = __invoke(addon.Notebook_find, [this._handle, id], __docsErrorFrom); + const _r = __invoke(addon.Notebook_find, [__borrow(this, Notebook), id], __docsErrorFrom); return __decode(__unpackDocument, _r); } size() { - return __invoke(addon.Notebook_size, [this._handle], __generic); + return __invoke(addon.Notebook_size, [__borrow(this, Notebook)], __generic); } static maxDocuments() { return __invoke(addon.Notebook_max_documents, [], __generic); } - destroy() { + close() { if (this._handle) { Notebook._cleanup.unregister(this); - addon.Notebook_destroy(this._handle); - this._handle = 0; + addon.Notebook__destroy(this._handle); + this._handle = 0n; } } + _cloneHandle() { + return addon.Notebook__clone(__borrow(this, Notebook)); + } } -Notebook._fromHandle = function (handle) { +Notebook._adopt = function (handle) { const _o = Object.create(Notebook.prototype); _o._handle = handle; Notebook._cleanup.register(_o, handle, _o); return _o; }; Notebook._cleanup = new FinalizationRegistry((handle) => { - if (handle) { addon.Notebook_destroy(handle); } + if (handle) { addon.Notebook__destroy(handle); } }); +if (typeof Symbol.dispose === 'symbol') { + Notebook.prototype[Symbol.dispose] = Notebook.prototype.close; +} wv.Notebook = Notebook; wv.createDocument = function (title, tags) { @@ -270,6 +307,9 @@ wv.getDocument = function (id) { wv.publishAsync = function (id) { return __invokeAsync(addon.publishAsync, [id], __generic); }; +wv.watch = function (listener) { + return __invoke(addon.watch, [__adaptSavedListener(listener)], __generic); +}; module.exports = wv; diff --git a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__types_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__types_d_ts.snap index b118b621..4206845a 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__types_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__types_d_ts.snap @@ -8,8 +8,10 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated types for WeaveFFI functions /** * Base class of every error thrown by these bindings. Non-throwing - * functions reject or throw it directly for panics and marshalling - * failures; throwing functions surface a module domain subclass. + * functions reject or throw it directly for runtime traps (code -2 a + * producer panic, -3 a marshalling failure, -4 a callback interface + * implementation that threw); throwing functions surface a module + * domain subclass for their declared codes. */ export class WeaveFFIError extends Error { /** The numeric ABI error code. */ @@ -39,7 +41,7 @@ export class InvalidStatusError extends DocsErrorsError { */ export interface Document { /** Stable opaque identifier */ - id: number; + id: bigint; /** Human-readable title shown in lists */ title: string; /** Current lifecycle status */ @@ -61,6 +63,19 @@ export enum Status { Archived = 2, } // module docs +/** + * Consumer-implemented observer of document-saved notifications. + * + * Implementations must be safe to call from any thread. + */ +export interface SavedListener { + /** + * Fires after a document has been persisted successfully + * @param docId Identifier of the saved document + * @param revision Monotonically increasing revision + */ + onSaved(docId: bigint, revision: number): void; +} /** * An ordered collection of documents. * @@ -84,7 +99,7 @@ export class Notebook { * Maps to C function: weaveffi_docs_Notebook_find * @throws {DocsErrorsError} */ - find(id: number): Document; + find(id: bigint): Document; /** * The number of documents currently held * Maps to C function: weaveffi_docs_Notebook_size @@ -95,12 +110,15 @@ export class Notebook { * Maps to C function: weaveffi_docs_Notebook_max_documents */ static maxDocuments(): number; - /** Free the underlying native object. */ - destroy(): void; + /** + * Release this wrapper's reference to the native object. Safe to call + * more than once; a wrapper that is never closed is released when it is + * garbage collected. Using the wrapper after `close()` throws. + */ + close(): void; + /** Alias of `close()` for `using` declarations. */ + [Symbol.dispose](): void; } -/** Subscribe to document-saved notifications */ -export function registerSavedListener(callback: (docId: number, revision: number) => void): number -export function unregisterSavedListener(id: number): void /** * Create a brand new document. * @@ -116,12 +134,18 @@ export function createDocument(title: string, tags: string[]): Document * @param id Identifier returned from create_document * Maps to C function: weaveffi_docs_get_document */ -export function getDocument(id: number): Document | null +export function getDocument(id: bigint): Document | null /** * Publish a document in the background * @param id Identifier of the document to publish * Maps to C function: weaveffi_docs_publish_async */ -export function publishAsync(id: number): Promise +export function publishAsync(id: bigint): Promise +/** + * Register a listener for saved notifications + * @param listener Receiver of saved notifications + * Maps to C function: weaveffi_docs_watch + */ +export function watch(listener: SavedListener): void // END types.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__weaveffi_addon_c.snap b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__weaveffi_addon_c.snap index 04fd6d34..a320ed5e 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__weaveffi_addon_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_docs_everywhere__weaveffi_addon_c.snap @@ -5,11 +5,89 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o +#ifndef NAPI_VERSION +#define NAPI_VERSION 8 +#endif #include #include "weaveffi.h" +#include +#include #include #include #include +#include + +static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; + } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const char* message, const uint8_t* payload_ptr, size_t payload_len) { napi_value msg; @@ -27,13 +105,198 @@ static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const ch return err; } -typedef struct weaveffi_napi_listener_ctx { - napi_threadsafe_function tsfn; - uint64_t id; - struct weaveffi_napi_listener_ctx* next; -} weaveffi_napi_listener_ctx; +static uv_thread_t weaveffi_napi_js_thread; + +static inline bool weaveffi_napi_on_js_thread(void) { + uv_thread_t self = uv_thread_self(); + return uv_thread_equal(&self, &weaveffi_napi_js_thread) != 0; +} + +// One handle-table entry per registered callback-interface implementation; +// its address is the `ctx` the producer passes back to every method. +typedef struct { + napi_env env; + napi_ref ref; + napi_threadsafe_function tsfn; +} weaveffi_napi_cb_ctx; + +// Every method frame starts with this header so the dispatcher can reach +// `out_err` without knowing the method. +typedef struct { + weaveffi_error* out_err; +} weaveffi_napi_cb_frame_hdr; + +// A request hopped from a producer thread to the JS thread. `method` is the +// vtable index to run, or -1 to release the context. +typedef struct { + weaveffi_napi_cb_ctx* ctx; + int method; + void* frame; + uv_mutex_t mu; + uv_cond_t cv; + bool done; +} weaveffi_napi_cb_req; + +static weaveffi_napi_cb_ctx* weaveffi_napi_cb_register(napi_env env, napi_value target, const char* name, napi_threadsafe_function_call_js dispatch) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)calloc(1, sizeof(weaveffi_napi_cb_ctx)); + ctx->env = env; + napi_create_reference(env, target, 1, &ctx->ref); + napi_value resource_name; + napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name); + napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, dispatch, &ctx->tsfn); + // A live implementation must not pin the event loop by itself. + napi_unref_threadsafe_function(env, ctx->tsfn); + return ctx; +} -static weaveffi_napi_listener_ctx* weaveffi_napi_listeners = NULL; +// Release one entry on the JS thread (env is NULL only during teardown, when +// the reference is already gone). +static void weaveffi_napi_cb_release(napi_env env, weaveffi_napi_cb_ctx* ctx) { + if (env != NULL) { + napi_delete_reference(env, ctx->ref); + } + napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); + free(ctx); +} + +// The vtable's `free` entry: the producer is done with `ctx`. +static void weaveffi_napi_cb_free(void* ctx_) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)ctx_; + if (ctx == NULL) { + return; + } + if (weaveffi_napi_on_js_thread()) { + weaveffi_napi_cb_release(ctx->env, ctx); + return; + } + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)calloc(1, sizeof(weaveffi_napi_cb_req)); + req->ctx = ctx; + req->method = -1; + if (napi_call_threadsafe_function(ctx->tsfn, req, napi_tsfn_nonblocking) != napi_ok) { + free(req); + } +} + +// Run `req` on the JS thread and block until it has completed. +static void weaveffi_napi_cb_hop(weaveffi_napi_cb_req* req) { + uv_mutex_init(&req->mu); + uv_cond_init(&req->cv); + req->done = false; + if (napi_call_threadsafe_function(req->ctx->tsfn, req, napi_tsfn_blocking) == napi_ok) { + uv_mutex_lock(&req->mu); + while (!req->done) { + uv_cond_wait(&req->cv, &req->mu); + } + uv_mutex_unlock(&req->mu); + } else { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "callback interface implementation is no longer reachable"); + } + uv_cond_destroy(&req->cv); + uv_mutex_destroy(&req->mu); +} + +static void weaveffi_napi_cb_finish(weaveffi_napi_cb_req* req) { + uv_mutex_lock(&req->mu); + req->done = true; + uv_cond_signal(&req->cv); + uv_mutex_unlock(&req->mu); +} + +// Report a JS exception raised by a callback implementation (or a return +// value of the wrong type) through `out_err` as a foreign error, clearing +// the pending exception so nothing unwinds through the C frame. +static void weaveffi_napi_cb_report(napi_env env, weaveffi_error* out_err, const char* fallback) { + char msg[512]; + msg[0] = 0; + bool pending = false; + napi_is_exception_pending(env, &pending); + if (pending) { + napi_value exc; + napi_get_and_clear_last_exception(env, &exc); + napi_valuetype t; + napi_typeof(env, exc, &t); + napi_value text = exc; + if (t == napi_object) { + napi_get_named_property(env, exc, "message", &text); + } + napi_value str; + if (napi_coerce_to_string(env, text, &str) == napi_ok) { + napi_get_value_string_utf8(env, str, msg, sizeof msg, NULL); + } + napi_is_exception_pending(env, &pending); + if (pending) { + napi_get_and_clear_last_exception(env, &exc); + } + } + weaveffi_error_set(out_err, -4, msg[0] ? msg : fallback); +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + int64_t doc_id; + int32_t revision; +} weaveffi_docs_SavedListener_on_saved_frame; + +static void weaveffi_docs_SavedListener_on_saved_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_docs_SavedListener_on_saved_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_saved", &fn); + napi_value argv[2]; + napi_create_bigint_int64(env, f->doc_id, &argv[0]); + napi_create_int32(env, f->revision, &argv[1]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "SavedListener implementation has no on_saved method"); + } else if (napi_call_function(env, target, fn, 2, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "SavedListener.on_saved threw"); + } + napi_close_handle_scope(env, scope); +} + +static void weaveffi_docs_SavedListener_on_saved_tramp(void* ctx, int64_t doc_id, int32_t revision, weaveffi_error* out_err) { + weaveffi_docs_SavedListener_on_saved_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.doc_id = doc_id; + f.revision = revision; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_docs_SavedListener_on_saved_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 0; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +static void weaveffi_docs_SavedListener_napi_dispatch(napi_env env, napi_value js_cb, void* context, void* data) { + (void)js_cb; + (void)context; + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)data; + if (req->method < 0) { + weaveffi_napi_cb_release(env, req->ctx); + free(req); + return; + } + if (env == NULL) { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "Node environment is shutting down"); + } else { + switch (req->method) { + case 0: weaveffi_docs_SavedListener_on_saved_invoke(env, req->ctx, (weaveffi_docs_SavedListener_on_saved_frame*)req->frame); break; + default: break; + } + } + weaveffi_napi_cb_finish(req); +} + +static const weaveffi_docs_SavedListener_vtable weaveffi_docs_SavedListener_napi_vtable = { weaveffi_docs_SavedListener_on_saved_tramp, weaveffi_napi_cb_free }; static napi_value Napi_weaveffi_docs_Notebook_new(napi_env env, napi_callback_info info) { size_t argc = 0; @@ -46,7 +309,7 @@ static napi_value Napi_weaveffi_docs_Notebook_new(napi_env env, napi_callback_in return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + weaveffi_napi_make_handle(env, result, &ret); return ret; } @@ -69,13 +332,21 @@ static napi_value Napi_weaveffi_docs_Notebook_insert(napi_env env, napi_callback size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - void* document_raw; - size_t document_len; + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + void* document_raw = NULL; + size_t document_len = 0; napi_get_buffer_info(env, args[1], &document_raw, &document_len); + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - int32_t result = weaveffi_docs_Notebook_insert((const weaveffi_docs_Notebook*)(intptr_t)self_raw, (const uint8_t*)document_raw, document_len, &err); + int32_t result = weaveffi_docs_Notebook_insert((const weaveffi_docs_Notebook*)self_raw, (const uint8_t*)document_raw, document_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -90,13 +361,23 @@ static napi_value Napi_weaveffi_docs_Notebook_find(napi_env env, napi_callback_i size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - int64_t id; - napi_get_value_int64(env, args[1], &id); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[1], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; - const uint8_t* result = weaveffi_docs_Notebook_find((const weaveffi_docs_Notebook*)(intptr_t)self_raw, id, &out_len, &err); + size_t out_len = 0; + const uint8_t* result = weaveffi_docs_Notebook_find((const weaveffi_docs_Notebook*)self_raw, id, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -112,10 +393,18 @@ static napi_value Napi_weaveffi_docs_Notebook_size(napi_env env, napi_callback_i size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - int32_t result = weaveffi_docs_Notebook_size((const weaveffi_docs_Notebook*)(intptr_t)self_raw, &err); + int32_t result = weaveffi_docs_Notebook_size((const weaveffi_docs_Notebook*)self_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -130,81 +419,29 @@ static napi_value Napi_weaveffi_docs_Notebook_destroy(napi_env env, napi_callbac size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - weaveffi_docs_Notebook* self = (weaveffi_docs_Notebook*)(intptr_t)self_raw; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; + } + weaveffi_docs_Notebook* self = (weaveffi_docs_Notebook*)self_raw; weaveffi_docs_Notebook_destroy(self); napi_value ret; napi_get_undefined(env, &ret); return ret; } -typedef struct { - int64_t doc_id; - int32_t revision; -} weaveffi_docs_OnSaved_fn_payload; - -static void weaveffi_docs_OnSaved_fn_napi_tramp(int64_t doc_id, int32_t revision, void* context) { - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context; - weaveffi_docs_OnSaved_fn_payload* p = (weaveffi_docs_OnSaved_fn_payload*)calloc(1, sizeof(weaveffi_docs_OnSaved_fn_payload)); - p->doc_id = doc_id; - p->revision = revision; - napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking); -} - -static void weaveffi_docs_OnSaved_fn_napi_calljs(napi_env env, napi_value js_cb, void* context, void* data) { - (void)context; - weaveffi_docs_OnSaved_fn_payload* p = (weaveffi_docs_OnSaved_fn_payload*)data; - if (env != NULL) { - napi_value undefined; - napi_get_undefined(env, &undefined); - napi_value argv[2]; - napi_create_int64(env, p->doc_id, &argv[0]); - napi_create_int32(env, p->revision, &argv[1]); - napi_call_function(env, undefined, js_cb, 2, argv, NULL); - } - free(p); -} - -static napi_value Napi_weaveffi_docs_register_saved_listener(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_docs_Notebook_clone(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)calloc(1, sizeof(weaveffi_napi_listener_ctx)); - napi_value resource_name; - napi_create_string_utf8(env, "weaveffi_docs_register_saved_listener", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_docs_OnSaved_fn_napi_calljs, &ctx->tsfn); - napi_unref_threadsafe_function(env, ctx->tsfn); - uint64_t id = weaveffi_docs_register_saved_listener(weaveffi_docs_OnSaved_fn_napi_tramp, ctx); - ctx->id = id; - ctx->next = weaveffi_napi_listeners; - weaveffi_napi_listeners = ctx; - napi_value ret; - napi_create_double(env, (double)id, &ret); - return ret; -} - -static napi_value Napi_weaveffi_docs_unregister_saved_listener(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); - double id_d = 0; - napi_get_value_double(env, args[0], &id_d); - uint64_t id = (uint64_t)id_d; - weaveffi_docs_unregister_saved_listener(id); - weaveffi_napi_listener_ctx** link = &weaveffi_napi_listeners; - while (*link != NULL) { - if ((*link)->id == id) { - weaveffi_napi_listener_ctx* found = *link; - *link = found->next; - napi_release_threadsafe_function(found->tsfn, napi_tsfn_release); - free(found); - break; - } - link = &(*link)->next; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; } + weaveffi_docs_Notebook* self = (weaveffi_docs_Notebook*)self_raw; + weaveffi_docs_Notebook* cloned = weaveffi_docs_Notebook_clone(self); napi_value ret; - napi_get_undefined(env, &ret); + weaveffi_napi_make_handle(env, cloned, &ret); return ret; } @@ -212,15 +449,15 @@ static napi_value Napi_weaveffi_docs_create_document(napi_env env, napi_callback size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t title_len; + size_t title_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &title_len); char* title = (char*)malloc(title_len + 1); napi_get_value_string_utf8(env, args[0], title, title_len + 1, &title_len); - void* tags_raw; - size_t tags_len; + void* tags_raw = NULL; + size_t tags_len = 0; napi_get_buffer_info(env, args[1], &tags_raw, &tags_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_docs_create_document(title, (const uint8_t*)tags_raw, tags_len, &out_len, &err); free(title); if (err.code != 0) { @@ -238,10 +475,16 @@ static napi_value Napi_weaveffi_docs_get_document(napi_env env, napi_callback_in size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t id; - napi_get_value_int64(env, args[0], &id); + napi_status arg_status = napi_ok; + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_docs_get_document(id, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -302,8 +545,14 @@ static napi_value Napi_weaveffi_docs_publish_async(napi_env env, napi_callback_i size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t id; - napi_get_value_int64(env, args[0], &id); + napi_status arg_status = napi_ok; + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_docs_publish_async_napi_actx* ctx = (weaveffi_docs_publish_async_napi_actx*)calloc(1, sizeof(weaveffi_docs_publish_async_napi_actx)); napi_value promise; napi_create_promise(env, &ctx->deferred, &promise); @@ -314,6 +563,36 @@ static napi_value Napi_weaveffi_docs_publish_async(napi_env env, napi_callback_i return promise; } +static napi_value Napi_weaveffi_docs_watch(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + napi_status arg_status = napi_ok; + napi_valuetype listener_type; + napi_typeof(env, args[0], &listener_type); + weaveffi_napi_cb_ctx* listener_ctx = NULL; + if (listener_type == napi_object || listener_type == napi_function) { + listener_ctx = weaveffi_napi_cb_register(env, args[0], "weaveffi_docs_SavedListener", weaveffi_docs_SavedListener_napi_dispatch); + } else { + napi_throw_type_error(env, NULL, "expected a SavedListener implementation"); + arg_status = napi_object_expected; + } + if (arg_status != napi_ok) { + if (listener_ctx != NULL) weaveffi_napi_cb_release(env, listener_ctx); + return NULL; + } + weaveffi_error err = {0}; + weaveffi_docs_watch((void*)listener_ctx, &weaveffi_docs_SavedListener_napi_vtable, &err); + if (err.code != 0) { + napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); + weaveffi_error_clear(&err); + return NULL; + } + napi_value ret; + napi_get_undefined(env, &ret); + return ret; +} + static napi_value Init(napi_env env, napi_value exports) { { uint32_t found = weaveffi_abi_version(); @@ -324,18 +603,19 @@ static napi_value Init(napi_env env, napi_value exports) { return NULL; } } + weaveffi_napi_js_thread = uv_thread_self(); napi_property_descriptor props[] = { { "Notebook_new", NULL, Napi_weaveffi_docs_Notebook_new, NULL, NULL, NULL, napi_default, NULL }, { "Notebook_max_documents", NULL, Napi_weaveffi_docs_Notebook_max_documents, NULL, NULL, NULL, napi_default, NULL }, { "Notebook_insert", NULL, Napi_weaveffi_docs_Notebook_insert, NULL, NULL, NULL, napi_default, NULL }, { "Notebook_find", NULL, Napi_weaveffi_docs_Notebook_find, NULL, NULL, NULL, napi_default, NULL }, { "Notebook_size", NULL, Napi_weaveffi_docs_Notebook_size, NULL, NULL, NULL, napi_default, NULL }, - { "Notebook_destroy", NULL, Napi_weaveffi_docs_Notebook_destroy, NULL, NULL, NULL, napi_default, NULL }, - { "registerSavedListener", NULL, Napi_weaveffi_docs_register_saved_listener, NULL, NULL, NULL, napi_default, NULL }, - { "unregisterSavedListener", NULL, Napi_weaveffi_docs_unregister_saved_listener, NULL, NULL, NULL, napi_default, NULL }, + { "Notebook__destroy", NULL, Napi_weaveffi_docs_Notebook_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "Notebook__clone", NULL, Napi_weaveffi_docs_Notebook_clone, NULL, NULL, NULL, napi_default, NULL }, { "createDocument", NULL, Napi_weaveffi_docs_create_document, NULL, NULL, NULL, napi_default, NULL }, { "getDocument", NULL, Napi_weaveffi_docs_get_document, NULL, NULL, NULL, napi_default, NULL }, { "publishAsync", NULL, Napi_weaveffi_docs_publish_async, NULL, NULL, NULL, napi_default, NULL }, + { "watch", NULL, Napi_weaveffi_docs_watch, NULL, NULL, NULL, napi_default, NULL }, }; napi_define_properties(env, exports, 11, props); return exports; diff --git a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__index_js.snap b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__index_js.snap index 489184cc..8047997a 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__index_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__index_js.snap @@ -56,6 +56,18 @@ function __invokeAsync(fn, args, map) { }); } +// Borrow a live object wrapper's native handle for one call. The wrapper +// keeps its own reference; the producer clones if it retains the object. +function __borrow(o, cls) { + if (!(o instanceof cls)) { + throw new WeaveFFIError(-3, 'expected an instance of ' + cls.name); + } + if (!o._handle) { + throw new WeaveFFIError(-3, cls.name + ' used after close()'); + } + return o._handle; +} + // --- Private value-buffer runtime (WeaveFFI wire format) -------------------- // Little-endian, packed, no alignment. Decoders reject truncated buffers, // invalid bool/flag bytes, hostile length prefixes, and trailing bytes. @@ -215,10 +227,10 @@ function __unpackOuter(r) { by_name: __rMap(r, (r) => r.str(), __unpackInner), modes: __rList(r, (r) => r.i32()), matrix: __rList(r, (r) => __rList(r, (r) => r.i32())), - sparse: __rList(r, (r) => __rOpt(r, (r) => Number(r.i64()))), + sparse: __rList(r, (r) => __rOpt(r, (r) => r.i64())), blob: __rOpt(r, (r) => r.bytes()), note: __rOpt(r, (r) => r.str()), - by_id: __rMap(r, (r) => Number(r.i64()), (r) => r.str()), + by_id: __rMap(r, (r) => r.i64(), (r) => r.str()), _hidden: r.bool(), v2: r.u8(), }; @@ -352,7 +364,21 @@ function __packRetired(w, v) { } function __unpackRetired(r) { return { - id: Number(r.i64()), + id: r.i64(), + }; +} +function __packWidgetHolder(w, v) { + w.u64(v.primary._cloneHandle()); + __wOpt(w, v.fallback, (w, v) => w.u64(v._cloneHandle())); + __wList(w, v.extras, (w, v) => w.u64(v._cloneHandle())); + __wMap(w, v.by_name, (w, k) => w.str(k), (w, v) => w.u64(v._cloneHandle())); +} +function __unpackWidgetHolder(r) { + return { + primary: Widget._adopt(r.u64()), + fallback: __rOpt(r, (r) => Widget._adopt(r.u64())), + extras: __rList(r, (r) => Widget._adopt(r.u64())), + by_name: __rMap(r, (r) => r.str(), (r) => Widget._adopt(r.u64())), }; } @@ -413,74 +439,132 @@ function __edgeCasesErrorFrom(code, message, payload) { return _err; } +wv.Legacy = Object.freeze({ + None: 0, + Default: 5, + Class: 10, + 0: 'None', + 5: 'Default', + 10: 'Class', +}); +wv.Mode = Object.freeze({ + Fast: 1, + Safe: 2, + 1: 'Fast', + 2: 'Safe', +}); +function __adaptBatchListener(impl) { + if (impl === null || impl === undefined) { + throw new WeaveFFIError(-3, 'BatchListener implementation must be an object'); + } + return { + on_batch(items, raw, first, mode) { + return impl.onBatch(__decode((r) => __rList(r, __unpackOuter), items), raw, __decode((r) => __rOpt(r, __unpackOuter), first), mode); + }, + on_widget(widget, fallback, children) { + return impl.onWidget(Widget._adopt(widget), (fallback == null ? null : Widget._adopt(fallback)), __decode((r) => __rList(r, (r) => Widget._adopt(r.u64())), children)); + }, + on_tick() { + return impl.onTick(); + }, + should_continue(class_) { + return impl.shouldContinue(class_); + }, + pick_mode() { + return impl.pickMode(); + }, + }; +} + +function __adaptKeyword(impl) { + if (impl === null || impl === undefined) { + throw new WeaveFFIError(-3, 'Keyword implementation must be an object'); + } + return { + delete(self) { + return impl.delete(self); + }, + import() { + return impl.import(); + }, + }; +} + class Widget { refresh() { - return __invoke(addon.Widget_refresh, [this._handle], __edgeCasesErrorFrom); + return __invoke(addon.Widget_refresh, [__borrow(this, Widget)], __edgeCasesErrorFrom); } rename(class_, import_) { - return __invoke(addon.Widget_rename, [this._handle, class_, __encode((w, v) => __wOpt(w, v, (w, v) => w.str(v)), import_)], __generic); + return __invoke(addon.Widget_rename, [__borrow(this, Widget), class_, __encode((w, v) => __wOpt(w, v, (w, v) => w.str(v)), import_)], __generic); } fetch(id) { - return __invokeAsync(addon.Widget_fetch, [this._handle, id], __edgeCasesErrorFrom).then((_r) => __decode(__unpackOuter, _r)); + return __invokeAsync(addon.Widget_fetch, [__borrow(this, Widget), id], __edgeCasesErrorFrom).then((_r) => __decode(__unpackOuter, _r)); } oldName() { - return __invoke(addon.Widget_old_name, [this._handle], __generic); + return __invoke(addon.Widget_old_name, [__borrow(this, Widget)], __generic); } - destroy() { + close() { if (this._handle) { Widget._cleanup.unregister(this); - addon.Widget_destroy(this._handle); - this._handle = 0; + addon.Widget__destroy(this._handle); + this._handle = 0n; } } + _cloneHandle() { + return addon.Widget__clone(__borrow(this, Widget)); + } } -Widget._fromHandle = function (handle) { +Widget._adopt = function (handle) { const _o = Object.create(Widget.prototype); _o._handle = handle; Widget._cleanup.register(_o, handle, _o); return _o; }; Widget._cleanup = new FinalizationRegistry((handle) => { - if (handle) { addon.Widget_destroy(handle); } + if (handle) { addon.Widget__destroy(handle); } }); +if (typeof Symbol.dispose === 'symbol') { + Widget.prototype[Symbol.dispose] = Widget.prototype.close; +} wv.Widget = Widget; class Vault { static open(path) { const _r = __invoke(addon.Vault_open, [path], __edgeCasesErrorFrom); - return Vault._fromHandle(_r); + return Vault._adopt(_r); } peek() { - const _r = __invoke(addon.Vault_peek, [this._handle], __generic); - return _r == null ? null : Widget._fromHandle(_r); + const _r = __invoke(addon.Vault_peek, [__borrow(this, Vault)], __generic); + return (_r == null ? null : Widget._adopt(_r)); } static probe() { return __invoke(addon.Vault_probe, [], __edgeCasesErrorFrom); } - destroy() { + close() { if (this._handle) { Vault._cleanup.unregister(this); - addon.Vault_destroy(this._handle); - this._handle = 0; + addon.Vault__destroy(this._handle); + this._handle = 0n; } } + _cloneHandle() { + return addon.Vault__clone(__borrow(this, Vault)); + } } -Vault._fromHandle = function (handle) { +Vault._adopt = function (handle) { const _o = Object.create(Vault.prototype); _o._handle = handle; Vault._cleanup.register(_o, handle, _o); return _o; }; Vault._cleanup = new FinalizationRegistry((handle) => { - if (handle) { addon.Vault_destroy(handle); } + if (handle) { addon.Vault__destroy(handle); } }); +if (typeof Symbol.dispose === 'symbol') { + Vault.prototype[Symbol.dispose] = Vault.prototype.close; +} wv.Vault = Vault; -wv.registerBatchListener = function (callback) { - return addon.registerBatchListener(function (items, raw, first, mode) { - callback(__decode((r) => __rList(r, __unpackOuter), items), raw, __decode((r) => __rOpt(r, __unpackOuter), first), mode); - }); -}; wv.noop = function () { return __invoke(addon.noop, [], __generic); }; @@ -495,22 +579,30 @@ wv.optionalParams = function (text, count, outer, tags, blob, mode) { const _r = __invoke(addon.optionalParams, [__encode((w, v) => __wOpt(w, v, (w, v) => w.str(v)), text), __encode((w, v) => __wOpt(w, v, (w, v) => w.i32(v)), count), __encode((w, v) => __wOpt(w, v, __packOuter), outer), __encode((w, v) => __wOpt(w, v, (w, v) => __wList(w, v, (w, v) => w.str(v))), tags), __encode((w, v) => __wOpt(w, v, (w, v) => w.bytes(v)), blob), __encode((w, v) => __wOpt(w, v, (w, v) => w.i32(v)), mode)], __generic); return __decode((r) => __rOpt(r, (r) => r.i32()), _r); }; -wv.mutableParams = function (text, blob) { - return __invoke(addon.mutableParams, [text, blob], __generic); +wv.registerBatch = function (listener, keyword) { + return __invoke(addon.registerBatch, [__adaptBatchListener(listener), __adaptKeyword(keyword)], __generic); +}; +wv.widgetsEverywhere = function (list, byName, sparse, holder) { + const _r = __invoke(addon.widgetsEverywhere, [__encode((w, v) => __wList(w, v, (w, v) => w.u64(v._cloneHandle())), list), __encode((w, v) => __wMap(w, v, (w, k) => w.str(k), (w, v) => w.u64(v._cloneHandle())), byName), __encode((w, v) => __wList(w, v, (w, v) => __wOpt(w, v, (w, v) => w.u64(v._cloneHandle()))), sparse), __encode(__packWidgetHolder, holder)], __generic); + return __decode(__unpackWidgetHolder, _r); +}; +wv.widgetStream = function () { + const _it = __invoke(addon.widgetStream, [], __generic); + return new WeaveFFIIterator(_it, addon.widgetStream_iterNext, addon.widgetStream_iterDestroy, __generic, (_e) => Widget._adopt(_e)); +}; +wv.asyncWidget = function () { + return __invokeAsync(addon.asyncWidget, [], __edgeCasesErrorFrom).then((_r) => Widget._adopt(_r)); }; wv.makeWidget = function (label) { const _r = __invoke(addon.makeWidget, [label], __generic); - return Widget._fromHandle(_r); + return Widget._adopt(_r); }; wv.findWidget = function (id) { const _r = __invoke(addon.findWidget, [id], __edgeCasesErrorFrom); - return _r == null ? null : Widget._fromHandle(_r); + return (_r == null ? null : Widget._adopt(_r)); }; wv.inspectWidget = function (widget, fallback) { - return __invoke(addon.inspectWidget, [widget instanceof Widget ? widget._handle : widget, fallback instanceof Widget ? fallback._handle : fallback], __generic); -}; -wv.typedHandleParam = function (token, raw) { - return __invoke(addon.typedHandleParam, [token, raw], __generic); + return __invoke(addon.inspectWidget, [__borrow(widget, Widget), (fallback == null ? null : __borrow(fallback, Widget))], __generic); }; wv.stringStream = function () { const _it = __invoke(addon.stringStream, [], __generic); @@ -537,7 +629,7 @@ wv.asyncList = function () { return __invokeAsync(addon.asyncList, [], __edgeCasesErrorFrom).then((_r) => __decode((r) => __rList(r, __unpackOuter), _r)); }; wv.asyncOptional = function () { - return __invokeAsync(addon.asyncOptional, [], __generic).then((_r) => __decode((r) => __rOpt(r, (r) => Number(r.i64())), _r)); + return __invokeAsync(addon.asyncOptional, [], __generic).then((_r) => __decode((r) => __rOpt(r, (r) => r.i64()), _r)); }; wv.asyncEnum = function () { return __invokeAsync(addon.asyncEnum, [], __generic); diff --git a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__types_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__types_d_ts.snap index de170b60..e4e4828f 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__types_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__types_d_ts.snap @@ -8,8 +8,10 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated types for WeaveFFI functions /** * Base class of every error thrown by these bindings. Non-throwing - * functions reject or throw it directly for panics and marshalling - * failures; throwing functions surface a module domain subclass. + * functions reject or throw it directly for runtime traps (code -2 a + * producer panic, -3 a marshalling failure, -4 a callback interface + * implementation that threw); throwing functions surface a module + * domain subclass for their declared codes. */ export class WeaveFFIError extends Error { /** The numeric ABI error code. */ @@ -40,10 +42,10 @@ export interface Outer { by_name: Record; modes: Mode[]; matrix: number[][]; - sparse: (number | null)[]; + sparse: (bigint | null)[]; blob: Buffer | null; note: string | null; - by_id: Record; + by_id: Record; _hidden: boolean; v2: number; } @@ -111,7 +113,14 @@ export interface Keywords { } /** Deprecated struct */ export interface Retired { - id: number; + id: bigint; +} +/** Record carrying objects in every buffered position */ +export interface WidgetHolder { + primary: Widget; + fallback: Widget | null; + extras: Widget[]; + by_name: Record; } /** Deprecated enum with non-contiguous values and keyword-like variants */ export enum Legacy { @@ -124,6 +133,31 @@ export enum Mode { Safe = 2, } // module edge_cases +/** Callback interface carrying composite payloads and objects */ +export interface BatchListener { + /** + * Composite payloads cross as value buffers + */ + onBatch(items: Outer[], raw: Buffer, first: Outer | null, mode: Mode): void; + /** + * Objects cross as strong references the consumer must release + */ + onWidget(widget: Widget, fallback: Widget | null, children: Widget[]): void; + onTick(): void; + /** + * Scalar return travels back through the vtable + */ + shouldContinue(class_: number): boolean; + /** + * Enum return travels back as its integer discriminant + */ + pickMode(): Mode; +} +/** Callback interface whose name and methods are keywords */ +export interface Keyword { + delete(self: number): void; + import(): number; +} /** Interface with no constructor; instances come from make_widget */ export class Widget { /** @@ -141,14 +175,20 @@ export class Widget { * Maps to C function: weaveffi_edge_cases_Widget_fetch * @throws {EdgeErrorsError} */ - fetch(id: number): Promise; + fetch(id: bigint): Promise; /** * Maps to C function: weaveffi_edge_cases_Widget_old_name * @deprecated Use rename instead */ oldName(): string; - /** Free the underlying native object. */ - destroy(): void; + /** + * Release this wrapper's reference to the native object. Safe to call + * more than once; a wrapper that is never closed is released when it is + * garbage collected. Using the wrapper after `close()` throws. + */ + close(): void; + /** Alias of `close()` for `using` declarations. */ + [Symbol.dispose](): void; } /** Deprecated interface with a throwing constructor */ export class Vault { @@ -168,13 +208,15 @@ export class Vault { * @throws {EdgeErrorsError} */ static probe(): boolean; - /** Free the underlying native object. */ - destroy(): void; + /** + * Release this wrapper's reference to the native object. Safe to call + * more than once; a wrapper that is never closed is released when it is + * garbage collected. Using the wrapper after `close()` throws. + */ + close(): void; + /** Alias of `close()` for `using` declarations. */ + [Symbol.dispose](): void; } -export function registerBatchListener(callback: (items: Outer[], raw: Buffer, first: Outer | null, mode: Mode) => void): number -export function unregisterBatchListener(id: number): void -export function registerTickListener(callback: () => void): number -export function unregisterTickListener(id: number): void /** * No parameters and no result * Maps to C function: weaveffi_edge_cases_noop @@ -195,10 +237,26 @@ export function nestedOptional(items: (Outer | null)[] | null): (Outer | null)[] */ export function optionalParams(text: string | null, count: number | null, outer: Outer | null, tags: string[] | null, blob: Buffer | null, mode: Mode | null): Mode | null /** - * Write-back parameters - * Maps to C function: weaveffi_edge_cases_mutable_params + * Two callback interface parameters on one function + * Maps to C function: weaveffi_edge_cases_register_batch + */ +export function registerBatch(listener: BatchListener, keyword: Keyword): void +/** + * Objects in every buffered position + * Maps to C function: weaveffi_edge_cases_widgets_everywhere + */ +export function widgetsEverywhere(list: Widget[], byName: Record, sparse: (Widget | null)[], holder: WidgetHolder): WidgetHolder +/** + * Iterator of objects + * Maps to C function: weaveffi_edge_cases_widget_stream + */ +export function widgetStream(): IterableIterator +/** + * Async returning an object + * Maps to C function: weaveffi_edge_cases_async_widget + * @throws {EdgeErrorsError} */ -export function mutableParams(text: string, blob: Buffer): void +export function asyncWidget(): Promise /** * Factory for an interface with no constructor * Maps to C function: weaveffi_edge_cases_make_widget @@ -209,16 +267,12 @@ export function makeWidget(label: string): Widget * Maps to C function: weaveffi_edge_cases_find_widget * @throws {EdgeErrorsError} */ -export function findWidget(id: number): Widget | null +export function findWidget(id: bigint): Widget | null /** * Interface parameter, including an optional one * Maps to C function: weaveffi_edge_cases_inspect_widget */ export function inspectWidget(widget: Widget, fallback: Widget | null): string -/** - * Maps to C function: weaveffi_edge_cases_typed_handle_param - */ -export function typedHandleParam(token: Leaf, raw: bigint): Leaf /** * Iterator of strings * Maps to C function: weaveffi_edge_cases_string_stream @@ -256,7 +310,7 @@ export function asyncList(): Promise /** * Maps to C function: weaveffi_edge_cases_async_optional */ -export function asyncOptional(): Promise +export function asyncOptional(): Promise /** * Maps to C function: weaveffi_edge_cases_async_enum */ diff --git a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__weaveffi_addon_c.snap b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__weaveffi_addon_c.snap index df5863cb..56a32fa3 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_edge_cases__weaveffi_addon_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_edge_cases__weaveffi_addon_c.snap @@ -5,11 +5,89 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o +#ifndef NAPI_VERSION +#define NAPI_VERSION 8 +#endif #include #include "weaveffi.h" +#include +#include #include #include #include +#include + +static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; + } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const char* message, const uint8_t* payload_ptr, size_t payload_len) { napi_value msg; @@ -31,22 +109,528 @@ typedef struct { void* iter; } weaveffi_napi_iter_state; -typedef struct weaveffi_napi_listener_ctx { - napi_threadsafe_function tsfn; - uint64_t id; - struct weaveffi_napi_listener_ctx* next; -} weaveffi_napi_listener_ctx; +static uv_thread_t weaveffi_napi_js_thread; + +static inline bool weaveffi_napi_on_js_thread(void) { + uv_thread_t self = uv_thread_self(); + return uv_thread_equal(&self, &weaveffi_napi_js_thread) != 0; +} + +// One handle-table entry per registered callback-interface implementation; +// its address is the `ctx` the producer passes back to every method. +typedef struct { + napi_env env; + napi_ref ref; + napi_threadsafe_function tsfn; +} weaveffi_napi_cb_ctx; + +// Every method frame starts with this header so the dispatcher can reach +// `out_err` without knowing the method. +typedef struct { + weaveffi_error* out_err; +} weaveffi_napi_cb_frame_hdr; + +// A request hopped from a producer thread to the JS thread. `method` is the +// vtable index to run, or -1 to release the context. +typedef struct { + weaveffi_napi_cb_ctx* ctx; + int method; + void* frame; + uv_mutex_t mu; + uv_cond_t cv; + bool done; +} weaveffi_napi_cb_req; + +static weaveffi_napi_cb_ctx* weaveffi_napi_cb_register(napi_env env, napi_value target, const char* name, napi_threadsafe_function_call_js dispatch) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)calloc(1, sizeof(weaveffi_napi_cb_ctx)); + ctx->env = env; + napi_create_reference(env, target, 1, &ctx->ref); + napi_value resource_name; + napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name); + napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, dispatch, &ctx->tsfn); + // A live implementation must not pin the event loop by itself. + napi_unref_threadsafe_function(env, ctx->tsfn); + return ctx; +} + +// Release one entry on the JS thread (env is NULL only during teardown, when +// the reference is already gone). +static void weaveffi_napi_cb_release(napi_env env, weaveffi_napi_cb_ctx* ctx) { + if (env != NULL) { + napi_delete_reference(env, ctx->ref); + } + napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); + free(ctx); +} + +// The vtable's `free` entry: the producer is done with `ctx`. +static void weaveffi_napi_cb_free(void* ctx_) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)ctx_; + if (ctx == NULL) { + return; + } + if (weaveffi_napi_on_js_thread()) { + weaveffi_napi_cb_release(ctx->env, ctx); + return; + } + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)calloc(1, sizeof(weaveffi_napi_cb_req)); + req->ctx = ctx; + req->method = -1; + if (napi_call_threadsafe_function(ctx->tsfn, req, napi_tsfn_nonblocking) != napi_ok) { + free(req); + } +} + +// Run `req` on the JS thread and block until it has completed. +static void weaveffi_napi_cb_hop(weaveffi_napi_cb_req* req) { + uv_mutex_init(&req->mu); + uv_cond_init(&req->cv); + req->done = false; + if (napi_call_threadsafe_function(req->ctx->tsfn, req, napi_tsfn_blocking) == napi_ok) { + uv_mutex_lock(&req->mu); + while (!req->done) { + uv_cond_wait(&req->cv, &req->mu); + } + uv_mutex_unlock(&req->mu); + } else { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "callback interface implementation is no longer reachable"); + } + uv_cond_destroy(&req->cv); + uv_mutex_destroy(&req->mu); +} + +static void weaveffi_napi_cb_finish(weaveffi_napi_cb_req* req) { + uv_mutex_lock(&req->mu); + req->done = true; + uv_cond_signal(&req->cv); + uv_mutex_unlock(&req->mu); +} + +// Report a JS exception raised by a callback implementation (or a return +// value of the wrong type) through `out_err` as a foreign error, clearing +// the pending exception so nothing unwinds through the C frame. +static void weaveffi_napi_cb_report(napi_env env, weaveffi_error* out_err, const char* fallback) { + char msg[512]; + msg[0] = 0; + bool pending = false; + napi_is_exception_pending(env, &pending); + if (pending) { + napi_value exc; + napi_get_and_clear_last_exception(env, &exc); + napi_valuetype t; + napi_typeof(env, exc, &t); + napi_value text = exc; + if (t == napi_object) { + napi_get_named_property(env, exc, "message", &text); + } + napi_value str; + if (napi_coerce_to_string(env, text, &str) == napi_ok) { + napi_get_value_string_utf8(env, str, msg, sizeof msg, NULL); + } + napi_is_exception_pending(env, &pending); + if (pending) { + napi_get_and_clear_last_exception(env, &exc); + } + } + weaveffi_error_set(out_err, -4, msg[0] ? msg : fallback); +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + const uint8_t* items_ptr; + size_t items_len; + const uint8_t* raw_ptr; + size_t raw_len; + const uint8_t* first_ptr; + size_t first_len; + weaveffi_edge_cases_Mode mode; +} weaveffi_edge_cases_BatchListener_on_batch_frame; + +static void weaveffi_edge_cases_BatchListener_on_batch_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_BatchListener_on_batch_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_batch", &fn); + napi_value argv[4]; + napi_create_buffer_copy(env, f->items_len, f->items_ptr ? (const void*)f->items_ptr : (const void*)"", NULL, &argv[0]); + napi_create_buffer_copy(env, f->raw_len, f->raw_ptr ? (const void*)f->raw_ptr : (const void*)"", NULL, &argv[1]); + napi_create_buffer_copy(env, f->first_len, f->first_ptr ? (const void*)f->first_ptr : (const void*)"", NULL, &argv[2]); + napi_create_int32(env, (int32_t)(f->mode), &argv[3]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener implementation has no on_batch method"); + } else if (napi_call_function(env, target, fn, 4, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.on_batch threw"); + } + napi_close_handle_scope(env, scope); +} + +static void weaveffi_edge_cases_BatchListener_on_batch_tramp(void* ctx, const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, weaveffi_error* out_err) { + weaveffi_edge_cases_BatchListener_on_batch_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.items_ptr = items_ptr; + f.items_len = items_len; + f.raw_ptr = raw_ptr; + f.raw_len = raw_len; + f.first_ptr = first_ptr; + f.first_len = first_len; + f.mode = mode; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_BatchListener_on_batch_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 0; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + weaveffi_edge_cases_Widget* widget; + weaveffi_edge_cases_Widget* fallback; + const uint8_t* children_ptr; + size_t children_len; +} weaveffi_edge_cases_BatchListener_on_widget_frame; + +static void weaveffi_edge_cases_BatchListener_on_widget_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_BatchListener_on_widget_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_widget", &fn); + napi_value argv[3]; + weaveffi_napi_make_handle(env, f->widget, &argv[0]); + weaveffi_napi_make_handle(env, f->fallback, &argv[1]); + napi_create_buffer_copy(env, f->children_len, f->children_ptr ? (const void*)f->children_ptr : (const void*)"", NULL, &argv[2]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener implementation has no on_widget method"); + } else if (napi_call_function(env, target, fn, 3, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.on_widget threw"); + } + napi_close_handle_scope(env, scope); +} + +static void weaveffi_edge_cases_BatchListener_on_widget_tramp(void* ctx, weaveffi_edge_cases_Widget* widget, weaveffi_edge_cases_Widget* fallback, const uint8_t* children_ptr, size_t children_len, weaveffi_error* out_err) { + weaveffi_edge_cases_BatchListener_on_widget_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.widget = widget; + f.fallback = fallback; + f.children_ptr = children_ptr; + f.children_len = children_len; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_BatchListener_on_widget_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 1; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; +} weaveffi_edge_cases_BatchListener_on_tick_frame; + +static void weaveffi_edge_cases_BatchListener_on_tick_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_BatchListener_on_tick_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_tick", &fn); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener implementation has no on_tick method"); + } else if (napi_call_function(env, target, fn, 0, NULL, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.on_tick threw"); + } + napi_close_handle_scope(env, scope); +} + +static void weaveffi_edge_cases_BatchListener_on_tick_tramp(void* ctx, weaveffi_error* out_err) { + weaveffi_edge_cases_BatchListener_on_tick_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_BatchListener_on_tick_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 2; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + int32_t class; + bool result; +} weaveffi_edge_cases_BatchListener_should_continue_frame; + +static void weaveffi_edge_cases_BatchListener_should_continue_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_BatchListener_should_continue_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "should_continue", &fn); + napi_value argv[1]; + napi_create_int32(env, f->class, &argv[0]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener implementation has no should_continue method"); + } else if (napi_call_function(env, target, fn, 1, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.should_continue threw"); + } else { + napi_status rs; + rs = napi_get_value_bool(env, result, &f->result); + if (rs != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.should_continue returned a value of the wrong type"); + } + } + napi_close_handle_scope(env, scope); +} + +static bool weaveffi_edge_cases_BatchListener_should_continue_tramp(void* ctx, int32_t class, weaveffi_error* out_err) { + weaveffi_edge_cases_BatchListener_should_continue_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.class = class; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_BatchListener_should_continue_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 3; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } + return f.result; +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + weaveffi_edge_cases_Mode result; +} weaveffi_edge_cases_BatchListener_pick_mode_frame; + +static void weaveffi_edge_cases_BatchListener_pick_mode_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_BatchListener_pick_mode_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "pick_mode", &fn); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener implementation has no pick_mode method"); + } else if (napi_call_function(env, target, fn, 0, NULL, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.pick_mode threw"); + } else { + napi_status rs; + int32_t f__result_tmp = 0; + rs = napi_get_value_int32(env, result, &f__result_tmp); + f->result = f__result_tmp; + if (rs != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "BatchListener.pick_mode returned a value of the wrong type"); + } + } + napi_close_handle_scope(env, scope); +} + +static weaveffi_edge_cases_Mode weaveffi_edge_cases_BatchListener_pick_mode_tramp(void* ctx, weaveffi_error* out_err) { + weaveffi_edge_cases_BatchListener_pick_mode_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_BatchListener_pick_mode_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 4; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } + return f.result; +} + +static void weaveffi_edge_cases_BatchListener_napi_dispatch(napi_env env, napi_value js_cb, void* context, void* data) { + (void)js_cb; + (void)context; + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)data; + if (req->method < 0) { + weaveffi_napi_cb_release(env, req->ctx); + free(req); + return; + } + if (env == NULL) { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "Node environment is shutting down"); + } else { + switch (req->method) { + case 0: weaveffi_edge_cases_BatchListener_on_batch_invoke(env, req->ctx, (weaveffi_edge_cases_BatchListener_on_batch_frame*)req->frame); break; + case 1: weaveffi_edge_cases_BatchListener_on_widget_invoke(env, req->ctx, (weaveffi_edge_cases_BatchListener_on_widget_frame*)req->frame); break; + case 2: weaveffi_edge_cases_BatchListener_on_tick_invoke(env, req->ctx, (weaveffi_edge_cases_BatchListener_on_tick_frame*)req->frame); break; + case 3: weaveffi_edge_cases_BatchListener_should_continue_invoke(env, req->ctx, (weaveffi_edge_cases_BatchListener_should_continue_frame*)req->frame); break; + case 4: weaveffi_edge_cases_BatchListener_pick_mode_invoke(env, req->ctx, (weaveffi_edge_cases_BatchListener_pick_mode_frame*)req->frame); break; + default: break; + } + } + weaveffi_napi_cb_finish(req); +} + +static const weaveffi_edge_cases_BatchListener_vtable weaveffi_edge_cases_BatchListener_napi_vtable = { weaveffi_edge_cases_BatchListener_on_batch_tramp, weaveffi_edge_cases_BatchListener_on_widget_tramp, weaveffi_edge_cases_BatchListener_on_tick_tramp, weaveffi_edge_cases_BatchListener_should_continue_tramp, weaveffi_edge_cases_BatchListener_pick_mode_tramp, weaveffi_napi_cb_free }; + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + int32_t self; +} weaveffi_edge_cases_Keyword_delete_frame; + +static void weaveffi_edge_cases_Keyword_delete_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_Keyword_delete_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "delete", &fn); + napi_value argv[1]; + napi_create_int32(env, f->self, &argv[0]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "Keyword implementation has no delete method"); + } else if (napi_call_function(env, target, fn, 1, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "Keyword.delete threw"); + } + napi_close_handle_scope(env, scope); +} -static weaveffi_napi_listener_ctx* weaveffi_napi_listeners = NULL; +static void weaveffi_edge_cases_Keyword_delete_tramp(void* ctx, int32_t self, weaveffi_error* out_err) { + weaveffi_edge_cases_Keyword_delete_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.self = self; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_Keyword_delete_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 0; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + int32_t result; +} weaveffi_edge_cases_Keyword_import_frame; + +static void weaveffi_edge_cases_Keyword_import_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_edge_cases_Keyword_import_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "import", &fn); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "Keyword implementation has no import method"); + } else if (napi_call_function(env, target, fn, 0, NULL, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "Keyword.import threw"); + } else { + napi_status rs; + rs = napi_get_value_int32(env, result, &f->result); + if (rs != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "Keyword.import returned a value of the wrong type"); + } + } + napi_close_handle_scope(env, scope); +} + +static int32_t weaveffi_edge_cases_Keyword_import_tramp(void* ctx, weaveffi_error* out_err) { + weaveffi_edge_cases_Keyword_import_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_edge_cases_Keyword_import_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 1; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } + return f.result; +} + +static void weaveffi_edge_cases_Keyword_napi_dispatch(napi_env env, napi_value js_cb, void* context, void* data) { + (void)js_cb; + (void)context; + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)data; + if (req->method < 0) { + weaveffi_napi_cb_release(env, req->ctx); + free(req); + return; + } + if (env == NULL) { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "Node environment is shutting down"); + } else { + switch (req->method) { + case 0: weaveffi_edge_cases_Keyword_delete_invoke(env, req->ctx, (weaveffi_edge_cases_Keyword_delete_frame*)req->frame); break; + case 1: weaveffi_edge_cases_Keyword_import_invoke(env, req->ctx, (weaveffi_edge_cases_Keyword_import_frame*)req->frame); break; + default: break; + } + } + weaveffi_napi_cb_finish(req); +} + +static const weaveffi_edge_cases_Keyword_vtable weaveffi_edge_cases_Keyword_napi_vtable = { weaveffi_edge_cases_Keyword_delete_tramp, weaveffi_edge_cases_Keyword_import_tramp, weaveffi_napi_cb_free }; static napi_value Napi_weaveffi_edge_cases_Widget_refresh(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - weaveffi_edge_cases_Widget_refresh((const weaveffi_edge_cases_Widget*)(intptr_t)self_raw, &err); + weaveffi_edge_cases_Widget_refresh((const weaveffi_edge_cases_Widget*)self_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -61,17 +645,26 @@ static napi_value Napi_weaveffi_edge_cases_Widget_rename(napi_env env, napi_call size_t argc = 3; napi_value args[3]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - size_t class_len; + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + size_t class_len = 0; napi_get_value_string_utf8(env, args[1], NULL, 0, &class_len); char* class = (char*)malloc(class_len + 1); napi_get_value_string_utf8(env, args[1], class, class_len + 1, &class_len); - void* import_raw; - size_t import_len; + void* import_raw = NULL; + size_t import_len = 0; napi_get_buffer_info(env, args[2], &import_raw, &import_len); + if (arg_status != napi_ok) { + free(class); + return NULL; + } weaveffi_error err = {0}; - const char* result = weaveffi_edge_cases_Widget_rename((const weaveffi_edge_cases_Widget*)(intptr_t)self_raw, class, (const uint8_t*)import_raw, import_len, &err); + const char* result = weaveffi_edge_cases_Widget_rename((const weaveffi_edge_cases_Widget*)self_raw, class, (const uint8_t*)import_raw, import_len, &err); free(class); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -136,17 +729,27 @@ static napi_value Napi_weaveffi_edge_cases_Widget_fetch(napi_env env, napi_callb size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - int64_t id; - napi_get_value_int64(env, args[1], &id); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[1], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_edge_cases_Widget_fetch_napi_actx* ctx = (weaveffi_edge_cases_Widget_fetch_napi_actx*)calloc(1, sizeof(weaveffi_edge_cases_Widget_fetch_napi_actx)); napi_value promise; napi_create_promise(env, &ctx->deferred, &promise); napi_value resource_name; napi_create_string_utf8(env, "weaveffi_edge_cases_Widget_fetch", NAPI_AUTO_LENGTH, &resource_name); napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_edge_cases_Widget_fetch_napi_settle, &ctx->tsfn); - weaveffi_edge_cases_Widget_fetch_async((const weaveffi_edge_cases_Widget*)(intptr_t)self_raw, id, weaveffi_edge_cases_Widget_fetch_napi_cb, ctx); + weaveffi_edge_cases_Widget_fetch_async((const weaveffi_edge_cases_Widget*)self_raw, id, weaveffi_edge_cases_Widget_fetch_napi_cb, ctx); return promise; } @@ -154,10 +757,18 @@ static napi_value Napi_weaveffi_edge_cases_Widget_old_name(napi_env env, napi_ca size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - const char* result = weaveffi_edge_cases_Widget_old_name((const weaveffi_edge_cases_Widget*)(intptr_t)self_raw, &err); + const char* result = weaveffi_edge_cases_Widget_old_name((const weaveffi_edge_cases_Widget*)self_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -173,20 +784,37 @@ static napi_value Napi_weaveffi_edge_cases_Widget_destroy(napi_env env, napi_cal size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - weaveffi_edge_cases_Widget* self = (weaveffi_edge_cases_Widget*)(intptr_t)self_raw; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; + } + weaveffi_edge_cases_Widget* self = (weaveffi_edge_cases_Widget*)self_raw; weaveffi_edge_cases_Widget_destroy(self); napi_value ret; napi_get_undefined(env, &ret); return ret; } +static napi_value Napi_weaveffi_edge_cases_Widget_clone(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; + } + weaveffi_edge_cases_Widget* self = (weaveffi_edge_cases_Widget*)self_raw; + weaveffi_edge_cases_Widget* cloned = weaveffi_edge_cases_Widget_clone(self); + napi_value ret; + weaveffi_napi_make_handle(env, cloned, &ret); + return ret; +} + static napi_value Napi_weaveffi_edge_cases_Vault_open(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t path_len; + size_t path_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &path_len); char* path = (char*)malloc(path_len + 1); napi_get_value_string_utf8(env, args[0], path, path_len + 1, &path_len); @@ -199,7 +827,7 @@ static napi_value Napi_weaveffi_edge_cases_Vault_open(napi_env env, napi_callbac return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + weaveffi_napi_make_handle(env, result, &ret); return ret; } @@ -222,21 +850,25 @@ static napi_value Napi_weaveffi_edge_cases_Vault_peek(napi_env env, napi_callbac size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_Vault_peek((const weaveffi_edge_cases_Vault*)(intptr_t)self_raw, &err); + weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_Vault_peek((const weaveffi_edge_cases_Vault*)self_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - if (result == NULL) { - napi_get_null(env, &ret); - } else { - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); - } + weaveffi_napi_make_handle(env, result, &ret); return ret; } @@ -244,158 +876,29 @@ static napi_value Napi_weaveffi_edge_cases_Vault_destroy(napi_env env, napi_call size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - weaveffi_edge_cases_Vault* self = (weaveffi_edge_cases_Vault*)(intptr_t)self_raw; - weaveffi_edge_cases_Vault_destroy(self); - napi_value ret; - napi_get_undefined(env, &ret); - return ret; -} - -typedef struct { - uint8_t* items_ptr; - size_t items_len; - uint8_t* raw_ptr; - size_t raw_len; - uint8_t* first_ptr; - size_t first_len; - weaveffi_edge_cases_Mode mode; -} weaveffi_edge_cases_OnBatch_fn_payload; - -static void weaveffi_edge_cases_OnBatch_fn_napi_tramp(const uint8_t* items_ptr, size_t items_len, const uint8_t* raw_ptr, size_t raw_len, const uint8_t* first_ptr, size_t first_len, weaveffi_edge_cases_Mode mode, void* context) { - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context; - weaveffi_edge_cases_OnBatch_fn_payload* p = (weaveffi_edge_cases_OnBatch_fn_payload*)calloc(1, sizeof(weaveffi_edge_cases_OnBatch_fn_payload)); - p->items_len = items_len; - if (items_ptr != NULL && items_len > 0) { p->items_ptr = (uint8_t*)malloc(items_len); memcpy(p->items_ptr, items_ptr, items_len); } - p->raw_len = raw_len; - if (raw_ptr != NULL && raw_len > 0) { p->raw_ptr = (uint8_t*)malloc(raw_len); memcpy(p->raw_ptr, raw_ptr, raw_len); } - p->first_len = first_len; - if (first_ptr != NULL && first_len > 0) { p->first_ptr = (uint8_t*)malloc(first_len); memcpy(p->first_ptr, first_ptr, first_len); } - p->mode = mode; - napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking); -} - -static void weaveffi_edge_cases_OnBatch_fn_napi_calljs(napi_env env, napi_value js_cb, void* context, void* data) { - (void)context; - weaveffi_edge_cases_OnBatch_fn_payload* p = (weaveffi_edge_cases_OnBatch_fn_payload*)data; - if (env != NULL) { - napi_value undefined; - napi_get_undefined(env, &undefined); - napi_value argv[4]; - napi_create_buffer_copy(env, p->items_len, p->items_ptr ? (const void*)p->items_ptr : (const void*)"", NULL, &argv[0]); - napi_create_buffer_copy(env, p->raw_len, p->raw_ptr ? (const void*)p->raw_ptr : (const void*)"", NULL, &argv[1]); - napi_create_buffer_copy(env, p->first_len, p->first_ptr ? (const void*)p->first_ptr : (const void*)"", NULL, &argv[2]); - napi_create_int32(env, (int32_t)p->mode, &argv[3]); - napi_call_function(env, undefined, js_cb, 4, argv, NULL); - } - free(p->items_ptr); - free(p->raw_ptr); - free(p->first_ptr); - free(p); -} - -typedef struct { -} weaveffi_edge_cases_OnTick_fn_payload; - -static void weaveffi_edge_cases_OnTick_fn_napi_tramp(void* context) { - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context; - weaveffi_edge_cases_OnTick_fn_payload* p = (weaveffi_edge_cases_OnTick_fn_payload*)calloc(1, sizeof(weaveffi_edge_cases_OnTick_fn_payload)); - napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking); -} - -static void weaveffi_edge_cases_OnTick_fn_napi_calljs(napi_env env, napi_value js_cb, void* context, void* data) { - (void)context; - weaveffi_edge_cases_OnTick_fn_payload* p = (weaveffi_edge_cases_OnTick_fn_payload*)data; - if (env != NULL) { - napi_value undefined; - napi_get_undefined(env, &undefined); - napi_call_function(env, undefined, js_cb, 0, NULL, NULL); - } - free(p); -} - -static napi_value Napi_weaveffi_edge_cases_register_batch_listener(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)calloc(1, sizeof(weaveffi_napi_listener_ctx)); - napi_value resource_name; - napi_create_string_utf8(env, "weaveffi_edge_cases_register_batch_listener", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_edge_cases_OnBatch_fn_napi_calljs, &ctx->tsfn); - napi_unref_threadsafe_function(env, ctx->tsfn); - uint64_t id = weaveffi_edge_cases_register_batch_listener(weaveffi_edge_cases_OnBatch_fn_napi_tramp, ctx); - ctx->id = id; - ctx->next = weaveffi_napi_listeners; - weaveffi_napi_listeners = ctx; - napi_value ret; - napi_create_double(env, (double)id, &ret); - return ret; -} - -static napi_value Napi_weaveffi_edge_cases_unregister_batch_listener(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); - double id_d = 0; - napi_get_value_double(env, args[0], &id_d); - uint64_t id = (uint64_t)id_d; - weaveffi_edge_cases_unregister_batch_listener(id); - weaveffi_napi_listener_ctx** link = &weaveffi_napi_listeners; - while (*link != NULL) { - if ((*link)->id == id) { - weaveffi_napi_listener_ctx* found = *link; - *link = found->next; - napi_release_threadsafe_function(found->tsfn, napi_tsfn_release); - free(found); - break; - } - link = &(*link)->next; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; } + weaveffi_edge_cases_Vault* self = (weaveffi_edge_cases_Vault*)self_raw; + weaveffi_edge_cases_Vault_destroy(self); napi_value ret; napi_get_undefined(env, &ret); return ret; } -static napi_value Napi_weaveffi_edge_cases_register_tick_listener(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_edge_cases_Vault_clone(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)calloc(1, sizeof(weaveffi_napi_listener_ctx)); - napi_value resource_name; - napi_create_string_utf8(env, "weaveffi_edge_cases_register_tick_listener", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_edge_cases_OnTick_fn_napi_calljs, &ctx->tsfn); - napi_unref_threadsafe_function(env, ctx->tsfn); - uint64_t id = weaveffi_edge_cases_register_tick_listener(weaveffi_edge_cases_OnTick_fn_napi_tramp, ctx); - ctx->id = id; - ctx->next = weaveffi_napi_listeners; - weaveffi_napi_listeners = ctx; - napi_value ret; - napi_create_double(env, (double)id, &ret); - return ret; -} - -static napi_value Napi_weaveffi_edge_cases_unregister_tick_listener(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); - double id_d = 0; - napi_get_value_double(env, args[0], &id_d); - uint64_t id = (uint64_t)id_d; - weaveffi_edge_cases_unregister_tick_listener(id); - weaveffi_napi_listener_ctx** link = &weaveffi_napi_listeners; - while (*link != NULL) { - if ((*link)->id == id) { - weaveffi_napi_listener_ctx* found = *link; - *link = found->next; - napi_release_threadsafe_function(found->tsfn, napi_tsfn_release); - free(found); - break; - } - link = &(*link)->next; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; } + weaveffi_edge_cases_Vault* self = (weaveffi_edge_cases_Vault*)self_raw; + weaveffi_edge_cases_Vault* cloned = weaveffi_edge_cases_Vault_clone(self); napi_value ret; - napi_get_undefined(env, &ret); + weaveffi_napi_make_handle(env, cloned, &ret); return ret; } @@ -418,20 +921,29 @@ static napi_value Napi_weaveffi_edge_cases_select(napi_env env, napi_callback_in size_t argc = 5; napi_value args[5]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t class; - napi_get_value_int32(env, args[0], &class); - size_t from_len; + napi_status arg_status = napi_ok; + int32_t class = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[0], &class); + } + size_t from_len = 0; napi_get_value_string_utf8(env, args[1], NULL, 0, &from_len); char* from = (char*)malloc(from_len + 1); napi_get_value_string_utf8(env, args[1], from, from_len + 1, &from_len); - void* func_raw; - size_t func_len; + void* func_raw = NULL; + size_t func_len = 0; napi_get_buffer_info(env, args[2], &func_raw, &func_len); - void* range_raw; - size_t range_len; + void* range_raw = NULL; + size_t range_len = 0; napi_get_buffer_info(env, args[3], &range_raw, &range_len); - bool type_; - napi_get_value_bool(env, args[4], &type_); + bool type_ = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_bool(env, args[4], &type_); + } + if (arg_status != napi_ok) { + free(from); + return NULL; + } weaveffi_error err = {0}; int32_t result = weaveffi_edge_cases_select(class, from, (const uint8_t*)func_raw, func_len, (const uint8_t*)range_raw, range_len, type_, &err); free(from); @@ -449,11 +961,11 @@ static napi_value Napi_weaveffi_edge_cases_nested_optional(napi_env env, napi_ca size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* items_raw; - size_t items_len; + void* items_raw = NULL; + size_t items_len = 0; napi_get_buffer_info(env, args[0], &items_raw, &items_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_edge_cases_nested_optional((const uint8_t*)items_raw, items_len, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -470,26 +982,26 @@ static napi_value Napi_weaveffi_edge_cases_optional_params(napi_env env, napi_ca size_t argc = 6; napi_value args[6]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* text_raw; - size_t text_len; + void* text_raw = NULL; + size_t text_len = 0; napi_get_buffer_info(env, args[0], &text_raw, &text_len); - void* count_raw; - size_t count_len; + void* count_raw = NULL; + size_t count_len = 0; napi_get_buffer_info(env, args[1], &count_raw, &count_len); - void* outer_raw; - size_t outer_len; + void* outer_raw = NULL; + size_t outer_len = 0; napi_get_buffer_info(env, args[2], &outer_raw, &outer_len); - void* tags_raw; - size_t tags_len; + void* tags_raw = NULL; + size_t tags_len = 0; napi_get_buffer_info(env, args[3], &tags_raw, &tags_len); - void* blob_raw; - size_t blob_len; + void* blob_raw = NULL; + size_t blob_len = 0; napi_get_buffer_info(env, args[4], &blob_raw, &blob_len); - void* mode_raw; - size_t mode_len; + void* mode_raw = NULL; + size_t mode_len = 0; napi_get_buffer_info(env, args[5], &mode_raw, &mode_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_edge_cases_optional_params((const uint8_t*)text_raw, text_len, (const uint8_t*)count_raw, count_len, (const uint8_t*)outer_raw, outer_len, (const uint8_t*)tags_raw, tags_len, (const uint8_t*)blob_raw, blob_len, (const uint8_t*)mode_raw, mode_len, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -502,20 +1014,36 @@ static napi_value Napi_weaveffi_edge_cases_optional_params(napi_env env, napi_ca return ret; } -static napi_value Napi_weaveffi_edge_cases_mutable_params(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_edge_cases_register_batch(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t text_len; - napi_get_value_string_utf8(env, args[0], NULL, 0, &text_len); - char* text = (char*)malloc(text_len + 1); - napi_get_value_string_utf8(env, args[0], text, text_len + 1, &text_len); - void* blob_raw; - size_t blob_len; - napi_get_buffer_info(env, args[1], &blob_raw, &blob_len); + napi_status arg_status = napi_ok; + napi_valuetype listener_type; + napi_typeof(env, args[0], &listener_type); + weaveffi_napi_cb_ctx* listener_ctx = NULL; + if (listener_type == napi_object || listener_type == napi_function) { + listener_ctx = weaveffi_napi_cb_register(env, args[0], "weaveffi_edge_cases_BatchListener", weaveffi_edge_cases_BatchListener_napi_dispatch); + } else { + napi_throw_type_error(env, NULL, "expected a BatchListener implementation"); + arg_status = napi_object_expected; + } + napi_valuetype keyword_type; + napi_typeof(env, args[1], &keyword_type); + weaveffi_napi_cb_ctx* keyword_ctx = NULL; + if (keyword_type == napi_object || keyword_type == napi_function) { + keyword_ctx = weaveffi_napi_cb_register(env, args[1], "weaveffi_edge_cases_Keyword", weaveffi_edge_cases_Keyword_napi_dispatch); + } else { + napi_throw_type_error(env, NULL, "expected a Keyword implementation"); + arg_status = napi_object_expected; + } + if (arg_status != napi_ok) { + if (listener_ctx != NULL) weaveffi_napi_cb_release(env, listener_ctx); + if (keyword_ctx != NULL) weaveffi_napi_cb_release(env, keyword_ctx); + return NULL; + } weaveffi_error err = {0}; - weaveffi_edge_cases_mutable_params(text, (const uint8_t*)blob_raw, blob_len, &err); - free(text); + weaveffi_edge_cases_register_batch((void*)listener_ctx, &weaveffi_edge_cases_BatchListener_napi_vtable, (void*)keyword_ctx, &weaveffi_edge_cases_Keyword_napi_vtable, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -526,91 +1054,233 @@ static napi_value Napi_weaveffi_edge_cases_mutable_params(napi_env env, napi_cal return ret; } -static napi_value Napi_weaveffi_edge_cases_make_widget(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_edge_cases_widgets_everywhere(napi_env env, napi_callback_info info) { + size_t argc = 4; + napi_value args[4]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + void* list_raw = NULL; + size_t list_len = 0; + napi_get_buffer_info(env, args[0], &list_raw, &list_len); + void* by_name_raw = NULL; + size_t by_name_len = 0; + napi_get_buffer_info(env, args[1], &by_name_raw, &by_name_len); + void* sparse_raw = NULL; + size_t sparse_len = 0; + napi_get_buffer_info(env, args[2], &sparse_raw, &sparse_len); + void* holder_raw = NULL; + size_t holder_len = 0; + napi_get_buffer_info(env, args[3], &holder_raw, &holder_len); + weaveffi_error err = {0}; + size_t out_len = 0; + const uint8_t* result = weaveffi_edge_cases_widgets_everywhere((const uint8_t*)list_raw, list_len, (const uint8_t*)by_name_raw, by_name_len, (const uint8_t*)sparse_raw, sparse_len, (const uint8_t*)holder_raw, holder_len, &out_len, &err); + if (err.code != 0) { + napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); + weaveffi_error_clear(&err); + return NULL; + } + napi_value ret; + napi_create_buffer_copy(env, out_len, result, NULL, &ret); + weaveffi_free_bytes((uint8_t*)result, out_len); + return ret; +} + +static void weaveffi_edge_cases_widget_stream_napi_iter_finalize(napi_env env, void* data, void* hint) { + (void)env; + (void)hint; + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)data; + if (state->iter != NULL) { + weaveffi_edge_cases_WidgetStreamIterator_destroy((weaveffi_edge_cases_WidgetStreamIterator*)state->iter); + state->iter = NULL; + } + free(state); +} + +static napi_value Napi_weaveffi_edge_cases_WidgetStreamIterator_next(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t label_len; - napi_get_value_string_utf8(env, args[0], NULL, 0, &label_len); - char* label = (char*)malloc(label_len + 1); - napi_get_value_string_utf8(env, args[0], label, label_len + 1, &label_len); + void* iter_data = NULL; + napi_get_value_external(env, args[0], &iter_data); + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)iter_data; + napi_value ret; + if (state == NULL || state->iter == NULL) { + napi_get_undefined(env, &ret); + return ret; + } + weaveffi_edge_cases_Widget* iter_item; + weaveffi_error iter_err = {0}; + if (!weaveffi_edge_cases_WidgetStreamIterator_next((weaveffi_edge_cases_WidgetStreamIterator*)state->iter, &iter_item, &iter_err)) { + weaveffi_edge_cases_WidgetStreamIterator_destroy((weaveffi_edge_cases_WidgetStreamIterator*)state->iter); + state->iter = NULL; + if (iter_err.code != 0) { + napi_throw(env, weaveffi_napi_error_value(env, iter_err.code, iter_err.message, iter_err.payload_ptr, iter_err.payload_len)); + weaveffi_error_clear(&iter_err); + return NULL; + } + napi_get_undefined(env, &ret); + return ret; + } + weaveffi_napi_make_handle(env, iter_item, &ret); + return ret; +} + +static napi_value Napi_weaveffi_edge_cases_WidgetStreamIterator_destroy(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + void* iter_data = NULL; + napi_get_value_external(env, args[0], &iter_data); + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)iter_data; + if (state != NULL && state->iter != NULL) { + weaveffi_edge_cases_WidgetStreamIterator_destroy((weaveffi_edge_cases_WidgetStreamIterator*)state->iter); + state->iter = NULL; + } + napi_value ret; + napi_get_undefined(env, &ret); + return ret; +} + +static napi_value Napi_weaveffi_edge_cases_widget_stream(napi_env env, napi_callback_info info) { + size_t argc = 0; + napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_make_widget(label, &err); - free(label); + void* result = weaveffi_edge_cases_widget_stream(&err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + weaveffi_napi_iter_state* iter_state = (weaveffi_napi_iter_state*)calloc(1, sizeof(weaveffi_napi_iter_state)); + iter_state->iter = (void*)result; + napi_create_external(env, iter_state, weaveffi_edge_cases_widget_stream_napi_iter_finalize, NULL, &ret); return ret; } -static napi_value Napi_weaveffi_edge_cases_find_widget(napi_env env, napi_callback_info info) { +typedef struct { + napi_deferred deferred; + napi_threadsafe_function tsfn; + int32_t err_code; + char* err_msg; + uint8_t* err_payload; + size_t err_payload_len; + void* result; +} weaveffi_edge_cases_async_widget_napi_actx; + +static void weaveffi_edge_cases_async_widget_napi_cb(void* context, weaveffi_error* err, weaveffi_edge_cases_Widget* result) { + weaveffi_edge_cases_async_widget_napi_actx* ctx = (weaveffi_edge_cases_async_widget_napi_actx*)context; + if (err != NULL && err->code != 0) { + ctx->err_code = err->code; + ctx->err_msg = err->message ? strdup(err->message) : strdup("unknown error"); + ctx->err_payload_len = err->payload_len; + if (err->payload_ptr != NULL && err->payload_len > 0) { ctx->err_payload = (uint8_t*)malloc(err->payload_len); memcpy(ctx->err_payload, err->payload_ptr, err->payload_len); } + } else { + ctx->result = (void*)result; + } + weaveffi_error_free(err); + napi_call_threadsafe_function(ctx->tsfn, ctx, napi_tsfn_blocking); +} + +static void weaveffi_edge_cases_async_widget_napi_settle(napi_env env, napi_value js_cb, void* context, void* data) { + (void)js_cb; + (void)context; + weaveffi_edge_cases_async_widget_napi_actx* ctx = (weaveffi_edge_cases_async_widget_napi_actx*)data; + if (env != NULL) { + if (ctx->err_code != 0) { + napi_value err_obj = weaveffi_napi_error_value(env, ctx->err_code, ctx->err_msg, ctx->err_payload, ctx->err_payload_len); + napi_reject_deferred(env, ctx->deferred, err_obj); + } else { + napi_value val; + weaveffi_napi_make_handle(env, ctx->result, &val); + napi_resolve_deferred(env, ctx->deferred, val); + } + } + free(ctx->err_msg); + free(ctx->err_payload); + napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); + free(ctx); +} + +static napi_value Napi_weaveffi_edge_cases_async_widget(napi_env env, napi_callback_info info) { + size_t argc = 0; + napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); + weaveffi_edge_cases_async_widget_napi_actx* ctx = (weaveffi_edge_cases_async_widget_napi_actx*)calloc(1, sizeof(weaveffi_edge_cases_async_widget_napi_actx)); + napi_value promise; + napi_create_promise(env, &ctx->deferred, &promise); + napi_value resource_name; + napi_create_string_utf8(env, "weaveffi_edge_cases_async_widget", NAPI_AUTO_LENGTH, &resource_name); + napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_edge_cases_async_widget_napi_settle, &ctx->tsfn); + weaveffi_edge_cases_async_widget_async(weaveffi_edge_cases_async_widget_napi_cb, ctx); + return promise; +} + +static napi_value Napi_weaveffi_edge_cases_make_widget(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t id; - napi_get_value_int64(env, args[0], &id); + size_t label_len = 0; + napi_get_value_string_utf8(env, args[0], NULL, 0, &label_len); + char* label = (char*)malloc(label_len + 1); + napi_get_value_string_utf8(env, args[0], label, label_len + 1, &label_len); weaveffi_error err = {0}; - weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_find_widget(id, &err); + weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_make_widget(label, &err); + free(label); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - if (result == NULL) { - napi_get_null(env, &ret); - } else { - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); - } + weaveffi_napi_make_handle(env, result, &ret); return ret; } -static napi_value Napi_weaveffi_edge_cases_inspect_widget(napi_env env, napi_callback_info info) { - size_t argc = 2; - napi_value args[2]; +static napi_value Napi_weaveffi_edge_cases_find_widget(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t widget_raw; - napi_get_value_int64(env, args[0], &widget_raw); - napi_valuetype fallback_type; - napi_typeof(env, args[1], &fallback_type); - int64_t fallback_raw = 0; - if (fallback_type != napi_null && fallback_type != napi_undefined) { - napi_get_value_int64(env, args[1], &fallback_raw); + napi_status arg_status = napi_ok; + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &id); + } + if (arg_status != napi_ok) { + return NULL; } weaveffi_error err = {0}; - const char* result = weaveffi_edge_cases_inspect_widget((const weaveffi_edge_cases_Widget*)(intptr_t)widget_raw, fallback_raw ? (const weaveffi_edge_cases_Widget*)(intptr_t)fallback_raw : NULL, &err); + weaveffi_edge_cases_Widget* result = weaveffi_edge_cases_find_widget(id, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, &ret); - weaveffi_free_string(result); + weaveffi_napi_make_handle(env, result, &ret); return ret; } -static napi_value Napi_weaveffi_edge_cases_typed_handle_param(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_edge_cases_inspect_widget(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t token_raw; - napi_get_value_int64(env, args[0], &token_raw); - int64_t raw_raw; - napi_get_value_int64(env, args[1], &raw_raw); + napi_status arg_status = napi_ok; + void* widget_raw = NULL; + if (arg_status == napi_ok) arg_status = weaveffi_napi_get_handle(env, args[0], &widget_raw); + void* fallback_raw = NULL; + if (arg_status == napi_ok) arg_status = weaveffi_napi_get_handle(env, args[1], &fallback_raw); + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - weaveffi_edge_cases_Leaf* result = weaveffi_edge_cases_typed_handle_param((weaveffi_edge_cases_Leaf*)(intptr_t)token_raw, (weaveffi_handle_t)raw_raw, &err); + const char* result = weaveffi_edge_cases_inspect_widget((const weaveffi_edge_cases_Widget*)widget_raw, (const weaveffi_edge_cases_Widget*)fallback_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, &ret); + weaveffi_free_string(result); return ret; } @@ -941,8 +1611,14 @@ static napi_value Napi_weaveffi_edge_cases_async_bytes(napi_env env, napi_callba size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - uint32_t size; - napi_get_value_uint32(env, args[0], &size); + napi_status arg_status = napi_ok; + uint32_t size = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_uint32(env, args[0], &size); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_edge_cases_async_bytes_napi_actx* ctx = (weaveffi_edge_cases_async_bytes_napi_actx*)calloc(1, sizeof(weaveffi_edge_cases_async_bytes_napi_actx)); napi_value promise; napi_create_promise(env, &ctx->deferred, &promise); @@ -1170,7 +1846,7 @@ static void weaveffi_edge_cases_async_enum_napi_settle(napi_env env, napi_value napi_reject_deferred(env, ctx->deferred, err_obj); } else { napi_value val; - napi_create_int32(env, (int32_t)ctx->result, &val); + napi_create_int32(env, (int32_t)(ctx->result), &val); napi_resolve_deferred(env, ctx->deferred, val); } } @@ -1245,12 +1921,19 @@ static napi_value Napi_weaveffi_edge_cases_async_keywords(napi_env env, napi_cal size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t class_len; + napi_status arg_status = napi_ok; + size_t class_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &class_len); char* class = (char*)malloc(class_len + 1); napi_get_value_string_utf8(env, args[0], class, class_len + 1, &class_len); - int32_t import; - napi_get_value_int32(env, args[1], &import); + int32_t import = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[1], &import); + } + if (arg_status != napi_ok) { + free(class); + return NULL; + } weaveffi_edge_cases_async_keywords_napi_actx* ctx = (weaveffi_edge_cases_async_keywords_napi_actx*)calloc(1, sizeof(weaveffi_edge_cases_async_keywords_napi_actx)); napi_value promise; napi_create_promise(env, &ctx->deferred, &promise); @@ -1266,11 +1949,11 @@ static napi_value Napi_weaveffi_edge_cases_keywords_roundtrip(napi_env env, napi size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* value_raw; - size_t value_len; + void* value_raw = NULL; + size_t value_len = 0; napi_get_buffer_info(env, args[0], &value_raw, &value_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_edge_cases_keywords_roundtrip((const uint8_t*)value_raw, value_len, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -1287,10 +1970,18 @@ static napi_value Napi_weaveffi_edge_cases_legacy_value(napi_env env, napi_callb size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t value; - napi_get_value_int32(env, args[0], &value); + napi_status arg_status = napi_ok; + int32_t value = 0; + if (arg_status == napi_ok) { + int32_t value_tmp = 0; + arg_status = napi_get_value_int32(env, args[0], &value_tmp); + value = value_tmp; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_edge_cases_legacy_value((weaveffi_edge_cases_Legacy)value, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -1313,29 +2004,32 @@ static napi_value Init(napi_env env, napi_value exports) { return NULL; } } + weaveffi_napi_js_thread = uv_thread_self(); napi_property_descriptor props[] = { { "Widget_refresh", NULL, Napi_weaveffi_edge_cases_Widget_refresh, NULL, NULL, NULL, napi_default, NULL }, { "Widget_rename", NULL, Napi_weaveffi_edge_cases_Widget_rename, NULL, NULL, NULL, napi_default, NULL }, { "Widget_fetch", NULL, Napi_weaveffi_edge_cases_Widget_fetch, NULL, NULL, NULL, napi_default, NULL }, { "Widget_old_name", NULL, Napi_weaveffi_edge_cases_Widget_old_name, NULL, NULL, NULL, napi_default, NULL }, - { "Widget_destroy", NULL, Napi_weaveffi_edge_cases_Widget_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "Widget__destroy", NULL, Napi_weaveffi_edge_cases_Widget_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "Widget__clone", NULL, Napi_weaveffi_edge_cases_Widget_clone, NULL, NULL, NULL, napi_default, NULL }, { "Vault_open", NULL, Napi_weaveffi_edge_cases_Vault_open, NULL, NULL, NULL, napi_default, NULL }, { "Vault_probe", NULL, Napi_weaveffi_edge_cases_Vault_probe, NULL, NULL, NULL, napi_default, NULL }, { "Vault_peek", NULL, Napi_weaveffi_edge_cases_Vault_peek, NULL, NULL, NULL, napi_default, NULL }, - { "Vault_destroy", NULL, Napi_weaveffi_edge_cases_Vault_destroy, NULL, NULL, NULL, napi_default, NULL }, - { "registerBatchListener", NULL, Napi_weaveffi_edge_cases_register_batch_listener, NULL, NULL, NULL, napi_default, NULL }, - { "unregisterBatchListener", NULL, Napi_weaveffi_edge_cases_unregister_batch_listener, NULL, NULL, NULL, napi_default, NULL }, - { "registerTickListener", NULL, Napi_weaveffi_edge_cases_register_tick_listener, NULL, NULL, NULL, napi_default, NULL }, - { "unregisterTickListener", NULL, Napi_weaveffi_edge_cases_unregister_tick_listener, NULL, NULL, NULL, napi_default, NULL }, + { "Vault__destroy", NULL, Napi_weaveffi_edge_cases_Vault_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "Vault__clone", NULL, Napi_weaveffi_edge_cases_Vault_clone, NULL, NULL, NULL, napi_default, NULL }, { "noop", NULL, Napi_weaveffi_edge_cases_noop, NULL, NULL, NULL, napi_default, NULL }, { "select", NULL, Napi_weaveffi_edge_cases_select, NULL, NULL, NULL, napi_default, NULL }, { "nestedOptional", NULL, Napi_weaveffi_edge_cases_nested_optional, NULL, NULL, NULL, napi_default, NULL }, { "optionalParams", NULL, Napi_weaveffi_edge_cases_optional_params, NULL, NULL, NULL, napi_default, NULL }, - { "mutableParams", NULL, Napi_weaveffi_edge_cases_mutable_params, NULL, NULL, NULL, napi_default, NULL }, + { "registerBatch", NULL, Napi_weaveffi_edge_cases_register_batch, NULL, NULL, NULL, napi_default, NULL }, + { "widgetsEverywhere", NULL, Napi_weaveffi_edge_cases_widgets_everywhere, NULL, NULL, NULL, napi_default, NULL }, + { "widgetStream_iterNext", NULL, Napi_weaveffi_edge_cases_WidgetStreamIterator_next, NULL, NULL, NULL, napi_default, NULL }, + { "widgetStream_iterDestroy", NULL, Napi_weaveffi_edge_cases_WidgetStreamIterator_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "widgetStream", NULL, Napi_weaveffi_edge_cases_widget_stream, NULL, NULL, NULL, napi_default, NULL }, + { "asyncWidget", NULL, Napi_weaveffi_edge_cases_async_widget, NULL, NULL, NULL, napi_default, NULL }, { "makeWidget", NULL, Napi_weaveffi_edge_cases_make_widget, NULL, NULL, NULL, napi_default, NULL }, { "findWidget", NULL, Napi_weaveffi_edge_cases_find_widget, NULL, NULL, NULL, napi_default, NULL }, { "inspectWidget", NULL, Napi_weaveffi_edge_cases_inspect_widget, NULL, NULL, NULL, napi_default, NULL }, - { "typedHandleParam", NULL, Napi_weaveffi_edge_cases_typed_handle_param, NULL, NULL, NULL, napi_default, NULL }, { "stringStream_iterNext", NULL, Napi_weaveffi_edge_cases_StringStreamIterator_next, NULL, NULL, NULL, napi_default, NULL }, { "stringStream_iterDestroy", NULL, Napi_weaveffi_edge_cases_StringStreamIterator_destroy, NULL, NULL, NULL, napi_default, NULL }, { "stringStream", NULL, Napi_weaveffi_edge_cases_string_stream, NULL, NULL, NULL, napi_default, NULL }, @@ -1355,7 +2049,7 @@ static napi_value Init(napi_env env, napi_value exports) { { "keywordsRoundtrip", NULL, Napi_weaveffi_edge_cases_keywords_roundtrip, NULL, NULL, NULL, napi_default, NULL }, { "legacyValue", NULL, Napi_weaveffi_edge_cases_legacy_value, NULL, NULL, NULL, napi_default, NULL }, }; - napi_define_properties(env, exports, 40, props); + napi_define_properties(env, exports, 42, props); return exports; } diff --git a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__index_js.snap b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__index_js.snap index ccb85e8e..95c217c6 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__index_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__index_js.snap @@ -56,6 +56,18 @@ function __invokeAsync(fn, args, map) { }); } +// Borrow a live object wrapper's native handle for one call. The wrapper +// keeps its own reference; the producer clones if it retains the object. +function __borrow(o, cls) { + if (!(o instanceof cls)) { + throw new WeaveFFIError(-3, 'expected an instance of ' + cls.name); + } + if (!o._handle) { + throw new WeaveFFIError(-3, cls.name + ' used after close()'); + } + return o._handle; +} + // --- Private value-buffer runtime (WeaveFFI wire format) -------------------- // Little-endian, packed, no alignment. Decoders reject truncated buffers, // invalid bool/flag bytes, hostile length prefixes, and trailing bytes. @@ -183,7 +195,7 @@ function __packToken(w, v) { } function __unpackToken(r) { return { - id: Number(r.i64()), + id: r.i64(), label: r.str(), }; } @@ -201,10 +213,11 @@ function __packItem(w, v) { __wOpt(w, v.parent, (w, v) => w.i64(v)); __packToken(w, v.token); w.i32(v.priority); + __wOpt(w, v.gadget, (w, v) => w.u64(v._cloneHandle())); } function __unpackItem(r) { return { - id: Number(r.i64()), + id: r.i64(), name: r.str(), count: r.i32(), enabled: r.bool(), @@ -213,9 +226,10 @@ function __unpackItem(r) { payload: r.bytes(), tags: __rList(r, (r) => r.str()), attrs: __rMap(r, (r) => r.str(), (r) => r.str()), - parent: __rOpt(r, (r) => Number(r.i64())), + parent: __rOpt(r, (r) => r.i64()), token: __unpackToken(r), priority: r.i32(), + gadget: __rOpt(r, (r) => Gadget._adopt(r.u64())), }; } @@ -284,37 +298,65 @@ function __kitchenErrorFrom(code, message, payload) { return _err; } +wv.Priority = Object.freeze({ + Low: 0, + Normal: 1, + High: 2, + 0: 'Low', + 1: 'Normal', + 2: 'High', +}); +function __adaptReadyListener(impl) { + if (impl === null || impl === undefined) { + throw new WeaveFFIError(-3, 'ReadyListener implementation must be an object'); + } + return { + on_ready(code, msg) { + return impl.onReady(code, msg); + }, + on_item(item, gadget) { + return impl.onItem(__decode(__unpackItem, item), Gadget._adopt(gadget)); + }, + }; +} + class Gadget { constructor(id) { this._handle = __invoke(addon.Gadget_new, [id], __generic); Gadget._cleanup.register(this, this._handle, this); } describe() { - return __invoke(addon.Gadget_describe, [this._handle], __generic); + return __invoke(addon.Gadget_describe, [__borrow(this, Gadget)], __generic); } poke(times) { - return __invoke(addon.Gadget_poke, [this._handle, times], __kitchenErrorFrom); + return __invoke(addon.Gadget_poke, [__borrow(this, Gadget), times], __kitchenErrorFrom); } static version() { return __invoke(addon.Gadget_version, [], __generic); } - destroy() { + close() { if (this._handle) { Gadget._cleanup.unregister(this); - addon.Gadget_destroy(this._handle); - this._handle = 0; + addon.Gadget__destroy(this._handle); + this._handle = 0n; } } + _cloneHandle() { + return addon.Gadget__clone(__borrow(this, Gadget)); + } } -Gadget._fromHandle = function (handle) { +Gadget._adopt = function (handle) { const _o = Object.create(Gadget.prototype); _o._handle = handle; Gadget._cleanup.register(_o, handle, _o); return _o; }; Gadget._cleanup = new FinalizationRegistry((handle) => { - if (handle) { addon.Gadget_destroy(handle); } + if (handle) { addon.Gadget__destroy(handle); } }); +if (typeof Symbol.dispose === 'symbol') { + Gadget.prototype[Symbol.dispose] = Gadget.prototype.close; +} wv.Gadget = Gadget; wv.ping = function () { @@ -341,17 +383,20 @@ wv.echoString = function (s) { wv.echoBytes = function (b) { return __invoke(addon.echoBytes, [b], __generic); }; -wv.echoBorrowedStr = function (s) { - return __invoke(addon.echoBorrowedStr, [s], __generic); +wv.subscribe = function (listener) { + return __invoke(addon.subscribe, [__adaptReadyListener(listener)], __generic); }; -wv.echoBorrowedBytes = function (b) { - return __invoke(addon.echoBorrowedBytes, [b], __generic); +wv.maybeGadget = function (fallback) { + const _r = __invoke(addon.maybeGadget, [(fallback == null ? null : __borrow(fallback, Gadget))], __generic); + return (_r == null ? null : Gadget._adopt(_r)); }; -wv.openHandle = function () { - return __invoke(addon.openHandle, [], __generic); +wv.gadgets = function () { + const _r = __invoke(addon.gadgets, [], __generic); + return __decode((r) => __rList(r, (r) => Gadget._adopt(r.u64())), _r); }; -wv.openTypedHandle = function () { - return __invoke(addon.openTypedHandle, [], __generic); +wv.streamGadgets = function () { + const _it = __invoke(addon.streamGadgets, [], __generic); + return new WeaveFFIIterator(_it, addon.streamGadgets_iterNext, addon.streamGadgets_iterDestroy, __generic, (_e) => Gadget._adopt(_e)); }; wv.maybeItem = function (id) { const _r = __invoke(addon.maybeItem, [id], __kitchenErrorFrom); diff --git a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__types_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__types_d_ts.snap index f0561a8f..76640c64 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__types_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__types_d_ts.snap @@ -8,8 +8,10 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated types for WeaveFFI functions /** * Base class of every error thrown by these bindings. Non-throwing - * functions reject or throw it directly for panics and marshalling - * failures; throwing functions surface a module domain subclass. + * functions reject or throw it directly for runtime traps (code -2 a + * producer panic, -3 a marshalling failure, -4 a callback interface + * implementation that threw); throwing functions surface a module + * domain subclass for their declared codes. */ export class WeaveFFIError extends Error { /** The numeric ABI error code. */ @@ -20,7 +22,7 @@ export class WeaveFFIError extends Error { } /** A reusable token referenced across modules */ export interface Token { - id: number; + id: bigint; label: string; } // module shared @@ -44,7 +46,7 @@ export class InvalidInputError extends KitchenErrorsError { /** Kitchen sink struct exercising every field feature */ export interface Item { /** Stable identifier */ - id: number; + id: bigint; /** Display name */ name: string; /** Initial count */ @@ -57,10 +59,12 @@ export interface Item { payload: Buffer; tags: string[]; attrs: Record; - parent: number | null; + parent: bigint | null; /** Cross-module struct reference */ token: Token; priority: Priority; + /** An optional object inside a record */ + gadget: Gadget | null; } /** Task priority levels */ export enum Priority { @@ -72,13 +76,24 @@ export enum Priority { High = 2, } // module kitchen +/** Consumer-implemented observer of ready items */ +export interface ReadyListener { + /** + * Fires when an item is ready + */ + onReady(code: number, msg: string): void; + /** + * Receives the item itself and says whether to keep listening + */ + onItem(item: Item, gadget: Gadget): boolean; +} /** A pocket-sized interface exercising the object surface */ export class Gadget { /** * Create a gadget with the given id * Maps to C function: weaveffi_kitchen_Gadget_new */ - constructor(id: number); + constructor(id: bigint); /** * Render the gadget as a human-readable string * Maps to C function: weaveffi_kitchen_Gadget_describe @@ -95,12 +110,15 @@ export class Gadget { * Maps to C function: weaveffi_kitchen_Gadget_version */ static version(): string; - /** Free the underlying native object. */ - destroy(): void; + /** + * Release this wrapper's reference to the native object. Safe to call + * more than once; a wrapper that is never closed is released when it is + * garbage collected. Using the wrapper after `close()` throws. + */ + close(): void; + /** Alias of `close()` for `using` declarations. */ + [Symbol.dispose](): void; } -/** Subscribe to OnReady events */ -export function registerReadyListener(callback: (code: number, msg: string) => void): number -export function unregisterReadyListener(id: number): void /** * Identity for bool * Maps to C function: weaveffi_kitchen_bool_id @@ -117,7 +135,7 @@ export function u32Id(x: number): number /** * Maps to C function: weaveffi_kitchen_i64_id */ -export function i64Id(x: number): number +export function i64Id(x: bigint): bigint /** * Maps to C function: weaveffi_kitchen_f64_id */ @@ -131,31 +149,31 @@ export function echoString(s: string): string */ export function echoBytes(b: Buffer): Buffer /** - * Borrowed string parameter - * Maps to C function: weaveffi_kitchen_echo_borrowed_str + * Register a listener, returning how many are attached + * Maps to C function: weaveffi_kitchen_subscribe */ -export function echoBorrowedStr(s: string): string +export function subscribe(listener: ReadyListener): number /** - * Borrowed bytes parameter - * Maps to C function: weaveffi_kitchen_echo_borrowed_bytes + * Optional object in both directions + * Maps to C function: weaveffi_kitchen_maybe_gadget */ -export function echoBorrowedBytes(b: Buffer): Buffer +export function maybeGadget(fallback: Gadget | null): Gadget | null /** - * Returns an opaque handle - * Maps to C function: weaveffi_kitchen_open_handle + * A list of objects + * Maps to C function: weaveffi_kitchen_gadgets */ -export function openHandle(): bigint +export function gadgets(): Gadget[] /** - * Returns a typed handle - * Maps to C function: weaveffi_kitchen_open_typed_handle + * An iterator over objects + * Maps to C function: weaveffi_kitchen_stream_gadgets */ -export function openTypedHandle(): Token +export function streamGadgets(): IterableIterator /** * Optional struct return * Maps to C function: weaveffi_kitchen_maybe_item * @throws {KitchenErrorsError} */ -export function maybeItem(id: number): Item | null +export function maybeItem(id: bigint): Item | null /** * List of structs * Maps to C function: weaveffi_kitchen_list_items diff --git a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__weaveffi_addon_c.snap b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__weaveffi_addon_c.snap index cae5e49f..0be6a8e5 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__weaveffi_addon_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_kitchen_sink__weaveffi_addon_c.snap @@ -5,11 +5,89 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o +#ifndef NAPI_VERSION +#define NAPI_VERSION 8 +#endif #include #include "weaveffi.h" +#include +#include #include #include #include +#include + +static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; + } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const char* message, const uint8_t* payload_ptr, size_t payload_len) { napi_value msg; @@ -31,13 +109,131 @@ typedef struct { void* iter; } weaveffi_napi_iter_state; -typedef struct weaveffi_napi_listener_ctx { - napi_threadsafe_function tsfn; - uint64_t id; - struct weaveffi_napi_listener_ctx* next; -} weaveffi_napi_listener_ctx; +static uv_thread_t weaveffi_napi_js_thread; + +static inline bool weaveffi_napi_on_js_thread(void) { + uv_thread_t self = uv_thread_self(); + return uv_thread_equal(&self, &weaveffi_napi_js_thread) != 0; +} + +// One handle-table entry per registered callback-interface implementation; +// its address is the `ctx` the producer passes back to every method. +typedef struct { + napi_env env; + napi_ref ref; + napi_threadsafe_function tsfn; +} weaveffi_napi_cb_ctx; + +// Every method frame starts with this header so the dispatcher can reach +// `out_err` without knowing the method. +typedef struct { + weaveffi_error* out_err; +} weaveffi_napi_cb_frame_hdr; + +// A request hopped from a producer thread to the JS thread. `method` is the +// vtable index to run, or -1 to release the context. +typedef struct { + weaveffi_napi_cb_ctx* ctx; + int method; + void* frame; + uv_mutex_t mu; + uv_cond_t cv; + bool done; +} weaveffi_napi_cb_req; + +static weaveffi_napi_cb_ctx* weaveffi_napi_cb_register(napi_env env, napi_value target, const char* name, napi_threadsafe_function_call_js dispatch) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)calloc(1, sizeof(weaveffi_napi_cb_ctx)); + ctx->env = env; + napi_create_reference(env, target, 1, &ctx->ref); + napi_value resource_name; + napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name); + napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, dispatch, &ctx->tsfn); + // A live implementation must not pin the event loop by itself. + napi_unref_threadsafe_function(env, ctx->tsfn); + return ctx; +} + +// Release one entry on the JS thread (env is NULL only during teardown, when +// the reference is already gone). +static void weaveffi_napi_cb_release(napi_env env, weaveffi_napi_cb_ctx* ctx) { + if (env != NULL) { + napi_delete_reference(env, ctx->ref); + } + napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); + free(ctx); +} + +// The vtable's `free` entry: the producer is done with `ctx`. +static void weaveffi_napi_cb_free(void* ctx_) { + weaveffi_napi_cb_ctx* ctx = (weaveffi_napi_cb_ctx*)ctx_; + if (ctx == NULL) { + return; + } + if (weaveffi_napi_on_js_thread()) { + weaveffi_napi_cb_release(ctx->env, ctx); + return; + } + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)calloc(1, sizeof(weaveffi_napi_cb_req)); + req->ctx = ctx; + req->method = -1; + if (napi_call_threadsafe_function(ctx->tsfn, req, napi_tsfn_nonblocking) != napi_ok) { + free(req); + } +} + +// Run `req` on the JS thread and block until it has completed. +static void weaveffi_napi_cb_hop(weaveffi_napi_cb_req* req) { + uv_mutex_init(&req->mu); + uv_cond_init(&req->cv); + req->done = false; + if (napi_call_threadsafe_function(req->ctx->tsfn, req, napi_tsfn_blocking) == napi_ok) { + uv_mutex_lock(&req->mu); + while (!req->done) { + uv_cond_wait(&req->cv, &req->mu); + } + uv_mutex_unlock(&req->mu); + } else { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "callback interface implementation is no longer reachable"); + } + uv_cond_destroy(&req->cv); + uv_mutex_destroy(&req->mu); +} + +static void weaveffi_napi_cb_finish(weaveffi_napi_cb_req* req) { + uv_mutex_lock(&req->mu); + req->done = true; + uv_cond_signal(&req->cv); + uv_mutex_unlock(&req->mu); +} -static weaveffi_napi_listener_ctx* weaveffi_napi_listeners = NULL; +// Report a JS exception raised by a callback implementation (or a return +// value of the wrong type) through `out_err` as a foreign error, clearing +// the pending exception so nothing unwinds through the C frame. +static void weaveffi_napi_cb_report(napi_env env, weaveffi_error* out_err, const char* fallback) { + char msg[512]; + msg[0] = 0; + bool pending = false; + napi_is_exception_pending(env, &pending); + if (pending) { + napi_value exc; + napi_get_and_clear_last_exception(env, &exc); + napi_valuetype t; + napi_typeof(env, exc, &t); + napi_value text = exc; + if (t == napi_object) { + napi_get_named_property(env, exc, "message", &text); + } + napi_value str; + if (napi_coerce_to_string(env, text, &str) == napi_ok) { + napi_get_value_string_utf8(env, str, msg, sizeof msg, NULL); + } + napi_is_exception_pending(env, &pending); + if (pending) { + napi_get_and_clear_last_exception(env, &exc); + } + } + weaveffi_error_set(out_err, -4, msg[0] ? msg : fallback); +} static napi_value Napi_weaveffi_shared_ping(napi_env env, napi_callback_info info) { size_t argc = 0; @@ -55,12 +251,141 @@ static napi_value Napi_weaveffi_shared_ping(napi_env env, napi_callback_info inf return ret; } +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + int32_t code; + const char* msg; +} weaveffi_kitchen_ReadyListener_on_ready_frame; + +static void weaveffi_kitchen_ReadyListener_on_ready_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_kitchen_ReadyListener_on_ready_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_ready", &fn); + napi_value argv[2]; + napi_create_int32(env, f->code, &argv[0]); + napi_create_string_utf8(env, f->msg ? f->msg : "", NAPI_AUTO_LENGTH, &argv[1]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "ReadyListener implementation has no on_ready method"); + } else if (napi_call_function(env, target, fn, 2, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "ReadyListener.on_ready threw"); + } + napi_close_handle_scope(env, scope); +} + +static void weaveffi_kitchen_ReadyListener_on_ready_tramp(void* ctx, int32_t code, const char* msg, weaveffi_error* out_err) { + weaveffi_kitchen_ReadyListener_on_ready_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.code = code; + f.msg = msg; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_kitchen_ReadyListener_on_ready_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 0; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } +} + +typedef struct { + weaveffi_napi_cb_frame_hdr hdr; + const uint8_t* item_ptr; + size_t item_len; + weaveffi_kitchen_Gadget* gadget; + bool result; +} weaveffi_kitchen_ReadyListener_on_item_frame; + +static void weaveffi_kitchen_ReadyListener_on_item_invoke(napi_env env, weaveffi_napi_cb_ctx* ctx, weaveffi_kitchen_ReadyListener_on_item_frame* f) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value target; + napi_get_reference_value(env, ctx->ref, &target); + napi_value fn; + napi_get_named_property(env, target, "on_item", &fn); + napi_value argv[2]; + napi_create_buffer_copy(env, f->item_len, f->item_ptr ? (const void*)f->item_ptr : (const void*)"", NULL, &argv[0]); + weaveffi_napi_make_handle(env, f->gadget, &argv[1]); + napi_value result; + napi_valuetype fn_type; + napi_typeof(env, fn, &fn_type); + if (fn_type != napi_function) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "ReadyListener implementation has no on_item method"); + } else if (napi_call_function(env, target, fn, 2, argv, &result) != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "ReadyListener.on_item threw"); + } else { + napi_status rs; + rs = napi_get_value_bool(env, result, &f->result); + if (rs != napi_ok) { + weaveffi_napi_cb_report(env, f->hdr.out_err, "ReadyListener.on_item returned a value of the wrong type"); + } + } + napi_close_handle_scope(env, scope); +} + +static bool weaveffi_kitchen_ReadyListener_on_item_tramp(void* ctx, const uint8_t* item_ptr, size_t item_len, weaveffi_kitchen_Gadget* gadget, weaveffi_error* out_err) { + weaveffi_kitchen_ReadyListener_on_item_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.item_ptr = item_ptr; + f.item_len = item_len; + f.gadget = gadget; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_kitchen_ReadyListener_on_item_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 1; + req.frame = &f; + weaveffi_napi_cb_hop(&req); + } + return f.result; +} + +static void weaveffi_kitchen_ReadyListener_napi_dispatch(napi_env env, napi_value js_cb, void* context, void* data) { + (void)js_cb; + (void)context; + weaveffi_napi_cb_req* req = (weaveffi_napi_cb_req*)data; + if (req->method < 0) { + weaveffi_napi_cb_release(env, req->ctx); + free(req); + return; + } + if (env == NULL) { + weaveffi_error_set(((weaveffi_napi_cb_frame_hdr*)req->frame)->out_err, -4, "Node environment is shutting down"); + } else { + switch (req->method) { + case 0: weaveffi_kitchen_ReadyListener_on_ready_invoke(env, req->ctx, (weaveffi_kitchen_ReadyListener_on_ready_frame*)req->frame); break; + case 1: weaveffi_kitchen_ReadyListener_on_item_invoke(env, req->ctx, (weaveffi_kitchen_ReadyListener_on_item_frame*)req->frame); break; + default: break; + } + } + weaveffi_napi_cb_finish(req); +} + +static const weaveffi_kitchen_ReadyListener_vtable weaveffi_kitchen_ReadyListener_napi_vtable = { weaveffi_kitchen_ReadyListener_on_ready_tramp, weaveffi_kitchen_ReadyListener_on_item_tramp, weaveffi_napi_cb_free }; + static napi_value Napi_weaveffi_kitchen_Gadget_new(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t id; - napi_get_value_int64(env, args[0], &id); + napi_status arg_status = napi_ok; + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; weaveffi_kitchen_Gadget* result = weaveffi_kitchen_Gadget_new(id, &err); if (err.code != 0) { @@ -69,7 +394,7 @@ static napi_value Napi_weaveffi_kitchen_Gadget_new(napi_env env, napi_callback_i return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + weaveffi_napi_make_handle(env, result, &ret); return ret; } @@ -93,10 +418,18 @@ static napi_value Napi_weaveffi_kitchen_Gadget_describe(napi_env env, napi_callb size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - const char* result = weaveffi_kitchen_Gadget_describe((const weaveffi_kitchen_Gadget*)(intptr_t)self_raw, &err); + const char* result = weaveffi_kitchen_Gadget_describe((const weaveffi_kitchen_Gadget*)self_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -112,12 +445,22 @@ static napi_value Napi_weaveffi_kitchen_Gadget_poke(napi_env env, napi_callback_ size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - int32_t times; - napi_get_value_int32(env, args[1], ×); + napi_status arg_status = napi_ok; + void* self_raw = NULL; + arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw); + if (arg_status == napi_ok && self_raw == NULL) { + napi_throw_type_error(env, NULL, "object used after close()"); + arg_status = napi_invalid_arg; + } + int32_t times = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[1], ×); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - int32_t result = weaveffi_kitchen_Gadget_poke((const weaveffi_kitchen_Gadget*)(intptr_t)self_raw, times, &err); + int32_t result = weaveffi_kitchen_Gadget_poke((const weaveffi_kitchen_Gadget*)self_raw, times, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); @@ -132,82 +475,29 @@ static napi_value Napi_weaveffi_kitchen_Gadget_destroy(napi_env env, napi_callba size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t self_raw; - napi_get_value_int64(env, args[0], &self_raw); - weaveffi_kitchen_Gadget* self = (weaveffi_kitchen_Gadget*)(intptr_t)self_raw; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; + } + weaveffi_kitchen_Gadget* self = (weaveffi_kitchen_Gadget*)self_raw; weaveffi_kitchen_Gadget_destroy(self); napi_value ret; napi_get_undefined(env, &ret); return ret; } -typedef struct { - int32_t code; - char* msg; -} weaveffi_kitchen_OnReady_fn_payload; - -static void weaveffi_kitchen_OnReady_fn_napi_tramp(int32_t code, const char* msg, void* context) { - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context; - weaveffi_kitchen_OnReady_fn_payload* p = (weaveffi_kitchen_OnReady_fn_payload*)calloc(1, sizeof(weaveffi_kitchen_OnReady_fn_payload)); - p->code = code; - p->msg = msg ? strdup(msg) : NULL; - napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking); -} - -static void weaveffi_kitchen_OnReady_fn_napi_calljs(napi_env env, napi_value js_cb, void* context, void* data) { - (void)context; - weaveffi_kitchen_OnReady_fn_payload* p = (weaveffi_kitchen_OnReady_fn_payload*)data; - if (env != NULL) { - napi_value undefined; - napi_get_undefined(env, &undefined); - napi_value argv[2]; - napi_create_int32(env, p->code, &argv[0]); - napi_create_string_utf8(env, p->msg ? p->msg : "", NAPI_AUTO_LENGTH, &argv[1]); - napi_call_function(env, undefined, js_cb, 2, argv, NULL); - } - free(p->msg); - free(p); -} - -static napi_value Napi_weaveffi_kitchen_register_ready_listener(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; - napi_get_cb_info(env, info, &argc, args, NULL, NULL); - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)calloc(1, sizeof(weaveffi_napi_listener_ctx)); - napi_value resource_name; - napi_create_string_utf8(env, "weaveffi_kitchen_register_ready_listener", NAPI_AUTO_LENGTH, &resource_name); - napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_kitchen_OnReady_fn_napi_calljs, &ctx->tsfn); - napi_unref_threadsafe_function(env, ctx->tsfn); - uint64_t id = weaveffi_kitchen_register_ready_listener(weaveffi_kitchen_OnReady_fn_napi_tramp, ctx); - ctx->id = id; - ctx->next = weaveffi_napi_listeners; - weaveffi_napi_listeners = ctx; - napi_value ret; - napi_create_double(env, (double)id, &ret); - return ret; -} - -static napi_value Napi_weaveffi_kitchen_unregister_ready_listener(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_kitchen_Gadget_clone(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - double id_d = 0; - napi_get_value_double(env, args[0], &id_d); - uint64_t id = (uint64_t)id_d; - weaveffi_kitchen_unregister_ready_listener(id); - weaveffi_napi_listener_ctx** link = &weaveffi_napi_listeners; - while (*link != NULL) { - if ((*link)->id == id) { - weaveffi_napi_listener_ctx* found = *link; - *link = found->next; - napi_release_threadsafe_function(found->tsfn, napi_tsfn_release); - free(found); - break; - } - link = &(*link)->next; + void* self_raw = NULL; + if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) { + return NULL; } + weaveffi_kitchen_Gadget* self = (weaveffi_kitchen_Gadget*)self_raw; + weaveffi_kitchen_Gadget* cloned = weaveffi_kitchen_Gadget_clone(self); napi_value ret; - napi_get_undefined(env, &ret); + weaveffi_napi_make_handle(env, cloned, &ret); return ret; } @@ -215,8 +505,14 @@ static napi_value Napi_weaveffi_kitchen_bool_id(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - bool x; - napi_get_value_bool(env, args[0], &x); + napi_status arg_status = napi_ok; + bool x = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_bool(env, args[0], &x); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; bool result = weaveffi_kitchen_bool_id(x, &err); if (err.code != 0) { @@ -233,8 +529,14 @@ static napi_value Napi_weaveffi_kitchen_i32_id(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t x; - napi_get_value_int32(env, args[0], &x); + napi_status arg_status = napi_ok; + int32_t x = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[0], &x); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; int32_t result = weaveffi_kitchen_i32_id(x, &err); if (err.code != 0) { @@ -251,8 +553,14 @@ static napi_value Napi_weaveffi_kitchen_u32_id(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - uint32_t x; - napi_get_value_uint32(env, args[0], &x); + napi_status arg_status = napi_ok; + uint32_t x = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_uint32(env, args[0], &x); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; uint32_t result = weaveffi_kitchen_u32_id(x, &err); if (err.code != 0) { @@ -269,8 +577,14 @@ static napi_value Napi_weaveffi_kitchen_i64_id(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t x; - napi_get_value_int64(env, args[0], &x); + napi_status arg_status = napi_ok; + int64_t x = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &x); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; int64_t result = weaveffi_kitchen_i64_id(x, &err); if (err.code != 0) { @@ -279,7 +593,7 @@ static napi_value Napi_weaveffi_kitchen_i64_id(napi_env env, napi_callback_info return NULL; } napi_value ret; - napi_create_int64(env, result, &ret); + napi_create_bigint_int64(env, result, &ret); return ret; } @@ -287,8 +601,14 @@ static napi_value Napi_weaveffi_kitchen_f64_id(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - double x; - napi_get_value_double(env, args[0], &x); + napi_status arg_status = napi_ok; + double x = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_double(env, args[0], &x); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; double result = weaveffi_kitchen_f64_id(x, &err); if (err.code != 0) { @@ -305,7 +625,7 @@ static napi_value Napi_weaveffi_kitchen_echo_string(napi_env env, napi_callback_ size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t s_len; + size_t s_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &s_len); char* s = (char*)malloc(s_len + 1); napi_get_value_string_utf8(env, args[0], s, s_len + 1, &s_len); @@ -327,11 +647,11 @@ static napi_value Napi_weaveffi_kitchen_echo_bytes(napi_env env, napi_callback_i size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* b_raw; - size_t b_len; + void* b_raw = NULL; + size_t b_len = 0; napi_get_buffer_info(env, args[0], &b_raw, &b_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_kitchen_echo_bytes((const uint8_t*)b_raw, b_len, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -344,76 +664,145 @@ static napi_value Napi_weaveffi_kitchen_echo_bytes(napi_env env, napi_callback_i return ret; } -static napi_value Napi_weaveffi_kitchen_echo_borrowed_str(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_kitchen_subscribe(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t s_len; - napi_get_value_string_utf8(env, args[0], NULL, 0, &s_len); - char* s = (char*)malloc(s_len + 1); - napi_get_value_string_utf8(env, args[0], s, s_len + 1, &s_len); + napi_status arg_status = napi_ok; + napi_valuetype listener_type; + napi_typeof(env, args[0], &listener_type); + weaveffi_napi_cb_ctx* listener_ctx = NULL; + if (listener_type == napi_object || listener_type == napi_function) { + listener_ctx = weaveffi_napi_cb_register(env, args[0], "weaveffi_kitchen_ReadyListener", weaveffi_kitchen_ReadyListener_napi_dispatch); + } else { + napi_throw_type_error(env, NULL, "expected a ReadyListener implementation"); + arg_status = napi_object_expected; + } + if (arg_status != napi_ok) { + if (listener_ctx != NULL) weaveffi_napi_cb_release(env, listener_ctx); + return NULL; + } weaveffi_error err = {0}; - const char* result = weaveffi_kitchen_echo_borrowed_str(s, &err); - free(s); + int32_t result = weaveffi_kitchen_subscribe((void*)listener_ctx, &weaveffi_kitchen_ReadyListener_napi_vtable, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, &ret); - weaveffi_free_string(result); + napi_create_int32(env, result, &ret); return ret; } -static napi_value Napi_weaveffi_kitchen_echo_borrowed_bytes(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_kitchen_maybe_gadget(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* b_raw; - size_t b_len; - napi_get_buffer_info(env, args[0], &b_raw, &b_len); + napi_status arg_status = napi_ok; + void* fallback_raw = NULL; + if (arg_status == napi_ok) arg_status = weaveffi_napi_get_handle(env, args[0], &fallback_raw); + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; - const uint8_t* result = weaveffi_kitchen_echo_borrowed_bytes((const uint8_t*)b_raw, b_len, &out_len, &err); + weaveffi_kitchen_Gadget* result = weaveffi_kitchen_maybe_gadget((const weaveffi_kitchen_Gadget*)fallback_raw, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_buffer_copy(env, out_len, result, NULL, &ret); - weaveffi_free_bytes((uint8_t*)result, out_len); + weaveffi_napi_make_handle(env, result, &ret); return ret; } -static napi_value Napi_weaveffi_kitchen_open_handle(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_kitchen_gadgets(napi_env env, napi_callback_info info) { size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - weaveffi_handle_t result = weaveffi_kitchen_open_handle(&err); + size_t out_len = 0; + const uint8_t* result = weaveffi_kitchen_gadgets(&out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + napi_create_buffer_copy(env, out_len, result, NULL, &ret); + weaveffi_free_bytes((uint8_t*)result, out_len); + return ret; +} + +static void weaveffi_kitchen_stream_gadgets_napi_iter_finalize(napi_env env, void* data, void* hint) { + (void)env; + (void)hint; + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)data; + if (state->iter != NULL) { + weaveffi_kitchen_StreamGadgetsIterator_destroy((weaveffi_kitchen_StreamGadgetsIterator*)state->iter); + state->iter = NULL; + } + free(state); +} + +static napi_value Napi_weaveffi_kitchen_StreamGadgetsIterator_next(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + void* iter_data = NULL; + napi_get_value_external(env, args[0], &iter_data); + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)iter_data; + napi_value ret; + if (state == NULL || state->iter == NULL) { + napi_get_undefined(env, &ret); + return ret; + } + weaveffi_kitchen_Gadget* iter_item; + weaveffi_error iter_err = {0}; + if (!weaveffi_kitchen_StreamGadgetsIterator_next((weaveffi_kitchen_StreamGadgetsIterator*)state->iter, &iter_item, &iter_err)) { + weaveffi_kitchen_StreamGadgetsIterator_destroy((weaveffi_kitchen_StreamGadgetsIterator*)state->iter); + state->iter = NULL; + if (iter_err.code != 0) { + napi_throw(env, weaveffi_napi_error_value(env, iter_err.code, iter_err.message, iter_err.payload_ptr, iter_err.payload_len)); + weaveffi_error_clear(&iter_err); + return NULL; + } + napi_get_undefined(env, &ret); + return ret; + } + weaveffi_napi_make_handle(env, iter_item, &ret); + return ret; +} + +static napi_value Napi_weaveffi_kitchen_StreamGadgetsIterator_destroy(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + void* iter_data = NULL; + napi_get_value_external(env, args[0], &iter_data); + weaveffi_napi_iter_state* state = (weaveffi_napi_iter_state*)iter_data; + if (state != NULL && state->iter != NULL) { + weaveffi_kitchen_StreamGadgetsIterator_destroy((weaveffi_kitchen_StreamGadgetsIterator*)state->iter); + state->iter = NULL; + } + napi_value ret; + napi_get_undefined(env, &ret); return ret; } -static napi_value Napi_weaveffi_kitchen_open_typed_handle(napi_env env, napi_callback_info info) { +static napi_value Napi_weaveffi_kitchen_stream_gadgets(napi_env env, napi_callback_info info) { size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - weaveffi_shared_Token* result = weaveffi_kitchen_open_typed_handle(&err); + void* result = weaveffi_kitchen_stream_gadgets(&err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)(intptr_t)result, &ret); + weaveffi_napi_iter_state* iter_state = (weaveffi_napi_iter_state*)calloc(1, sizeof(weaveffi_napi_iter_state)); + iter_state->iter = (void*)result; + napi_create_external(env, iter_state, weaveffi_kitchen_stream_gadgets_napi_iter_finalize, NULL, &ret); return ret; } @@ -421,10 +810,16 @@ static napi_value Napi_weaveffi_kitchen_maybe_item(napi_env env, napi_callback_i size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int64_t id; - napi_get_value_int64(env, args[0], &id); + napi_status arg_status = napi_ok; + int64_t id = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[0], &id); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_kitchen_maybe_item(id, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -441,7 +836,7 @@ static napi_value Napi_weaveffi_kitchen_list_items(napi_env env, napi_callback_i size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_kitchen_list_items(&out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -458,7 +853,7 @@ static napi_value Napi_weaveffi_kitchen_get_attrs(napi_env env, napi_callback_in size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_kitchen_get_attrs(&out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -550,7 +945,7 @@ static napi_value Napi_weaveffi_kitchen_cross_module_token(napi_env env, napi_ca size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_kitchen_cross_module_token(&out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -615,7 +1010,7 @@ static napi_value Napi_weaveffi_kitchen_do_async(napi_env env, napi_callback_inf size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t input_len; + size_t input_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &input_len); char* input = (char*)malloc(input_len + 1); napi_get_value_string_utf8(env, args[0], input, input_len + 1, &input_len); @@ -682,7 +1077,7 @@ static napi_value Napi_weaveffi_kitchen_do_cancellable(napi_env env, napi_callba size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - size_t input_len; + size_t input_len = 0; napi_get_value_string_utf8(env, args[0], NULL, 0, &input_len); char* input = (char*)malloc(input_len + 1); napi_get_value_string_utf8(env, args[0], input, input_len + 1, &input_len); @@ -753,15 +1148,15 @@ static napi_value Init(napi_env env, napi_value exports) { return NULL; } } + weaveffi_napi_js_thread = uv_thread_self(); napi_property_descriptor props[] = { { "ping", NULL, Napi_weaveffi_shared_ping, NULL, NULL, NULL, napi_default, NULL }, { "Gadget_new", NULL, Napi_weaveffi_kitchen_Gadget_new, NULL, NULL, NULL, napi_default, NULL }, { "Gadget_version", NULL, Napi_weaveffi_kitchen_Gadget_version, NULL, NULL, NULL, napi_default, NULL }, { "Gadget_describe", NULL, Napi_weaveffi_kitchen_Gadget_describe, NULL, NULL, NULL, napi_default, NULL }, { "Gadget_poke", NULL, Napi_weaveffi_kitchen_Gadget_poke, NULL, NULL, NULL, napi_default, NULL }, - { "Gadget_destroy", NULL, Napi_weaveffi_kitchen_Gadget_destroy, NULL, NULL, NULL, napi_default, NULL }, - { "registerReadyListener", NULL, Napi_weaveffi_kitchen_register_ready_listener, NULL, NULL, NULL, napi_default, NULL }, - { "unregisterReadyListener", NULL, Napi_weaveffi_kitchen_unregister_ready_listener, NULL, NULL, NULL, napi_default, NULL }, + { "Gadget__destroy", NULL, Napi_weaveffi_kitchen_Gadget_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "Gadget__clone", NULL, Napi_weaveffi_kitchen_Gadget_clone, NULL, NULL, NULL, napi_default, NULL }, { "boolId", NULL, Napi_weaveffi_kitchen_bool_id, NULL, NULL, NULL, napi_default, NULL }, { "i32Id", NULL, Napi_weaveffi_kitchen_i32_id, NULL, NULL, NULL, napi_default, NULL }, { "u32Id", NULL, Napi_weaveffi_kitchen_u32_id, NULL, NULL, NULL, napi_default, NULL }, @@ -769,10 +1164,12 @@ static napi_value Init(napi_env env, napi_value exports) { { "f64Id", NULL, Napi_weaveffi_kitchen_f64_id, NULL, NULL, NULL, napi_default, NULL }, { "echoString", NULL, Napi_weaveffi_kitchen_echo_string, NULL, NULL, NULL, napi_default, NULL }, { "echoBytes", NULL, Napi_weaveffi_kitchen_echo_bytes, NULL, NULL, NULL, napi_default, NULL }, - { "echoBorrowedStr", NULL, Napi_weaveffi_kitchen_echo_borrowed_str, NULL, NULL, NULL, napi_default, NULL }, - { "echoBorrowedBytes", NULL, Napi_weaveffi_kitchen_echo_borrowed_bytes, NULL, NULL, NULL, napi_default, NULL }, - { "openHandle", NULL, Napi_weaveffi_kitchen_open_handle, NULL, NULL, NULL, napi_default, NULL }, - { "openTypedHandle", NULL, Napi_weaveffi_kitchen_open_typed_handle, NULL, NULL, NULL, napi_default, NULL }, + { "subscribe", NULL, Napi_weaveffi_kitchen_subscribe, NULL, NULL, NULL, napi_default, NULL }, + { "maybeGadget", NULL, Napi_weaveffi_kitchen_maybe_gadget, NULL, NULL, NULL, napi_default, NULL }, + { "gadgets", NULL, Napi_weaveffi_kitchen_gadgets, NULL, NULL, NULL, napi_default, NULL }, + { "streamGadgets_iterNext", NULL, Napi_weaveffi_kitchen_StreamGadgetsIterator_next, NULL, NULL, NULL, napi_default, NULL }, + { "streamGadgets_iterDestroy", NULL, Napi_weaveffi_kitchen_StreamGadgetsIterator_destroy, NULL, NULL, NULL, napi_default, NULL }, + { "streamGadgets", NULL, Napi_weaveffi_kitchen_stream_gadgets, NULL, NULL, NULL, napi_default, NULL }, { "maybeItem", NULL, Napi_weaveffi_kitchen_maybe_item, NULL, NULL, NULL, napi_default, NULL }, { "listItems", NULL, Napi_weaveffi_kitchen_list_items, NULL, NULL, NULL, napi_default, NULL }, { "getAttrs", NULL, Napi_weaveffi_kitchen_get_attrs, NULL, NULL, NULL, napi_default, NULL }, @@ -786,7 +1183,7 @@ static napi_value Init(napi_env env, napi_value exports) { { "newOp", NULL, Napi_weaveffi_kitchen_new_op, NULL, NULL, NULL, napi_default, NULL }, { "hello", NULL, Napi_weaveffi_kitchen_nested_hello, NULL, NULL, NULL, napi_default, NULL }, }; - napi_define_properties(env, exports, 31, props); + napi_define_properties(env, exports, 32, props); return exports; } diff --git a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__index_js.snap b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__index_js.snap index 1371426c..3be4f1fc 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__index_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__index_js.snap @@ -232,6 +232,12 @@ function __graphicsErrorFrom(code, message, payload) { return _err; } +wv.Unit = Object.freeze({ + pixels: 0, + points: 1, + 0: 'pixels', + 1: 'points', +}); wv.newCanvas = function (width) { const _r = __invoke(addon.newCanvas, [width], __generic); return __decode(__unpackCanvas, _r); diff --git a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__types_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__types_d_ts.snap index 2c5a5730..3c88f26a 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__types_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__types_d_ts.snap @@ -8,8 +8,10 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated types for WeaveFFI functions /** * Base class of every error thrown by these bindings. Non-throwing - * functions reject or throw it directly for panics and marshalling - * failures; throwing functions surface a module domain subclass. + * functions reject or throw it directly for runtime traps (code -2 a + * producer panic, -3 a marshalling failure, -4 a callback interface + * implementation that threw); throwing functions surface a module + * domain subclass for their declared codes. */ export class WeaveFFIError extends Error { /** The numeric ABI error code. */ diff --git a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__weaveffi_addon_c.snap b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__weaveffi_addon_c.snap index 1bfc3bc7..4f89c1f2 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_nested_modules__weaveffi_addon_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_nested_modules__weaveffi_addon_c.snap @@ -5,12 +5,89 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o +#ifndef NAPI_VERSION +#define NAPI_VERSION 8 +#endif #include #include "weaveffi.h" +#include +#include #include #include #include +static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; + } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} + static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const char* message, const uint8_t* payload_ptr, size_t payload_len) { napi_value msg; napi_create_string_utf8(env, message ? message : "", NAPI_AUTO_LENGTH, &msg); @@ -31,10 +108,16 @@ static napi_value Napi_weaveffi_graphics_new_canvas(napi_env env, napi_callback_ size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t width; - napi_get_value_int32(env, args[0], &width); + napi_status arg_status = napi_ok; + int32_t width = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[0], &width); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_graphics_new_canvas(width, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -51,10 +134,16 @@ static napi_value Napi_weaveffi_graphics_shapes_make_shape(napi_env env, napi_ca size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t sides; - napi_get_value_int32(env, args[0], &sides); + napi_status arg_status = napi_ok; + int32_t sides = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[0], &sides); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_graphics_shapes_make_shape(sides, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -71,7 +160,7 @@ static napi_value Napi_weaveffi_graphics_shapes_describe(napi_env env, napi_call size_t argc = 0; napi_get_cb_info(env, info, &argc, NULL, NULL, NULL); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_graphics_shapes_describe(&out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -88,11 +177,11 @@ static napi_value Napi_weaveffi_graphics_shapes_geometry_compute(napi_env env, n size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* shape_raw; - size_t shape_len; + void* shape_raw = NULL; + size_t shape_len = 0; napi_get_buffer_info(env, args[0], &shape_raw, &shape_len); weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_graphics_shapes_geometry_compute((const uint8_t*)shape_raw, shape_len, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); diff --git a/crates/weaveffi-cli/tests/snapshots/node_shapes__index_js.snap b/crates/weaveffi-cli/tests/snapshots/node_shapes__index_js.snap index a053c175..1585a234 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_shapes__index_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_shapes__index_js.snap @@ -194,11 +194,11 @@ function __unpackNumerics(r) { a: r.i8(), b: r.i16(), c: r.i32(), - d: Number(r.i64()), + d: r.i64(), e: r.u8(), f: r.u16(), g: r.u32(), - h: Number(r.u64()), + h: r.u64(), i: r.f32(), j: r.f64(), }; @@ -238,12 +238,20 @@ function __unpackShape(r) { case 0: return { tag: 'Empty' }; case 1: return { tag: 'Circle', radius: r.f64() }; case 2: return { tag: 'Rectangle', width: r.f32(), height: r.f32() }; - case 3: return { tag: 'Bounded', min: r.i16(), max: r.i16(), weight: Number(r.u64()) }; + case 3: return { tag: 'Bounded', min: r.i16(), max: r.i16(), weight: r.u64() }; case 4: return { tag: 'Labeled', label: r.str(), count: r.u8() }; default: throw new WeaveFFIError(-2, 'unknown Shape tag: ' + tag); } } +wv.Channel = Object.freeze({ + Red: 0, + Green: 1, + Blue: 2, + 0: 'Red', + 1: 'Green', + 2: 'Blue', +}); wv.describe = function (shape) { return __invoke(addon.describe, [__encode(__packShape, shape)], __generic); }; diff --git a/crates/weaveffi-cli/tests/snapshots/node_shapes__types_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/node_shapes__types_d_ts.snap index 8e3757b0..7da37c22 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_shapes__types_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_shapes__types_d_ts.snap @@ -8,8 +8,10 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated types for WeaveFFI functions /** * Base class of every error thrown by these bindings. Non-throwing - * functions reject or throw it directly for panics and marshalling - * failures; throwing functions surface a module domain subclass. + * functions reject or throw it directly for runtime traps (code -2 a + * producer panic, -3 a marshalling failure, -4 a callback interface + * implementation that threw); throwing functions surface a module + * domain subclass for their declared codes. */ export class WeaveFFIError extends Error { /** The numeric ABI error code. */ @@ -23,11 +25,11 @@ export interface Numerics { a: number; b: number; c: number; - d: number; + d: bigint; e: number; f: number; g: number; - h: number; + h: bigint; i: number; j: number; } @@ -40,7 +42,7 @@ export type Shape = /** An axis-aligned rectangle */ | { tag: 'Rectangle'; width: number; height: number } /** An integer-bounded shape (exercises narrow + wide ints) */ - | { tag: 'Bounded'; min: number; max: number; weight: number } + | { tag: 'Bounded'; min: number; max: number; weight: bigint } /** A labeled shape with a small count */ | { tag: 'Labeled'; label: string; count: number }; /** A plain C-style enum (no payloads) */ @@ -64,11 +66,11 @@ export function scale(shape: Shape, factor: number): Shape * Sum a list of bytes into a wide integer (numerics smoke) * Maps to C function: weaveffi_shapes_sum_bytes */ -export function sumBytes(values: Buffer): number +export function sumBytes(values: Buffer): bigint /** * Build a Numerics struct from every numeric primitive * Maps to C function: weaveffi_shapes_make_numerics */ -export function makeNumerics(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number): Numerics +export function makeNumerics(a: number, b: number, c: number, d: bigint, e: number, f: number, g: number, h: bigint, i: number, j: number): Numerics // END types.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/node_shapes__weaveffi_addon_c.snap b/crates/weaveffi-cli/tests/snapshots/node_shapes__weaveffi_addon_c.snap index d1390958..e151188d 100644 --- a/crates/weaveffi-cli/tests/snapshots/node_shapes__weaveffi_addon_c.snap +++ b/crates/weaveffi-cli/tests/snapshots/node_shapes__weaveffi_addon_c.snap @@ -5,12 +5,89 @@ source: crates/weaveffi-cli/tests/snapshots.rs // DO NOT EDIT. Your changes will be overwritten. // To regenerate: weaveffi generate weaveffi.yml -o +#ifndef NAPI_VERSION +#define NAPI_VERSION 8 +#endif #include #include "weaveffi.h" +#include +#include #include #include #include +static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; + } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} + static napi_value weaveffi_napi_error_value(napi_env env, int32_t code, const char* message, const uint8_t* payload_ptr, size_t payload_len) { napi_value msg; napi_create_string_utf8(env, message ? message : "", NAPI_AUTO_LENGTH, &msg); @@ -31,8 +108,8 @@ static napi_value Napi_weaveffi_shapes_describe(napi_env env, napi_callback_info size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* shape_raw; - size_t shape_len; + void* shape_raw = NULL; + size_t shape_len = 0; napi_get_buffer_info(env, args[0], &shape_raw, &shape_len); weaveffi_error err = {0}; const char* result = weaveffi_shapes_describe((const uint8_t*)shape_raw, shape_len, &err); @@ -51,13 +128,19 @@ static napi_value Napi_weaveffi_shapes_scale(napi_env env, napi_callback_info in size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* shape_raw; - size_t shape_len; + napi_status arg_status = napi_ok; + void* shape_raw = NULL; + size_t shape_len = 0; napi_get_buffer_info(env, args[0], &shape_raw, &shape_len); - double factor; - napi_get_value_double(env, args[1], &factor); + double factor = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_double(env, args[1], &factor); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; + size_t out_len = 0; const uint8_t* result = weaveffi_shapes_scale((const uint8_t*)shape_raw, shape_len, factor, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); @@ -74,8 +157,8 @@ static napi_value Napi_weaveffi_shapes_sum_bytes(napi_env env, napi_callback_inf size_t argc = 1; napi_value args[1]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - void* values_raw; - size_t values_len; + void* values_raw = NULL; + size_t values_len = 0; napi_get_buffer_info(env, args[0], &values_raw, &values_len); weaveffi_error err = {0}; uint64_t result = weaveffi_shapes_sum_bytes((const uint8_t*)values_raw, values_len, &err); @@ -85,7 +168,7 @@ static napi_value Napi_weaveffi_shapes_sum_bytes(napi_env env, napi_callback_inf return NULL; } napi_value ret; - napi_create_int64(env, (int64_t)result, &ret); + napi_create_bigint_uint64(env, result, &ret); return ret; } @@ -93,29 +176,63 @@ static napi_value Napi_weaveffi_shapes_make_numerics(napi_env env, napi_callback size_t argc = 10; napi_value args[10]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); - int32_t a_raw; - napi_get_value_int32(env, args[0], &a_raw); - int32_t b_raw; - napi_get_value_int32(env, args[1], &b_raw); - int32_t c; - napi_get_value_int32(env, args[2], &c); - int64_t d; - napi_get_value_int64(env, args[3], &d); - uint32_t e_raw; - napi_get_value_uint32(env, args[4], &e_raw); - uint32_t f_raw; - napi_get_value_uint32(env, args[5], &f_raw); - uint32_t g; - napi_get_value_uint32(env, args[6], &g); - int64_t h_raw; - napi_get_value_int64(env, args[7], &h_raw); - double i_raw; - napi_get_value_double(env, args[8], &i_raw); - double j; - napi_get_value_double(env, args[9], &j); + napi_status arg_status = napi_ok; + int8_t a = 0; + if (arg_status == napi_ok) { + int32_t a_tmp = 0; + arg_status = napi_get_value_int32(env, args[0], &a_tmp); + a = (int8_t)a_tmp; + } + int16_t b = 0; + if (arg_status == napi_ok) { + int32_t b_tmp = 0; + arg_status = napi_get_value_int32(env, args[1], &b_tmp); + b = (int16_t)b_tmp; + } + int32_t c = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_int32(env, args[2], &c); + } + int64_t d = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_i64(env, args[3], &d); + } + uint8_t e = 0; + if (arg_status == napi_ok) { + uint32_t e_tmp = 0; + arg_status = napi_get_value_uint32(env, args[4], &e_tmp); + e = (uint8_t)e_tmp; + } + uint16_t f = 0; + if (arg_status == napi_ok) { + uint32_t f_tmp = 0; + arg_status = napi_get_value_uint32(env, args[5], &f_tmp); + f = (uint16_t)f_tmp; + } + uint32_t g = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_uint32(env, args[6], &g); + } + uint64_t h = 0; + if (arg_status == napi_ok) { + arg_status = weaveffi_napi_get_u64(env, args[7], &h); + } + float i = 0; + if (arg_status == napi_ok) { + double i_tmp = 0; + arg_status = napi_get_value_double(env, args[8], &i_tmp); + i = (float)i_tmp; + } + double j = 0; + if (arg_status == napi_ok) { + arg_status = napi_get_value_double(env, args[9], &j); + } + if (arg_status != napi_ok) { + return NULL; + } weaveffi_error err = {0}; - size_t out_len; - const uint8_t* result = weaveffi_shapes_make_numerics((int8_t)a_raw, (int16_t)b_raw, c, d, (uint8_t)e_raw, (uint16_t)f_raw, g, (uint64_t)h_raw, (float)i_raw, j, &out_len, &err); + size_t out_len = 0; + const uint8_t* result = weaveffi_shapes_make_numerics(a, b, c, d, e, f, g, h, i, j, &out_len, &err); if (err.code != 0) { napi_throw(env, weaveffi_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len)); weaveffi_error_clear(&err); diff --git a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__pyproject_toml.snap b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__pyproject_toml.snap index c07695fa..244fb84d 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__pyproject_toml.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__pyproject_toml.snap @@ -18,4 +18,7 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["weaveffi"] +[tool.setuptools.package-data] +"weaveffi" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + # END pyproject.toml diff --git a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_py_typed.snap b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_py_typed.snap new file mode 100644 index 00000000..501a8090 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_py_typed.snap @@ -0,0 +1,6 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o diff --git a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_py.snap b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_py.snap index 0a6b5094..15f821b9 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_py.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_py.snap @@ -17,6 +17,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -53,7 +66,7 @@ _lib = _load_library() # The ABI revision these bindings were generated against. Checked before any # other symbol is bound so a mismatched producer fails at import time instead # of misreading the error struct or a value buffer later. -_ABI_VERSION = 1 +_ABI_VERSION = 2 def _check_abi_version(lib: ctypes.CDLL) -> None: @@ -75,6 +88,14 @@ def _check_abi_version(lib: ctypes.CDLL) -> None: _check_abi_version(_lib) +# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None _lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; @@ -130,14 +151,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -188,6 +225,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -268,6 +313,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") @@ -290,6 +343,7 @@ def _take_buffer(ptr, length) -> bytes: _lib.weaveffi_free_bytes(ptr, ctypes.c_size_t(length)) return _data +import abc import asyncio import threading @@ -312,10 +366,23 @@ def _async_register(cb) -> int: return _token -# Registered listener trampolines, keyed by subscription id. Holding -# the ctypes function objects here keeps them alive until unregistered; -# without this the GC could collect a trampoline the producer still calls. -_listener_refs: Dict[int, object] = {} +# Live callback-interface implementations, keyed by the integer the producer +# receives as `ctx`. An entry is added when an implementation is passed to a +# call and removed when the producer invokes the vtable's `free(ctx)`, so the +# implementation stays alive exactly as long as the producer may call it and +# the producer never holds a raw reference to a Python object. +_cb_impls: Dict[int, object] = {} +_cb_lock = threading.Lock() +_cb_next_ctx = 0 + + +def _cb_register(impl) -> int: + global _cb_next_ctx + with _cb_lock: + _cb_next_ctx += 1 + _ctx = _cb_next_ctx + _cb_impls[_ctx] = impl + return _ctx # === Module: docs === @@ -431,6 +498,67 @@ def _unpack_Document(data: bytes) -> "Document": return _decode_buffer(data, _read_Document) +class SavedListener(abc.ABC): + """ + Consumer-implemented observer of document-saved notifications. + + Implementations must be safe to call from any thread. + + Consumer-implemented callback interface. Subclass it, implement every abstract method, and pass an instance wherever the API takes a `SavedListener`; the producer may call the methods from any thread until it releases the instance. An exception raised by a method is reported to the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the call that was in progress. + """ + + @abc.abstractmethod + def on_saved(self, doc_id: int, revision: int) -> None: + """Fires after a document has been persisted successfully + + Parameters + ---------- + doc_id : Identifier of the saved document + revision : Monotonically increasing revision + """ + + +_SavedListener_on_saved_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_int64, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct)) +_SavedListener_vtable_free_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class _SavedListenerVtable(ctypes.Structure): + """The C vtable `weaveffi_docs_SavedListener_vtable`.""" + + _fields_ = [ + ("on_saved", _SavedListener_on_saved_cfunctype), + ("free", _SavedListener_vtable_free_cfunctype), + ] + + +def _SavedListener_on_saved_trampoline(ctx, doc_id, revision, out_err): + try: + _impl = _cb_impls[ctx] + _impl.on_saved(doc_id, revision) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _SavedListener_vtable_free_trampoline(ctx): + # The producer's last reference is gone; it never touches `ctx` again. + _cb_impls.pop(ctx, None) + + +# The one process-wide vtable for SavedListener. The CFUNCTYPE objects are held at +# module scope so their function pointers stay valid for the process lifetime. +_SavedListener_on_saved_cfunc = _SavedListener_on_saved_cfunctype(_SavedListener_on_saved_trampoline) +_SavedListener_vtable_free_cfunc = _SavedListener_vtable_free_cfunctype(_SavedListener_vtable_free_trampoline) +_SavedListener_vtable = _SavedListenerVtable(_SavedListener_on_saved_cfunc, _SavedListener_vtable_free_cfunc) + + +_lib.weaveffi_docs_Notebook_clone.argtypes = [ctypes.c_void_p] +_lib.weaveffi_docs_Notebook_clone.restype = ctypes.c_void_p +_lib.weaveffi_docs_Notebook_destroy.argtypes = [ctypes.c_void_p] +_lib.weaveffi_docs_Notebook_destroy.restype = None + + class Notebook: """ An ordered collection of documents. @@ -440,6 +568,7 @@ class Notebook: @classmethod def _from_ptr(cls, ptr) -> "Notebook": + """Adopt one strong reference the producer handed over.""" _obj = cls.__new__(cls) _obj._ptr = ptr return _obj @@ -459,12 +588,30 @@ class Notebook: self._ptr = _result + def _clone_ptr(self): + """A new strong reference to the same object (a raw pointer the + receiver must eventually release), leaving this wrapper's own + reference untouched.""" + return _lib.weaveffi_docs_Notebook_clone(_borrow(self)) + + def close(self) -> None: + """Release this wrapper's reference. The object itself is dropped + when its last reference (here or in the producer) is released. + Idempotent; the wrapper is unusable afterwards.""" + _p = getattr(self, "_ptr", None) + self._ptr = None + if _p is not None: + _lib.weaveffi_docs_Notebook_destroy(_p) + + def __enter__(self) -> "Notebook": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False + def __del__(self) -> None: - if self._ptr is not None: - _lib.weaveffi_docs_Notebook_destroy.argtypes = [ctypes.c_void_p] - _lib.weaveffi_docs_Notebook_destroy.restype = None - _lib.weaveffi_docs_Notebook_destroy(self._ptr) - self._ptr = None + self.close() def insert(self, document: "Document") -> int: """Add a document, returning its position in the notebook @@ -478,7 +625,7 @@ class Notebook: _fn.restype = ctypes.c_int32 _document_buf = _pack_Document(document) _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, _document_buf, len(_document_buf), ctypes.byref(_err)) + _result = _fn(_borrow(self), _document_buf, len(_document_buf), ctypes.byref(_err)) _check_error(_err) return _result @@ -500,7 +647,7 @@ class Notebook: _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() _out_len = ctypes.c_size_t(0) - _result = _fn(self._ptr, id, ctypes.byref(_out_len), ctypes.byref(_err)) + _result = _fn(_borrow(self), id, ctypes.byref(_out_len), ctypes.byref(_err)) _check_docs_errors_error(_err) _data = _take_buffer(_result, _out_len.value) return _unpack_Document(_data) @@ -513,7 +660,7 @@ class Notebook: _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_int32 _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, ctypes.byref(_err)) + _result = _fn(_borrow(self), ctypes.byref(_err)) _check_error(_err) return _result @@ -532,37 +679,6 @@ class Notebook: -# Fires after a document has been persisted successfully -# Callback type OnSaved: Callable[[int, int], None] -_CFUNC_weaveffi_docs_OnSaved_fn = ctypes.CFUNCTYPE(None, ctypes.c_int64, ctypes.c_int32, ctypes.c_void_p) - - -def register_saved_listener(callback: Callable[[int, int], None]) -> int: - """ - Subscribe to document-saved notifications - - Returns a subscription id for unregister_saved_listener(). - """ - def _trampoline(doc_id, revision, _context): - callback(doc_id, revision) - _cfunc = _CFUNC_weaveffi_docs_OnSaved_fn(_trampoline) - _fn = _lib.weaveffi_docs_register_saved_listener - _fn.argtypes = [_CFUNC_weaveffi_docs_OnSaved_fn, ctypes.c_void_p] - _fn.restype = ctypes.c_uint64 - _listener_id = int(_fn(_cfunc, None)) - _listener_refs[_listener_id] = _cfunc - return _listener_id - - -def unregister_saved_listener(listener_id: int) -> None: - """Unregister a listener previously registered with register_saved_listener().""" - _fn = _lib.weaveffi_docs_unregister_saved_listener - _fn.argtypes = [ctypes.c_uint64] - _fn.restype = None - _fn(ctypes.c_uint64(listener_id)) - _listener_refs.pop(listener_id, None) - - def create_document(title: str, tags: List[str]) -> "Document": """ Create a brand new document. @@ -655,4 +771,20 @@ async def publish_async(id: int) -> bool: _fn(id, _cb, None) return await _fut + +def watch(listener: "SavedListener") -> None: + """Register a listener for saved notifications + + Parameters + ---------- + listener : Receiver of saved notifications + """ + _fn = _lib.weaveffi_docs_watch + _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_SavedListenerVtable), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = None + _listener_ctx = _cb_register(listener) + _err = _WeaveFFIErrorStruct() + _fn(_listener_ctx, ctypes.byref(_SavedListener_vtable), ctypes.byref(_err)) + _check_error(_err) + # END weaveffi.py diff --git a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_pyi.snap b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_pyi.snap index e1d990b2..d6971ba8 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_pyi.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_docs_everywhere__weaveffi_weaveffi_pyi.snap @@ -5,10 +5,15 @@ source: crates/weaveffi-cli/tests/snapshots.rs # DO NOT EDIT. Your changes will be overwritten. # To regenerate: weaveffi generate weaveffi.yml -o +from abc import ABC, abstractmethod from enum import IntEnum from typing import Callable, Dict, Iterator, List, Optional, Type class WeaveFFIError(Exception): + GENERIC_ERROR_CODE: int + PANIC_ERROR_CODE: int + MARSHAL_ERROR_CODE: int + FOREIGN_ERROR_CODE: int code: int message: str def __init__(self, code: int, message: str) -> None: ... @@ -53,21 +58,28 @@ class Document: tags: List[str] def __init__(self, id: int, title: str, status: "Status", tags: List[str]) -> None: ... +# Consumer-implemented observer of document-saved notifications. +# +# Implementations must be safe to call from any thread. +class SavedListener(ABC): + # Fires after a document has been persisted successfully + @abstractmethod + def on_saved(self, doc_id: int, revision: int) -> None: ... + # An ordered collection of documents. # # Notebooks own their documents and release them when destroyed. class Notebook: def __init__(self) -> None: ... + def close(self) -> None: ... + def __enter__(self) -> "Notebook": ... + def __exit__(self, *exc: object) -> bool: ... def insert(self, document: "Document") -> int: ... def find(self, id: int) -> "Document": ... def size(self) -> int: ... @staticmethod def max_documents() -> int: ... -# Subscribe to document-saved notifications -def register_saved_listener(callback: Callable[[int, int], None]) -> int: ... -def unregister_saved_listener(listener_id: int) -> None: ... - # Create a brand new document. # # The document starts in `Draft` status; callers must publish @@ -80,4 +92,7 @@ def get_document(id: int) -> Optional["Document"]: ... # Publish a document in the background async def publish_async(id: int) -> bool: ... +# Register a listener for saved notifications +def watch(listener: "SavedListener") -> None: ... + # END weaveffi.pyi diff --git a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__pyproject_toml.snap b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__pyproject_toml.snap index c07695fa..244fb84d 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__pyproject_toml.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__pyproject_toml.snap @@ -18,4 +18,7 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["weaveffi"] +[tool.setuptools.package-data] +"weaveffi" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + # END pyproject.toml diff --git a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_py_typed.snap b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_py_typed.snap new file mode 100644 index 00000000..501a8090 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_py_typed.snap @@ -0,0 +1,6 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o diff --git a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_py.snap b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_py.snap index fb85f35e..05ddd67c 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_py.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_py.snap @@ -17,6 +17,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -53,7 +66,7 @@ _lib = _load_library() # The ABI revision these bindings were generated against. Checked before any # other symbol is bound so a mismatched producer fails at import time instead # of misreading the error struct or a value buffer later. -_ABI_VERSION = 1 +_ABI_VERSION = 2 def _check_abi_version(lib: ctypes.CDLL) -> None: @@ -75,6 +88,14 @@ def _check_abi_version(lib: ctypes.CDLL) -> None: _check_abi_version(_lib) +# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None _lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; @@ -130,14 +151,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -188,6 +225,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -268,6 +313,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") @@ -290,6 +343,7 @@ def _take_buffer(ptr, length) -> bytes: _lib.weaveffi_free_bytes(ptr, ctypes.c_size_t(length)) return _data +import abc import asyncio import threading @@ -312,10 +366,23 @@ def _async_register(cb) -> int: return _token -# Registered listener trampolines, keyed by subscription id. Holding -# the ctypes function objects here keeps them alive until unregistered; -# without this the GC could collect a trampoline the producer still calls. -_listener_refs: Dict[int, object] = {} +# Live callback-interface implementations, keyed by the integer the producer +# receives as `ctx`. An entry is added when an implementation is passed to a +# call and removed when the producer invokes the vtable's `free(ctx)`, so the +# implementation stays alive exactly as long as the producer may call it and +# the producer never holds a raw reference to a Python object. +_cb_impls: Dict[int, object] = {} +_cb_lock = threading.Lock() +_cb_next_ctx = 0 + + +def _cb_register(impl) -> int: + global _cb_next_ctx + with _cb_lock: + _cb_next_ctx += 1 + _ctx = _cb_next_ctx + _cb_impls[_ctx] = impl + return _ctx # === Module: edge_cases === @@ -742,25 +809,284 @@ def _unpack_Retired(data: bytes) -> "Retired": return _decode_buffer(data, _read_Retired) +@dataclass +class WidgetHolder: + """Record carrying objects in every buffered position""" + + primary: "Widget" + fallback: Optional["Widget"] + extras: List["Widget"] + by_name: Dict[str, "Widget"] + + +def _write_WidgetHolder(_w: _BufferWriter, value: "WidgetHolder") -> None: + _w.write_object(value.primary) + if value.fallback is None: + _w.write_option_flag(False) + else: + _w.write_option_flag(True) + _w.write_object(value.fallback) + _w.write_len(len(value.extras)) + for _e0 in value.extras: + _w.write_object(_e0) + _w.write_len(len(value.by_name)) + for _k0, _v0 in value.by_name.items(): + _w.write_string(_k0) + _w.write_object(_v0) + + +def _read_WidgetHolder(_r: _BufferReader) -> "WidgetHolder": + return WidgetHolder( + primary=Widget._from_ptr(_r.read_object()), + fallback=(Widget._from_ptr(_r.read_object()) if _r.read_option_flag() else None), + extras=[Widget._from_ptr(_r.read_object()) for _i0 in range(_r.read_len())], + by_name=dict((_r.read_string(), Widget._from_ptr(_r.read_object())) for _i0 in range(_r.read_len())), + ) + + +def _pack_WidgetHolder(value: "WidgetHolder") -> bytes: + _w = _BufferWriter() + _write_WidgetHolder(_w, value) + return _w.finish() + + +def _unpack_WidgetHolder(data: bytes) -> "WidgetHolder": + return _decode_buffer(data, _read_WidgetHolder) + + +class BatchListener(abc.ABC): + """ + Callback interface carrying composite payloads and objects + + Consumer-implemented callback interface. Subclass it, implement every abstract method, and pass an instance wherever the API takes a `BatchListener`; the producer may call the methods from any thread until it releases the instance. An exception raised by a method is reported to the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the call that was in progress. + """ + + @abc.abstractmethod + def on_batch(self, items: List["Outer"], raw: bytes, first: Optional["Outer"], mode: "Mode") -> None: + """Composite payloads cross as value buffers + """ + + @abc.abstractmethod + def on_widget(self, widget: "Widget", fallback: Optional["Widget"], children: List["Widget"]) -> None: + """Objects cross as strong references the consumer must release + """ + + @abc.abstractmethod + def on_tick(self) -> None: + ... + + @abc.abstractmethod + def should_continue(self, class_: int) -> bool: + """Scalar return travels back through the vtable + """ + + @abc.abstractmethod + def pick_mode(self) -> "Mode": + """Enum return travels back as its integer discriminant + """ + + +_BatchListener_on_batch_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct)) +_BatchListener_on_widget_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(_WeaveFFIErrorStruct)) +_BatchListener_on_tick_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)) +_BatchListener_should_continue_cfunctype = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct)) +_BatchListener_pick_mode_cfunctype = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)) +_BatchListener_vtable_free_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class _BatchListenerVtable(ctypes.Structure): + """The C vtable `weaveffi_edge_cases_BatchListener_vtable`.""" + + _fields_ = [ + ("on_batch", _BatchListener_on_batch_cfunctype), + ("on_widget", _BatchListener_on_widget_cfunctype), + ("on_tick", _BatchListener_on_tick_cfunctype), + ("should_continue", _BatchListener_should_continue_cfunctype), + ("pick_mode", _BatchListener_pick_mode_cfunctype), + ("free", _BatchListener_vtable_free_cfunctype), + ] + + +def _BatchListener_on_batch_trampoline(ctx, items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, out_err): + try: + _impl = _cb_impls[ctx] + _impl.on_batch(_decode_buffer(ctypes.string_at(items_ptr, items_len) if items_ptr else b"", lambda _r: [_read_Outer(_r) for _i0 in range(_r.read_len())]), (ctypes.string_at(raw_ptr, raw_len) if raw_ptr else b""), _decode_buffer(ctypes.string_at(first_ptr, first_len) if first_ptr else b"", lambda _r: (_read_Outer(_r) if _r.read_option_flag() else None)), Mode(mode)) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _BatchListener_on_widget_trampoline(ctx, widget, fallback, children_ptr, children_len, out_err): + try: + _impl = _cb_impls[ctx] + _impl.on_widget(Widget._from_ptr(widget), (Widget._from_ptr(fallback) if fallback else None), _decode_buffer(ctypes.string_at(children_ptr, children_len) if children_ptr else b"", lambda _r: [Widget._from_ptr(_r.read_object()) for _i0 in range(_r.read_len())])) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _BatchListener_on_tick_trampoline(ctx, out_err): + try: + _impl = _cb_impls[ctx] + _impl.on_tick() + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _BatchListener_should_continue_trampoline(ctx, class_, out_err): + try: + _impl = _cb_impls[ctx] + _ret = _impl.should_continue(class_) + return 1 if _ret else 0 + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + return 0 + + +def _BatchListener_pick_mode_trampoline(ctx, out_err): + try: + _impl = _cb_impls[ctx] + _ret = _impl.pick_mode() + return int(_ret) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + return 0 + + +def _BatchListener_vtable_free_trampoline(ctx): + # The producer's last reference is gone; it never touches `ctx` again. + _cb_impls.pop(ctx, None) + + +# The one process-wide vtable for BatchListener. The CFUNCTYPE objects are held at +# module scope so their function pointers stay valid for the process lifetime. +_BatchListener_on_batch_cfunc = _BatchListener_on_batch_cfunctype(_BatchListener_on_batch_trampoline) +_BatchListener_on_widget_cfunc = _BatchListener_on_widget_cfunctype(_BatchListener_on_widget_trampoline) +_BatchListener_on_tick_cfunc = _BatchListener_on_tick_cfunctype(_BatchListener_on_tick_trampoline) +_BatchListener_should_continue_cfunc = _BatchListener_should_continue_cfunctype(_BatchListener_should_continue_trampoline) +_BatchListener_pick_mode_cfunc = _BatchListener_pick_mode_cfunctype(_BatchListener_pick_mode_trampoline) +_BatchListener_vtable_free_cfunc = _BatchListener_vtable_free_cfunctype(_BatchListener_vtable_free_trampoline) +_BatchListener_vtable = _BatchListenerVtable(_BatchListener_on_batch_cfunc, _BatchListener_on_widget_cfunc, _BatchListener_on_tick_cfunc, _BatchListener_should_continue_cfunc, _BatchListener_pick_mode_cfunc, _BatchListener_vtable_free_cfunc) + + +class Keyword(abc.ABC): + """ + Callback interface whose name and methods are keywords + + Consumer-implemented callback interface. Subclass it, implement every abstract method, and pass an instance wherever the API takes a `Keyword`; the producer may call the methods from any thread until it releases the instance. An exception raised by a method is reported to the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the call that was in progress. + """ + + @abc.abstractmethod + def delete(self, self: int) -> None: + ... + + @abc.abstractmethod + def import_(self) -> int: + ... + + +_Keyword_delete_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct)) +_Keyword_import_cfunctype = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)) +_Keyword_vtable_free_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class _KeywordVtable(ctypes.Structure): + """The C vtable `weaveffi_edge_cases_Keyword_vtable`.""" + + _fields_ = [ + ("delete", _Keyword_delete_cfunctype), + ("import", _Keyword_import_cfunctype), + ("free", _Keyword_vtable_free_cfunctype), + ] + + +def _Keyword_delete_trampoline(ctx, self, out_err): + try: + _impl = _cb_impls[ctx] + _impl.delete(self) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _Keyword_import_trampoline(ctx, out_err): + try: + _impl = _cb_impls[ctx] + _ret = _impl.import_() + return int(_ret) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + return 0 + + +def _Keyword_vtable_free_trampoline(ctx): + # The producer's last reference is gone; it never touches `ctx` again. + _cb_impls.pop(ctx, None) + + +# The one process-wide vtable for Keyword. The CFUNCTYPE objects are held at +# module scope so their function pointers stay valid for the process lifetime. +_Keyword_delete_cfunc = _Keyword_delete_cfunctype(_Keyword_delete_trampoline) +_Keyword_import_cfunc = _Keyword_import_cfunctype(_Keyword_import_trampoline) +_Keyword_vtable_free_cfunc = _Keyword_vtable_free_cfunctype(_Keyword_vtable_free_trampoline) +_Keyword_vtable = _KeywordVtable(_Keyword_delete_cfunc, _Keyword_import_cfunc, _Keyword_vtable_free_cfunc) + + +_lib.weaveffi_edge_cases_Widget_clone.argtypes = [ctypes.c_void_p] +_lib.weaveffi_edge_cases_Widget_clone.restype = ctypes.c_void_p +_lib.weaveffi_edge_cases_Widget_destroy.argtypes = [ctypes.c_void_p] +_lib.weaveffi_edge_cases_Widget_destroy.restype = None + + class Widget: """Interface with no constructor; instances come from make_widget""" @classmethod def _from_ptr(cls, ptr) -> "Widget": + """Adopt one strong reference the producer handed over.""" _obj = cls.__new__(cls) _obj._ptr = ptr return _obj - def __init__(self, _ptr: int) -> None: - self._ptr = _ptr + def __init__(self) -> None: + raise TypeError("Widget cannot be instantiated directly") + + def _clone_ptr(self): + """A new strong reference to the same object (a raw pointer the + receiver must eventually release), leaving this wrapper's own + reference untouched.""" + return _lib.weaveffi_edge_cases_Widget_clone(_borrow(self)) + + def close(self) -> None: + """Release this wrapper's reference. The object itself is dropped + when its last reference (here or in the producer) is released. + Idempotent; the wrapper is unusable afterwards.""" + _p = getattr(self, "_ptr", None) + self._ptr = None + if _p is not None: + _lib.weaveffi_edge_cases_Widget_destroy(_p) + + def __enter__(self) -> "Widget": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False def __del__(self) -> None: - if self._ptr is not None: - _lib.weaveffi_edge_cases_Widget_destroy.argtypes = [ctypes.c_void_p] - _lib.weaveffi_edge_cases_Widget_destroy.restype = None - _lib.weaveffi_edge_cases_Widget_destroy(self._ptr) - self._ptr = None + self.close() def refresh(self) -> None: """Void method that can fail @@ -774,7 +1100,7 @@ class Widget: _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = None _err = _WeaveFFIErrorStruct() - _fn(self._ptr, ctypes.byref(_err)) + _fn(_borrow(self), ctypes.byref(_err)) _check_edge_errors_error(_err) @@ -790,7 +1116,7 @@ class Widget: _import__w.write_string(import_) _import__buf = _import__w.finish() _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, _string_to_bytes(class_), _import__buf, len(_import__buf), ctypes.byref(_err)) + _result = _fn(_borrow(self), _string_to_bytes(class_), _import__buf, len(_import__buf), ctypes.byref(_err)) _check_error(_err) return _take_string(_result) or "" @@ -840,7 +1166,7 @@ class Widget: _token = _async_register(_cb) # pinned until completion _fn.argtypes = [ctypes.c_void_p, ctypes.c_int64, _cb_type, ctypes.c_void_p] _fn.restype = None - _fn(self._ptr, id, _cb, None) + _fn(_borrow(self), id, _cb, None) return await _fut @@ -851,31 +1177,56 @@ class Widget: _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, ctypes.byref(_err)) + _result = _fn(_borrow(self), ctypes.byref(_err)) _check_error(_err) return _take_string(_result) or "" +_lib.weaveffi_edge_cases_Vault_clone.argtypes = [ctypes.c_void_p] +_lib.weaveffi_edge_cases_Vault_clone.restype = ctypes.c_void_p +_lib.weaveffi_edge_cases_Vault_destroy.argtypes = [ctypes.c_void_p] +_lib.weaveffi_edge_cases_Vault_destroy.restype = None + + class Vault: """Deprecated interface with a throwing constructor""" @classmethod def _from_ptr(cls, ptr) -> "Vault": + """Adopt one strong reference the producer handed over.""" _obj = cls.__new__(cls) _obj._ptr = ptr return _obj - def __init__(self, _ptr: int) -> None: - self._ptr = _ptr + def __init__(self) -> None: + raise TypeError("Vault cannot be instantiated directly") + + + def _clone_ptr(self): + """A new strong reference to the same object (a raw pointer the + receiver must eventually release), leaving this wrapper's own + reference untouched.""" + return _lib.weaveffi_edge_cases_Vault_clone(_borrow(self)) + def close(self) -> None: + """Release this wrapper's reference. The object itself is dropped + when its last reference (here or in the producer) is released. + Idempotent; the wrapper is unusable afterwards.""" + _p = getattr(self, "_ptr", None) + self._ptr = None + if _p is not None: + _lib.weaveffi_edge_cases_Vault_destroy(_p) + + def __enter__(self) -> "Vault": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False def __del__(self) -> None: - if self._ptr is not None: - _lib.weaveffi_edge_cases_Vault_destroy.argtypes = [ctypes.c_void_p] - _lib.weaveffi_edge_cases_Vault_destroy.restype = None - _lib.weaveffi_edge_cases_Vault_destroy(self._ptr) - self._ptr = None + self.close() @classmethod def open(cls, path: str) -> "Vault": @@ -902,7 +1253,7 @@ class Vault: _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, ctypes.byref(_err)) + _result = _fn(_borrow(self), ctypes.byref(_err)) _check_error(_err) if _result is None: return None @@ -928,59 +1279,6 @@ class Vault: -# Callback carrying composite payloads -# Callback type OnBatch: Callable[[List["Outer"], bytes, Optional["Outer"], "Mode"], None] -_CFUNC_weaveffi_edge_cases_OnBatch_fn = ctypes.CFUNCTYPE(None, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_int32, ctypes.c_void_p) - - -# Callback type OnTick: Callable[[], None] -_CFUNC_weaveffi_edge_cases_OnTick_fn = ctypes.CFUNCTYPE(None, ctypes.c_void_p) - - -def register_batch_listener(callback: Callable[[List["Outer"], bytes, Optional["Outer"], "Mode"], None]) -> int: - """Register a OnBatch listener. Returns a subscription id for unregister_batch_listener().""" - def _trampoline(items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, _context): - callback(_decode_buffer(ctypes.string_at(items_ptr, items_len) if items_ptr else b"", lambda _r: [_read_Outer(_r) for _i0 in range(_r.read_len())]), bytes(raw_ptr[:raw_len]) if raw_ptr else b"", _decode_buffer(ctypes.string_at(first_ptr, first_len) if first_ptr else b"", lambda _r: (_read_Outer(_r) if _r.read_option_flag() else None)), Mode(mode)) - _cfunc = _CFUNC_weaveffi_edge_cases_OnBatch_fn(_trampoline) - _fn = _lib.weaveffi_edge_cases_register_batch_listener - _fn.argtypes = [_CFUNC_weaveffi_edge_cases_OnBatch_fn, ctypes.c_void_p] - _fn.restype = ctypes.c_uint64 - _listener_id = int(_fn(_cfunc, None)) - _listener_refs[_listener_id] = _cfunc - return _listener_id - - -def unregister_batch_listener(listener_id: int) -> None: - """Unregister a listener previously registered with register_batch_listener().""" - _fn = _lib.weaveffi_edge_cases_unregister_batch_listener - _fn.argtypes = [ctypes.c_uint64] - _fn.restype = None - _fn(ctypes.c_uint64(listener_id)) - _listener_refs.pop(listener_id, None) - - -def register_tick_listener(callback: Callable[[], None]) -> int: - """Register a OnTick listener. Returns a subscription id for unregister_tick_listener().""" - def _trampoline(_context): - callback() - _cfunc = _CFUNC_weaveffi_edge_cases_OnTick_fn(_trampoline) - _fn = _lib.weaveffi_edge_cases_register_tick_listener - _fn.argtypes = [_CFUNC_weaveffi_edge_cases_OnTick_fn, ctypes.c_void_p] - _fn.restype = ctypes.c_uint64 - _listener_id = int(_fn(_cfunc, None)) - _listener_refs[_listener_id] = _cfunc - return _listener_id - - -def unregister_tick_listener(listener_id: int) -> None: - """Unregister a listener previously registered with register_tick_listener().""" - _fn = _lib.weaveffi_edge_cases_unregister_tick_listener - _fn.argtypes = [ctypes.c_uint64] - _fn.restype = None - _fn(ctypes.c_uint64(listener_id)) - _listener_refs.pop(listener_id, None) - - def noop() -> None: """No parameters and no result """ @@ -1099,18 +1397,166 @@ def optional_params(text: Optional[str], count: Optional[int], outer: Optional[" return _decode_buffer(_data, lambda _r: (Mode(_r.read_i32()) if _r.read_option_flag() else None)) -def mutable_params(text: str, blob: bytes) -> None: - """Write-back parameters +def register_batch(listener: "BatchListener", keyword: "Keyword") -> None: + """Two callback interface parameters on one function """ - _fn = _lib.weaveffi_edge_cases_mutable_params - _fn.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn = _lib.weaveffi_edge_cases_register_batch + _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_BatchListenerVtable), ctypes.c_void_p, ctypes.POINTER(_KeywordVtable), ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = None - _blob_arr = (ctypes.c_uint8 * len(blob))(*blob) + _listener_ctx = _cb_register(listener) + _keyword_ctx = _cb_register(keyword) _err = _WeaveFFIErrorStruct() - _fn(_string_to_bytes(text), _blob_arr, len(blob), ctypes.byref(_err)) + _fn(_listener_ctx, ctypes.byref(_BatchListener_vtable), _keyword_ctx, ctypes.byref(_Keyword_vtable), ctypes.byref(_err)) _check_error(_err) +def widgets_everywhere(list: List["Widget"], by_name: Dict[str, "Widget"], sparse: List[Optional["Widget"]], holder: "WidgetHolder") -> "WidgetHolder": + """Objects in every buffered position + """ + _fn = _lib.weaveffi_edge_cases_widgets_everywhere + _fn.argtypes = [ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p + _list_w = _BufferWriter() + _list_w.write_len(len(list)) + for _e0 in list: + _list_w.write_object(_e0) + _list_buf = _list_w.finish() + _by_name_w = _BufferWriter() + _by_name_w.write_len(len(by_name)) + for _k0, _v0 in by_name.items(): + _by_name_w.write_string(_k0) + _by_name_w.write_object(_v0) + _by_name_buf = _by_name_w.finish() + _sparse_w = _BufferWriter() + _sparse_w.write_len(len(sparse)) + for _e0 in sparse: + if _e0 is None: + _sparse_w.write_option_flag(False) + else: + _sparse_w.write_option_flag(True) + _sparse_w.write_object(_e0) + _sparse_buf = _sparse_w.finish() + _holder_buf = _pack_WidgetHolder(holder) + _err = _WeaveFFIErrorStruct() + _out_len = ctypes.c_size_t(0) + _result = _fn(_list_buf, len(_list_buf), _by_name_buf, len(_by_name_buf), _sparse_buf, len(_sparse_buf), _holder_buf, len(_holder_buf), ctypes.byref(_out_len), ctypes.byref(_err)) + _check_error(_err) + _data = _take_buffer(_result, _out_len.value) + return _unpack_WidgetHolder(_data) + + +class _WidgetStreamIterator: + """Lazy iterator over a producer stream: each step pulls one element + across the C boundary. The native handle is released exactly once, on + exhaustion, on close(), or when the iterator is garbage collected.""" + + def __init__(self, ptr): + self._ptr = ptr + self._done = False + + def __iter__(self): + return self + + def __next__(self): + if self._done: + raise StopIteration + _next_fn = _lib.weaveffi_edge_cases_WidgetStreamIterator_next + _next_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(_WeaveFFIErrorStruct)] + _next_fn.restype = ctypes.c_int32 + _out_item = ctypes.c_void_p() + _err = _WeaveFFIErrorStruct() + _has = _next_fn(self._ptr, ctypes.byref(_out_item), ctypes.byref(_err)) + _check_error(_err) + if not _has: + self._done = True + self._destroy() + raise StopIteration + return Widget._from_ptr(_out_item.value) + + def close(self): + """Release the native iterator without draining it.""" + self._done = True + self._destroy() + + def _destroy(self): + if self._ptr is not None: + _destroy_fn = _lib.weaveffi_edge_cases_WidgetStreamIterator_destroy + _destroy_fn.argtypes = [ctypes.c_void_p] + _destroy_fn.restype = None + _destroy_fn(self._ptr) + self._ptr = None + + def __del__(self): + self._destroy() + + +def widget_stream() -> Iterator["Widget"]: + """ + Iterator of objects + + Returns a lazy iterator: each step pulls one element from the producer. Exhaust or close() the iterator to release its native handle (garbage collection also releases it). + """ + _fn = _lib.weaveffi_edge_cases_widget_stream + _fn.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p + _err = _WeaveFFIErrorStruct() + _result = _fn(ctypes.byref(_err)) + _check_error(_err) + return _WidgetStreamIterator(_result) + + +async def async_widget() -> "Widget": + """Async returning an object + + Raises + ------ + EdgeErrorsError + If the call reports one of the domain's error codes. + """ + _fn = _lib.weaveffi_edge_cases_async_widget_async + _loop = asyncio.get_running_loop() + _fut = _loop.create_future() + + def _cb_impl(context, err, result): + # Fires exactly once, on a producer thread: take ownership of + # the result here, then hop back to the event loop. + _state = {"err": None, "val": None} + if err and err.contents.code != 0: + _code = err.contents.code + _msg = err.contents.message.decode("utf-8") if err.contents.message else "" + _payload = ctypes.string_at(err.contents.payload_ptr, err.contents.payload_len) if err.contents.payload_ptr else b"" + _lib.weaveffi_error_free(err) + _state["err"] = _edge_errors_error_from(_code, _msg, _payload) + else: + try: + if result is None: + _state["err"] = WeaveFFIError(-1, "null pointer") + else: + _state["val"] = Widget._from_ptr(result) + except Exception as _exc: + _state["err"] = _exc + + def _resolve(): + _async_pending.pop(_token, None) + # A cancelled future must not be resolved. + if _fut.cancelled(): + return + if _state["err"] is not None: + _fut.set_exception(_state["err"]) + else: + _fut.set_result(_state["val"]) + + _loop.call_soon_threadsafe(_resolve) + + _cb_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct), ctypes.c_void_p) + _cb = _cb_type(_cb_impl) + _token = _async_register(_cb) # pinned until completion + _fn.argtypes = [_cb_type, ctypes.c_void_p] + _fn.restype = None + _fn(_cb, None) + return await _fut + + def make_widget(label: str) -> "Widget": """Factory for an interface with no constructor """ @@ -1151,21 +1597,11 @@ def inspect_widget(widget: "Widget", fallback: Optional["Widget"]) -> str: _fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(widget._ptr, fallback._ptr if fallback is not None else None, ctypes.byref(_err)) + _result = _fn(_borrow(widget), (_borrow(fallback) if fallback is not None else None), ctypes.byref(_err)) _check_error(_err) return _take_string(_result) or "" -def typed_handle_param(token: int, raw: int) -> int: - _fn = _lib.weaveffi_edge_cases_typed_handle_param - _fn.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_void_p - _err = _WeaveFFIErrorStruct() - _result = _fn(token, raw, ctypes.byref(_err)) - _check_error(_err) - return _result or 0 - - class _StringStreamIterator: """Lazy iterator over a producer stream: each step pulls one element across the C boundary. The native handle is released exactly once, on diff --git a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_pyi.snap b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_pyi.snap index d3f7cc79..a91ed3c9 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_pyi.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_edge_cases__weaveffi_weaveffi_pyi.snap @@ -5,10 +5,15 @@ source: crates/weaveffi-cli/tests/snapshots.rs # DO NOT EDIT. Your changes will be overwritten. # To regenerate: weaveffi generate weaveffi.yml -o +from abc import ABC, abstractmethod from enum import IntEnum from typing import Callable, Dict, Iterator, List, Optional, Type class WeaveFFIError(Exception): + GENERIC_ERROR_CODE: int + PANIC_ERROR_CODE: int + MARSHAL_ERROR_CODE: int + FOREIGN_ERROR_CODE: int code: int message: str def __init__(self, code: int, message: str) -> None: ... @@ -123,8 +128,43 @@ class Retired: id: int def __init__(self, id: int) -> None: ... +# Record carrying objects in every buffered position +class WidgetHolder: + primary: "Widget" + fallback: Optional["Widget"] + extras: List["Widget"] + by_name: Dict[str, "Widget"] + def __init__(self, primary: "Widget", fallback: Optional["Widget"], extras: List["Widget"], by_name: Dict[str, "Widget"]) -> None: ... + +# Callback interface carrying composite payloads and objects +class BatchListener(ABC): + # Composite payloads cross as value buffers + @abstractmethod + def on_batch(self, items: List["Outer"], raw: bytes, first: Optional["Outer"], mode: "Mode") -> None: ... + # Objects cross as strong references the consumer must release + @abstractmethod + def on_widget(self, widget: "Widget", fallback: Optional["Widget"], children: List["Widget"]) -> None: ... + @abstractmethod + def on_tick(self) -> None: ... + # Scalar return travels back through the vtable + @abstractmethod + def should_continue(self, class_: int) -> bool: ... + # Enum return travels back as its integer discriminant + @abstractmethod + def pick_mode(self) -> "Mode": ... + +# Callback interface whose name and methods are keywords +class Keyword(ABC): + @abstractmethod + def delete(self, self: int) -> None: ... + @abstractmethod + def import_(self) -> int: ... + # Interface with no constructor; instances come from make_widget class Widget: + def close(self) -> None: ... + def __enter__(self) -> "Widget": ... + def __exit__(self, *exc: object) -> bool: ... def refresh(self) -> None: ... def rename(self, class_: str, import_: Optional[str]) -> str: ... async def fetch(self, id: int) -> "Outer": ... @@ -132,18 +172,15 @@ class Widget: # Deprecated interface with a throwing constructor class Vault: + def close(self) -> None: ... + def __enter__(self) -> "Vault": ... + def __exit__(self, *exc: object) -> bool: ... @classmethod def open(cls, path: str) -> "Vault": ... def peek(self) -> Optional["Widget"]: ... @staticmethod def probe() -> bool: ... -def register_batch_listener(callback: Callable[[List["Outer"], bytes, Optional["Outer"], "Mode"], None]) -> int: ... -def unregister_batch_listener(listener_id: int) -> None: ... - -def register_tick_listener(callback: Callable[[], None]) -> int: ... -def unregister_tick_listener(listener_id: int) -> None: ... - # No parameters and no result def noop() -> None: ... @@ -155,8 +192,17 @@ def nested_optional(items: Optional[List[Optional["Outer"]]]) -> Optional[List[O def optional_params(text: Optional[str], count: Optional[int], outer: Optional["Outer"], tags: Optional[List[str]], blob: Optional[bytes], mode: Optional["Mode"]) -> Optional["Mode"]: ... -# Write-back parameters -def mutable_params(text: str, blob: bytes) -> None: ... +# Two callback interface parameters on one function +def register_batch(listener: "BatchListener", keyword: "Keyword") -> None: ... + +# Objects in every buffered position +def widgets_everywhere(list: List["Widget"], by_name: Dict[str, "Widget"], sparse: List[Optional["Widget"]], holder: "WidgetHolder") -> "WidgetHolder": ... + +# Iterator of objects +def widget_stream() -> Iterator["Widget"]: ... + +# Async returning an object +async def async_widget() -> "Widget": ... # Factory for an interface with no constructor def make_widget(label: str) -> "Widget": ... @@ -167,8 +213,6 @@ def find_widget(id: int) -> Optional["Widget"]: ... # Interface parameter, including an optional one def inspect_widget(widget: "Widget", fallback: Optional["Widget"]) -> str: ... -def typed_handle_param(token: int, raw: int) -> int: ... - # Iterator of strings def string_stream() -> Iterator[str]: ... diff --git a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__pyproject_toml.snap b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__pyproject_toml.snap index c07695fa..244fb84d 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__pyproject_toml.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__pyproject_toml.snap @@ -18,4 +18,7 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["weaveffi"] +[tool.setuptools.package-data] +"weaveffi" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + # END pyproject.toml diff --git a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_py_typed.snap b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_py_typed.snap new file mode 100644 index 00000000..501a8090 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_py_typed.snap @@ -0,0 +1,6 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o diff --git a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_py.snap b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_py.snap index 4c137ce8..71ecd2a7 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_py.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_py.snap @@ -17,6 +17,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -53,7 +66,7 @@ _lib = _load_library() # The ABI revision these bindings were generated against. Checked before any # other symbol is bound so a mismatched producer fails at import time instead # of misreading the error struct or a value buffer later. -_ABI_VERSION = 1 +_ABI_VERSION = 2 def _check_abi_version(lib: ctypes.CDLL) -> None: @@ -75,6 +88,14 @@ def _check_abi_version(lib: ctypes.CDLL) -> None: _check_abi_version(_lib) +# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None _lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; @@ -130,14 +151,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -188,6 +225,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -268,6 +313,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") @@ -290,6 +343,7 @@ def _take_buffer(ptr, length) -> bytes: _lib.weaveffi_free_bytes(ptr, ctypes.c_size_t(length)) return _data +import abc import asyncio import threading @@ -312,10 +366,23 @@ def _async_register(cb) -> int: return _token -# Registered listener trampolines, keyed by subscription id. Holding -# the ctypes function objects here keeps them alive until unregistered; -# without this the GC could collect a trampoline the producer still calls. -_listener_refs: Dict[int, object] = {} +# Live callback-interface implementations, keyed by the integer the producer +# receives as `ctx`. An entry is added when an implementation is passed to a +# call and removed when the producer invokes the vtable's `free(ctx)`, so the +# implementation stays alive exactly as long as the producer may call it and +# the producer never holds a raw reference to a Python object. +_cb_impls: Dict[int, object] = {} +_cb_lock = threading.Lock() +_cb_next_ctx = 0 + + +def _cb_register(impl) -> int: + global _cb_next_ctx + with _cb_lock: + _cb_next_ctx += 1 + _ctx = _cb_next_ctx + _cb_impls[_ctx] = impl + return _ctx # === Module: shared === @@ -447,6 +514,8 @@ class Item: # Cross-module struct reference token: "Token" priority: "Priority" + # An optional object inside a record + gadget: Optional["Gadget"] def _write_Item(_w: _BufferWriter, value: "Item") -> None: @@ -471,6 +540,11 @@ def _write_Item(_w: _BufferWriter, value: "Item") -> None: _w.write_i64(value.parent) _write_Token(_w, value.token) _w.write_i32(value.priority) + if value.gadget is None: + _w.write_option_flag(False) + else: + _w.write_option_flag(True) + _w.write_object(value.gadget) def _read_Item(_r: _BufferReader) -> "Item": @@ -487,6 +561,7 @@ def _read_Item(_r: _BufferReader) -> "Item": parent=(_r.read_i64() if _r.read_option_flag() else None), token=_read_Token(_r), priority=Priority(_r.read_i32()), + gadget=(Gadget._from_ptr(_r.read_object()) if _r.read_option_flag() else None), ) @@ -500,11 +575,86 @@ def _unpack_Item(data: bytes) -> "Item": return _decode_buffer(data, _read_Item) +class ReadyListener(abc.ABC): + """ + Consumer-implemented observer of ready items + + Consumer-implemented callback interface. Subclass it, implement every abstract method, and pass an instance wherever the API takes a `ReadyListener`; the producer may call the methods from any thread until it releases the instance. An exception raised by a method is reported to the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the call that was in progress. + """ + + @abc.abstractmethod + def on_ready(self, code: int, msg: str) -> None: + """Fires when an item is ready + """ + + @abc.abstractmethod + def on_item(self, item: "Item", gadget: "Gadget") -> bool: + """Receives the item itself and says whether to keep listening + """ + + +_ReadyListener_on_ready_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_int32, ctypes.c_char_p, ctypes.POINTER(_WeaveFFIErrorStruct)) +_ReadyListener_on_item_cfunctype = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)) +_ReadyListener_vtable_free_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) + + +class _ReadyListenerVtable(ctypes.Structure): + """The C vtable `weaveffi_kitchen_ReadyListener_vtable`.""" + + _fields_ = [ + ("on_ready", _ReadyListener_on_ready_cfunctype), + ("on_item", _ReadyListener_on_item_cfunctype), + ("free", _ReadyListener_vtable_free_cfunctype), + ] + + +def _ReadyListener_on_ready_trampoline(ctx, code, msg, out_err): + try: + _impl = _cb_impls[ctx] + _impl.on_ready(code, _bytes_to_string(msg)) + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + + +def _ReadyListener_on_item_trampoline(ctx, item_ptr, item_len, gadget, out_err): + try: + _impl = _cb_impls[ctx] + _ret = _impl.on_item(_unpack_Item(ctypes.string_at(item_ptr, item_len) if item_ptr else b""), Gadget._from_ptr(gadget)) + return 1 if _ret else 0 + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + return 0 + + +def _ReadyListener_vtable_free_trampoline(ctx): + # The producer's last reference is gone; it never touches `ctx` again. + _cb_impls.pop(ctx, None) + + +# The one process-wide vtable for ReadyListener. The CFUNCTYPE objects are held at +# module scope so their function pointers stay valid for the process lifetime. +_ReadyListener_on_ready_cfunc = _ReadyListener_on_ready_cfunctype(_ReadyListener_on_ready_trampoline) +_ReadyListener_on_item_cfunc = _ReadyListener_on_item_cfunctype(_ReadyListener_on_item_trampoline) +_ReadyListener_vtable_free_cfunc = _ReadyListener_vtable_free_cfunctype(_ReadyListener_vtable_free_trampoline) +_ReadyListener_vtable = _ReadyListenerVtable(_ReadyListener_on_ready_cfunc, _ReadyListener_on_item_cfunc, _ReadyListener_vtable_free_cfunc) + + +_lib.weaveffi_kitchen_Gadget_clone.argtypes = [ctypes.c_void_p] +_lib.weaveffi_kitchen_Gadget_clone.restype = ctypes.c_void_p +_lib.weaveffi_kitchen_Gadget_destroy.argtypes = [ctypes.c_void_p] +_lib.weaveffi_kitchen_Gadget_destroy.restype = None + + class Gadget: """A pocket-sized interface exercising the object surface""" @classmethod def _from_ptr(cls, ptr) -> "Gadget": + """Adopt one strong reference the producer handed over.""" _obj = cls.__new__(cls) _obj._ptr = ptr return _obj @@ -524,12 +674,30 @@ class Gadget: self._ptr = _result + def _clone_ptr(self): + """A new strong reference to the same object (a raw pointer the + receiver must eventually release), leaving this wrapper's own + reference untouched.""" + return _lib.weaveffi_kitchen_Gadget_clone(_borrow(self)) + + def close(self) -> None: + """Release this wrapper's reference. The object itself is dropped + when its last reference (here or in the producer) is released. + Idempotent; the wrapper is unusable afterwards.""" + _p = getattr(self, "_ptr", None) + self._ptr = None + if _p is not None: + _lib.weaveffi_kitchen_Gadget_destroy(_p) + + def __enter__(self) -> "Gadget": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False + def __del__(self) -> None: - if self._ptr is not None: - _lib.weaveffi_kitchen_Gadget_destroy.argtypes = [ctypes.c_void_p] - _lib.weaveffi_kitchen_Gadget_destroy.restype = None - _lib.weaveffi_kitchen_Gadget_destroy(self._ptr) - self._ptr = None + self.close() def describe(self) -> str: """Render the gadget as a human-readable string @@ -538,7 +706,7 @@ class Gadget: _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, ctypes.byref(_err)) + _result = _fn(_borrow(self), ctypes.byref(_err)) _check_error(_err) return _take_string(_result) or "" @@ -555,7 +723,7 @@ class Gadget: _fn.argtypes = [ctypes.c_void_p, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_int32 _err = _WeaveFFIErrorStruct() - _result = _fn(self._ptr, times, ctypes.byref(_err)) + _result = _fn(_borrow(self), times, ctypes.byref(_err)) _check_kitchen_errors_error(_err) return _result @@ -574,37 +742,6 @@ class Gadget: -# Fires when an item is ready -# Callback type OnReady: Callable[[int, str], None] -_CFUNC_weaveffi_kitchen_OnReady_fn = ctypes.CFUNCTYPE(None, ctypes.c_int32, ctypes.c_char_p, ctypes.c_void_p) - - -def register_ready_listener(callback: Callable[[int, str], None]) -> int: - """ - Subscribe to OnReady events - - Returns a subscription id for unregister_ready_listener(). - """ - def _trampoline(code, msg, _context): - callback(code, _bytes_to_string(msg)) - _cfunc = _CFUNC_weaveffi_kitchen_OnReady_fn(_trampoline) - _fn = _lib.weaveffi_kitchen_register_ready_listener - _fn.argtypes = [_CFUNC_weaveffi_kitchen_OnReady_fn, ctypes.c_void_p] - _fn.restype = ctypes.c_uint64 - _listener_id = int(_fn(_cfunc, None)) - _listener_refs[_listener_id] = _cfunc - return _listener_id - - -def unregister_ready_listener(listener_id: int) -> None: - """Unregister a listener previously registered with register_ready_listener().""" - _fn = _lib.weaveffi_kitchen_unregister_ready_listener - _fn.argtypes = [ctypes.c_uint64] - _fn.restype = None - _fn(ctypes.c_uint64(listener_id)) - _listener_refs.pop(listener_id, None) - - def bool_id(x: bool) -> bool: """Identity for bool """ @@ -683,58 +820,105 @@ def echo_bytes(b: bytes) -> bytes: return _val -def echo_borrowed_str(s: str) -> str: - """Borrowed string parameter +def subscribe(listener: "ReadyListener") -> int: + """Register a listener, returning how many are attached """ - _fn = _lib.weaveffi_kitchen_echo_borrowed_str - _fn.argtypes = [ctypes.c_char_p, ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_void_p + _fn = _lib.weaveffi_kitchen_subscribe + _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_ReadyListenerVtable), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_int32 + _listener_ctx = _cb_register(listener) _err = _WeaveFFIErrorStruct() - _result = _fn(_string_to_bytes(s), ctypes.byref(_err)) + _result = _fn(_listener_ctx, ctypes.byref(_ReadyListener_vtable), ctypes.byref(_err)) _check_error(_err) - return _take_string(_result) or "" + return _result -def echo_borrowed_bytes(b: bytes) -> bytes: - """Borrowed bytes parameter +def maybe_gadget(fallback: Optional["Gadget"]) -> Optional["Gadget"]: + """Optional object in both directions """ - _fn = _lib.weaveffi_kitchen_echo_borrowed_bytes - _fn.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.POINTER(ctypes.c_uint8) - _b_arr = (ctypes.c_uint8 * len(b))(*b) + _fn = _lib.weaveffi_kitchen_maybe_gadget + _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _out_len = ctypes.c_size_t(0) - _result = _fn(_b_arr, len(b), ctypes.byref(_out_len), ctypes.byref(_err)) + _result = _fn((_borrow(fallback) if fallback is not None else None), ctypes.byref(_err)) _check_error(_err) - if not _result: - return b"" - _val = bytes(_result[:_out_len.value]) - _lib.weaveffi_free_bytes(_result, ctypes.c_size_t(_out_len.value)) - return _val + if _result is None: + return None + return Gadget._from_ptr(_result) -def open_handle() -> int: - """Returns an opaque handle +def gadgets() -> List["Gadget"]: + """A list of objects """ - _fn = _lib.weaveffi_kitchen_open_handle - _fn.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_uint64 + _fn = _lib.weaveffi_kitchen_gadgets + _fn.argtypes = [ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(ctypes.byref(_err)) + _out_len = ctypes.c_size_t(0) + _result = _fn(ctypes.byref(_out_len), ctypes.byref(_err)) _check_error(_err) - return _result + _data = _take_buffer(_result, _out_len.value) + return _decode_buffer(_data, lambda _r: [Gadget._from_ptr(_r.read_object()) for _i0 in range(_r.read_len())]) + + +class _StreamGadgetsIterator: + """Lazy iterator over a producer stream: each step pulls one element + across the C boundary. The native handle is released exactly once, on + exhaustion, on close(), or when the iterator is garbage collected.""" + + def __init__(self, ptr): + self._ptr = ptr + self._done = False + def __iter__(self): + return self -def open_typed_handle() -> int: - """Returns a typed handle + def __next__(self): + if self._done: + raise StopIteration + _next_fn = _lib.weaveffi_kitchen_StreamGadgetsIterator_next + _next_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(_WeaveFFIErrorStruct)] + _next_fn.restype = ctypes.c_int32 + _out_item = ctypes.c_void_p() + _err = _WeaveFFIErrorStruct() + _has = _next_fn(self._ptr, ctypes.byref(_out_item), ctypes.byref(_err)) + _check_error(_err) + if not _has: + self._done = True + self._destroy() + raise StopIteration + return Gadget._from_ptr(_out_item.value) + + def close(self): + """Release the native iterator without draining it.""" + self._done = True + self._destroy() + + def _destroy(self): + if self._ptr is not None: + _destroy_fn = _lib.weaveffi_kitchen_StreamGadgetsIterator_destroy + _destroy_fn.argtypes = [ctypes.c_void_p] + _destroy_fn.restype = None + _destroy_fn(self._ptr) + self._ptr = None + + def __del__(self): + self._destroy() + + +def stream_gadgets() -> Iterator["Gadget"]: + """ + An iterator over objects + + Returns a lazy iterator: each step pulls one element from the producer. Exhaust or close() the iterator to release its native handle (garbage collection also releases it). """ - _fn = _lib.weaveffi_kitchen_open_typed_handle + _fn = _lib.weaveffi_kitchen_stream_gadgets _fn.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() _result = _fn(ctypes.byref(_err)) _check_error(_err) - return _result or 0 + return _StreamGadgetsIterator(_result) def maybe_item(id: int) -> Optional["Item"]: diff --git a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_pyi.snap b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_pyi.snap index 0c455e28..e3eedfaf 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_pyi.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_kitchen_sink__weaveffi_weaveffi_pyi.snap @@ -5,10 +5,15 @@ source: crates/weaveffi-cli/tests/snapshots.rs # DO NOT EDIT. Your changes will be overwritten. # To regenerate: weaveffi generate weaveffi.yml -o +from abc import ABC, abstractmethod from enum import IntEnum from typing import Callable, Dict, Iterator, List, Optional, Type class WeaveFFIError(Exception): + GENERIC_ERROR_CODE: int + PANIC_ERROR_CODE: int + MARSHAL_ERROR_CODE: int + FOREIGN_ERROR_CODE: int code: int message: str def __init__(self, code: int, message: str) -> None: ... @@ -64,20 +69,30 @@ class Item: # Cross-module struct reference token: "Token" priority: "Priority" - def __init__(self, id: int, name: str, count: int, enabled: bool, ratio: float, nick: int, payload: bytes, tags: List[str], attrs: Dict[str, str], parent: Optional[int], token: "Token", priority: "Priority") -> None: ... + # An optional object inside a record + gadget: Optional["Gadget"] + def __init__(self, id: int, name: str, count: int, enabled: bool, ratio: float, nick: int, payload: bytes, tags: List[str], attrs: Dict[str, str], parent: Optional[int], token: "Token", priority: "Priority", gadget: Optional["Gadget"]) -> None: ... + +# Consumer-implemented observer of ready items +class ReadyListener(ABC): + # Fires when an item is ready + @abstractmethod + def on_ready(self, code: int, msg: str) -> None: ... + # Receives the item itself and says whether to keep listening + @abstractmethod + def on_item(self, item: "Item", gadget: "Gadget") -> bool: ... # A pocket-sized interface exercising the object surface class Gadget: def __init__(self, id: int) -> None: ... + def close(self) -> None: ... + def __enter__(self) -> "Gadget": ... + def __exit__(self, *exc: object) -> bool: ... def describe(self) -> str: ... def poke(self, times: int) -> int: ... @staticmethod def version() -> str: ... -# Subscribe to OnReady events -def register_ready_listener(callback: Callable[[int, str], None]) -> int: ... -def unregister_ready_listener(listener_id: int) -> None: ... - # Identity for bool def bool_id(x: bool) -> bool: ... @@ -93,17 +108,17 @@ def echo_string(s: str) -> str: ... def echo_bytes(b: bytes) -> bytes: ... -# Borrowed string parameter -def echo_borrowed_str(s: str) -> str: ... +# Register a listener, returning how many are attached +def subscribe(listener: "ReadyListener") -> int: ... -# Borrowed bytes parameter -def echo_borrowed_bytes(b: bytes) -> bytes: ... +# Optional object in both directions +def maybe_gadget(fallback: Optional["Gadget"]) -> Optional["Gadget"]: ... -# Returns an opaque handle -def open_handle() -> int: ... +# A list of objects +def gadgets() -> List["Gadget"]: ... -# Returns a typed handle -def open_typed_handle() -> int: ... +# An iterator over objects +def stream_gadgets() -> Iterator["Gadget"]: ... # Optional struct return def maybe_item(id: int) -> Optional["Item"]: ... diff --git a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__pyproject_toml.snap b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__pyproject_toml.snap index c07695fa..244fb84d 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__pyproject_toml.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__pyproject_toml.snap @@ -18,4 +18,7 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["weaveffi"] +[tool.setuptools.package-data] +"weaveffi" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + # END pyproject.toml diff --git a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_py_typed.snap b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_py_typed.snap new file mode 100644 index 00000000..501a8090 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_py_typed.snap @@ -0,0 +1,6 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o diff --git a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_py.snap b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_py.snap index 65925b23..2e1886fe 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_py.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_py.snap @@ -17,6 +17,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -53,7 +66,7 @@ _lib = _load_library() # The ABI revision these bindings were generated against. Checked before any # other symbol is bound so a mismatched producer fails at import time instead # of misreading the error struct or a value buffer later. -_ABI_VERSION = 1 +_ABI_VERSION = 2 def _check_abi_version(lib: ctypes.CDLL) -> None: @@ -75,6 +88,14 @@ def _check_abi_version(lib: ctypes.CDLL) -> None: _check_abi_version(_lib) +# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None _lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; @@ -130,14 +151,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -188,6 +225,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -268,6 +313,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_pyi.snap b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_pyi.snap index ad031748..2c4c152c 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_pyi.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_nested_modules__weaveffi_weaveffi_pyi.snap @@ -5,10 +5,15 @@ source: crates/weaveffi-cli/tests/snapshots.rs # DO NOT EDIT. Your changes will be overwritten. # To regenerate: weaveffi generate weaveffi.yml -o +from abc import ABC, abstractmethod from enum import IntEnum from typing import Callable, Dict, Iterator, List, Optional, Type class WeaveFFIError(Exception): + GENERIC_ERROR_CODE: int + PANIC_ERROR_CODE: int + MARSHAL_ERROR_CODE: int + FOREIGN_ERROR_CODE: int code: int message: str def __init__(self, code: int, message: str) -> None: ... diff --git a/crates/weaveffi-cli/tests/snapshots/python_shapes__pyproject_toml.snap b/crates/weaveffi-cli/tests/snapshots/python_shapes__pyproject_toml.snap index c07695fa..244fb84d 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_shapes__pyproject_toml.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_shapes__pyproject_toml.snap @@ -18,4 +18,7 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["weaveffi"] +[tool.setuptools.package-data] +"weaveffi" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + # END pyproject.toml diff --git a/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_py_typed.snap b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_py_typed.snap new file mode 100644 index 00000000..501a8090 --- /dev/null +++ b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_py_typed.snap @@ -0,0 +1,6 @@ +--- +source: crates/weaveffi-cli/tests/snapshots.rs +--- +# Generated by WeaveFFI [VERSION] from weaveffi.yml +# DO NOT EDIT. Your changes will be overwritten. +# To regenerate: weaveffi generate weaveffi.yml -o diff --git a/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_py.snap b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_py.snap index 00b70ead..8c65ea50 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_py.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_py.snap @@ -17,6 +17,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -53,7 +66,7 @@ _lib = _load_library() # The ABI revision these bindings were generated against. Checked before any # other symbol is bound so a mismatched producer fails at import time instead # of misreading the error struct or a value buffer later. -_ABI_VERSION = 1 +_ABI_VERSION = 2 def _check_abi_version(lib: ctypes.CDLL) -> None: @@ -75,6 +88,14 @@ def _check_abi_version(lib: ctypes.CDLL) -> None: _check_abi_version(_lib) +# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None _lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; @@ -130,14 +151,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -188,6 +225,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -268,6 +313,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") diff --git a/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_pyi.snap b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_pyi.snap index 5b6ad464..bdf758b2 100644 --- a/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_pyi.snap +++ b/crates/weaveffi-cli/tests/snapshots/python_shapes__weaveffi_weaveffi_pyi.snap @@ -5,10 +5,15 @@ source: crates/weaveffi-cli/tests/snapshots.rs # DO NOT EDIT. Your changes will be overwritten. # To regenerate: weaveffi generate weaveffi.yml -o +from abc import ABC, abstractmethod from enum import IntEnum from typing import Callable, Dict, Iterator, List, Optional, Type class WeaveFFIError(Exception): + GENERIC_ERROR_CODE: int + PANIC_ERROR_CODE: int + MARSHAL_ERROR_CODE: int + FOREIGN_ERROR_CODE: int code: int message: str def __init__(self, code: int, message: str) -> None: ... diff --git a/crates/weaveffi-cli/tests/snapshots/ruby_docs_everywhere__lib_weaveffi_rb.snap b/crates/weaveffi-cli/tests/snapshots/ruby_docs_everywhere__lib_weaveffi_rb.snap index 905ee6a5..9ad65da5 100644 --- a/crates/weaveffi-cli/tests/snapshots/ruby_docs_everywhere__lib_weaveffi_rb.snap +++ b/crates/weaveffi-cli/tests/snapshots/ruby_docs_everywhere__lib_weaveffi_rb.snap @@ -32,7 +32,7 @@ module WeaveFFI # The ABI revision these bindings were generated against. Checked before any # other symbol is attached so a mismatched producer fails at require time # instead of misreading the error struct or a value buffer later. - ABI_VERSION = 1 + ABI_VERSION = 2 begin attach_function :weaveffi_abi_version, [], :uint32 rescue FFI::NotFoundError @@ -52,6 +52,16 @@ module WeaveFFI :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -225,12 +235,21 @@ module WeaveFFI take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') end end + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -240,15 +259,51 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end - # Registered listener trampolines, keyed by subscription id. Holding the - # FFI::Function objects here keeps them alive until unregistered; without - # this the GC could collect a trampoline the producer still calls. - @listener_refs = {} + # Live callback-interface implementations keyed by the Integer the producer + # holds as `ctx`. Keys start at 1 so a context pointer is never NULL. + @wv_cb_registry = {} + @wv_cb_next_key = 0 + @wv_cb_mutex = Mutex.new + + # @api private + # Registers `impl` and returns the `ctx` pointer to hand the producer. + def self._wv_cb_register(impl) + @wv_cb_mutex.synchronize do + @wv_cb_next_key += 1 + @wv_cb_registry[@wv_cb_next_key] = impl + FFI::Pointer.new(@wv_cb_next_key) + end + end + + # @api private + # Resolves a trampoline's `ctx` back to the registered implementation. + def self._wv_cb_lookup(ctx) + impl = @wv_cb_mutex.synchronize { @wv_cb_registry[ctx.address] } + raise Error.new(FOREIGN_ERROR_CODE, 'callback context is not registered') if impl.nil? + impl + end + + # @api private + # Drops the registry entry when the producer releases its last reference. + def self._wv_cb_free(ctx) + @wv_cb_mutex.synchronize { @wv_cb_registry.delete(ctx.address) } + nil + end + + # @api private + # Reports a callback implementation's exception through `out_err`. The + # producer copies the message, so the Ruby String need not outlive the call. + def self._wv_cb_fail(out_err, exception) + message = exception.message.to_s + message = exception.class.name if message.empty? + weaveffi_error_set(out_err, FOREIGN_ERROR_CODE, message) + nil + end # === Module: docs === @@ -281,7 +336,7 @@ module WeaveFFI # Builds the DocsErrorsError subclass matching `code`, decoding any payload # fields the code declares, or a generic Error for codes outside - # the domain (panics, marshalling). + # the domain (panics, marshalling, callback failures). def self.docs_errors_error_from(code, message, payload = nil) cls = DOCS_ERRORS_ERROR_CODES[code] return Error.new(code, message) if cls.nil? @@ -293,7 +348,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) payload_ptr = err[:payload_ptr] payload = payload_ptr.null? ? nil : payload_ptr.read_string(err[:payload_len]) weaveffi_error_clear(err.to_ptr) @@ -371,6 +426,7 @@ module WeaveFFI Document.new(id: _wv_id, title: _wv_title, status: _wv_status, tags: _wv_tags) end + attach_function :weaveffi_docs_Notebook_clone, [:pointer], :pointer attach_function :weaveffi_docs_Notebook_destroy, [:pointer], :void # Create an empty notebook attach_function :weaveffi_docs_Notebook_new, [:pointer], :pointer @@ -382,10 +438,6 @@ module WeaveFFI attach_function :weaveffi_docs_Notebook_size, [:pointer, :pointer], :int32 # The largest number of documents one notebook will hold attach_function :weaveffi_docs_Notebook_max_documents, [:pointer], :int32 - # Fires after a document has been persisted successfully - callback :weaveffi_docs_OnSaved_fn, [:int64, :int32, :pointer], :void - attach_function :weaveffi_docs_register_saved_listener, [:weaveffi_docs_OnSaved_fn, :pointer], :uint64 - attach_function :weaveffi_docs_unregister_saved_listener, [:uint64], :void # Create a brand new document. # # The document starts in `Draft` status; callers must publish @@ -396,7 +448,63 @@ module WeaveFFI # Publish a document in the background callback :weaveffi_docs_publish_async_callback, [:pointer, :pointer, :int32], :void attach_function :weaveffi_docs_publish_async_async, [:int64, :weaveffi_docs_publish_async_callback, :pointer], :void + # Register a listener for saved notifications + attach_function :weaveffi_docs_watch, [:pointer, :pointer, :pointer], :void + # Consumer-implemented observer of document-saved notifications. + # + # Implementations must be safe to call from any thread. + # Consumer-implemented callback interface. Any object responding to the + # methods below is accepted wherever a SavedListener parameter is expected; + # include this module to inherit NotImplementedError defaults. The + # producer may call the methods from any thread until it releases the + # implementation. + module SavedListener + # Fires after a document has been persisted successfully + # @param doc_id [Object] Identifier of the saved document + # @param revision [Object] Monotonically increasing revision + def on_saved(doc_id, revision) + raise NotImplementedError, "#{self.class}#on_saved is not implemented" + end + end + + # @api private + # The C vtable layout the producer calls a SavedListener through. + class WvSavedListenerVtable < FFI::Struct + layout :on_saved, :pointer, + :free, :pointer + end + + # @api private + # Trampoline for SavedListener#on_saved. + WV_SAVED_LISTENER_ON_SAVED = FFI::Function.new(:void, [:pointer, :int64, :int32, :pointer]) do |ctx, doc_id, revision, out_err| + begin + impl = _wv_cb_lookup(ctx) + doc_id_v = doc_id + revision_v = revision + impl.on_saved(doc_id_v, revision_v) + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Releases a SavedListener implementation when the producer drops its last + # reference. + WV_SAVED_LISTENER_FREE = FFI::Function.new(:void, [:pointer]) do |ctx| + _wv_cb_free(ctx) + end + + # The one process-wide SavedListener vtable every registration hands the + # producer; its entries live for the process lifetime. + WV_SAVED_LISTENER_VTABLE = WvSavedListenerVtable.new + WV_SAVED_LISTENER_VTABLE[:on_saved] = WV_SAVED_LISTENER_ON_SAVED + WV_SAVED_LISTENER_VTABLE[:free] = WV_SAVED_LISTENER_FREE + + # @api private + # Owns one strong reference to a Notebook; releases it exactly once. class NotebookPtr < FFI::AutoPointer def self.release(ptr) WeaveFFI.weaveffi_docs_Notebook_destroy(ptr) @@ -407,9 +515,8 @@ module WeaveFFI # # Notebooks own their documents and release them when destroyed. class Notebook - attr_reader :handle - - # Wraps an owned pointer the producer handed over, without + # @api private + # Adopts one strong reference the producer handed over, without # re-running initialize. def self._from_ptr(ptr) obj = allocate @@ -417,12 +524,39 @@ module WeaveFFI obj end - def destroy + # The borrowed object pointer passed to producer calls. + def handle + raise Error.new(-1, 'Notebook used after close') if @handle.nil? + @handle + end + + # Whether close has released this wrapper's reference. + def closed? + @handle.nil? + end + + # Releases this wrapper's reference now rather than at GC time. + # Idempotent; the object itself is dropped when the last reference + # anywhere (another wrapper, a record field, the producer) goes. + def close return if @handle.nil? @handle.free @handle = nil end + # @api private + # Mints a new strong reference the caller owns (used when this + # object is written into a value buffer). + def _wv_clone_ptr + WeaveFFI.weaveffi_docs_Notebook_clone(handle) + end + + # dup and clone produce an independent wrapper with its own reference. + def initialize_copy(other) + super + @handle = NotebookPtr.new(WeaveFFI.weaveffi_docs_Notebook_clone(other.handle)) + end + # Create an empty notebook def initialize() err = ErrorStruct.new @@ -441,7 +575,7 @@ module WeaveFFI document_data = document_w.data document_buf = FFI::MemoryPointer.new(:uint8, document_data.bytesize) document_buf.put_bytes(0, document_data) - result = WeaveFFI.weaveffi_docs_Notebook_insert(@handle, document_buf, document_data.bytesize, err) + result = WeaveFFI.weaveffi_docs_Notebook_insert(handle, document_buf, document_data.bytesize, err) WeaveFFI.check_error!(err) result end @@ -451,7 +585,7 @@ module WeaveFFI def find(id) err = ErrorStruct.new out_len = FFI::MemoryPointer.new(:size_t) - result = WeaveFFI.weaveffi_docs_Notebook_find(@handle, id, out_len, err) + result = WeaveFFI.weaveffi_docs_Notebook_find(handle, id, out_len, err) WeaveFFI.check_docs_errors_error!(err) len = out_len.read(:size_t) data = result.null? ? ''.b : result.read_string(len) @@ -465,7 +599,7 @@ module WeaveFFI # The number of documents currently held def size() err = ErrorStruct.new - result = WeaveFFI.weaveffi_docs_Notebook_size(@handle, err) + result = WeaveFFI.weaveffi_docs_Notebook_size(handle, err) WeaveFFI.check_error!(err) result end @@ -479,25 +613,6 @@ module WeaveFFI end end - # Subscribe to document-saved notifications - # Registers a OnSaved listener block. Returns a subscription id for - # unregister_saved_listener. - def self.register_saved_listener(&block) - trampoline = FFI::Function.new(:void, [:int64, :int32, :pointer]) do |doc_id, revision, _context| - block.call(doc_id, revision) - end - listener_id = weaveffi_docs_register_saved_listener(trampoline, FFI::Pointer::NULL) - @listener_refs[listener_id] = trampoline - listener_id - end - - # Unregisters a listener previously registered with register_saved_listener. - def self.unregister_saved_listener(listener_id) - weaveffi_docs_unregister_saved_listener(listener_id) - @listener_refs.delete(listener_id) - nil - end - # Create a brand new document. # # The document starts in `Draft` status; callers must publish @@ -555,7 +670,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else @@ -567,6 +682,15 @@ module WeaveFFI raise value if value.is_a?(Error) value end + + # Register a listener for saved notifications + # @param listener [Object] Receiver of saved notifications + def self.watch(listener) + err = ErrorStruct.new + listener_ctx = _wv_cb_register(listener) + weaveffi_docs_watch(listener_ctx, WV_SAVED_LISTENER_VTABLE.to_ptr, err) + check_error!(err) + end end # END weaveffi.rb diff --git a/crates/weaveffi-cli/tests/snapshots/ruby_edge_cases__lib_weaveffi_rb.snap b/crates/weaveffi-cli/tests/snapshots/ruby_edge_cases__lib_weaveffi_rb.snap index f0d5ec47..4b5209de 100644 --- a/crates/weaveffi-cli/tests/snapshots/ruby_edge_cases__lib_weaveffi_rb.snap +++ b/crates/weaveffi-cli/tests/snapshots/ruby_edge_cases__lib_weaveffi_rb.snap @@ -32,7 +32,7 @@ module WeaveFFI # The ABI revision these bindings were generated against. Checked before any # other symbol is attached so a mismatched producer fails at require time # instead of misreading the error struct or a value buffer later. - ABI_VERSION = 1 + ABI_VERSION = 2 begin attach_function :weaveffi_abi_version, [], :uint32 rescue FFI::NotFoundError @@ -52,6 +52,16 @@ module WeaveFFI :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -225,12 +235,21 @@ module WeaveFFI take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') end end + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -240,15 +259,51 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end - # Registered listener trampolines, keyed by subscription id. Holding the - # FFI::Function objects here keeps them alive until unregistered; without - # this the GC could collect a trampoline the producer still calls. - @listener_refs = {} + # Live callback-interface implementations keyed by the Integer the producer + # holds as `ctx`. Keys start at 1 so a context pointer is never NULL. + @wv_cb_registry = {} + @wv_cb_next_key = 0 + @wv_cb_mutex = Mutex.new + + # @api private + # Registers `impl` and returns the `ctx` pointer to hand the producer. + def self._wv_cb_register(impl) + @wv_cb_mutex.synchronize do + @wv_cb_next_key += 1 + @wv_cb_registry[@wv_cb_next_key] = impl + FFI::Pointer.new(@wv_cb_next_key) + end + end + + # @api private + # Resolves a trampoline's `ctx` back to the registered implementation. + def self._wv_cb_lookup(ctx) + impl = @wv_cb_mutex.synchronize { @wv_cb_registry[ctx.address] } + raise Error.new(FOREIGN_ERROR_CODE, 'callback context is not registered') if impl.nil? + impl + end + + # @api private + # Drops the registry entry when the producer releases its last reference. + def self._wv_cb_free(ctx) + @wv_cb_mutex.synchronize { @wv_cb_registry.delete(ctx.address) } + nil + end + + # @api private + # Reports a callback implementation's exception through `out_err`. The + # producer copies the message, so the Ruby String need not outlive the call. + def self._wv_cb_fail(out_err, exception) + message = exception.message.to_s + message = exception.class.name if message.empty? + weaveffi_error_set(out_err, FOREIGN_ERROR_CODE, message) + nil + end # === Module: edge_cases === @@ -271,7 +326,7 @@ module WeaveFFI # Builds the EdgeErrorsError subclass matching `code`, decoding any payload # fields the code declares, or a generic Error for codes outside - # the domain (panics, marshalling). + # the domain (panics, marshalling, callback failures). def self.edge_errors_error_from(code, message, payload = nil) cls = EDGE_ERRORS_ERROR_CODES[code] return Error.new(code, message) if cls.nil? @@ -283,7 +338,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) payload_ptr = err[:payload_ptr] payload = payload_ptr.null? ? nil : payload_ptr.read_string(err[:payload_len]) weaveffi_error_clear(err.to_ptr) @@ -656,6 +711,34 @@ module WeaveFFI end end + # Record carrying objects in every buffered position + class WidgetHolder + attr_reader :primary + + attr_reader :fallback + + attr_reader :extras + + attr_reader :by_name + + def initialize(primary:, fallback:, extras:, by_name:) + @primary = primary + @fallback = fallback + @extras = extras + @by_name = by_name + end + + # Structural equality over every field. + def ==(other) + return false unless other.is_a?(WidgetHolder) + return false unless primary == other.primary + return false unless fallback == other.fallback + return false unless extras == other.extras + return false unless by_name == other.by_name + true + end + end + # @api private # Packs a Leaf into the value-buffer wire format. def self._wv_write_leaf(w, v) @@ -943,6 +1026,50 @@ module WeaveFFI Retired.new(id: _wv_id) end + # @api private + # Packs a WidgetHolder into the value-buffer wire format. + def self._wv_write_widget_holder(w, v) + w.write_u64(v.primary._wv_clone_ptr.address) + if v.fallback.nil? + w.write_flag(false) + else + w.write_flag(true) + w.write_u64(v.fallback._wv_clone_ptr.address) + end + w.write_len(v.extras.length) + v.extras.each do |_wv_e0| + w.write_u64(_wv_e0._wv_clone_ptr.address) + end + w.write_len(v.by_name.length) + v.by_name.each do |_wv_k0, _wv_v0| + w.write_string(_wv_k0) + w.write_u64(_wv_v0._wv_clone_ptr.address) + end + end + + # @api private + # Unpacks a WidgetHolder from the value-buffer wire format. + def self._wv_read_widget_holder(r) + _wv_primary = Widget._from_ptr(r.read_object_token) + if r.read_flag + _wv_fallback = Widget._from_ptr(r.read_object_token) + else + _wv_fallback = nil + end + _wv_extras = Array.new(r.read_len) do + _wv_e0 = Widget._from_ptr(r.read_object_token) + _wv_e0 + end + _wv_by_name = {} + r.read_len.times do + _wv_k0 = r.read_string + _wv_v0 = Widget._from_ptr(r.read_object_token) + _wv_by_name[_wv_k0] = _wv_v0 + end + WidgetHolder.new(primary: _wv_primary, fallback: _wv_fallback, extras: _wv_extras, by_name: _wv_by_name) + end + + attach_function :weaveffi_edge_cases_Widget_clone, [:pointer], :pointer attach_function :weaveffi_edge_cases_Widget_destroy, [:pointer], :void # Void method that can fail attach_function :weaveffi_edge_cases_Widget_refresh, [:pointer, :pointer], :void @@ -952,19 +1079,13 @@ module WeaveFFI attach_function :weaveffi_edge_cases_Widget_fetch_async, [:pointer, :int64, :weaveffi_edge_cases_Widget_fetch_callback, :pointer], :void attach_function :weaveffi_edge_cases_Widget_old_name, [:pointer, :pointer], :pointer + attach_function :weaveffi_edge_cases_Vault_clone, [:pointer], :pointer attach_function :weaveffi_edge_cases_Vault_destroy, [:pointer], :void # Fails when the vault is busy attach_function :weaveffi_edge_cases_Vault_open, [:string, :pointer], :pointer attach_function :weaveffi_edge_cases_Vault_peek, [:pointer, :pointer], :pointer # Throwing static attach_function :weaveffi_edge_cases_Vault_probe, [:pointer], :int32 - # Callback carrying composite payloads - callback :weaveffi_edge_cases_OnBatch_fn, [:pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :int32, :pointer], :void - callback :weaveffi_edge_cases_OnTick_fn, [:pointer], :void - attach_function :weaveffi_edge_cases_register_batch_listener, [:weaveffi_edge_cases_OnBatch_fn, :pointer], :uint64 - attach_function :weaveffi_edge_cases_unregister_batch_listener, [:uint64], :void - attach_function :weaveffi_edge_cases_register_tick_listener, [:weaveffi_edge_cases_OnTick_fn, :pointer], :uint64 - attach_function :weaveffi_edge_cases_unregister_tick_listener, [:uint64], :void # No parameters and no result attach_function :weaveffi_edge_cases_noop, [:pointer], :void # Function named after a keyword whose parameters are all keywords @@ -972,15 +1093,23 @@ module WeaveFFI # Optional list of optional structs, the deepest legal nesting attach_function :weaveffi_edge_cases_nested_optional, [:pointer, :size_t, :pointer, :pointer], :pointer attach_function :weaveffi_edge_cases_optional_params, [:pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :pointer], :pointer - # Write-back parameters - attach_function :weaveffi_edge_cases_mutable_params, [:string, :pointer, :size_t, :pointer], :void + # Two callback interface parameters on one function + attach_function :weaveffi_edge_cases_register_batch, [:pointer, :pointer, :pointer, :pointer, :pointer], :void + # Objects in every buffered position + attach_function :weaveffi_edge_cases_widgets_everywhere, [:pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :pointer], :pointer + # Iterator of objects + attach_function :weaveffi_edge_cases_widget_stream, [:pointer], :pointer + attach_function :weaveffi_edge_cases_WidgetStreamIterator_next, [:pointer, :pointer, :pointer], :int32 + attach_function :weaveffi_edge_cases_WidgetStreamIterator_destroy, [:pointer], :void + # Async returning an object + callback :weaveffi_edge_cases_async_widget_callback, [:pointer, :pointer, :pointer], :void + attach_function :weaveffi_edge_cases_async_widget_async, [:weaveffi_edge_cases_async_widget_callback, :pointer], :void # Factory for an interface with no constructor attach_function :weaveffi_edge_cases_make_widget, [:string, :pointer], :pointer # Optional interface return attach_function :weaveffi_edge_cases_find_widget, [:int64, :pointer], :pointer # Interface parameter, including an optional one attach_function :weaveffi_edge_cases_inspect_widget, [:pointer, :pointer, :pointer], :pointer - attach_function :weaveffi_edge_cases_typed_handle_param, [:pointer, :uint64, :pointer], :pointer # Iterator of strings attach_function :weaveffi_edge_cases_string_stream, [:pointer], :pointer attach_function :weaveffi_edge_cases_StringStreamIterator_next, [:pointer, :pointer, :pointer], :int32 @@ -1011,6 +1140,222 @@ module WeaveFFI attach_function :weaveffi_edge_cases_keywords_roundtrip, [:pointer, :size_t, :pointer, :pointer], :pointer attach_function :weaveffi_edge_cases_legacy_value, [:int32, :pointer, :pointer], :pointer + # Callback interface carrying composite payloads and objects + # Consumer-implemented callback interface. Any object responding to the + # methods below is accepted wherever a BatchListener parameter is expected; + # include this module to inherit NotImplementedError defaults. The + # producer may call the methods from any thread until it releases the + # implementation. + module BatchListener + # Composite payloads cross as value buffers + def on_batch(items, raw, first, mode) + raise NotImplementedError, "#{self.class}#on_batch is not implemented" + end + + # Objects cross as strong references the consumer must release + def on_widget(widget, fallback, children) + raise NotImplementedError, "#{self.class}#on_widget is not implemented" + end + + def on_tick + raise NotImplementedError, "#{self.class}#on_tick is not implemented" + end + + # Scalar return travels back through the vtable + # @return [Object] a bool + def should_continue(class_) + raise NotImplementedError, "#{self.class}#should_continue is not implemented" + end + + # Enum return travels back as its integer discriminant + # @return [Object] a Mode + def pick_mode + raise NotImplementedError, "#{self.class}#pick_mode is not implemented" + end + end + + # @api private + # The C vtable layout the producer calls a BatchListener through. + class WvBatchListenerVtable < FFI::Struct + layout :on_batch, :pointer, + :on_widget, :pointer, + :on_tick, :pointer, + :should_continue, :pointer, + :pick_mode, :pointer, + :free, :pointer + end + + # @api private + # Trampoline for BatchListener#on_batch. + WV_BATCH_LISTENER_ON_BATCH = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :int32, :pointer]) do |ctx, items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, out_err| + begin + impl = _wv_cb_lookup(ctx) + items_r = WvBufferReader.new(items_ptr.null? ? ''.b : items_ptr.read_string(items_len)) + items_v = Array.new(items_r.read_len) do + _wv_e0 = _wv_read_outer(items_r) + _wv_e0 + end + items_r.expect_end! + raw_v = raw_ptr.null? ? ''.b : raw_ptr.read_string(raw_len) + first_r = WvBufferReader.new(first_ptr.null? ? ''.b : first_ptr.read_string(first_len)) + if first_r.read_flag + first_v = _wv_read_outer(first_r) + else + first_v = nil + end + first_r.expect_end! + mode_v = mode + impl.on_batch(items_v, raw_v, first_v, mode_v) + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Trampoline for BatchListener#on_widget. + WV_BATCH_LISTENER_ON_WIDGET = FFI::Function.new(:void, [:pointer, :pointer, :pointer, :pointer, :size_t, :pointer]) do |ctx, widget, fallback, children_ptr, children_len, out_err| + begin + impl = _wv_cb_lookup(ctx) + widget_v = Widget._from_ptr(widget) + fallback_v = fallback.null? ? nil : Widget._from_ptr(fallback) + children_r = WvBufferReader.new(children_ptr.null? ? ''.b : children_ptr.read_string(children_len)) + children_v = Array.new(children_r.read_len) do + _wv_e0 = Widget._from_ptr(children_r.read_object_token) + _wv_e0 + end + children_r.expect_end! + impl.on_widget(widget_v, fallback_v, children_v) + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Trampoline for BatchListener#on_tick. + WV_BATCH_LISTENER_ON_TICK = FFI::Function.new(:void, [:pointer, :pointer]) do |ctx, out_err| + begin + impl = _wv_cb_lookup(ctx) + impl.on_tick + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Trampoline for BatchListener#should_continue. + WV_BATCH_LISTENER_SHOULD_CONTINUE = FFI::Function.new(:int32, [:pointer, :int32, :pointer]) do |ctx, class_, out_err| + begin + impl = _wv_cb_lookup(ctx) + class__v = class_ + impl.should_continue(class__v) ? 1 : 0 + rescue Exception => e + _wv_cb_fail(out_err, e) + 0 + end + end + + # @api private + # Trampoline for BatchListener#pick_mode. + WV_BATCH_LISTENER_PICK_MODE = FFI::Function.new(:int32, [:pointer, :pointer]) do |ctx, out_err| + begin + impl = _wv_cb_lookup(ctx) + Integer(impl.pick_mode) + rescue Exception => e + _wv_cb_fail(out_err, e) + 0 + end + end + + # @api private + # Releases a BatchListener implementation when the producer drops its last + # reference. + WV_BATCH_LISTENER_FREE = FFI::Function.new(:void, [:pointer]) do |ctx| + _wv_cb_free(ctx) + end + + # The one process-wide BatchListener vtable every registration hands the + # producer; its entries live for the process lifetime. + WV_BATCH_LISTENER_VTABLE = WvBatchListenerVtable.new + WV_BATCH_LISTENER_VTABLE[:on_batch] = WV_BATCH_LISTENER_ON_BATCH + WV_BATCH_LISTENER_VTABLE[:on_widget] = WV_BATCH_LISTENER_ON_WIDGET + WV_BATCH_LISTENER_VTABLE[:on_tick] = WV_BATCH_LISTENER_ON_TICK + WV_BATCH_LISTENER_VTABLE[:should_continue] = WV_BATCH_LISTENER_SHOULD_CONTINUE + WV_BATCH_LISTENER_VTABLE[:pick_mode] = WV_BATCH_LISTENER_PICK_MODE + WV_BATCH_LISTENER_VTABLE[:free] = WV_BATCH_LISTENER_FREE + + # Callback interface whose name and methods are keywords + # Consumer-implemented callback interface. Any object responding to the + # methods below is accepted wherever a Keyword parameter is expected; + # include this module to inherit NotImplementedError defaults. The + # producer may call the methods from any thread until it releases the + # implementation. + module Keyword + def delete(self_) + raise NotImplementedError, "#{self.class}#delete is not implemented" + end + + # @return [Object] a i32 + def import + raise NotImplementedError, "#{self.class}#import is not implemented" + end + end + + # @api private + # The C vtable layout the producer calls a Keyword through. + class WvKeywordVtable < FFI::Struct + layout :delete, :pointer, + :import, :pointer, + :free, :pointer + end + + # @api private + # Trampoline for Keyword#delete. + WV_KEYWORD_DELETE = FFI::Function.new(:void, [:pointer, :int32, :pointer]) do |ctx, self_, out_err| + begin + impl = _wv_cb_lookup(ctx) + self__v = self_ + impl.delete(self__v) + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Trampoline for Keyword#import. + WV_KEYWORD_IMPORT = FFI::Function.new(:int32, [:pointer, :pointer]) do |ctx, out_err| + begin + impl = _wv_cb_lookup(ctx) + Integer(impl.import) + rescue Exception => e + _wv_cb_fail(out_err, e) + 0 + end + end + + # @api private + # Releases a Keyword implementation when the producer drops its last + # reference. + WV_KEYWORD_FREE = FFI::Function.new(:void, [:pointer]) do |ctx| + _wv_cb_free(ctx) + end + + # The one process-wide Keyword vtable every registration hands the + # producer; its entries live for the process lifetime. + WV_KEYWORD_VTABLE = WvKeywordVtable.new + WV_KEYWORD_VTABLE[:delete] = WV_KEYWORD_DELETE + WV_KEYWORD_VTABLE[:import] = WV_KEYWORD_IMPORT + WV_KEYWORD_VTABLE[:free] = WV_KEYWORD_FREE + + # @api private + # Owns one strong reference to a Widget; releases it exactly once. class WidgetPtr < FFI::AutoPointer def self.release(ptr) WeaveFFI.weaveffi_edge_cases_Widget_destroy(ptr) @@ -1019,9 +1364,8 @@ module WeaveFFI # Interface with no constructor; instances come from make_widget class Widget - attr_reader :handle - - # Wraps an owned pointer the producer handed over, without + # @api private + # Adopts one strong reference the producer handed over, without # re-running initialize. def self._from_ptr(ptr) obj = allocate @@ -1029,16 +1373,43 @@ module WeaveFFI obj end - def destroy + # The borrowed object pointer passed to producer calls. + def handle + raise Error.new(-1, 'Widget used after close') if @handle.nil? + @handle + end + + # Whether close has released this wrapper's reference. + def closed? + @handle.nil? + end + + # Releases this wrapper's reference now rather than at GC time. + # Idempotent; the object itself is dropped when the last reference + # anywhere (another wrapper, a record field, the producer) goes. + def close return if @handle.nil? @handle.free @handle = nil end + # @api private + # Mints a new strong reference the caller owns (used when this + # object is written into a value buffer). + def _wv_clone_ptr + WeaveFFI.weaveffi_edge_cases_Widget_clone(handle) + end + + # dup and clone produce an independent wrapper with its own reference. + def initialize_copy(other) + super + @handle = WidgetPtr.new(WeaveFFI.weaveffi_edge_cases_Widget_clone(other.handle)) + end + # Void method that can fail def refresh() err = ErrorStruct.new - WeaveFFI.weaveffi_edge_cases_Widget_refresh(@handle, err) + WeaveFFI.weaveffi_edge_cases_Widget_refresh(handle, err) WeaveFFI.check_edge_errors_error!(err) end @@ -1054,10 +1425,10 @@ module WeaveFFI import_data = import_w.data import_buf = FFI::MemoryPointer.new(:uint8, import_data.bytesize) import_buf.put_bytes(0, import_data) - result = WeaveFFI.weaveffi_edge_cases_Widget_rename(@handle, class_, import_buf, import_data.bytesize, err) + result = WeaveFFI.weaveffi_edge_cases_Widget_rename(handle, class_, import_buf, import_data.bytesize, err) WeaveFFI.check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) WeaveFFI.weaveffi_free_string(result) str end @@ -1071,7 +1442,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) payload = err[:payload_ptr].null? ? nil : err[:payload_ptr].read_string(err[:payload_len]) WeaveFFI.weaveffi_error_free(err_ptr) queue << WeaveFFI.edge_errors_error_from(code, msg, payload) @@ -1087,7 +1458,7 @@ module WeaveFFI end end end - WeaveFFI.weaveffi_edge_cases_Widget_fetch_async(@handle, id, callback, FFI::Pointer::NULL) + WeaveFFI.weaveffi_edge_cases_Widget_fetch_async(handle, id, callback, FFI::Pointer::NULL) value = queue.pop raise value if value.is_a?(Error) value @@ -1096,15 +1467,17 @@ module WeaveFFI def old_name() warn "[DEPRECATED] Use rename instead" err = ErrorStruct.new - result = WeaveFFI.weaveffi_edge_cases_Widget_old_name(@handle, err) + result = WeaveFFI.weaveffi_edge_cases_Widget_old_name(handle, err) WeaveFFI.check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) WeaveFFI.weaveffi_free_string(result) str end end + # @api private + # Owns one strong reference to a Vault; releases it exactly once. class VaultPtr < FFI::AutoPointer def self.release(ptr) WeaveFFI.weaveffi_edge_cases_Vault_destroy(ptr) @@ -1112,10 +1485,10 @@ module WeaveFFI end # Deprecated interface with a throwing constructor + # @deprecated Use Widget instead class Vault - attr_reader :handle - - # Wraps an owned pointer the producer handed over, without + # @api private + # Adopts one strong reference the producer handed over, without # re-running initialize. def self._from_ptr(ptr) obj = allocate @@ -1123,12 +1496,39 @@ module WeaveFFI obj end - def destroy + # The borrowed object pointer passed to producer calls. + def handle + raise Error.new(-1, 'Vault used after close') if @handle.nil? + @handle + end + + # Whether close has released this wrapper's reference. + def closed? + @handle.nil? + end + + # Releases this wrapper's reference now rather than at GC time. + # Idempotent; the object itself is dropped when the last reference + # anywhere (another wrapper, a record field, the producer) goes. + def close return if @handle.nil? @handle.free @handle = nil end + # @api private + # Mints a new strong reference the caller owns (used when this + # object is written into a value buffer). + def _wv_clone_ptr + WeaveFFI.weaveffi_edge_cases_Vault_clone(handle) + end + + # dup and clone produce an independent wrapper with its own reference. + def initialize_copy(other) + super + @handle = VaultPtr.new(WeaveFFI.weaveffi_edge_cases_Vault_clone(other.handle)) + end + # Fails when the vault is busy def self.open(path) err = ErrorStruct.new @@ -1140,7 +1540,7 @@ module WeaveFFI def peek() err = ErrorStruct.new - result = WeaveFFI.weaveffi_edge_cases_Vault_peek(@handle, err) + result = WeaveFFI.weaveffi_edge_cases_Vault_peek(handle, err) WeaveFFI.check_error!(err) return nil if result.null? Widget._from_ptr(result) @@ -1151,62 +1551,8 @@ module WeaveFFI err = ErrorStruct.new result = WeaveFFI.weaveffi_edge_cases_Vault_probe(err) WeaveFFI.check_edge_errors_error!(err) - result != 0 - end - end - - # Registers a OnBatch listener block. Returns a subscription id for - # unregister_batch_listener. - def self.register_batch_listener(&block) - trampoline = FFI::Function.new(:void, [:pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :int32, :pointer]) do |items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, _context| - begin - items_r = WvBufferReader.new(items_ptr.null? ? ''.b : items_ptr.read_string(items_len)) - items_v = Array.new(items_r.read_len) do - _wv_e0 = _wv_read_outer(items_r) - _wv_e0 - end - items_r.expect_end! - first_r = WvBufferReader.new(first_ptr.null? ? ''.b : first_ptr.read_string(first_len)) - if first_r.read_flag - first_v = _wv_read_outer(first_r) - else - first_v = nil - end - first_r.expect_end! - rescue Error => e - warn "weaveffi: dropped OnBatch event: #{e.message}" - next - end - block.call(items_v, (raw_ptr.null? ? ''.b : raw_ptr.read_string(raw_len)), first_v, mode) + (result != 0) end - listener_id = weaveffi_edge_cases_register_batch_listener(trampoline, FFI::Pointer::NULL) - @listener_refs[listener_id] = trampoline - listener_id - end - - # Unregisters a listener previously registered with register_batch_listener. - def self.unregister_batch_listener(listener_id) - weaveffi_edge_cases_unregister_batch_listener(listener_id) - @listener_refs.delete(listener_id) - nil - end - - # Registers a OnTick listener block. Returns a subscription id for - # unregister_tick_listener. - def self.register_tick_listener(&block) - trampoline = FFI::Function.new(:void, [:pointer]) do |_context| - block.call() - end - listener_id = weaveffi_edge_cases_register_tick_listener(trampoline, FFI::Pointer::NULL) - @listener_refs[listener_id] = trampoline - listener_id - end - - # Unregisters a listener previously registered with register_tick_listener. - def self.unregister_tick_listener(listener_id) - weaveffi_edge_cases_unregister_tick_listener(listener_id) - @listener_refs.delete(listener_id) - nil end # No parameters and no result @@ -1368,13 +1714,121 @@ module WeaveFFI _wv_value end - # Write-back parameters - def self.mutable_params(text, blob) + # Two callback interface parameters on one function + def self.register_batch(listener, keyword) err = ErrorStruct.new - blob_buf = FFI::MemoryPointer.new(:uint8, blob.bytesize) - blob_buf.put_bytes(0, blob) - weaveffi_edge_cases_mutable_params(text, blob_buf, blob.bytesize, err) + listener_ctx = _wv_cb_register(listener) + keyword_ctx = _wv_cb_register(keyword) + weaveffi_edge_cases_register_batch(listener_ctx, WV_BATCH_LISTENER_VTABLE.to_ptr, keyword_ctx, WV_KEYWORD_VTABLE.to_ptr, err) + check_error!(err) + end + + # Objects in every buffered position + def self.widgets_everywhere(list, by_name, sparse, holder) + err = ErrorStruct.new + list_w = WvBufferWriter.new + list_w.write_len(list.length) + list.each do |_wv_e0| + list_w.write_u64(_wv_e0._wv_clone_ptr.address) + end + list_data = list_w.data + list_buf = FFI::MemoryPointer.new(:uint8, list_data.bytesize) + list_buf.put_bytes(0, list_data) + by_name_w = WvBufferWriter.new + by_name_w.write_len(by_name.length) + by_name.each do |_wv_k0, _wv_v0| + by_name_w.write_string(_wv_k0) + by_name_w.write_u64(_wv_v0._wv_clone_ptr.address) + end + by_name_data = by_name_w.data + by_name_buf = FFI::MemoryPointer.new(:uint8, by_name_data.bytesize) + by_name_buf.put_bytes(0, by_name_data) + sparse_w = WvBufferWriter.new + sparse_w.write_len(sparse.length) + sparse.each do |_wv_e0| + if _wv_e0.nil? + sparse_w.write_flag(false) + else + sparse_w.write_flag(true) + sparse_w.write_u64(_wv_e0._wv_clone_ptr.address) + end + end + sparse_data = sparse_w.data + sparse_buf = FFI::MemoryPointer.new(:uint8, sparse_data.bytesize) + sparse_buf.put_bytes(0, sparse_data) + holder_w = WvBufferWriter.new + _wv_write_widget_holder(holder_w, holder) + holder_data = holder_w.data + holder_buf = FFI::MemoryPointer.new(:uint8, holder_data.bytesize) + holder_buf.put_bytes(0, holder_data) + out_len = FFI::MemoryPointer.new(:size_t) + result = weaveffi_edge_cases_widgets_everywhere(list_buf, list_data.bytesize, by_name_buf, by_name_data.bytesize, sparse_buf, sparse_data.bytesize, holder_buf, holder_data.bytesize, out_len, err) check_error!(err) + len = out_len.read(:size_t) + data = result.null? ? ''.b : result.read_string(len) + weaveffi_free_bytes(result, len) unless result.null? + _wv_r = WvBufferReader.new(data) + _wv_value = _wv_read_widget_holder(_wv_r) + _wv_r.expect_end! + _wv_value + end + + # Iterator of objects + # Returns a lazy Enumerator that streams one element per pull; call + # `.to_a` to collect eagerly. The underlying producer iterator is + # launched on the first pull, so launch errors raise at that point + # rather than when this method returns. The iterator handle is + # released exactly once, when iteration finishes or is abandoned + # early (for example by `break`). + def self.widget_stream() + Enumerator.new do |y| + err = ErrorStruct.new + iter = weaveffi_edge_cases_widget_stream(err) + begin + check_error!(err) + unless iter.null? + loop do + out_item = FFI::MemoryPointer.new(:pointer) + item_err = ErrorStruct.new + has_item = weaveffi_edge_cases_WidgetStreamIterator_next(iter, out_item, item_err) + check_error!(item_err) + break if has_item.zero? + item_ptr = out_item.read_pointer + raise Error.new(-1, 'null pointer') if item_ptr.null? + y << Widget._from_ptr(item_ptr) + end + end + ensure + weaveffi_edge_cases_WidgetStreamIterator_destroy(iter) unless iter.null? + end + end + end + + # Async returning an object + # Blocks the current thread until the async producer completes; the + # result (or error) is delivered through the completion callback. + def self.async_widget() + queue = Queue.new + callback = FFI::Function.new(:void, [:pointer, :pointer, :pointer]) do |_context, err_ptr, result| + err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) + if err && err[:code] != 0 + code = err[:code] + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) + payload = err[:payload_ptr].null? ? nil : err[:payload_ptr].read_string(err[:payload_len]) + weaveffi_error_free(err_ptr) + queue << edge_errors_error_from(code, msg, payload) + else + if result.null? + queue << Error.new(-1, 'null pointer') + else + queue << Widget._from_ptr(result) + end + end + end + weaveffi_edge_cases_async_widget_async(callback, FFI::Pointer::NULL) + value = queue.pop + raise value if value.is_a?(Error) + value end # Factory for an interface with no constructor @@ -1401,19 +1855,11 @@ module WeaveFFI result = weaveffi_edge_cases_inspect_widget(widget.handle, fallback&.handle, err) check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) str end - def self.typed_handle_param(token, raw) - err = ErrorStruct.new - result = weaveffi_edge_cases_typed_handle_param(token.handle, raw, err) - check_error!(err) - raise Error.new(-1, 'null pointer') if result.null? - Leaf.new(result) - end - # Iterator of strings # Returns a lazy Enumerator that streams one element per pull; call # `.to_a` to collect eagerly. The underlying producer iterator is @@ -1438,7 +1884,7 @@ module WeaveFFI if item_ptr.null? y << '' else - item = item_ptr.read_string + item = item_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(item_ptr) y << item end @@ -1517,7 +1963,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else @@ -1538,7 +1984,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else @@ -1561,7 +2007,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) payload = err[:payload_ptr].null? ? nil : err[:payload_ptr].read_string(err[:payload_len]) weaveffi_error_free(err_ptr) queue << edge_errors_error_from(code, msg, payload) @@ -1591,7 +2037,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) payload = err[:payload_ptr].null? ? nil : err[:payload_ptr].read_string(err[:payload_len]) weaveffi_error_free(err_ptr) queue << edge_errors_error_from(code, msg, payload) @@ -1624,7 +2070,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else @@ -1657,7 +2103,7 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else @@ -1678,11 +2124,11 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else - _wv_s = result.null? ? '' : result.read_string + _wv_s = result.null? ? '' : result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) unless result.null? queue << _wv_s end diff --git a/crates/weaveffi-cli/tests/snapshots/ruby_kitchen_sink__lib_weaveffi_rb.snap b/crates/weaveffi-cli/tests/snapshots/ruby_kitchen_sink__lib_weaveffi_rb.snap index 5830a08d..4d0bd359 100644 --- a/crates/weaveffi-cli/tests/snapshots/ruby_kitchen_sink__lib_weaveffi_rb.snap +++ b/crates/weaveffi-cli/tests/snapshots/ruby_kitchen_sink__lib_weaveffi_rb.snap @@ -32,7 +32,7 @@ module WeaveFFI # The ABI revision these bindings were generated against. Checked before any # other symbol is attached so a mismatched producer fails at require time # instead of misreading the error struct or a value buffer later. - ABI_VERSION = 1 + ABI_VERSION = 2 begin attach_function :weaveffi_abi_version, [], :uint32 rescue FFI::NotFoundError @@ -52,6 +52,16 @@ module WeaveFFI :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -225,12 +235,21 @@ module WeaveFFI take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') end end + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -240,15 +259,51 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end - # Registered listener trampolines, keyed by subscription id. Holding the - # FFI::Function objects here keeps them alive until unregistered; without - # this the GC could collect a trampoline the producer still calls. - @listener_refs = {} + # Live callback-interface implementations keyed by the Integer the producer + # holds as `ctx`. Keys start at 1 so a context pointer is never NULL. + @wv_cb_registry = {} + @wv_cb_next_key = 0 + @wv_cb_mutex = Mutex.new + + # @api private + # Registers `impl` and returns the `ctx` pointer to hand the producer. + def self._wv_cb_register(impl) + @wv_cb_mutex.synchronize do + @wv_cb_next_key += 1 + @wv_cb_registry[@wv_cb_next_key] = impl + FFI::Pointer.new(@wv_cb_next_key) + end + end + + # @api private + # Resolves a trampoline's `ctx` back to the registered implementation. + def self._wv_cb_lookup(ctx) + impl = @wv_cb_mutex.synchronize { @wv_cb_registry[ctx.address] } + raise Error.new(FOREIGN_ERROR_CODE, 'callback context is not registered') if impl.nil? + impl + end + + # @api private + # Drops the registry entry when the producer releases its last reference. + def self._wv_cb_free(ctx) + @wv_cb_mutex.synchronize { @wv_cb_registry.delete(ctx.address) } + nil + end + + # @api private + # Reports a callback implementation's exception through `out_err`. The + # producer copies the message, so the Ruby String need not outlive the call. + def self._wv_cb_fail(out_err, exception) + message = exception.message.to_s + message = exception.class.name if message.empty? + weaveffi_error_set(out_err, FOREIGN_ERROR_CODE, message) + nil + end # === Module: shared === @@ -295,7 +350,7 @@ module WeaveFFI result = weaveffi_shared_ping(err) check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) str end @@ -331,7 +386,7 @@ module WeaveFFI # Builds the KitchenErrorsError subclass matching `code`, decoding any payload # fields the code declares, or a generic Error for codes outside - # the domain (panics, marshalling). + # the domain (panics, marshalling, callback failures). def self.kitchen_errors_error_from(code, message, payload = nil) cls = KITCHEN_ERRORS_ERROR_CODES[code] return Error.new(code, message) if cls.nil? @@ -343,7 +398,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) payload_ptr = err[:payload_ptr] payload = payload_ptr.null? ? nil : payload_ptr.read_string(err[:payload_len]) weaveffi_error_clear(err.to_ptr) @@ -392,7 +447,10 @@ module WeaveFFI attr_reader :priority - def initialize(id:, name:, count:, enabled:, ratio:, nick:, payload:, tags:, attrs:, parent:, token:, priority:) + # An optional object inside a record + attr_reader :gadget + + def initialize(id:, name:, count:, enabled:, ratio:, nick:, payload:, tags:, attrs:, parent:, token:, priority:, gadget:) @id = id @name = name @count = count @@ -405,6 +463,7 @@ module WeaveFFI @parent = parent @token = token @priority = priority + @gadget = gadget end # Structural equality over every field. @@ -422,6 +481,7 @@ module WeaveFFI return false unless parent == other.parent return false unless token == other.token return false unless priority == other.priority + return false unless gadget == other.gadget true end end @@ -453,6 +513,12 @@ module WeaveFFI end _wv_write_token(w, v.token) w.write_i32(v.priority) + if v.gadget.nil? + w.write_flag(false) + else + w.write_flag(true) + w.write_u64(v.gadget._wv_clone_ptr.address) + end end # @api private @@ -482,9 +548,15 @@ module WeaveFFI end _wv_token = _wv_read_token(r) _wv_priority = r.read_i32 - Item.new(id: _wv_id, name: _wv_name, count: _wv_count, enabled: _wv_enabled, ratio: _wv_ratio, nick: _wv_nick, payload: _wv_payload, tags: _wv_tags, attrs: _wv_attrs, parent: _wv_parent, token: _wv_token, priority: _wv_priority) + if r.read_flag + _wv_gadget = Gadget._from_ptr(r.read_object_token) + else + _wv_gadget = nil + end + Item.new(id: _wv_id, name: _wv_name, count: _wv_count, enabled: _wv_enabled, ratio: _wv_ratio, nick: _wv_nick, payload: _wv_payload, tags: _wv_tags, attrs: _wv_attrs, parent: _wv_parent, token: _wv_token, priority: _wv_priority, gadget: _wv_gadget) end + attach_function :weaveffi_kitchen_Gadget_clone, [:pointer], :pointer attach_function :weaveffi_kitchen_Gadget_destroy, [:pointer], :void # Create a gadget with the given id attach_function :weaveffi_kitchen_Gadget_new, [:int64, :pointer], :pointer @@ -494,10 +566,6 @@ module WeaveFFI attach_function :weaveffi_kitchen_Gadget_poke, [:pointer, :int32, :pointer], :int32 # The gadget subsystem version string attach_function :weaveffi_kitchen_Gadget_version, [:pointer], :pointer - # Fires when an item is ready - callback :weaveffi_kitchen_OnReady_fn, [:int32, :string, :pointer], :void - attach_function :weaveffi_kitchen_register_ready_listener, [:weaveffi_kitchen_OnReady_fn, :pointer], :uint64 - attach_function :weaveffi_kitchen_unregister_ready_listener, [:uint64], :void # Identity for bool attach_function :weaveffi_kitchen_bool_id, [:int32, :pointer], :int32 attach_function :weaveffi_kitchen_i32_id, [:int32, :pointer], :int32 @@ -506,14 +574,16 @@ module WeaveFFI attach_function :weaveffi_kitchen_f64_id, [:double, :pointer], :double attach_function :weaveffi_kitchen_echo_string, [:string, :pointer], :pointer attach_function :weaveffi_kitchen_echo_bytes, [:pointer, :size_t, :pointer, :pointer], :pointer - # Borrowed string parameter - attach_function :weaveffi_kitchen_echo_borrowed_str, [:string, :pointer], :pointer - # Borrowed bytes parameter - attach_function :weaveffi_kitchen_echo_borrowed_bytes, [:pointer, :size_t, :pointer, :pointer], :pointer - # Returns an opaque handle - attach_function :weaveffi_kitchen_open_handle, [:pointer], :uint64 - # Returns a typed handle - attach_function :weaveffi_kitchen_open_typed_handle, [:pointer], :pointer + # Register a listener, returning how many are attached + attach_function :weaveffi_kitchen_subscribe, [:pointer, :pointer, :pointer], :int32 + # Optional object in both directions + attach_function :weaveffi_kitchen_maybe_gadget, [:pointer, :pointer], :pointer + # A list of objects + attach_function :weaveffi_kitchen_gadgets, [:pointer, :pointer], :pointer + # An iterator over objects + attach_function :weaveffi_kitchen_stream_gadgets, [:pointer], :pointer + attach_function :weaveffi_kitchen_StreamGadgetsIterator_next, [:pointer, :pointer, :pointer], :int32 + attach_function :weaveffi_kitchen_StreamGadgetsIterator_destroy, [:pointer], :void # Optional struct return attach_function :weaveffi_kitchen_maybe_item, [:int64, :pointer, :pointer], :pointer # List of structs @@ -537,6 +607,80 @@ module WeaveFFI # Replacement for legacy_op attach_function :weaveffi_kitchen_new_op, [:pointer], :int32 + # Consumer-implemented observer of ready items + # Consumer-implemented callback interface. Any object responding to the + # methods below is accepted wherever a ReadyListener parameter is expected; + # include this module to inherit NotImplementedError defaults. The + # producer may call the methods from any thread until it releases the + # implementation. + module ReadyListener + # Fires when an item is ready + def on_ready(code, msg) + raise NotImplementedError, "#{self.class}#on_ready is not implemented" + end + + # Receives the item itself and says whether to keep listening + # @return [Object] a bool + def on_item(item, gadget) + raise NotImplementedError, "#{self.class}#on_item is not implemented" + end + end + + # @api private + # The C vtable layout the producer calls a ReadyListener through. + class WvReadyListenerVtable < FFI::Struct + layout :on_ready, :pointer, + :on_item, :pointer, + :free, :pointer + end + + # @api private + # Trampoline for ReadyListener#on_ready. + WV_READY_LISTENER_ON_READY = FFI::Function.new(:void, [:pointer, :int32, :string, :pointer]) do |ctx, code, msg, out_err| + begin + impl = _wv_cb_lookup(ctx) + code_v = code + msg_v = msg.nil? ? '' : msg.force_encoding(Encoding::UTF_8) + impl.on_ready(code_v, msg_v) + nil + rescue Exception => e + _wv_cb_fail(out_err, e) + nil + end + end + + # @api private + # Trampoline for ReadyListener#on_item. + WV_READY_LISTENER_ON_ITEM = FFI::Function.new(:int32, [:pointer, :pointer, :size_t, :pointer, :pointer]) do |ctx, item_ptr, item_len, gadget, out_err| + begin + impl = _wv_cb_lookup(ctx) + item_r = WvBufferReader.new(item_ptr.null? ? ''.b : item_ptr.read_string(item_len)) + item_v = _wv_read_item(item_r) + item_r.expect_end! + gadget_v = Gadget._from_ptr(gadget) + impl.on_item(item_v, gadget_v) ? 1 : 0 + rescue Exception => e + _wv_cb_fail(out_err, e) + 0 + end + end + + # @api private + # Releases a ReadyListener implementation when the producer drops its last + # reference. + WV_READY_LISTENER_FREE = FFI::Function.new(:void, [:pointer]) do |ctx| + _wv_cb_free(ctx) + end + + # The one process-wide ReadyListener vtable every registration hands the + # producer; its entries live for the process lifetime. + WV_READY_LISTENER_VTABLE = WvReadyListenerVtable.new + WV_READY_LISTENER_VTABLE[:on_ready] = WV_READY_LISTENER_ON_READY + WV_READY_LISTENER_VTABLE[:on_item] = WV_READY_LISTENER_ON_ITEM + WV_READY_LISTENER_VTABLE[:free] = WV_READY_LISTENER_FREE + + # @api private + # Owns one strong reference to a Gadget; releases it exactly once. class GadgetPtr < FFI::AutoPointer def self.release(ptr) WeaveFFI.weaveffi_kitchen_Gadget_destroy(ptr) @@ -545,9 +689,8 @@ module WeaveFFI # A pocket-sized interface exercising the object surface class Gadget - attr_reader :handle - - # Wraps an owned pointer the producer handed over, without + # @api private + # Adopts one strong reference the producer handed over, without # re-running initialize. def self._from_ptr(ptr) obj = allocate @@ -555,12 +698,39 @@ module WeaveFFI obj end - def destroy + # The borrowed object pointer passed to producer calls. + def handle + raise Error.new(-1, 'Gadget used after close') if @handle.nil? + @handle + end + + # Whether close has released this wrapper's reference. + def closed? + @handle.nil? + end + + # Releases this wrapper's reference now rather than at GC time. + # Idempotent; the object itself is dropped when the last reference + # anywhere (another wrapper, a record field, the producer) goes. + def close return if @handle.nil? @handle.free @handle = nil end + # @api private + # Mints a new strong reference the caller owns (used when this + # object is written into a value buffer). + def _wv_clone_ptr + WeaveFFI.weaveffi_kitchen_Gadget_clone(handle) + end + + # dup and clone produce an independent wrapper with its own reference. + def initialize_copy(other) + super + @handle = GadgetPtr.new(WeaveFFI.weaveffi_kitchen_Gadget_clone(other.handle)) + end + # Create a gadget with the given id def initialize(id) err = ErrorStruct.new @@ -573,10 +743,10 @@ module WeaveFFI # Render the gadget as a human-readable string def describe() err = ErrorStruct.new - result = WeaveFFI.weaveffi_kitchen_Gadget_describe(@handle, err) + result = WeaveFFI.weaveffi_kitchen_Gadget_describe(handle, err) WeaveFFI.check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) WeaveFFI.weaveffi_free_string(result) str end @@ -584,7 +754,7 @@ module WeaveFFI # Poke the gadget a number of times, failing on a negative count def poke(times) err = ErrorStruct.new - result = WeaveFFI.weaveffi_kitchen_Gadget_poke(@handle, times, err) + result = WeaveFFI.weaveffi_kitchen_Gadget_poke(handle, times, err) WeaveFFI.check_kitchen_errors_error!(err) result end @@ -595,38 +765,19 @@ module WeaveFFI result = WeaveFFI.weaveffi_kitchen_Gadget_version(err) WeaveFFI.check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) WeaveFFI.weaveffi_free_string(result) str end end - # Subscribe to OnReady events - # Registers a OnReady listener block. Returns a subscription id for - # unregister_ready_listener. - def self.register_ready_listener(&block) - trampoline = FFI::Function.new(:void, [:int32, :string, :pointer]) do |code, msg, _context| - block.call(code, msg) - end - listener_id = weaveffi_kitchen_register_ready_listener(trampoline, FFI::Pointer::NULL) - @listener_refs[listener_id] = trampoline - listener_id - end - - # Unregisters a listener previously registered with register_ready_listener. - def self.unregister_ready_listener(listener_id) - weaveffi_kitchen_unregister_ready_listener(listener_id) - @listener_refs.delete(listener_id) - nil - end - # Identity for bool def self.bool_id(x) err = ErrorStruct.new x_c = x ? 1 : 0 result = weaveffi_kitchen_bool_id(x_c, err) check_error!(err) - result != 0 + (result != 0) end def self.i32_id(x) @@ -662,7 +813,7 @@ module WeaveFFI result = weaveffi_kitchen_echo_string(s, err) check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) str end @@ -681,47 +832,71 @@ module WeaveFFI data end - # Borrowed string parameter - def self.echo_borrowed_str(s) + # Register a listener, returning how many are attached + def self.subscribe(listener) err = ErrorStruct.new - result = weaveffi_kitchen_echo_borrowed_str(s, err) + listener_ctx = _wv_cb_register(listener) + result = weaveffi_kitchen_subscribe(listener_ctx, WV_READY_LISTENER_VTABLE.to_ptr, err) check_error!(err) - return '' if result.null? - str = result.read_string - weaveffi_free_string(result) - str + result end - # Borrowed bytes parameter - def self.echo_borrowed_bytes(b) + # Optional object in both directions + def self.maybe_gadget(fallback) err = ErrorStruct.new - b_buf = FFI::MemoryPointer.new(:uint8, b.bytesize) - b_buf.put_bytes(0, b) - out_len = FFI::MemoryPointer.new(:size_t) - result = weaveffi_kitchen_echo_borrowed_bytes(b_buf, b.bytesize, out_len, err) + result = weaveffi_kitchen_maybe_gadget(fallback&.handle, err) check_error!(err) - return ''.b if result.null? - len = out_len.read(:size_t) - data = result.read_string(len) - weaveffi_free_bytes(result, len) - data + return nil if result.null? + Gadget._from_ptr(result) end - # Returns an opaque handle - def self.open_handle() + # A list of objects + def self.gadgets() err = ErrorStruct.new - result = weaveffi_kitchen_open_handle(err) + out_len = FFI::MemoryPointer.new(:size_t) + result = weaveffi_kitchen_gadgets(out_len, err) check_error!(err) - result + len = out_len.read(:size_t) + data = result.null? ? ''.b : result.read_string(len) + weaveffi_free_bytes(result, len) unless result.null? + _wv_r = WvBufferReader.new(data) + _wv_value = Array.new(_wv_r.read_len) do + _wv_e0 = Gadget._from_ptr(_wv_r.read_object_token) + _wv_e0 + end + _wv_r.expect_end! + _wv_value end - # Returns a typed handle - def self.open_typed_handle() - err = ErrorStruct.new - result = weaveffi_kitchen_open_typed_handle(err) - check_error!(err) - raise Error.new(-1, 'null pointer') if result.null? - shared.Token.new(result) + # An iterator over objects + # Returns a lazy Enumerator that streams one element per pull; call + # `.to_a` to collect eagerly. The underlying producer iterator is + # launched on the first pull, so launch errors raise at that point + # rather than when this method returns. The iterator handle is + # released exactly once, when iteration finishes or is abandoned + # early (for example by `break`). + def self.stream_gadgets() + Enumerator.new do |y| + err = ErrorStruct.new + iter = weaveffi_kitchen_stream_gadgets(err) + begin + check_error!(err) + unless iter.null? + loop do + out_item = FFI::MemoryPointer.new(:pointer) + item_err = ErrorStruct.new + has_item = weaveffi_kitchen_StreamGadgetsIterator_next(iter, out_item, item_err) + check_error!(item_err) + break if has_item.zero? + item_ptr = out_item.read_pointer + raise Error.new(-1, 'null pointer') if item_ptr.null? + y << Gadget._from_ptr(item_ptr) + end + end + ensure + weaveffi_kitchen_StreamGadgetsIterator_destroy(iter) unless iter.null? + end + end end # Optional struct return @@ -842,11 +1017,11 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else - _wv_s = result.null? ? '' : result.read_string + _wv_s = result.null? ? '' : result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) unless result.null? queue << _wv_s end @@ -866,11 +1041,11 @@ module WeaveFFI err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) if err && err[:code] != 0 code = err[:code] - msg = err[:message].null? ? '' : err[:message].read_string + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) weaveffi_error_free(err_ptr) queue << Error.new(code, msg) else - _wv_s = result.null? ? '' : result.read_string + _wv_s = result.null? ? '' : result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) unless result.null? queue << _wv_s end @@ -908,7 +1083,7 @@ module WeaveFFI result = weaveffi_kitchen_nested_hello(err) check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) str end diff --git a/crates/weaveffi-cli/tests/snapshots/ruby_nested_modules__lib_weaveffi_rb.snap b/crates/weaveffi-cli/tests/snapshots/ruby_nested_modules__lib_weaveffi_rb.snap index 7373796c..fea71f45 100644 --- a/crates/weaveffi-cli/tests/snapshots/ruby_nested_modules__lib_weaveffi_rb.snap +++ b/crates/weaveffi-cli/tests/snapshots/ruby_nested_modules__lib_weaveffi_rb.snap @@ -32,7 +32,7 @@ module WeaveFFI # The ABI revision these bindings were generated against. Checked before any # other symbol is attached so a mismatched producer fails at require time # instead of misreading the error struct or a value buffer later. - ABI_VERSION = 1 + ABI_VERSION = 2 begin attach_function :weaveffi_abi_version, [], :uint32 rescue FFI::NotFoundError @@ -52,6 +52,16 @@ module WeaveFFI :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -225,12 +235,21 @@ module WeaveFFI take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') end end + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -240,7 +259,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end @@ -266,7 +285,7 @@ module WeaveFFI # Builds the GraphicsError subclass matching `code`, decoding any payload # fields the code declares, or a generic Error for codes outside - # the domain (panics, marshalling). + # the domain (panics, marshalling, callback failures). def self.graphics_error_from(code, message, payload = nil) cls = GRAPHICS_ERROR_CODES[code] return Error.new(code, message) if cls.nil? @@ -278,7 +297,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) payload_ptr = err[:payload_ptr] payload = payload_ptr.null? ? nil : payload_ptr.read_string(err[:payload_len]) weaveffi_error_clear(err.to_ptr) diff --git a/crates/weaveffi-cli/tests/snapshots/ruby_shapes__lib_weaveffi_rb.snap b/crates/weaveffi-cli/tests/snapshots/ruby_shapes__lib_weaveffi_rb.snap index cc7e0094..38854662 100644 --- a/crates/weaveffi-cli/tests/snapshots/ruby_shapes__lib_weaveffi_rb.snap +++ b/crates/weaveffi-cli/tests/snapshots/ruby_shapes__lib_weaveffi_rb.snap @@ -32,7 +32,7 @@ module WeaveFFI # The ABI revision these bindings were generated against. Checked before any # other symbol is attached so a mismatched producer fails at require time # instead of misreading the error struct or a value buffer later. - ABI_VERSION = 1 + ABI_VERSION = 2 begin attach_function :weaveffi_abi_version, [], :uint32 rescue FFI::NotFoundError @@ -52,6 +52,16 @@ module WeaveFFI :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -225,12 +235,21 @@ module WeaveFFI take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') end end + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -240,7 +259,7 @@ module WeaveFFI return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end @@ -519,7 +538,7 @@ module WeaveFFI result = weaveffi_shapes_describe(shape_buf, shape_data.bytesize, err) check_error!(err) return '' if result.null? - str = result.read_string + str = result.read_string.force_encoding(Encoding::UTF_8) weaveffi_free_string(result) str end diff --git a/crates/weaveffi-cli/tests/snapshots/swift_docs_everywhere__Sources_WeaveFFI_WeaveFFI_swift.snap b/crates/weaveffi-cli/tests/snapshots/swift_docs_everywhere__Sources_WeaveFFI_WeaveFFI_swift.snap index 01cda7f7..dce3741d 100644 --- a/crates/weaveffi-cli/tests/snapshots/swift_docs_everywhere__Sources_WeaveFFI_WeaveFFI_swift.snap +++ b/crates/weaveffi-cli/tests/snapshots/swift_docs_everywhere__Sources_WeaveFFI_WeaveFFI_swift.snap @@ -83,6 +83,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -160,6 +163,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } @@ -170,14 +181,13 @@ private final class ContinuationRef { init(_ value: CheckedContinuation) { self.value = value } } -final class WvCallbackBox { - let value: T - init(_ value: T) { self.value = value } +/// Reports a thrown Swift error to the producer as a foreign callback failure +/// (code -4); the producer aborts its current call with that code and message. +func wvForeignError(_ outErr: UnsafeMutablePointer?, _ error: Error) { + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + message.withCString { weaveffi_error_set(outErr, -4, $0) } } -var wvListenerContexts: [UInt64: UnsafeMutableRawPointer] = [:] -let wvListenerLock = NSLock() - /// Typed errors reported by the `docs` module. public enum DocsErrorsError: Error, LocalizedError { /// Returned when no document matches the requested id @@ -279,6 +289,53 @@ func wvReadDocument(_ r: inout WvReader) -> Document { return Document(id: v1, title: v2, status: v3, tags: v4) } +/// Consumer-implemented observer of document-saved notifications. +/// +/// Implementations must be safe to call from any thread. +/// +/// Implement this protocol and pass the value where the API expects it; the +/// producer keeps it alive for as long as it holds the callback and may call +/// any method from any thread. A thrown error aborts the producer's call and +/// surfaces to the original caller as a foreign error (code -4). +public protocol SavedListener { + /// Fires after a document has been persisted successfully + /// - Parameter docId: Identifier of the saved document + /// - Parameter revision: Monotonically increasing revision + func onSaved(docId: Int64, revision: Int32) throws +} + +/// Retains one `SavedListener` implementation across the C boundary; the producer's +/// `free` entry releases it. +final class WvSavedListenerBox { + let impl: any SavedListener + init(_ impl: any SavedListener) { self.impl = impl } +} + +/// The process-wide `weaveffi_docs_SavedListener_vtable` the producer calls `SavedListener` +/// implementations through. Every entry recovers the box from `ctx`, copies +/// or adopts its arguments, and reports a thrown error through `out_err` +/// instead of unwinding. +enum WvSavedListenerVtable { + static let value = weaveffi_docs_SavedListener_vtable( + on_saved: { ctx, doc_id, revision, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + try wvBox.impl.onSaved(docId: doc_id, revision: revision) + } catch { + wvForeignError(out_err, error) + } + }, + free: { ctx in + Unmanaged.fromOpaque(ctx!).release() + } + ) + static let pointer: UnsafePointer = { + let cell = UnsafeMutablePointer.allocate(capacity: 1) + cell.initialize(to: value) + return UnsafePointer(cell) + }() +} + /// An ordered collection of documents. /// /// Notebooks own their documents and release them when destroyed. @@ -292,6 +349,12 @@ public final class Notebook { deinit { weaveffi_docs_Notebook_destroy(ptr) } + + /// Returns a new strong reference to the same object, for a position that + /// takes ownership of it (an object token inside a value buffer). + func clonePtr() -> OpaquePointer { + weaveffi_docs_Notebook_clone(ptr)! + } /// Create an empty notebook public init() { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -346,30 +409,6 @@ public final class Notebook { } public enum Docs { - /// Subscribe to document-saved notifications - /// - Returns: A subscription id for ``unregisterSavedListener(_:)``. - public static func registerSavedListener(_ callback: @escaping (Int64, Int32) -> Void) -> UInt64 { - let box = WvCallbackBox(callback) - let ctx = Unmanaged.passRetained(box).toOpaque() - let id = weaveffi_docs_register_saved_listener({ doc_id, revision, context in - let cb = Unmanaged Void>>.fromOpaque(context!).takeUnretainedValue().value - cb(doc_id, revision) - }, ctx) - wvListenerLock.lock() - wvListenerContexts[id] = ctx - wvListenerLock.unlock() - return id - } - /// Unregisters a listener previously registered with ``registerSavedListener(_:)``. - public static func unregisterSavedListener(_ id: UInt64) { - weaveffi_docs_unregister_saved_listener(id) - wvListenerLock.lock() - let ctx = wvListenerContexts.removeValue(forKey: id) - wvListenerLock.unlock() - if let ctx = ctx { - Unmanaged Void>>.fromOpaque(ctx).release() - } - } /// Create a brand new document. /// /// The document starts in `Draft` status; callers must publish @@ -437,6 +476,14 @@ public enum Docs { }, ctx) } } + /// Register a listener for saved notifications + /// - Parameter listener: Receiver of saved notifications + public static func watch(listener: SavedListener) -> Void { + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + let listener_ctx = Unmanaged.passRetained(WvSavedListenerBox(listener)).toOpaque() + weaveffi_docs_watch(listener_ctx, WvSavedListenerVtable.pointer, &err) + trap(&err) + } } // END WeaveFFI.swift diff --git a/crates/weaveffi-cli/tests/snapshots/swift_edge_cases__Sources_WeaveFFI_WeaveFFI_swift.snap b/crates/weaveffi-cli/tests/snapshots/swift_edge_cases__Sources_WeaveFFI_WeaveFFI_swift.snap index 390ae646..79fd765a 100644 --- a/crates/weaveffi-cli/tests/snapshots/swift_edge_cases__Sources_WeaveFFI_WeaveFFI_swift.snap +++ b/crates/weaveffi-cli/tests/snapshots/swift_edge_cases__Sources_WeaveFFI_WeaveFFI_swift.snap @@ -83,6 +83,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -160,6 +163,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } @@ -170,14 +181,13 @@ private final class ContinuationRef { init(_ value: CheckedContinuation) { self.value = value } } -final class WvCallbackBox { - let value: T - init(_ value: T) { self.value = value } +/// Reports a thrown Swift error to the producer as a foreign callback failure +/// (code -4); the producer aborts its current call with that code and message. +func wvForeignError(_ outErr: UnsafeMutablePointer?, _ error: Error) { + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + message.withCString { weaveffi_error_set(outErr, -4, $0) } } -var wvListenerContexts: [UInt64: UnsafeMutableRawPointer] = [:] -let wvListenerLock = NSLock() - /// Typed errors reported by the `edge_cases` module. public enum EdgeErrorsError: Error, LocalizedError { case busy(message: String) @@ -707,6 +717,232 @@ func wvReadRetired(_ r: inout WvReader) -> Retired { return Retired(id: v0) } +/// Record carrying objects in every buffered position +public struct WidgetHolder { + public var primary: Widget + public var fallback: Widget? + public var extras: [Widget] + public var byName: [String: Widget] + + /// Creates a `WidgetHolder` value. + public init(primary: Widget, fallback: Widget?, extras: [Widget], byName: [String: Widget]) { + self.primary = primary + self.fallback = fallback + self.extras = extras + self.byName = byName + } +} + +/// Serializes a `WidgetHolder` into the value-buffer wire format. +func wvWriteWidgetHolder(_ value: WidgetHolder, into w: inout WvWriter) { + w.writeObject(value.primary.clonePtr()) + if let v0 = value.fallback { + w.writeOptionFlag(true) + w.writeObject(v0.clonePtr()) + } else { + w.writeOptionFlag(false) + } + w.writeLen(value.extras.count) + for v1 in value.extras { + w.writeObject(v1.clonePtr()) + } + w.writeLen(value.byName.count) + for (v2, v3) in value.byName { + w.writeString(v2) + w.writeObject(v3.clonePtr()) + } +} + +/// Deserializes a `WidgetHolder` from the value-buffer wire format. +func wvReadWidgetHolder(_ r: inout WvReader) -> WidgetHolder { + let v4 = Widget(ptr: r.readObject()) + var v5: Widget? = nil + if r.readOptionFlag() { + let v6 = Widget(ptr: r.readObject()) + v5 = v6 + } + let n8 = r.readLen() + var v7: [Widget] = [] + v7.reserveCapacity(n8) + for _ in 0.. Bool + /// Enum return travels back as its integer discriminant + func pickMode() throws -> Mode +} + +/// Retains one `BatchListener` implementation across the C boundary; the producer's +/// `free` entry releases it. +final class WvBatchListenerBox { + let impl: any BatchListener + init(_ impl: any BatchListener) { self.impl = impl } +} + +/// The process-wide `weaveffi_edge_cases_BatchListener_vtable` the producer calls `BatchListener` +/// implementations through. Every entry recovers the box from `ctx`, copies +/// or adopts its arguments, and reports a thrown error through `out_err` +/// instead of unwinding. +enum WvBatchListenerVtable { + static let value = weaveffi_edge_cases_BatchListener_vtable( + on_batch: { ctx, items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + let b0 = [UInt8](UnsafeBufferPointer(start: items_ptr, count: items_len)) + var r1 = WvReader(bytes: b0) + let n3 = r1.readLen() + var v2: [Outer] = [] + v2.reserveCapacity(n3) + for _ in 0...fromOpaque(ctx!).takeUnretainedValue() + let b0 = [UInt8](UnsafeBufferPointer(start: children_ptr, count: children_len)) + var r1 = WvReader(bytes: b0) + let n3 = r1.readLen() + var v2: [Widget] = [] + v2.reserveCapacity(n3) + for _ in 0...fromOpaque(ctx!).takeUnretainedValue() + do { + try wvBox.impl.onTick() + } catch { + wvForeignError(out_err, error) + } + }, + should_continue: { ctx, class_, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + return try wvBox.impl.shouldContinue(class_: class_) + } catch { + wvForeignError(out_err, error) + return false + } + }, + pick_mode: { ctx, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + return weaveffi_edge_cases_Mode(try wvBox.impl.pickMode().rawValue) + } catch { + wvForeignError(out_err, error) + return weaveffi_edge_cases_Mode(0) + } + }, + free: { ctx in + Unmanaged.fromOpaque(ctx!).release() + } + ) + static let pointer: UnsafePointer = { + let cell = UnsafeMutablePointer.allocate(capacity: 1) + cell.initialize(to: value) + return UnsafePointer(cell) + }() +} + +/// Callback interface whose name and methods are keywords +/// +/// Implement this protocol and pass the value where the API expects it; the +/// producer keeps it alive for as long as it holds the callback and may call +/// any method from any thread. A thrown error aborts the producer's call and +/// surfaces to the original caller as a foreign error (code -4). +public protocol Keyword { + func delete(self_: Int32) throws + func import_() throws -> Int32 +} + +/// Retains one `Keyword` implementation across the C boundary; the producer's +/// `free` entry releases it. +final class WvKeywordBox { + let impl: any Keyword + init(_ impl: any Keyword) { self.impl = impl } +} + +/// The process-wide `weaveffi_edge_cases_Keyword_vtable` the producer calls `Keyword` +/// implementations through. Every entry recovers the box from `ctx`, copies +/// or adopts its arguments, and reports a thrown error through `out_err` +/// instead of unwinding. +enum WvKeywordVtable { + static let value = weaveffi_edge_cases_Keyword_vtable( + delete_: { ctx, self_, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + try wvBox.impl.delete(self_: self_) + } catch { + wvForeignError(out_err, error) + } + }, + import: { ctx, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + return try wvBox.impl.import_() + } catch { + wvForeignError(out_err, error) + return 0 + } + }, + free: { ctx in + Unmanaged.fromOpaque(ctx!).release() + } + ) + static let pointer: UnsafePointer = { + let cell = UnsafeMutablePointer.allocate(capacity: 1) + cell.initialize(to: value) + return UnsafePointer(cell) + }() +} + /// Interface with no constructor; instances come from make_widget public final class Widget { let ptr: OpaquePointer @@ -718,6 +954,12 @@ public final class Widget { deinit { weaveffi_edge_cases_Widget_destroy(ptr) } + + /// Returns a new strong reference to the same object, for a position that + /// takes ownership of it (an object token inside a value buffer). + func clonePtr() -> OpaquePointer { + weaveffi_edge_cases_Widget_clone(ptr)! + } /// Void method that can fail public func refresh() throws -> Void { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -794,6 +1036,12 @@ public final class Vault { deinit { weaveffi_edge_cases_Vault_destroy(ptr) } + + /// Returns a new strong reference to the same object, for a position that + /// takes ownership of it (an object token inside a value buffer). + func clonePtr() -> OpaquePointer { + weaveffi_edge_cases_Vault_clone(ptr)! + } /// Fails when the vault is busy public static func open(path: String) throws -> Vault { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -819,6 +1067,48 @@ public final class Vault { } } +/// A lazy sequence over the `Widget` elements streamed by `weaveffi_edge_cases_widget_stream`. +/// +/// Each `next()` call pulls exactly one element from the producer. The +/// underlying C iterator is destroyed eagerly on exhaustion and from +/// `deinit` when iteration is abandoned early. +public final class EdgeCasesWidgetStreamIterator: Sequence, IteratorProtocol { + private var handle: OpaquePointer? + + init(handle: OpaquePointer) { + self.handle = handle + } + + deinit { + destroyHandle() + } + + private func destroyHandle() { + guard let handle = handle else { return } + weaveffi_edge_cases_WidgetStreamIterator_destroy(handle) + self.handle = nil + } + + /// Pulls the next element from the producer, or returns `nil` once the + /// stream is exhausted (destroying the underlying iterator). + public func next() -> Widget? { + guard let handle = handle else { return nil } + var item: OpaquePointer? = nil + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + if weaveffi_edge_cases_WidgetStreamIterator_next(handle, &item, &err) == 0 { + if err.code != 0 { + let code = err.code + let message = err.message.flatMap { String(cString: $0) } ?? "" + weaveffi_error_clear(&err) + fatalError("\(code): \(message)") + } + destroyHandle() + return nil + } + return Widget(ptr: item!) + } +} + /// A lazy sequence over the `String` elements streamed by `weaveffi_edge_cases_string_stream`. /// /// Each `next()` call pulls exactly one element from the producer. The @@ -901,8 +1191,7 @@ public final class EdgeCasesScalarStreamIterator: Sequence, IteratorProtocol { destroyHandle() return nil } - let element = item - return element + return item } } @@ -944,76 +1233,11 @@ public final class EdgeCasesModeStreamIterator: Sequence, IteratorProtocol { destroyHandle() return nil } - let element = Mode(rawValue: item.rawValue)! - return element + return Mode(rawValue: item.rawValue)! } } public enum EdgeCases { - /// - Returns: A subscription id for ``unregisterBatchListener(_:)``. - public static func registerBatchListener(_ callback: @escaping ([Outer], Data, Outer?, Mode) -> Void) -> UInt64 { - let box = WvCallbackBox(callback) - let ctx = Unmanaged.passRetained(box).toOpaque() - let id = weaveffi_edge_cases_register_batch_listener({ items_ptr, items_len, raw_ptr, raw_len, first_ptr, first_len, mode, context in - let cb = Unmanaged Void>>.fromOpaque(context!).takeUnretainedValue().value - let itemsBuf = [UInt8](UnsafeBufferPointer(start: items_ptr, count: items_len)) - var itemsReader = WvReader(bytes: itemsBuf) - let n1 = itemsReader.readLen() - var v0: [Outer] = [] - v0.reserveCapacity(n1) - for _ in 0.. Void>>.fromOpaque(ctx).release() - } - } - /// - Returns: A subscription id for ``unregisterTickListener(_:)``. - public static func registerTickListener(_ callback: @escaping () -> Void) -> UInt64 { - let box = WvCallbackBox(callback) - let ctx = Unmanaged.passRetained(box).toOpaque() - let id = weaveffi_edge_cases_register_tick_listener({ context in - let cb = Unmanaged Void>>.fromOpaque(context!).takeUnretainedValue().value - cb() - }, ctx) - wvListenerLock.lock() - wvListenerContexts[id] = ctx - wvListenerLock.unlock() - return id - } - /// Unregisters a listener previously registered with ``registerTickListener(_:)``. - public static func unregisterTickListener(_ id: UInt64) { - weaveffi_edge_cases_unregister_tick_listener(id) - wvListenerLock.lock() - let ctx = wvListenerContexts.removeValue(forKey: id) - wvListenerLock.unlock() - if let ctx = ctx { - Unmanaged Void>>.fromOpaque(ctx).release() - } - } /// No parameters and no result public static func noop() -> Void { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -1182,18 +1406,99 @@ public enum EdgeCases { rvReader.finish() return v7 } - /// Write-back parameters - public static func mutableParams(text: String, blob: Data) -> Void { + /// Two callback interface parameters on one function + public static func registerBatch(listener: BatchListener, keyword: Keyword) -> Void { + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + let listener_ctx = Unmanaged.passRetained(WvBatchListenerBox(listener)).toOpaque() + let keyword_ctx = Unmanaged.passRetained(WvKeywordBox(keyword)).toOpaque() + weaveffi_edge_cases_register_batch(listener_ctx, WvBatchListenerVtable.pointer, keyword_ctx, WvKeywordVtable.pointer, &err) + trap(&err) + } + /// Objects in every buffered position + public static func widgetsEverywhere(list: [Widget], byName: [String: Widget], sparse: [Widget?], holder: WidgetHolder) -> WidgetHolder { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let blobBytes = Array(blob) - text.withCString { text_ptr in - blobBytes.withUnsafeBufferPointer { blob_buf in - let blob_ptr = blob_buf.baseAddress - let blob_len = blob_buf.count - weaveffi_edge_cases_mutable_params(text_ptr, blob_ptr, blob_len, &err) + var listWriter = WvWriter() + listWriter.writeLen(list.count) + for v0 in list { + listWriter.writeObject(v0.clonePtr()) + } + var byNameWriter = WvWriter() + byNameWriter.writeLen(byName.count) + for (v1, v2) in byName { + byNameWriter.writeString(v1) + byNameWriter.writeObject(v2.clonePtr()) + } + var sparseWriter = WvWriter() + sparseWriter.writeLen(sparse.count) + for v3 in sparse { + if let v4 = v3 { + sparseWriter.writeOptionFlag(true) + sparseWriter.writeObject(v4.clonePtr()) + } else { + sparseWriter.writeOptionFlag(false) + } + } + var holderWriter = WvWriter() + wvWriteWidgetHolder(holder, into: &holderWriter) + var outLen: Int = 0 + let rv: UnsafePointer? = listWriter.bytes.withUnsafeBufferPointer { list_buf in + let list_ptr = list_buf.baseAddress + let list_len = list_buf.count + return byNameWriter.bytes.withUnsafeBufferPointer { byName_buf in + let byName_ptr = byName_buf.baseAddress + let byName_len = byName_buf.count + return sparseWriter.bytes.withUnsafeBufferPointer { sparse_buf in + let sparse_ptr = sparse_buf.baseAddress + let sparse_len = sparse_buf.count + return holderWriter.bytes.withUnsafeBufferPointer { holder_buf in + let holder_ptr = holder_buf.baseAddress + let holder_len = holder_buf.count + return weaveffi_edge_cases_widgets_everywhere(list_ptr, list_len, byName_ptr, byName_len, sparse_ptr, sparse_len, holder_ptr, holder_len, &outLen, &err) + } + } } } trap(&err) + guard let rv = rv else { fatalError("-1: null buffer") } + let rvBytes = [UInt8](UnsafeBufferPointer(start: rv, count: outLen)) + weaveffi_free_bytes(UnsafeMutablePointer(mutating: rv), outLen) + var rvReader = WvReader(bytes: rvBytes) + let v5 = wvReadWidgetHolder(&rvReader) + rvReader.finish() + return v5 + } + /// Iterator of objects + /// - Returns: A lazy sequence that pulls one element per step from the + /// producer; the underlying iterator is destroyed when the sequence is + /// exhausted or deinitialized. + public static func widgetStream() -> EdgeCasesWidgetStreamIterator { + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + let rv = weaveffi_edge_cases_widget_stream(&err) + trap(&err) + guard let rv = rv else { fatalError("-1: null iterator") } + return EdgeCasesWidgetStreamIterator(handle: rv) + } + /// Async returning an object + public static func asyncWidget() async throws -> Widget { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let ctx = Unmanaged.passRetained(ContinuationRef(continuation)).toOpaque() + weaveffi_edge_cases_async_widget_async({ context, err, result in + let contRef = Unmanaged>.fromOpaque(context!).takeRetainedValue() + if let err = err, err.pointee.code != 0 { + let code = err.pointee.code + let msg = err.pointee.message.flatMap { String(cString: $0) } ?? "" + let payload: [UInt8]? = err.pointee.payload_ptr.map { [UInt8](UnsafeBufferPointer(start: $0, count: err.pointee.payload_len)) } + weaveffi_error_free(err) + contRef.value.resume(throwing: mapEdgeCases(code: code, message: msg, payload: payload)) + } else { + guard let result = result else { + contRef.value.resume(throwing: WeaveFFIError.error(code: -1, message: "null pointer")) + return + } + contRef.value.resume(returning: Widget(ptr: result)) + } + }, ctx) + } } /// Factory for an interface with no constructor public static func makeWidget(label: String) -> Widget { @@ -1221,12 +1526,6 @@ public enum EdgeCases { defer { weaveffi_free_string(rv) } return String(cString: rv) } - public static func typedHandleParam(token: UInt64, raw: UInt64) -> UInt64 { - var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let rv = weaveffi_edge_cases_typed_handle_param(OpaquePointer(bitPattern: UInt(token)), raw, &err) - trap(&err) - return UInt64(UInt(bitPattern: rv)) - } /// Iterator of strings /// - Returns: A lazy sequence that pulls one element per step from the /// producer; the underlying iterator is destroyed when the sequence is diff --git a/crates/weaveffi-cli/tests/snapshots/swift_kitchen_sink__Sources_WeaveFFI_WeaveFFI_swift.snap b/crates/weaveffi-cli/tests/snapshots/swift_kitchen_sink__Sources_WeaveFFI_WeaveFFI_swift.snap index 450e5539..97b1a72c 100644 --- a/crates/weaveffi-cli/tests/snapshots/swift_kitchen_sink__Sources_WeaveFFI_WeaveFFI_swift.snap +++ b/crates/weaveffi-cli/tests/snapshots/swift_kitchen_sink__Sources_WeaveFFI_WeaveFFI_swift.snap @@ -83,6 +83,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -160,6 +163,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } @@ -170,14 +181,13 @@ private final class ContinuationRef { init(_ value: CheckedContinuation) { self.value = value } } -final class WvCallbackBox { - let value: T - init(_ value: T) { self.value = value } +/// Reports a thrown Swift error to the producer as a foreign callback failure +/// (code -4); the producer aborts its current call with that code and message. +func wvForeignError(_ outErr: UnsafeMutablePointer?, _ error: Error) { + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + message.withCString { weaveffi_error_set(outErr, -4, $0) } } -var wvListenerContexts: [UInt64: UnsafeMutableRawPointer] = [:] -let wvListenerLock = NSLock() - /// A reusable token referenced across modules public struct Token { public var id: Int64 @@ -284,9 +294,11 @@ public struct Item { /// Cross-module struct reference public var token: Token public var priority: Priority + /// An optional object inside a record + public var gadget: Gadget? /// Creates a `Item` value. - public init(id: Int64, name: String, count: Int32, enabled: Bool, ratio: Double, nick: UInt32, payload: Data, tags: [String], attrs: [String: String], parent: Int64?, token: Token, priority: Priority) { + public init(id: Int64, name: String, count: Int32, enabled: Bool, ratio: Double, nick: UInt32, payload: Data, tags: [String], attrs: [String: String], parent: Int64?, token: Token, priority: Priority, gadget: Gadget?) { self.id = id self.name = name self.count = count @@ -299,6 +311,7 @@ public struct Item { self.parent = parent self.token = token self.priority = priority + self.gadget = gadget } } @@ -328,40 +341,109 @@ func wvWriteItem(_ value: Item, into w: inout WvWriter) { } wvWriteToken(value.token, into: &w) w.writeInt32(Int32(bitPattern: value.priority.rawValue)) + if let v4 = value.gadget { + w.writeOptionFlag(true) + w.writeObject(v4.clonePtr()) + } else { + w.writeOptionFlag(false) + } } /// Deserializes a `Item` from the value-buffer wire format. func wvReadItem(_ r: inout WvReader) -> Item { - let v4 = r.readInt64() - let v5 = r.readString() - let v6 = r.readInt32() - let v7 = r.readBool() - let v8 = r.readDouble() - let v9 = r.readUInt32() - let v10 = r.readBytes() - let n12 = r.readLen() - var v11: [String] = [] - v11.reserveCapacity(n12) - for _ in 0.. Bool +} + +/// Retains one `ReadyListener` implementation across the C boundary; the producer's +/// `free` entry releases it. +final class WvReadyListenerBox { + let impl: any ReadyListener + init(_ impl: any ReadyListener) { self.impl = impl } +} + +/// The process-wide `weaveffi_kitchen_ReadyListener_vtable` the producer calls `ReadyListener` +/// implementations through. Every entry recovers the box from `ctx`, copies +/// or adopts its arguments, and reports a thrown error through `out_err` +/// instead of unwinding. +enum WvReadyListenerVtable { + static let value = weaveffi_kitchen_ReadyListener_vtable( + on_ready: { ctx, code, msg, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + try wvBox.impl.onReady(code: code, msg: String(cString: msg!)) + } catch { + wvForeignError(out_err, error) + } + }, + on_item: { ctx, item_ptr, item_len, gadget, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + let b0 = [UInt8](UnsafeBufferPointer(start: item_ptr, count: item_len)) + var r1 = WvReader(bytes: b0) + let v2 = wvReadItem(&r1) + r1.finish() + do { + return try wvBox.impl.onItem(item: v2, gadget: Gadget(ptr: gadget!)) + } catch { + wvForeignError(out_err, error) + return false + } + }, + free: { ctx in + Unmanaged.fromOpaque(ctx!).release() + } + ) + static let pointer: UnsafePointer = { + let cell = UnsafeMutablePointer.allocate(capacity: 1) + cell.initialize(to: value) + return UnsafePointer(cell) + }() } /// A pocket-sized interface exercising the object surface @@ -375,6 +457,12 @@ public final class Gadget { deinit { weaveffi_kitchen_Gadget_destroy(ptr) } + + /// Returns a new strong reference to the same object, for a position that + /// takes ownership of it (an object token inside a value buffer). + func clonePtr() -> OpaquePointer { + weaveffi_kitchen_Gadget_clone(ptr)! + } /// Create a gadget with the given id public init(id: Int64) { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -410,6 +498,48 @@ public final class Gadget { } } +/// A lazy sequence over the `Gadget` elements streamed by `weaveffi_kitchen_stream_gadgets`. +/// +/// Each `next()` call pulls exactly one element from the producer. The +/// underlying C iterator is destroyed eagerly on exhaustion and from +/// `deinit` when iteration is abandoned early. +public final class KitchenStreamGadgetsIterator: Sequence, IteratorProtocol { + private var handle: OpaquePointer? + + init(handle: OpaquePointer) { + self.handle = handle + } + + deinit { + destroyHandle() + } + + private func destroyHandle() { + guard let handle = handle else { return } + weaveffi_kitchen_StreamGadgetsIterator_destroy(handle) + self.handle = nil + } + + /// Pulls the next element from the producer, or returns `nil` once the + /// stream is exhausted (destroying the underlying iterator). + public func next() -> Gadget? { + guard let handle = handle else { return nil } + var item: OpaquePointer? = nil + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + if weaveffi_kitchen_StreamGadgetsIterator_next(handle, &item, &err) == 0 { + if err.code != 0 { + let code = err.code + let message = err.message.flatMap { String(cString: $0) } ?? "" + weaveffi_error_clear(&err) + fatalError("\(code): \(message)") + } + destroyHandle() + return nil + } + return Gadget(ptr: item!) + } +} + /// A lazy sequence over the `Item` elements streamed by `weaveffi_kitchen_stream_items`. /// /// Each `next()` call pulls exactly one element from the producer. The @@ -459,30 +589,6 @@ public final class KitchenStreamItemsIterator: Sequence, IteratorProtocol { } public enum Kitchen { - /// Subscribe to OnReady events - /// - Returns: A subscription id for ``unregisterReadyListener(_:)``. - public static func registerReadyListener(_ callback: @escaping (Int32, String) -> Void) -> UInt64 { - let box = WvCallbackBox(callback) - let ctx = Unmanaged.passRetained(box).toOpaque() - let id = weaveffi_kitchen_register_ready_listener({ code, msg, context in - let cb = Unmanaged Void>>.fromOpaque(context!).takeUnretainedValue().value - cb(code, String(cString: msg!)) - }, ctx) - wvListenerLock.lock() - wvListenerContexts[id] = ctx - wvListenerLock.unlock() - return id - } - /// Unregisters a listener previously registered with ``registerReadyListener(_:)``. - public static func unregisterReadyListener(_ id: UInt64) { - weaveffi_kitchen_unregister_ready_listener(id) - wvListenerLock.lock() - let ctx = wvListenerContexts.removeValue(forKey: id) - wvListenerLock.unlock() - if let ctx = ctx { - Unmanaged Void>>.fromOpaque(ctx).release() - } - } /// Identity for bool public static func boolId(x: Bool) -> Bool { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) @@ -538,45 +644,51 @@ public enum Kitchen { defer { weaveffi_free_bytes(UnsafeMutablePointer(mutating: rv), outLen) } return Data(bytes: rv, count: outLen) } - /// Borrowed string parameter - public static func echoBorrowedStr(s: String) -> String { + /// Register a listener, returning how many are attached + public static func subscribe(listener: ReadyListener) -> Int32 { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let rv: UnsafePointer? = s.withCString { s_ptr in - return weaveffi_kitchen_echo_borrowed_str(s_ptr, &err) - } + let listener_ctx = Unmanaged.passRetained(WvReadyListenerBox(listener)).toOpaque() + let rv = weaveffi_kitchen_subscribe(listener_ctx, WvReadyListenerVtable.pointer, &err) trap(&err) - guard let rv = rv else { fatalError("-1: null string") } - defer { weaveffi_free_string(rv) } - return String(cString: rv) + return rv } - /// Borrowed bytes parameter - public static func echoBorrowedBytes(b: Data) -> Data { + /// Optional object in both directions + public static func maybeGadget(fallback: Gadget?) -> Gadget? { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let bBytes = Array(b) - var outLen: Int = 0 - let rv: UnsafePointer? = bBytes.withUnsafeBufferPointer { b_buf in - let b_ptr = b_buf.baseAddress - let b_len = b_buf.count - return weaveffi_kitchen_echo_borrowed_bytes(b_ptr, b_len, &outLen, &err) - } + let rv = weaveffi_kitchen_maybe_gadget(fallback?.ptr, &err) trap(&err) - guard let rv = rv else { return Data() } - defer { weaveffi_free_bytes(UnsafeMutablePointer(mutating: rv), outLen) } - return Data(bytes: rv, count: outLen) + return rv.map { Gadget(ptr: $0) } } - /// Returns an opaque handle - public static func openHandle() -> UInt64 { + /// A list of objects + public static func gadgets() -> [Gadget] { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let rv = weaveffi_kitchen_open_handle(&err) + var outLen: Int = 0 + let rv = weaveffi_kitchen_gadgets(&outLen, &err) trap(&err) - return rv + guard let rv = rv else { fatalError("-1: null buffer") } + let rvBytes = [UInt8](UnsafeBufferPointer(start: rv, count: outLen)) + weaveffi_free_bytes(UnsafeMutablePointer(mutating: rv), outLen) + var rvReader = WvReader(bytes: rvBytes) + let n1 = rvReader.readLen() + var v0: [Gadget] = [] + v0.reserveCapacity(n1) + for _ in 0.. UInt64 { + /// An iterator over objects + /// - Returns: A lazy sequence that pulls one element per step from the + /// producer; the underlying iterator is destroyed when the sequence is + /// exhausted or deinitialized. + public static func streamGadgets() -> KitchenStreamGadgetsIterator { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let rv = weaveffi_kitchen_open_typed_handle(&err) + let rv = weaveffi_kitchen_stream_gadgets(&err) trap(&err) - return UInt64(UInt(bitPattern: rv)) + guard let rv = rv else { fatalError("-1: null iterator") } + return KitchenStreamGadgetsIterator(handle: rv) } /// Optional struct return public static func maybeItem(id: Int64) throws -> Item? { diff --git a/crates/weaveffi-cli/tests/snapshots/swift_nested_modules__Sources_WeaveFFI_WeaveFFI_swift.snap b/crates/weaveffi-cli/tests/snapshots/swift_nested_modules__Sources_WeaveFFI_WeaveFFI_swift.snap index c7a4f5a6..89b960ce 100644 --- a/crates/weaveffi-cli/tests/snapshots/swift_nested_modules__Sources_WeaveFFI_WeaveFFI_swift.snap +++ b/crates/weaveffi-cli/tests/snapshots/swift_nested_modules__Sources_WeaveFFI_WeaveFFI_swift.snap @@ -83,6 +83,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -160,6 +163,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } diff --git a/crates/weaveffi-cli/tests/snapshots/swift_shapes__Sources_WeaveFFI_WeaveFFI_swift.snap b/crates/weaveffi-cli/tests/snapshots/swift_shapes__Sources_WeaveFFI_WeaveFFI_swift.snap index 7b2d6efb..18381fb2 100644 --- a/crates/weaveffi-cli/tests/snapshots/swift_shapes__Sources_WeaveFFI_WeaveFFI_swift.snap +++ b/crates/weaveffi-cli/tests/snapshots/swift_shapes__Sources_WeaveFFI_WeaveFFI_swift.snap @@ -83,6 +83,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -160,6 +163,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__README_md.snap b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__README_md.snap index 94882ac3..28735168 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__README_md.snap @@ -15,7 +15,7 @@ Build (example): cargo build --target wasm32-unknown-unknown --release ``` -Then serve the `.wasm` and use `weaveffi_wasm.js` to load it. +Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot read files), or an already compiled `WebAssembly.Module`. ## Complex Type Handling @@ -29,9 +29,17 @@ Records are **plain JavaScript objects**. A record crosses the boundary serializ C-style enums are passed as **`i32` values** corresponding to the variant's integer discriminant. Rich (algebraic) enums are plain objects tagged by variant name (`{ tag: "Circle", radius: 2 }`) and cross the boundary serialized in a value buffer, exactly like records. +### Objects + +Interfaces are **wrapper classes** holding one reference to a producer object (an `i32` pointer into linear memory). A wrapper you receive is yours: release it with `close()` (or bind it with `using`, which calls `Symbol.dispose`); a wrapper collected unclosed is released by a `FinalizationRegistry` backstop where the runtime provides one, and destroy runs exactly once either way. Passing a wrapper to a function lends its reference for the duration of the call. Inside a value buffer an object crosses as a `u64` token carrying one reference: the writer clones the wrapper's reference and the reader adopts the token into a new wrapper. + +### 64-bit Integers + +`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, returns, record fields, and callback parameters. Map keys of these types become string property names on the decoded object. A `number` is accepted where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional value throws a `RangeError`). Values crossing by value follow the wasm calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than throwing, and unsigned results are read back as unsigned (`u64` via `BigInt.asUintN`, `u32` via `>>> 0`). + ### Optionals -Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent). +Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`. ### Lists and Maps @@ -41,11 +49,15 @@ Lists are JS arrays; maps are plain objects (a `Map` instance is also accepted o `iter` functions return a **lazy JS iterator** (typed `IterableIterator`): each `next()` issues exactly one producer call, so iteration streams in constant memory. The producer handle is destroyed exactly once, on exhaustion or via `return()` when iteration stops early (a `for...of` loop calls `return()` automatically on `break` or `throw`). Abandoning an iterator without exhausting or closing it leaks the handle. -### Callbacks and Listeners +### Async Functions -Each listener surfaces as a `register.../unregister...` pair. `register` takes a plain JS function and returns a numeric subscription id; `unregister` takes that id and stops delivery. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so events fire only while a call into the module is on the stack (for example, a producer function that emits during its own execution). A producer that emits from a spawned thread cannot run on this target at all. +`async` functions return a **`Promise`**. `wasm32-unknown-unknown` has no threads, so the producer's default spawner drives each future to completion inline, inside the launch call: the completion callback fires (through a function-table trampoline) before the wrapper returns, and the Promise is already settled when you `await` it. There is nothing to pump or poll from JS. A future that returns `Pending` can't be woken on this target with the default spawner; a producer whose futures wait on external events needs its own spawner (`weaveffi::set_spawner`), and driving that executor is then the producer's responsibility, not the glue's. A typed error rejects the Promise with the same class the synchronous wrapper would throw. -Callback arguments are **borrowed for the duration of the callback**: strings, byte buffers, and buffered values (records, rich enums, optionals, lists, maps) are copied or decoded into JS values before your function runs, and interface arguments wrap producer-owned objects. Read what you need inside the callback and do not retain an interface wrapper or call `free()` on it. +### Callback Interfaces + +A callback interface is a TypeScript `interface`; pass any object with the matching methods where a parameter of that type is expected. The loader keeps one **static vtable per interface** in linear memory, filled with function-table indices of long-lived trampolines, and passes a small integer context key with it; the producer calls `free(ctx)` when it drops the callback, which releases your object. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so a method runs only while a call into the module is on the stack. A producer that calls back from a spawned thread cannot run on this target at all. + +Method arguments that are strings, bytes, or buffered values are **borrowed for the duration of the call**: they're copied or decoded into JS values before your method runs. Object arguments transfer one reference to you: `close()` the wrapper when you're done with it. An exception thrown from a method is reported to the producer as error code `-4` (with the exception's message) and the method's default value is returned in its place; the producer then aborts the call it was making, and the original caller sees a `WeaveFFIError` with code `-4` carrying your message (see *Traps* below for how that abort reaches the caller on this target). ### Error Handling @@ -59,15 +71,25 @@ functions: - `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory - `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block - `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources +- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions) - `weaveffi_free_string(ptr: i32)`: free a producer-returned C string - `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer Wrappers of functions declared `throws` raise the declaring module's typed error class (a `WeaveFFIError` subclass with a per-code subclass, such as `KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only -for producer panics and marshalling failures. When the matched error code -declares payload fields, the wrapper decodes them from the error's value -buffer and attaches them as properties on the thrown error. +for producer panics, marshalling failures, and callback exceptions +(code `-4`). When the matched error code declares payload fields, the +wrapper decodes them from the error's value buffer and attaches them as +properties on the thrown error. + +#### Callback failures without unwinding + +`wasm32-unknown-unknown` builds with `panic = "abort"`, so when a callback interface method throws, the producer can't unwind out of the call the way native targets do. Instead the runtime records the failure, the producer's code runs to completion on the method's default return value (`0`, `0n`, or `false`), and the thunk then reports code `-4` with the exception's message in place of the result, so the wrapper throws (or the Promise rejects with) the same `WeaveFFIError` a native build would produce. While that producer call is still on the stack, the glue refuses every further callback invocation with the same error rather than calling your implementation again, so your code observes the failure exactly as it would under unwinding. Producers must tolerate a callback returning its default once (a well-behaved producer already does, because a consumer could return that value on purpose). + +#### Traps + +A producer panic can't be caught on the Rust side either: it executes `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of the call. The glue translates that trap for you: the wrapper throws (or the Promise rejects with) a `WeaveFFIError` with code `-2` (`producer panicked`), or code `-4` if a callback method had already failed during the call. The error slot and every staged argument are released either way. The producer frames between the trap and the call are *not* unwound, so their cleanup does not run: a lock held across the panic stays locked, and allocations those frames owned leak. ## API Reference @@ -122,6 +144,16 @@ Publish a document in the background | `id` | `i64` | `i64` | native Wasm i64 | | _returns_ | `bool` | `i32` | 0 = false, 1 = true | +##### `weaveffi_docs_watch` + +Register a listener for saved notifications + +`weaveffi_docs_watch(listener: i32, i32) -> void` + +| Param | API Type | Wasm | Notes | +|-------|----------|------|-------| +| `listener` | `SavedListener` | `i32, i32` | context key + static vtable pointer | + #### Interfaces ##### `Notebook` @@ -131,7 +163,7 @@ An ordered collection of documents. Notebooks own their documents and release them when destroyed. -Passed as an **opaque object pointer** (`i32`), wrapped by a JS class. Constructors return an owned handle; methods pass the handle as the implicit leading `self` argument; `free()` releases the handle via the destroy symbol. +Passed as an **object pointer** (`i32`), wrapped by a JS class holding one reference. Constructors return an owned reference; methods lend the pointer as the implicit leading `self` argument; `close()` (or `Symbol.dispose`) releases the reference via the destroy symbol exactly once. ##### `weaveffi_docs_Notebook_new` @@ -141,7 +173,7 @@ Create an empty notebook | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| _returns_ | `Notebook` | `i32` | opaque object pointer | +| _returns_ | `Notebook` | `i32` | borrowed object pointer | ##### `weaveffi_docs_Notebook_insert` @@ -185,9 +217,28 @@ The largest number of documents one notebook will hold |-------|----------|------|-------| | _returns_ | `i32` | `i32` | native Wasm i32 | +##### `weaveffi_docs_Notebook_clone` + +Returns a second reference to the same object. Used when the wrapper is encoded as an object token inside a value buffer. + ##### `weaveffi_docs_Notebook_destroy` -Releases the object reference. Called by the wrapper's `free()`. +Releases one object reference. Called by the wrapper's `close()` (and by the finalizer backstop). + +#### Callback Interfaces + +##### `SavedListener` + +Consumer-implemented observer of document-saved notifications. + +Implementations must be safe to call from any thread. + + +Implemented in JS as an object with the methods below. Passed as an `i32` context key plus an `i32` pointer to the static `weaveffi_docs_SavedListener_vtable` vtable; the vtable's trailing `free(ctx)` entry releases the JS object. + +| Method | Parameters | Returns | +|--------|------------|---------| +| `onSaved` | `doc_id: bigint`, `revision: number` | `void` | #### Structs diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_d_ts.snap index 9384bfa3..812a67bb 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_d_ts.snap @@ -8,8 +8,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated TypeScript declarations for WeaveFFI Wasm bindings /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export declare class WeaveFFIError extends Error { constructor(code: number, message?: string); code: number; @@ -64,14 +65,33 @@ export declare const Status: Readonly<{ }>; export type Status = (typeof Status)[keyof typeof Status]; +/** + * Consumer-implemented observer of document-saved notifications. + * + * Implementations must be safe to call from any thread. + * + * Implemented by the consumer; the producer calls these methods synchronously. An exception thrown from a method is reported to the producer as an error (code -4). + */ +export interface SavedListener { + /** + * Fires after a document has been persisted successfully + * @param docId Identifier of the saved document + * @param revision Monotonically increasing revision + */ + onSaved(docId: bigint, revision: number): void; +} + /** * An ordered collection of documents. * * Notebooks own their documents and release them when destroyed. + * + * Wraps one reference to a producer object; release it with `close()`. */ export declare class Notebook { /** * Create an empty notebook + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {WeaveFFIError} if the native call fails */ constructor(); @@ -97,8 +117,11 @@ export declare class Notebook { * @throws {WeaveFFIError} if the native call fails */ static maxDocuments(): number; - /** Releases the producer-owned handle exactly once. */ - free(): void; + /** Releases this wrapper's reference exactly once; later calls are + * no-ops. Also reachable as `[Symbol.dispose]()` for `using` + * declarations. A wrapper collected unclosed is released by a + * finalizer where the runtime provides one. */ + close(): void; } export interface WeaveffiWasmModule { @@ -127,17 +150,15 @@ export interface WeaveffiWasmModule { */ publishAsync(id: bigint): Promise; /** - * Subscribe to document-saved notifications - * - * @returns A subscription id for `unregisterSavedListener()`. + * Register a listener for saved notifications + * @param listener Receiver of saved notifications + * @throws {WeaveFFIError} if the native call fails */ - registerSavedListener(callback: (docId: bigint, revision: number) => void): number; - /** Unregister a listener previously registered with `registerSavedListener()`. */ - unregisterSavedListener(id: number): void; + watch(listener: SavedListener): void; Notebook: typeof Notebook; }; } -export function loadWeaveffiWasm(url: string): Promise; +export function loadWeaveffiWasm(source: string | URL | BufferSource | WebAssembly.Module): Promise; // END weaveffi_wasm.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_js.snap b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_js.snap index 4a168a55..399bbb5f 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_docs_everywhere__weaveffi_wasm_js.snap @@ -9,16 +9,18 @@ source: crates/weaveffi-cli/tests/snapshots.rs // // Boundary conventions for a wasm32-unknown-unknown build: // -// Objects -> i32 pointer into linear memory (0 = null/absent) +// Objects -> i32 pointer into linear memory (0 = null/absent); one +// strong reference per wrapper, released by close() // Enums -> i32 discriminant value // i64/u64 -> JavaScript BigInt // Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer // Bytes -> i32 data pointer + i32 length (out_len for returns) // Buffered -> records, rich enums, optionals, lists, and maps cross // as one value buffer: i32 pointer + i32 length +// Callbacks -> i32 context key + i32 pointer to a static vtable // The ABI revision this glue was generated against. -const _ABI_VERSION = 1; +const _ABI_VERSION = 2; function _checkAbiVersion(wasm) { if (typeof wasm.weaveffi_abi_version !== 'function') { @@ -31,8 +33,9 @@ function _checkAbiVersion(wasm) { } /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -67,14 +70,6 @@ const _DOCS_ERRORS_ERROR_CODES = Object.freeze({ 2: InvalidStatus, }); -// Build the DocsErrorsError subclass matching `code`, or a generic -// WeaveFFIError for codes outside the domain (panics, marshalling). -function _docsErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { - const _cls = _DOCS_ERRORS_ERROR_CODES[code]; - const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); - return _e; -} - const _enc = new TextEncoder(); const _dec = new TextDecoder(); @@ -154,6 +149,8 @@ class _BufWriter { f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; } len(n) { this.u32(n); } flag(present) { this.u8(present ? 1 : 0); } + // An object token: the pointer (a wasm32 i32, read unsigned) widened to u64. + obj(ptr) { this.u64(BigInt(ptr >>> 0)); } str(v) { const b = _enc.encode(v); this.len(b.length); @@ -218,6 +215,13 @@ class _BufReader { if (b > 1) this._bad('option flag byte out of range'); return b === 1; } + // An object token narrowed back to a wasm32 pointer; zero is never a + // valid token (an absent `Interface?` uses the option flag instead). + obj() { + const t = this.u64(); + if (t === 0n || t > 0xffffffffn) this._bad('object token out of range'); + return Number(t); + } str() { const n = this.len(); const at = this._take(n, 'string bytes'); @@ -247,9 +251,11 @@ function _allocErr(wasm) { // Throw (and free the slot) if the error slot carries a non-zero code. // Non-throwing wrappers route here: a non-zero code can only be a -// producer panic or a marshalling failure, surfaced as the generic +// producer panic (-2), a marshalling failure (-3), or a callback +// interface implementation that raised (-4), surfaced as the generic // WeaveFFIError. function _checkErr(wasm, errPtr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code !== 0) { @@ -263,21 +269,36 @@ function _checkErr(wasm, errPtr) { // Release an error slot on the success path. function _freeErr(wasm, errPtr) { + _pendingForeign = null; wasm.weaveffi_dealloc(errPtr, 16); } -// Throw the `DocsErrorsError` domain error (and free the slot) if the error slot -// carries a non-zero code. -function _checkDocsErrorsError(wasm, errPtr) { - const dv = new DataView(wasm.memory.buffer); - const code = dv.getInt32(errPtr, true); - if (code !== 0) { - const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; - const _e = _docsErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); - wasm.weaveffi_error_clear(errPtr); - wasm.weaveffi_dealloc(errPtr, 16); - throw _e; - } +// The message of a callback-interface failure a trampoline reported through +// out_err, held until the producer call on the stack returns (every error +// checker and _freeErr clear it) so later callbacks in that call are refused. +let _pendingForeign = null; + +// Translate an exception raised while a producer call was on the stack. +// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes +// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of +// the producer writing out_err. A trap that follows a callback-interface +// failure is labelled with that failure (-4); any other trap is a producer +// panic (-2). Non-trap exceptions pass through unchanged. The producer +// frames between the trap and the call are not unwound, so a lock held +// across a callback stays locked; a producer that snapshots its state +// before calling out stays usable. +function _trapError(e) { + const foreign = _pendingForeign; + _pendingForeign = null; + if (!(e instanceof WebAssembly.RuntimeError)) return e; + if (foreign !== null) return new WeaveFFIError(-4, foreign); + return new WeaveFFIError(-2, 'producer panicked: ' + e.message); +} + +// Release the error slot a failed call never filled, then translate. +function _trap(wasm, errPtr, e) { + _freeErr(wasm, errPtr); + return _trapError(e); } // Throw if a heap-boxed (consumer-owned) error carries a non-zero @@ -286,6 +307,7 @@ function _checkDocsErrorsError(wasm, errPtr) { // `mkErr` maps domain codes (and decodes payload fields) for // throwing callables; without it the generic WeaveFFIError is thrown. function _checkErrRef(wasm, errPtr, mkErr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code === 0) { wasm.weaveffi_error_free(errPtr); return; } @@ -295,32 +317,71 @@ function _checkErrRef(wasm, errPtr, mkErr) { throw err; } -// Serialize a `docs.Document` record into the value-buffer wire format. -function _write_docs_Document(w, v) { - w.i64(v.id); - w.str(v.title); - w.i32(v.status); - const _a1 = v.tags || []; - w.len(_a1.length); - for (const _e1 of _a1) { - w.str(_e1); +// `using` declarations (explicit resource management) call this symbol; +// runtimes that predate it get the registered stand-in so the method is +// still reachable by name. +const _dispose = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); + +// Finalization backstop: a wrapper that is garbage collected without +// close() still releases its reference. The held value is the +// [destroy, handle] pair (never the wrapper, which would keep it alive); +// the wrapper is the unregister token so close() disarms the backstop and +// destroy runs exactly once either way. Absent on runtimes without +// FinalizationRegistry, where close() is the only release path. +const _finalizer = typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry(([destroy, handle]) => destroy(handle)) + : null; + +// Bind one strong reference to a wrapper and arm the backstop. +function _adopt(obj, handle, destroy) { + obj._handle = handle; + obj._destroy = destroy; + if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj); + return obj; +} + +// Release a wrapper's reference exactly once; later calls are no-ops. +function _release(obj) { + if (!obj._handle) return; + if (_finalizer !== null) _finalizer.unregister(obj); + obj._destroy(obj._handle); + obj._handle = 0; +} + +// The pointer of a live wrapper, lent to the producer for one call (the +// wrapper keeps its own reference). A closed or non-object argument is a +// consumer programming error, reported with the marshalling code. +function _borrow(obj) { + if (obj === null || obj === undefined || !obj._handle) { + throw new WeaveFFIError(-3, 'expected a live object wrapper'); } + return obj._handle; +} + +// Report a callback-interface failure through the trampoline's out_err +// slot as FOREIGN_ERROR_CODE (-4). weaveffi_error_set copies the message +// with the producer's allocator, so the staged C string is released as +// soon as it returns. The message is parked in _pendingForeign until the +// producer call on the stack returns: the producer can't unwind on wasm32, +// so it keeps running on the trampoline's default return value, and any +// further callback it makes during that call is refused with this error +// instead of reaching the implementation again. +function _reportForeign(wasm, errPtr, msg) { + if (_pendingForeign === null) _pendingForeign = msg; + const [p, s] = _cstr(wasm, msg); + wasm.weaveffi_error_set(errPtr, -4, p); + wasm.weaveffi_dealloc(p, s); } -// Decode a `docs.Document` record from the value-buffer wire format. -function _read_docs_Document(r) { - const v = {}; - v.id = r.i64(); - v.title = r.str(); - v.status = r.i32(); - v.tags = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.str()); return _arr; })(); - return v; +// Report an exception thrown by a callback-interface implementation. +function _setForeignError(wasm, errPtr, e) { + _reportForeign(wasm, errPtr, e instanceof Error ? (e.message || e.name) : String(e)); } -function _registerTrampoline(table, paramTypes, handler) { +function _registerTrampoline(table, paramTypes, resultTypes, handler) { const idx = table.grow(1); table.set(idx, new WebAssembly.Function( - { parameters: paramTypes, results: [] }, + { parameters: paramTypes, results: resultTypes }, handler )); return idx; @@ -333,9 +394,11 @@ export const Status = Object.freeze({ }); /** - * Load a WeaveFFI Wasm module from the given URL. + * Load a WeaveFFI Wasm module. * - * @param {string} url - URL to the `.wasm` file. + * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to + * fetch the `.wasm` from, its bytes, or an already compiled module + * (use the bytes form where `fetch` cannot read files, e.g. Node). * @returns {Promise} The API bindings. * * Exported functions follow the C ABI naming convention: @@ -350,6 +413,12 @@ export const Status = Object.freeze({ * // Record: plain objects in and out (serialized automatically). * const person = api.contacts.create({ name: 'Ada', age: 36 }); * + * // Object: a class wrapping one producer reference; close() releases it + * // (or let `using` do it). Passing it lends the reference for the call. + * const store = new api.kv.Store(); + * api.kv.inspect(store); + * store.close(); + * * // Enum: pass the integer discriminant. * api.ui.set_color(0); // 0 = first variant * @@ -360,14 +429,67 @@ export const Status = Object.freeze({ * // List/Map: pass arrays/objects; receive arrays/objects. * const names = api.data.all_names(); */ -export async function loadWeaveffiWasm(url) { - const response = await fetch(url); - const bytes = await response.arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, {}); +export async function loadWeaveffiWasm(source) { + let module; + if (source instanceof WebAssembly.Module) { + module = source; + } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + module = await WebAssembly.compile(source); + } else { + const response = await fetch(source); + module = await WebAssembly.compile(await response.arrayBuffer()); + } + const instance = await WebAssembly.instantiate(module, {}); const wasm = instance.exports; _checkAbiVersion(wasm); + // Build the DocsErrorsError subclass matching `code`, or a generic + // WeaveFFIError for codes outside the domain (panics, marshalling, + // foreign callback failures). + function _docsErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { + const _cls = _DOCS_ERRORS_ERROR_CODES[code]; + const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); + return _e; + } + + // Throw the `DocsErrorsError` domain error (and free the slot) if the error slot + // carries a non-zero code. + function _checkDocsErrorsError(wasm, errPtr) { + _pendingForeign = null; + const dv = new DataView(wasm.memory.buffer); + const code = dv.getInt32(errPtr, true); + if (code !== 0) { + const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; + const _e = _docsErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); + wasm.weaveffi_error_clear(errPtr); + wasm.weaveffi_dealloc(errPtr, 16); + throw _e; + } + } + + // Serialize a `docs.Document` record into the value-buffer wire format. + function _write_docs_Document(w, v) { + w.i64(v.id); + w.str(v.title); + w.i32(v.status); + const _a1 = v.tags || []; + w.len(_a1.length); + for (const _e1 of _a1) { + w.str(_e1); + } + } + + // Decode a `docs.Document` record from the value-buffer wire format. + function _read_docs_Document(r) { + const v = {}; + v.id = r.i64(); + v.title = r.str(); + v.status = r.i32(); + v.tags = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.str()); return _arr; })(); + return v; + } + const _table = wasm.__indirect_function_table; let _nextCtxId = 1; @@ -385,21 +507,43 @@ export async function loadWeaveffiWasm(url) { } } - const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], _asyncHandler); + const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], [], _asyncHandler); - // Listener subscriptions, keyed by the context id the loader - // threads through the C ABI's void* context slot. Each entry - // holds the JS callback and the producer's subscription id. - let _nextLsnId = 1; - const _listeners = new Map(); + // Callback-interface implementations, keyed by the integer context + // the loader passes as `ctx`; an entry lives until the producer + // calls the vtable's `free(ctx)`. + let _nextCbId = 1; + const _callbacks = new Map(); - const _lsnPtr_weaveffi_docs_OnSaved_fn = _registerTrampoline(_table, ['i64', 'i32', 'i32'], (a0, a1, _ctx) => { - const _l = _listeners.get(_ctx); - if (_l === undefined) return; - const _p0 = a0; - const _p1 = a1; - _l.callback(_p0, _p1); + // Trampolines for the `SavedListener` callback interface: one WebAssembly.Function + // per vtable entry, mirroring the C signature of each method. Borrowed + // arguments are decoded before the JS method runs; object arguments + // are adopted; a thrown exception becomes a -4 error on out_err. + const _cb_SavedListener_on_saved = _registerTrampoline(_table, ['i32', 'i64', 'i32', 'i32'], [], (_ctx, a0, a1, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = a0; + const _p1 = a1; + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onSaved(_p0, _p1); + } catch (e) { + _setForeignError(wasm, _err, e); + } }); + // `free(ctx)`: the producer dropped its last reference; forget the + // implementation so it can be collected. + const _cb_SavedListener_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); }); + // The one static `weaveffi_docs_SavedListener_vtable` for this instance, filled with the table indices + // above in declaration order, then `free`. + const _vtable_SavedListener = wasm.weaveffi_alloc(8); + { + const _dv = new DataView(wasm.memory.buffer); + _dv.setUint32(_vtable_SavedListener + 0, _cb_SavedListener_on_saved, true); + _dv.setUint32(_vtable_SavedListener + 4, _cb_SavedListener_free, true); + } // An ordered collection of documents. // @@ -407,29 +551,41 @@ export async function loadWeaveffiWasm(url) { class Notebook { constructor() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_Notebook_new(_err); + let _r; + try { + _r = wasm.weaveffi_docs_Notebook_new(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - this._handle = _r; + _adopt(this, _r, wasm.weaveffi_docs_Notebook_destroy); } static _wrap(handle) { - const _o = Object.create(Notebook.prototype); - _o._handle = handle; - return _o; + return _adopt(Object.create(Notebook.prototype), handle, wasm.weaveffi_docs_Notebook_destroy); } - free() { - if (this._handle !== 0) { - wasm.weaveffi_docs_Notebook_destroy(this._handle); - this._handle = 0; - } + _clone() { + return wasm.weaveffi_docs_Notebook_clone(_borrow(this)); + } + close() { + _release(this); + } + [_dispose]() { + _release(this); } insert(document) { const a0_w = new _BufWriter(); _write_docs_Document(a0_w, document); const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_Notebook_insert(this._handle, a0_p, a0_l, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_docs_Notebook_insert(_borrow(this), a0_p, a0_l, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; @@ -437,7 +593,13 @@ export async function loadWeaveffiWasm(url) { find(id) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_Notebook_find(this._handle, BigInt(id), _lp, _err); + let _r; + try { + _r = wasm.weaveffi_docs_Notebook_find(_borrow(this), BigInt(id), _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkDocsErrorsError(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -449,14 +611,24 @@ export async function loadWeaveffiWasm(url) { } size() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_Notebook_size(this._handle, _err); + let _r; + try { + _r = wasm.weaveffi_docs_Notebook_size(_borrow(this), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; } static maxDocuments() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_Notebook_max_documents(_err); + let _r; + try { + _r = wasm.weaveffi_docs_Notebook_max_documents(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; @@ -477,9 +649,16 @@ export async function loadWeaveffiWasm(url) { const [a1_p, a1_l] = _bytes(wasm, a1_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_create_document(a0_p, a1_p, a1_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); - wasm.weaveffi_dealloc(a1_p, a1_l); + let _r; + try { + _r = wasm.weaveffi_docs_create_document(a0_p, a1_p, a1_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + wasm.weaveffi_dealloc(a1_p, a1_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -492,7 +671,13 @@ export async function loadWeaveffiWasm(url) { getDocument(id) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_docs_get_document(BigInt(id), _lp, _err); + let _r; + try { + _r = wasm.weaveffi_docs_get_document(BigInt(id), _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -506,21 +691,25 @@ export async function loadWeaveffiWasm(url) { return new Promise((resolve, reject) => { const ctxId = _nextCtxId++; _asyncContexts.set(ctxId, { resolve, reject, unwrap: (w, r) => r !== 0 }); - wasm.weaveffi_docs_publish_async_async(BigInt(id), _cbPtr_i32_i32_i32, ctxId); + try { + wasm.weaveffi_docs_publish_async_async(BigInt(id), _cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, - /** Subscribe to document-saved notifications */ - registerSavedListener(callback) { - const _id = _nextLsnId++; - const _rid = wasm.weaveffi_docs_register_saved_listener(_lsnPtr_weaveffi_docs_OnSaved_fn, _id); - _listeners.set(_id, { callback, rid: _rid }); - return _id; - }, - unregisterSavedListener(id) { - const _l = _listeners.get(id); - if (_l === undefined) return; - _listeners.delete(id); - wasm.weaveffi_docs_unregister_saved_listener(_l.rid); + watch(listener) { + const a0_ctx = _nextCbId++; + _callbacks.set(a0_ctx, listener); + const _err = _allocErr(wasm); + try { + wasm.weaveffi_docs_watch(a0_ctx, _vtable_SavedListener, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } + _checkErr(wasm, _err); + _freeErr(wasm, _err); }, Notebook: Notebook, }, diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__README_md.snap b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__README_md.snap index eabfe41d..40450c4a 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__README_md.snap @@ -15,7 +15,7 @@ Build (example): cargo build --target wasm32-unknown-unknown --release ``` -Then serve the `.wasm` and use `weaveffi_wasm.js` to load it. +Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot read files), or an already compiled `WebAssembly.Module`. ## Complex Type Handling @@ -29,9 +29,17 @@ Records are **plain JavaScript objects**. A record crosses the boundary serializ C-style enums are passed as **`i32` values** corresponding to the variant's integer discriminant. Rich (algebraic) enums are plain objects tagged by variant name (`{ tag: "Circle", radius: 2 }`) and cross the boundary serialized in a value buffer, exactly like records. +### Objects + +Interfaces are **wrapper classes** holding one reference to a producer object (an `i32` pointer into linear memory). A wrapper you receive is yours: release it with `close()` (or bind it with `using`, which calls `Symbol.dispose`); a wrapper collected unclosed is released by a `FinalizationRegistry` backstop where the runtime provides one, and destroy runs exactly once either way. Passing a wrapper to a function lends its reference for the duration of the call. Inside a value buffer an object crosses as a `u64` token carrying one reference: the writer clones the wrapper's reference and the reader adopts the token into a new wrapper. + +### 64-bit Integers + +`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, returns, record fields, and callback parameters. Map keys of these types become string property names on the decoded object. A `number` is accepted where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional value throws a `RangeError`). Values crossing by value follow the wasm calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than throwing, and unsigned results are read back as unsigned (`u64` via `BigInt.asUintN`, `u32` via `>>> 0`). + ### Optionals -Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent). +Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`. ### Lists and Maps @@ -41,11 +49,15 @@ Lists are JS arrays; maps are plain objects (a `Map` instance is also accepted o `iter` functions return a **lazy JS iterator** (typed `IterableIterator`): each `next()` issues exactly one producer call, so iteration streams in constant memory. The producer handle is destroyed exactly once, on exhaustion or via `return()` when iteration stops early (a `for...of` loop calls `return()` automatically on `break` or `throw`). Abandoning an iterator without exhausting or closing it leaks the handle. -### Callbacks and Listeners +### Async Functions -Each listener surfaces as a `register.../unregister...` pair. `register` takes a plain JS function and returns a numeric subscription id; `unregister` takes that id and stops delivery. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so events fire only while a call into the module is on the stack (for example, a producer function that emits during its own execution). A producer that emits from a spawned thread cannot run on this target at all. +`async` functions return a **`Promise`**. `wasm32-unknown-unknown` has no threads, so the producer's default spawner drives each future to completion inline, inside the launch call: the completion callback fires (through a function-table trampoline) before the wrapper returns, and the Promise is already settled when you `await` it. There is nothing to pump or poll from JS. A future that returns `Pending` can't be woken on this target with the default spawner; a producer whose futures wait on external events needs its own spawner (`weaveffi::set_spawner`), and driving that executor is then the producer's responsibility, not the glue's. A typed error rejects the Promise with the same class the synchronous wrapper would throw. -Callback arguments are **borrowed for the duration of the callback**: strings, byte buffers, and buffered values (records, rich enums, optionals, lists, maps) are copied or decoded into JS values before your function runs, and interface arguments wrap producer-owned objects. Read what you need inside the callback and do not retain an interface wrapper or call `free()` on it. +### Callback Interfaces + +A callback interface is a TypeScript `interface`; pass any object with the matching methods where a parameter of that type is expected. The loader keeps one **static vtable per interface** in linear memory, filled with function-table indices of long-lived trampolines, and passes a small integer context key with it; the producer calls `free(ctx)` when it drops the callback, which releases your object. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so a method runs only while a call into the module is on the stack. A producer that calls back from a spawned thread cannot run on this target at all. + +Method arguments that are strings, bytes, or buffered values are **borrowed for the duration of the call**: they're copied or decoded into JS values before your method runs. Object arguments transfer one reference to you: `close()` the wrapper when you're done with it. An exception thrown from a method is reported to the producer as error code `-4` (with the exception's message) and the method's default value is returned in its place; the producer then aborts the call it was making, and the original caller sees a `WeaveFFIError` with code `-4` carrying your message (see *Traps* below for how that abort reaches the caller on this target). ### Error Handling @@ -59,15 +71,25 @@ functions: - `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory - `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block - `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources +- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions) - `weaveffi_free_string(ptr: i32)`: free a producer-returned C string - `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer Wrappers of functions declared `throws` raise the declaring module's typed error class (a `WeaveFFIError` subclass with a per-code subclass, such as `KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only -for producer panics and marshalling failures. When the matched error code -declares payload fields, the wrapper decodes them from the error's value -buffer and attaches them as properties on the thrown error. +for producer panics, marshalling failures, and callback exceptions +(code `-4`). When the matched error code declares payload fields, the +wrapper decodes them from the error's value buffer and attaches them as +properties on the thrown error. + +#### Callback failures without unwinding + +`wasm32-unknown-unknown` builds with `panic = "abort"`, so when a callback interface method throws, the producer can't unwind out of the call the way native targets do. Instead the runtime records the failure, the producer's code runs to completion on the method's default return value (`0`, `0n`, or `false`), and the thunk then reports code `-4` with the exception's message in place of the result, so the wrapper throws (or the Promise rejects with) the same `WeaveFFIError` a native build would produce. While that producer call is still on the stack, the glue refuses every further callback invocation with the same error rather than calling your implementation again, so your code observes the failure exactly as it would under unwinding. Producers must tolerate a callback returning its default once (a well-behaved producer already does, because a consumer could return that value on purpose). + +#### Traps + +A producer panic can't be caught on the Rust side either: it executes `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of the call. The glue translates that trap for you: the wrapper throws (or the Promise rejects with) a `WeaveFFIError` with code `-2` (`producer panicked`), or code `-4` if a callback method had already failed during the call. The error slot and every staged argument are released either way. The producer frames between the trap and the call are *not* unwound, so their cleanup does not run: a lock held across the panic stays locked, and allocations those frames owned leak. ## API Reference @@ -132,16 +154,50 @@ Optional list of optional structs, the deepest legal nesting | `mode` | `Mode?` | `i32, i32` | value buffer: ptr + len in linear memory | | _returns_ | `Mode?` | `i32, i32` | value buffer: ptr + len in linear memory | -##### `weaveffi_edge_cases_mutable_params` +##### `weaveffi_edge_cases_register_batch` -Write-back parameters +Two callback interface parameters on one function -`weaveffi_edge_cases_mutable_params(text: i32, blob: i32, i32) -> void` +`weaveffi_edge_cases_register_batch(listener: i32, i32, keyword: i32, i32) -> void` | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| `text` | `string` | `i32` | NUL-terminated C string pointer | -| `blob` | `bytes` | `i32, i32` | ptr + len in linear memory | +| `listener` | `BatchListener` | `i32, i32` | context key + static vtable pointer | +| `keyword` | `Keyword` | `i32, i32` | context key + static vtable pointer | + +##### `weaveffi_edge_cases_widgets_everywhere` + +Objects in every buffered position + +`weaveffi_edge_cases_widgets_everywhere(list: i32, i32, by_name: i32, i32, sparse: i32, i32, holder: i32, i32) -> i32, i32` + +| Param | API Type | Wasm | Notes | +|-------|----------|------|-------| +| `list` | `[Widget]` | `i32, i32` | value buffer: ptr + len in linear memory | +| `by_name` | `{string:Widget}` | `i32, i32` | value buffer: ptr + len in linear memory | +| `sparse` | `[Widget?]` | `i32, i32` | value buffer: ptr + len in linear memory | +| `holder` | `WidgetHolder` | `i32, i32` | value buffer: ptr + len in linear memory | +| _returns_ | `WidgetHolder` | `i32, i32` | value buffer: ptr + len in linear memory | + +##### `weaveffi_edge_cases_widget_stream` + +Iterator of objects + +`weaveffi_edge_cases_widget_stream() -> i32` + +| Param | API Type | Wasm | Notes | +|-------|----------|------|-------| +| _returns_ | `iter` | `i32` | opaque iterator handle | + +##### `weaveffi_edge_cases_async_widget` + +Async returning an object + +`weaveffi_edge_cases_async_widget() -> i32` + +| Param | API Type | Wasm | Notes | +|-------|----------|------|-------| +| _returns_ | `Widget` | `i32` | borrowed object pointer | ##### `weaveffi_edge_cases_make_widget` @@ -152,7 +208,7 @@ Factory for an interface with no constructor | Param | API Type | Wasm | Notes | |-------|----------|------|-------| | `label` | `string` | `i32` | NUL-terminated C string pointer | -| _returns_ | `Widget` | `i32` | opaque object pointer | +| _returns_ | `Widget` | `i32` | borrowed object pointer | ##### `weaveffi_edge_cases_find_widget` @@ -173,20 +229,10 @@ Interface parameter, including an optional one | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| `widget` | `Widget` | `i32` | opaque object pointer | +| `widget` | `Widget` | `i32` | borrowed object pointer | | `fallback` | `Widget?` | `i32` | nullable object pointer, 0 = absent | | _returns_ | `string` | `i32` | NUL-terminated C string pointer | -##### `weaveffi_edge_cases_typed_handle_param` - -`weaveffi_edge_cases_typed_handle_param(token: i32, raw: i64) -> i32` - -| Param | API Type | Wasm | Notes | -|-------|----------|------|-------| -| `token` | `handle` | `i32` | opaque pointer | -| `raw` | `handle` | `i64` | opaque 64-bit handle | -| _returns_ | `handle` | `i32` | opaque pointer | - ##### `weaveffi_edge_cases_string_stream` Iterator of strings @@ -303,7 +349,7 @@ Async with no result Interface with no constructor; instances come from make_widget -Passed as an **opaque object pointer** (`i32`), wrapped by a JS class. Constructors return an owned handle; methods pass the handle as the implicit leading `self` argument; `free()` releases the handle via the destroy symbol. +Passed as an **object pointer** (`i32`), wrapped by a JS class holding one reference. Constructors return an owned reference; methods lend the pointer as the implicit leading `self` argument; `close()` (or `Symbol.dispose`) releases the reference via the destroy symbol exactly once. ##### `weaveffi_edge_cases_Widget_refresh` @@ -345,15 +391,19 @@ Async method with a struct result |-------|----------|------|-------| | _returns_ | `string` | `i32` | NUL-terminated C string pointer | +##### `weaveffi_edge_cases_Widget_clone` + +Returns a second reference to the same object. Used when the wrapper is encoded as an object token inside a value buffer. + ##### `weaveffi_edge_cases_Widget_destroy` -Releases the object reference. Called by the wrapper's `free()`. +Releases one object reference. Called by the wrapper's `close()` (and by the finalizer backstop). ##### `Vault` Deprecated interface with a throwing constructor -Passed as an **opaque object pointer** (`i32`), wrapped by a JS class. Constructors return an owned handle; methods pass the handle as the implicit leading `self` argument; `free()` releases the handle via the destroy symbol. +Passed as an **object pointer** (`i32`), wrapped by a JS class holding one reference. Constructors return an owned reference; methods lend the pointer as the implicit leading `self` argument; `close()` (or `Symbol.dispose`) releases the reference via the destroy symbol exactly once. ##### `weaveffi_edge_cases_Vault_open` @@ -364,7 +414,7 @@ Fails when the vault is busy | Param | API Type | Wasm | Notes | |-------|----------|------|-------| | `path` | `string` | `i32` | NUL-terminated C string pointer | -| _returns_ | `Vault` | `i32` | opaque object pointer | +| _returns_ | `Vault` | `i32` | borrowed object pointer | ##### `weaveffi_edge_cases_Vault_peek` @@ -384,9 +434,40 @@ Throwing static |-------|----------|------|-------| | _returns_ | `bool` | `i32` | 0 = false, 1 = true | +##### `weaveffi_edge_cases_Vault_clone` + +Returns a second reference to the same object. Used when the wrapper is encoded as an object token inside a value buffer. + ##### `weaveffi_edge_cases_Vault_destroy` -Releases the object reference. Called by the wrapper's `free()`. +Releases one object reference. Called by the wrapper's `close()` (and by the finalizer backstop). + +#### Callback Interfaces + +##### `BatchListener` + +Callback interface carrying composite payloads and objects + +Implemented in JS as an object with the methods below. Passed as an `i32` context key plus an `i32` pointer to the static `weaveffi_edge_cases_BatchListener_vtable` vtable; the vtable's trailing `free(ctx)` entry releases the JS object. + +| Method | Parameters | Returns | +|--------|------------|---------| +| `onBatch` | `items: Outer[]`, `raw: Uint8Array`, `first: Outer | null`, `mode: Mode` | `void` | +| `onWidget` | `widget: Widget`, `fallback: Widget | null`, `children: Widget[]` | `void` | +| `onTick` | (none) | `void` | +| `shouldContinue` | `class: number` | `boolean` | +| `pickMode` | (none) | `Mode` | + +##### `Keyword` + +Callback interface whose name and methods are keywords + +Implemented in JS as an object with the methods below. Passed as an `i32` context key plus an `i32` pointer to the static `weaveffi_edge_cases_Keyword_vtable` vtable; the vtable's trailing `free(ctx)` entry releases the JS object. + +| Method | Parameters | Returns | +|--------|------------|---------| +| `delete` | `self: number` | `void` | +| `import` | (none) | `number` | #### Structs @@ -505,6 +586,19 @@ A plain JS object, serialized in a value buffer (`i32` ptr + `i32` len) at the b |-------|------| | `id` | `i64` | +##### `WidgetHolder` + +Record carrying objects in every buffered position + +A plain JS object, serialized in a value buffer (`i32` ptr + `i32` len) at the boundary. Fields in declaration (wire) order: + +| Field | Type | +|-------|------| +| `primary` | `Widget` | +| `fallback` | `Widget?` | +| `extras` | `[Widget]` | +| `by_name` | `{string:Widget}` | + #### Enums ##### `Legacy` diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_d_ts.snap index b428cf9a..e77514c4 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_d_ts.snap @@ -8,8 +8,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated TypeScript declarations for WeaveFFI Wasm bindings /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export declare class WeaveFFIError extends Error { constructor(code: number, message?: string); code: number; @@ -118,6 +119,14 @@ export interface Retired { id: bigint; } +/** Record carrying objects in every buffered position */ +export interface WidgetHolder { + primary: Widget; + fallback: Widget | null; + extras: Widget[]; + by_name: Record; +} + /** Deprecated enum with non-contiguous values and keyword-like variants */ export declare const Legacy: Readonly<{ None: 0; @@ -132,7 +141,46 @@ export declare const Mode: Readonly<{ }>; export type Mode = (typeof Mode)[keyof typeof Mode]; -/** Interface with no constructor; instances come from make_widget */ +/** + * Callback interface carrying composite payloads and objects + * + * Implemented by the consumer; the producer calls these methods synchronously. An exception thrown from a method is reported to the producer as an error (code -4). + */ +export interface BatchListener { + /** + * Composite payloads cross as value buffers + */ + onBatch(items: Outer[], raw: Uint8Array, first: Outer | null, mode: Mode): void; + /** + * Objects cross as strong references the consumer must release + */ + onWidget(widget: Widget, fallback: Widget | null, children: Widget[]): void; + onTick(): void; + /** + * Scalar return travels back through the vtable + */ + shouldContinue(class_: number): boolean; + /** + * Enum return travels back as its integer discriminant + */ + pickMode(): Mode; +} + +/** + * Callback interface whose name and methods are keywords + * + * Implemented by the consumer; the producer calls these methods synchronously. An exception thrown from a method is reported to the producer as an error (code -4). + */ +export interface Keyword { + delete(self: number): void; + import(): number; +} + +/** + * Interface with no constructor; instances come from make_widget + * + * Wraps one reference to a producer object; release it with `close()`. + */ export declare class Widget { /** * Void method that can fail @@ -153,18 +201,27 @@ export declare class Widget { * @throws {WeaveFFIError} if the native call fails */ oldName(): string; - /** Releases the producer-owned handle exactly once. */ - free(): void; + /** Releases this wrapper's reference exactly once; later calls are + * no-ops. Also reachable as `[Symbol.dispose]()` for `using` + * declarations. A wrapper collected unclosed is released by a + * finalizer where the runtime provides one. */ + close(): void; } -/** Deprecated interface with a throwing constructor */ +/** + * Deprecated interface with a throwing constructor + * + * Wraps one reference to a producer object; release it with `close()`. + */ export declare class Vault { /** * Fails when the vault is busy + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {EdgeErrorsError} on a domain error code */ static open(path: string): Vault; /** + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {WeaveFFIError} if the native call fails */ peek(): Widget | null; @@ -173,8 +230,11 @@ export declare class Vault { * @throws {EdgeErrorsError} on a domain error code */ static probe(): boolean; - /** Releases the producer-owned handle exactly once. */ - free(): void; + /** Releases this wrapper's reference exactly once; later calls are + * no-ops. Also reachable as `[Symbol.dispose]()` for `using` + * declarations. A wrapper collected unclosed is released by a + * finalizer where the runtime provides one. */ + close(): void; } export interface WeaveffiWasmModule { @@ -200,17 +260,36 @@ export interface WeaveffiWasmModule { */ optionalParams(text: string | null, count: number | null, outer: Outer | null, tags: string[] | null, blob: Uint8Array | null, mode: Mode | null): Mode | null; /** - * Write-back parameters + * Two callback interface parameters on one function * @throws {WeaveFFIError} if the native call fails */ - mutableParams(text: string, blob: Uint8Array): void; + registerBatch(listener: BatchListener, keyword: Keyword): void; + /** + * Objects in every buffered position + * @throws {WeaveFFIError} if the native call fails + */ + widgetsEverywhere(list: Widget[], byName: Record, sparse: (Widget | null)[], holder: WidgetHolder): WidgetHolder; + /** + * Iterator of objects + * @returns A lazy iterator: one producer step per `next()` call. Exhaust it or call `return()` to release the producer handle (a `for...of` loop does both automatically); an abandoned iterator leaks the handle. + * @throws {WeaveFFIError} if the native call fails + */ + widgetStream(): IterableIterator; + /** + * Async returning an object + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. + * @throws {EdgeErrorsError} on a domain error code + */ + asyncWidget(): Promise; /** * Factory for an interface with no constructor + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {WeaveFFIError} if the native call fails */ makeWidget(label: string): Widget; /** * Optional interface return + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {EdgeErrorsError} on a domain error code */ findWidget(id: bigint): Widget | null; @@ -219,10 +298,6 @@ export interface WeaveffiWasmModule { * @throws {WeaveFFIError} if the native call fails */ inspectWidget(widget: Widget, fallback: Widget | null): string; - /** - * @throws {WeaveFFIError} if the native call fails - */ - typedHandleParam(token: number, raw: bigint): number; /** * Iterator of strings * @returns A lazy iterator: one producer step per `next()` call. Exhaust it or call `return()` to release the producer handle (a `for...of` loop does both automatically); an abandoned iterator leaks the handle. @@ -279,27 +354,11 @@ export interface WeaveffiWasmModule { * @throws {WeaveFFIError} if the native call fails */ legacyValue(value: Legacy): Retired; - /** - * Register a listener for the `OnBatch` callback. - * - * @returns A subscription id for `unregisterBatchListener()`. - */ - registerBatchListener(callback: (items: Outer[], raw: Uint8Array, first: Outer | null, mode: Mode) => void): number; - /** Unregister a listener previously registered with `registerBatchListener()`. */ - unregisterBatchListener(id: number): void; - /** - * Register a listener for the `OnTick` callback. - * - * @returns A subscription id for `unregisterTickListener()`. - */ - registerTickListener(callback: () => void): number; - /** Unregister a listener previously registered with `registerTickListener()`. */ - unregisterTickListener(id: number): void; Widget: typeof Widget; Vault: typeof Vault; }; } -export function loadWeaveffiWasm(url: string): Promise; +export function loadWeaveffiWasm(source: string | URL | BufferSource | WebAssembly.Module): Promise; // END weaveffi_wasm.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_js.snap b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_js.snap index 8f1a8876..87ce7c63 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_edge_cases__weaveffi_wasm_js.snap @@ -9,16 +9,18 @@ source: crates/weaveffi-cli/tests/snapshots.rs // // Boundary conventions for a wasm32-unknown-unknown build: // -// Objects -> i32 pointer into linear memory (0 = null/absent) +// Objects -> i32 pointer into linear memory (0 = null/absent); one +// strong reference per wrapper, released by close() // Enums -> i32 discriminant value // i64/u64 -> JavaScript BigInt // Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer // Bytes -> i32 data pointer + i32 length (out_len for returns) // Buffered -> records, rich enums, optionals, lists, and maps cross // as one value buffer: i32 pointer + i32 length +// Callbacks -> i32 context key + i32 pointer to a static vtable // The ABI revision this glue was generated against. -const _ABI_VERSION = 1; +const _ABI_VERSION = 2; function _checkAbiVersion(wasm) { if (typeof wasm.weaveffi_abi_version !== 'function') { @@ -31,8 +33,9 @@ function _checkAbiVersion(wasm) { } /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -57,14 +60,6 @@ const _EDGE_ERRORS_ERROR_CODES = Object.freeze({ 7: Busy, }); -// Build the EdgeErrorsError subclass matching `code`, or a generic -// WeaveFFIError for codes outside the domain (panics, marshalling). -function _edgeErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { - const _cls = _EDGE_ERRORS_ERROR_CODES[code]; - const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); - return _e; -} - const _enc = new TextEncoder(); const _dec = new TextDecoder(); @@ -144,6 +139,8 @@ class _BufWriter { f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; } len(n) { this.u32(n); } flag(present) { this.u8(present ? 1 : 0); } + // An object token: the pointer (a wasm32 i32, read unsigned) widened to u64. + obj(ptr) { this.u64(BigInt(ptr >>> 0)); } str(v) { const b = _enc.encode(v); this.len(b.length); @@ -208,6 +205,13 @@ class _BufReader { if (b > 1) this._bad('option flag byte out of range'); return b === 1; } + // An object token narrowed back to a wasm32 pointer; zero is never a + // valid token (an absent `Interface?` uses the option flag instead). + obj() { + const t = this.u64(); + if (t === 0n || t > 0xffffffffn) this._bad('object token out of range'); + return Number(t); + } str() { const n = this.len(); const at = this._take(n, 'string bytes'); @@ -237,9 +241,11 @@ function _allocErr(wasm) { // Throw (and free the slot) if the error slot carries a non-zero code. // Non-throwing wrappers route here: a non-zero code can only be a -// producer panic or a marshalling failure, surfaced as the generic +// producer panic (-2), a marshalling failure (-3), or a callback +// interface implementation that raised (-4), surfaced as the generic // WeaveFFIError. function _checkErr(wasm, errPtr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code !== 0) { @@ -253,21 +259,36 @@ function _checkErr(wasm, errPtr) { // Release an error slot on the success path. function _freeErr(wasm, errPtr) { + _pendingForeign = null; wasm.weaveffi_dealloc(errPtr, 16); } -// Throw the `EdgeErrorsError` domain error (and free the slot) if the error slot -// carries a non-zero code. -function _checkEdgeErrorsError(wasm, errPtr) { - const dv = new DataView(wasm.memory.buffer); - const code = dv.getInt32(errPtr, true); - if (code !== 0) { - const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; - const _e = _edgeErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); - wasm.weaveffi_error_clear(errPtr); - wasm.weaveffi_dealloc(errPtr, 16); - throw _e; - } +// The message of a callback-interface failure a trampoline reported through +// out_err, held until the producer call on the stack returns (every error +// checker and _freeErr clear it) so later callbacks in that call are refused. +let _pendingForeign = null; + +// Translate an exception raised while a producer call was on the stack. +// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes +// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of +// the producer writing out_err. A trap that follows a callback-interface +// failure is labelled with that failure (-4); any other trap is a producer +// panic (-2). Non-trap exceptions pass through unchanged. The producer +// frames between the trap and the call are not unwound, so a lock held +// across a callback stays locked; a producer that snapshots its state +// before calling out stays usable. +function _trapError(e) { + const foreign = _pendingForeign; + _pendingForeign = null; + if (!(e instanceof WebAssembly.RuntimeError)) return e; + if (foreign !== null) return new WeaveFFIError(-4, foreign); + return new WeaveFFIError(-2, 'producer panicked: ' + e.message); +} + +// Release the error slot a failed call never filled, then translate. +function _trap(wasm, errPtr, e) { + _freeErr(wasm, errPtr); + return _trapError(e); } // Throw if a heap-boxed (consumer-owned) error carries a non-zero @@ -276,6 +297,7 @@ function _checkEdgeErrorsError(wasm, errPtr) { // `mkErr` maps domain codes (and decodes payload fields) for // throwing callables; without it the generic WeaveFFIError is thrown. function _checkErrRef(wasm, errPtr, mkErr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code === 0) { wasm.weaveffi_error_free(errPtr); return; } @@ -285,254 +307,65 @@ function _checkErrRef(wasm, errPtr, mkErr) { throw err; } -// Serialize a `edge_cases.Leaf` record into the value-buffer wire format. -function _write_edge_cases_Leaf(w, v) { - w.f32(v.weight); -} +// `using` declarations (explicit resource management) call this symbol; +// runtimes that predate it get the registered stand-in so the method is +// still reachable by name. +const _dispose = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); -// Decode a `edge_cases.Leaf` record from the value-buffer wire format. -function _read_edge_cases_Leaf(r) { - const v = {}; - v.weight = r.f32(); - return v; -} +// Finalization backstop: a wrapper that is garbage collected without +// close() still releases its reference. The held value is the +// [destroy, handle] pair (never the wrapper, which would keep it alive); +// the wrapper is the unregister token so close() disarms the backstop and +// destroy runs exactly once either way. Absent on runtimes without +// FinalizationRegistry, where close() is the only release path. +const _finalizer = typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry(([destroy, handle]) => destroy(handle)) + : null; -// Serialize a `edge_cases.Inner` record into the value-buffer wire format. -function _write_edge_cases_Inner(w, v) { - _write_edge_cases_Leaf(w, v.leaf); - const _a1 = v.leaves || []; - w.len(_a1.length); - for (const _e1 of _a1) { - _write_edge_cases_Leaf(w, _e1); - } +// Bind one strong reference to a wrapper and arm the backstop. +function _adopt(obj, handle, destroy) { + obj._handle = handle; + obj._destroy = destroy; + if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj); + return obj; } -// Decode a `edge_cases.Inner` record from the value-buffer wire format. -function _read_edge_cases_Inner(r) { - const v = {}; - v.leaf = _read_edge_cases_Leaf(r); - v.leaves = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(_read_edge_cases_Leaf(r)); return _arr; })(); - return v; +// Release a wrapper's reference exactly once; later calls are no-ops. +function _release(obj) { + if (!obj._handle) return; + if (_finalizer !== null) _finalizer.unregister(obj); + obj._destroy(obj._handle); + obj._handle = 0; } -// Serialize a `edge_cases.Outer` record into the value-buffer wire format. -function _write_edge_cases_Outer(w, v) { - _write_edge_cases_Inner(w, v.inner); - if (v.maybe_inner === null || v.maybe_inner === undefined) { - w.flag(false); - } else { - w.flag(true); - _write_edge_cases_Inner(w, v.maybe_inner); - } - const _s1 = v.by_name || {}; - const _m1 = _s1 instanceof Map ? [..._s1.entries()] : Object.entries(_s1); - w.len(_m1.length); - for (const [_k1, _v1] of _m1) { - w.str(_k1); - _write_edge_cases_Inner(w, _v1); - } - const _a2 = v.modes || []; - w.len(_a2.length); - for (const _e2 of _a2) { - w.i32(_e2); - } - const _a3 = v.matrix || []; - w.len(_a3.length); - for (const _e3 of _a3) { - const _a4 = _e3 || []; - w.len(_a4.length); - for (const _e4 of _a4) { - w.i32(_e4); - } - } - const _a5 = v.sparse || []; - w.len(_a5.length); - for (const _e5 of _a5) { - if (_e5 === null || _e5 === undefined) { - w.flag(false); - } else { - w.flag(true); - w.i64(_e5); - } - } - if (v.blob === null || v.blob === undefined) { - w.flag(false); - } else { - w.flag(true); - w.bytes(v.blob); - } - if (v.note === null || v.note === undefined) { - w.flag(false); - } else { - w.flag(true); - w.str(v.note); - } - const _s6 = v.by_id || {}; - const _m6 = _s6 instanceof Map ? [..._s6.entries()] : Object.entries(_s6); - w.len(_m6.length); - for (const [_k6, _v6] of _m6) { - w.i64(_k6); - w.str(_v6); +// The pointer of a live wrapper, lent to the producer for one call (the +// wrapper keeps its own reference). A closed or non-object argument is a +// consumer programming error, reported with the marshalling code. +function _borrow(obj) { + if (obj === null || obj === undefined || !obj._handle) { + throw new WeaveFFIError(-3, 'expected a live object wrapper'); } - w.bool(v._hidden); - w.u8(v.v2); -} - -// Decode a `edge_cases.Outer` record from the value-buffer wire format. -function _read_edge_cases_Outer(r) { - const v = {}; - v.inner = _read_edge_cases_Inner(r); - v.maybe_inner = (r.flag() ? _read_edge_cases_Inner(r) : null); - v.by_name = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = _read_edge_cases_Inner(r); } return _obj; })(); - v.modes = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.i32()); return _arr; })(); - v.matrix = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push((() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.i32()); return _arr; })()); return _arr; })(); - v.sparse = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push((r.flag() ? r.i64() : null)); return _arr; })(); - v.blob = (r.flag() ? r.bytes() : null); - v.note = (r.flag() ? r.str() : null); - v.by_id = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.i64(); _obj[_k] = r.str(); } return _obj; })(); - v._hidden = r.bool(); - v.v2 = r.u8(); - return v; -} - -// Serialize a `edge_cases.Keywords` record into the value-buffer wire format. -function _write_edge_cases_Keywords(w, v) { - w.i32(v.class); - w.i32(v.def); - w.i32(v.import); - w.i32(v.from); - w.i32(v.func); - w.i32(v.range); - w.i32(v.register); - w.i32(v.volatile); - w.i32(v.new); - w.i32(v.default); - w.i32(v.end); - w.i32(v.in); - w.i32(v.is); - w.i32(v.int); - w.i32(v.var); - w.i32(v.val); - w.i32(v.self); - w.i32(v.this); - w.i32(v.object); - w.i32(v.string); - w.i32(v.case); - w.i32(v.select); - w.i32(v.switch); - w.i32(v.void); - w.i32(v.union); - w.i32(v.namespace); - w.i32(v.operator); - w.i32(v.template); - w.i32(v.package); - w.i32(v.protocol); - w.i32(v.guard); - w.i32(v.defer); - w.i32(v.inout); - w.i32(v.internal); - w.i32(v.when); - w.i32(v.fun); - w.i32(v.data); - w.i32(v.late); - w.i32(v.part); - w.i32(v.out); - w.i32(v.ref); - w.i32(v.event); - w.i32(v.base); - w.i32(v.lambda); - w.i32(v.yield); - w.i32(v.nil); - w.i32(v.null); - w.i32(v.begin); - w.i32(v.rescue); - w.i32(v.module); - w.i32(v.chan); - w.i32(v.go); - w.i32(v.let); - w.i32(v.export); - w.i32(v.delete); - w.i32(v.typedef); - w.i32(v.sizeof); - w.i32(v.static); - w.i32(v.super); -} - -// Decode a `edge_cases.Keywords` record from the value-buffer wire format. -function _read_edge_cases_Keywords(r) { - const v = {}; - v.class = r.i32(); - v.def = r.i32(); - v.import = r.i32(); - v.from = r.i32(); - v.func = r.i32(); - v.range = r.i32(); - v.register = r.i32(); - v.volatile = r.i32(); - v.new = r.i32(); - v.default = r.i32(); - v.end = r.i32(); - v.in = r.i32(); - v.is = r.i32(); - v.int = r.i32(); - v.var = r.i32(); - v.val = r.i32(); - v.self = r.i32(); - v.this = r.i32(); - v.object = r.i32(); - v.string = r.i32(); - v.case = r.i32(); - v.select = r.i32(); - v.switch = r.i32(); - v.void = r.i32(); - v.union = r.i32(); - v.namespace = r.i32(); - v.operator = r.i32(); - v.template = r.i32(); - v.package = r.i32(); - v.protocol = r.i32(); - v.guard = r.i32(); - v.defer = r.i32(); - v.inout = r.i32(); - v.internal = r.i32(); - v.when = r.i32(); - v.fun = r.i32(); - v.data = r.i32(); - v.late = r.i32(); - v.part = r.i32(); - v.out = r.i32(); - v.ref = r.i32(); - v.event = r.i32(); - v.base = r.i32(); - v.lambda = r.i32(); - v.yield = r.i32(); - v.nil = r.i32(); - v.null = r.i32(); - v.begin = r.i32(); - v.rescue = r.i32(); - v.module = r.i32(); - v.chan = r.i32(); - v.go = r.i32(); - v.let = r.i32(); - v.export = r.i32(); - v.delete = r.i32(); - v.typedef = r.i32(); - v.sizeof = r.i32(); - v.static = r.i32(); - v.super = r.i32(); - return v; + return obj._handle; } -// Serialize a `edge_cases.Retired` record into the value-buffer wire format. -function _write_edge_cases_Retired(w, v) { - w.i64(v.id); +// Report a callback-interface failure through the trampoline's out_err +// slot as FOREIGN_ERROR_CODE (-4). weaveffi_error_set copies the message +// with the producer's allocator, so the staged C string is released as +// soon as it returns. The message is parked in _pendingForeign until the +// producer call on the stack returns: the producer can't unwind on wasm32, +// so it keeps running on the trampoline's default return value, and any +// further callback it makes during that call is refused with this error +// instead of reaching the implementation again. +function _reportForeign(wasm, errPtr, msg) { + if (_pendingForeign === null) _pendingForeign = msg; + const [p, s] = _cstr(wasm, msg); + wasm.weaveffi_error_set(errPtr, -4, p); + wasm.weaveffi_dealloc(p, s); } -// Decode a `edge_cases.Retired` record from the value-buffer wire format. -function _read_edge_cases_Retired(r) { - const v = {}; - v.id = r.i64(); - return v; +// Report an exception thrown by a callback-interface implementation. +function _setForeignError(wasm, errPtr, e) { + _reportForeign(wasm, errPtr, e instanceof Error ? (e.message || e.name) : String(e)); } // Lazy wrapper over a producer iterator handle, implementing the JS @@ -570,6 +403,12 @@ class _WeaveFFIIterator { let _has; try { _has = this._callNext(this._handle, this._slot, _err); + } catch (e) { + // A trap: the slot was never filled, so release it here. + this._close(); + throw _trap(wasm, _err, e); + } + try { // Throws (and releases the slot) on a non-zero code. this._check(wasm, _err); } catch (e) { @@ -593,10 +432,10 @@ class _WeaveFFIIterator { } } -function _registerTrampoline(table, paramTypes, handler) { +function _registerTrampoline(table, paramTypes, resultTypes, handler) { const idx = table.grow(1); table.set(idx, new WebAssembly.Function( - { parameters: paramTypes, results: [] }, + { parameters: paramTypes, results: resultTypes }, handler )); return idx; @@ -614,9 +453,11 @@ export const Mode = Object.freeze({ }); /** - * Load a WeaveFFI Wasm module from the given URL. + * Load a WeaveFFI Wasm module. * - * @param {string} url - URL to the `.wasm` file. + * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to + * fetch the `.wasm` from, its bytes, or an already compiled module + * (use the bytes form where `fetch` cannot read files, e.g. Node). * @returns {Promise} The API bindings. * * Exported functions follow the C ABI naming convention: @@ -631,6 +472,12 @@ export const Mode = Object.freeze({ * // Record: plain objects in and out (serialized automatically). * const person = api.contacts.create({ name: 'Ada', age: 36 }); * + * // Object: a class wrapping one producer reference; close() releases it + * // (or let `using` do it). Passing it lends the reference for the call. + * const store = new api.kv.Store(); + * api.kv.inspect(store); + * store.close(); + * * // Enum: pass the integer discriminant. * api.ui.set_color(0); // 0 = first variant * @@ -641,14 +488,328 @@ export const Mode = Object.freeze({ * // List/Map: pass arrays/objects; receive arrays/objects. * const names = api.data.all_names(); */ -export async function loadWeaveffiWasm(url) { - const response = await fetch(url); - const bytes = await response.arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, {}); +export async function loadWeaveffiWasm(source) { + let module; + if (source instanceof WebAssembly.Module) { + module = source; + } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + module = await WebAssembly.compile(source); + } else { + const response = await fetch(source); + module = await WebAssembly.compile(await response.arrayBuffer()); + } + const instance = await WebAssembly.instantiate(module, {}); const wasm = instance.exports; _checkAbiVersion(wasm); + // Build the EdgeErrorsError subclass matching `code`, or a generic + // WeaveFFIError for codes outside the domain (panics, marshalling, + // foreign callback failures). + function _edgeErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { + const _cls = _EDGE_ERRORS_ERROR_CODES[code]; + const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); + return _e; + } + + // Throw the `EdgeErrorsError` domain error (and free the slot) if the error slot + // carries a non-zero code. + function _checkEdgeErrorsError(wasm, errPtr) { + _pendingForeign = null; + const dv = new DataView(wasm.memory.buffer); + const code = dv.getInt32(errPtr, true); + if (code !== 0) { + const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; + const _e = _edgeErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); + wasm.weaveffi_error_clear(errPtr); + wasm.weaveffi_dealloc(errPtr, 16); + throw _e; + } + } + + // Serialize a `edge_cases.Leaf` record into the value-buffer wire format. + function _write_edge_cases_Leaf(w, v) { + w.f32(v.weight); + } + + // Decode a `edge_cases.Leaf` record from the value-buffer wire format. + function _read_edge_cases_Leaf(r) { + const v = {}; + v.weight = r.f32(); + return v; + } + + // Serialize a `edge_cases.Inner` record into the value-buffer wire format. + function _write_edge_cases_Inner(w, v) { + _write_edge_cases_Leaf(w, v.leaf); + const _a1 = v.leaves || []; + w.len(_a1.length); + for (const _e1 of _a1) { + _write_edge_cases_Leaf(w, _e1); + } + } + + // Decode a `edge_cases.Inner` record from the value-buffer wire format. + function _read_edge_cases_Inner(r) { + const v = {}; + v.leaf = _read_edge_cases_Leaf(r); + v.leaves = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(_read_edge_cases_Leaf(r)); return _arr; })(); + return v; + } + + // Serialize a `edge_cases.Outer` record into the value-buffer wire format. + function _write_edge_cases_Outer(w, v) { + _write_edge_cases_Inner(w, v.inner); + if (v.maybe_inner === null || v.maybe_inner === undefined) { + w.flag(false); + } else { + w.flag(true); + _write_edge_cases_Inner(w, v.maybe_inner); + } + const _s1 = v.by_name || {}; + const _m1 = _s1 instanceof Map ? [..._s1.entries()] : Object.entries(_s1); + w.len(_m1.length); + for (const [_k1, _v1] of _m1) { + w.str(_k1); + _write_edge_cases_Inner(w, _v1); + } + const _a2 = v.modes || []; + w.len(_a2.length); + for (const _e2 of _a2) { + w.i32(_e2); + } + const _a3 = v.matrix || []; + w.len(_a3.length); + for (const _e3 of _a3) { + const _a4 = _e3 || []; + w.len(_a4.length); + for (const _e4 of _a4) { + w.i32(_e4); + } + } + const _a5 = v.sparse || []; + w.len(_a5.length); + for (const _e5 of _a5) { + if (_e5 === null || _e5 === undefined) { + w.flag(false); + } else { + w.flag(true); + w.i64(_e5); + } + } + if (v.blob === null || v.blob === undefined) { + w.flag(false); + } else { + w.flag(true); + w.bytes(v.blob); + } + if (v.note === null || v.note === undefined) { + w.flag(false); + } else { + w.flag(true); + w.str(v.note); + } + const _s6 = v.by_id || {}; + const _m6 = _s6 instanceof Map ? [..._s6.entries()] : Object.entries(_s6); + w.len(_m6.length); + for (const [_k6, _v6] of _m6) { + w.i64(_k6); + w.str(_v6); + } + w.bool(v._hidden); + w.u8(v.v2); + } + + // Decode a `edge_cases.Outer` record from the value-buffer wire format. + function _read_edge_cases_Outer(r) { + const v = {}; + v.inner = _read_edge_cases_Inner(r); + v.maybe_inner = (r.flag() ? _read_edge_cases_Inner(r) : null); + v.by_name = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = _read_edge_cases_Inner(r); } return _obj; })(); + v.modes = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.i32()); return _arr; })(); + v.matrix = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push((() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.i32()); return _arr; })()); return _arr; })(); + v.sparse = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push((r.flag() ? r.i64() : null)); return _arr; })(); + v.blob = (r.flag() ? r.bytes() : null); + v.note = (r.flag() ? r.str() : null); + v.by_id = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.i64(); _obj[_k] = r.str(); } return _obj; })(); + v._hidden = r.bool(); + v.v2 = r.u8(); + return v; + } + + // Serialize a `edge_cases.Keywords` record into the value-buffer wire format. + function _write_edge_cases_Keywords(w, v) { + w.i32(v.class); + w.i32(v.def); + w.i32(v.import); + w.i32(v.from); + w.i32(v.func); + w.i32(v.range); + w.i32(v.register); + w.i32(v.volatile); + w.i32(v.new); + w.i32(v.default); + w.i32(v.end); + w.i32(v.in); + w.i32(v.is); + w.i32(v.int); + w.i32(v.var); + w.i32(v.val); + w.i32(v.self); + w.i32(v.this); + w.i32(v.object); + w.i32(v.string); + w.i32(v.case); + w.i32(v.select); + w.i32(v.switch); + w.i32(v.void); + w.i32(v.union); + w.i32(v.namespace); + w.i32(v.operator); + w.i32(v.template); + w.i32(v.package); + w.i32(v.protocol); + w.i32(v.guard); + w.i32(v.defer); + w.i32(v.inout); + w.i32(v.internal); + w.i32(v.when); + w.i32(v.fun); + w.i32(v.data); + w.i32(v.late); + w.i32(v.part); + w.i32(v.out); + w.i32(v.ref); + w.i32(v.event); + w.i32(v.base); + w.i32(v.lambda); + w.i32(v.yield); + w.i32(v.nil); + w.i32(v.null); + w.i32(v.begin); + w.i32(v.rescue); + w.i32(v.module); + w.i32(v.chan); + w.i32(v.go); + w.i32(v.let); + w.i32(v.export); + w.i32(v.delete); + w.i32(v.typedef); + w.i32(v.sizeof); + w.i32(v.static); + w.i32(v.super); + } + + // Decode a `edge_cases.Keywords` record from the value-buffer wire format. + function _read_edge_cases_Keywords(r) { + const v = {}; + v.class = r.i32(); + v.def = r.i32(); + v.import = r.i32(); + v.from = r.i32(); + v.func = r.i32(); + v.range = r.i32(); + v.register = r.i32(); + v.volatile = r.i32(); + v.new = r.i32(); + v.default = r.i32(); + v.end = r.i32(); + v.in = r.i32(); + v.is = r.i32(); + v.int = r.i32(); + v.var = r.i32(); + v.val = r.i32(); + v.self = r.i32(); + v.this = r.i32(); + v.object = r.i32(); + v.string = r.i32(); + v.case = r.i32(); + v.select = r.i32(); + v.switch = r.i32(); + v.void = r.i32(); + v.union = r.i32(); + v.namespace = r.i32(); + v.operator = r.i32(); + v.template = r.i32(); + v.package = r.i32(); + v.protocol = r.i32(); + v.guard = r.i32(); + v.defer = r.i32(); + v.inout = r.i32(); + v.internal = r.i32(); + v.when = r.i32(); + v.fun = r.i32(); + v.data = r.i32(); + v.late = r.i32(); + v.part = r.i32(); + v.out = r.i32(); + v.ref = r.i32(); + v.event = r.i32(); + v.base = r.i32(); + v.lambda = r.i32(); + v.yield = r.i32(); + v.nil = r.i32(); + v.null = r.i32(); + v.begin = r.i32(); + v.rescue = r.i32(); + v.module = r.i32(); + v.chan = r.i32(); + v.go = r.i32(); + v.let = r.i32(); + v.export = r.i32(); + v.delete = r.i32(); + v.typedef = r.i32(); + v.sizeof = r.i32(); + v.static = r.i32(); + v.super = r.i32(); + return v; + } + + // Serialize a `edge_cases.Retired` record into the value-buffer wire format. + function _write_edge_cases_Retired(w, v) { + w.i64(v.id); + } + + // Decode a `edge_cases.Retired` record from the value-buffer wire format. + function _read_edge_cases_Retired(r) { + const v = {}; + v.id = r.i64(); + return v; + } + + // Serialize a `edge_cases.WidgetHolder` record into the value-buffer wire format. + function _write_edge_cases_WidgetHolder(w, v) { + w.obj(v.primary._clone()); + if (v.fallback === null || v.fallback === undefined) { + w.flag(false); + } else { + w.flag(true); + w.obj(v.fallback._clone()); + } + const _a1 = v.extras || []; + w.len(_a1.length); + for (const _e1 of _a1) { + w.obj(_e1._clone()); + } + const _s2 = v.by_name || {}; + const _m2 = _s2 instanceof Map ? [..._s2.entries()] : Object.entries(_s2); + w.len(_m2.length); + for (const [_k2, _v2] of _m2) { + w.str(_k2); + w.obj(_v2._clone()); + } + } + + // Decode a `edge_cases.WidgetHolder` record from the value-buffer wire format. + function _read_edge_cases_WidgetHolder(r) { + const v = {}; + v.primary = Widget._wrap(r.obj()); + v.fallback = (r.flag() ? Widget._wrap(r.obj()) : null); + v.extras = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(Widget._wrap(r.obj())); return _arr; })(); + v.by_name = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = Widget._wrap(r.obj()); } return _obj; })(); + return v; + } + const _table = wasm.__indirect_function_table; let _nextCtxId = 1; @@ -666,53 +827,179 @@ export async function loadWeaveffiWasm(url) { } } - const _cbPtr_i32_i32 = _registerTrampoline(_table, ['i32', 'i32'], _asyncHandler); - const _cbPtr_i32_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32'], _asyncHandler); - const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], _asyncHandler); + const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], [], _asyncHandler); + const _cbPtr_i32_i32 = _registerTrampoline(_table, ['i32', 'i32'], [], _asyncHandler); + const _cbPtr_i32_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32'], [], _asyncHandler); - // Listener subscriptions, keyed by the context id the loader - // threads through the C ABI's void* context slot. Each entry - // holds the JS callback and the producer's subscription id. - let _nextLsnId = 1; - const _listeners = new Map(); + // Callback-interface implementations, keyed by the integer context + // the loader passes as `ctx`; an entry lives until the producer + // calls the vtable's `free(ctx)`. + let _nextCbId = 1; + const _callbacks = new Map(); - const _lsnPtr_weaveffi_edge_cases_OnBatch_fn = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'], (a0, a1, a2, a3, a4, a5, a6, _ctx) => { - const _l = _listeners.get(_ctx); - if (_l === undefined) return; - const _p0_b = (a0 === 0 || a1 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a0, a1).slice(); - const _p0_r = new _BufReader(_p0_b); - const _p0 = (() => { const _n = _p0_r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(_read_edge_cases_Outer(_p0_r)); return _arr; })(); - _p0_r.end(); - const _p1 = (a2 === 0 || a3 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a2, a3).slice(); - const _p2_b = (a4 === 0 || a5 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a4, a5).slice(); - const _p2_r = new _BufReader(_p2_b); - const _p2 = (_p2_r.flag() ? _read_edge_cases_Outer(_p2_r) : null); - _p2_r.end(); - const _p3 = a6; - _l.callback(_p0, _p1, _p2, _p3); + // Trampolines for the `BatchListener` callback interface: one WebAssembly.Function + // per vtable entry, mirroring the C signature of each method. Borrowed + // arguments are decoded before the JS method runs; object arguments + // are adopted; a thrown exception becomes a -4 error on out_err. + const _cb_BatchListener_on_batch = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'], [], (_ctx, a0, a1, a2, a3, a4, a5, a6, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0_b = (a0 === 0 || a1 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a0, a1).slice(); + const _p0_r = new _BufReader(_p0_b); + const _p0 = (() => { const _n = _p0_r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(_read_edge_cases_Outer(_p0_r)); return _arr; })(); + _p0_r.end(); + const _p1 = (a2 === 0 || a3 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a2, a3).slice(); + const _p2_b = (a4 === 0 || a5 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a4, a5).slice(); + const _p2_r = new _BufReader(_p2_b); + const _p2 = (_p2_r.flag() ? _read_edge_cases_Outer(_p2_r) : null); + _p2_r.end(); + const _p3 = a6; + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onBatch(_p0, _p1, _p2, _p3); + } catch (e) { + _setForeignError(wasm, _err, e); + } + }); + const _cb_BatchListener_on_widget = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32', 'i32', 'i32'], [], (_ctx, a0, a1, a2, a3, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = Widget._wrap(a0); + const _p1 = a1 === 0 ? null : Widget._wrap(a1); + const _p2_b = (a2 === 0 || a3 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a2, a3).slice(); + const _p2_r = new _BufReader(_p2_b); + const _p2 = (() => { const _n = _p2_r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(Widget._wrap(_p2_r.obj())); return _arr; })(); + _p2_r.end(); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onWidget(_p0, _p1, _p2); + } catch (e) { + _setForeignError(wasm, _err, e); + } }); - const _lsnPtr_weaveffi_edge_cases_OnTick_fn = _registerTrampoline(_table, ['i32'], (_ctx) => { - const _l = _listeners.get(_ctx); - if (_l === undefined) return; - _l.callback(); + const _cb_BatchListener_on_tick = _registerTrampoline(_table, ['i32', 'i32'], [], (_ctx, _err) => { + try { + const _impl = _callbacks.get(_ctx); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onTick(); + } catch (e) { + _setForeignError(wasm, _err, e); + } }); + const _cb_BatchListener_should_continue = _registerTrampoline(_table, ['i32', 'i32', 'i32'], ['i32'], (_ctx, a0, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = a0; + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return 0; + } + return _impl.shouldContinue(_p0) ? 1 : 0; + } catch (e) { + _setForeignError(wasm, _err, e); + return 0; + } + }); + const _cb_BatchListener_pick_mode = _registerTrampoline(_table, ['i32', 'i32'], ['i32'], (_ctx, _err) => { + try { + const _impl = _callbacks.get(_ctx); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return 0; + } + return _impl.pickMode(); + } catch (e) { + _setForeignError(wasm, _err, e); + return 0; + } + }); + // `free(ctx)`: the producer dropped its last reference; forget the + // implementation so it can be collected. + const _cb_BatchListener_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); }); + // The one static `weaveffi_edge_cases_BatchListener_vtable` for this instance, filled with the table indices + // above in declaration order, then `free`. + const _vtable_BatchListener = wasm.weaveffi_alloc(24); + { + const _dv = new DataView(wasm.memory.buffer); + _dv.setUint32(_vtable_BatchListener + 0, _cb_BatchListener_on_batch, true); + _dv.setUint32(_vtable_BatchListener + 4, _cb_BatchListener_on_widget, true); + _dv.setUint32(_vtable_BatchListener + 8, _cb_BatchListener_on_tick, true); + _dv.setUint32(_vtable_BatchListener + 12, _cb_BatchListener_should_continue, true); + _dv.setUint32(_vtable_BatchListener + 16, _cb_BatchListener_pick_mode, true); + _dv.setUint32(_vtable_BatchListener + 20, _cb_BatchListener_free, true); + } + + // Trampolines for the `Keyword` callback interface: one WebAssembly.Function + // per vtable entry, mirroring the C signature of each method. Borrowed + // arguments are decoded before the JS method runs; object arguments + // are adopted; a thrown exception becomes a -4 error on out_err. + const _cb_Keyword_delete = _registerTrampoline(_table, ['i32', 'i32', 'i32'], [], (_ctx, a0, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = a0; + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.delete(_p0); + } catch (e) { + _setForeignError(wasm, _err, e); + } + }); + const _cb_Keyword_import = _registerTrampoline(_table, ['i32', 'i32'], ['i32'], (_ctx, _err) => { + try { + const _impl = _callbacks.get(_ctx); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return 0; + } + return _impl.import(); + } catch (e) { + _setForeignError(wasm, _err, e); + return 0; + } + }); + // `free(ctx)`: the producer dropped its last reference; forget the + // implementation so it can be collected. + const _cb_Keyword_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); }); + // The one static `weaveffi_edge_cases_Keyword_vtable` for this instance, filled with the table indices + // above in declaration order, then `free`. + const _vtable_Keyword = wasm.weaveffi_alloc(12); + { + const _dv = new DataView(wasm.memory.buffer); + _dv.setUint32(_vtable_Keyword + 0, _cb_Keyword_delete, true); + _dv.setUint32(_vtable_Keyword + 4, _cb_Keyword_import, true); + _dv.setUint32(_vtable_Keyword + 8, _cb_Keyword_free, true); + } // Interface with no constructor; instances come from make_widget class Widget { static _wrap(handle) { - const _o = Object.create(Widget.prototype); - _o._handle = handle; - return _o; + return _adopt(Object.create(Widget.prototype), handle, wasm.weaveffi_edge_cases_Widget_destroy); } - free() { - if (this._handle !== 0) { - wasm.weaveffi_edge_cases_Widget_destroy(this._handle); - this._handle = 0; - } + _clone() { + return wasm.weaveffi_edge_cases_Widget_clone(_borrow(this)); + } + close() { + _release(this); + } + [_dispose]() { + _release(this); } refresh() { const _err = _allocErr(wasm); - wasm.weaveffi_edge_cases_Widget_refresh(this._handle, _err); + try { + wasm.weaveffi_edge_cases_Widget_refresh(_borrow(this), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkEdgeErrorsError(wasm, _err); _freeErr(wasm, _err); } @@ -727,9 +1014,15 @@ export async function loadWeaveffiWasm(url) { } const [a1_p, a1_l] = _bytes(wasm, a1_w.finish()); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_Widget_rename(this._handle, a0_p, a1_p, a1_l, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); - wasm.weaveffi_dealloc(a1_p, a1_l); + let _r; + try { + _r = wasm.weaveffi_edge_cases_Widget_rename(_borrow(this), a0_p, a1_p, a1_l, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + wasm.weaveffi_dealloc(a1_p, a1_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -745,13 +1038,23 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _v; } }); - wasm.weaveffi_edge_cases_Widget_fetch_async(this._handle, BigInt(id), _cbPtr_i32_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_Widget_fetch_async(_borrow(this), BigInt(id), _cbPtr_i32_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); } /** @deprecated Use rename instead */ oldName() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_Widget_old_name(this._handle, _err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_Widget_old_name(_borrow(this), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -761,35 +1064,52 @@ export async function loadWeaveffiWasm(url) { // Deprecated interface with a throwing constructor class Vault { static _wrap(handle) { - const _o = Object.create(Vault.prototype); - _o._handle = handle; - return _o; + return _adopt(Object.create(Vault.prototype), handle, wasm.weaveffi_edge_cases_Vault_destroy); } - free() { - if (this._handle !== 0) { - wasm.weaveffi_edge_cases_Vault_destroy(this._handle); - this._handle = 0; - } + _clone() { + return wasm.weaveffi_edge_cases_Vault_clone(_borrow(this)); + } + close() { + _release(this); + } + [_dispose]() { + _release(this); } static open(path) { const [a0_p, a0_s] = _cstr(wasm, path); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_Vault_open(a0_p, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); + let _r; + try { + _r = wasm.weaveffi_edge_cases_Vault_open(a0_p, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } _checkEdgeErrorsError(wasm, _err); _freeErr(wasm, _err); return Vault._wrap(_r); } peek() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_Vault_peek(this._handle, _err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_Vault_peek(_borrow(this), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r === 0 ? null : Widget._wrap(_r); } static probe() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_Vault_probe(_err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_Vault_probe(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkEdgeErrorsError(wasm, _err); _freeErr(wasm, _err); return _r !== 0; @@ -801,7 +1121,11 @@ export async function loadWeaveffiWasm(url) { edge_cases: { noop() { const _err = _allocErr(wasm); - wasm.weaveffi_edge_cases_noop(_err); + try { + wasm.weaveffi_edge_cases_noop(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); }, @@ -823,10 +1147,16 @@ export async function loadWeaveffiWasm(url) { } const [a3_p, a3_l] = _bytes(wasm, a3_w.finish()); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_select(class_, a1_p, a2_p, a2_l, a3_p, a3_l, type ? 1 : 0, _err); - wasm.weaveffi_dealloc(a1_p, a1_s); - wasm.weaveffi_dealloc(a2_p, a2_l); - wasm.weaveffi_dealloc(a3_p, a3_l); + let _r; + try { + _r = wasm.weaveffi_edge_cases_select(class_, a1_p, a2_p, a2_l, a3_p, a3_l, type ? 1 : 0, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a1_p, a1_s); + wasm.weaveffi_dealloc(a2_p, a2_l); + wasm.weaveffi_dealloc(a3_p, a3_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; @@ -851,8 +1181,15 @@ export async function loadWeaveffiWasm(url) { const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_nested_optional(a0_p, a0_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_edge_cases_nested_optional(a0_p, a0_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -917,13 +1254,20 @@ export async function loadWeaveffiWasm(url) { const [a5_p, a5_l] = _bytes(wasm, a5_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_optional_params(a0_p, a0_l, a1_p, a1_l, a2_p, a2_l, a3_p, a3_l, a4_p, a4_l, a5_p, a5_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); - wasm.weaveffi_dealloc(a1_p, a1_l); - wasm.weaveffi_dealloc(a2_p, a2_l); - wasm.weaveffi_dealloc(a3_p, a3_l); - wasm.weaveffi_dealloc(a4_p, a4_l); - wasm.weaveffi_dealloc(a5_p, a5_l); + let _r; + try { + _r = wasm.weaveffi_edge_cases_optional_params(a0_p, a0_l, a1_p, a1_l, a2_p, a2_l, a3_p, a3_l, a4_p, a4_l, a5_p, a5_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + wasm.weaveffi_dealloc(a1_p, a1_l); + wasm.weaveffi_dealloc(a2_p, a2_l); + wasm.weaveffi_dealloc(a3_p, a3_l); + wasm.weaveffi_dealloc(a4_p, a4_l); + wasm.weaveffi_dealloc(a5_p, a5_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -933,49 +1277,149 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _out; }, - mutableParams(text, blob) { - const [a0_p, a0_s] = _cstr(wasm, text); - const [a1_p, a1_l] = _bytes(wasm, blob); + registerBatch(listener, keyword) { + const a0_ctx = _nextCbId++; + _callbacks.set(a0_ctx, listener); + const a1_ctx = _nextCbId++; + _callbacks.set(a1_ctx, keyword); const _err = _allocErr(wasm); - wasm.weaveffi_edge_cases_mutable_params(a0_p, a1_p, a1_l, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); - wasm.weaveffi_dealloc(a1_p, a1_l); + try { + wasm.weaveffi_edge_cases_register_batch(a0_ctx, _vtable_BatchListener, a1_ctx, _vtable_Keyword, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); }, + widgetsEverywhere(list, byName, sparse, holder) { + const a0_w = new _BufWriter(); + const _a1 = list || []; + a0_w.len(_a1.length); + for (const _e1 of _a1) { + a0_w.obj(_e1._clone()); + } + const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); + const a1_w = new _BufWriter(); + const _s1 = byName || {}; + const _m1 = _s1 instanceof Map ? [..._s1.entries()] : Object.entries(_s1); + a1_w.len(_m1.length); + for (const [_k1, _v1] of _m1) { + a1_w.str(_k1); + a1_w.obj(_v1._clone()); + } + const [a1_p, a1_l] = _bytes(wasm, a1_w.finish()); + const a2_w = new _BufWriter(); + const _a1 = sparse || []; + a2_w.len(_a1.length); + for (const _e1 of _a1) { + if (_e1 === null || _e1 === undefined) { + a2_w.flag(false); + } else { + a2_w.flag(true); + a2_w.obj(_e1._clone()); + } + } + const [a2_p, a2_l] = _bytes(wasm, a2_w.finish()); + const a3_w = new _BufWriter(); + _write_edge_cases_WidgetHolder(a3_w, holder); + const [a3_p, a3_l] = _bytes(wasm, a3_w.finish()); + const _lp = wasm.weaveffi_alloc(4); + const _err = _allocErr(wasm); + let _r; + try { + _r = wasm.weaveffi_edge_cases_widgets_everywhere(a0_p, a0_l, a1_p, a1_l, a2_p, a2_l, a3_p, a3_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + wasm.weaveffi_dealloc(a1_p, a1_l); + wasm.weaveffi_dealloc(a2_p, a2_l); + wasm.weaveffi_dealloc(a3_p, a3_l); + } + _checkErr(wasm, _err); + _freeErr(wasm, _err); + const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); + wasm.weaveffi_dealloc(_lp, 4); + const _rd = new _BufReader(_takeBytes(wasm, _r, _len)); + const _out = _read_edge_cases_WidgetHolder(_rd); + _rd.end(); + return _out; + }, + widgetStream() { + const _err = _allocErr(wasm); + let _it; + try { + _it = wasm.weaveffi_edge_cases_widget_stream(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } + _checkErr(wasm, _err); + _freeErr(wasm, _err); + return new _WeaveFFIIterator(wasm, _it, 4, + (it, slot, ep) => wasm.weaveffi_edge_cases_WidgetStreamIterator_next(it, slot, ep), + (it) => wasm.weaveffi_edge_cases_WidgetStreamIterator_destroy(it), + _checkErr, (w, p) => Widget._wrap(new DataView(w.memory.buffer).getUint32(p, true))); + }, + asyncWidget() { + return new Promise((resolve, reject) => { + const ctxId = _nextCtxId++; + _asyncContexts.set(ctxId, { resolve, reject, mkErr: _edgeErrorsErrorFrom, unwrap: (w, h) => Widget._wrap(h) }); + try { + wasm.weaveffi_edge_cases_async_widget_async(_cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } + }); + }, makeWidget(label) { const [a0_p, a0_s] = _cstr(wasm, label); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_make_widget(a0_p, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); + let _r; + try { + _r = wasm.weaveffi_edge_cases_make_widget(a0_p, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return Widget._wrap(_r); }, findWidget(id) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_find_widget(BigInt(id), _err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_find_widget(BigInt(id), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkEdgeErrorsError(wasm, _err); _freeErr(wasm, _err); return _r === 0 ? null : Widget._wrap(_r); }, inspectWidget(widget, fallback) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_inspect_widget(widget._handle, (fallback ? fallback._handle : 0), _err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_inspect_widget(_borrow(widget), (fallback === null || fallback === undefined ? 0 : _borrow(fallback)), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); }, - typedHandleParam(token, raw) { - const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_typed_handle_param(token, BigInt(raw), _err); - _checkErr(wasm, _err); - _freeErr(wasm, _err); - return _r; - }, stringStream() { const _err = _allocErr(wasm); - const _it = wasm.weaveffi_edge_cases_string_stream(_err); + let _it; + try { + _it = wasm.weaveffi_edge_cases_string_stream(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return new _WeaveFFIIterator(wasm, _it, 4, @@ -985,7 +1429,12 @@ export async function loadWeaveffiWasm(url) { }, scalarStream() { const _err = _allocErr(wasm); - const _it = wasm.weaveffi_edge_cases_scalar_stream(_err); + let _it; + try { + _it = wasm.weaveffi_edge_cases_scalar_stream(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return new _WeaveFFIIterator(wasm, _it, 4, @@ -995,7 +1444,12 @@ export async function loadWeaveffiWasm(url) { }, modeStream() { const _err = _allocErr(wasm); - const _it = wasm.weaveffi_edge_cases_mode_stream(_err); + let _it; + try { + _it = wasm.weaveffi_edge_cases_mode_stream(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return new _WeaveFFIIterator(wasm, _it, 4, @@ -1007,7 +1461,12 @@ export async function loadWeaveffiWasm(url) { return new Promise((resolve, reject) => { const ctxId = _nextCtxId++; _asyncContexts.set(ctxId, { resolve, reject }); - wasm.weaveffi_edge_cases_async_void_async(_cbPtr_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_void_async(_cbPtr_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncBytes(size) { @@ -1018,7 +1477,12 @@ export async function loadWeaveffiWasm(url) { if (ptr !== 0) w.weaveffi_free_bytes(ptr, len); return _b; } }); - wasm.weaveffi_edge_cases_async_bytes_async(size, _cbPtr_i32_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_bytes_async(size, _cbPtr_i32_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncStruct() { @@ -1032,7 +1496,12 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _v; } }); - wasm.weaveffi_edge_cases_async_struct_async(_cbPtr_i32_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_struct_async(_cbPtr_i32_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncList() { @@ -1046,7 +1515,12 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _v; } }); - wasm.weaveffi_edge_cases_async_list_async(0, _cbPtr_i32_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_list_async(0, _cbPtr_i32_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncOptional() { @@ -1060,14 +1534,24 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _v; } }); - wasm.weaveffi_edge_cases_async_optional_async(_cbPtr_i32_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_optional_async(_cbPtr_i32_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncEnum() { return new Promise((resolve, reject) => { const ctxId = _nextCtxId++; _asyncContexts.set(ctxId, { resolve, reject }); - wasm.weaveffi_edge_cases_async_enum_async(_cbPtr_i32_i32_i32, ctxId); + try { + wasm.weaveffi_edge_cases_async_enum_async(_cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); }, asyncKeywords(class_, import_) { @@ -1079,8 +1563,14 @@ export async function loadWeaveffiWasm(url) { return _s; } }); const [a0_p, a0_s] = _cstr(wasm, class_); - wasm.weaveffi_edge_cases_async_keywords_async(a0_p, import_, _cbPtr_i32_i32_i32, ctxId); - wasm.weaveffi_dealloc(a0_p, a0_s); + try { + wasm.weaveffi_edge_cases_async_keywords_async(a0_p, import_, _cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } }); }, keywordsRoundtrip(value) { @@ -1089,8 +1579,15 @@ export async function loadWeaveffiWasm(url) { const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_keywords_roundtrip(a0_p, a0_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_edge_cases_keywords_roundtrip(a0_p, a0_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -1104,7 +1601,13 @@ export async function loadWeaveffiWasm(url) { legacyValue(value) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_edge_cases_legacy_value(value, _lp, _err); + let _r; + try { + _r = wasm.weaveffi_edge_cases_legacy_value(value, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -1114,30 +1617,6 @@ export async function loadWeaveffiWasm(url) { _rd.end(); return _out; }, - registerBatchListener(callback) { - const _id = _nextLsnId++; - const _rid = wasm.weaveffi_edge_cases_register_batch_listener(_lsnPtr_weaveffi_edge_cases_OnBatch_fn, _id); - _listeners.set(_id, { callback, rid: _rid }); - return _id; - }, - unregisterBatchListener(id) { - const _l = _listeners.get(id); - if (_l === undefined) return; - _listeners.delete(id); - wasm.weaveffi_edge_cases_unregister_batch_listener(_l.rid); - }, - registerTickListener(callback) { - const _id = _nextLsnId++; - const _rid = wasm.weaveffi_edge_cases_register_tick_listener(_lsnPtr_weaveffi_edge_cases_OnTick_fn, _id); - _listeners.set(_id, { callback, rid: _rid }); - return _id; - }, - unregisterTickListener(id) { - const _l = _listeners.get(id); - if (_l === undefined) return; - _listeners.delete(id); - wasm.weaveffi_edge_cases_unregister_tick_listener(_l.rid); - }, Widget: Widget, Vault: Vault, }, diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__README_md.snap b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__README_md.snap index 449719f4..44341e7c 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__README_md.snap @@ -15,7 +15,7 @@ Build (example): cargo build --target wasm32-unknown-unknown --release ``` -Then serve the `.wasm` and use `weaveffi_wasm.js` to load it. +Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot read files), or an already compiled `WebAssembly.Module`. ## Complex Type Handling @@ -29,9 +29,17 @@ Records are **plain JavaScript objects**. A record crosses the boundary serializ C-style enums are passed as **`i32` values** corresponding to the variant's integer discriminant. Rich (algebraic) enums are plain objects tagged by variant name (`{ tag: "Circle", radius: 2 }`) and cross the boundary serialized in a value buffer, exactly like records. +### Objects + +Interfaces are **wrapper classes** holding one reference to a producer object (an `i32` pointer into linear memory). A wrapper you receive is yours: release it with `close()` (or bind it with `using`, which calls `Symbol.dispose`); a wrapper collected unclosed is released by a `FinalizationRegistry` backstop where the runtime provides one, and destroy runs exactly once either way. Passing a wrapper to a function lends its reference for the duration of the call. Inside a value buffer an object crosses as a `u64` token carrying one reference: the writer clones the wrapper's reference and the reader adopts the token into a new wrapper. + +### 64-bit Integers + +`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, returns, record fields, and callback parameters. Map keys of these types become string property names on the decoded object. A `number` is accepted where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional value throws a `RangeError`). Values crossing by value follow the wasm calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than throwing, and unsigned results are read back as unsigned (`u64` via `BigInt.asUintN`, `u32` via `>>> 0`). + ### Optionals -Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent). +Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`. ### Lists and Maps @@ -41,11 +49,15 @@ Lists are JS arrays; maps are plain objects (a `Map` instance is also accepted o `iter` functions return a **lazy JS iterator** (typed `IterableIterator`): each `next()` issues exactly one producer call, so iteration streams in constant memory. The producer handle is destroyed exactly once, on exhaustion or via `return()` when iteration stops early (a `for...of` loop calls `return()` automatically on `break` or `throw`). Abandoning an iterator without exhausting or closing it leaks the handle. -### Callbacks and Listeners +### Async Functions + +`async` functions return a **`Promise`**. `wasm32-unknown-unknown` has no threads, so the producer's default spawner drives each future to completion inline, inside the launch call: the completion callback fires (through a function-table trampoline) before the wrapper returns, and the Promise is already settled when you `await` it. There is nothing to pump or poll from JS. A future that returns `Pending` can't be woken on this target with the default spawner; a producer whose futures wait on external events needs its own spawner (`weaveffi::set_spawner`), and driving that executor is then the producer's responsibility, not the glue's. A typed error rejects the Promise with the same class the synchronous wrapper would throw. + +### Callback Interfaces -Each listener surfaces as a `register.../unregister...` pair. `register` takes a plain JS function and returns a numeric subscription id; `unregister` takes that id and stops delivery. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so events fire only while a call into the module is on the stack (for example, a producer function that emits during its own execution). A producer that emits from a spawned thread cannot run on this target at all. +A callback interface is a TypeScript `interface`; pass any object with the matching methods where a parameter of that type is expected. The loader keeps one **static vtable per interface** in linear memory, filled with function-table indices of long-lived trampolines, and passes a small integer context key with it; the producer calls `free(ctx)` when it drops the callback, which releases your object. Delivery is **synchronous and same-thread**: `wasm32-unknown-unknown` is single-threaded, so a method runs only while a call into the module is on the stack. A producer that calls back from a spawned thread cannot run on this target at all. -Callback arguments are **borrowed for the duration of the callback**: strings, byte buffers, and buffered values (records, rich enums, optionals, lists, maps) are copied or decoded into JS values before your function runs, and interface arguments wrap producer-owned objects. Read what you need inside the callback and do not retain an interface wrapper or call `free()` on it. +Method arguments that are strings, bytes, or buffered values are **borrowed for the duration of the call**: they're copied or decoded into JS values before your method runs. Object arguments transfer one reference to you: `close()` the wrapper when you're done with it. An exception thrown from a method is reported to the producer as error code `-4` (with the exception's message) and the method's default value is returned in its place; the producer then aborts the call it was making, and the original caller sees a `WeaveFFIError` with code `-4` carrying your message (see *Traps* below for how that abort reaches the caller on this target). ### Error Handling @@ -59,15 +71,25 @@ functions: - `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory - `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block - `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources +- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions) - `weaveffi_free_string(ptr: i32)`: free a producer-returned C string - `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer Wrappers of functions declared `throws` raise the declaring module's typed error class (a `WeaveFFIError` subclass with a per-code subclass, such as `KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only -for producer panics and marshalling failures. When the matched error code -declares payload fields, the wrapper decodes them from the error's value -buffer and attaches them as properties on the thrown error. +for producer panics, marshalling failures, and callback exceptions +(code `-4`). When the matched error code declares payload fields, the +wrapper decodes them from the error's value buffer and attaches them as +properties on the thrown error. + +#### Callback failures without unwinding + +`wasm32-unknown-unknown` builds with `panic = "abort"`, so when a callback interface method throws, the producer can't unwind out of the call the way native targets do. Instead the runtime records the failure, the producer's code runs to completion on the method's default return value (`0`, `0n`, or `false`), and the thunk then reports code `-4` with the exception's message in place of the result, so the wrapper throws (or the Promise rejects with) the same `WeaveFFIError` a native build would produce. While that producer call is still on the stack, the glue refuses every further callback invocation with the same error rather than calling your implementation again, so your code observes the failure exactly as it would under unwinding. Producers must tolerate a callback returning its default once (a well-behaved producer already does, because a consumer could return that value on purpose). + +#### Traps + +A producer panic can't be caught on the Rust side either: it executes `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of the call. The glue translates that trap for you: the wrapper throws (or the Promise rejects with) a `WeaveFFIError` with code `-2` (`producer panicked`), or code `-4` if a callback method had already failed during the call. The error slot and every staged argument are released either way. The producer frames between the trap and the call are *not* unwound, so their cleanup does not run: a lock held across the panic stays locked, and allocations those frames owned leak. ## API Reference @@ -176,47 +198,47 @@ Identity for bool | `b` | `bytes` | `i32, i32` | ptr + len in linear memory | | _returns_ | `bytes` | `i32, i32` | ptr + len in linear memory | -##### `weaveffi_kitchen_echo_borrowed_str` +##### `weaveffi_kitchen_subscribe` -Borrowed string parameter +Register a listener, returning how many are attached -`weaveffi_kitchen_echo_borrowed_str(s: i32) -> i32` +`weaveffi_kitchen_subscribe(listener: i32, i32) -> i32` | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| `s` | `string` | `i32` | NUL-terminated C string pointer | -| _returns_ | `string` | `i32` | NUL-terminated C string pointer | +| `listener` | `ReadyListener` | `i32, i32` | context key + static vtable pointer | +| _returns_ | `i32` | `i32` | native Wasm i32 | -##### `weaveffi_kitchen_echo_borrowed_bytes` +##### `weaveffi_kitchen_maybe_gadget` -Borrowed bytes parameter +Optional object in both directions -`weaveffi_kitchen_echo_borrowed_bytes(b: i32, i32) -> i32, i32` +`weaveffi_kitchen_maybe_gadget(fallback: i32) -> i32` | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| `b` | `bytes` | `i32, i32` | ptr + len in linear memory | -| _returns_ | `bytes` | `i32, i32` | ptr + len in linear memory | +| `fallback` | `Gadget?` | `i32` | nullable object pointer, 0 = absent | +| _returns_ | `Gadget?` | `i32` | nullable object pointer, 0 = absent | -##### `weaveffi_kitchen_open_handle` +##### `weaveffi_kitchen_gadgets` -Returns an opaque handle +A list of objects -`weaveffi_kitchen_open_handle() -> i64` +`weaveffi_kitchen_gadgets() -> i32, i32` | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| _returns_ | `handle` | `i64` | opaque 64-bit handle | +| _returns_ | `[Gadget]` | `i32, i32` | value buffer: ptr + len in linear memory | -##### `weaveffi_kitchen_open_typed_handle` +##### `weaveffi_kitchen_stream_gadgets` -Returns a typed handle +An iterator over objects -`weaveffi_kitchen_open_typed_handle() -> i32` +`weaveffi_kitchen_stream_gadgets() -> i32` | Param | API Type | Wasm | Notes | |-------|----------|------|-------| -| _returns_ | `handle` | `i32` | opaque pointer | +| _returns_ | `iter` | `i32` | opaque iterator handle | ##### `weaveffi_kitchen_maybe_item` @@ -319,7 +341,7 @@ Replacement for legacy_op A pocket-sized interface exercising the object surface -Passed as an **opaque object pointer** (`i32`), wrapped by a JS class. Constructors return an owned handle; methods pass the handle as the implicit leading `self` argument; `free()` releases the handle via the destroy symbol. +Passed as an **object pointer** (`i32`), wrapped by a JS class holding one reference. Constructors return an owned reference; methods lend the pointer as the implicit leading `self` argument; `close()` (or `Symbol.dispose`) releases the reference via the destroy symbol exactly once. ##### `weaveffi_kitchen_Gadget_new` @@ -330,7 +352,7 @@ Create a gadget with the given id | Param | API Type | Wasm | Notes | |-------|----------|------|-------| | `id` | `i64` | `i64` | native Wasm i64 | -| _returns_ | `Gadget` | `i32` | opaque object pointer | +| _returns_ | `Gadget` | `i32` | borrowed object pointer | ##### `weaveffi_kitchen_Gadget_describe` @@ -363,9 +385,26 @@ The gadget subsystem version string |-------|----------|------|-------| | _returns_ | `string` | `i32` | NUL-terminated C string pointer | +##### `weaveffi_kitchen_Gadget_clone` + +Returns a second reference to the same object. Used when the wrapper is encoded as an object token inside a value buffer. + ##### `weaveffi_kitchen_Gadget_destroy` -Releases the object reference. Called by the wrapper's `free()`. +Releases one object reference. Called by the wrapper's `close()` (and by the finalizer backstop). + +#### Callback Interfaces + +##### `ReadyListener` + +Consumer-implemented observer of ready items + +Implemented in JS as an object with the methods below. Passed as an `i32` context key plus an `i32` pointer to the static `weaveffi_kitchen_ReadyListener_vtable` vtable; the vtable's trailing `free(ctx)` entry releases the JS object. + +| Method | Parameters | Returns | +|--------|------------|---------| +| `onReady` | `code: number`, `msg: string` | `void` | +| `onItem` | `item: Item`, `gadget: Gadget` | `boolean` | #### Structs @@ -389,6 +428,7 @@ A plain JS object, serialized in a value buffer (`i32` ptr + `i32` len) at the b | `parent` | `i64?` | | `token` | `Token` | | `priority` | `Priority` | +| `gadget` | `Gadget?` | #### Enums diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_d_ts.snap index 02917f02..c0b3853e 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_d_ts.snap @@ -8,8 +8,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated TypeScript declarations for WeaveFFI Wasm bindings /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export declare class WeaveFFIError extends Error { constructor(code: number, message?: string); code: number; @@ -59,6 +60,8 @@ export interface Item { /** Cross-module struct reference */ token: Token; priority: Priority; + /** An optional object inside a record */ + gadget: Gadget | null; } /** Task priority levels */ @@ -72,10 +75,31 @@ export declare const Priority: Readonly<{ }>; export type Priority = (typeof Priority)[keyof typeof Priority]; -/** A pocket-sized interface exercising the object surface */ +/** + * Consumer-implemented observer of ready items + * + * Implemented by the consumer; the producer calls these methods synchronously. An exception thrown from a method is reported to the producer as an error (code -4). + */ +export interface ReadyListener { + /** + * Fires when an item is ready + */ + onReady(code: number, msg: string): void; + /** + * Receives the item itself and says whether to keep listening + */ + onItem(item: Item, gadget: Gadget): boolean; +} + +/** + * A pocket-sized interface exercising the object surface + * + * Wraps one reference to a producer object; release it with `close()`. + */ export declare class Gadget { /** * Create a gadget with the given id + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {WeaveFFIError} if the native call fails */ constructor(id: bigint); @@ -94,8 +118,11 @@ export declare class Gadget { * @throws {WeaveFFIError} if the native call fails */ static version(): string; - /** Releases the producer-owned handle exactly once. */ - free(): void; + /** Releases this wrapper's reference exactly once; later calls are + * no-ops. Also reachable as `[Symbol.dispose]()` for `using` + * declarations. A wrapper collected unclosed is released by a + * finalizer where the runtime provides one. */ + close(): void; } export interface WeaveffiWasmModule { @@ -138,25 +165,27 @@ export interface WeaveffiWasmModule { */ echoBytes(b: Uint8Array): Uint8Array; /** - * Borrowed string parameter + * Register a listener, returning how many are attached * @throws {WeaveFFIError} if the native call fails */ - echoBorrowedStr(s: string): string; + subscribe(listener: ReadyListener): number; /** - * Borrowed bytes parameter + * Optional object in both directions + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. * @throws {WeaveFFIError} if the native call fails */ - echoBorrowedBytes(b: Uint8Array): Uint8Array; + maybeGadget(fallback: Gadget | null): Gadget | null; /** - * Returns an opaque handle + * A list of objects * @throws {WeaveFFIError} if the native call fails */ - openHandle(): bigint; + gadgets(): Gadget[]; /** - * Returns a typed handle + * An iterator over objects + * @returns A lazy iterator: one producer step per `next()` call. Exhaust it or call `return()` to release the producer handle (a `for...of` loop does both automatically); an abandoned iterator leaks the handle. * @throws {WeaveFFIError} if the native call fails */ - openTypedHandle(): number; + streamGadgets(): IterableIterator; /** * Optional struct return * @throws {KitchenErrorsError} on a domain error code @@ -204,14 +233,6 @@ export interface WeaveffiWasmModule { * @throws {WeaveFFIError} if the native call fails */ newOp(): number; - /** - * Subscribe to OnReady events - * - * @returns A subscription id for `unregisterReadyListener()`. - */ - registerReadyListener(callback: (code: number, msg: string) => void): number; - /** Unregister a listener previously registered with `registerReadyListener()`. */ - unregisterReadyListener(id: number): void; Gadget: typeof Gadget; nested: { /** @@ -223,6 +244,6 @@ export interface WeaveffiWasmModule { }; } -export function loadWeaveffiWasm(url: string): Promise; +export function loadWeaveffiWasm(source: string | URL | BufferSource | WebAssembly.Module): Promise; // END weaveffi_wasm.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_js.snap b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_js.snap index 2b79e996..b89df651 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_kitchen_sink__weaveffi_wasm_js.snap @@ -9,16 +9,18 @@ source: crates/weaveffi-cli/tests/snapshots.rs // // Boundary conventions for a wasm32-unknown-unknown build: // -// Objects -> i32 pointer into linear memory (0 = null/absent) +// Objects -> i32 pointer into linear memory (0 = null/absent); one +// strong reference per wrapper, released by close() // Enums -> i32 discriminant value // i64/u64 -> JavaScript BigInt // Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer // Bytes -> i32 data pointer + i32 length (out_len for returns) // Buffered -> records, rich enums, optionals, lists, and maps cross // as one value buffer: i32 pointer + i32 length +// Callbacks -> i32 context key + i32 pointer to a static vtable // The ABI revision this glue was generated against. -const _ABI_VERSION = 1; +const _ABI_VERSION = 2; function _checkAbiVersion(wasm) { if (typeof wasm.weaveffi_abi_version !== 'function') { @@ -31,8 +33,9 @@ function _checkAbiVersion(wasm) { } /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -67,14 +70,6 @@ const _KITCHEN_ERRORS_ERROR_CODES = Object.freeze({ 2: InvalidInput, }); -// Build the KitchenErrorsError subclass matching `code`, or a generic -// WeaveFFIError for codes outside the domain (panics, marshalling). -function _kitchenErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { - const _cls = _KITCHEN_ERRORS_ERROR_CODES[code]; - const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); - return _e; -} - const _enc = new TextEncoder(); const _dec = new TextDecoder(); @@ -154,6 +149,8 @@ class _BufWriter { f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; } len(n) { this.u32(n); } flag(present) { this.u8(present ? 1 : 0); } + // An object token: the pointer (a wasm32 i32, read unsigned) widened to u64. + obj(ptr) { this.u64(BigInt(ptr >>> 0)); } str(v) { const b = _enc.encode(v); this.len(b.length); @@ -218,6 +215,13 @@ class _BufReader { if (b > 1) this._bad('option flag byte out of range'); return b === 1; } + // An object token narrowed back to a wasm32 pointer; zero is never a + // valid token (an absent `Interface?` uses the option flag instead). + obj() { + const t = this.u64(); + if (t === 0n || t > 0xffffffffn) this._bad('object token out of range'); + return Number(t); + } str() { const n = this.len(); const at = this._take(n, 'string bytes'); @@ -247,9 +251,11 @@ function _allocErr(wasm) { // Throw (and free the slot) if the error slot carries a non-zero code. // Non-throwing wrappers route here: a non-zero code can only be a -// producer panic or a marshalling failure, surfaced as the generic +// producer panic (-2), a marshalling failure (-3), or a callback +// interface implementation that raised (-4), surfaced as the generic // WeaveFFIError. function _checkErr(wasm, errPtr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code !== 0) { @@ -263,21 +269,36 @@ function _checkErr(wasm, errPtr) { // Release an error slot on the success path. function _freeErr(wasm, errPtr) { + _pendingForeign = null; wasm.weaveffi_dealloc(errPtr, 16); } -// Throw the `KitchenErrorsError` domain error (and free the slot) if the error slot -// carries a non-zero code. -function _checkKitchenErrorsError(wasm, errPtr) { - const dv = new DataView(wasm.memory.buffer); - const code = dv.getInt32(errPtr, true); - if (code !== 0) { - const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; - const _e = _kitchenErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); - wasm.weaveffi_error_clear(errPtr); - wasm.weaveffi_dealloc(errPtr, 16); - throw _e; - } +// The message of a callback-interface failure a trampoline reported through +// out_err, held until the producer call on the stack returns (every error +// checker and _freeErr clear it) so later callbacks in that call are refused. +let _pendingForeign = null; + +// Translate an exception raised while a producer call was on the stack. +// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes +// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of +// the producer writing out_err. A trap that follows a callback-interface +// failure is labelled with that failure (-4); any other trap is a producer +// panic (-2). Non-trap exceptions pass through unchanged. The producer +// frames between the trap and the call are not unwound, so a lock held +// across a callback stays locked; a producer that snapshots its state +// before calling out stays usable. +function _trapError(e) { + const foreign = _pendingForeign; + _pendingForeign = null; + if (!(e instanceof WebAssembly.RuntimeError)) return e; + if (foreign !== null) return new WeaveFFIError(-4, foreign); + return new WeaveFFIError(-2, 'producer panicked: ' + e.message); +} + +// Release the error slot a failed call never filled, then translate. +function _trap(wasm, errPtr, e) { + _freeErr(wasm, errPtr); + return _trapError(e); } // Throw if a heap-boxed (consumer-owned) error carries a non-zero @@ -286,6 +307,7 @@ function _checkKitchenErrorsError(wasm, errPtr) { // `mkErr` maps domain codes (and decodes payload fields) for // throwing callables; without it the generic WeaveFFIError is thrown. function _checkErrRef(wasm, errPtr, mkErr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code === 0) { wasm.weaveffi_error_free(errPtr); return; } @@ -295,67 +317,65 @@ function _checkErrRef(wasm, errPtr, mkErr) { throw err; } -// Serialize a `shared.Token` record into the value-buffer wire format. -function _write_shared_Token(w, v) { - w.i64(v.id); - w.str(v.label); +// `using` declarations (explicit resource management) call this symbol; +// runtimes that predate it get the registered stand-in so the method is +// still reachable by name. +const _dispose = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose'); + +// Finalization backstop: a wrapper that is garbage collected without +// close() still releases its reference. The held value is the +// [destroy, handle] pair (never the wrapper, which would keep it alive); +// the wrapper is the unregister token so close() disarms the backstop and +// destroy runs exactly once either way. Absent on runtimes without +// FinalizationRegistry, where close() is the only release path. +const _finalizer = typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry(([destroy, handle]) => destroy(handle)) + : null; + +// Bind one strong reference to a wrapper and arm the backstop. +function _adopt(obj, handle, destroy) { + obj._handle = handle; + obj._destroy = destroy; + if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj); + return obj; } -// Decode a `shared.Token` record from the value-buffer wire format. -function _read_shared_Token(r) { - const v = {}; - v.id = r.i64(); - v.label = r.str(); - return v; +// Release a wrapper's reference exactly once; later calls are no-ops. +function _release(obj) { + if (!obj._handle) return; + if (_finalizer !== null) _finalizer.unregister(obj); + obj._destroy(obj._handle); + obj._handle = 0; } -// Serialize a `kitchen.Item` record into the value-buffer wire format. -function _write_kitchen_Item(w, v) { - w.i64(v.id); - w.str(v.name); - w.i32(v.count); - w.bool(v.enabled); - w.f64(v.ratio); - w.u32(v.nick); - w.bytes(v.payload); - const _a1 = v.tags || []; - w.len(_a1.length); - for (const _e1 of _a1) { - w.str(_e1); - } - const _s2 = v.attrs || {}; - const _m2 = _s2 instanceof Map ? [..._s2.entries()] : Object.entries(_s2); - w.len(_m2.length); - for (const [_k2, _v2] of _m2) { - w.str(_k2); - w.str(_v2); - } - if (v.parent === null || v.parent === undefined) { - w.flag(false); - } else { - w.flag(true); - w.i64(v.parent); +// The pointer of a live wrapper, lent to the producer for one call (the +// wrapper keeps its own reference). A closed or non-object argument is a +// consumer programming error, reported with the marshalling code. +function _borrow(obj) { + if (obj === null || obj === undefined || !obj._handle) { + throw new WeaveFFIError(-3, 'expected a live object wrapper'); } - _write_shared_Token(w, v.token); - w.i32(v.priority); + return obj._handle; } -// Decode a `kitchen.Item` record from the value-buffer wire format. -function _read_kitchen_Item(r) { - const v = {}; - v.id = r.i64(); - v.name = r.str(); - v.count = r.i32(); - v.enabled = r.bool(); - v.ratio = r.f64(); - v.nick = r.u32(); - v.payload = r.bytes(); - v.tags = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.str()); return _arr; })(); - v.attrs = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = r.str(); } return _obj; })(); - v.parent = (r.flag() ? r.i64() : null); - v.token = _read_shared_Token(r); - v.priority = r.i32(); - return v; +// Report a callback-interface failure through the trampoline's out_err +// slot as FOREIGN_ERROR_CODE (-4). weaveffi_error_set copies the message +// with the producer's allocator, so the staged C string is released as +// soon as it returns. The message is parked in _pendingForeign until the +// producer call on the stack returns: the producer can't unwind on wasm32, +// so it keeps running on the trampoline's default return value, and any +// further callback it makes during that call is refused with this error +// instead of reaching the implementation again. +function _reportForeign(wasm, errPtr, msg) { + if (_pendingForeign === null) _pendingForeign = msg; + const [p, s] = _cstr(wasm, msg); + wasm.weaveffi_error_set(errPtr, -4, p); + wasm.weaveffi_dealloc(p, s); +} + +// Report an exception thrown by a callback-interface implementation. +function _setForeignError(wasm, errPtr, e) { + _reportForeign(wasm, errPtr, e instanceof Error ? (e.message || e.name) : String(e)); } // Lazy wrapper over a producer iterator handle, implementing the JS @@ -393,6 +413,12 @@ class _WeaveFFIIterator { let _has; try { _has = this._callNext(this._handle, this._slot, _err); + } catch (e) { + // A trap: the slot was never filled, so release it here. + this._close(); + throw _trap(wasm, _err, e); + } + try { // Throws (and releases the slot) on a non-zero code. this._check(wasm, _err); } catch (e) { @@ -416,10 +442,10 @@ class _WeaveFFIIterator { } } -function _registerTrampoline(table, paramTypes, handler) { +function _registerTrampoline(table, paramTypes, resultTypes, handler) { const idx = table.grow(1); table.set(idx, new WebAssembly.Function( - { parameters: paramTypes, results: [] }, + { parameters: paramTypes, results: resultTypes }, handler )); return idx; @@ -432,9 +458,11 @@ export const Priority = Object.freeze({ }); /** - * Load a WeaveFFI Wasm module from the given URL. + * Load a WeaveFFI Wasm module. * - * @param {string} url - URL to the `.wasm` file. + * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to + * fetch the `.wasm` from, its bytes, or an already compiled module + * (use the bytes form where `fetch` cannot read files, e.g. Node). * @returns {Promise} The API bindings. * * Exported functions follow the C ABI naming convention: @@ -449,6 +477,12 @@ export const Priority = Object.freeze({ * // Record: plain objects in and out (serialized automatically). * const person = api.contacts.create({ name: 'Ada', age: 36 }); * + * // Object: a class wrapping one producer reference; close() releases it + * // (or let `using` do it). Passing it lends the reference for the call. + * const store = new api.kv.Store(); + * api.kv.inspect(store); + * store.close(); + * * // Enum: pass the integer discriminant. * api.ui.set_color(0); // 0 = first variant * @@ -459,14 +493,115 @@ export const Priority = Object.freeze({ * // List/Map: pass arrays/objects; receive arrays/objects. * const names = api.data.all_names(); */ -export async function loadWeaveffiWasm(url) { - const response = await fetch(url); - const bytes = await response.arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, {}); +export async function loadWeaveffiWasm(source) { + let module; + if (source instanceof WebAssembly.Module) { + module = source; + } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + module = await WebAssembly.compile(source); + } else { + const response = await fetch(source); + module = await WebAssembly.compile(await response.arrayBuffer()); + } + const instance = await WebAssembly.instantiate(module, {}); const wasm = instance.exports; _checkAbiVersion(wasm); + // Build the KitchenErrorsError subclass matching `code`, or a generic + // WeaveFFIError for codes outside the domain (panics, marshalling, + // foreign callback failures). + function _kitchenErrorsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { + const _cls = _KITCHEN_ERRORS_ERROR_CODES[code]; + const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); + return _e; + } + + // Throw the `KitchenErrorsError` domain error (and free the slot) if the error slot + // carries a non-zero code. + function _checkKitchenErrorsError(wasm, errPtr) { + _pendingForeign = null; + const dv = new DataView(wasm.memory.buffer); + const code = dv.getInt32(errPtr, true); + if (code !== 0) { + const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; + const _e = _kitchenErrorsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); + wasm.weaveffi_error_clear(errPtr); + wasm.weaveffi_dealloc(errPtr, 16); + throw _e; + } + } + + // Serialize a `shared.Token` record into the value-buffer wire format. + function _write_shared_Token(w, v) { + w.i64(v.id); + w.str(v.label); + } + + // Decode a `shared.Token` record from the value-buffer wire format. + function _read_shared_Token(r) { + const v = {}; + v.id = r.i64(); + v.label = r.str(); + return v; + } + + // Serialize a `kitchen.Item` record into the value-buffer wire format. + function _write_kitchen_Item(w, v) { + w.i64(v.id); + w.str(v.name); + w.i32(v.count); + w.bool(v.enabled); + w.f64(v.ratio); + w.u32(v.nick); + w.bytes(v.payload); + const _a1 = v.tags || []; + w.len(_a1.length); + for (const _e1 of _a1) { + w.str(_e1); + } + const _s2 = v.attrs || {}; + const _m2 = _s2 instanceof Map ? [..._s2.entries()] : Object.entries(_s2); + w.len(_m2.length); + for (const [_k2, _v2] of _m2) { + w.str(_k2); + w.str(_v2); + } + if (v.parent === null || v.parent === undefined) { + w.flag(false); + } else { + w.flag(true); + w.i64(v.parent); + } + _write_shared_Token(w, v.token); + w.i32(v.priority); + if (v.gadget === null || v.gadget === undefined) { + w.flag(false); + } else { + w.flag(true); + w.obj(v.gadget._clone()); + } + } + + // Decode a `kitchen.Item` record from the value-buffer wire format. + function _read_kitchen_Item(r) { + const v = {}; + v.id = r.i64(); + v.name = r.str(); + v.count = r.i32(); + v.enabled = r.bool(); + v.ratio = r.f64(); + v.nick = r.u32(); + v.payload = r.bytes(); + v.tags = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.str()); return _arr; })(); + v.attrs = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = r.str(); } return _obj; })(); + v.parent = (r.flag() ? r.i64() : null); + v.token = _read_shared_Token(r); + v.priority = r.i32(); + v.gadget = (r.flag() ? Gadget._wrap(r.obj()) : null); + return v; + } + const _table = wasm.__indirect_function_table; let _nextCtxId = 1; @@ -484,59 +619,121 @@ export async function loadWeaveffiWasm(url) { } } - const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], _asyncHandler); + const _cbPtr_i32_i32_i32 = _registerTrampoline(_table, ['i32', 'i32', 'i32'], [], _asyncHandler); - // Listener subscriptions, keyed by the context id the loader - // threads through the C ABI's void* context slot. Each entry - // holds the JS callback and the producer's subscription id. - let _nextLsnId = 1; - const _listeners = new Map(); + // Callback-interface implementations, keyed by the integer context + // the loader passes as `ctx`; an entry lives until the producer + // calls the vtable's `free(ctx)`. + let _nextCbId = 1; + const _callbacks = new Map(); - const _lsnPtr_weaveffi_kitchen_OnReady_fn = _registerTrampoline(_table, ['i32', 'i32', 'i32'], (a0, a1, _ctx) => { - const _l = _listeners.get(_ctx); - if (_l === undefined) return; - const _p0 = a0; - const _p1 = _readCStr(wasm, a1); - _l.callback(_p0, _p1); + // Trampolines for the `ReadyListener` callback interface: one WebAssembly.Function + // per vtable entry, mirroring the C signature of each method. Borrowed + // arguments are decoded before the JS method runs; object arguments + // are adopted; a thrown exception becomes a -4 error on out_err. + const _cb_ReadyListener_on_ready = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32'], [], (_ctx, a0, a1, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = a0; + const _p1 = _readCStr(wasm, a1); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onReady(_p0, _p1); + } catch (e) { + _setForeignError(wasm, _err, e); + } + }); + const _cb_ReadyListener_on_item = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32', 'i32'], ['i32'], (_ctx, a0, a1, a2, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0_b = (a0 === 0 || a1 === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, a0, a1).slice(); + const _p0_r = new _BufReader(_p0_b); + const _p0 = _read_kitchen_Item(_p0_r); + _p0_r.end(); + const _p1 = Gadget._wrap(a2); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return 0; + } + return _impl.onItem(_p0, _p1) ? 1 : 0; + } catch (e) { + _setForeignError(wasm, _err, e); + return 0; + } }); + // `free(ctx)`: the producer dropped its last reference; forget the + // implementation so it can be collected. + const _cb_ReadyListener_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); }); + // The one static `weaveffi_kitchen_ReadyListener_vtable` for this instance, filled with the table indices + // above in declaration order, then `free`. + const _vtable_ReadyListener = wasm.weaveffi_alloc(12); + { + const _dv = new DataView(wasm.memory.buffer); + _dv.setUint32(_vtable_ReadyListener + 0, _cb_ReadyListener_on_ready, true); + _dv.setUint32(_vtable_ReadyListener + 4, _cb_ReadyListener_on_item, true); + _dv.setUint32(_vtable_ReadyListener + 8, _cb_ReadyListener_free, true); + } // A pocket-sized interface exercising the object surface class Gadget { constructor(id) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_Gadget_new(BigInt(id), _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_Gadget_new(BigInt(id), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - this._handle = _r; + _adopt(this, _r, wasm.weaveffi_kitchen_Gadget_destroy); } static _wrap(handle) { - const _o = Object.create(Gadget.prototype); - _o._handle = handle; - return _o; + return _adopt(Object.create(Gadget.prototype), handle, wasm.weaveffi_kitchen_Gadget_destroy); } - free() { - if (this._handle !== 0) { - wasm.weaveffi_kitchen_Gadget_destroy(this._handle); - this._handle = 0; - } + _clone() { + return wasm.weaveffi_kitchen_Gadget_clone(_borrow(this)); + } + close() { + _release(this); + } + [_dispose]() { + _release(this); } describe() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_Gadget_describe(this._handle, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_Gadget_describe(_borrow(this), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); } poke(times) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_Gadget_poke(this._handle, times, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_Gadget_poke(_borrow(this), times, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkKitchenErrorsError(wasm, _err); _freeErr(wasm, _err); return _r; } static version() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_Gadget_version(_err); + let _r; + try { + _r = wasm.weaveffi_kitchen_Gadget_version(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -548,7 +745,12 @@ export async function loadWeaveffiWasm(url) { shared: { ping() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_shared_ping(_err); + let _r; + try { + _r = wasm.weaveffi_shared_ping(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -557,35 +759,60 @@ export async function loadWeaveffiWasm(url) { kitchen: { boolId(x) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_bool_id(x ? 1 : 0, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_bool_id(x ? 1 : 0, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r !== 0; }, i32Id(x) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_i32_id(x, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_i32_id(x, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; }, u32Id(x) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_u32_id(x, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_u32_id(x, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - return _r; + return _r >>> 0; }, i64Id(x) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_i64_id(BigInt(x), _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_i64_id(BigInt(x), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; }, f64Id(x) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_f64_id(x, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_f64_id(x, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; @@ -593,8 +820,14 @@ export async function loadWeaveffiWasm(url) { echoString(s) { const [a0_p, a0_s] = _cstr(wasm, s); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_echo_string(a0_p, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); + let _r; + try { + _r = wasm.weaveffi_kitchen_echo_string(a0_p, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -603,53 +836,91 @@ export async function loadWeaveffiWasm(url) { const [a0_p, a0_l] = _bytes(wasm, b); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_echo_bytes(a0_p, a0_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_kitchen_echo_bytes(a0_p, a0_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); wasm.weaveffi_dealloc(_lp, 4); return _takeBytes(wasm, _r, _len); }, - echoBorrowedStr(s) { - const [a0_p, a0_s] = _cstr(wasm, s); + subscribe(listener) { + const a0_ctx = _nextCbId++; + _callbacks.set(a0_ctx, listener); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_echo_borrowed_str(a0_p, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); + let _r; + try { + _r = wasm.weaveffi_kitchen_subscribe(a0_ctx, _vtable_ReadyListener, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - return _takeCStr(wasm, _r); + return _r; }, - echoBorrowedBytes(b) { - const [a0_p, a0_l] = _bytes(wasm, b); - const _lp = wasm.weaveffi_alloc(4); + maybeGadget(fallback) { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_echo_borrowed_bytes(a0_p, a0_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_kitchen_maybe_gadget((fallback === null || fallback === undefined ? 0 : _borrow(fallback)), _err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); - wasm.weaveffi_dealloc(_lp, 4); - return _takeBytes(wasm, _r, _len); + return _r === 0 ? null : Gadget._wrap(_r); }, - openHandle() { + gadgets() { + const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_open_handle(_err); + let _r; + try { + _r = wasm.weaveffi_kitchen_gadgets(_lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - return _r; + const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); + wasm.weaveffi_dealloc(_lp, 4); + const _rd = new _BufReader(_takeBytes(wasm, _r, _len)); + const _out = (() => { const _n = _rd.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(Gadget._wrap(_rd.obj())); return _arr; })(); + _rd.end(); + return _out; }, - openTypedHandle() { + streamGadgets() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_open_typed_handle(_err); + let _it; + try { + _it = wasm.weaveffi_kitchen_stream_gadgets(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - return _r; + return new _WeaveFFIIterator(wasm, _it, 4, + (it, slot, ep) => wasm.weaveffi_kitchen_StreamGadgetsIterator_next(it, slot, ep), + (it) => wasm.weaveffi_kitchen_StreamGadgetsIterator_destroy(it), + _checkErr, (w, p) => Gadget._wrap(new DataView(w.memory.buffer).getUint32(p, true))); }, maybeItem(id) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_maybe_item(BigInt(id), _lp, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_maybe_item(BigInt(id), _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkKitchenErrorsError(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -662,7 +933,13 @@ export async function loadWeaveffiWasm(url) { listItems() { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_list_items(_lp, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_list_items(_lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -675,7 +952,13 @@ export async function loadWeaveffiWasm(url) { getAttrs() { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_get_attrs(_lp, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_get_attrs(_lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -687,7 +970,12 @@ export async function loadWeaveffiWasm(url) { }, streamItems() { const _err = _allocErr(wasm); - const _it = wasm.weaveffi_kitchen_stream_items(_err); + let _it; + try { + _it = wasm.weaveffi_kitchen_stream_items(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return new _WeaveFFIIterator(wasm, _it, 8, @@ -698,7 +986,13 @@ export async function loadWeaveffiWasm(url) { crossModuleToken() { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_cross_module_token(_lp, _err); + let _r; + try { + _r = wasm.weaveffi_kitchen_cross_module_token(_lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -717,8 +1011,14 @@ export async function loadWeaveffiWasm(url) { return _s; } }); const [a0_p, a0_s] = _cstr(wasm, input); - wasm.weaveffi_kitchen_do_async_async(a0_p, _cbPtr_i32_i32_i32, ctxId); - wasm.weaveffi_dealloc(a0_p, a0_s); + try { + wasm.weaveffi_kitchen_do_async_async(a0_p, _cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } }); }, doCancellable(input) { @@ -730,43 +1030,51 @@ export async function loadWeaveffiWasm(url) { return _s; } }); const [a0_p, a0_s] = _cstr(wasm, input); - wasm.weaveffi_kitchen_do_cancellable_async(a0_p, 0, _cbPtr_i32_i32_i32, ctxId); - wasm.weaveffi_dealloc(a0_p, a0_s); + try { + wasm.weaveffi_kitchen_do_cancellable_async(a0_p, 0, _cbPtr_i32_i32_i32, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } }); }, /** @deprecated Use new_op instead */ legacyOp() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_legacy_op(_err); + let _r; + try { + _r = wasm.weaveffi_kitchen_legacy_op(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; }, newOp() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_new_op(_err); + let _r; + try { + _r = wasm.weaveffi_kitchen_new_op(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _r; }, - /** Subscribe to OnReady events */ - registerReadyListener(callback) { - const _id = _nextLsnId++; - const _rid = wasm.weaveffi_kitchen_register_ready_listener(_lsnPtr_weaveffi_kitchen_OnReady_fn, _id); - _listeners.set(_id, { callback, rid: _rid }); - return _id; - }, - unregisterReadyListener(id) { - const _l = _listeners.get(id); - if (_l === undefined) return; - _listeners.delete(id); - wasm.weaveffi_kitchen_unregister_ready_listener(_l.rid); - }, Gadget: Gadget, nested: { hello() { const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kitchen_nested_hello(_err); + let _r; + try { + _r = wasm.weaveffi_kitchen_nested_hello(_err); + } catch (e) { + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__README_md.snap b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__README_md.snap index 06a50f1b..cabfd935 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__README_md.snap @@ -15,7 +15,7 @@ Build (example): cargo build --target wasm32-unknown-unknown --release ``` -Then serve the `.wasm` and use `weaveffi_wasm.js` to load it. +Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot read files), or an already compiled `WebAssembly.Module`. ## Complex Type Handling @@ -29,9 +29,17 @@ Records are **plain JavaScript objects**. A record crosses the boundary serializ C-style enums are passed as **`i32` values** corresponding to the variant's integer discriminant. Rich (algebraic) enums are plain objects tagged by variant name (`{ tag: "Circle", radius: 2 }`) and cross the boundary serialized in a value buffer, exactly like records. +### Objects + +Interfaces are **wrapper classes** holding one reference to a producer object (an `i32` pointer into linear memory). A wrapper you receive is yours: release it with `close()` (or bind it with `using`, which calls `Symbol.dispose`); a wrapper collected unclosed is released by a `FinalizationRegistry` backstop where the runtime provides one, and destroy runs exactly once either way. Passing a wrapper to a function lends its reference for the duration of the call. Inside a value buffer an object crosses as a `u64` token carrying one reference: the writer clones the wrapper's reference and the reader adopts the token into a new wrapper. + +### 64-bit Integers + +`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, returns, record fields, and callback parameters. Map keys of these types become string property names on the decoded object. A `number` is accepted where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional value throws a `RangeError`). Values crossing by value follow the wasm calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than throwing, and unsigned results are read back as unsigned (`u64` via `BigInt.asUintN`, `u32` via `>>> 0`). + ### Optionals -Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent). +Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`. ### Lists and Maps @@ -53,15 +61,25 @@ functions: - `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory - `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block - `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources +- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions) - `weaveffi_free_string(ptr: i32)`: free a producer-returned C string - `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer Wrappers of functions declared `throws` raise the declaring module's typed error class (a `WeaveFFIError` subclass with a per-code subclass, such as `KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only -for producer panics and marshalling failures. When the matched error code -declares payload fields, the wrapper decodes them from the error's value -buffer and attaches them as properties on the thrown error. +for producer panics, marshalling failures, and callback exceptions +(code `-4`). When the matched error code declares payload fields, the +wrapper decodes them from the error's value buffer and attaches them as +properties on the thrown error. + +#### Callback failures without unwinding + +`wasm32-unknown-unknown` builds with `panic = "abort"`, so when a callback interface method throws, the producer can't unwind out of the call the way native targets do. Instead the runtime records the failure, the producer's code runs to completion on the method's default return value (`0`, `0n`, or `false`), and the thunk then reports code `-4` with the exception's message in place of the result, so the wrapper throws (or the Promise rejects with) the same `WeaveFFIError` a native build would produce. While that producer call is still on the stack, the glue refuses every further callback invocation with the same error rather than calling your implementation again, so your code observes the failure exactly as it would under unwinding. Producers must tolerate a callback returning its default once (a well-behaved producer already does, because a consumer could return that value on purpose). + +#### Traps + +A producer panic can't be caught on the Rust side either: it executes `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of the call. The glue translates that trap for you: the wrapper throws (or the Promise rejects with) a `WeaveFFIError` with code `-2` (`producer panicked`), or code `-4` if a callback method had already failed during the call. The error slot and every staged argument are released either way. The producer frames between the trap and the call are *not* unwound, so their cleanup does not run: a lock held across the panic stays locked, and allocations those frames owned leak. ## API Reference diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_d_ts.snap index 72d65d31..cc41fc66 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_d_ts.snap @@ -8,8 +8,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated TypeScript declarations for WeaveFFI Wasm bindings /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export declare class WeaveFFIError extends Error { constructor(code: number, message?: string); code: number; @@ -84,6 +85,6 @@ export interface WeaveffiWasmModule { }; } -export function loadWeaveffiWasm(url: string): Promise; +export function loadWeaveffiWasm(source: string | URL | BufferSource | WebAssembly.Module): Promise; // END weaveffi_wasm.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_js.snap b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_js.snap index 79ea0d39..3f3bda2e 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_nested_modules__weaveffi_wasm_js.snap @@ -9,16 +9,18 @@ source: crates/weaveffi-cli/tests/snapshots.rs // // Boundary conventions for a wasm32-unknown-unknown build: // -// Objects -> i32 pointer into linear memory (0 = null/absent) +// Objects -> i32 pointer into linear memory (0 = null/absent); one +// strong reference per wrapper, released by close() // Enums -> i32 discriminant value // i64/u64 -> JavaScript BigInt // Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer // Bytes -> i32 data pointer + i32 length (out_len for returns) // Buffered -> records, rich enums, optionals, lists, and maps cross // as one value buffer: i32 pointer + i32 length +// Callbacks -> i32 context key + i32 pointer to a static vtable // The ABI revision this glue was generated against. -const _ABI_VERSION = 1; +const _ABI_VERSION = 2; function _checkAbiVersion(wasm) { if (typeof wasm.weaveffi_abi_version !== 'function') { @@ -31,8 +33,9 @@ function _checkAbiVersion(wasm) { } /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -57,14 +60,6 @@ const _GRAPHICS_ERROR_CODES = Object.freeze({ 1: OutOfBounds, }); -// Build the GraphicsError subclass matching `code`, or a generic -// WeaveFFIError for codes outside the domain (panics, marshalling). -function _graphicsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { - const _cls = _GRAPHICS_ERROR_CODES[code]; - const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); - return _e; -} - const _enc = new TextEncoder(); const _dec = new TextDecoder(); @@ -144,6 +139,8 @@ class _BufWriter { f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; } len(n) { this.u32(n); } flag(present) { this.u8(present ? 1 : 0); } + // An object token: the pointer (a wasm32 i32, read unsigned) widened to u64. + obj(ptr) { this.u64(BigInt(ptr >>> 0)); } str(v) { const b = _enc.encode(v); this.len(b.length); @@ -208,6 +205,13 @@ class _BufReader { if (b > 1) this._bad('option flag byte out of range'); return b === 1; } + // An object token narrowed back to a wasm32 pointer; zero is never a + // valid token (an absent `Interface?` uses the option flag instead). + obj() { + const t = this.u64(); + if (t === 0n || t > 0xffffffffn) this._bad('object token out of range'); + return Number(t); + } str() { const n = this.len(); const at = this._take(n, 'string bytes'); @@ -237,9 +241,11 @@ function _allocErr(wasm) { // Throw (and free the slot) if the error slot carries a non-zero code. // Non-throwing wrappers route here: a non-zero code can only be a -// producer panic or a marshalling failure, surfaced as the generic +// producer panic (-2), a marshalling failure (-3), or a callback +// interface implementation that raised (-4), surfaced as the generic // WeaveFFIError. function _checkErr(wasm, errPtr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code !== 0) { @@ -253,63 +259,36 @@ function _checkErr(wasm, errPtr) { // Release an error slot on the success path. function _freeErr(wasm, errPtr) { + _pendingForeign = null; wasm.weaveffi_dealloc(errPtr, 16); } -// Throw the `GraphicsError` domain error (and free the slot) if the error slot -// carries a non-zero code. -function _checkGraphicsError(wasm, errPtr) { - const dv = new DataView(wasm.memory.buffer); - const code = dv.getInt32(errPtr, true); - if (code !== 0) { - const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; - const _e = _graphicsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); - wasm.weaveffi_error_clear(errPtr); - wasm.weaveffi_dealloc(errPtr, 16); - throw _e; - } -} - -// Serialize a `graphics.Canvas` record into the value-buffer wire format. -function _write_graphics_Canvas(w, v) { - w.i32(v.width); - _write_graphics_shapes_Shape(w, v.root); +// The message of a callback-interface failure a trampoline reported through +// out_err, held until the producer call on the stack returns (every error +// checker and _freeErr clear it) so later callbacks in that call are refused. +let _pendingForeign = null; + +// Translate an exception raised while a producer call was on the stack. +// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes +// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of +// the producer writing out_err. A trap that follows a callback-interface +// failure is labelled with that failure (-4); any other trap is a producer +// panic (-2). Non-trap exceptions pass through unchanged. The producer +// frames between the trap and the call are not unwound, so a lock held +// across a callback stays locked; a producer that snapshots its state +// before calling out stays usable. +function _trapError(e) { + const foreign = _pendingForeign; + _pendingForeign = null; + if (!(e instanceof WebAssembly.RuntimeError)) return e; + if (foreign !== null) return new WeaveFFIError(-4, foreign); + return new WeaveFFIError(-2, 'producer panicked: ' + e.message); } -// Decode a `graphics.Canvas` record from the value-buffer wire format. -function _read_graphics_Canvas(r) { - const v = {}; - v.width = r.i32(); - v.root = _read_graphics_shapes_Shape(r); - return v; -} - -// Serialize a `graphics_shapes.Shape` record into the value-buffer wire format. -function _write_graphics_shapes_Shape(w, v) { - w.i32(v.sides); - w.i32(v.unit); - _write_graphics_shapes_geometry_Detail(w, v.detail); -} - -// Decode a `graphics_shapes.Shape` record from the value-buffer wire format. -function _read_graphics_shapes_Shape(r) { - const v = {}; - v.sides = r.i32(); - v.unit = r.i32(); - v.detail = _read_graphics_shapes_geometry_Detail(r); - return v; -} - -// Serialize a `graphics_shapes_geometry.Detail` record into the value-buffer wire format. -function _write_graphics_shapes_geometry_Detail(w, v) { - w.f64(v.area); -} - -// Decode a `graphics_shapes_geometry.Detail` record from the value-buffer wire format. -function _read_graphics_shapes_geometry_Detail(r) { - const v = {}; - v.area = r.f64(); - return v; +// Release the error slot a failed call never filled, then translate. +function _trap(wasm, errPtr, e) { + _freeErr(wasm, errPtr); + return _trapError(e); } export const Unit = Object.freeze({ @@ -318,9 +297,11 @@ export const Unit = Object.freeze({ }); /** - * Load a WeaveFFI Wasm module from the given URL. + * Load a WeaveFFI Wasm module. * - * @param {string} url - URL to the `.wasm` file. + * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to + * fetch the `.wasm` from, its bytes, or an already compiled module + * (use the bytes form where `fetch` cannot read files, e.g. Node). * @returns {Promise} The API bindings. * * Exported functions follow the C ABI naming convention: @@ -335,6 +316,12 @@ export const Unit = Object.freeze({ * // Record: plain objects in and out (serialized automatically). * const person = api.contacts.create({ name: 'Ada', age: 36 }); * + * // Object: a class wrapping one producer reference; close() releases it + * // (or let `using` do it). Passing it lends the reference for the call. + * const store = new api.kv.Store(); + * api.kv.inspect(store); + * store.close(); + * * // Enum: pass the integer discriminant. * api.ui.set_color(0); // 0 = first variant * @@ -345,21 +332,100 @@ export const Unit = Object.freeze({ * // List/Map: pass arrays/objects; receive arrays/objects. * const names = api.data.all_names(); */ -export async function loadWeaveffiWasm(url) { - const response = await fetch(url); - const bytes = await response.arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, {}); +export async function loadWeaveffiWasm(source) { + let module; + if (source instanceof WebAssembly.Module) { + module = source; + } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + module = await WebAssembly.compile(source); + } else { + const response = await fetch(source); + module = await WebAssembly.compile(await response.arrayBuffer()); + } + const instance = await WebAssembly.instantiate(module, {}); const wasm = instance.exports; _checkAbiVersion(wasm); + // Build the GraphicsError subclass matching `code`, or a generic + // WeaveFFIError for codes outside the domain (panics, marshalling, + // foreign callback failures). + function _graphicsErrorFrom(wasm, code, message, payloadPtr, payloadLen) { + const _cls = _GRAPHICS_ERROR_CODES[code]; + const _e = _cls ? (message ? new _cls(message) : new _cls()) : new WeaveFFIError(code, message); + return _e; + } + + // Throw the `GraphicsError` domain error (and free the slot) if the error slot + // carries a non-zero code. + function _checkGraphicsError(wasm, errPtr) { + _pendingForeign = null; + const dv = new DataView(wasm.memory.buffer); + const code = dv.getInt32(errPtr, true); + if (code !== 0) { + const msg = _readCStr(wasm, dv.getUint32(errPtr + 4, true)) || ''; + const _e = _graphicsErrorFrom(wasm, code, msg, dv.getUint32(errPtr + 8, true), dv.getUint32(errPtr + 12, true)); + wasm.weaveffi_error_clear(errPtr); + wasm.weaveffi_dealloc(errPtr, 16); + throw _e; + } + } + + // Serialize a `graphics.Canvas` record into the value-buffer wire format. + function _write_graphics_Canvas(w, v) { + w.i32(v.width); + _write_graphics_shapes_Shape(w, v.root); + } + + // Decode a `graphics.Canvas` record from the value-buffer wire format. + function _read_graphics_Canvas(r) { + const v = {}; + v.width = r.i32(); + v.root = _read_graphics_shapes_Shape(r); + return v; + } + + // Serialize a `graphics_shapes.Shape` record into the value-buffer wire format. + function _write_graphics_shapes_Shape(w, v) { + w.i32(v.sides); + w.i32(v.unit); + _write_graphics_shapes_geometry_Detail(w, v.detail); + } + + // Decode a `graphics_shapes.Shape` record from the value-buffer wire format. + function _read_graphics_shapes_Shape(r) { + const v = {}; + v.sides = r.i32(); + v.unit = r.i32(); + v.detail = _read_graphics_shapes_geometry_Detail(r); + return v; + } + + // Serialize a `graphics_shapes_geometry.Detail` record into the value-buffer wire format. + function _write_graphics_shapes_geometry_Detail(w, v) { + w.f64(v.area); + } + + // Decode a `graphics_shapes_geometry.Detail` record from the value-buffer wire format. + function _read_graphics_shapes_geometry_Detail(r) { + const v = {}; + v.area = r.f64(); + return v; + } + return { _raw: wasm, graphics: { newCanvas(width) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_graphics_new_canvas(width, _lp, _err); + let _r; + try { + _r = wasm.weaveffi_graphics_new_canvas(width, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -373,7 +439,13 @@ export async function loadWeaveffiWasm(url) { makeShape(sides) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_graphics_shapes_make_shape(sides, _lp, _err); + let _r; + try { + _r = wasm.weaveffi_graphics_shapes_make_shape(sides, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -386,7 +458,13 @@ export async function loadWeaveffiWasm(url) { describe() { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_graphics_shapes_describe(_lp, _err); + let _r; + try { + _r = wasm.weaveffi_graphics_shapes_describe(_lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -403,8 +481,15 @@ export async function loadWeaveffiWasm(url) { const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_graphics_shapes_geometry_compute(a0_p, a0_l, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_graphics_shapes_geometry_compute(a0_p, a0_l, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkGraphicsError(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__README_md.snap b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__README_md.snap index a63d5bb8..c80505fc 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__README_md.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__README_md.snap @@ -15,7 +15,7 @@ Build (example): cargo build --target wasm32-unknown-unknown --release ``` -Then serve the `.wasm` and use `weaveffi_wasm.js` to load it. +Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot read files), or an already compiled `WebAssembly.Module`. ## Complex Type Handling @@ -29,9 +29,17 @@ Records are **plain JavaScript objects**. A record crosses the boundary serializ C-style enums are passed as **`i32` values** corresponding to the variant's integer discriminant. Rich (algebraic) enums are plain objects tagged by variant name (`{ tag: "Circle", radius: 2 }`) and cross the boundary serialized in a value buffer, exactly like records. +### Objects + +Interfaces are **wrapper classes** holding one reference to a producer object (an `i32` pointer into linear memory). A wrapper you receive is yours: release it with `close()` (or bind it with `using`, which calls `Symbol.dispose`); a wrapper collected unclosed is released by a `FinalizationRegistry` backstop where the runtime provides one, and destroy runs exactly once either way. Passing a wrapper to a function lends its reference for the duration of the call. Inside a value buffer an object crosses as a `u64` token carrying one reference: the writer clones the wrapper's reference and the reader adopts the token into a new wrapper. + +### 64-bit Integers + +`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, returns, record fields, and callback parameters. Map keys of these types become string property names on the decoded object. A `number` is accepted where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional value throws a `RangeError`). Values crossing by value follow the wasm calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than throwing, and unsigned results are read back as unsigned (`u64` via `BigInt.asUintN`, `u32` via `>>> 0`). + ### Optionals -Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent). +Optional values map to **`null`** for the absent case. An optional crosses the boundary inside a value buffer as a one-byte presence flag followed by the value when present. The one exception is an optional interface, which stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`. ### Lists and Maps @@ -53,15 +61,25 @@ functions: - `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory - `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block - `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources +- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions) - `weaveffi_free_string(ptr: i32)`: free a producer-returned C string - `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer Wrappers of functions declared `throws` raise the declaring module's typed error class (a `WeaveFFIError` subclass with a per-code subclass, such as `KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only -for producer panics and marshalling failures. When the matched error code -declares payload fields, the wrapper decodes them from the error's value -buffer and attaches them as properties on the thrown error. +for producer panics, marshalling failures, and callback exceptions +(code `-4`). When the matched error code declares payload fields, the +wrapper decodes them from the error's value buffer and attaches them as +properties on the thrown error. + +#### Callback failures without unwinding + +`wasm32-unknown-unknown` builds with `panic = "abort"`, so when a callback interface method throws, the producer can't unwind out of the call the way native targets do. Instead the runtime records the failure, the producer's code runs to completion on the method's default return value (`0`, `0n`, or `false`), and the thunk then reports code `-4` with the exception's message in place of the result, so the wrapper throws (or the Promise rejects with) the same `WeaveFFIError` a native build would produce. While that producer call is still on the stack, the glue refuses every further callback invocation with the same error rather than calling your implementation again, so your code observes the failure exactly as it would under unwinding. Producers must tolerate a callback returning its default once (a well-behaved producer already does, because a consumer could return that value on purpose). + +#### Traps + +A producer panic can't be caught on the Rust side either: it executes `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of the call. The glue translates that trap for you: the wrapper throws (or the Promise rejects with) a `WeaveFFIError` with code `-2` (`producer panicked`), or code `-4` if a callback method had already failed during the call. The error slot and every staged argument are released either way. The producer frames between the trap and the call are *not* unwound, so their cleanup does not run: a lock held across the panic stays locked, and allocations those frames owned leak. ## API Reference diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_d_ts.snap b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_d_ts.snap index b1842f0f..705039d7 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_d_ts.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_d_ts.snap @@ -8,8 +8,9 @@ source: crates/weaveffi-cli/tests/snapshots.rs // Generated TypeScript declarations for WeaveFFI Wasm bindings /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export declare class WeaveFFIError extends Error { constructor(code: number, message?: string); code: number; @@ -71,6 +72,6 @@ export interface WeaveffiWasmModule { }; } -export function loadWeaveffiWasm(url: string): Promise; +export function loadWeaveffiWasm(source: string | URL | BufferSource | WebAssembly.Module): Promise; // END weaveffi_wasm.d.ts diff --git a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_js.snap b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_js.snap index 09c65c9c..7f0942f7 100644 --- a/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_js.snap +++ b/crates/weaveffi-cli/tests/snapshots/wasm_shapes__weaveffi_wasm_js.snap @@ -9,16 +9,18 @@ source: crates/weaveffi-cli/tests/snapshots.rs // // Boundary conventions for a wasm32-unknown-unknown build: // -// Objects -> i32 pointer into linear memory (0 = null/absent) +// Objects -> i32 pointer into linear memory (0 = null/absent); one +// strong reference per wrapper, released by close() // Enums -> i32 discriminant value // i64/u64 -> JavaScript BigInt // Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer // Bytes -> i32 data pointer + i32 length (out_len for returns) // Buffered -> records, rich enums, optionals, lists, and maps cross // as one value buffer: i32 pointer + i32 length +// Callbacks -> i32 context key + i32 pointer to a static vtable // The ABI revision this glue was generated against. -const _ABI_VERSION = 1; +const _ABI_VERSION = 2; function _checkAbiVersion(wasm) { if (typeof wasm.weaveffi_abi_version !== 'function') { @@ -31,8 +33,9 @@ function _checkAbiVersion(wasm) { } /** Base error for WeaveFFI failures: domain errors extend it, and it is - * thrown directly for unknown codes, marshalling failures, and producer - * panics. Carries the stable ABI `code`. */ + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -120,6 +123,8 @@ class _BufWriter { f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; } len(n) { this.u32(n); } flag(present) { this.u8(present ? 1 : 0); } + // An object token: the pointer (a wasm32 i32, read unsigned) widened to u64. + obj(ptr) { this.u64(BigInt(ptr >>> 0)); } str(v) { const b = _enc.encode(v); this.len(b.length); @@ -184,6 +189,13 @@ class _BufReader { if (b > 1) this._bad('option flag byte out of range'); return b === 1; } + // An object token narrowed back to a wasm32 pointer; zero is never a + // valid token (an absent `Interface?` uses the option flag instead). + obj() { + const t = this.u64(); + if (t === 0n || t > 0xffffffffn) this._bad('object token out of range'); + return Number(t); + } str() { const n = this.len(); const at = this._take(n, 'string bytes'); @@ -213,9 +225,11 @@ function _allocErr(wasm) { // Throw (and free the slot) if the error slot carries a non-zero code. // Non-throwing wrappers route here: a non-zero code can only be a -// producer panic or a marshalling failure, surfaced as the generic +// producer panic (-2), a marshalling failure (-3), or a callback +// interface implementation that raised (-4), surfaced as the generic // WeaveFFIError. function _checkErr(wasm, errPtr) { + _pendingForeign = null; const dv = new DataView(wasm.memory.buffer); const code = dv.getInt32(errPtr, true); if (code !== 0) { @@ -229,109 +243,36 @@ function _checkErr(wasm, errPtr) { // Release an error slot on the success path. function _freeErr(wasm, errPtr) { + _pendingForeign = null; wasm.weaveffi_dealloc(errPtr, 16); } -// Serialize a `shapes.Numerics` record into the value-buffer wire format. -function _write_shapes_Numerics(w, v) { - w.i8(v.a); - w.i16(v.b); - w.i32(v.c); - w.i64(v.d); - w.u8(v.e); - w.u16(v.f); - w.u32(v.g); - w.u64(v.h); - w.f32(v.i); - w.f64(v.j); -} +// The message of a callback-interface failure a trampoline reported through +// out_err, held until the producer call on the stack returns (every error +// checker and _freeErr clear it) so later callbacks in that call are refused. +let _pendingForeign = null; -// Decode a `shapes.Numerics` record from the value-buffer wire format. -function _read_shapes_Numerics(r) { - const v = {}; - v.a = r.i8(); - v.b = r.i16(); - v.c = r.i32(); - v.d = r.i64(); - v.e = r.u8(); - v.f = r.u16(); - v.g = r.u32(); - v.h = r.u64(); - v.i = r.f32(); - v.j = r.f64(); - return v; +// Translate an exception raised while a producer call was on the stack. +// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes +// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of +// the producer writing out_err. A trap that follows a callback-interface +// failure is labelled with that failure (-4); any other trap is a producer +// panic (-2). Non-trap exceptions pass through unchanged. The producer +// frames between the trap and the call are not unwound, so a lock held +// across a callback stays locked; a producer that snapshots its state +// before calling out stays usable. +function _trapError(e) { + const foreign = _pendingForeign; + _pendingForeign = null; + if (!(e instanceof WebAssembly.RuntimeError)) return e; + if (foreign !== null) return new WeaveFFIError(-4, foreign); + return new WeaveFFIError(-2, 'producer panicked: ' + e.message); } -// Serialize a `shapes.Shape` rich enum into the value-buffer wire format: -// an i32 tag, then the active variant's fields in order. -function _write_shapes_Shape(w, v) { - switch (v.tag) { - case "Empty": { - w.i32(0); - break; - } - case "Circle": { - w.i32(1); - w.f64(v.radius); - break; - } - case "Rectangle": { - w.i32(2); - w.f32(v.width); - w.f32(v.height); - break; - } - case "Bounded": { - w.i32(3); - w.i16(v.min); - w.i16(v.max); - w.u64(v.weight); - break; - } - case "Labeled": { - w.i32(4); - w.str(v.label); - w.u8(v.count); - break; - } - default: - throw new WeaveFFIError(-3, "unknown Shape variant tag: " + v.tag); - } -} - -// Decode a `shapes.Shape` rich enum from the value-buffer wire format. -function _read_shapes_Shape(r) { - const _tag = r.i32(); - switch (_tag) { - case 0: - return { tag: "Empty" }; - case 1: { - const v = { tag: "Circle" }; - v.radius = r.f64(); - return v; - } - case 2: { - const v = { tag: "Rectangle" }; - v.width = r.f32(); - v.height = r.f32(); - return v; - } - case 3: { - const v = { tag: "Bounded" }; - v.min = r.i16(); - v.max = r.i16(); - v.weight = r.u64(); - return v; - } - case 4: { - const v = { tag: "Labeled" }; - v.label = r.str(); - v.count = r.u8(); - return v; - } - default: - throw new WeaveFFIError(-3, "malformed value buffer: unknown Shape tag " + _tag); - } +// Release the error slot a failed call never filled, then translate. +function _trap(wasm, errPtr, e) { + _freeErr(wasm, errPtr); + return _trapError(e); } export const Channel = Object.freeze({ @@ -341,9 +282,11 @@ export const Channel = Object.freeze({ }); /** - * Load a WeaveFFI Wasm module from the given URL. + * Load a WeaveFFI Wasm module. * - * @param {string} url - URL to the `.wasm` file. + * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to + * fetch the `.wasm` from, its bytes, or an already compiled module + * (use the bytes form where `fetch` cannot read files, e.g. Node). * @returns {Promise} The API bindings. * * Exported functions follow the C ABI naming convention: @@ -358,6 +301,12 @@ export const Channel = Object.freeze({ * // Record: plain objects in and out (serialized automatically). * const person = api.contacts.create({ name: 'Ada', age: 36 }); * + * // Object: a class wrapping one producer reference; close() releases it + * // (or let `using` do it). Passing it lends the reference for the call. + * const store = new api.kv.Store(); + * api.kv.inspect(store); + * store.close(); + * * // Enum: pass the integer discriminant. * api.ui.set_color(0); // 0 = first variant * @@ -368,14 +317,123 @@ export const Channel = Object.freeze({ * // List/Map: pass arrays/objects; receive arrays/objects. * const names = api.data.all_names(); */ -export async function loadWeaveffiWasm(url) { - const response = await fetch(url); - const bytes = await response.arrayBuffer(); - const { instance } = await WebAssembly.instantiate(bytes, {}); +export async function loadWeaveffiWasm(source) { + let module; + if (source instanceof WebAssembly.Module) { + module = source; + } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) { + module = await WebAssembly.compile(source); + } else { + const response = await fetch(source); + module = await WebAssembly.compile(await response.arrayBuffer()); + } + const instance = await WebAssembly.instantiate(module, {}); const wasm = instance.exports; _checkAbiVersion(wasm); + // Serialize a `shapes.Numerics` record into the value-buffer wire format. + function _write_shapes_Numerics(w, v) { + w.i8(v.a); + w.i16(v.b); + w.i32(v.c); + w.i64(v.d); + w.u8(v.e); + w.u16(v.f); + w.u32(v.g); + w.u64(v.h); + w.f32(v.i); + w.f64(v.j); + } + + // Decode a `shapes.Numerics` record from the value-buffer wire format. + function _read_shapes_Numerics(r) { + const v = {}; + v.a = r.i8(); + v.b = r.i16(); + v.c = r.i32(); + v.d = r.i64(); + v.e = r.u8(); + v.f = r.u16(); + v.g = r.u32(); + v.h = r.u64(); + v.i = r.f32(); + v.j = r.f64(); + return v; + } + + // Serialize a `shapes.Shape` rich enum into the value-buffer wire format: + // an i32 tag, then the active variant's fields in order. + function _write_shapes_Shape(w, v) { + switch (v.tag) { + case "Empty": { + w.i32(0); + break; + } + case "Circle": { + w.i32(1); + w.f64(v.radius); + break; + } + case "Rectangle": { + w.i32(2); + w.f32(v.width); + w.f32(v.height); + break; + } + case "Bounded": { + w.i32(3); + w.i16(v.min); + w.i16(v.max); + w.u64(v.weight); + break; + } + case "Labeled": { + w.i32(4); + w.str(v.label); + w.u8(v.count); + break; + } + default: + throw new WeaveFFIError(-3, "unknown Shape variant tag: " + v.tag); + } + } + + // Decode a `shapes.Shape` rich enum from the value-buffer wire format. + function _read_shapes_Shape(r) { + const _tag = r.i32(); + switch (_tag) { + case 0: + return { tag: "Empty" }; + case 1: { + const v = { tag: "Circle" }; + v.radius = r.f64(); + return v; + } + case 2: { + const v = { tag: "Rectangle" }; + v.width = r.f32(); + v.height = r.f32(); + return v; + } + case 3: { + const v = { tag: "Bounded" }; + v.min = r.i16(); + v.max = r.i16(); + v.weight = r.u64(); + return v; + } + case 4: { + const v = { tag: "Labeled" }; + v.label = r.str(); + v.count = r.u8(); + return v; + } + default: + throw new WeaveFFIError(-3, "malformed value buffer: unknown Shape tag " + _tag); + } + } + return { _raw: wasm, shapes: { @@ -384,8 +442,14 @@ export async function loadWeaveffiWasm(url) { _write_shapes_Shape(a0_w, shape); const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_shapes_describe(a0_p, a0_l, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_shapes_describe(a0_p, a0_l, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); return _takeCStr(wasm, _r); @@ -396,8 +460,15 @@ export async function loadWeaveffiWasm(url) { const [a0_p, a0_l] = _bytes(wasm, a0_w.finish()); const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_shapes_scale(a0_p, a0_l, factor, _lp, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_shapes_scale(a0_p, a0_l, factor, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); @@ -410,16 +481,28 @@ export async function loadWeaveffiWasm(url) { sumBytes(values) { const [a0_p, a0_l] = _bytes(wasm, values); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_shapes_sum_bytes(a0_p, a0_l, _err); - wasm.weaveffi_dealloc(a0_p, a0_l); + let _r; + try { + _r = wasm.weaveffi_shapes_sum_bytes(a0_p, a0_l, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_l); + } _checkErr(wasm, _err); _freeErr(wasm, _err); - return _r; + return BigInt.asUintN(64, _r); }, makeNumerics(a, b, c, d, e, f, g, h, i, j) { const _lp = wasm.weaveffi_alloc(4); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_shapes_make_numerics(a, b, c, BigInt(d), e, f, g, BigInt(h), i, j, _lp, _err); + let _r; + try { + _r = wasm.weaveffi_shapes_make_numerics(a, b, c, BigInt(d), e, f, g, BigInt(h), i, j, _lp, _err); + } catch (e) { + wasm.weaveffi_dealloc(_lp, 4); + throw _trap(wasm, _err, e); + } _checkErr(wasm, _err); _freeErr(wasm, _err); const _len = new DataView(wasm.memory.buffer).getUint32(_lp, true); diff --git a/crates/weaveffi-core/benches/codegen_bench.rs b/crates/weaveffi-core/benches/codegen_bench.rs index c116dfa4..f0cd87f4 100644 --- a/crates/weaveffi-core/benches/codegen_bench.rs +++ b/crates/weaveffi-core/benches/codegen_bench.rs @@ -8,7 +8,7 @@ use weaveffi_ir::parse::parse_api_str; fn calculator_api() -> Api { Api { - version: "0.8.0".to_string(), + version: "0.9.0".to_string(), modules: vec![Module { name: "calculator".to_string(), doc: None, @@ -20,13 +20,11 @@ fn calculator_api() -> Api { Param { name: "a".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, Param { name: "b".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, ], @@ -35,7 +33,6 @@ fn calculator_api() -> Api { cancellable: false, throws: false, deprecated: None, - since: None, }, Function { name: "mul".to_string(), @@ -44,13 +41,11 @@ fn calculator_api() -> Api { Param { name: "a".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, Param { name: "b".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, ], @@ -59,7 +54,6 @@ fn calculator_api() -> Api { cancellable: false, throws: false, deprecated: None, - since: None, }, Function { name: "div".to_string(), @@ -68,13 +62,11 @@ fn calculator_api() -> Api { Param { name: "a".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, Param { name: "b".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, ], @@ -83,7 +75,6 @@ fn calculator_api() -> Api { cancellable: false, throws: false, deprecated: None, - since: None, }, Function { name: "echo".to_string(), @@ -91,7 +82,6 @@ fn calculator_api() -> Api { params: vec![Param { name: "s".to_string(), ty: TypeRef::StringUtf8, - mutable: false, doc: None, }], returns: Some(TypeRef::StringUtf8), @@ -99,13 +89,11 @@ fn calculator_api() -> Api { cancellable: false, throws: false, deprecated: None, - since: None, }, ], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, interfaces: vec![], modules: vec![], @@ -180,19 +168,16 @@ fn large_api() -> Api { Param { name: "a".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, Param { name: "b".to_string(), ty: TypeRef::StringUtf8, - mutable: false, doc: None, }, Param { name: "c".to_string(), ty: TypeRef::Named(format!("M{m}Struct0")), - mutable: false, doc: None, }, ], @@ -203,7 +188,6 @@ fn large_api() -> Api { cancellable: false, throws: false, deprecated: None, - since: None, }) .collect(); @@ -213,8 +197,7 @@ fn large_api() -> Api { functions, structs, enums, - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, interfaces: vec![], modules: vec![], @@ -223,7 +206,7 @@ fn large_api() -> Api { .collect(); Api { - version: "0.8.0".to_string(), + version: "0.9.0".to_string(), modules, } } diff --git a/crates/weaveffi-core/src/abi/ctype.rs b/crates/weaveffi-core/src/abi/ctype.rs index dfd914fc..7d60cc08 100644 --- a/crates/weaveffi-core/src/abi/ctype.rs +++ b/crates/weaveffi-core/src/abi/ctype.rs @@ -3,8 +3,9 @@ //! [`CType`] is a prefix-agnostic description of a single C type as it appears //! in a WeaveFFI ABI signature. The canonical C rendering lives in //! [`CType::render_c`]; every other language generator maps `CType` onto its -//! own FFI vocabulary (ctypes, P/Invoke, `dart:ffi`, …). Because the structure -//! is shared, every target agrees on the calling convention by construction. +//! own FFI vocabulary (ctypes, P/Invoke, `dart:ffi`, ...). Because the +//! structure is shared, every target agrees on the calling convention by +//! construction. /// Placement of a `const` qualifier on a pointer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -13,8 +14,6 @@ pub enum ConstPos { None, /// West `const`: `const T*`. West, - /// East `const`: `T const*` (used for arrays of pointer elements). - East, } /// A single C type in an ABI signature, independent of the configured symbol @@ -50,8 +49,6 @@ pub enum CType { Char, /// `void`. Void, - /// `{prefix}_handle_t`. - Handle, /// `{prefix}_cancel_token`. CancelToken, /// `{prefix}_error`. @@ -63,15 +60,23 @@ pub enum CType { /// The enum's bare type name. name: String, }, - /// A user struct / typed-handle tag: `{prefix}_{module}_{name}`. + /// An interface's opaque object tag: `{prefix}_{module}_{name}`. StructTag { /// Underscore-joined symbol path of the module that declares the type. module: String, - /// The struct or typed-handle's bare type name. + /// The interface's bare type name. name: String, }, - /// A prefixed named type emitted elsewhere in the header (callback - /// function-pointer typedefs, iterator opaque structs, …). Renders as + /// A callback interface's vtable struct: `{prefix}_{module}_{name}_vtable`. + VtableTag { + /// Underscore-joined symbol path of the module that declares the + /// callback interface. + module: String, + /// The callback interface's bare type name. + name: String, + }, + /// A prefixed named type emitted elsewhere in the header (async callback + /// function-pointer typedefs, iterator opaque structs, ...). Renders as /// `{prefix}_{core}`. Named(String), /// A pointer to `pointee` with the given `const` placement. @@ -101,8 +106,6 @@ impl CType { } /// Whether this type is represented as a pointer at the C ABI boundary. - /// Mirrors the pointer/value split the array renderer keys its east/west - /// `const` decision on. pub fn is_pointer(&self) -> bool { matches!(self, CType::Ptr { .. }) } @@ -122,8 +125,7 @@ impl CType { /// * A C-style [`Enum`](Self::Enum) crosses the ABI as its `int`-sized /// discriminant, so it lowers to `i32` (matching the header's /// `int`-backed `typedef enum`). - /// * Both `const` placements collapse to `*const` (Rust draws no - /// east/west distinction); a non-`const` pointer is `*mut`. + /// * A `const` pointer is `*const`; a non-`const` pointer is `*mut`. pub fn render_rust(&self, prefix: &str) -> String { match self { CType::Int8 => "i8".to_string(), @@ -140,7 +142,6 @@ impl CType { CType::Size => "usize".to_string(), CType::Char => "c_char".to_string(), CType::Void => "std::ffi::c_void".to_string(), - CType::Handle => "u64".to_string(), // The runtime types come from `weaveffi-abi` and keep their fixed // names regardless of the configured business-symbol prefix. CType::CancelToken => "weaveffi_cancel_token".to_string(), @@ -148,12 +149,13 @@ impl CType { // A C-style enum is passed/returned as its int discriminant. CType::Enum { .. } => "i32".to_string(), CType::StructTag { module, name } => format!("{prefix}_{module}_{name}"), + CType::VtableTag { module, name } => format!("{prefix}_{module}_{name}_vtable"), CType::Named(core) => format!("{prefix}_{core}"), CType::Ptr { konst, pointee } => { let inner = pointee.render_rust(prefix); match konst { ConstPos::None => format!("*mut {inner}"), - ConstPos::West | ConstPos::East => format!("*const {inner}"), + ConstPos::West => format!("*const {inner}"), } } } @@ -177,18 +179,17 @@ impl CType { CType::Size => "size_t".to_string(), CType::Char => "char".to_string(), CType::Void => "void".to_string(), - CType::Handle => format!("{prefix}_handle_t"), CType::CancelToken => format!("{prefix}_cancel_token"), CType::Error => format!("{prefix}_error"), CType::Enum { module, name } => format!("{prefix}_{module}_{name}"), CType::StructTag { module, name } => format!("{prefix}_{module}_{name}"), + CType::VtableTag { module, name } => format!("{prefix}_{module}_{name}_vtable"), CType::Named(core) => format!("{prefix}_{core}"), CType::Ptr { konst, pointee } => { let inner = pointee.render_c(prefix); match konst { ConstPos::None => format!("{inner}*"), ConstPos::West => format!("const {inner}*"), - ConstPos::East => format!("{inner} const*"), } } } @@ -203,7 +204,6 @@ mod tests { fn scalars_render() { assert_eq!(CType::Int32.render_c("weaveffi"), "int32_t"); assert_eq!(CType::Size.render_c("weaveffi"), "size_t"); - assert_eq!(CType::Handle.render_c("myffi"), "myffi_handle_t"); assert_eq!(CType::Error.render_c("myffi"), "myffi_error"); } @@ -215,34 +215,35 @@ mod tests { } #[test] - fn struct_tag_uses_prefix_and_module() { + fn struct_and_vtable_tags_use_prefix_and_module() { let t = CType::StructTag { module: "contacts".into(), - name: "Contact".into(), + name: "Book".into(), }; - assert_eq!(t.render_c("weaveffi"), "weaveffi_contacts_Contact"); + assert_eq!(t.render_c("weaveffi"), "weaveffi_contacts_Book"); assert_eq!( CType::ptr(t).render_c("weaveffi"), - "weaveffi_contacts_Contact*" + "weaveffi_contacts_Book*" ); - } - - #[test] - fn east_const_array_of_string_pointers() { - // `[string]` element is `const char*`; the array pointer is east-const. - let elem = CType::const_ptr(CType::Char); - let arr = CType::Ptr { - konst: ConstPos::East, - pointee: Box::new(elem), + let v = CType::VtableTag { + module: "events".into(), + name: "Listener".into(), }; - assert_eq!(arr.render_c("weaveffi"), "const char* const*"); + assert_eq!( + CType::const_ptr(v.clone()).render_c("weaveffi"), + "const weaveffi_events_Listener_vtable*" + ); + assert_eq!( + CType::const_ptr(v).render_rust("weaveffi"), + "*const weaveffi_events_Listener_vtable" + ); } #[test] fn named_type_is_prefixed() { assert_eq!( - CType::Named("events_on_data_fn".into()).render_c("weaveffi"), - "weaveffi_events_on_data_fn" + CType::Named("events_fetch_callback".into()).render_c("weaveffi"), + "weaveffi_events_fetch_callback" ); } @@ -254,13 +255,10 @@ mod tests { assert_eq!(CType::Double.render_rust("weaveffi"), "f64"); assert_eq!(CType::Bool.render_rust("weaveffi"), "bool"); assert_eq!(CType::Size.render_rust("weaveffi"), "usize"); - assert_eq!(CType::Handle.render_rust("weaveffi"), "u64"); } #[test] fn rust_runtime_types_keep_canonical_names_under_custom_prefix() { - // `weaveffi-abi` exports these Rust types under fixed names regardless - // of the configured business-symbol prefix. assert_eq!(CType::Error.render_rust("acme"), "weaveffi_error"); assert_eq!( CType::CancelToken.render_rust("acme"), @@ -289,38 +287,6 @@ mod tests { assert_eq!(e.render_rust("weaveffi"), "i32"); } - #[test] - fn rust_struct_tag_uses_prefix_and_module() { - let t = CType::StructTag { - module: "contacts".into(), - name: "Contact".into(), - }; - assert_eq!(t.render_rust("weaveffi"), "weaveffi_contacts_Contact"); - assert_eq!( - CType::ptr(t).render_rust("weaveffi"), - "*mut weaveffi_contacts_Contact" - ); - } - - #[test] - fn rust_east_const_collapses_to_const() { - // `[string]` lowers to an east-const array of string pointers; Rust has - // no east/west distinction so both render `*const`. - let arr = CType::Ptr { - konst: ConstPos::East, - pointee: Box::new(CType::const_ptr(CType::Char)), - }; - assert_eq!(arr.render_rust("weaveffi"), "*const *const c_char"); - } - - #[test] - fn rust_map_return_keys_match_header_arity() { - // The header types a returned map's keys as `const char*** out_keys`; - // the Rust producer must declare the matching `*mut *mut *const c_char`. - let out_keys = CType::ptr(CType::ptr(CType::const_ptr(CType::Char))); - assert_eq!(out_keys.render_rust("weaveffi"), "*mut *mut *const c_char"); - } - #[test] fn rust_void_is_only_a_pointee() { assert_eq!( diff --git a/crates/weaveffi-core/src/abi/lower.rs b/crates/weaveffi-core/src/abi/lower.rs index 4ef47a86..0ed4b6d5 100644 --- a/crates/weaveffi-core/src/abi/lower.rs +++ b/crates/weaveffi-core/src/abi/lower.rs @@ -3,18 +3,20 @@ //! //! The lowering dispatches on [`Ty::family`]: //! -//! * [`Family::Direct`] types occupy one C slot by value: scalars, bools, -//! C-style enums, and handles. +//! * [`Family::Direct`] types occupy one C slot by value: scalars, bools, and +//! C-style enums. //! * [`Family::String`] is a `char*`; [`Family::Bytes`] is a `ptr` + `len` //! pair. //! * [`Family::Buffer`] types (records, rich enums, optionals, lists, and //! maps) cross as one serialized value buffer: a `const uint8_t*` + -//! `size_t` pair encoded in the WeaveFFI buffer format (`weaveffi-abi`'s -//! `buffer` module). A buffered parameter is borrowed for the call; a -//! buffered return is producer-allocated and released with -//! `{prefix}_free_bytes` after decoding. -//! * [`Family::Object`] is an opaque interface pointer, borrowed as a -//! parameter and owned as a return; a nullable one is `Interface?`. +//! `size_t` pair encoded in the WeaveFFI buffer format. A buffered +//! parameter is borrowed for the call; a buffered return is +//! producer-allocated and released with `{prefix}_free_bytes` after +//! decoding. +//! * [`Family::Object`] is an interface pointer, borrowed as a parameter and +//! one strong reference as a return; a nullable one is `Interface?`. +//! * [`Family::Callback`] is a `void* ctx` plus `const {tag}_vtable*` pair, +//! only ever a parameter. use crate::model::{Family, Ty}; @@ -69,13 +71,20 @@ pub fn split_qualified(name: &str, current_module: &str) -> (String, String) { } } -/// Resolve a struct/interface reference (possibly `module.Name`) to its C tag -/// type. +/// Resolve an interface reference (possibly `module.Name`) to its opaque C +/// tag type. pub fn struct_tag(name: &str, current_module: &str) -> CType { let (module, name) = split_qualified(name, current_module); CType::StructTag { module, name } } +/// Resolve a callback interface reference (possibly `module.Name`) to its +/// vtable struct type. +pub fn vtable_tag(name: &str, current_module: &str) -> CType { + let (module, name) = split_qualified(name, current_module); + CType::VtableTag { module, name } +} + /// The by-value C type of a [`Family::Direct`] type. fn direct_ctype(ty: &Ty, module: &str) -> CType { match ty { @@ -90,8 +99,6 @@ fn direct_ctype(ty: &Ty, module: &str) -> CType { Ty::F32 => CType::Float, Ty::F64 => CType::Double, Ty::Bool => CType::Bool, - Ty::Handle => CType::Handle, - Ty::TypedHandle(n) => CType::ptr(struct_tag(n, module)), Ty::Enum(e) => { let (module, name) = split_qualified(e, module); CType::Enum { module, name } @@ -100,9 +107,9 @@ fn direct_ctype(ty: &Ty, module: &str) -> CType { } } -/// The two slots of a borrowed buffered parameter: `const uint8_t* {name}_ptr` -/// and `size_t {name}_len`. -fn buffer_param_slots(name: &str) -> Vec { +/// The two slots of a borrowed `(ptr, len)` parameter: `const uint8_t* +/// {name}_ptr` and `size_t {name}_len`. Shared by bytes and buffered values. +fn ptr_len_slots(name: &str) -> Vec { vec![ AbiParam::new(format!("{name}_ptr"), CType::const_ptr(CType::Uint8)), AbiParam::new(format!("{name}_len"), CType::Size), @@ -114,37 +121,14 @@ fn buffer_param_slots(name: &str) -> Vec { /// # Panics /// /// Panics on an iterator type, which validation never admits as a parameter. -pub fn lower_param(name: &str, ty: &Ty, module: &str, mutable: bool) -> Vec { - let west_if_immut = if mutable { - ConstPos::None - } else { - ConstPos::West - }; +pub fn lower_param(name: &str, ty: &Ty, module: &str) -> Vec { match ty.family() { Family::Direct => vec![AbiParam::new(name, direct_ctype(ty, module))], - Family::String => vec![AbiParam::new( - name, - CType::Ptr { - konst: west_if_immut, - pointee: Box::new(CType::Char), - }, - )], - Family::Bytes => vec![ - AbiParam::new( - format!("{name}_ptr"), - CType::Ptr { - konst: west_if_immut, - pointee: Box::new(CType::Uint8), - }, - ), - AbiParam::new(format!("{name}_len"), CType::Size), - ], - // A buffered parameter is always an immutable borrow of the encoded - // value; validation rejects `mutable: true` on buffered types. - Family::Buffer => buffer_param_slots(name), + Family::String => vec![AbiParam::new(name, CType::const_ptr(CType::Char))], + Family::Bytes | Family::Buffer => ptr_len_slots(name), // An interface parameter borrows the object for the call: the callee - // reads through the const pointer and never takes ownership. A - // nullable one is the same slot with null meaning none. + // reads through the const pointer and clones if it wants to retain + // the object. A nullable one is the same slot with null meaning none. Family::Object { .. } => { let iface = ty .interface_name() @@ -157,6 +141,20 @@ pub fn lower_param(name: &str, ty: &Ty, module: &str, mutable: bool) -> Vec { + let cb = ty + .callback_interface_name() + .expect("callback family names a callback interface"); + vec![ + AbiParam::new(format!("{name}_ctx"), CType::ptr(CType::Void)), + AbiParam::new( + format!("{name}_vtable"), + CType::const_ptr(vtable_tag(cb, module)), + ), + ] + } Family::Iterator => unreachable!("iterator not valid as parameter"), } } @@ -166,7 +164,8 @@ pub fn lower_param(name: &str, ty: &Ty, module: &str, mutable: bool) -> Vec AbiReturn { let no_out = |ret| AbiReturn { ret, @@ -181,14 +180,15 @@ pub fn lower_return(ty: &Ty, module: &str) -> AbiReturn { ret: CType::const_ptr(CType::Uint8), out_params: vec![AbiParam::new("out_len", CType::ptr(CType::Size))], }, - // A returned interface transfers ownership of a new object reference; - // a nullable one may be null. + // A returned interface transfers one strong reference; a nullable one + // may be null. Family::Object { .. } => { let iface = ty .interface_name() .expect("object family names an interface"); no_out(CType::ptr(struct_tag(iface, module))) } + Family::Callback => unreachable!("callback interfaces are never returned"), Family::Iterator => { unreachable!("iterator return handled specially by the function lowering") } @@ -228,32 +228,24 @@ mod tests { #[test] fn params_lower_by_family() { + assert_eq!(render(&lower_param("x", &Ty::I32, "m")), ["int32_t x"]); assert_eq!( - render(&lower_param("x", &Ty::I32, "m", false)), - ["int32_t x"] - ); - assert_eq!( - render(&lower_param("s", &Ty::StringUtf8, "m", false)), + render(&lower_param("s", &Ty::StringUtf8, "m")), ["const char* s"] ); assert_eq!( - render(&lower_param("s", &Ty::StringUtf8, "m", true)), - ["char* s"] - ); - assert_eq!( - render(&lower_param("data", &Ty::Bytes, "m", false)), + render(&lower_param("data", &Ty::Bytes, "m")), ["const uint8_t* data_ptr", "size_t data_len"] ); assert_eq!( - render(&lower_param("xs", &Ty::List(Box::new(Ty::I32)), "m", false)), + render(&lower_param("xs", &Ty::List(Box::new(Ty::I32)), "m")), ["const uint8_t* xs_ptr", "size_t xs_len"] ); assert_eq!( render(&lower_param( "c", &Ty::Record("other.Contact".into()), - "ops", - false + "ops" )), ["const uint8_t* c_ptr", "size_t c_len"] ); @@ -261,8 +253,7 @@ mod tests { render(&lower_param( "s", &Ty::Optional(Box::new(Ty::Interface("Store".into()))), - "kv", - false + "kv" )), ["const weaveffi_kv_Store* s"] ); @@ -270,19 +261,20 @@ mod tests { render(&lower_param( "s", &Ty::Enum("shared.Status".into()), - "orders", - false + "orders" )), ["weaveffi_shared_Status s"] ); assert_eq!( render(&lower_param( - "h", - &Ty::TypedHandle("auth.Session".into()), - "api", - false + "listener", + &Ty::CallbackInterface("events.Listener".into()), + "kv" )), - ["weaveffi_auth_Session* h"] + [ + "void* listener_ctx", + "const weaveffi_events_Listener_vtable* listener_vtable" + ] ); } @@ -295,6 +287,7 @@ mod tests { Ty::Record("Contact".into()), Ty::RichEnum("Shape".into()), Ty::List(Box::new(Ty::Record("Contact".into()))), + Ty::List(Box::new(Ty::Interface("Store".into()))), Ty::Map(Box::new(Ty::StringUtf8), Box::new(Ty::I32)), Ty::Optional(Box::new(Ty::I64)), ] { diff --git a/crates/weaveffi-core/src/abi/mod.rs b/crates/weaveffi-core/src/abi/mod.rs index d6d2a958..5276b74b 100644 --- a/crates/weaveffi-core/src/abi/mod.rs +++ b/crates/weaveffi-core/src/abi/mod.rs @@ -16,11 +16,11 @@ pub mod lower; pub use ctype::{CType, ConstPos}; pub use lower::{ - callback_result_params, lower_param, lower_return, split_qualified, struct_tag, AbiParam, - AbiReturn, + callback_result_params, lower_param, lower_return, split_qualified, struct_tag, vtable_tag, + AbiParam, AbiReturn, }; -use crate::model::{ParamBinding, Ty}; +use crate::model::{Family, ParamBinding, Ty}; /// A fully-assembled C ABI signature: the ordered parameter slots and the /// C return type. @@ -38,11 +38,59 @@ pub fn error_out_param() -> AbiParam { AbiParam::new("out_err", CType::ptr(CType::Error)) } -/// The `void* context` token threaded through callbacks and async calls. +/// The `void* context` token threaded through async completion callbacks. pub fn context_param() -> AbiParam { AbiParam::new("context", CType::ptr(CType::Void)) } +/// The leading `void* ctx` slot of every callback-interface method (the +/// consumer's opaque implementation handle). +pub fn ctx_param() -> AbiParam { + AbiParam::new("ctx", CType::ptr(CType::Void)) +} + +/// Assemble the signature of one callback-interface method as it appears in +/// the vtable: `ctx`, then every parameter's slots, then `out_err`. The return +/// is the method's direct C type or `void`; validation restricts callback +/// method returns to the direct family, so no out-parameters ever appear. +/// +/// Object parameters differ from a plain call: the producer transfers one +/// strong reference the consumer adopts, so the slot is a mutable `{tag}*` +/// rather than the borrowed `const {tag}*` of a top-level parameter. +pub fn callback_method_signature( + params: &[ParamBinding], + returns: Option<&Ty>, + module: &str, +) -> AbiSig { + let mut out = vec![ctx_param()]; + for p in params { + if matches!(p.ty.family(), Family::Object { .. }) { + let [slot] = p.abi.as_slice() else { + unreachable!("object parameter '{}' has one slot", p.name); + }; + let CType::Ptr { pointee, .. } = &slot.ty else { + unreachable!("object slot '{}' is a pointer", p.name); + }; + out.push(AbiParam::new(&slot.name, CType::ptr((**pointee).clone()))); + } else { + out.extend(p.abi.iter().cloned()); + } + } + let ret = match returns { + Some(ty) => { + let r = lower_return(ty, module); + debug_assert!( + r.out_params.is_empty(), + "callback method returns are direct-family only" + ); + r.ret + } + None => CType::Void, + }; + out.push(error_out_param()); + AbiSig { params: out, ret } +} + /// The optional `{prefix}_cancel_token*` parameter of a cancellable async call. pub fn cancel_token_param() -> AbiParam { AbiParam::new("cancel_token", CType::ptr(CType::CancelToken)) @@ -94,7 +142,7 @@ mod tests { use super::*; fn param(name: &str, ty: Ty) -> ParamBinding { - ParamBinding::new(name, ty, false, None, "math") + ParamBinding::new(name, ty, None, "math") } fn render(params: &[AbiParam]) -> Vec { @@ -139,4 +187,21 @@ mod tests { ["int64_t id", "weaveffi_cancel_token* cancel_token"] ); } + + #[test] + fn callback_method_signature_wraps_ctx_and_out_err() { + let sig = + callback_method_signature(&[param("text", Ty::StringUtf8)], Some(&Ty::Bool), "events"); + assert_eq!(sig.ret, CType::Bool); + assert_eq!( + render(&sig.params), + ["void* ctx", "const char* text", "weaveffi_error* out_err"] + ); + let sig = callback_method_signature(&[], None, "events"); + assert_eq!(sig.ret, CType::Void); + assert_eq!( + render(&sig.params), + ["void* ctx", "weaveffi_error* out_err"] + ); + } } diff --git a/crates/weaveffi-core/src/backend/mod.rs b/crates/weaveffi-core/src/backend/mod.rs index 16355a3f..deb623b1 100644 --- a/crates/weaveffi-core/src/backend/mod.rs +++ b/crates/weaveffi-core/src/backend/mod.rs @@ -1,8 +1,8 @@ //! The language-backend framework. //! //! Every idiomatic WeaveFFI generator does the same three things: it walks the -//! [`BindingModel`] in a fixed order (errors, enums, structs, interfaces, -//! callbacks, listeners, functions), dispatches each function on its +//! [`BindingModel`] in a fixed order (errors, enums, structs, callback +//! interfaces, interfaces, functions), dispatches each function on its //! [`CallShape`], and writes a primary source file plus a handful of package //! manifests. //! @@ -25,8 +25,8 @@ use serde::Serialize; use crate::capabilities::TargetCapabilities; use crate::model::{ - BindingModel, CallbackBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, - ListenerBinding, ModuleBinding, StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + ModuleBinding, StructBinding, }; use crate::package::{PackageContext, PackagedFile}; use crate::resolved::ResolvedApi; @@ -64,14 +64,14 @@ impl OutputFile { /// /// Backends whose primary file is a straightforward per-module walk (Python, /// Ruby, Go, Dart, .NET) override the per-entity hooks (`render_enum`, -/// `render_struct`, `render_function`, and optionally -/// `render_callback`/`render_listener`) and call the provided +/// `render_struct`, `render_callback_interface`, `render_interface`, +/// `render_function`) and call the provided /// [`emit_members`](Self::emit_members) from inside their module scoping; that /// is what removes the hand-rolled walk + call-shape dispatch each generator /// used to carry. Backends whose output is not one linear pass leave the hooks /// at their no-op defaults and build their layout directly in /// [`files`](Self::files): C and C++ order declarations by dependency, Swift -/// splits types from the namespaced module body, and Android, Node, and Wasm +/// splits types from the namespaced module body, and Kotlin, Node, and Wasm /// each render parallel files (Kotlin + JNI C, addon C + JS, JS + `.d.ts`) /// that would each need their own walk anyway. /// @@ -90,10 +90,12 @@ pub trait LanguageBackend: Send + Sync { fn name(&self) -> &'static str; /// The gated IDL features this backend implements (async functions, - /// callbacks, listeners, iterators). Required: declaring capabilities - /// explicitly is what lets the orchestrator fail loudly instead of a - /// backend silently skipping a feature it never implemented. - fn capabilities(&self) -> TargetCapabilities; + /// callback interfaces, iterators) under `config`. Required: declaring + /// capabilities explicitly is what lets the orchestrator fail loudly + /// instead of a backend silently skipping a feature it never implemented. + /// The config is passed because some backends have modes with different + /// ceilings (the Wasm backend's Emscripten mode, for example). + fn capabilities(&self, config: &Self::Config) -> TargetCapabilities; /// Whether the user explicitly opted in to generating this target even /// though the API uses features the target does not support (via an @@ -153,40 +155,31 @@ pub trait LanguageBackend: Send + Sync { let _ = (out, module, e, config); } - /// Render one interface: the wrapper class with its constructors, methods, - /// statics, and destructor wiring. Override when using + /// Render one callback interface: the protocol, abstract class, or + /// interface type the consumer implements, plus the vtable trampolines + /// that adapt an implementation to the C ABI. Override when using /// [`emit_members`](Self::emit_members). - fn render_interface( + fn render_callback_interface( &self, out: &mut String, module: &ModuleBinding, - i: &InterfaceBinding, + cb: &CallbackInterfaceBinding, config: &Self::Config, ) { - let _ = (out, module, i, config); + let _ = (out, module, cb, config); } - /// Render a module-scope callback typedef. Default: no output (most idiomatic - /// backends express callbacks inline at the async/listener call site). - fn render_callback( - &self, - out: &mut String, - module: &ModuleBinding, - c: &CallbackBinding, - config: &Self::Config, - ) { - let _ = (out, module, c, config); - } - - /// Render a listener's register/unregister surface. Default: no output. - fn render_listener( + /// Render one interface: the wrapper class with its constructors, methods, + /// statics, and reference-count wiring. Override when using + /// [`emit_members`](Self::emit_members). + fn render_interface( &self, out: &mut String, module: &ModuleBinding, - l: &ListenerBinding, + i: &InterfaceBinding, config: &Self::Config, ) { - let _ = (out, module, l, config); + let _ = (out, module, i, config); } /// Render one function. Implementations match on `f.shape` (sync / async / @@ -202,11 +195,12 @@ pub trait LanguageBackend: Send + Sync { let _ = (out, module, f, config); } - /// Emit every member of `module` in canonical order (error domain → enums - /// → structs → interfaces → callbacks → listeners → functions). Backends - /// call this from within their own module scoping; overriding the - /// per-entity hooks is what guarantees a single-pass backend cannot - /// silently skip an entity kind. + /// Emit every member of `module` in canonical order (error domain, enums, + /// structs, callback interfaces, interfaces, functions). Callback + /// interfaces precede interfaces because interface members take them as + /// parameters. Backends call this from within their own module scoping; + /// overriding the per-entity hooks is what guarantees a single-pass + /// backend cannot silently skip an entity kind. fn emit_members(&self, out: &mut String, module: &ModuleBinding, config: &Self::Config) { if let Some(e) = module.error.as_ref().filter(|e| e.declared_here) { self.render_error(out, module, e, config); @@ -217,15 +211,12 @@ pub trait LanguageBackend: Send + Sync { for s in &module.structs { self.render_struct(out, module, s, config); } + for cb in &module.callback_interfaces { + self.render_callback_interface(out, module, cb, config); + } for i in &module.interfaces { self.render_interface(out, module, i, config); } - for c in &module.callbacks { - self.render_callback(out, module, c, config); - } - for l in &module.listeners { - self.render_listener(out, module, l, config); - } for f in &module.functions { self.render_function(out, module, f, config); } @@ -367,7 +358,7 @@ mod tests { "fake" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -412,7 +403,6 @@ mod tests { params: vec![Param { name: "x".into(), ty: TypeRef::I32, - mutable: false, doc: None, }], returns, @@ -421,7 +411,6 @@ mod tests { r#async: is_async, cancellable: false, deprecated: None, - since: None, } } @@ -443,8 +432,7 @@ mod tests { interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], }], diff --git a/crates/weaveffi-core/src/cabi.rs b/crates/weaveffi-core/src/cabi.rs index 2f7500ce..469d0c64 100644 --- a/crates/weaveffi-core/src/cabi.rs +++ b/crates/weaveffi-core/src/cabi.rs @@ -3,21 +3,21 @@ //! //! Both the C generator (which emits the canonical `{prefix}.h`) and the C++ //! generator (whose idiomatic wrapper opens an `extern "C"` block re-declaring -//! the same symbols) render their C declarations through this module. Before it -//! existed the two re-derived the ABI independently and drifted. Most visibly, -//! the C++ `extern "C"` block lowered `iter` as a list and omitted callbacks -//! and listeners entirely. Routing both through one model-driven renderer makes -//! that class of drift impossible. +//! the same symbols) render their C declarations through this module, so the +//! two can't drift. +//! +//! The normative description of what is rendered here is +//! `docs/src/reference/abi.md`. -use std::collections::BTreeSet; use std::fmt::Write; -use crate::abi::{AbiParam, CType}; +use crate::abi::AbiParam; use crate::codegen::common::{emit_doc as common_emit_doc, DocCommentStyle}; use crate::codegen::CodeWriter; use crate::lang::{is_reserved, CPP_KEYWORDS, C_KEYWORDS}; use crate::model::{ - AbiFn, CallShape, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, ModuleBinding, + AbiFn, CallShape, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, + InterfaceBinding, ModuleBinding, }; /// The revision of the WeaveFFI C ABI the generators emit bindings for. @@ -27,7 +27,7 @@ use crate::model::{ /// embed this value and, where a load-time check is cheap, compare it against /// the producer's exported `{prefix}_abi_version()` before making any other /// call. -pub const ABI_VERSION: u32 = 1; +pub const ABI_VERSION: u32 = 2; /// Emit a `/** ... */` doc comment at `indent`. pub fn emit_doc(out: &mut String, doc: &Option, indent: &str) { @@ -152,9 +152,9 @@ pub fn fn_decl(out: &mut String, f: &AbiFn, prefix: &str) { ); } -/// Render the runtime typedefs and helper prototypes (`handle_t`, `error`, -/// `free_*`, `alloc`/`dealloc`, `cancel_token`) that every WeaveFFI C surface -/// depends on. +/// Render the runtime typedefs and helper prototypes (`error`, `free_*`, +/// `alloc`/`dealloc`, `cancel_token`) that every WeaveFFI C surface depends +/// on. /// /// `alloc`/`dealloc` back the Wasm JavaScript glue, which stages strings, /// bytes, and arrays into linear memory before each call. Native consumers @@ -172,17 +172,23 @@ pub fn render_runtime_decls(out: &mut String, prefix: &str) { error struct or value buffers. */\n\ #define {upper}_ABI_VERSION {ABI_VERSION}u\n\ {api} uint32_t {prefix}_abi_version(void);\n\n\ - typedef uint64_t {prefix}_handle_t;\n\n\ /* Error slot written by every fallible call. `payload_ptr`/`payload_len`\n \ hold the matched error code's fields serialized in the WeaveFFI value\n \ buffer format (null when the code declares no fields); both the message\n \ - and the payload are released by {prefix}_error_clear. */\n\ + and the payload are released by {prefix}_error_clear. Positive codes are\n \ + the module's declared error codes; negative codes are runtime traps:\n \ + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback\n \ + interface implementation raised. */\n\ typedef struct {prefix}_error {{\n \ int32_t code;\n \ const char* message;\n \ const uint8_t* payload_ptr;\n \ size_t payload_len;\n\ }} {prefix}_error;\n\n\ + /* Fill `err` with `code` and a producer-owned copy of `message`. Callback\n \ + interface trampolines call this to report a failure in the consumer's\n \ + implementation (code -4) without allocating with a foreign allocator. */\n\ + {api} void {prefix}_error_set({prefix}_error* err, int32_t code, const char* message);\n\ {api} void {prefix}_error_clear({prefix}_error* err);\n\n\ /* Async completion callbacks receive a heap-boxed error the consumer\n \ owns; {prefix}_error_free releases the message, the payload, and the\n \ @@ -316,67 +322,52 @@ pub fn render_module_type_tags(out: &mut String, module: &ModuleBinding) { } } -/// Record `ty`'s struct tag (if any) into `tags`, recursing through pointers. -fn walk_struct_tags(ty: &CType, prefix: &str, tags: &mut BTreeSet) { - match ty { - CType::StructTag { .. } => { - tags.insert(ty.render_c(prefix)); - } - CType::Ptr { pointee, .. } => walk_struct_tags(pointee, prefix, tags), - _ => {} - } -} - -/// Collect every struct tag reachable from one module's lowered ABI -/// signatures. With records and rich enums crossing by value, the struct tags -/// left in signatures are interface receivers and typed-handle targets; the -/// latter have no other declaration site, so [`render_decls`] forward-declares -/// any tag phase 1b did not already emit. -fn collect_signature_struct_tags( - module: &ModuleBinding, - prefix: &str, - tags: &mut BTreeSet, -) { - let walk_fn = |f: &AbiFn, tags: &mut BTreeSet| { - for p in &f.params { - walk_struct_tags(&p.ty, prefix, tags); - } - walk_struct_tags(&f.ret, prefix, tags); - }; - for f in module.callables() { - match &f.shape { - CallShape::Sync(abi) => walk_fn(abi, tags), - CallShape::Async(a) => { - walk_fn(&a.launch, tags); - for p in &a.callback_params { - walk_struct_tags(&p.ty, prefix, tags); - } - } - CallShape::Iterator(it) => { - walk_fn(&it.launch, tags); - walk_fn(&it.next, tags); +/// Render one callback interface's vtable struct: one function pointer per +/// method in declaration order, then the trailing `free`. +fn render_vtable_decl(out: &mut String, cb: &CallbackInterfaceBinding, prefix: &str) { + let mut w = CodeWriter::four_space(); + w.doc( + &Some(match &cb.doc { + Some(doc) => format!( + "{doc}\n\nConsumer-implemented callback interface. The consumer passes a \ + context pointer plus a pointer to a static instance of this vtable; the \ + producer may call any entry from any thread until it calls `free(ctx)` \ + exactly once." + ), + None => "Consumer-implemented callback interface. The consumer passes a \ + context pointer plus a pointer to a static instance of this vtable; the \ + producer may call any entry from any thread until it calls `free(ctx)` \ + exactly once." + .to_string(), + }), + DocCommentStyle::Javadoc, + ); + w.block( + format!("typedef struct {} {{", cb.vtable_tag), + format!("}} {};", cb.vtable_tag), + |w| { + for m in &cb.methods { + w.doc(&m.doc, DocCommentStyle::Javadoc); + w.line(format!( + "{} (*{})({});", + m.abi_ret.render_c(prefix), + c_param_name(&m.name), + params_str(&m.abi_params, prefix) + )); } - } - } - for cb in &module.callbacks { - for p in &cb.abi_params { - walk_struct_tags(&p.ty, prefix, tags); - } - } + w.line("void (*free)(void* ctx);"); + }, + ); + out.push_str(&w.finish()); } -/// Phase 1c: callback / async-callback function-pointer typedefs for one -/// module. These may reference enums (by value) and structs (by pointer), so -/// they are emitted after every module's enums and type tags. +/// Phase 1c: callback-interface vtables and async completion-callback +/// function-pointer typedefs for one module. These may reference enums (by +/// value) and interfaces (by pointer), so they are emitted after every +/// module's enums and type tags. pub fn render_module_callback_types(out: &mut String, module: &ModuleBinding, prefix: &str) { - for cb in &module.callbacks { - emit_doc(out, &cb.doc, ""); - let _ = writeln!( - out, - "typedef void (*{})({});", - cb.c_fn_type, - params_str(&cb.abi_params, prefix) - ); + for cb in &module.callback_interfaces { + render_vtable_decl(out, cb, prefix); } for f in module.callables() { if let CallShape::Async(a) = &f.shape { @@ -417,7 +408,7 @@ fn render_callable_decl(out: &mut String, f: &FnBinding, prefix: &str) { } /// Render the function surface of one interface: constructors, statics, -/// methods, then the destructor. Assumes the opaque tag is already +/// methods, then the reference-count pair. Assumes the opaque tag is already /// forward-declared (phase 1b). fn render_interface_fn_decls(out: &mut String, i: &InterfaceBinding, prefix: &str) { let api = export_macro(prefix); @@ -432,30 +423,37 @@ fn render_interface_fn_decls(out: &mut String, i: &InterfaceBinding, prefix: &st for m in &i.methods { render_callable_decl(out, m, prefix); } + emit_doc( + out, + &Some( + "Returns a new strong reference to the same object (the pointer value is \ + unchanged). Null is a no-op returning null." + .to_string(), + ), + "", + ); + let _ = writeln!(out, "{api} {tag}* {}(const {tag}* self);", i.clone_symbol); + emit_doc( + out, + &Some( + "Releases one strong reference; the object is dropped when the last reference \ + is released. Null is a no-op." + .to_string(), + ), + "", + ); let _ = writeln!(out, "{api} void {}({tag}* self);", i.destroy_symbol); out.push('\n'); } -/// Phase 2: every function prototype for one module: struct create/destroy/ -/// getters and builders, interface members, listeners, then sync/async/ -/// iterator functions. All type tags and callback typedefs are assumed already -/// emitted (phases 1a–1c). Caller controls the leading `// Module:` comment -/// and any framing. +/// Phase 2: every function prototype for one module: interface members, then +/// sync/async/iterator functions. All type tags, vtables, and callback +/// typedefs are assumed already emitted (phases 1a-1c). Caller controls the +/// leading `// Module:` comment and any framing. pub fn render_module_fn_decls(out: &mut String, module: &ModuleBinding, prefix: &str) { - let api = export_macro(prefix); for i in &module.interfaces { render_interface_fn_decls(out, i, prefix); } - for l in &module.listeners { - emit_doc(out, &l.doc, ""); - let _ = writeln!( - out, - "{api} uint64_t {}({} callback, void* context);", - l.register_symbol, l.callback_c_fn_type - ); - emit_doc(out, &l.doc, ""); - let _ = writeln!(out, "{api} void {}(uint64_t id);", l.unregister_symbol); - } for f in &module.functions { render_callable_decl(out, f, prefix); } @@ -463,14 +461,14 @@ pub fn render_module_fn_decls(out: &mut String, module: &ModuleBinding, prefix: /// Render the complete C ABI declaration surface for `modules` in /// dependency-safe order: all enum definitions, then all opaque type tags, then -/// all callback typedefs, then per-module function prototypes. Emitting every -/// type tag before any function lets a parent module's function reference a -/// child module's struct: cross-module forward references the previous +/// all vtables and callback typedefs, then per-module function prototypes. +/// Emitting every type tag before any function lets a parent module's function +/// reference a child module's interface: cross-module forward references a /// per-module interleaving could not express. /// -/// The runtime decls (`handle_t`, `error`, `free_*`, cancel token) are *not* -/// emitted here; callers render those first (the C generator inserts its map -/// convention comment in between). +/// The runtime decls (`error`, `free_*`, cancel token) are *not* emitted here; +/// callers render those first (the C generator inserts its map convention +/// comment in between). pub fn render_decls( out: &mut String, modules: &[ModuleBinding], @@ -480,27 +478,8 @@ pub fn render_decls( for m in modules { render_module_enum_defs(out, m); } - let mut declared: BTreeSet = BTreeSet::new(); for m in modules { render_module_type_tags(out, m); - for i in &m.interfaces { - declared.insert(i.c_tag.clone()); - } - for f in m.callables() { - if let CallShape::Iterator(it) = &f.shape { - declared.insert(it.iter_tag.clone()); - } - } - } - // Typed-handle targets are the one struct tag left in signatures with no - // declaration of their own (records are value types now), so forward- - // declare any tag phase 1b did not cover, e.g. `handle`. - let mut used: BTreeSet = BTreeSet::new(); - for m in modules { - collect_signature_struct_tags(m, prefix, &mut used); - } - for t in used.difference(&declared) { - let _ = writeln!(out, "typedef struct {t} {t};"); } for m in modules { render_module_callback_types(out, m, prefix); diff --git a/crates/weaveffi-core/src/cache.rs b/crates/weaveffi-core/src/cache.rs index 071c7bd4..cfc7d1d2 100644 --- a/crates/weaveffi-core/src/cache.rs +++ b/crates/weaveffi-core/src/cache.rs @@ -123,8 +123,7 @@ mod tests { interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], }], diff --git a/crates/weaveffi-core/src/capabilities.rs b/crates/weaveffi-core/src/capabilities.rs index 2486fcd4..29b1b581 100644 --- a/crates/weaveffi-core/src/capabilities.rs +++ b/crates/weaveffi-core/src/capabilities.rs @@ -1,13 +1,11 @@ //! Per-target **feature capability declarations** and the loud-failure //! contract that replaces silent feature skipping. //! -//! Historically a backend that did not implement an IDL feature simply -//! omitted it from its output: Go and Ruby dropped `async` functions, nine -//! of eleven wrappers skipped callbacks and listeners, and nothing told the -//! user. That class of silent degradation is banned: every generator now -//! declares a [`TargetCapabilities`] and the orchestrator refuses to run a -//! generator against an API that uses a feature the target does not support, -//! listing each offending declaration by path. +//! A backend that does not implement an IDL feature must not simply omit it +//! from its output with nothing telling the user. Every generator declares a +//! [`TargetCapabilities`] and the orchestrator refuses to run a generator +//! against an API that uses a feature the target does not support, listing +//! each offending declaration by path. //! //! A backend that gains a feature flips the corresponding flag and the gate //! opens; a backend that loses one (or a new feature lands in the IR before @@ -21,26 +19,24 @@ use weaveffi_ir::ir::{Api, Module, TypeRef}; /// An IDL feature whose support varies (or could vary) per target. /// -/// Core types (scalars, strings, bytes, structs, enums, optionals, lists, -/// maps, handles) are mandatory for every backend and are not gated. +/// Core types (scalars, strings, bytes, structs, enums, interfaces, +/// optionals, lists, maps) are mandatory for every backend and are not gated. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { /// `async: true` functions (callback-completed launchers). AsyncFunctions, - /// Module-level callback typedefs (`callbacks:`). - Callbacks, - /// Listener register/unregister pairs (`listeners:`). - Listeners, + /// Callback interfaces (`callback_interfaces:`): consumer-implemented + /// vtables the producer calls into. + CallbackInterfaces, /// `iter` returns (opaque iterator handle + `next`/`destroy`). Iterators, } impl Feature { /// Every gated feature, for exhaustive iteration in checks and tests. - pub const ALL: [Feature; 4] = [ + pub const ALL: [Feature; 3] = [ Feature::AsyncFunctions, - Feature::Callbacks, - Feature::Listeners, + Feature::CallbackInterfaces, Feature::Iterators, ]; @@ -48,8 +44,7 @@ impl Feature { pub fn idl_name(&self) -> &'static str { match self { Feature::AsyncFunctions => "async functions", - Feature::Callbacks => "callbacks", - Feature::Listeners => "listeners", + Feature::CallbackInterfaces => "callback interfaces", Feature::Iterators => "iterator returns (iter)", } } @@ -70,10 +65,8 @@ impl fmt::Display for Feature { pub struct TargetCapabilities { /// Whether the target generates `async: true` functions. pub async_functions: bool, - /// Whether the target generates module-level callback typedefs. - pub callbacks: bool, - /// Whether the target generates listener register/unregister pairs. - pub listeners: bool, + /// Whether the target generates callback-interface implementations. + pub callback_interfaces: bool, /// Whether the target generates `iter` returns. pub iterators: bool, } @@ -84,8 +77,7 @@ impl TargetCapabilities { pub const fn full() -> Self { Self { async_functions: true, - callbacks: true, - listeners: true, + callback_interfaces: true, iterators: true, } } @@ -94,8 +86,7 @@ impl TargetCapabilities { pub const fn supports(&self, feature: Feature) -> bool { match feature { Feature::AsyncFunctions => self.async_functions, - Feature::Callbacks => self.callbacks, - Feature::Listeners => self.listeners, + Feature::CallbackInterfaces => self.callback_interfaces, Feature::Iterators => self.iterators, } } @@ -117,16 +108,11 @@ fn collect_module(module: &Module, parent: &str, used: &mut BTreeMap) -> Function { Function { @@ -226,7 +212,6 @@ mod tests { params: vec![Param { name: "x".into(), ty: TypeRef::I32, - mutable: false, doc: None, }], returns, @@ -235,7 +220,6 @@ mod tests { r#async: is_async, cancellable: false, deprecated: None, - since: None, } } @@ -245,10 +229,9 @@ mod tests { doc: None, functions: vec![], interfaces: vec![], + callback_interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], errors: None, modules: vec![], } @@ -263,15 +246,11 @@ mod tests { fn events_api() -> Api { api(vec![Module { - callbacks: vec![CallbackDef { - name: "OnMessage".into(), - params: vec![], - doc: None, - }], - listeners: vec![ListenerDef { - name: "message_listener".into(), - event_callback: "OnMessage".into(), + callback_interfaces: vec![CallbackInterfaceDef { + name: "MessageListener".into(), doc: None, + deprecated: None, + methods: vec![func("on_message", false, None)], }], functions: vec![ func("send", false, None), @@ -304,12 +283,8 @@ mod tests { fn used_features_collects_locations() { let used = used_features(&events_api()); assert_eq!( - used[&Feature::Callbacks], - vec!["events.OnMessage".to_string()] - ); - assert_eq!( - used[&Feature::Listeners], - vec!["events.message_listener".to_string()] + used[&Feature::CallbackInterfaces], + vec!["events.MessageListener".to_string()] ); assert_eq!( used[&Feature::AsyncFunctions], @@ -338,7 +313,7 @@ mod tests { fn missing_capability_is_reported_with_locations() { let caps = TargetCapabilities { async_functions: false, - listeners: false, + callback_interfaces: false, ..TargetCapabilities::full() }; let err = check(&events_api(), "go", &caps).unwrap_err(); @@ -351,7 +326,7 @@ mod tests { "{msg}" ); assert!( - msg.contains("listeners (used by: events.message_listener)"), + msg.contains("callback interfaces (used by: events.MessageListener)"), "{msg}" ); } diff --git a/crates/weaveffi-core/src/codegen/common.rs b/crates/weaveffi-core/src/codegen/common.rs index 0f144c0e..10698555 100644 --- a/crates/weaveffi-core/src/codegen/common.rs +++ b/crates/weaveffi-core/src/codegen/common.rs @@ -160,8 +160,7 @@ mod tests { interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], } diff --git a/crates/weaveffi-core/src/codegen/mod.rs b/crates/weaveffi-core/src/codegen/mod.rs index 6fd5bbb6..503ce95b 100644 --- a/crates/weaveffi-core/src/codegen/mod.rs +++ b/crates/weaveffi-core/src/codegen/mod.rs @@ -108,7 +108,7 @@ impl Target for ConfiguredBackend { } fn capabilities(&self) -> TargetCapabilities { - self.inner.capabilities() + self.inner.capabilities(&self.config) } fn allows_unsupported(&self) -> bool { @@ -263,7 +263,7 @@ mod tests { use crate::model::BindingModel; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; - use weaveffi_ir::ir::{Api, CallbackDef, ListenerDef, Module}; + use weaveffi_ir::ir::{Api, CallbackInterfaceDef, Function, Module}; #[derive(Default, Clone, serde::Serialize)] struct TestConfig { @@ -284,7 +284,7 @@ mod tests { self.name } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { self.caps } @@ -315,8 +315,7 @@ mod tests { interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], } @@ -329,19 +328,24 @@ mod tests { }) } - /// An API that uses listeners, so a target without listener support - /// trips the capability gate. + /// An API that uses a callback interface, so a target without callback + /// interface support trips the capability gate. fn listener_api() -> ResolvedApi { let mut m = module("math"); - m.callbacks = vec![CallbackDef { + m.callback_interfaces = vec![CallbackInterfaceDef { name: "OnChange".into(), - params: vec![], - doc: None, - }]; - m.listeners = vec![ListenerDef { - name: "on_change".into(), - event_callback: "OnChange".into(), doc: None, + deprecated: None, + methods: vec![Function { + name: "changed".into(), + params: vec![], + returns: None, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + }], }]; ResolvedApi::assume_valid(Api { version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), @@ -370,7 +374,7 @@ mod tests { name, calls: Arc::clone(calls), caps: TargetCapabilities { - listeners: false, + callback_interfaces: false, ..TargetCapabilities::full() }, }, @@ -401,7 +405,7 @@ mod tests { .unwrap_err() .to_string(); assert!(err.contains("target 'strict' does not support"), "{err}"); - assert!(err.contains("math.on_change"), "{err}"); + assert!(err.contains("math.OnChange"), "{err}"); assert!(!err.contains("target 'opted'"), "{err}"); assert_eq!(strict_calls.load(Ordering::SeqCst), 0); assert_eq!(opted_calls.load(Ordering::SeqCst), 0); diff --git a/crates/weaveffi-core/src/errors.rs b/crates/weaveffi-core/src/errors.rs index c1f31e33..3223af43 100644 --- a/crates/weaveffi-core/src/errors.rs +++ b/crates/weaveffi-core/src/errors.rs @@ -1,23 +1,21 @@ -//! Shared error-domain model and naming policy. +//! The shared error **naming policy**. //! -//! Every `errors:` domain declared anywhere in the API is flattened into a -//! single, de-duplicated list ([`all`]) so all backends surface the *same* -//! typed errors, and the idiomatic naming policy is centralized here so we -//! never again emit drift like `KEY_NOT_FOUNDError` (raw SCREAMING_SNAKE with a -//! naive `Error` suffix) in one language and `keyNotFound` in another. +//! The error *model* (which domains exist, which codes they carry, which +//! module owns them) lives in the binding model as +//! [`ErrorBinding`](crate::model::ErrorBinding). This module holds only the +//! idiomatic naming rules every backend applies to those names, centralized so +//! no target drifts into `KEY_NOT_FOUNDError` (raw SCREAMING_SNAKE with a +//! naive `Error` suffix) while another emits `keyNotFound`. //! //! Backends pick the brand/suffix that matches their ecosystem //! ([`ERROR_BRAND`] for Swift/Python/TS/C++/Ruby/Go, [`EXCEPTION_BRAND`] for -//! Kotlin/.NET/Dart) and case-convert each code's [`ResolvedError::raw_name`] -//! through the helpers below. +//! Kotlin/.NET/Dart) and case-convert each code's name through the helpers +//! below. -use std::collections::BTreeSet; - -use heck::{ToLowerCamelCase, ToShoutySnakeCase, ToUpperCamelCase}; -use weaveffi_ir::ir::{Api, Module}; +use heck::ToUpperCamelCase; /// Canonical brand stem. Always `WeaveFFI` (uppercase `FFI`), never the -/// `heck`-derived `Weaveffi` that several generators used to emit. +/// `heck`-derived `Weaveffi`. pub const BRAND_STEM: &str = "WeaveFFI"; /// Base error type for ecosystems that use the `Error` suffix @@ -25,63 +23,20 @@ pub const BRAND_STEM: &str = "WeaveFFI"; pub const ERROR_BRAND: &str = "WeaveFFIError"; /// Base exception type for ecosystems that use the `Exception` suffix -/// (Kotlin/Android, .NET, Dart). +/// (Kotlin, .NET, Dart). pub const EXCEPTION_BRAND: &str = "WeaveFFIException"; -/// A single error code, flattened across the whole API. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedError { - /// Raw identifier exactly as written in the IDL (e.g. `KEY_NOT_FOUND`). - pub raw_name: String, - /// Numeric ABI code carried in `weaveffi_error.code`. - pub code: i32, - /// Human-readable default message for the code. - pub message: String, - /// Optional doc comment. - pub doc: Option, -} - -impl ResolvedError { - /// PascalCase type name with exactly one `Error` suffix. - /// `KEY_NOT_FOUND` → `KeyNotFoundError`. - pub fn error_class(&self) -> String { - type_name(&self.raw_name, "Error") - } - - /// PascalCase type name with exactly one `Exception` suffix. - /// `KEY_NOT_FOUND` → `KeyNotFoundException`. - pub fn exception_class(&self) -> String { - type_name(&self.raw_name, "Exception") - } - - /// lowerCamelCase member name (Swift enum case, JS field). - /// `KEY_NOT_FOUND` → `keyNotFound`. - pub fn camel(&self) -> String { - self.raw_name.to_lower_camel_case() - } - - /// PascalCase name without a suffix. `KEY_NOT_FOUND` → `KeyNotFound`. - pub fn pascal(&self) -> String { - self.raw_name.to_upper_camel_case() - } - - /// SCREAMING_SNAKE constant spelling. `KeyNotFound` → `KEY_NOT_FOUND`. - pub fn shouty(&self) -> String { - self.raw_name.to_shouty_snake_case() - } -} - /// PascalCase form of a raw error code name, with no suffix. -/// `KEY_NOT_FOUND` → `KeyNotFound`. Use for languages whose error variants are -/// nested types/cases (Kotlin sealed subclasses, etc.) rather than standalone -/// `*Error` classes. +/// `KEY_NOT_FOUND` -> `KeyNotFound`. Use for languages whose error variants +/// are nested types/cases (Kotlin sealed subclasses, etc.) rather than +/// standalone `*Error` classes. pub fn pascal(raw: &str) -> String { raw.to_upper_camel_case() } /// PascalCase + exactly one `suffix`, avoiding doubled or SCREAMING suffixes. -/// `("KEY_NOT_FOUND", "Error")` → `KeyNotFoundError`; -/// `("AlreadyError", "Error")` → `AlreadyError`. +/// `("KEY_NOT_FOUND", "Error")` -> `KeyNotFoundError`; +/// `("AlreadyError", "Error")` -> `AlreadyError`. pub fn type_name(raw: &str, suffix: &str) -> String { let pascal = raw.to_upper_camel_case(); if pascal.ends_with(suffix) { @@ -94,90 +49,20 @@ pub fn type_name(raw: &str, suffix: &str) -> String { /// Exception-branded type name for an error domain, for targets whose /// idiomatic errors are exceptions rather than `*Error` types. /// A trailing `Error` stem is replaced instead of stacked: -/// `KvError` → `KvException`; `Failure` → `FailureException`. +/// `KvError` -> `KvException`; `Failure` -> `FailureException`. pub fn exception_type_name(raw: &str) -> String { let pascal = raw.to_upper_camel_case(); let stem = pascal.strip_suffix("Error").unwrap_or(&pascal); if stem.is_empty() { - "WeaveFFIException".to_string() + EXCEPTION_BRAND.to_string() } else { type_name(stem, "Exception") } } -/// All error codes declared anywhere in the API, in module-declaration order -/// (depth-first), de-duplicated by `raw_name` (first occurrence wins). Returns -/// an empty vec when the API declares no error domains. -pub fn all(api: &Api) -> Vec { - let mut seen: BTreeSet = BTreeSet::new(); - let mut out: Vec = Vec::new(); - fn walk(mods: &[Module], seen: &mut BTreeSet, out: &mut Vec) { - for m in mods { - if let Some(domain) = &m.errors { - for c in &domain.codes { - if seen.insert(c.name.clone()) { - out.push(ResolvedError { - raw_name: c.name.clone(), - code: c.code, - message: c.message.clone(), - doc: c.doc.clone(), - }); - } - } - } - walk(&m.modules, seen, out); - } - } - walk(&api.modules, &mut seen, &mut out); - out -} - -/// Whether the API declares any error domains at all. -pub fn has_domains(api: &Api) -> bool { - fn any(mods: &[Module]) -> bool { - mods.iter().any(|m| m.errors.is_some() || any(&m.modules)) - } - any(&api.modules) -} - #[cfg(test)] mod tests { use super::*; - use weaveffi_ir::ir::{ErrorCode, ErrorDomain, Module}; - - fn module_with_errors(name: &str, codes: Vec<(&str, i32, &str)>) -> Module { - Module { - name: name.into(), - doc: None, - functions: vec![], - interfaces: vec![], - structs: vec![], - enums: vec![], - callbacks: vec![], - listeners: vec![], - errors: Some(ErrorDomain { - name: format!("{name}Error"), - codes: codes - .into_iter() - .map(|(n, c, m)| ErrorCode { - name: n.into(), - code: c, - message: m.into(), - doc: None, - fields: vec![], - }) - .collect(), - }), - modules: vec![], - } - } - - fn api_with(mods: Vec) -> Api { - Api { - version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), - modules: mods, - } - } #[test] fn type_name_avoids_screaming_and_doubling() { @@ -200,47 +85,9 @@ mod tests { } #[test] - fn member_spellings() { - let e = ResolvedError { - raw_name: "KEY_NOT_FOUND".into(), - code: 1, - message: "nope".into(), - doc: None, - }; - assert_eq!(e.error_class(), "KeyNotFoundError"); - assert_eq!(e.exception_class(), "KeyNotFoundException"); - assert_eq!(e.camel(), "keyNotFound"); - assert_eq!(e.pascal(), "KeyNotFound"); - assert_eq!(e.shouty(), "KEY_NOT_FOUND"); - } - - #[test] - fn flattens_and_dedups_across_modules() { - let api = api_with(vec![ - module_with_errors("a", vec![("NOT_FOUND", 1, "x"), ("DENIED", 2, "y")]), - module_with_errors("b", vec![("NOT_FOUND", 1, "x"), ("TIMEOUT", 3, "z")]), - ]); - let codes = all(&api); - let names: Vec<_> = codes.iter().map(|c| c.raw_name.as_str()).collect(); - assert_eq!(names, vec!["NOT_FOUND", "DENIED", "TIMEOUT"]); - assert!(has_domains(&api)); - } - - #[test] - fn no_domains_is_empty() { - let api = api_with(vec![Module { - name: "m".into(), - doc: None, - functions: vec![], - interfaces: vec![], - structs: vec![], - enums: vec![], - callbacks: vec![], - listeners: vec![], - errors: None, - modules: vec![], - }]); - assert!(all(&api).is_empty()); - assert!(!has_domains(&api)); + fn pascal_is_suffix_free() { + assert_eq!(pascal("KEY_NOT_FOUND"), "KeyNotFound"); + assert_eq!(BRAND_STEM, "WeaveFFI"); + assert!(ERROR_BRAND.starts_with(BRAND_STEM)); } } diff --git a/crates/weaveffi-core/src/model/mod.rs b/crates/weaveffi-core/src/model/mod.rs index 63618fd2..a4857301 100644 --- a/crates/weaveffi-core/src/model/mod.rs +++ b/crates/weaveffi-core/src/model/mod.rs @@ -9,9 +9,10 @@ //! [`Ty::wire`] instead of re-deriving what a name means; //! * every emitted **C symbol name** is precomputed once, so all backends //! agree by construction and a non-default prefix is honored everywhere; and -//! * every function, callback, and interface member is paired with its -//! lowered [`AbiFn`] signature (built from [`crate::abi`]), so no backend -//! re-derives parameter arity, ordering, or `out_*`/`out_err` placement. +//! * every function, interface member, and callback-interface method is +//! paired with its lowered [`AbiFn`] signature (built from [`crate::abi`]), +//! so no backend re-derives parameter arity, ordering, or `out_*`/`out_err` +//! placement. //! //! A backend reads the *idiomatic* shape from the retained [`Ty`]s //! (`param.ty`, `field.ty`, ...) and the *native* shape from the [`AbiFn`]s, @@ -25,13 +26,13 @@ pub use ty::{Family, Prim, Ty, WireType}; use heck::ToUpperCamelCase; use weaveffi_ir::ir::{ - CallbackDef, EnumDef, ErrorDomain, Function, InterfaceDef, Module, StructDef, StructField, - TypeRef, + CallbackInterfaceDef, EnumDef, ErrorDomain, Function, InterfaceDef, Module, StructDef, + StructField, TypeRef, }; use crate::abi::{ - async_callback_params, async_input_params, context_param, error_out_param, lower_param, - lower_return, sync_signature, AbiParam, CType, ConstPos, + async_callback_params, async_input_params, callback_method_signature, context_param, + error_out_param, lower_param, lower_return, sync_signature, AbiParam, CType, ConstPos, }; use crate::resolved::ResolvedApi; @@ -95,8 +96,6 @@ pub struct ParamBinding { pub name: String, /// The resolved type a backend renders the parameter as. pub ty: Ty, - /// Whether the parameter is mutable (drops the `const` on its pointer slots). - pub mutable: bool, /// Optional doc comment carried from the IDL. pub doc: Option, /// The ordered C ABI slots this single parameter expands into. @@ -106,22 +105,10 @@ pub struct ParamBinding { impl ParamBinding { /// Lower one parameter declared in the module whose underscore-joined C /// path is `module`. - pub fn new( - name: impl Into, - ty: Ty, - mutable: bool, - doc: Option, - module: &str, - ) -> Self { + pub fn new(name: impl Into, ty: Ty, doc: Option, module: &str) -> Self { let name = name.into(); - let abi = lower_param(&name, &ty, module, mutable); - Self { - name, - ty, - mutable, - doc, - abi, - } + let abi = lower_param(&name, &ty, module); + Self { name, ty, doc, abi } } } @@ -140,8 +127,6 @@ pub struct FnBinding { pub doc: Option, /// Deprecation message when the function is marked deprecated, else `None`. pub deprecated: Option, - /// The version the function was introduced, when the IDL records one. - pub since: Option, /// Whether an async function accepts a trailing `cancel_token` slot. pub cancellable: bool, /// Whether the function is `async` (lowered as a callback-completed launcher). @@ -251,39 +236,7 @@ pub struct EnumVariantBinding { pub fields: Vec, } -/// A callback function-pointer typedef declared at module scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CallbackBinding { - /// The callback name as written in the IDL. - pub name: String, - /// Optional doc comment carried from the IDL. - pub doc: Option, - /// `{prefix}_{module_path}_{name}_fn`. - pub c_fn_type: String, - /// Parameters of the callback (without the trailing context). - pub params: Vec, - /// The full ABI slot list, including the trailing `void* context`. - pub abi_params: Vec, -} - -/// A listener: a register/unregister pair bound to a callback. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListenerBinding { - /// The listener name as written in the IDL. - pub name: String, - /// Optional doc comment carried from the IDL. - pub doc: Option, - /// The callback this listener fires (name within the same module). - pub event_callback: String, - /// The referenced callback's `_fn` typedef name. - pub callback_c_fn_type: String, - /// `uint64_t {prefix}_{path}_register_{name}({cb}_fn callback, void* context)`. - pub register_symbol: String, - /// `void {prefix}_{path}_unregister_{name}(uint64_t id)`. - pub unregister_symbol: String, -} - -/// An interface (opaque object type), fully lowered. +/// An interface (reference-counted object type), fully lowered. /// /// Constructors, methods, and statics are all [`FnBinding`]s sharing the /// member symbol scheme `{c_tag}_{name}`. Methods additionally carry an @@ -306,10 +259,61 @@ pub struct InterfaceBinding { pub methods: Vec, /// Static functions namespaced under the interface. pub statics: Vec, - /// `void {c_tag}_destroy({c_tag}* self)`: releases the object reference. + /// `{c_tag}* {c_tag}_clone(const {c_tag}* self)`: returns a new strong + /// reference to the same object. + pub clone_symbol: String, + /// `void {c_tag}_destroy({c_tag}* self)`: releases one strong reference. pub destroy_symbol: String, } +/// One method of a callback interface, lowered to its vtable entry. +/// +/// The consumer implements this; the producer calls it through the vtable. +/// [`abi_params`](Self::abi_params) is the full C slot list of the vtable +/// entry (`void* ctx`, the parameter slots, `{prefix}_error* out_err`) and +/// [`abi_ret`](Self::abi_ret) its C return type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallbackMethodBinding { + /// The method name as written in the IDL; also the vtable field name. + pub name: String, + /// Optional doc comment carried from the IDL. + pub doc: Option, + /// Deprecation message when the method is marked deprecated, else `None`. + pub deprecated: Option, + /// Input parameters with their lowered slots. + pub params: Vec, + /// The resolved return type (`None` = void); always in the direct family. + pub ret: Option, + /// The vtable entry's C parameter slots: `ctx`, then every parameter's + /// slots, then `out_err`. + pub abi_params: Vec, + /// The vtable entry's C return type. + pub abi_ret: CType, +} + +/// A callback interface, fully lowered. +/// +/// The C ABI sees a vtable struct ([`vtable_tag`](Self::vtable_tag)) with one +/// function-pointer field per method, in declaration order, followed by a +/// trailing `void (*free)(void* ctx)`. A parameter of this type lowers to two +/// slots, `void* {name}_ctx` and `const {vtable_tag}* {name}_vtable`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallbackInterfaceBinding { + /// The callback interface name as written in the IDL. + pub name: String, + /// Optional doc comment carried from the IDL. + pub doc: Option, + /// Deprecation message when the callback interface is marked deprecated, + /// else `None`. + pub deprecated: Option, + /// `{prefix}_{module_path}_{name}`, the type's C name stem. + pub c_tag: String, + /// `{c_tag}_vtable`, the vtable struct tag. + pub vtable_tag: String, + /// Methods in declaration (and vtable) order. Never empty. + pub methods: Vec, +} + /// One error code of a module's error domain, with its C constant name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ErrorCodeBinding { @@ -382,18 +386,16 @@ pub struct ModuleBinding { pub structs: Vec, /// Interfaces declared in this module, fully lowered. pub interfaces: Vec, - /// Callback typedefs declared in this module. - pub callbacks: Vec, - /// Listeners declared in this module. - pub listeners: Vec, + /// Callback interfaces declared in this module, fully lowered. + pub callback_interfaces: Vec, /// Functions declared in this module, fully lowered. pub functions: Vec, } impl ModuleBinding { - /// Find a callback declared in this module by name. - pub fn callback(&self, name: &str) -> Option<&CallbackBinding> { - self.callbacks.iter().find(|c| c.name == name) + /// Find a callback interface declared in this module by name. + pub fn callback_interface(&self, name: &str) -> Option<&CallbackInterfaceBinding> { + self.callback_interfaces.iter().find(|c| c.name == name) } /// True when this module declares no API surface at all. @@ -401,8 +403,7 @@ impl ModuleBinding { self.enums.is_empty() && self.structs.is_empty() && self.interfaces.is_empty() - && self.callbacks.is_empty() - && self.listeners.is_empty() + && self.callback_interfaces.is_empty() && self.functions.is_empty() && !self.declares_error() } @@ -500,6 +501,30 @@ impl BindingModel { .flat_map(|m| m.callables().map(move |f| (m, f))) } + /// Iterate every callback interface across all modules, paired with its + /// module. + pub fn callback_interfaces( + &self, + ) -> impl Iterator { + self.modules + .iter() + .flat_map(|m| m.callback_interfaces.iter().map(move |c| (m, c))) + } + + /// Find an interface anywhere in the API by its (possibly dot-qualified) + /// resolved name, as carried by [`Ty::Interface`]. A bare name is looked + /// up in `current` (the referencing module's dot path) first. + pub fn interface(&self, name: &str, current: &str) -> Option<&InterfaceBinding> { + let (dot_path, bare) = match name.rsplit_once('.') { + Some((p, n)) => (p, n), + None => (current, name), + }; + self.modules + .iter() + .find(|m| m.dot_path == dot_path) + .and_then(|m| m.interfaces.iter().find(|i| i.name == bare)) + } + /// `true` when any callable anywhere in the API is `async`. pub fn has_async(&self) -> bool { self.modules.iter().any(ModuleBinding::has_async) @@ -510,9 +535,16 @@ impl BindingModel { self.modules.iter().any(ModuleBinding::has_iterators) } - /// `true` when the API declares any listener. - pub fn has_listeners(&self) -> bool { - self.modules.iter().any(|m| !m.listeners.is_empty()) + /// `true` when the API declares any callback interface. + pub fn has_callback_interfaces(&self) -> bool { + self.modules + .iter() + .any(|m| !m.callback_interfaces.is_empty()) + } + + /// `true` when the API declares any interface. + pub fn has_interfaces(&self) -> bool { + self.modules.iter().any(|m| !m.interfaces.is_empty()) } /// `true` when any type anywhere in the API crosses the ABI as a value @@ -527,38 +559,70 @@ impl BindingModel { }) || self.any_type(&|t| t.is_buffered()) } - /// Visit every boundary-crossing type in the API (callable and callback - /// parameters and returns, iterator elements, record, variant, and error - /// payload fields), recursing into composites, and return whether any - /// satisfies `pred`. Backends use this to decide which runtime helpers a - /// given API actually needs. + /// `true` when any interface appears inside a buffered position (a record + /// field, a collection element, an optional payload, a rich-enum field), + /// so codecs need object-token support. + pub fn has_buffered_objects(&self) -> bool { + self.any_top_type(&|ty, position| match position { + // Fields are always serialized, so any object inside one is a + // buffered object. + TypePosition::Field => ty.contains_object(), + // A top-level `Store` or `Store?` is a plain pointer slot; only a + // top-level *buffered* type that nests an object counts. + TypePosition::Boundary => ty.is_buffered() && ty.contains_object(), + }) + } + + /// Visit every boundary-crossing type in the API (callable and + /// callback-method parameters and returns, iterator elements, record, + /// variant, and error payload fields), recursing into composites, and + /// return whether any satisfies `pred`. Backends use this to decide which + /// runtime helpers a given API actually needs. pub fn any_type(&self, pred: &dyn Fn(&Ty) -> bool) -> bool { + self.any_top_type(&|ty, _| ty.any(pred)) + } + + /// Visit every *top-level* type in the API (no recursion into composites) + /// together with the kind of position it occupies, and return whether any + /// satisfies `pred`. + fn any_top_type(&self, pred: &dyn Fn(&Ty, TypePosition) -> bool) -> bool { + let boundary = |ty: &Ty| pred(ty, TypePosition::Boundary); + let field = |ty: &Ty| pred(ty, TypePosition::Field); self.modules.iter().any(|m| { m.callables().any(|f| { - f.params.iter().any(|p| p.ty.any(pred)) - || f.ret.as_ref().is_some_and(|r| r.any(pred)) + f.params.iter().any(|p| boundary(&p.ty)) || f.ret.as_ref().is_some_and(boundary) + }) || m.callback_interfaces.iter().any(|c| { + c.methods.iter().any(|f| { + f.params.iter().any(|p| boundary(&p.ty)) || f.ret.as_ref().is_some_and(boundary) + }) }) || m - .callbacks + .structs .iter() - .any(|c| c.params.iter().any(|p| p.ty.any(pred))) - || m.structs - .iter() - .any(|s| s.fields.iter().any(|f| f.ty.any(pred))) + .any(|s| s.fields.iter().any(|f| field(&f.ty))) || m.enums.iter().any(|e| { e.variants .iter() - .any(|v| v.fields.iter().any(|f| f.ty.any(pred))) + .any(|v| v.fields.iter().any(|f| field(&f.ty))) }) || m.error.as_ref().is_some_and(|e| { e.declared_here && e.codes .iter() - .any(|c| c.fields.iter().any(|f| f.ty.any(pred))) + .any(|c| c.fields.iter().any(|f| field(&f.ty))) }) }) } } +/// Where a top-level type sits, for [`BindingModel::any_top_type`]. +#[derive(Clone, Copy)] +enum TypePosition { + /// A callable or callback-method parameter or return. + Boundary, + /// A record, rich-enum variant, or error payload field (always serialized). + Field, +} + /// The per-build lowering context: the resolver and the symbol prefix. struct Lowerer<'a> { api: &'a ResolvedApi, @@ -623,22 +687,10 @@ impl Lowerer<'_> { .iter() .map(|i| self.interface(i, &scope)) .collect(); - let callbacks = module - .callbacks - .iter() - .map(|c| self.callback(c, &scope)) - .collect(); - let listeners = module - .listeners + let callback_interfaces = module + .callback_interfaces .iter() - .map(|l| ListenerBinding { - name: l.name.clone(), - doc: l.doc.clone(), - event_callback: l.event_callback.clone(), - callback_c_fn_type: format!("{prefix}_{path}_{}_fn", l.event_callback), - register_symbol: format!("{prefix}_{path}_register_{}", l.name), - unregister_symbol: format!("{prefix}_{path}_unregister_{}", l.name), - }) + .map(|c| self.callback_interface(c, &scope)) .collect(); let functions = module .functions @@ -664,8 +716,7 @@ impl Lowerer<'_> { enums, structs, interfaces, - callbacks, - listeners, + callback_interfaces, functions, }); @@ -743,28 +794,41 @@ impl Lowerer<'_> { fn params(&self, params: &[weaveffi_ir::ir::Param], scope: &Scope<'_>) -> Vec { params .iter() - .map(|p| { - ParamBinding::new( - &p.name, - self.ty(&p.ty, scope), - p.mutable, - p.doc.clone(), - scope.path, - ) - }) + .map(|p| ParamBinding::new(&p.name, self.ty(&p.ty, scope), p.doc.clone(), scope.path)) .collect() } - fn callback(&self, c: &CallbackDef, scope: &Scope<'_>) -> CallbackBinding { - let params = self.params(&c.params, scope); - let mut abi_params: Vec = params.iter().flat_map(|p| p.abi.clone()).collect(); - abi_params.push(context_param()); - CallbackBinding { + fn callback_interface( + &self, + c: &CallbackInterfaceDef, + scope: &Scope<'_>, + ) -> CallbackInterfaceBinding { + let c_tag = format!("{}_{}_{}", self.prefix, scope.path, c.name); + let methods = c + .methods + .iter() + .map(|m| { + let params = self.params(&m.params, scope); + let ret = m.returns.as_ref().map(|r| self.ty(r, scope)); + let sig = callback_method_signature(¶ms, ret.as_ref(), scope.path); + CallbackMethodBinding { + name: m.name.clone(), + doc: m.doc.clone(), + deprecated: m.deprecated.clone(), + params, + ret, + abi_params: sig.params, + abi_ret: sig.ret, + } + }) + .collect(); + CallbackInterfaceBinding { name: c.name.clone(), doc: c.doc.clone(), - c_fn_type: format!("{}_{}_{}_fn", self.prefix, scope.path, c.name), - params, - abi_params, + deprecated: c.deprecated.clone(), + vtable_tag: format!("{c_tag}_vtable"), + c_tag, + methods, } } @@ -788,8 +852,8 @@ impl Lowerer<'_> { .constructors .iter() .map(|c| { - // A constructor yields a new owned reference to the interface, - // exactly like a static returning it. + // A constructor yields a new strong reference to the + // interface, exactly like a static returning it. let mut f = c.clone(); f.returns = Some(TypeRef::Named(iface.name.clone())); self.callable(&f, scope, &member(&c.name), None) @@ -813,6 +877,7 @@ impl Lowerer<'_> { constructors, methods, statics, + clone_symbol: format!("{c_tag}_clone"), destroy_symbol: format!("{c_tag}_destroy"), } } @@ -914,7 +979,6 @@ impl Lowerer<'_> { name: f.name.clone(), doc: f.doc.clone(), deprecated: f.deprecated.clone(), - since: f.since.clone(), cancellable: f.cancellable, is_async: f.r#async, throws: f.throws, @@ -937,7 +1001,7 @@ pub fn iterator_item_ctype(elem: &Ty, module: &str) -> CType { mod tests { use super::*; use weaveffi_ir::ir::{ - Api, CallbackDef, EnumDef, EnumVariant, Function, InterfaceDef, ListenerDef, Module, Param, + Api, CallbackInterfaceDef, EnumDef, EnumVariant, Function, InterfaceDef, Module, Param, StructDef, StructField, }; @@ -945,7 +1009,6 @@ mod tests { Param { name: name.into(), ty, - mutable: false, doc: None, } } @@ -960,7 +1023,6 @@ mod tests { r#async: false, cancellable: false, deprecated: None, - since: None, } } @@ -970,10 +1032,9 @@ mod tests { doc: None, functions: vec![], interfaces: vec![], + callback_interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], errors: None, modules: vec![], } @@ -1093,6 +1154,11 @@ mod tests { ty: TypeRef::Named("Status".into()), doc: None, }, + StructField { + name: "store".into(), + ty: TypeRef::Optional(Box::new(TypeRef::Named("Store".into()))), + doc: None, + }, ], }], enums: vec![EnumDef { @@ -1133,6 +1199,10 @@ mod tests { let s = &contacts.structs[0]; assert_eq!(s.deprecated.as_deref(), Some("use Person")); assert_eq!(s.fields[1].ty, Ty::Enum("Status".into())); + assert_eq!( + s.fields[2].ty, + Ty::Optional(Box::new(Ty::Interface("Store".into()))) + ); assert_eq!(contacts.enums[0].c_tag, "weaveffi_contacts_Status"); assert_eq!( contacts.enums[0].variants[0].c_const, @@ -1141,6 +1211,7 @@ mod tests { let iface = &contacts.interfaces[0]; assert_eq!(iface.c_tag, "weaveffi_contacts_Store"); + assert_eq!(iface.clone_symbol, "weaveffi_contacts_Store_clone"); assert_eq!(iface.destroy_symbol, "weaveffi_contacts_Store_destroy"); assert_eq!( iface.constructors[0].ret, @@ -1180,35 +1251,72 @@ mod tests { ); assert_eq!(abi.ret.render_c("weaveffi"), "weaveffi_contacts_Status"); assert!(model.has_buffers()); + assert!(model.has_buffered_objects()); + assert!(model.has_interfaces()); + assert_eq!( + model.interface("contacts.Store", "ops").map(|i| &i.c_tag), + Some(&"weaveffi_contacts_Store".to_string()) + ); + assert_eq!( + model.interface("Store", "contacts").map(|i| &i.c_tag), + Some(&"weaveffi_contacts_Store".to_string()) + ); } #[test] - fn callbacks_and_listeners_are_linked() { + fn callback_interfaces_lower_to_vtables() { let m = Module { - callbacks: vec![CallbackDef { - name: "on_message".into(), - params: vec![param("text", TypeRef::StringUtf8)], - doc: None, - }], - listeners: vec![ListenerDef { - name: "messages".into(), - event_callback: "on_message".into(), - doc: None, + callback_interfaces: vec![CallbackInterfaceDef { + name: "Listener".into(), + doc: Some("Receives messages.".into()), + deprecated: None, + methods: vec![ + func("on_message", vec![param("text", TypeRef::StringUtf8)], None), + func("should_stop", vec![], Some(TypeRef::Bool)), + ], }], + functions: vec![func( + "subscribe", + vec![param("listener", TypeRef::Named("Listener".into()))], + None, + )], ..module("events") }; let model = BindingModel::build(&api(vec![m]), "weaveffi"); let mb = &model.modules[0]; - let cb = &mb.callbacks[0]; - assert_eq!(cb.c_fn_type, "weaveffi_events_on_message_fn"); - assert_eq!(cb.abi_params.last().unwrap().name, "context"); - let l = &mb.listeners[0]; - assert_eq!(l.register_symbol, "weaveffi_events_register_messages"); - assert_eq!(l.unregister_symbol, "weaveffi_events_unregister_messages"); - assert_eq!(l.callback_c_fn_type, "weaveffi_events_on_message_fn"); - assert!(mb.callback("on_message").is_some()); - assert!(model.has_listeners()); + let cb = &mb.callback_interfaces[0]; + assert_eq!(cb.c_tag, "weaveffi_events_Listener"); + assert_eq!(cb.vtable_tag, "weaveffi_events_Listener_vtable"); + let on_message = &cb.methods[0]; + let slots: Vec = on_message + .abi_params + .iter() + .map(|p| format!("{} {}", p.ty.render_c("weaveffi"), p.name)) + .collect(); + assert_eq!( + slots, + ["void* ctx", "const char* text", "weaveffi_error* out_err"] + ); + assert_eq!(on_message.abi_ret, CType::Void); + assert_eq!(cb.methods[1].abi_ret, CType::Bool); + assert!(mb.callback_interface("Listener").is_some()); + assert!(model.has_callback_interfaces()); + + let f = &mb.functions[0]; + assert_eq!(f.params[0].ty, Ty::CallbackInterface("Listener".into())); + let CallShape::Sync(abi) = &f.shape else { + panic!("expected sync") + }; + assert_eq!( + rendered(abi), + [ + "void* listener_ctx", + "const weaveffi_events_Listener_vtable* listener_vtable", + "weaveffi_error* out_err" + ] + ); assert!(!model.has_buffers()); + assert!(!model.has_buffered_objects()); } #[test] diff --git a/crates/weaveffi-core/src/model/ty.rs b/crates/weaveffi-core/src/model/ty.rs index 85d72c94..ab8aec64 100644 --- a/crates/weaveffi-core/src/model/ty.rs +++ b/crates/weaveffi-core/src/model/ty.rs @@ -3,23 +3,23 @@ //! //! The IDL's [`TypeRef`](weaveffi_ir::ir::TypeRef) is the type *as written*: //! a user-defined type is a bare `Named` string because the parser cannot -//! know whether it names a record, an enum, or an interface. [`Ty`] is the -//! type *as resolved* by validation: every user reference carries its kind, -//! cross-module references are qualified with the owner's dot-joined module -//! path, and there is no "unresolved" variant for a backend to trip over. +//! know whether it names a record, an enum, an interface, or a callback +//! interface. [`Ty`] is the type *as resolved* by validation: every user +//! reference carries its kind, cross-module references are qualified with the +//! owner's dot-joined module path, and there is no "unresolved" variant for a +//! backend to trip over. //! -//! Three questions about a type used to be answered by separate match trees -//! in `abi`, `plan`, and `wire`, and each generator had its own copies on top. -//! They are answered once here: +//! Three questions about a type are answered once here: //! //! * [`Ty::family`]: how the type crosses a **call boundary** (by value, as a //! pinned string, as a `(ptr, len)` byte pair, as a serialized value -//! buffer, or as an object pointer). The ABI lowering, the marshalling -//! plan, and every backend's argument and return handling dispatch on it. +//! buffer, as an object pointer, or as a callback vtable pair). The ABI +//! lowering, the marshalling plan, and every backend's argument and return +//! handling dispatch on it. //! * [`Ty::wire`]: how the type is encoded **inside a value buffer**. Every //! backend's codec emitter dispatches on it. -//! * [`Ty::contains_user_type`]: whether encoding the type needs a -//! user-defined codec function somewhere. +//! * [`Ty::contains_user_type`] and [`Ty::contains_object`]: whether encoding +//! the type needs a user-defined codec function or object-token support. use std::fmt; @@ -48,19 +48,10 @@ pub enum Ty { F64, /// Boolean (`bool`). Bool, - /// Owned UTF-8 string (`string`). + /// UTF-8 string (`string`). Borrowed as a parameter, owned as a return. StringUtf8, - /// Owned byte buffer (`bytes`). + /// Byte buffer (`bytes`). Borrowed as a parameter, owned as a return. Bytes, - /// Borrowed string slice (`&str`): valid only for the duration of a call. - BorrowedStr, - /// Borrowed byte slice (`&[u8]`): valid only for the duration of a call. - BorrowedBytes, - /// Opaque, untyped resource handle (`handle`). - Handle, - /// Opaque resource handle tagged with the (possibly dot-qualified) name - /// of what it refers to (`handle`). - TypedHandle(String), /// A user record (struct): a plain value type. Crosses the C ABI by value /// as a serialized buffer (`ptr` + `len`), borrowed for a call as a /// parameter and owned (freed with `{prefix}_free_bytes`) as a return. @@ -73,10 +64,14 @@ pub enum Ty { RichEnum(String), /// A C-style integer enum (no variant payloads). Lowers by value. Enum(String), - /// A user interface: an opaque object reference. As a parameter the - /// object is borrowed for the call; as a return the caller receives a new - /// owned reference it must eventually release. + /// A user interface: a reference-counted object. As a parameter the + /// object is borrowed for the call; as a return (or inside a buffer) the + /// receiver adopts one strong reference it must eventually release. Interface(String), + /// A callback interface: a method set the consumer implements. Only valid + /// as a top-level parameter, where it lowers to a context pointer plus a + /// vtable pointer. + CallbackInterface(String), /// Optional value (`T?`): either the inner type or nothing. Optional(Box), /// Homogeneous list (`[T]`) of the inner element type. @@ -93,8 +88,7 @@ pub enum Ty { /// argument and return handling agree on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Family { - /// One C slot passed by value: scalars, bools, C-style enums, and handles - /// (a typed handle's slot is an opaque pointer, still by value). + /// One C slot passed by value: scalars, bools, and C-style enums. Direct, /// One `char*` slot. Borrowed for a call as a parameter; producer-owned /// and released with `{prefix}_free_string` as a return. @@ -106,12 +100,15 @@ pub enum Family { /// list, or map). Borrowed as a parameter; producer-owned and released /// with `{prefix}_free_bytes` after decoding as a return. Buffer, - /// An opaque object pointer to an interface. Borrowed as a parameter; - /// an owned reference (released with its `_destroy` symbol) as a return. + /// An object pointer to an interface. Borrowed as a parameter; one + /// strong reference (released with its `_destroy` symbol) as a return. Object { /// `true` for `Interface?`: null is a legal "none" value. nullable: bool, }, + /// A callback interface: a `void* ctx` plus `const {tag}_vtable*` pair. + /// Only ever a parameter. + Callback, /// An `iter` return: an opaque iterator handle with its own /// `next`/`destroy` protocol. Never a parameter. Iterator, @@ -147,9 +144,8 @@ pub enum Prim { /// Eight bytes, IEEE 754 bits. F64, /// A `u32` byte length followed by UTF-8 bytes, no NUL terminator. - /// Covers `string` and `&str`. String, - /// A `u32` length followed by raw bytes. Covers `bytes` and `&[u8]`. + /// A `u32` length followed by raw bytes. Bytes, } @@ -228,11 +224,12 @@ impl Prim { pub enum WireType<'a> { /// A fixed-width primitive, a string, or a byte blob. Prim(Prim), - /// An opaque handle token: eight bytes, little-endian, unsigned. Carries - /// the typed handle's referent name (if any) so backends that wrap - /// handles in typed structs can pick the wrapper; the encoding is the - /// same either way. - Handle(Option<&'a str>), + /// An object token: eight bytes, little-endian, unsigned, holding the + /// object pointer and carrying one strong reference. Carries the + /// (possibly dot-qualified) interface name so backends can wrap the + /// adopted pointer in the right class and, when writing, call the right + /// `_clone` symbol. + Object(&'a str), /// A C-style enum: an `i32` discriminant. Carries the (possibly /// dot-qualified) enum name so backends can wrap the integer in their /// typed enum. @@ -255,17 +252,18 @@ impl Ty { /// How this type crosses a call boundary. Total over every `Ty`. pub fn family(&self) -> Family { match self { - Ty::StringUtf8 | Ty::BorrowedStr => Family::String, - Ty::Bytes | Ty::BorrowedBytes => Family::Bytes, + Ty::StringUtf8 => Family::String, + Ty::Bytes => Family::Bytes, Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => Family::Buffer, Ty::Interface(_) => Family::Object { nullable: false }, - // The one optional that is not buffered: an object reference - // cannot be serialized by value, so `Interface?` stays a nullable - // pointer. + // The one optional that is not buffered: `Interface?` stays a + // nullable pointer so the common "maybe an object" shape needs no + // encoding step. Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { Family::Object { nullable: true } } Ty::Optional(_) => Family::Buffer, + Ty::CallbackInterface(_) => Family::Callback, Ty::Iterator(_) => Family::Iterator, Ty::I8 | Ty::I16 @@ -278,8 +276,6 @@ impl Ty { | Ty::F32 | Ty::F64 | Ty::Bool - | Ty::Handle - | Ty::TypedHandle(_) | Ty::Enum(_) => Family::Direct, } } @@ -290,20 +286,15 @@ impl Ty { self.family() == Family::Buffer } - /// `true` for the two borrowed views (`&str`, `&[u8]`). - pub fn is_borrowed(&self) -> bool { - matches!(self, Ty::BorrowedStr | Ty::BorrowedBytes) - } - /// The referenced user-type name for a record, rich enum, enum, - /// interface, or typed handle, or `None` for every other type. + /// interface, or callback interface, or `None` for every other type. pub fn user_name(&self) -> Option<&str> { match self { Ty::Record(n) | Ty::RichEnum(n) | Ty::Enum(n) | Ty::Interface(n) - | Ty::TypedHandle(n) => Some(n), + | Ty::CallbackInterface(n) => Some(n), _ => None, } } @@ -318,17 +309,25 @@ impl Ty { } } + /// The callback interface name, or `None` for every other type. + pub fn callback_interface_name(&self) -> Option<&str> { + match self { + Ty::CallbackInterface(n) => Some(n), + _ => None, + } + } + /// Classify this type's encoding inside a value buffer. /// /// Total over every type validation admits inside a buffered position. - /// Interfaces and iterators never appear inside value buffers (validation - /// rejects them there), so those inputs are bugs in the caller's - /// pipeline, not user errors. + /// Callback interfaces and iterators never appear inside value buffers + /// (validation rejects them there), so those inputs are bugs in the + /// caller's pipeline, not user errors. /// /// # Panics /// - /// Panics when `self` is an interface or an iterator, neither of which - /// can legally appear inside a value buffer. + /// Panics when `self` is a callback interface or an iterator, neither of + /// which can legally appear inside a value buffer. pub fn wire(&self) -> WireType<'_> { match self { Ty::Bool => WireType::Prim(Prim::Bool), @@ -342,17 +341,16 @@ impl Ty { Ty::U64 => WireType::Prim(Prim::U64), Ty::F32 => WireType::Prim(Prim::F32), Ty::F64 => WireType::Prim(Prim::F64), - Ty::StringUtf8 | Ty::BorrowedStr => WireType::Prim(Prim::String), - Ty::Bytes | Ty::BorrowedBytes => WireType::Prim(Prim::Bytes), - Ty::Handle => WireType::Handle(None), - Ty::TypedHandle(n) => WireType::Handle(Some(n)), + Ty::StringUtf8 => WireType::Prim(Prim::String), + Ty::Bytes => WireType::Prim(Prim::Bytes), + Ty::Interface(n) => WireType::Object(n), Ty::Enum(name) => WireType::Enum(name), Ty::Record(name) | Ty::RichEnum(name) => WireType::User(name), Ty::Optional(inner) => WireType::Optional(inner), Ty::List(inner) => WireType::List(inner), Ty::Map(k, v) => WireType::Map(k, v), - Ty::Interface(_) | Ty::Iterator(_) => { - panic!("object references cannot appear inside value buffers: {self}") + Ty::CallbackInterface(_) | Ty::Iterator(_) => { + panic!("{self} cannot appear inside value buffers") } } } @@ -361,14 +359,13 @@ impl Ty { /// somewhere in its encoding: it is (or transitively contains) a record /// or rich enum. pub fn contains_user_type(&self) -> bool { - match self { - Ty::Record(_) | Ty::RichEnum(_) => true, - Ty::Optional(inner) | Ty::List(inner) | Ty::Iterator(inner) => { - inner.contains_user_type() - } - Ty::Map(k, v) => k.contains_user_type() || v.contains_user_type(), - _ => false, - } + self.any(&|t| matches!(t, Ty::Record(_) | Ty::RichEnum(_))) + } + + /// `true` when this type is (or transitively contains) an interface, so + /// encoding it needs object-token support in the codec. + pub fn contains_object(&self) -> bool { + self.any(&|t| matches!(t, Ty::Interface(_))) } /// `true` when `pred` holds for this type or any type nested inside it @@ -412,11 +409,11 @@ impl fmt::Display for Ty { Ty::Bool => f.write_str("bool"), Ty::StringUtf8 => f.write_str("string"), Ty::Bytes => f.write_str("bytes"), - Ty::BorrowedStr => f.write_str("&str"), - Ty::BorrowedBytes => f.write_str("&[u8]"), - Ty::Handle => f.write_str("handle"), - Ty::TypedHandle(n) => write!(f, "handle<{n}>"), - Ty::Record(n) | Ty::RichEnum(n) | Ty::Enum(n) | Ty::Interface(n) => f.write_str(n), + Ty::Record(n) + | Ty::RichEnum(n) + | Ty::Enum(n) + | Ty::Interface(n) + | Ty::CallbackInterface(n) => f.write_str(n), Ty::Optional(inner) => write!(f, "{inner}?"), Ty::List(inner) => write!(f, "[{inner}]"), Ty::Map(k, v) => write!(f, "{{{k}:{v}}}"), @@ -432,17 +429,14 @@ mod tests { #[test] fn families_are_total_and_agree_with_the_abi_contract() { assert_eq!(Ty::I32.family(), Family::Direct); - assert_eq!(Ty::Handle.family(), Family::Direct); - assert_eq!(Ty::TypedHandle("S".into()).family(), Family::Direct); assert_eq!(Ty::Enum("Color".into()).family(), Family::Direct); assert_eq!(Ty::StringUtf8.family(), Family::String); - assert_eq!(Ty::BorrowedStr.family(), Family::String); assert_eq!(Ty::Bytes.family(), Family::Bytes); - assert_eq!(Ty::BorrowedBytes.family(), Family::Bytes); for ty in [ Ty::Record("C".into()), Ty::RichEnum("S".into()), Ty::List(Box::new(Ty::I32)), + Ty::List(Box::new(Ty::Interface("Store".into()))), Ty::Map(Box::new(Ty::StringUtf8), Box::new(Ty::I32)), Ty::Optional(Box::new(Ty::I32)), Ty::Optional(Box::new(Ty::StringUtf8)), @@ -459,17 +453,20 @@ mod tests { Ty::Optional(Box::new(Ty::Interface("Store".into()))).family(), Family::Object { nullable: true } ); + assert_eq!( + Ty::CallbackInterface("Listener".into()).family(), + Family::Callback + ); assert_eq!(Ty::Iterator(Box::new(Ty::I32)).family(), Family::Iterator); } #[test] - fn wire_folds_borrowed_and_handles() { - assert_eq!(Ty::BorrowedStr.wire(), WireType::Prim(Prim::String)); - assert_eq!(Ty::BorrowedBytes.wire(), WireType::Prim(Prim::Bytes)); - assert_eq!(Ty::Handle.wire(), WireType::Handle(None)); + fn wire_shapes() { + assert_eq!(Ty::StringUtf8.wire(), WireType::Prim(Prim::String)); + assert_eq!(Ty::Bytes.wire(), WireType::Prim(Prim::Bytes)); assert_eq!( - Ty::TypedHandle("Session".into()).wire(), - WireType::Handle(Some("Session")) + Ty::Interface("kv.Store".into()).wire(), + WireType::Object("kv.Store") ); assert_eq!(Ty::RichEnum("Shape".into()).wire(), WireType::User("Shape")); assert_eq!(Ty::Enum("Color".into()).wire(), WireType::Enum("Color")); @@ -478,13 +475,13 @@ mod tests { } #[test] - #[should_panic(expected = "object references")] - fn interfaces_have_no_wire_shape() { - Ty::Interface("Store".into()).wire(); + #[should_panic(expected = "cannot appear inside value buffers")] + fn callback_interfaces_have_no_wire_shape() { + Ty::CallbackInterface("Listener".into()).wire(); } #[test] - fn user_type_containment_recurses() { + fn containment_recurses() { assert!(Ty::Record("C".into()).contains_user_type()); assert!(Ty::Map( Box::new(Ty::StringUtf8), @@ -492,6 +489,8 @@ mod tests { ) .contains_user_type()); assert!(!Ty::List(Box::new(Ty::Enum("Color".into()))).contains_user_type()); + assert!(Ty::List(Box::new(Ty::Interface("Store".into()))).contains_object()); + assert!(!Ty::List(Box::new(Ty::Record("C".into()))).contains_object()); } #[test] @@ -502,8 +501,8 @@ mod tests { ))); assert_eq!(ty.to_string(), "[{string:a.Contact?}]"); assert_eq!( - Ty::Iterator(Box::new(Ty::BorrowedStr)).to_string(), - "iter<&str>" + Ty::Iterator(Box::new(Ty::Interface("Store".into()))).to_string(), + "iter" ); assert_eq!(Prim::String.snake(), "string"); assert_eq!(Prim::I32.pascal(), "I32"); diff --git a/crates/weaveffi-core/src/plan.rs b/crates/weaveffi-core/src/plan.rs index 8af26c3b..b59bf126 100644 --- a/crates/weaveffi-core/src/plan.rs +++ b/crates/weaveffi-core/src/plan.rs @@ -7,20 +7,23 @@ //! //! * **Passing** ([`ArgPass`], [`RetPass`]): how each argument crosses into //! its ABI slots (by value, pinned string/bytes, serialized value buffer, -//! or borrowed object pointer) and what the wrapper does with the result -//! (use, copy, decode, or adopt) before any owed release. +//! borrowed object pointer, or callback context plus vtable) and what the +//! wrapper does with the result (use, copy, decode, or adopt) before any +//! owed release. //! * **Errors** ([`ErrorStrategy`]): when a call reports through `out_err`, //! is that a typed domain error the caller can catch, or a producer bug the //! wrapper must trap on? -//! * **Ownership** ([`RetPass::free`], [`ElemFree`]): after copying a -//! returned value into a native one, exactly which runtime release call -//! does the wrapper owe, if any? +//! * **Ownership** ([`RetPass::free`], [`Free`]): after copying a returned +//! value into a native one, exactly which runtime release call does the +//! wrapper owe, if any? //! * **Iterators** ([`IteratorProtocol`]): the pull contract of `iter`, //! including the requirement that wrappers stay **lazy** (one producer //! `next` per consumer step, never a hidden drain into a list). //! * **Async** ([`AsyncProtocol`]): the completion-callback contract, //! including the rule that results and errors are owned by the consumer //! and released through the runtime free symbols. +//! * **Callback interfaces** ([`CallbackProtocol`]): the contract for the +//! consumer-implemented vtable the producer calls back into. //! //! Every classification here derives from [`Ty::family`], so a backend that //! renders these plans in its own syntax cannot drift from the others on @@ -28,7 +31,9 @@ use crate::abi::split_qualified; use crate::abi::AbiParam; -use crate::model::{AsyncBinding, Family, FnBinding, IteratorBinding, ParamBinding, Ty}; +use crate::model::{ + AsyncBinding, CallbackInterfaceBinding, Family, FnBinding, IteratorBinding, ParamBinding, Ty, +}; /// How a callable's `out_err` slot is interpreted by idiomatic wrappers. /// @@ -44,13 +49,17 @@ pub enum ErrorStrategy { /// `error` value, ...), decodes any payload fields from the error's /// `payload_ptr`/`payload_len` buffer, and surfaces it through the /// target's normal error channel so callers can catch and match on it. + /// A *negative* code (a runtime trap: panic, marshalling failure, + /// foreign callback failure) is still a programming error and follows + /// [`Trap`](Self::Trap) even on a throwing function. Throws, /// The function does not throw: the only way `out_err` reports failure - /// is a producer bug (most commonly a caught panic, code `-2`). The - /// wrapper surfaces it through the target's *programming-error* idiom - /// (a Python `WeaveFFIError`, a Go `panic`, a Swift `fatalError`, a C# - /// exception). It must never be silently ignored, and it must never be - /// dressed up as a typed domain error. + /// is a producer bug or a runtime trap (a caught panic, code `-2`; a + /// consumer callback that raised, code `-4`). The wrapper surfaces it + /// through the target's *programming-error* idiom (a Python + /// `WeaveFFIError`, a Go `panic`, a Swift `fatalError`, a C# exception). + /// It must never be silently ignored, and it must never be dressed up as + /// a typed domain error. Trap, } @@ -75,8 +84,7 @@ impl FnBinding { /// header about arity or slot order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArgPass<'a> { - /// One slot passed by value: scalars, bools, C-style enums, and handles - /// (including typed handles, whose slot is an opaque pointer). + /// One slot passed by value: scalars, bools, and C-style enums. Direct { /// The single ABI slot. slot: &'a AbiParam, @@ -99,7 +107,9 @@ pub enum ArgPass<'a> { /// A buffered value (record, rich enum, optional, list, or map): the /// wrapper serializes it into the value-buffer wire format /// ([`Ty::wire`]), passes the encoding as a borrowed `(ptr, len)` pair, - /// and releases its own encoding after the call returns. + /// and releases its own encoding after the call returns. Any object + /// token written into the encoding must be a freshly cloned reference + /// (see [`clone_symbol`]). Buffer { /// The `const uint8_t*` data slot. ptr: &'a AbiParam, @@ -107,14 +117,24 @@ pub enum ArgPass<'a> { len: &'a AbiParam, }, /// A borrowed object pointer: the wrapper passes the wrapped object's - /// native handle and retains ownership. When `nullable`, the IDL type is - /// `Interface?` and null means none. + /// native handle and retains its own reference. When `nullable`, the IDL + /// type is `Interface?` and null means none. Object { /// The single object-pointer slot. slot: &'a AbiParam, /// `true` for `Interface?`: null is a legal "none" argument. nullable: bool, }, + /// A callback interface: the wrapper registers its native implementation + /// in a handle table, passes the table key as `ctx` and the interface's + /// static vtable as `vtable`, and removes the entry when the producer + /// calls the vtable's `free` (see [`CallbackProtocol`]). + Callback { + /// The `void*` context slot. + ctx: &'a AbiParam, + /// The `const {vtable}*` slot. + vtable: &'a AbiParam, + }, } impl ParamBinding { @@ -157,6 +177,10 @@ impl ParamBinding { slot: single(), nullable, }, + Family::Callback => { + let (ctx, vtable) = pair(); + ArgPass::Callback { ctx, vtable } + } Family::Iterator => unreachable!("iterators are never parameters"), } } @@ -175,17 +199,16 @@ pub enum Free { Bytes, } -/// How a callable's result crosses back to the wrapper: the receiving -/// contract, including the decode step and the release obligation. +/// How a value produced by the producer crosses back to the wrapper: the +/// receiving contract, including the decode step and the release obligation. /// -/// Only the sync and async call shapes consult this; an iterator's result -/// contract lives in [`IteratorProtocol`]. +/// Sync returns, async results, and iterator elements all use this. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RetPass { /// No return value. Void, - /// By-value return (scalar, bool, C-style enum, handle): use directly, - /// nothing to free. + /// By-value return (scalar, bool, C-style enum): use directly, nothing to + /// free. Direct, /// Owned `const char*`: copy into the native string, then release with /// `{prefix}_free_string`. @@ -194,15 +217,20 @@ pub enum RetPass { /// `{prefix}_free_bytes`. Bytes, /// Owned `(const uint8_t*, out_len)` value buffer: decode via the wire - /// format ([`Ty::wire`]), then release with `{prefix}_free_bytes`. + /// format ([`Ty::wire`]), adopting any object tokens it carries, then + /// release with `{prefix}_free_bytes`. Buffer, - /// An owned object reference the wrapper adopts into its disposal idiom. - /// When `nullable`, the IDL type is `Interface?` and a null return means - /// none. + /// One strong object reference the wrapper adopts into its disposal + /// idiom. When `nullable`, the IDL type is `Interface?` and a null return + /// means none. Object { /// The `{prefix}_{module}_{Name}_destroy` symbol the adopted /// reference eventually owes. destroy_symbol: String, + /// The `{prefix}_{module}_{Name}_clone` symbol the wrapper calls when + /// it needs a second reference (for example to write the object into + /// a value buffer). + clone_symbol: String, /// `true` for `Interface?`: a null return is a legal "none" result. nullable: bool, }, @@ -221,15 +249,16 @@ impl RetPass { } } -/// The receiving contract for a value of type `ty` returned from a callable +/// The receiving contract for a value of type `ty` produced by a callable /// declared inside `module` under `prefix`. `None` (a void return) is /// [`RetPass::Void`]. /// /// # Panics /// /// Panics on an iterator return, whose contract is [`IteratorProtocol`], not -/// a value-passing plan; backends dispatch on -/// [`CallShape`](crate::model::CallShape) before consulting this. +/// a value-passing plan (backends dispatch on +/// [`CallShape`](crate::model::CallShape) before consulting this), and on a +/// callback interface, which validation never admits as a return. pub fn ret_pass(ty: Option<&Ty>, module: &str, prefix: &str) -> RetPass { let Some(ty) = ty else { return RetPass::Void; @@ -239,25 +268,25 @@ pub fn ret_pass(ty: Option<&Ty>, module: &str, prefix: &str) -> RetPass { Family::String => RetPass::String, Family::Bytes => RetPass::Bytes, Family::Buffer => RetPass::Buffer, - Family::Object { nullable } => RetPass::Object { - destroy_symbol: destroy_symbol( - ty.interface_name() - .expect("object family names an interface"), - module, - prefix, - ), - nullable, - }, + Family::Object { nullable } => { + let iface = ty + .interface_name() + .expect("object family names an interface"); + RetPass::Object { + destroy_symbol: destroy_symbol(iface, module, prefix), + clone_symbol: clone_symbol(iface, module, prefix), + nullable, + } + } + Family::Callback => panic!("callback interfaces are never returned"), Family::Iterator => panic!("iterator returns follow IteratorProtocol, not a RetPass"), } } -/// The release call a consumer wrapper owes for one *element* it copied out -/// of an iterator `next` slot. This is [`Free`] under its historical name. -pub type ElemFree = Free; - -/// The per-element release owed for one iterator element of type `ty`. -pub fn elem_free(ty: &Ty) -> ElemFree { +/// The per-element release owed for one iterator element of type `ty`. An +/// object element is adopted (see [`IteratorProtocol::elem`]) and owes +/// [`Free::None`] here. +pub fn elem_free(ty: &Ty) -> Free { match ty.family() { Family::String => Free::String, Family::Bytes | Family::Buffer => Free::Bytes, @@ -272,6 +301,13 @@ pub fn destroy_symbol(name: &str, current_module: &str, prefix: &str) -> String format!("{prefix}_{module}_{name}_destroy") } +/// The `{prefix}_{module}_{Name}_clone` symbol for a (possibly +/// dot-qualified) interface name referenced from `current_module`. +pub fn clone_symbol(name: &str, current_module: &str, prefix: &str) -> String { + let (module, name) = split_qualified(name, current_module); + format!("{prefix}_{module}_{name}_clone") +} + /// The `iter` pull contract every backend renders. /// /// The producer returns an opaque iterator handle; the consumer then calls @@ -285,8 +321,9 @@ pub fn destroy_symbol(name: &str, current_module: &str, prefix: &str) -> String /// step**. Draining the producer into a hidden list defeats the point of /// `iter` (constant-memory streaming) and is a contract violation. /// 2. **Element ownership.** Each `next` writes an element the consumer now -/// owns; after copying (or decoding) it, the wrapper owes -/// [`elem_free`](Self::elem_free). +/// owns; the wrapper receives it exactly as it would a return of the same +/// type ([`elem`](Self::elem)): copy and free a string or bytes, decode +/// and free a buffer, adopt an object. /// 3. **Handle lifecycle.** `destroy` is called exactly once: eagerly on /// exhaustion, and from the wrapper's disposal idiom (RAII destructor, /// finalizer, `close()`, generator cleanup) when iteration is abandoned @@ -298,19 +335,30 @@ pub fn destroy_symbol(name: &str, current_module: &str, prefix: &str) -> String pub struct IteratorProtocol<'a> { /// The lowered iterator surface: launcher, `next`, and destroy symbols. pub binding: &'a IteratorBinding, - /// The release owed for each element copied out of a `next` slot. - pub elem_free: ElemFree, + /// How each element written by `next` is received. + pub elem: RetPass, + /// The release owed for each element copied out of a `next` slot + /// (`elem.free()`, kept for convenience). + pub elem_free: Free, /// How `out_err` reports from the launcher and each `next` call are /// interpreted. pub error: ErrorStrategy, } impl IteratorBinding { - /// Build the full pull contract for this iterator. - pub fn protocol<'a>(&'a self, f: &FnBinding) -> IteratorProtocol<'a> { + /// Build the full pull contract for this iterator, resolving the element + /// plan against the declaring `module` and `prefix`. + pub fn protocol<'a>( + &'a self, + f: &FnBinding, + module: &str, + prefix: &str, + ) -> IteratorProtocol<'a> { + let elem = ret_pass(Some(&self.elem), module, prefix); IteratorProtocol { binding: self, - elem_free: elem_free(&self.elem), + elem_free: elem.free(), + elem, error: f.error_strategy(), } } @@ -329,11 +377,11 @@ impl IteratorBinding { /// 2. **Owned results.** Everything passed to the callback is owned by the /// consumer. String results are released with `{prefix}_free_string`, /// byte and buffered-value results with `{prefix}_free_bytes`, and -/// interface-object results transfer ownership of the object (the -/// wrapper adopts the pointer). This is what lets runtimes that defer -/// callback bodies past the native return (Dart's -/// `NativeCallable.listener`, for example) decode safely; a wrapper that -/// processes results inline still copies or decodes first and then frees. +/// interface-object results transfer one strong reference (the wrapper +/// adopts the pointer). This is what lets runtimes that defer callback +/// bodies past the native return (Dart's `NativeCallable.listener`, for +/// example) decode safely; a wrapper that processes results inline still +/// copies or decodes first and then frees. /// 3. **Foreign-thread delivery.** The callback runs on a producer thread, /// so the wrapper must hop back to its native scheduler before touching /// consumer state (`call_soon_threadsafe`, a threadsafe function, a @@ -371,21 +419,76 @@ impl AsyncBinding { } } +/// The callback-interface contract every backend renders. +/// +/// A callback interface is the consumer's side of the boundary: the consumer +/// supplies an implementation, the producer calls it. The contract has four +/// clauses: +/// +/// 1. **One static vtable per interface.** The wrapper emits exactly one +/// process-wide vtable value for the interface whose entries are +/// trampolines from the C signature ([`CallbackMethodBinding::abi_params`]) +/// into the native implementation, plus a trailing `free` entry. +/// 2. **Context is a handle-table key.** The wrapper stores the native +/// implementation in a table keyed by an integer or pointer it passes as +/// `ctx`, so the implementation stays alive as long as the producer holds +/// the callback and garbage collectors never see a raw pointer. +/// 3. **Arguments are received like returns.** Strings, bytes, and buffers +/// arriving in a trampoline are borrowed for the call: the wrapper copies +/// or decodes them before returning and frees nothing. Object arguments +/// transfer one strong reference the wrapper adopts. Method returns are +/// direct values written straight into the C return. +/// 4. **Foreign failures trap.** When the native implementation raises, the +/// trampoline calls `{prefix}_error_set(out_err, -4, message)` and returns +/// a default value; it must never let an exception unwind through the C +/// frame. The producer then aborts its call with `FOREIGN_ERROR_CODE`. +/// +/// Trampolines may be invoked from any producer thread; the wrapper is +/// responsible for whatever thread affinity its runtime demands (a GIL +/// acquisition, a JNI attach, a threadsafe-function hop). +/// +/// [`CallbackMethodBinding::abi_params`]: crate::model::CallbackMethodBinding::abi_params +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallbackProtocol<'a> { + /// The lowered callback interface: vtable tag and methods. + pub binding: &'a CallbackInterfaceBinding, + /// How each method's parameters are received inside a trampoline, in + /// method order then parameter order. + pub method_args: Vec>, +} + +impl CallbackInterfaceBinding { + /// Build the full contract for this callback interface, resolving each + /// parameter's receiving plan against the declaring `module` and + /// `prefix`. + pub fn protocol<'a>(&'a self, module: &str, prefix: &str) -> CallbackProtocol<'a> { + CallbackProtocol { + binding: self, + method_args: self + .methods + .iter() + .map(|m| { + m.params + .iter() + .map(|p| ret_pass(Some(&p.ty), module, prefix)) + .collect() + }) + .collect(), + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn arg_pass_classifies_every_family() { - let pb = |name: &str, ty: Ty| ParamBinding::new(name, ty, false, None, "m"); + let pb = |name: &str, ty: Ty| ParamBinding::new(name, ty, None, "m"); assert!(matches!( pb("x", Ty::I32).arg_pass(), ArgPass::Direct { slot } if slot.name == "x" )); - assert!(matches!( - pb("h", Ty::Handle).arg_pass(), - ArgPass::Direct { .. } - )); assert!(matches!( pb("s", Ty::StringUtf8).arg_pass(), ArgPass::String { slot } if slot.name == "s" @@ -417,6 +520,10 @@ mod tests { .arg_pass(), ArgPass::Object { nullable: true, .. } )); + assert!(matches!( + pb("l", Ty::CallbackInterface("Listener".into())).arg_pass(), + ArgPass::Callback { ctx, vtable } if ctx.name == "l_ctx" && vtable.name == "l_vtable" + )); } #[test] @@ -432,6 +539,7 @@ mod tests { Ty::Record("Contact".into()), Ty::RichEnum("Shape".into()), Ty::List(Box::new(Ty::StringUtf8)), + Ty::List(Box::new(Ty::Interface("Store".into()))), Ty::Optional(Box::new(Ty::I64)), ] { assert_eq!( @@ -444,6 +552,7 @@ mod tests { ret_pass(Some(&Ty::Interface("kv.Store".into())), "m", "weaveffi"), RetPass::Object { destroy_symbol: "weaveffi_kv_Store_destroy".into(), + clone_symbol: "weaveffi_kv_Store_clone".into(), nullable: false, } ); @@ -455,6 +564,7 @@ mod tests { ), RetPass::Object { destroy_symbol: "weaveffi_kv_Store_destroy".into(), + clone_symbol: "weaveffi_kv_Store_clone".into(), nullable: true, } ); @@ -470,5 +580,6 @@ mod tests { assert_eq!(elem_free(&Ty::Bytes), Free::Bytes); assert_eq!(elem_free(&Ty::Record("Entry".into())), Free::Bytes); assert_eq!(elem_free(&Ty::Optional(Box::new(Ty::I32))), Free::Bytes); + assert_eq!(elem_free(&Ty::Interface("Store".into())), Free::None); } } diff --git a/crates/weaveffi-core/src/platform.rs b/crates/weaveffi-core/src/platform.rs index b8c4b56a..ca3961b8 100644 --- a/crates/weaveffi-core/src/platform.rs +++ b/crates/weaveffi-core/src/platform.rs @@ -28,6 +28,10 @@ pub enum Os { Linux, /// Windows; shared libraries are `.dll`. Windows, + /// Android (Bionic); shared libraries are `.so` bundled under `jniLibs`. + Android, + /// WebAssembly; the "library" is a standalone `.wasm` module. + Wasm, } /// The CPU architecture of a [`Platform`]. @@ -37,15 +41,22 @@ pub enum Arch { X64, /// 64-bit ARM (`aarch64` / `arm64`). Arm64, + /// 32-bit WebAssembly (`wasm32`). + Wasm32, } /// A single native target platform WeaveFFI can build for and bundle a /// prebuilt library into a published package. /// -/// The v1 matrix is macOS (arm64 and x64), Linux glibc (x64 and arm64), and -/// Windows (x64). Each variant carries a stable [`id`](Self::id) used both as +/// The desktop matrix is macOS (arm64 and x64), Linux glibc (x64 and arm64), +/// and Windows (x64); the mobile and web additions are Android (arm64 and x64) +/// and `wasm32`. Each variant carries a stable [`id`](Self::id) used both as /// the `--platforms` token and as the per-platform subdirectory name in the /// `--binaries` input layout (`//`). +/// +/// Not every ecosystem publishes for every platform: a NuGet package has no +/// Android RID, a wheel has no wasm tag. The ecosystem accessors return +/// `Option` and a packaging backend skips binaries it has no slot for. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Platform { /// macOS on Apple silicon (`aarch64-apple-darwin`). @@ -58,11 +69,31 @@ pub enum Platform { LinuxArm64, /// Windows on x86-64 (`x86_64-pc-windows-msvc`). WindowsX64, + /// Android on ARM64 (`aarch64-linux-android`, jniLibs `arm64-v8a`). + AndroidArm64, + /// Android on x86-64 (`x86_64-linux-android`, jniLibs `x86_64`), the + /// emulator target. + AndroidX64, + /// Bare WebAssembly (`wasm32-unknown-unknown`). + Wasm32, } impl Platform { - /// Every platform in the v1 support matrix, in a stable order. - pub const ALL: [Platform; 5] = [ + /// Every supported platform, in a stable order. + pub const ALL: [Platform; 8] = [ + Platform::MacosArm64, + Platform::MacosX64, + Platform::LinuxX64, + Platform::LinuxArm64, + Platform::WindowsX64, + Platform::AndroidArm64, + Platform::AndroidX64, + Platform::Wasm32, + ]; + + /// The desktop platforms every dynamic-library ecosystem (NuGet, npm, + /// wheels, gems, JVM desktop) can bundle, in a stable order. + pub const DESKTOP: [Platform; 5] = [ Platform::MacosArm64, Platform::MacosX64, Platform::LinuxX64, @@ -72,7 +103,8 @@ impl Platform { /// The stable WeaveFFI platform identifier, used as the `--platforms` token /// and the `--binaries` subdirectory name (`darwin-arm64`, `darwin-x64`, - /// `linux-x64`, `linux-arm64`, `windows-x64`). + /// `linux-x64`, `linux-arm64`, `windows-x64`, `android-arm64`, + /// `android-x64`, `wasm32`). pub fn id(self) -> &'static str { match self { Platform::MacosArm64 => "darwin-arm64", @@ -80,9 +112,17 @@ impl Platform { Platform::LinuxX64 => "linux-x64", Platform::LinuxArm64 => "linux-arm64", Platform::WindowsX64 => "windows-x64", + Platform::AndroidArm64 => "android-arm64", + Platform::AndroidX64 => "android-x64", + Platform::Wasm32 => "wasm32", } } + /// Whether this is one of the [`DESKTOP`](Self::DESKTOP) platforms. + pub fn is_desktop(self) -> bool { + Platform::DESKTOP.contains(&self) + } + /// Parse a [`Platform`] from its [`id`](Self::id), returning `None` for an /// unrecognized token. pub fn from_id(s: &str) -> Option { @@ -95,14 +135,20 @@ impl Platform { Platform::MacosArm64 | Platform::MacosX64 => Os::MacOs, Platform::LinuxX64 | Platform::LinuxArm64 => Os::Linux, Platform::WindowsX64 => Os::Windows, + Platform::AndroidArm64 | Platform::AndroidX64 => Os::Android, + Platform::Wasm32 => Os::Wasm, } } /// The CPU architecture. pub fn arch(self) -> Arch { match self { - Platform::MacosArm64 | Platform::LinuxArm64 => Arch::Arm64, - Platform::MacosX64 | Platform::LinuxX64 | Platform::WindowsX64 => Arch::X64, + Platform::MacosArm64 | Platform::LinuxArm64 | Platform::AndroidArm64 => Arch::Arm64, + Platform::MacosX64 + | Platform::LinuxX64 + | Platform::WindowsX64 + | Platform::AndroidX64 => Arch::X64, + Platform::Wasm32 => Arch::Wasm32, } } @@ -115,6 +161,9 @@ impl Platform { Platform::LinuxX64 => "x86_64-unknown-linux-gnu", Platform::LinuxArm64 => "aarch64-unknown-linux-gnu", Platform::WindowsX64 => "x86_64-pc-windows-msvc", + Platform::AndroidArm64 => "aarch64-linux-android", + Platform::AndroidX64 => "x86_64-linux-android", + Platform::Wasm32 => "wasm32-unknown-unknown", } } @@ -126,21 +175,33 @@ impl Platform { .find(|p| p.rust_target() == triple) } - /// The shared-library filename prefix: `"lib"` on Unix, empty on Windows. + /// The shared-library filename prefix: `"lib"` on Unix-like targets + /// (including Android), empty on Windows and for a `.wasm` module. pub fn lib_prefix(self) -> &'static str { match self.os() { - Os::MacOs | Os::Linux => "lib", - Os::Windows => "", + Os::MacOs | Os::Linux | Os::Android => "lib", + Os::Windows | Os::Wasm => "", } } /// The shared-library filename extension (without the dot): `dylib`, `so`, - /// or `dll`. + /// `dll`, or `wasm`. pub fn lib_extension(self) -> &'static str { match self.os() { Os::MacOs => "dylib", - Os::Linux => "so", + Os::Linux | Os::Android => "so", Os::Windows => "dll", + Os::Wasm => "wasm", + } + } + + /// The Android `jniLibs//` directory name (`arm64-v8a`, `x86_64`), + /// or `None` off Android. + pub fn android_abi(self) -> Option<&'static str> { + match self { + Platform::AndroidArm64 => Some("arm64-v8a"), + Platform::AndroidX64 => Some("x86_64"), + _ => None, } } @@ -153,55 +214,65 @@ impl Platform { } /// The NuGet runtime identifier (RID) for the `runtimes//native/` - /// layout: `osx-arm64`, `osx-x64`, `linux-x64`, `linux-arm64`, `win-x64`. - pub fn nuget_rid(self) -> &'static str { + /// layout (`osx-arm64`, `osx-x64`, `linux-x64`, `linux-arm64`, `win-x64`), + /// or `None` for a platform NuGet has no RID for. + pub fn nuget_rid(self) -> Option<&'static str> { match self { - Platform::MacosArm64 => "osx-arm64", - Platform::MacosX64 => "osx-x64", - Platform::LinuxX64 => "linux-x64", - Platform::LinuxArm64 => "linux-arm64", - Platform::WindowsX64 => "win-x64", + Platform::MacosArm64 => Some("osx-arm64"), + Platform::MacosX64 => Some("osx-x64"), + Platform::LinuxX64 => Some("linux-x64"), + Platform::LinuxArm64 => Some("linux-arm64"), + Platform::WindowsX64 => Some("win-x64"), + Platform::AndroidArm64 | Platform::AndroidX64 | Platform::Wasm32 => None, } } - /// The Node.js `process.platform` value (`darwin`, `linux`, `win32`). - pub fn node_os(self) -> &'static str { + /// The Node.js `process.platform` value (`darwin`, `linux`, `win32`), or + /// `None` where Node does not run. + pub fn node_os(self) -> Option<&'static str> { match self.os() { - Os::MacOs => "darwin", - Os::Linux => "linux", - Os::Windows => "win32", + Os::MacOs => Some("darwin"), + Os::Linux => Some("linux"), + Os::Windows => Some("win32"), + Os::Android | Os::Wasm => None, } } - /// The Node.js `process.arch` value (`arm64`, `x64`). - pub fn node_cpu(self) -> &'static str { + /// The Node.js `process.arch` value (`arm64`, `x64`), or `None` for an + /// architecture Node has no token for. + pub fn node_cpu(self) -> Option<&'static str> { match self.arch() { - Arch::Arm64 => "arm64", - Arch::X64 => "x64", + Arch::Arm64 => Some("arm64"), + Arch::X64 => Some("x64"), + Arch::Wasm32 => None, } } /// The Python wheel platform tag (the final segment of a wheel filename), - /// for example `macosx_11_0_arm64` or `manylinux2014_x86_64`. - pub fn python_platform_tag(self) -> &'static str { + /// for example `macosx_11_0_arm64` or `manylinux2014_x86_64`, or `None` + /// for a platform wheels do not target. + pub fn python_platform_tag(self) -> Option<&'static str> { match self { - Platform::MacosArm64 => "macosx_11_0_arm64", - Platform::MacosX64 => "macosx_10_12_x86_64", - Platform::LinuxX64 => "manylinux2014_x86_64", - Platform::LinuxArm64 => "manylinux2014_aarch64", - Platform::WindowsX64 => "win_amd64", + Platform::MacosArm64 => Some("macosx_11_0_arm64"), + Platform::MacosX64 => Some("macosx_10_12_x86_64"), + Platform::LinuxX64 => Some("manylinux2014_x86_64"), + Platform::LinuxArm64 => Some("manylinux2014_aarch64"), + Platform::WindowsX64 => Some("win_amd64"), + Platform::AndroidArm64 | Platform::AndroidX64 | Platform::Wasm32 => None, } } /// The RubyGems platform string used for a precompiled platform gem, for - /// example `arm64-darwin` or `x86_64-linux`. - pub fn ruby_platform(self) -> &'static str { + /// example `arm64-darwin` or `x86_64-linux`, or `None` for a platform gems + /// do not target. + pub fn ruby_platform(self) -> Option<&'static str> { match self { - Platform::MacosArm64 => "arm64-darwin", - Platform::MacosX64 => "x86_64-darwin", - Platform::LinuxX64 => "x86_64-linux", - Platform::LinuxArm64 => "aarch64-linux", - Platform::WindowsX64 => "x64-mingw-ucrt", + Platform::MacosArm64 => Some("arm64-darwin"), + Platform::MacosX64 => Some("x86_64-darwin"), + Platform::LinuxX64 => Some("x86_64-linux"), + Platform::LinuxArm64 => Some("aarch64-linux"), + Platform::WindowsX64 => Some("x64-mingw-ucrt"), + Platform::AndroidArm64 | Platform::AndroidX64 | Platform::Wasm32 => None, } } @@ -214,6 +285,9 @@ impl Platform { Platform::LinuxX64 => "Linux x64", Platform::LinuxArm64 => "Linux arm64", Platform::WindowsX64 => "Windows x64", + Platform::AndroidArm64 => "Android arm64", + Platform::AndroidX64 => "Android x64", + Platform::Wasm32 => "WebAssembly (wasm32)", } } } @@ -319,21 +393,40 @@ mod tests { Platform::WindowsX64.lib_filename("contacts"), "contacts.dll" ); + assert_eq!( + Platform::AndroidArm64.lib_filename("contacts"), + "libcontacts.so" + ); + assert_eq!(Platform::Wasm32.lib_filename("contacts"), "contacts.wasm"); } #[test] fn ecosystem_identifiers() { - assert_eq!(Platform::MacosArm64.nuget_rid(), "osx-arm64"); - assert_eq!(Platform::WindowsX64.nuget_rid(), "win-x64"); - assert_eq!(Platform::MacosX64.node_os(), "darwin"); - assert_eq!(Platform::MacosX64.node_cpu(), "x64"); - assert_eq!(Platform::WindowsX64.node_os(), "win32"); + assert_eq!(Platform::MacosArm64.nuget_rid(), Some("osx-arm64")); + assert_eq!(Platform::WindowsX64.nuget_rid(), Some("win-x64")); + assert_eq!(Platform::MacosX64.node_os(), Some("darwin")); + assert_eq!(Platform::MacosX64.node_cpu(), Some("x64")); + assert_eq!(Platform::WindowsX64.node_os(), Some("win32")); assert_eq!( Platform::LinuxArm64.python_platform_tag(), - "manylinux2014_aarch64" + Some("manylinux2014_aarch64") ); - assert_eq!(Platform::MacosArm64.ruby_platform(), "arm64-darwin"); - assert_eq!(Platform::LinuxX64.ruby_platform(), "x86_64-linux"); + assert_eq!(Platform::MacosArm64.ruby_platform(), Some("arm64-darwin")); + assert_eq!(Platform::LinuxX64.ruby_platform(), Some("x86_64-linux")); + assert_eq!(Platform::AndroidArm64.android_abi(), Some("arm64-v8a")); + assert_eq!(Platform::AndroidX64.android_abi(), Some("x86_64")); + assert_eq!(Platform::MacosArm64.android_abi(), None); + for p in [ + Platform::AndroidArm64, + Platform::AndroidX64, + Platform::Wasm32, + ] { + assert!(!p.is_desktop()); + assert_eq!(p.nuget_rid(), None); + assert_eq!(p.python_platform_tag(), None); + assert_eq!(p.ruby_platform(), None); + } + assert!(Platform::DESKTOP.iter().all(|p| p.is_desktop())); } #[test] diff --git a/crates/weaveffi-core/src/resolved.rs b/crates/weaveffi-core/src/resolved.rs index 51c4dbe6..ff020117 100644 --- a/crates/weaveffi-core/src/resolved.rs +++ b/crates/weaveffi-core/src/resolved.rs @@ -34,6 +34,8 @@ pub enum TypeKind { RichEnum, /// An `interfaces:` entry. Interface, + /// A `callback_interfaces:` entry. + CallbackInterface, } /// Where a type is declared: the owning module's dot-joined path and its kind. @@ -121,7 +123,7 @@ impl ResolvedApi { /// declared in a *different* module is qualified with the owner's /// dot-joined path (`shared.Status`), so the ABI lowering emits the /// owner's symbol prefix; a reference to a type in the same module stays - /// bare. Typed handles are qualified the same way. + /// bare. pub fn resolve(&self, ty: &TypeRef, module_path: &str) -> Ty { match ty { TypeRef::I8 => Ty::I8, @@ -137,16 +139,13 @@ impl ResolvedApi { TypeRef::Bool => Ty::Bool, TypeRef::StringUtf8 => Ty::StringUtf8, TypeRef::Bytes => Ty::Bytes, - TypeRef::BorrowedStr => Ty::BorrowedStr, - TypeRef::BorrowedBytes => Ty::BorrowedBytes, - TypeRef::Handle => Ty::Handle, - TypeRef::TypedHandle(name) => Ty::TypedHandle(self.qualify(name, module_path)), TypeRef::Named(name) => { let qualified = self.qualify(name, module_path); match self.types.get(bare(name)).map(|d| d.kind) { Some(TypeKind::Enum) => Ty::Enum(qualified), Some(TypeKind::RichEnum) => Ty::RichEnum(qualified), Some(TypeKind::Interface) => Ty::Interface(qualified), + Some(TypeKind::CallbackInterface) => Ty::CallbackInterface(qualified), Some(TypeKind::Record) | None => Ty::Record(qualified), } } @@ -178,9 +177,10 @@ fn bare(name: &str) -> &str { name.rsplit('.').next().unwrap_or(name) } -/// Recursively index every struct/enum/interface in the module tree by bare -/// name. Bare names are globally unique (validation enforces it), so the map -/// has one entry per name; the first declaration wins otherwise. +/// Recursively index every struct, enum, interface, and callback interface in +/// the module tree by bare name. Bare names are globally unique (validation +/// enforces it), so the map has one entry per name; the first declaration +/// wins otherwise. fn index_module(module: &Module, parent: &str, out: &mut BTreeMap) { let path = if parent.is_empty() { module.name.clone() @@ -207,6 +207,9 @@ fn index_module(module: &Module, parent: &str, out: &mut BTreeMap for ResolvedApi { mod tests { use super::*; use weaveffi_ir::ir::{ - EnumDef, EnumVariant, InterfaceDef, StructDef, StructField, CURRENT_SCHEMA_VERSION, + CallbackInterfaceDef, EnumDef, EnumVariant, Function, InterfaceDef, StructDef, StructField, + CURRENT_SCHEMA_VERSION, }; fn module(name: &str) -> Module { @@ -239,10 +243,9 @@ mod tests { doc: None, functions: vec![], interfaces: vec![], + callback_interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], errors: None, modules: vec![], } @@ -293,6 +296,21 @@ mod tests { methods: vec![], statics: vec![], }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Watcher".into(), + doc: None, + deprecated: None, + methods: vec![Function { + name: "on_change".into(), + params: vec![], + returns: None, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + }], + }], ..module("inner") }], ..module("shared") @@ -332,8 +350,12 @@ mod tests { Ty::Record("shared.Point".into()) ); assert_eq!( - api.resolve(&TypeRef::TypedHandle("Store".into()), "orders"), - Ty::TypedHandle("shared.inner.Store".into()) + api.resolve(&TypeRef::Named("Watcher".into()), "orders"), + Ty::CallbackInterface("shared.inner.Watcher".into()) + ); + assert_eq!( + api.declaration("Watcher").unwrap().kind, + TypeKind::CallbackInterface ); } diff --git a/crates/weaveffi-core/src/utils.rs b/crates/weaveffi-core/src/utils.rs index 30f6415f..edd122ca 100644 --- a/crates/weaveffi-core/src/utils.rs +++ b/crates/weaveffi-core/src/utils.rs @@ -71,8 +71,8 @@ pub fn render_json_prelude(input_basename: &str) -> String { } /// Runtime symbols (functions and types) that consumer code links against from -/// the `weaveffi-abi` runtime: the `weaveffi_error`/`weaveffi_handle_t`/ -/// `weaveffi_cancel_token` types and every `#[no_mangle]` entry point that +/// the `weaveffi-abi` runtime: the `weaveffi_error` and `weaveffi_cancel_token` +/// types and every `#[no_mangle]` entry point that /// `weaveffi_abi::export_runtime!` emits into the consumer cdylib. /// /// Generators that emit C/C++ headers use this list to produce @@ -83,20 +83,16 @@ pub fn render_json_prelude(input_basename: &str) -> String { /// /// This list must stay in lockstep with `export_runtime!`: every entry has to /// be a real exported C symbol (or a public C type), otherwise the generated -/// `#define` would alias a name that does not exist. In particular `error_set` -/// is intentionally absent: it is a Rust-only `pub fn` taking `&str`, never a -/// C ABI symbol. +/// `#define` would alias a name that does not exist. `alloc`/`dealloc` are +/// absent because they're exported by wasm32 producers only. pub const ABI_RUNTIME_SYMBOLS: &[&str] = &[ "error", - "handle_t", "abi_version", + "error_set", "error_clear", "error_free", "free_string", "free_bytes", - "arena_create", - "arena_destroy", - "arena_register", "cancel_token", "cancel_token_create", "cancel_token_cancel", diff --git a/crates/weaveffi-core/src/validate.rs b/crates/weaveffi-core/src/validate.rs index 7e2e43d5..15b1a97e 100644 --- a/crates/weaveffi-core/src/validate.rs +++ b/crates/weaveffi-core/src/validate.rs @@ -98,12 +98,12 @@ pub enum ValidationError { /// Conflicting numeric error code. code: i32, }, - /// An error code uses a reserved value: `0` means success and `-2` is the - /// panic code the runtime reports when a producer panics. + /// An error code uses a reserved value: `0` means success and the + /// negative range belongs to the runtime's trap codes. #[error("invalid error code in module '{module}' for '{name}': must be a positive integer")] #[diagnostic(help( "0 means success and negative codes are reserved for the runtime (-1 generic, \ - -2 panic, -3 marshalling failure); use a positive integer" + -2 panic, -3 marshalling failure, -4 foreign callback error); use a positive integer" ))] InvalidErrorCode { /// Module that contains the error domain. @@ -152,7 +152,8 @@ pub enum ValidationError { /// Function marked `throws` with no domain in scope. function: String, }, - /// Two types (structs, enums, or interfaces) share a bare name. + /// Two types (structs, enums, interfaces, or callback interfaces) share a + /// bare name. /// /// Type names must be unique across the whole API: generators emit flat /// per-language type names, and unqualified cross-module references @@ -160,8 +161,8 @@ pub enum ValidationError { /// generated code and make references ambiguous. #[error("duplicate type name '{name}' (declared in '{first}' and '{second}')")] #[diagnostic(help( - "struct, enum, interface, and error domain names must be unique across the whole \ - API; rename one of the declarations" + "struct, enum, interface, callback interface, and error domain names must be unique \ + across the whole API; rename one of the declarations" ))] DuplicateTypeName { /// The colliding bare type name. @@ -326,12 +327,11 @@ pub enum ValidationError { /// The offending constructor. constructor: String, }, - /// An interface reference appears in a position the ABI cannot support. + /// An interface reference appears as a map key. #[error("interface type '{name}' is not valid in {location}")] #[diagnostic(help( - "interface objects may appear as function parameters, return types, and \ - optionals of those; they cannot be struct fields, collection elements, \ - map keys/values, or callback parameters" + "interface objects may appear anywhere except as map keys; key a map by a scalar, \ + string, or C-style enum instead" ))] InterfaceInInvalidPosition { /// The referenced interface name. @@ -339,106 +339,94 @@ pub enum ValidationError { /// Position where the interface reference appeared. location: String, }, - /// A type reference names a struct, enum, or interface that doesn't exist. + /// A type reference names a struct, enum, interface, or callback interface + /// that doesn't exist. #[error("unknown type reference: {name}")] - #[diagnostic(help("define a struct, enum, or interface with this name, or check for typos"))] + #[diagnostic(help( + "define a struct, enum, interface, or callback interface with this name, or check \ + for typos" + ))] UnknownTypeRef { /// Unresolved type name. name: String, }, /// A map uses a key type the C ABI can't represent. - #[error("invalid map key type: {key_type}; only primitive types and strings are allowed as map keys")] - #[diagnostic(help("map keys must be primitive types (i32, u32, i64, f64, bool, string); structs, lists, and maps cannot be keys"))] + #[error("invalid map key type: {key_type}; only scalars, strings, and C-style enums are allowed as map keys")] + #[diagnostic(help("map keys must be integers (i8 through u64), f32, f64, bool, string, or a C-style enum; structs, rich enums, interfaces, optionals, lists, and maps cannot be keys"))] InvalidMapKey { /// Rejected key type, rendered as it appears in the IDL. key_type: String, }, - /// A borrowed type appears somewhere other than a function parameter. - #[error( - "borrowed type '{ty}' is not valid in {location}; only function parameters are allowed" - )] - #[diagnostic(help("borrowed types (&str, &[u8]) can only be used as function parameters, not return types or struct fields"))] - BorrowedTypeInInvalidPosition { - /// Borrowed type that was rejected. - ty: String, - /// Position where the borrowed type appeared. + /// An iterator type appears somewhere other than a function return. + #[error("iterator type is only valid as a function return type, found in {location}")] + #[diagnostic(help("iterator types can only be used as function return types, not as parameters or struct fields"))] + IteratorInInvalidPosition { + /// Position where the iterator type appeared. location: String, }, - /// Two callbacks in the same module share a name. - #[error("duplicate callback name in module '{module}': {name}")] - #[diagnostic(help("callback names must be unique within a module; rename the duplicate"))] - DuplicateCallbackName { - /// Module that contains the callbacks. + /// Two callback interfaces in the same module share a name. + #[error("duplicate callback interface name in module '{module}': {name}")] + #[diagnostic(help( + "callback interface names must be unique within a module; rename the duplicate" + ))] + DuplicateCallbackInterfaceName { + /// Module that contains the callback interfaces. module: String, - /// Duplicated callback name. + /// Duplicated callback interface name. name: String, }, - /// A listener references a callback that isn't defined in its module. - #[error( - "listener '{listener}' in module '{module}' references undefined callback '{callback}'" - )] + /// A callback interface declares no methods. + #[error("empty callback interface in module '{module}': {name}")] #[diagnostic(help( - "listener event_callback must reference a callback defined in the same module" + "callback interfaces must declare at least one method; add a method or remove the \ + callback interface" ))] - ListenerCallbackNotFound { - /// Module that contains the listener. + EmptyCallbackInterface { + /// Module that contains the callback interface. module: String, - /// Listener with the dangling reference. - listener: String, - /// Callback name that could not be resolved. - callback: String, - }, - /// Two listeners in the same module share a name. - #[error("duplicate listener name in module '{module}': {name}")] - #[diagnostic(help("listener names must be unique within a module; rename the duplicate"))] - DuplicateListenerName { - /// Module that contains the listeners. - module: String, - /// Duplicated listener name. + /// Name of the empty callback interface. name: String, }, - /// A callback parameter uses a type that can't cross the callback ABI. - #[error( - "callback '{callback}' in module '{module}' has parameter '{param}' with unsupported \ - type '{ty}'" - )] + /// Two methods of one callback interface share a name. + #[error("duplicate method name in callback interface '{interface}': {name}")] #[diagnostic(help( - "callback parameters are limited to scalars, bool, enums, string, bytes, handles, \ - structs, optionals of those, lists of scalars/strings, and maps of scalars/strings; \ - every target must be able to marshal a callback argument without an FFI round-trip" + "method names must be unique within a callback interface; rename the duplicate" ))] - UnsupportedCallbackParamType { - /// Module that contains the callback. - module: String, - /// Callback that declares the parameter. - callback: String, - /// Parameter with the unsupported type. - param: String, - /// Offending type, rendered as it appears in the IDL. - ty: String, - }, - /// An iterator type appears somewhere other than a function return. - #[error("iterator type is only valid as a function return type, found in {location}")] - #[diagnostic(help("iterator types can only be used as function return types, not as parameters or struct fields"))] - IteratorInInvalidPosition { - /// Position where the iterator type appeared. - location: String, + DuplicateCallbackMethod { + /// Callback interface that contains the colliding methods. + interface: String, + /// Duplicated method name. + name: String, }, - /// A parameter declares `mutable: true` with a type that has no - /// write-back lowering. - #[error("parameter '{param}' of '{function}' cannot be mutable: type '{ty}' has no write-back lowering")] + /// A callback-interface method uses a flag or return type the callback + /// ABI can't carry. The `reason` names the offending feature. + #[error("method '{method}' of callback interface '{interface}' {reason}")] #[diagnostic(help( - "only string and bytes parameters support mutable: true; buffered types (records, \ - rich enums, optionals, lists, maps) cross the ABI as borrowed serialized buffers \ - and cannot be written back" + "callback-interface methods are synchronous, never throw, and return either nothing \ + or a direct value (a scalar, bool, or C-style enum): the consumer implements them, \ + so no producer-owned allocation or error payload can flow back" ))] - MutableParamUnsupported { - /// Callable that declares the parameter. - function: String, - /// Parameter marked mutable. - param: String, - /// The parameter's type, rendered as it appears in the IDL. - ty: String, + InvalidCallbackMethod { + /// Callback interface that declares the method. + interface: String, + /// The offending method. + method: String, + /// Why the method was rejected. + reason: &'static str, + }, + /// A callback-interface type appears in a position other than a callable + /// parameter. + #[error("callback interface '{name}' is not valid in {location}")] + #[diagnostic(help( + "a callback interface is a consumer-implemented vtable: it may be passed as a \ + parameter of a function, constructor, static, or method, but it can't be returned, \ + nested inside another type, or passed to another callback-interface method" + ))] + CallbackInterfaceInInvalidPosition { + /// The referenced callback interface name. + name: String, + /// Position where the reference appeared. + location: String, }, /// An async function tries to return an iterator, which has no async ABI. #[error("async function '{module}::{function}' cannot return an iterator")] diff --git a/crates/weaveffi-core/src/validate/diagnostic.rs b/crates/weaveffi-core/src/validate/diagnostic.rs index 16f9655b..a0559826 100644 --- a/crates/weaveffi-core/src/validate/diagnostic.rs +++ b/crates/weaveffi-core/src/validate/diagnostic.rs @@ -106,11 +106,11 @@ fn find_offending_span(err: &ValidationError, src: &str) -> Option { ValidationError::EmptyEnum { name, .. } => Some(name.as_str()), ValidationError::DuplicateEnumVariant { variant, .. } => Some(variant.as_str()), ValidationError::UnknownTypeRef { name } => Some(name.as_str()), - ValidationError::DuplicateCallbackName { name, .. } => Some(name.as_str()), - ValidationError::UnsupportedCallbackParamType { param, .. } => Some(param.as_str()), - ValidationError::ListenerCallbackNotFound { callback, .. } => Some(callback.as_str()), - ValidationError::DuplicateListenerName { name, .. } => Some(name.as_str()), - ValidationError::MutableParamUnsupported { param, .. } => Some(param.as_str()), + ValidationError::DuplicateCallbackInterfaceName { name, .. } => Some(name.as_str()), + ValidationError::EmptyCallbackInterface { name, .. } => Some(name.as_str()), + ValidationError::DuplicateCallbackMethod { name, .. } => Some(name.as_str()), + ValidationError::InvalidCallbackMethod { method, .. } => Some(method.as_str()), + ValidationError::CallbackInterfaceInInvalidPosition { name, .. } => Some(name.as_str()), ValidationError::UnsupportedSchemaVersion { version, .. } => Some(version.as_str()), ValidationError::AsyncIteratorReturn { function, .. } => Some(function.as_str()), ValidationError::DuplicateInterfaceName { name, .. } => Some(name.as_str()), diff --git a/crates/weaveffi-core/src/validate/rules.rs b/crates/weaveffi-core/src/validate/rules.rs index 2c1963c8..eec74d16 100644 --- a/crates/weaveffi-core/src/validate/rules.rs +++ b/crates/weaveffi-core/src/validate/rules.rs @@ -1,6 +1,6 @@ //! The validation rules: per-module name/uniqueness checks, type-reference -//! existence, ABI-representability of element shapes, callback parameter -//! marshalability, interface shape rules, and error-domain consistency. +//! existence, ABI-representability of element shapes, callback-interface +//! shape rules, interface positions, and error-domain consistency. //! //! Every rule pushes into a shared `Vec` sink instead of //! returning early, so one validation pass reports every violation in the @@ -8,7 +8,9 @@ use super::ValidationError; use std::collections::{BTreeMap, BTreeSet}; -use weaveffi_ir::ir::{ErrorDomain, Function, InterfaceDef, Module, Param, StructField, TypeRef}; +use weaveffi_ir::ir::{ + CallbackInterfaceDef, ErrorDomain, Function, InterfaceDef, Module, Param, StructField, TypeRef, +}; const RESERVED: &[&str] = &[ "if", "else", "for", "while", "loop", "match", "type", "return", "async", "await", "break", @@ -45,10 +47,13 @@ fn check_identifier(name: &str, errors: &mut Vec) -> bool { /// positional rules consult this index to learn what a name is. #[derive(Default)] pub(super) struct TypeIndex { - /// Bare names of every struct, enum, and interface anywhere in the API. + /// Bare names of every struct, enum, interface, and callback interface + /// anywhere in the API. pub all: BTreeSet, /// Bare names of every interface anywhere in the API. pub interfaces: BTreeSet, + /// Bare names of every callback interface anywhere in the API. + pub callback_interfaces: BTreeSet, /// Bare names of every C-style (payload-free) enum anywhere in the API. pub plain_enums: BTreeSet, } @@ -75,6 +80,10 @@ impl TypeIndex { self.all.insert(i.name.clone()); self.interfaces.insert(i.name.clone()); } + for cb in &m.callback_interfaces { + self.all.insert(cb.name.clone()); + self.callback_interfaces.insert(cb.name.clone()); + } self.walk(&m.modules); } } @@ -84,15 +93,20 @@ impl TypeIndex { self.interfaces.contains(bare(name)) } - /// Does a struct, enum, or interface with this (bare or dot-qualified) - /// name exist anywhere in the API? + /// Is `name` (bare or dot-qualified) a callback interface? + fn is_callback_interface(&self, name: &str) -> bool { + self.callback_interfaces.contains(bare(name)) + } + + /// Does a declaration with this (bare or dot-qualified) name exist + /// anywhere in the API? fn exists(&self, name: &str) -> bool { self.all.contains(bare(name)) } /// May `ty` be a map key? Every target must be able to use the key in /// its native dictionary idiom, so only scalars, bools, strings, and - /// C-style enums qualify; composites, optionals, bytes, and handles are + /// C-style enums qualify; composites, optionals, bytes, and objects are /// rejected. fn is_map_key(&self, ty: &TypeRef) -> bool { match ty { @@ -107,8 +121,27 @@ impl TypeIndex { | TypeRef::F32 | TypeRef::F64 | TypeRef::Bool - | TypeRef::StringUtf8 - | TypeRef::BorrowedStr => true, + | TypeRef::StringUtf8 => true, + TypeRef::Named(name) => self.plain_enums.contains(bare(name)), + _ => false, + } + } + + /// May `ty` be returned from a callback-interface method? Only `void` + /// and the direct family: scalars, bool, and C-style enums. + fn is_direct(&self, ty: &TypeRef) -> bool { + match ty { + TypeRef::I8 + | TypeRef::I16 + | TypeRef::I32 + | TypeRef::U8 + | TypeRef::U16 + | TypeRef::U32 + | TypeRef::I64 + | TypeRef::U64 + | TypeRef::F32 + | TypeRef::F64 + | TypeRef::Bool => true, TypeRef::Named(name) => self.plain_enums.contains(bare(name)), _ => false, } @@ -121,8 +154,8 @@ fn bare(name: &str) -> &str { } /// Enforce global bare-name uniqueness for the type namespace: structs, -/// enums, interfaces, and error domains across every module (including -/// nested submodules). +/// enums, interfaces, callback interfaces, and error domains across every +/// module (including nested submodules). /// /// Generators emit flat per-language type names, and unqualified cross-module /// references resolve by bare name, so two types sharing a name would collide @@ -146,6 +179,7 @@ pub(super) fn check_global_type_names(modules: &[Module], errors: &mut Vec = BTreeSet::new(); let mut claim_symbol = |suffix: String, errors: &mut Vec| { if !symbol_suffixes.insert(suffix.clone()) { @@ -334,28 +368,36 @@ pub(super) fn validate_module( name: i.name.clone(), }); } + claim_symbol(format!("{}_clone", i.name), errors); claim_symbol(format!("{}_destroy", i.name), errors); validate_interface(module, i, has_domain, &mut claim_symbol, errors); } + let mut callback_names = BTreeSet::new(); + for cb in &module.callback_interfaces { + check_identifier(&cb.name, errors); + if !callback_names.insert(cb.name.clone()) { + errors.push(ValidationError::DuplicateCallbackInterfaceName { + module: module.name.clone(), + name: cb.name.clone(), + }); + } + validate_callback_interface(module, cb, types, errors); + } + // A field of a record, a rich-enum variant, or an error payload is // serialized inside a value buffer, so it obeys the buffered positional - // rules: no borrowed views, no iterators, no interfaces, and every - // reference must resolve. + // rules: no iterators, no callback interfaces, no interface map keys, and + // every reference must resolve. let mut check_buffered_field = |f: &StructField, location: &dyn Fn() -> String| { - if let Some(ty) = contains_borrowed(&f.ty) { - errors.push(ValidationError::BorrowedTypeInInvalidPosition { - ty: ty.to_string(), - location: location(), - }); - } if contains_iterator(&f.ty) { errors.push(ValidationError::IteratorInInvalidPosition { location: location(), }); } validate_type_ref(&f.ty, types, errors); - check_interface_positions(&f.ty, types, false, location, errors); + check_interface_positions(&f.ty, types, location, errors); + check_no_callback_interface(&f.ty, types, location, errors); }; for s in &module.structs { for f in &s.fields { @@ -394,52 +436,6 @@ pub(super) fn validate_module( } } - let mut callback_names = BTreeSet::new(); - for cb in &module.callbacks { - check_identifier(&cb.name, errors); - if !callback_names.insert(cb.name.clone()) { - errors.push(ValidationError::DuplicateCallbackName { - module: module.name.clone(), - name: cb.name.clone(), - }); - } - for p in &cb.params { - validate_param(p, errors); - validate_type_ref(&p.ty, types, errors); - if references_interface(&p.ty, types) { - errors.push(ValidationError::InterfaceInInvalidPosition { - name: user_name_in(&p.ty), - location: format!("param '{}' of callback '{}'", p.name, cb.name), - }); - } else if !callback_param_type_supported(&p.ty) { - errors.push(ValidationError::UnsupportedCallbackParamType { - module: module.name.clone(), - callback: cb.name.clone(), - param: p.name.clone(), - ty: p.ty.to_string(), - }); - } - } - } - - let mut listener_names = BTreeSet::new(); - for l in &module.listeners { - check_identifier(&l.name, errors); - if !listener_names.insert(l.name.clone()) { - errors.push(ValidationError::DuplicateListenerName { - module: module.name.clone(), - name: l.name.clone(), - }); - } - if !callback_names.contains(&l.event_callback) { - errors.push(ValidationError::ListenerCallbackNotFound { - module: module.name.clone(), - listener: l.name.clone(), - callback: l.event_callback.clone(), - }); - } - } - if let Some(domain) = &module.errors { validate_error_domain(module, domain, &function_names, errors); } @@ -504,6 +500,85 @@ fn validate_interface( } } +/// Validate a callback interface: at least one method, unique method names, +/// and per-method restrictions. A callback method is implemented by the +/// consumer, so it is synchronous, never throws, never takes a cancel token, +/// returns nothing or a direct value, and takes no callback interface or +/// iterator as a parameter. +fn validate_callback_interface( + module: &Module, + cb: &CallbackInterfaceDef, + types: &TypeIndex, + errors: &mut Vec, +) { + if cb.methods.is_empty() { + errors.push(ValidationError::EmptyCallbackInterface { + module: module.name.clone(), + name: cb.name.clone(), + }); + } + let mut method_names = BTreeSet::new(); + for m in &cb.methods { + check_identifier(&m.name, errors); + if !method_names.insert(m.name.clone()) { + errors.push(ValidationError::DuplicateCallbackMethod { + interface: cb.name.clone(), + name: m.name.clone(), + }); + } + let reject = |reason: &'static str, errors: &mut Vec| { + errors.push(ValidationError::InvalidCallbackMethod { + interface: cb.name.clone(), + method: m.name.clone(), + reason, + }); + }; + if m.r#async { + reject("cannot be async", errors); + } + if m.throws { + reject("cannot declare throws", errors); + } + if m.cancellable { + reject("cannot be cancellable", errors); + } + if let Some(ret) = &m.returns { + validate_type_ref(ret, types, errors); + if !types.is_direct(ret) { + reject( + "must return nothing or a direct value (scalar, bool, or C-style enum)", + errors, + ); + } + } + let mut param_names = BTreeSet::new(); + for p in &m.params { + validate_param(p, errors); + if !param_names.insert(p.name.clone()) { + errors.push(ValidationError::DuplicateParamName { + module: module.name.clone(), + function: format!("{}.{}", cb.name, m.name), + param: p.name.clone(), + }); + } + let location = || { + format!( + "param '{}' of callback interface method '{}.{}'", + p.name, cb.name, m.name + ) + }; + if contains_iterator(&p.ty) { + errors.push(ValidationError::IteratorInInvalidPosition { + location: location(), + }); + } + validate_type_ref(&p.ty, types, errors); + check_interface_positions(&p.ty, types, location, errors); + check_no_callback_interface(&p.ty, types, location, errors); + } + } +} + /// Name-level checks for one callable: a valid identifier, unique parameter /// names, and an error domain in scope when the callable declares `throws`. fn validate_function( @@ -535,9 +610,9 @@ fn validate_function( } } -/// Type-level checks for one callable's parameters and return: iterator and -/// borrowed positions, async-iterator exclusion, reference existence, element -/// shapes, and interface positions. +/// Type-level checks for one callable's parameters and return: iterator +/// positions, async-iterator exclusion, reference existence, element shapes, +/// interface map keys, and callback-interface positions. fn validate_callable_types( module_name: &str, display_name: &str, @@ -557,46 +632,17 @@ fn validate_callable_types( location: location(), }); } - // A borrowed view is valid only as the parameter's own type; inside a - // composite it would end up serialized in a value buffer, which - // cannot hold a borrow. - if !matches!(p.ty, TypeRef::BorrowedStr | TypeRef::BorrowedBytes) { - if let Some(ty) = contains_borrowed(&p.ty) { - errors.push(ValidationError::BorrowedTypeInInvalidPosition { - ty: ty.to_string(), - location: location(), - }); - } - } - // `mutable: true` needs a write-back lowering, which only the string - // and bytes pointer shapes have. Buffered types are borrowed - // serialized copies; mutating the copy could never reach the caller. - if p.mutable - && !matches!( - p.ty, - TypeRef::StringUtf8 - | TypeRef::Bytes - | TypeRef::BorrowedStr - | TypeRef::BorrowedBytes - ) - { - errors.push(ValidationError::MutableParamUnsupported { - function: format!("{module_name}::{display_name}"), - param: p.name.clone(), - ty: p.ty.to_string(), - }); - } validate_type_ref(&p.ty, types, errors); - check_interface_positions(&p.ty, types, true, location, errors); + check_interface_positions(&p.ty, types, location, errors); + // A bare callback interface is the one legal callback position; + // anything nested (`Listener?`, `[Listener]`) is not. + match &p.ty { + TypeRef::Named(name) if types.is_callback_interface(name) => {} + other => check_no_callback_interface(other, types, location, errors), + } } if let Some(ret) = &f.returns { let location = || format!("return type of {module_name}::{display_name}"); - if let Some(ty) = contains_borrowed(ret) { - errors.push(ValidationError::BorrowedTypeInInvalidPosition { - ty: ty.to_string(), - location: location(), - }); - } // An async function completes through a one-shot callback; an // iterator needs a pull-based handle. The two shapes cannot // compose on the C ABI, so reject the combination up front @@ -607,8 +653,21 @@ fn validate_callable_types( function: display_name.to_string(), }); } + // An iterator is a pull handle, valid only as the outermost return + // shape: `[iter]`, `iter?`, `iter>`, and iterators + // inside a map have no lowering. + let nested_iterator = match ret { + TypeRef::Iterator(elem) => contains_iterator(elem), + other => contains_iterator(other), + }; + if nested_iterator { + errors.push(ValidationError::IteratorInInvalidPosition { + location: format!("nested inside the {}", location()), + }); + } validate_type_ref(ret, types, errors); - check_interface_positions(ret, types, true, location, errors); + check_interface_positions(ret, types, location, errors); + check_no_callback_interface(ret, types, location, errors); } } @@ -616,118 +675,63 @@ fn validate_param(p: &Param, errors: &mut Vec) { check_identifier(&p.name, errors); } -/// Whether a type may appear as a callback parameter. Callback arguments -/// cross the boundary *into* the foreign language, either as a direct slot -/// (scalars, strings, bytes, handles) or as a borrowed serialized buffer -/// (records, rich enums, optionals, lists, maps), so nearly everything is -/// marshalable. The exceptions: iterators have no callback protocol, and a -/// borrowed view can appear only as the parameter's own type (a buffer -/// cannot hold a borrow). Interfaces are rejected separately. -fn callback_param_type_supported(ty: &TypeRef) -> bool { - if contains_iterator(ty) { - return false; - } - match ty { - TypeRef::BorrowedStr | TypeRef::BorrowedBytes => true, - _ => contains_borrowed(ty).is_none(), - } -} - -/// Does `ty` reference an interface anywhere in its structure? Used for -/// positions where interfaces are wholly disallowed (callback parameters). -fn references_interface(ty: &TypeRef, types: &TypeIndex) -> bool { - match ty { - TypeRef::Named(name) => types.is_interface(name), - TypeRef::Optional(inner) | TypeRef::List(inner) | TypeRef::Iterator(inner) => { - references_interface(inner, types) - } - TypeRef::Map(k, v) => references_interface(k, types) || references_interface(v, types), - _ => false, - } -} - -/// The innermost user-type name inside `ty` for an error message, falling -/// back to the IDL spelling for shapes without one. -fn user_name_in(ty: &TypeRef) -> String { - match ty { - TypeRef::Named(name) => name.clone(), - TypeRef::Optional(inner) | TypeRef::List(inner) | TypeRef::Iterator(inner) => { - user_name_in(inner) +/// Enforce the one place an interface reference may not appear: as a map +/// key. Objects are reference-counted tokens, so they compose with every +/// other buffered shape (fields, elements, map values, optionals), but no +/// target can hash an object by identity in a way that survives the ABI. +fn check_interface_positions( + ty: &TypeRef, + types: &TypeIndex, + location: impl Fn() -> String + Copy, + errors: &mut Vec, +) { + ty.walk(&mut |t| { + if let TypeRef::Map(k, _) = t { + if let TypeRef::Named(name) = &**k { + if types.is_interface(name) { + errors.push(ValidationError::InterfaceInInvalidPosition { + name: name.clone(), + location: format!("map key of {}", location()), + }); + } + } } - other => other.to_string(), - } + }); } -/// Enforce where an interface reference may appear. With `top` true (a -/// function parameter or return), a bare interface or an optional interface -/// is allowed; anywhere deeper (collection elements, map keys/values, -/// iterator elements, struct fields via `top` false) is rejected: an -/// interface is a live object reference, and element positions imply deep -/// copies the object cannot provide. -fn check_interface_positions( +/// Reject any callback-interface reference reachable from `ty`. Callers that +/// permit a bare top-level callback interface unwrap it before calling. +fn check_no_callback_interface( ty: &TypeRef, types: &TypeIndex, - top: bool, location: impl Fn() -> String + Copy, errors: &mut Vec, ) { - match ty { - TypeRef::Named(name) => { - if types.is_interface(name) && !top { - errors.push(ValidationError::InterfaceInInvalidPosition { + ty.walk(&mut |t| { + if let TypeRef::Named(name) = t { + if types.is_callback_interface(name) { + errors.push(ValidationError::CallbackInterfaceInInvalidPosition { name: name.clone(), location: location(), }); } } - // A typed handle names a token *type tag*, not an object; pointing - // one at an interface would conflate u64 tokens with object pointers. - TypeRef::TypedHandle(name) => { - if types.is_interface(name) { - errors.push(ValidationError::InterfaceInInvalidPosition { - name: name.clone(), - location: format!("typed handle in {}", location()), - }); - } - } - // Optionality does not change the position: `Store?` is still a - // top-level object reference. - TypeRef::Optional(inner) => check_interface_positions(inner, types, top, location, errors), - TypeRef::List(inner) | TypeRef::Iterator(inner) => { - check_interface_positions(inner, types, false, location, errors); - } - TypeRef::Map(k, v) => { - check_interface_positions(k, types, false, location, errors); - check_interface_positions(v, types, false, location, errors); - } - _ => {} - } -} - -fn contains_borrowed(ty: &TypeRef) -> Option<&'static str> { - match ty { - TypeRef::BorrowedStr => Some("&str"), - TypeRef::BorrowedBytes => Some("&[u8]"), - TypeRef::Optional(inner) | TypeRef::List(inner) | TypeRef::Iterator(inner) => { - contains_borrowed(inner) - } - TypeRef::Map(k, v) => contains_borrowed(k).or_else(|| contains_borrowed(v)), - _ => None, - } + }); } fn contains_iterator(ty: &TypeRef) -> bool { - match ty { - TypeRef::Iterator(_) => true, - TypeRef::Optional(inner) | TypeRef::List(inner) => contains_iterator(inner), - TypeRef::Map(k, v) => contains_iterator(k) || contains_iterator(v), - _ => false, - } + let mut found = false; + ty.walk(&mut |t| { + if matches!(t, TypeRef::Iterator(_)) { + found = true; + } + }); + found } fn validate_type_ref(ty: &TypeRef, types: &TypeIndex, errors: &mut Vec) { match ty { - TypeRef::Named(name) | TypeRef::TypedHandle(name) => { + TypeRef::Named(name) => { if !types.exists(name) { errors.push(ValidationError::UnknownTypeRef { name: name.clone() }); } @@ -758,6 +762,7 @@ fn validate_error_domain( errors.push(ValidationError::ErrorDomainMissingName(module.name.clone())); return; } + check_identifier(&domain.name, errors); if function_names.contains(&domain.name) { errors.push(ValidationError::NameCollisionWithErrorDomain { module: module.name.clone(), @@ -768,9 +773,11 @@ fn validate_error_domain( let mut by_name: BTreeSet = BTreeSet::new(); let mut by_code: BTreeMap = BTreeMap::new(); for c in &domain.codes { + check_identifier(&c.name, errors); // 0 means success and the whole negative range is reserved for the - // runtime (-1 generic error, -2 panic, -3 marshalling failure, and - // room to grow), so domain codes must be positive. + // runtime (-1 generic error, -2 panic, -3 marshalling failure, -4 + // foreign callback error, and room to grow), so domain codes must be + // positive. if c.code <= 0 { errors.push(ValidationError::InvalidErrorCode { module: module.name.clone(), diff --git a/crates/weaveffi-core/src/validate/tests.rs b/crates/weaveffi-core/src/validate/tests.rs index 0082aab7..9465e972 100644 --- a/crates/weaveffi-core/src/validate/tests.rs +++ b/crates/weaveffi-core/src/validate/tests.rs @@ -70,21 +70,29 @@ modules: - { name: Bad, code: 2, message: "bad", fields: [{ name: why, type: string }] } interfaces: - name: Store - constructors: [{ name: open, params: [{ name: path, type: "&str" }] }] + constructors: [{ name: open, params: [{ name: path, type: string }] }] methods: - { name: get, params: [{ name: k, type: string }], return: "Point?", throws: true } - { name: scan, params: [], return: "iter" } + - { name: stores, params: [], return: "iter" } - { name: sibling, params: [{ name: other, type: "Store?" }], return: Store } + - { name: children, params: [], return: "[Store]" } + - { name: by_name, params: [], return: "{string:Store}" } + - { name: watch, params: [{ name: listener, type: PointListener }] } statics: [{ name: version, params: [], return: string }] - callbacks: - - name: on_point - params: [{ name: p, type: Point }, { name: raw, type: "&[u8]" }] - listeners: - - { name: points, event_callback: on_point } + structs: + - name: Owner + fields: [{ name: store, type: "Store?" }, { name: all, type: "[Store]" }] + callback_interfaces: + - name: PointListener + methods: + - name: on_point + params: [{ name: p, type: Point }, { name: raw, type: bytes }, { name: from, type: Store }] + - { name: wants_more, params: [{ name: seen, type: u32 }], return: bool } + - { name: status, params: [], return: Status } functions: - { name: fetch, params: [{ name: id, type: i64 }], return: "[Point]", async: true, cancellable: true } - - { name: token, params: [], return: "handle" } - - { name: rename, params: [{ name: s, type: string, mutable: true }] } + - { name: subscribe, params: [{ name: l, type: PointListener }, { name: s, type: Store }] } modules: - name: inner functions: @@ -223,6 +231,17 @@ modules: interfaces: - name: Store methods: [{ name: get, params: [] }] +"#, + ), + ( + "AbiSymbolCollision", + r#" +modules: + - name: m + functions: [{ name: Store_clone, params: [] }] + interfaces: + - name: Store + methods: [{ name: get, params: [] }] "#, ), ( @@ -375,7 +394,7 @@ modules: modules: - name: m interfaces: [{ name: I, methods: [{ name: a, params: [] }] }] - structs: [{ name: S, fields: [{ name: i, type: I }] }] + functions: [{ name: f, params: [{ name: xs, type: "{I:i32}" }] }] "#, ), ( @@ -384,25 +403,7 @@ modules: modules: - name: m interfaces: [{ name: I, methods: [{ name: a, params: [] }] }] - functions: [{ name: f, params: [{ name: xs, type: "[I]" }] }] -"#, - ), - ( - "InterfaceInInvalidPosition", - r#" -modules: - - name: m - interfaces: [{ name: I, methods: [{ name: a, params: [] }] }] - functions: [{ name: f, params: [], return: "handle" }] -"#, - ), - ( - "InterfaceInInvalidPosition", - r#" -modules: - - name: m - interfaces: [{ name: I, methods: [{ name: a, params: [] }] }] - callbacks: [{ name: cb, params: [{ name: i, type: I }] }] + structs: [{ name: S, fields: [{ name: m, type: "{I:i32}" }] }] "#, ), ( @@ -411,7 +412,7 @@ modules: ), ( "UnknownTypeRef", - "modules: [{ name: m, functions: [{ name: f, params: [], return: 'handle' }] }]", + "modules: [{ name: m, functions: [{ name: f, params: [], return: '[Nope?]' }] }]", ), ( "InvalidMapKey", @@ -438,45 +439,92 @@ modules: "modules: [{ name: m, functions: [{ name: f, params: [], return: '{bytes:i32}' }] }]", ), ( - "BorrowedTypeInInvalidPosition", - "modules: [{ name: m, functions: [{ name: f, params: [], return: '&str' }] }]", + "DuplicateCallbackInterfaceName", + r#" +modules: + - name: m + callback_interfaces: + - { name: L, methods: [{ name: a, params: [] }] } + - { name: L, methods: [{ name: b, params: [] }] } +"#, ), ( - "BorrowedTypeInInvalidPosition", - "modules: [{ name: m, functions: [{ name: f, params: [{ name: x, type: '[&str]' }] }] }]", + "EmptyCallbackInterface", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [] }] }]", ), ( - "BorrowedTypeInInvalidPosition", - "modules: [{ name: m, structs: [{ name: S, fields: [{ name: x, type: '&[u8]' }] }] }]", + "DuplicateCallbackMethod", + r#" +modules: + - name: m + callback_interfaces: + - { name: L, methods: [{ name: a, params: [] }, { name: a, params: [] }] } +"#, ), ( - "DuplicateCallbackName", + "InvalidCallbackMethod", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [{ name: a, params: [], async: true }] }] }]", + ), + ( + "InvalidCallbackMethod", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [{ name: a, params: [], throws: true }] }] }]", + ), + ( + "InvalidCallbackMethod", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [{ name: a, params: [], cancellable: true }] }] }]", + ), + ( + "InvalidCallbackMethod", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [{ name: a, params: [], return: string }] }] }]", + ), + ( + "InvalidCallbackMethod", + r#" +modules: + - name: m + interfaces: [{ name: I, methods: [{ name: a, params: [] }] }] + callback_interfaces: [{ name: L, methods: [{ name: a, params: [], return: I }] }] +"#, + ), + ( + "CallbackInterfaceInInvalidPosition", r#" modules: - name: m - callbacks: [{ name: cb, params: [] }, { name: cb, params: [] }] + callback_interfaces: [{ name: L, methods: [{ name: a, params: [] }] }] + functions: [{ name: f, params: [], return: L }] "#, ), ( - "ListenerCallbackNotFound", - "modules: [{ name: m, listeners: [{ name: l, event_callback: nope }] }]", + "CallbackInterfaceInInvalidPosition", + r#" +modules: + - name: m + callback_interfaces: [{ name: L, methods: [{ name: a, params: [] }] }] + functions: [{ name: f, params: [{ name: l, type: "L?" }] }] +"#, ), ( - "DuplicateListenerName", + "CallbackInterfaceInInvalidPosition", r#" modules: - name: m - callbacks: [{ name: cb, params: [] }] - listeners: [{ name: l, event_callback: cb }, { name: l, event_callback: cb }] + callback_interfaces: [{ name: L, methods: [{ name: a, params: [] }] }] + structs: [{ name: S, fields: [{ name: l, type: L }] }] "#, ), ( - "UnsupportedCallbackParamType", - "modules: [{ name: m, callbacks: [{ name: cb, params: [{ name: x, type: 'iter' }] }] }]", + "CallbackInterfaceInInvalidPosition", + r#" +modules: + - name: m + callback_interfaces: + - { name: L, methods: [{ name: a, params: [{ name: other, type: L }] }] } +"#, ), ( - "UnsupportedCallbackParamType", - "modules: [{ name: m, callbacks: [{ name: cb, params: [{ name: x, type: '[&str]' }] }] }]", + "IteratorInInvalidPosition", + "modules: [{ name: m, callback_interfaces: [{ name: L, methods: [{ name: a, params: [{ name: x, type: 'iter' }] }] }] }]", ), ( "IteratorInInvalidPosition", @@ -487,17 +535,29 @@ modules: "modules: [{ name: m, structs: [{ name: S, fields: [{ name: x, type: 'iter' }] }] }]", ), ( - "MutableParamUnsupported", - "modules: [{ name: m, functions: [{ name: f, params: [{ name: x, type: i32, mutable: true }] }] }]", + "IteratorInInvalidPosition", + "modules: [{ name: m, functions: [{ name: f, params: [], return: '[iter]' }] }]", + ), + ( + "IteratorInInvalidPosition", + "modules: [{ name: m, functions: [{ name: f, params: [], return: 'iter?' }] }]", ), ( - "MutableParamUnsupported", - "modules: [{ name: m, functions: [{ name: f, params: [{ name: x, type: '[i32]', mutable: true }] }] }]", + "IteratorInInvalidPosition", + "modules: [{ name: m, functions: [{ name: f, params: [], return: 'iter>' }] }]", ), ( "AsyncIteratorReturn", "modules: [{ name: m, functions: [{ name: f, params: [], return: 'iter', async: true }] }]", ), + ( + "ReservedKeyword", + "modules: [{ name: m, errors: { name: 'if', codes: [{ name: A, code: 1, message: a }] } }]", + ), + ( + "InvalidIdentifier", + "modules: [{ name: m, errors: { name: E, codes: [{ name: '1bad', code: 1, message: a }] } }]", + ), ]; for (expected, doc) in cases { let got = errors(doc); @@ -562,7 +622,7 @@ fn diagnostics_render_every_message_and_related() { #[test] fn source_spans_underline_the_offending_identifier() { - let src = "version: \"0.8.0\"\nmodules:\n - name: \"dup\"\n - name: \"dup\"\n"; + let src = "version: \"0.9.0\"\nmodules:\n - name: \"dup\"\n - name: \"dup\"\n"; let api = parse_api_str(src, "yaml").unwrap(); let err = validate_api(api, Some(("api.yml", src))).unwrap_err(); let d = err.first(); @@ -581,10 +641,11 @@ modules: enums: [{ name: Status, variants: [{ name: Ok, value: 0 }] }] structs: [{ name: Point, fields: [{ name: x, type: f64 }] }] interfaces: [{ name: Store, methods: [{ name: get, params: [] }] }] + callback_interfaces: [{ name: Watcher, methods: [{ name: on, params: [] }] }] - name: orders functions: - name: f - params: [{ name: s, type: Store }, { name: st, type: Status }] + params: [{ name: s, type: Store }, { name: st, type: Status }, { name: w, type: Watcher }] return: "[Point]" "#, ) @@ -600,6 +661,10 @@ modules: api.resolve(&f.params[1].ty, "orders"), Ty::Enum("shared.Status".into()) ); + assert_eq!( + api.resolve(&f.params[2].ty, "orders"), + Ty::CallbackInterface("shared.Watcher".into()) + ); assert_eq!( api.resolve(f.returns.as_ref().unwrap(), "orders"), Ty::List(Box::new(Ty::Record("shared.Point".into()))) diff --git a/crates/weaveffi-core/tests/codegen_hooks.rs b/crates/weaveffi-core/tests/codegen_hooks.rs index 49fba1dc..4ddad9c4 100644 --- a/crates/weaveffi-core/tests/codegen_hooks.rs +++ b/crates/weaveffi-core/tests/codegen_hooks.rs @@ -39,7 +39,7 @@ impl LanguageBackend for Counting { "counting" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -68,8 +68,7 @@ fn api() -> ResolvedApi { interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], + callback_interfaces: vec![], errors: None, modules: vec![], }], diff --git a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_json/minimal.json b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_json/minimal.json index 780bdf48..44a1da20 100644 --- a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_json/minimal.json +++ b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_json/minimal.json @@ -1,5 +1,5 @@ { - "version": "0.8.0", + "version": "0.9.0", "modules": [ { "name": "m", diff --git a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_toml/minimal.toml b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_toml/minimal.toml index 879d7af2..c3d995c0 100644 --- a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_toml/minimal.toml +++ b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_toml/minimal.toml @@ -1,4 +1,4 @@ -version = "0.8.0" +version = "0.9.0" [[modules]] name = "m" diff --git a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_yaml/minimal.yml b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_yaml/minimal.yml index 202af628..0e4dd926 100644 --- a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_yaml/minimal.yml +++ b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_parse_yaml/minimal.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" modules: - name: m functions: diff --git a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_validate/minimal.yml b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_validate/minimal.yml index 846d3901..5e909b40 100644 --- a/crates/weaveffi-fuzz/fuzz/seeds/fuzz_validate/minimal.yml +++ b/crates/weaveffi-fuzz/fuzz/seeds/fuzz_validate/minimal.yml @@ -1,4 +1,4 @@ -version: "0.8.0" +version: "0.9.0" modules: - name: m functions: diff --git a/crates/weaveffi-gen-android/src/lib.rs b/crates/weaveffi-gen-android/src/lib.rs deleted file mode 100644 index a0299130..00000000 --- a/crates/weaveffi-gen-android/src/lib.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! Android (Kotlin/JNI) binding generator for WeaveFFI. -//! -//! Generates a Gradle project skeleton with a Kotlin wrapper plus a JNI -//! bridge layer that calls into the C ABI. `suspend fun` shims are emitted -//! for async functions. Implements [`LanguageBackend`]; the shared driver -//! bridges it into the generator pipeline. -#![deny(missing_docs)] -#![warn(clippy::missing_errors_doc)] -#![warn(clippy::missing_panics_doc)] -#![warn(clippy::doc_markdown)] - -mod calls; -mod codec; -mod docs; -mod entities; -mod jni; -mod package; -mod runtime; -mod types; - -use camino::Utf8Path; -use serde::{Deserialize, Serialize}; -use std::fmt::Write as _; -use weaveffi_core::backend::{LanguageBackend, OutputFile}; -use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::{BindingModel, CallShape, CallbackBinding}; -use weaveffi_core::pkg; -use weaveffi_core::resolved::ResolvedApi; -use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; - -use crate::calls::{render_kotlin_free_fn, render_listener_api}; -use crate::codec::model_uses_buffers; -use crate::entities::{ - render_kotlin_enum, render_kotlin_error_types, render_kotlin_interface, - render_kotlin_iterator_class, render_kotlin_struct, -}; -use crate::jni::{ - render_jni_async_function, render_jni_cb_tramp, render_jni_interface, - render_jni_iterator_natives, render_jni_listener_fns, render_jni_sync_export, -}; -use crate::package::{build_gradle, cmake_lists, settings_gradle}; -use crate::runtime::{ - domain_thrower_used, jni_thrower_for, render_jni_domain_thrower, render_jni_generic_thrower, - render_jni_listener_support, render_jni_uncaught_support, render_kotlin_buffer_runtime, - render_kotlin_exception_handler_api, render_weave_continuation, -}; -use crate::types::{kotlin_fn_name, needs_wrapper_split}; - -/// Per-target configuration for [`AndroidGenerator`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AndroidConfig { - /// JVM package for the generated Kotlin wrapper (default - /// `"com.weaveffi"`). - pub package: Option, - /// When `true` (the default), strip the IR module name prefix from - /// emitted Kotlin function names. Set to `false` to keep the prefixed - /// spelling (`contactsCreateContact` rather than `createContact`). - pub strip_module_prefix: bool, - /// C ABI symbol prefix (default `"weaveffi"`). Normally set once globally - /// via `[global] c_prefix`; honored so the JNI shim calls the same - /// exported symbols the producer emits. - pub prefix: Option, - /// Basename of the IDL the CLI was invoked with. - #[serde(skip)] - pub input_basename: Option, -} - -impl Default for AndroidConfig { - /// The default configuration strips module prefixes; every other field - /// falls back to `None` and resolves through its accessor. - fn default() -> Self { - Self { - package: None, - strip_module_prefix: true, - prefix: None, - input_basename: None, - } - } -} - -impl AndroidConfig { - /// Returns the configured JVM package, falling back to `"com.weaveffi"`. - pub fn package(&self) -> &str { - self.package.as_deref().unwrap_or("com.weaveffi") - } - - /// Returns the configured C ABI symbol prefix, falling back to - /// `"weaveffi"`. - pub fn prefix(&self) -> &str { - self.prefix.as_deref().unwrap_or("weaveffi") - } - - /// Returns the input IDL basename embedded in generated file headers, - /// falling back to `"weaveffi.yml"`. - pub fn input_basename(&self) -> &str { - self.input_basename.as_deref().unwrap_or("weaveffi.yml") - } -} - -/// Android backend: emits a Gradle project with a Kotlin wrapper over a JNI -/// bridge layer that calls into the C ABI. `suspend fun` shims wrap async -/// functions. -pub struct AndroidGenerator; - -impl LanguageBackend for AndroidGenerator { - type Config = AndroidConfig; - - fn name(&self) -> &'static str { - "android" - } - - fn capabilities(&self) -> TargetCapabilities { - TargetCapabilities::full() - } - - fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str { - config.prefix() - } - - fn files( - &self, - api: &ResolvedApi, - model: &BindingModel, - out_dir: &Utf8Path, - config: &Self::Config, - ) -> Vec { - let package = config.package(); - let strip = config.strip_module_prefix; - let input_basename = config.input_basename(); - let dir = out_dir.join("android"); - let pkg_path = package.replace('.', "/"); - let src_dir = dir.join(format!("src/main/kotlin/{pkg_path}")); - let jni_dir = dir.join("src/main/cpp"); - let project_name = pkg::resolve(api, None, config.input_basename.as_deref()).name; - vec![ - OutputFile::new( - dir.join("settings.gradle"), - settings_gradle(&project_name, input_basename), - ), - OutputFile::new( - dir.join("build.gradle"), - build_gradle(package, input_basename), - ), - OutputFile::new( - src_dir.join("WeaveFFI.kt"), - render_kotlin(model, package, strip, input_basename), - ), - OutputFile::new(jni_dir.join("CMakeLists.txt"), cmake_lists(input_basename)), - OutputFile::new( - jni_dir.join("weaveffi_jni.c"), - render_jni_c(model, package, strip, input_basename), - ), - ] - } -} - -/// Render the complete `WeaveFFI.kt`: the `WeaveFFI` entry class (listener -/// registration and free functions inside the companion), then the entity -/// surface (enums, records, interfaces, iterator classes), the exception -/// hierarchy, and the buffer and continuation runtimes when the model needs -/// them. -fn render_kotlin( - model: &BindingModel, - package: &str, - strip_module_prefix: bool, - input_basename: &str, -) -> String { - let c_prefix = model.prefix.as_str(); - let has_async = model - .modules - .iter() - .any(|m| m.callables().any(|f| f.is_async)); - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - let mut kotlin = render_prelude(CommentStyle::DoubleSlash, input_basename); - kotlin.push_str(&format!("package {package}\n\n")); - if has_async { - kotlin.push_str("import kotlinx.coroutines.suspendCancellableCoroutine\n"); - kotlin.push_str("import kotlin.coroutines.resume\n"); - kotlin.push_str("import kotlin.coroutines.resumeWithException\n\n"); - } - kotlin.push_str("class WeaveFFI {\n companion object {\n init { System.loadLibrary(\"weaveffi\") }\n\n"); - if has_async || has_listeners { - render_kotlin_exception_handler_api(&mut kotlin); - } - for m in &model.modules { - for l in &m.listeners { - render_listener_api(&mut kotlin, m, l, strip_module_prefix); - } - for f in &m.functions { - render_kotlin_free_fn(&mut kotlin, m, f, strip_module_prefix, c_prefix); - } - } - kotlin.push_str(" }\n}\n"); - for m in &model.modules { - for e in &m.enums { - render_kotlin_enum(&mut kotlin, e); - } - for s in &m.structs { - render_kotlin_struct(&mut kotlin, s); - } - for i in &m.interfaces { - render_kotlin_interface(&mut kotlin, i, m.error.as_ref(), c_prefix); - } - // One lazy iterator wrapper class per `iter` callable, streaming - // one producer `next` per consumer step. - for f in m.callables() { - if let CallShape::Iterator(it) = &f.shape { - render_kotlin_iterator_class(&mut kotlin, f, it, c_prefix); - } - } - } - render_kotlin_error_types(&mut kotlin, model); - if model_uses_buffers(model) { - render_kotlin_buffer_runtime(&mut kotlin); - } - if has_async { - render_weave_continuation(&mut kotlin); - } - kotlin.push('\n'); - kotlin.push_str(&render_trailer(CommentStyle::DoubleSlash, "WeaveFFI.kt")); - kotlin -} - -/// Render the complete `weaveffi_jni.c`: the error throwers, the async and -/// listener support blocks, the callback trampolines and listener exports, -/// then one JNI export per callable (sync, async, interface member, and -/// iterator native). -fn render_jni_c( - model: &BindingModel, - package: &str, - strip_module_prefix: bool, - input_basename: &str, -) -> String { - let c_prefix = model.prefix.as_str(); - let jni_prefix = package.replace('.', "_"); - let jni_pkg_path = package.replace('.', "/"); - let mut jni_c = render_prelude(CommentStyle::DoubleSlash, input_basename); - jni_c.push_str("#include \n#include \n#include \n#include \n#include \n"); - if model.modules.iter().any(|m| !m.listeners.is_empty()) { - jni_c.push_str("#include \n"); - } - let _ = writeln!(jni_c, "#include \"{c_prefix}.h\"\n"); - - render_jni_generic_thrower(&mut jni_c, &jni_pkg_path); - for m in &model.modules { - if let Some(eb) = m.error.as_ref().filter(|e| e.declared_here) { - if domain_thrower_used(model, &eb.c_tag) { - render_jni_domain_thrower(&mut jni_c, eb, &jni_pkg_path); - } - } - } - - let has_async = model - .modules - .iter() - .any(|m| m.callables().any(|f| f.is_async)); - if has_async { - jni_c.push_str("typedef struct {\n"); - jni_c.push_str(" JavaVM* jvm;\n"); - jni_c.push_str(" jobject callback;\n"); - jni_c.push_str("} weaveffi_jni_async_ctx;\n\n"); - } - - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_async || has_listeners { - render_jni_uncaught_support(&mut jni_c, &jni_pkg_path); - } - if has_listeners { - render_jni_listener_support(&mut jni_c); - } - for m in &model.modules { - let used_callbacks: Vec<&CallbackBinding> = m - .listeners - .iter() - .filter_map(|l| m.callback(&l.event_callback)) - .collect(); - for cb in &used_callbacks { - render_jni_cb_tramp(&mut jni_c, cb, c_prefix); - } - for l in &m.listeners { - let Some(cb) = m.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - render_jni_listener_fns(&mut jni_c, &m.path, l, cb, &jni_prefix, strip_module_prefix); - } - } - - for m in &model.modules { - for f in &m.functions { - let thrower = jni_thrower_for(f, m.error.as_ref()); - let func_name = kotlin_fn_name(&m.path, &f.name, strip_module_prefix); - if f.is_async { - render_jni_async_function( - &mut jni_c, - &m.path, - f, - "WeaveFFI", - &format!("{func_name}Async"), - None, - &jni_prefix, - c_prefix, - ); - continue; - } - let jni_name = if needs_wrapper_split(f) { - format!("{func_name}Jni") - } else { - func_name - }; - render_jni_sync_export( - &mut jni_c, - f, - "WeaveFFI", - &jni_name, - None, - &thrower, - &jni_prefix, - &m.path, - c_prefix, - ); - } - } - for m in &model.modules { - // Records and rich enums are value types with no C symbols: their - // packing and decoding happens entirely in Kotlin, so no JNI bridge - // is emitted for them. Plain C-style enums need no natives either. - for i in &m.interfaces { - render_jni_interface(&mut jni_c, m, i, &jni_prefix, c_prefix); - } - // The `nativeNext`/`nativeDestroy` exports backing each generated - // lazy iterator class, covering free functions, interface methods, - // and statics returning `iter`. - for f in m.callables() { - if let CallShape::Iterator(it) = &f.shape { - let thrower = jni_thrower_for(f, m.error.as_ref()); - render_jni_iterator_natives( - &mut jni_c, - it, - &thrower, - &jni_prefix, - &m.path, - c_prefix, - ); - } - } - } - jni_c.push('\n'); - jni_c.push_str(&render_trailer(CommentStyle::DoubleSlash, "weaveffi_jni.c")); - jni_c -} diff --git a/crates/weaveffi-gen-android/src/package.rs b/crates/weaveffi-gen-android/src/package.rs deleted file mode 100644 index 557aac2b..00000000 --- a/crates/weaveffi-gen-android/src/package.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Gradle project scaffolding: `settings.gradle`, `build.gradle`, and the -//! CMake build script compiling the JNI shim. - -use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; - -/// Escape `s` for placement inside a single-quoted Groovy (Gradle) string -/// literal: backslashes and single quotes gain a backslash, so a package -/// name or project name containing either can't terminate the literal. -pub(crate) fn gradle_squote(s: &str) -> String { - s.replace('\\', "\\\\").replace('\'', "\\'") -} - -/// Render `settings.gradle`, naming the root project after the resolved -/// package identity. -pub(crate) fn settings_gradle(project_name: &str, input_basename: &str) -> String { - format!( - "{}rootProject.name = '{}'\n\n{}", - render_prelude(CommentStyle::DoubleSlash, input_basename), - gradle_squote(project_name), - render_trailer(CommentStyle::DoubleSlash, "settings.gradle"), - ) -} - -/// Render `build.gradle` for the Android library module, wiring the JNI shim -/// through `externalNativeBuild` and setting the JVM `namespace`. -pub(crate) fn build_gradle(namespace: &str, input_basename: &str) -> String { - let prelude = render_prelude(CommentStyle::DoubleSlash, input_basename); - let trailer = render_trailer(CommentStyle::DoubleSlash, "build.gradle"); - let namespace = gradle_squote(namespace); - format!( - r#"{prelude}plugins {{ - id 'com.android.library' - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false -}} - -android {{ - namespace '{namespace}' - compileSdk 34 - defaultConfig {{ - minSdk 24 - externalNativeBuild {{ - cmake {{ - cppFlags "" - }} - }} - }} - externalNativeBuild {{ - cmake {{ - path "src/main/cpp/CMakeLists.txt" - }} - }} -}} - -{trailer}"# - ) -} - -/// The static body of `CMakeLists.txt`: one shared library built from the -/// JNI shim, with the generated C header on the include path. -const CMAKE: &str = r#"cmake_minimum_required(VERSION 3.22) -project(weaveffi) -add_library(weaveffi SHARED weaveffi_jni.c) -target_include_directories(weaveffi PRIVATE ../../../../c) -"#; - -/// Render `CMakeLists.txt` for the JNI shim. -pub(crate) fn cmake_lists(input_basename: &str) -> String { - format!( - "{}{CMAKE}\n{}", - render_prelude(CommentStyle::Hash, input_basename), - render_trailer(CommentStyle::Hash, "CMakeLists.txt"), - ) -} diff --git a/crates/weaveffi-gen-android/src/runtime.rs b/crates/weaveffi-gen-android/src/runtime.rs deleted file mode 100644 index d0cdf599..00000000 --- a/crates/weaveffi-gen-android/src/runtime.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Once-per-file runtime support: the Kotlin value-buffer writer/reader, the -//! callback exception hook, the async continuation shim, and the JNI error -//! throwers, uncaught-exception plumbing, and listener registry. - -use std::fmt::Write as _; - -use weaveffi_core::codegen::CodeWriter; -use weaveffi_core::errors; -use weaveffi_core::model::{BindingModel, ErrorBinding, FnBinding}; -use weaveffi_core::plan::ErrorStrategy; - -use crate::entities::kotlin_exception_name; - -/// Render the settable uncaught-exception hook into the `WeaveFFI` companion. -/// Listener callbacks and async continuation resume paths run on native -/// producer threads with no Kotlin caller up-stack, so a thrown exception has -/// nowhere to propagate; the JNI glue routes it here. When no handler is -/// installed, `dispatchCallbackException` rethrows, and the glue falls back to -/// `ExceptionDescribe` (logging the stack trace) before clearing. -pub(crate) fn render_kotlin_exception_handler_api(out: &mut String) { - let mut w = CodeWriter::four_space().with_depth(2); - w.line("@Volatile private var callbackExceptionHandler: ((Throwable) -> Unit)? = null"); - w.blank(); - w.line("/**"); - w.line(" * Installs a handler for exceptions thrown by listener callbacks and"); - w.line(" * async continuations on native producer threads. These exceptions have"); - w.line(" * no Kotlin caller to propagate to; when no handler is installed, they"); - w.line(" * are logged with their stack trace and dropped. Pass `null` to"); - w.line(" * restore the default logging behavior."); - w.line(" */"); - w.line("@JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) {"); - w.scope(|w| { - w.line("callbackExceptionHandler = handler"); - }); - w.line("}"); - w.blank(); - w.line("// Invoked from the JNI glue; rethrowing signals \"no handler\" so the"); - w.line("// glue falls back to ExceptionDescribe."); - w.line("@JvmStatic private fun dispatchCallbackException(t: Throwable) {"); - w.scope(|w| { - w.line("callbackExceptionHandler?.invoke(t) ?: throw t"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); -} - -/// Emit the private Kotlin value-buffer runtime: a growable little-endian -/// writer, a validating reader (rejecting truncated buffers, invalid -/// bool/flag bytes, oversized length prefixes, and trailing bytes), and the -/// `weaveEncode`/`weaveDecode` entry points the generated wrappers call. -pub(crate) fn render_kotlin_buffer_runtime(out: &mut String) { - let brand = errors::EXCEPTION_BRAND; - let _ = write!( - out, - r#" -/** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ -internal class WeaveBufferWriter {{ - private var buf = ByteArray(32) - private var len = 0 - - private fun reserve(extra: Int) {{ - if (len + extra <= buf.size) return - var cap = buf.size * 2 - while (cap < len + extra) cap *= 2 - buf = buf.copyOf(cap) - }} - - fun toByteArray(): ByteArray = buf.copyOf(len) - - fun writeBool(v: Boolean) {{ reserve(1); buf[len++] = if (v) 1 else 0 }} - fun writeI8(v: Byte) {{ reserve(1); buf[len++] = v }} - fun writeI16(v: Short) {{ - reserve(2) - val b = v.toInt() - buf[len++] = (b and 0xFF).toByte() - buf[len++] = ((b shr 8) and 0xFF).toByte() - }} - fun writeI32(v: Int) {{ - reserve(4) - buf[len++] = (v and 0xFF).toByte() - buf[len++] = ((v shr 8) and 0xFF).toByte() - buf[len++] = ((v shr 16) and 0xFF).toByte() - buf[len++] = ((v shr 24) and 0xFF).toByte() - }} - fun writeU32(v: Long) = writeI32(v.toInt()) - fun writeI64(v: Long) {{ - writeI32(v.toInt()) - writeI32((v ushr 32).toInt()) - }} - fun writeF32(v: Float) = writeI32(v.toRawBits()) - fun writeF64(v: Double) = writeI64(v.toRawBits()) - fun writeString(v: String) = writeBytes(v.toByteArray(Charsets.UTF_8)) - fun writeBytes(v: ByteArray) {{ - writeI32(v.size) - reserve(v.size) - v.copyInto(buf, len) - len += v.size - }} - fun writeOptional(v: T?, write: (T) -> Unit) {{ - if (v == null) writeBool(false) else {{ writeBool(true); write(v) }} - }} - fun writeList(v: List, write: (T) -> Unit) {{ - writeI32(v.size) - for (e in v) write(e) - }} - fun writeMap(v: Map, writeKey: (K) -> Unit, writeValue: (V) -> Unit) {{ - writeI32(v.size) - for ((k, e) in v) {{ writeKey(k); writeValue(e) }} - }} -}} - -/** Validating little-endian reader for the WeaveFFI value-buffer wire format. */ -internal class WeaveBufferReader(private val buf: ByteArray) {{ - private var pos = 0 - - private fun malformed(detail: String): Nothing = - throw {brand}(-2, "malformed WeaveFFI value buffer: " + detail) - - private fun take(n: Int): Int {{ - if (n > buf.size - pos) malformed("truncated buffer") - val at = pos - pos += n - return at - }} - - fun readBool(): Boolean = when (buf[take(1)].toInt()) {{ - 0 -> false - 1 -> true - else -> malformed("invalid bool byte") - }} - fun readI8(): Byte = buf[take(1)] - fun readI16(): Short {{ - val at = take(2) - return ((buf[at].toInt() and 0xFF) or ((buf[at + 1].toInt() and 0xFF) shl 8)).toShort() - }} - fun readI32(): Int {{ - val at = take(4) - return (buf[at].toInt() and 0xFF) or - ((buf[at + 1].toInt() and 0xFF) shl 8) or - ((buf[at + 2].toInt() and 0xFF) shl 16) or - ((buf[at + 3].toInt() and 0xFF) shl 24) - }} - fun readU32(): Long = readI32().toLong() and 0xFFFFFFFFL - fun readI64(): Long {{ - val lo = readI32().toLong() and 0xFFFFFFFFL - val hi = readI32().toLong() - return lo or (hi shl 32) - }} - fun readF32(): Float = Float.fromBits(readI32()) - fun readF64(): Double = Double.fromBits(readI64()) - private fun readLen(): Int {{ - val n = readI32() - if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") - return n - }} - fun readString(): String {{ - val n = readLen() - val at = take(n) - return try {{ - Charsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(buf, at, n)).toString() - }} catch (e: java.nio.charset.CharacterCodingException) {{ - malformed("string is not valid UTF-8") - }} - }} - fun readBytes(): ByteArray {{ - val n = readLen() - val at = take(n) - return buf.copyOfRange(at, at + n) - }} - fun readOptional(read: () -> T): T? = if (readBool()) read() else null - fun readList(read: () -> T): List {{ - val n = readLen() - val out = ArrayList(n) - repeat(n) {{ out.add(read()) }} - return out - }} - fun readMap(readKey: () -> K, readValue: () -> V): Map {{ - val n = readLen() - val out = LinkedHashMap() - repeat(n) {{ out[readKey()] = readValue() }} - return out - }} - fun expectEnd() {{ - if (pos != buf.size) malformed("trailing bytes after value") - }} -}} - -internal fun weaveEncode(write: (WeaveBufferWriter) -> Unit): ByteArray {{ - val w = WeaveBufferWriter() - write(w) - return w.toByteArray() -}} - -internal fun weaveDecode(bytes: ByteArray, read: (WeaveBufferReader) -> T): T {{ - val r = WeaveBufferReader(bytes) - val v = read(r) - r.expectEnd() - return v -}} -"# - ); -} - -/// Render the `WeaveContinuation` shim boxing a cancellable continuation and -/// its error mapper for delivery through the JNI async callback. -pub(crate) fn render_weave_continuation(out: &mut String) { - out.push_str("\ninternal class WeaveContinuation(\n"); - out.push_str(" private val cont: kotlinx.coroutines.CancellableContinuation,\n"); - out.push_str(" private val mapError: (Int, String, ByteArray?) -> Throwable\n"); - out.push_str(") {\n"); - out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n"); - out.push_str(" fun onSuccess(result: Any?) { cont.resume(result as T) }\n"); - out.push_str(" fun onError(code: Int, message: String, payload: ByteArray?) { cont.resumeWithException(mapError(code, message, payload)) }\n"); - out.push_str("}\n"); -} - -/// Emit the uncaught-exception plumbing shared by listener trampolines, -/// callback invocations, and async continuation resumes: a `JNI_OnLoad` that -/// caches a global reference to the generated `WeaveFFI` class (producer -/// threads cannot `FindClass` app classes), and a helper that routes a -/// pending exception to the settable Kotlin handler. When no handler is -/// installed (the dispatcher rethrows) or the handler itself throws, the -/// helper falls back to `ExceptionDescribe`, so the exception is logged with -/// its stack trace before being cleared; it is never silently swallowed. -pub(crate) fn render_jni_uncaught_support(out: &mut String, jni_pkg_path: &str) { - let mut w = CodeWriter::four_space(); - w.line("static jclass weaveffi_jni_entry_class = NULL;"); - w.line("static jmethodID weaveffi_jni_dispatch_exc = NULL;"); - w.blank(); - w.line("JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {"); - w.scope(|w| { - w.line("(void)reserved;"); - w.line("JNIEnv* env = NULL;"); - w.line("if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; }"); - w.line(format!( - "jclass cls = (*env)->FindClass(env, \"{jni_pkg_path}/WeaveFFI\");" - )); - w.line("if (cls == NULL) { (*env)->ExceptionClear(env); return JNI_VERSION_1_6; }"); - w.line("weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, cls);"); - w.line("weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, \"dispatchCallbackException\", \"(Ljava/lang/Throwable;)V\");"); - w.line("if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); }"); - w.line("return JNI_VERSION_1_6;"); - }); - w.line("}"); - w.blank(); - w.line("static void weaveffi_jni_handle_uncaught(JNIEnv* env) {"); - w.scope(|w| { - w.line("if (!(*env)->ExceptionCheck(env)) { return; }"); - w.line("jthrowable ex = (*env)->ExceptionOccurred(env);"); - w.line("(*env)->ExceptionClear(env);"); - w.block( - "if (weaveffi_jni_entry_class != NULL && weaveffi_jni_dispatch_exc != NULL) {", - "}", - |w| { - w.line("(*env)->CallStaticVoidMethod(env, weaveffi_jni_entry_class, weaveffi_jni_dispatch_exc, ex);"); - w.line("if (!(*env)->ExceptionCheck(env)) { (*env)->DeleteLocalRef(env, ex); return; }"); - w.line("(*env)->ExceptionClear(env);"); - }, - ); - w.line("(*env)->Throw(env, ex);"); - w.line("(*env)->ExceptionDescribe(env);"); - w.line("(*env)->ExceptionClear(env);"); - w.line("(*env)->DeleteLocalRef(env, ex);"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); -} - -/// Emit the generic thrower: constructs the brand exception with the raw -/// `(code, message)` pair via `NewObject` (so unknown codes keep their numeric -/// code) and throws it. Every non-throwing callable dispatches here, and it -/// is the trap channel for the runtime's reserved negative codes. -pub(crate) fn render_jni_generic_thrower(out: &mut String, jni_pkg_path: &str) { - let mut w = CodeWriter::four_space(); - w.line("static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {"); - w.scope(|w| { - w.line("const char* msg = err->message ? err->message : \"WeaveFFI error\";"); - w.line(format!( - "jclass exClass = (*env)->FindClass(env, \"{}/{}\");", - jni_pkg_path, - errors::EXCEPTION_BRAND - )); - w.block("if (exClass != NULL) {", "}", |w| { - w.line("jmethodID ctor = (*env)->GetMethodID(env, exClass, \"\", \"(ILjava/lang/String;)V\");"); - w.line("jstring jmsg = (*env)->NewStringUTF(env, msg);"); - w.line("jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg);"); - w.line("if (ex != NULL) { (*env)->Throw(env, ex); }"); - }); - w.line("weaveffi_error_clear(err);"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); -} - -/// Emit the thrower for one declared error domain: the raw `(code, message)` -/// pair and the serialized payload (copied to a `jbyteArray`, or `NULL` when -/// absent) are handed to the sealed exception's static `fromCode` factory, -/// which decodes payload fields into the typed subclass; unknown codes fall -/// back to the generic exception inside `fromCode` itself. Both the message -/// and the payload buffer are released via `weaveffi_error_clear`. -pub(crate) fn render_jni_domain_thrower(out: &mut String, eb: &ErrorBinding, jni_pkg_path: &str) { - let exc = kotlin_exception_name(eb); - let brand = errors::EXCEPTION_BRAND; - let mut w = CodeWriter::four_space(); - w.line(format!( - "static void throw_{}(JNIEnv* env, weaveffi_error* err) {{", - eb.c_tag - )); - w.scope(|w| { - w.line(format!( - "jclass exClass = (*env)->FindClass(env, \"{jni_pkg_path}/{exc}\");" - )); - w.line(format!( - "jmethodID fromCode = exClass ? (*env)->GetStaticMethodID(env, exClass, \"fromCode\", \"(ILjava/lang/String;[B)L{jni_pkg_path}/{brand};\") : NULL;" - )); - w.block("if (fromCode == NULL) {", "}", |w| { - w.line("(*env)->ExceptionClear(env);"); - w.line("throw_weaveffi_error(env, err);"); - w.line("return;"); - }); - w.line("const char* msg = err->message ? err->message : \"WeaveFFI error\";"); - w.line("jstring jmsg = (*env)->NewStringUTF(env, msg);"); - w.line("jbyteArray jpayload = NULL;"); - w.block("if (err->payload_ptr != NULL) {", "}", |w| { - w.line("jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len);"); - w.line("if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); }"); - }); - w.line("jthrowable ex = (jthrowable)(*env)->CallStaticObjectMethod(env, exClass, fromCode, (jint)err->code, jmsg, jpayload);"); - // A pending exception from fromCode itself (e.g. a malformed payload - // buffer) is left in place; otherwise the mapped exception is thrown. - w.line("if (ex != NULL && !(*env)->ExceptionCheck(env)) { (*env)->Throw(env, ex); }"); - w.line("weaveffi_error_clear(err);"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); -} - -/// The C thrower a sync callable's error check dispatches to: the typed -/// domain thrower when the callable's [`ErrorStrategy`] is -/// [`Throws`](ErrorStrategy::Throws) inside a module with an error domain, -/// the generic thrower otherwise (the trap channel). -pub(crate) fn jni_thrower_for(f: &FnBinding, error: Option<&ErrorBinding>) -> String { - match (error, f.error_strategy()) { - (Some(eb), ErrorStrategy::Throws) => format!("throw_{}", eb.c_tag), - _ => "throw_weaveffi_error".to_string(), - } -} - -/// Whether any sync or iterator callable dispatches to the domain thrower for -/// `c_tag`, counting inheriting submodules. Async errors bypass the C -/// throwers (they resume the continuation), so an async-only domain emits no -/// thrower. -pub(crate) fn domain_thrower_used(model: &BindingModel, c_tag: &str) -> bool { - model.modules.iter().any(|m| { - m.error.as_ref().is_some_and(|e| e.c_tag == c_tag) - && m.callables().any(|f| f.throws && !f.is_async) - }) -} - -/// The shared listener context + registry. Producers may fire events on any -/// thread, so registry mutation (register/unregister, both on JVM threads) -/// is mutex-guarded; trampolines only read their own context. -pub(crate) fn render_jni_listener_support(out: &mut String) { - let mut w = CodeWriter::four_space(); - w.block( - "typedef struct weaveffi_jni_listener_ctx {", - "} weaveffi_jni_listener_ctx;", - |w| { - w.line("JavaVM* jvm;"); - w.line("jobject callback;"); - w.line("uint64_t id;"); - w.line("struct weaveffi_jni_listener_ctx* next;"); - }, - ); - w.blank(); - w.line("static weaveffi_jni_listener_ctx* weaveffi_jni_listeners = NULL;"); - w.line("static pthread_mutex_t weaveffi_jni_listener_lock = PTHREAD_MUTEX_INITIALIZER;"); - w.blank(); - out.push_str(&w.finish()); -} diff --git a/crates/weaveffi-gen-c/src/lib.rs b/crates/weaveffi-gen-c/src/lib.rs index f2845b98..50801d6b 100644 --- a/crates/weaveffi-gen-c/src/lib.rs +++ b/crates/weaveffi-gen-c/src/lib.rs @@ -31,7 +31,7 @@ use package::{render_packaged_cmake, render_packaged_readme}; /// Per-target configuration for [`CGenerator`]. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct CConfig { /// Prefix applied to every emitted C symbol (default `"weaveffi"`). /// Renames produce both `prefix_*` user symbols and @@ -68,7 +68,7 @@ impl LanguageBackend for CGenerator { "c" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } diff --git a/crates/weaveffi-gen-cpp/Cargo.toml b/crates/weaveffi-gen-cpp/Cargo.toml index d1fc7fba..2cf61bb7 100644 --- a/crates/weaveffi-gen-cpp/Cargo.toml +++ b/crates/weaveffi-gen-cpp/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-cpp/src/callbacks.rs b/crates/weaveffi-gen-cpp/src/callbacks.rs new file mode 100644 index 00000000..5a994fad --- /dev/null +++ b/crates/weaveffi-gen-cpp/src/callbacks.rs @@ -0,0 +1,279 @@ +//! Callback-interface rendering: the abstract class the consumer implements, +//! the `extern "C"`-compatible trampolines that adapt an implementation to +//! the C vtable, and the process-wide static vtable, per +//! `weaveffi_core::plan::CallbackProtocol`. + +use weaveffi_core::codegen::common::DocCommentStyle; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::model::{CallbackInterfaceBinding, CallbackMethodBinding, ParamBinding, Ty}; +use weaveffi_core::plan::ArgPass; +use weaveffi_core::utils::{c_abi_struct_name, local_type_name}; + +use crate::codec::emit_read_decl; +use crate::types::{ + cpp_cb_param_decl, cpp_fn_name, cpp_ident, cpp_type, render_param_decls, slot_name, + trampoline_struct, vtable_accessor, +}; + +/// The foreign-error code a trampoline reports when the implementation throws. +const FOREIGN_ERROR_CODE: i32 = -4; + +/// Render the abstract class a consumer subclasses to implement a callback +/// interface: a virtual destructor and one pure virtual method per IDL +/// method, with idiomatic C++ signatures (strings and buffered values by +/// const reference, object arguments by value as the adopted wrapper). +pub(crate) fn render_callback_class(out: &mut String, cb: &CallbackInterfaceBinding) { + let name = &cb.name; + let usage = format!( + "Implement this interface and pass a `std::shared_ptr<{name}>` to any \ + function that accepts one. The producer may call the methods from any \ + thread until it releases its last reference to the implementation. An \ + exception thrown by a method is reported to the producer as a foreign \ + error (code -4) and aborts the call that triggered it." + ); + let doc = match &cb.doc { + Some(d) => format!("{d}\n\n{usage}"), + None => usage, + }; + let mut w = CodeWriter::four_space(); + w.doc(&Some(doc), DocCommentStyle::Javadoc); + w.line(format!("class {name} {{")); + w.line("public:"); + w.scope(|w| { + w.line(format!("virtual ~{name}() = default;")); + w.blank(); + for m in &cb.methods { + w.doc(&m.doc, DocCommentStyle::Javadoc); + if let Some(msg) = &m.deprecated { + let escaped = msg.replace('"', "\\\""); + w.line(format!("[[deprecated(\"{escaped}\")]]")); + } + let ret = m.ret.as_ref().map_or("void".to_string(), cpp_type); + let params: Vec = m + .params + .iter() + .map(|p| cpp_cb_param_decl(&p.ty, &cpp_ident(&p.name))) + .collect(); + w.line(format!( + "virtual {ret} {}({}) = 0;", + cpp_fn_name(&m.name), + params.join(", ") + )); + } + }); + w.line("};"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Render, inside `detail`, the trampolines and the static vtable for one +/// callback interface. +/// +/// `ctx` is a heap-allocated `std::shared_ptr` box created when the +/// implementation is passed to the producer; the trailing `free` entry +/// deletes it. Each method trampoline receives its arguments per the +/// callback protocol (strings, bytes, and buffers borrowed and copied or +/// decoded; objects adopted), calls the virtual method, and on any exception +/// reports `{prefix}_error_set(out_err, -4, message)` and returns a default +/// value, so nothing ever unwinds through the C frame. +pub(crate) fn render_callback_trampolines( + out: &mut String, + cb: &CallbackInterfaceBinding, + module: &str, + prefix: &str, +) { + let name = &cb.name; + let strukt = trampoline_struct(name); + let mut w = CodeWriter::four_space(); + w.line("namespace detail {"); + w.blank(); + w.line(format!( + "/** Vtable trampolines adapting a `{name}` implementation to `{}`. */", + cb.vtable_tag + )); + w.line(format!("struct {strukt} {{")); + w.scope(|w| { + for m in &cb.methods { + render_trampoline(w, cb, m, module, prefix); + } + w.line( + "/** Releases the implementation box once the producer drops its last reference. */", + ); + w.line("static void free_ctx(void* ctx) {"); + w.scope(|w| { + w.line(format!( + "delete static_cast*>(ctx);" + )); + }); + w.line("}"); + }); + w.line("};"); + w.blank(); + + w.line(format!( + "/** The process-wide vtable every `{name}` implementation is passed with. */" + )); + w.line(format!( + "inline const {}& {}() {{", + cb.vtable_tag, + vtable_accessor(name) + )); + w.scope(|w| { + w.line(format!("static const {} vtable = {{", cb.vtable_tag)); + w.scope(|w| { + for m in &cb.methods { + w.line(format!("&{strukt}::{},", cpp_ident(&m.name))); + } + w.line(format!("&{strukt}::free_ctx,")); + }); + w.line("};"); + w.line("return vtable;"); + }); + w.line("}"); + w.blank(); + w.line("} // namespace detail"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit one method trampoline: a static function with the vtable entry's +/// exact C signature. +fn render_trampoline( + w: &mut CodeWriter, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + module: &str, + prefix: &str, +) { + let iface = &cb.name; + let method = cpp_fn_name(&m.name); + let c_ret = m.abi_ret.render_c(prefix); + let params = render_param_decls(&m.abi_params, prefix).join(", "); + + w.line(format!( + "static {c_ret} {}({params}) {{", + cpp_ident(&m.name) + )); + w.scope(|w| { + // Object arguments transfer one strong reference each. Adopting them + // cannot throw, so it happens before anything that can, which + // guarantees every reference is released even when a later argument + // fails to decode. + for p in &m.params { + if let ArgPass::Object { slot, nullable } = p.arg_pass() { + let class = local_type_name( + p.ty + .interface_name() + .expect("object arguments name an interface"), + ); + let slot = slot_name(slot); + let var = arg_var(p); + if nullable { + w.line(format!("std::optional<{class}> {var};")); + w.line(format!("if ({slot}) {var}.emplace({slot});")); + } else { + w.line(format!("{class} {var}({slot});")); + } + } + } + w.line("try {"); + w.scope(|w| { + w.line(format!( + "{iface}& impl = **static_cast*>(ctx);" + )); + let args: Vec = m + .params + .iter() + .map(|p| emit_trampoline_arg(w, p, module, prefix)) + .collect(); + let call = format!("impl.{method}({})", args.join(", ")); + // Validation restricts callback returns to the direct family, so + // the value is written straight into the C return. + match &m.ret { + None => { + w.line(format!("{call};")); + w.line("return;"); + } + Some(Ty::Enum(e)) => { + w.line(format!( + "return static_cast<{}>(static_cast({call}));", + c_abi_struct_name(e, module, prefix) + )); + } + Some(_) => { + w.line(format!("return {call};")); + } + } + }); + w.line("} catch (const std::exception& e) {"); + w.scope(|w| { + w.line(format!( + "{prefix}_error_set(out_err, {FOREIGN_ERROR_CODE}, e.what());" + )); + }); + w.line("} catch (...) {"); + w.scope(|w| { + w.line(format!( + "{prefix}_error_set(out_err, {FOREIGN_ERROR_CODE}, \"{iface}::{method} threw a non-standard exception\");" + )); + }); + w.line("}"); + if m.ret.is_some() { + w.line(format!("return {c_ret}{{}};")); + } + }); + w.line("}"); + w.blank(); +} + +/// The local variable holding one decoded or adopted trampoline argument. +fn arg_var(p: &ParamBinding) -> String { + format!("{}_val", p.name) +} + +/// Emit any decode statements for one trampoline argument and return the +/// expression handed to the virtual method. Strings, bytes, and buffered +/// values are borrowed for the call, so they are copied or decoded into owned +/// C++ values and nothing is freed. Objects were adopted before the `try` +/// block and are moved into the call. +fn emit_trampoline_arg(w: &mut CodeWriter, p: &ParamBinding, module: &str, prefix: &str) -> String { + let var = arg_var(p); + match p.arg_pass() { + ArgPass::Buffer { ptr, len } => { + let rdr = format!("{}_r", p.name); + w.line(format!( + "detail::BufferReader {rdr}({}, {});", + slot_name(ptr), + slot_name(len) + )); + emit_read_decl(w, &p.ty, &var, &rdr, module, prefix); + w.line(format!("{rdr}.expect_end();")); + var + } + ArgPass::String { slot } => { + let n = slot_name(slot); + w.line(format!("std::string {var}({n} ? {n} : \"\");")); + var + } + ArgPass::Bytes { ptr, len } => { + let n0 = slot_name(ptr); + let n1 = slot_name(len); + w.line(format!("std::vector {var}({n0}, {n0} + {n1});")); + var + } + ArgPass::Object { .. } => format!("std::move({var})"), + ArgPass::Direct { slot } => { + let n = slot_name(slot); + match &p.ty { + Ty::Enum(e) => format!( + "static_cast<{}>(static_cast({n}))", + local_type_name(e) + ), + _ => n, + } + } + ArgPass::Callback { .. } => { + unreachable!("callback interfaces are never callback-method parameters") + } + } +} diff --git a/crates/weaveffi-gen-cpp/src/calls.rs b/crates/weaveffi-gen-cpp/src/calls.rs index 2dc65cbd..94be775d 100644 --- a/crates/weaveffi-gen-cpp/src/calls.rs +++ b/crates/weaveffi-gen-cpp/src/calls.rs @@ -1,22 +1,28 @@ -//! Callable rendering: free functions, interface members, listeners, and the -//! sync, iterator, and async call shapes, each marshalled per the shared -//! passing plans ([`ArgPass`], [`RetPass`], `elem_free`). - -use heck::{ToSnakeCase, ToUpperCamelCase}; +//! Callable rendering: free functions and interface members in the sync, +//! iterator, and async call shapes, each marshalled per the shared passing +//! plans ([`ArgPass`], [`RetPass`], `IteratorProtocol`, `AsyncProtocol`). +//! +//! Interface members are rendered twice: a declaration inside the class body +//! (so the class is complete before any record that holds it by value) and an +//! out-of-line `inline` definition after every value type and codec exists. +//! Free functions are defined inline inside their module namespace, which is +//! rendered last. + +use heck::ToUpperCamelCase; use weaveffi_core::abi; use weaveffi_core::codegen::common::DocCommentStyle; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - AbiFn, AsyncBinding, CallShape, FnBinding, IteratorBinding, ListenerBinding, ModuleBinding, - ParamBinding, + AbiFn, AsyncBinding, CallShape, FnBinding, IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{elem_free, ret_pass, ArgPass, ElemFree, ErrorStrategy, RetPass}; +use weaveffi_core::plan::{ret_pass, ArgPass, ErrorStrategy, RetPass}; use weaveffi_core::utils::{c_abi_struct_name, local_type_name}; -use crate::codec::{emit_read_decl, emit_read_into, emit_write_value}; +use crate::codec::{emit_read_decl, emit_write_value}; use crate::types::{ cpp_fn_name, cpp_ident, cpp_namespace_path, cpp_param_decl, cpp_type, render_param_decls, + vtable_accessor, }; // ── Error routing ── @@ -49,21 +55,27 @@ fn make_error_call(f: &FnBinding, module: &ModuleBinding) -> String { // ── Parameter and return marshalling ── -/// The local interface name behind an object-passed type: the interface +/// The local wrapper class name behind an object-passed type: the interface /// itself, or the interface inside `Interface?`. -fn object_iface_name(ty: &Ty) -> &str { - match ty { - Ty::Interface(n) => n, - Ty::Optional(inner) => object_iface_name(inner), - _ => unreachable!("object passing only applies to interfaces"), - } +fn object_class(ty: &Ty) -> &str { + local_type_name( + ty.interface_name() + .expect("object passing only applies to interfaces"), + ) } /// Emit the setup statements for one C++ parameter and return the C argument /// expressions its ABI slots receive, dispatching on the parameter's -/// [`ArgPass`] plan. A buffered parameter is encoded into a local -/// `detail::BufferWriter` and passed as `(data(), size())`; the caller owns -/// the encoding for the duration of the call. +/// [`ArgPass`] plan. +/// +/// * A buffered parameter is encoded into a local `detail::BufferWriter` and +/// passed as `(data(), size())`; the caller owns the encoding for the +/// duration of the call. +/// * An object parameter is borrowed: the wrapper's pointer is passed and the +/// wrapper keeps its own reference. `Interface?` passes null for none. +/// * A callback interface moves the caller's `std::shared_ptr` into a heap +/// box that becomes `ctx`; the producer releases it through the vtable's +/// `free`. The vtable is the process-wide static for the interface. fn emit_param_setup( w: &mut CodeWriter, p: &ParamBinding, @@ -78,36 +90,38 @@ fn emit_param_setup( emit_write_value(w, &p.ty, &name, &buf, 0); vec![format!("{buf}.data()"), format!("{buf}.size()")] } - // `std::string::data()` is the non-const overload on a mutable - // parameter (C++17), yielding the `char*` the write-back slot wants. - ArgPass::String { .. } if p.mutable => vec![format!("{name}.data()")], ArgPass::String { .. } => vec![format!("{name}.c_str()")], ArgPass::Bytes { .. } => { vec![format!("{name}.data()"), format!("{name}.size()")] } - // An interface argument borrows: pass its raw handle as a const - // pointer, leaving ownership with the wrapper object. A nullable - // object (`Interface?`) passes null for "none". ArgPass::Object { nullable, .. } => { - let tag = c_abi_struct_name(object_iface_name(&p.ty), module, prefix); if nullable { - vec![format!( - "{name}.has_value() ? static_cast({name}.value().handle()) : nullptr" - )] + vec![format!("{name}.has_value() ? {name}->handle() : nullptr")] } else { - vec![format!("static_cast({name}.handle())")] + vec![format!("{name}.handle()")] } } + ArgPass::Callback { .. } => { + let iface = local_type_name( + p.ty.callback_interface_name() + .expect("callback passing only applies to callback interfaces"), + ); + w.line(format!( + "if (!{name}) throw std::invalid_argument(\"{name}: null callback interface\");" + )); + w.line(format!( + "auto* {name}_ctx = new std::shared_ptr<{iface}>(std::move({name}));" + )); + vec![ + format!("static_cast({name}_ctx)"), + format!("&detail::{}()", vtable_accessor(iface)), + ] + } ArgPass::Direct { .. } => match &p.ty { - Ty::Handle => vec![format!( - "static_cast<{prefix}_handle_t>(reinterpret_cast({name}))" - )], Ty::Enum(e) => vec![format!( "static_cast<{}>(static_cast({name}))", c_abi_struct_name(e, module, prefix) )], - // Scalars pass through; a typed handle is already the raw - // prefixed tag pointer. _ => vec![name], }, } @@ -117,7 +131,8 @@ fn emit_param_setup( /// return value at the writer's current depth, dispatching on the return's /// [`RetPass`] plan. A buffered return decodes the producer's buffer and /// releases it with `{prefix}_free_bytes` via a scope guard, so the release -/// happens even when decoding throws. +/// happens even when decoding throws. An object return is adopted into the +/// RAII wrapper, which owes the eventual `_destroy`. fn emit_sync_return(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) { match ret_pass(Some(ty), module, prefix) { RetPass::Buffer => { @@ -139,29 +154,19 @@ fn emit_sync_return(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) { )); w.line("return ret;"); } - // An owned interface pointer is adopted by the RAII class, which - // destroys it when the wrapper drops. A nullable return maps null to - // `std::nullopt`. RetPass::Object { nullable, .. } => { if nullable { w.line("if (!result) return std::nullopt;"); } - w.line(format!( - "return {}(result);", - local_type_name(object_iface_name(ty)) - )); + w.line(format!("return {}(result);", object_class(ty))); } RetPass::Direct => match ty { - Ty::Handle => { - w.line("return reinterpret_cast(static_cast(result));"); - } Ty::Enum(n) => { w.line(format!( "return static_cast<{}>(result);", local_type_name(n) )); } - // Scalars and typed handles (the raw tag pointer) pass through. _ => { w.line("return result;"); } @@ -170,80 +175,151 @@ fn emit_sync_return(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) { } } -// ── Callable rendering (free functions and interface members) ── +// ── Callable kinds ── /// How a rendered callable is declared in the C++ surface. #[derive(Clone, Copy)] pub(crate) enum FnKind<'a> { /// A namespace-scope free function (`inline` linkage). Free, - /// An instance method on an interface class: passes the wrapped handle as + /// An instance method on an interface class: passes the wrapped pointer as /// the leading C argument and is declared `const` (the ABI receiver is a /// const pointer). Method { - /// The interface's opaque C tag, used to cast `handle_` for the call. - c_tag: &'a str, + /// The wrapper class name. + class: &'a str, }, /// A static member function: interface statics and the factory form of /// constructors not named `new`. - Static, + Static { + /// The wrapper class name. + class: &'a str, + }, /// The canonical constructor (an interface constructor named `new`): - /// rendered as a real C++ constructor adopting the returned handle. - Ctor, + /// rendered as a real C++ constructor adopting the returned pointer. + Ctor { + /// The wrapper class name. + class: &'a str, + }, } -impl FnKind<'_> { - /// Leading declaration keyword for this kind. - fn keyword(self) -> &'static str { +impl<'a> FnKind<'a> { + /// The expression passed as the leading `self` C argument, when present. + fn self_arg(self) -> Option { match self { - FnKind::Free => "inline ", - FnKind::Method { .. } | FnKind::Ctor => "", - FnKind::Static => "static ", + FnKind::Method { .. } => Some("handle_".to_string()), + _ => None, } } - /// Nesting depth of the declaration: class members are one level deep. - fn depth(self) -> usize { + /// The owning class, for member kinds. + fn class(self) -> Option<&'a str> { match self { - FnKind::Free => 0, - _ => 1, + FnKind::Free => None, + FnKind::Method { class } | FnKind::Static { class } | FnKind::Ctor { class } => { + Some(class) + } } } +} - /// The expression passed as the leading `self` C argument, when present. - fn self_arg(self) -> Option { - match self { - FnKind::Method { c_tag } => Some(format!("static_cast(handle_)")), - _ => None, - } +/// The name of the lazy range class an iterator-returning callable yields: +/// `{PascalName}Iterator` for a free function (defined beside it in the module +/// namespace) and `{Class}{PascalName}Iterator` for an interface member +/// (defined at namespace scope after every value type). +pub(crate) fn iterator_class_name(f: &FnBinding, kind: FnKind<'_>) -> String { + let pascal = f.name.to_upper_camel_case(); + match kind.class() { + Some(class) => format!("{class}{pascal}Iterator"), + None => format!("{pascal}Iterator"), } +} - /// Trailing cv-qualifier on the declaration (methods are `const`). - fn const_qual(self) -> &'static str { - match self { - FnKind::Method { .. } => " const", - _ => "", - } +/// The C++ return type of a callable in its call shape: the mapped type (or +/// `void`) for sync, `std::future` for async, and the range class for an +/// iterator. `None` for a canonical constructor, which has no return type. +fn return_type(f: &FnBinding, kind: FnKind<'_>) -> Option { + if matches!(kind, FnKind::Ctor { .. }) { + return None; + } + let value = || f.ret.as_ref().map_or("void".to_string(), cpp_type); + Some(match &f.shape { + CallShape::Sync(_) => value(), + CallShape::Async(_) => format!("std::future<{}>", value()), + CallShape::Iterator(_) => iterator_class_name(f, kind), + }) +} + +/// The C++ parameter list of a callable. `with_defaults` adds the +/// `= nullptr` default on a cancellable async call's `cancel_token`, which +/// belongs on the declaration only. +fn param_decls(f: &FnBinding, prefix: &str, with_defaults: bool) -> String { + let mut decls: Vec = f + .params + .iter() + .map(|p| cpp_param_decl(&p.ty, &cpp_ident(&p.name))) + .collect(); + if f.is_async && f.cancellable { + let default = if with_defaults { " = nullptr" } else { "" }; + decls.push(format!("{prefix}_cancel_token* cancel_token{default}")); } + decls.join(", ") } /// Emit the doc comment and any `[[deprecated]]` attribute for a callable. -fn emit_callable_attrs(w: &mut CodeWriter, f: &FnBinding) { - w.doc(&f.doc, DocCommentStyle::Javadoc); +/// An iterator-returning callable's doc also names the range class it yields. +fn emit_callable_attrs(w: &mut CodeWriter, f: &FnBinding, kind: FnKind<'_>) { + let doc = if let CallShape::Iterator(_) = &f.shape { + let class_name = iterator_class_name(f, kind); + let return_doc = format!( + "@return A lazy `{class_name}` range that streams one element per iteration \ + step and releases the producer iterator when exhausted or destroyed." + ); + Some(match &f.doc { + Some(d) => format!("{d}\n\n{return_doc}"), + None => return_doc, + }) + } else { + f.doc.clone() + }; + w.doc(&doc, DocCommentStyle::Javadoc); if let Some(msg) = &f.deprecated { let escaped = msg.replace('"', "\\\""); w.line(format!("[[deprecated(\"{escaped}\")]]")); } } -/// Render one callable (free function or interface member) in whatever call -/// shape it lowers to. `cpp_name` is the already-cased C++ name (the class -/// name for a canonical constructor). +/// Render the in-class declaration of an interface member (one level of +/// indentation), with its doc comment and deprecation marker. +pub(crate) fn render_member_decl( + out: &mut String, + f: &FnBinding, + cpp_name: &str, + kind: FnKind<'_>, + prefix: &str, +) { + let mut w = CodeWriter::four_space().with_depth(1); + emit_callable_attrs(&mut w, f, kind); + let params = param_decls(f, prefix, true); + match (kind, return_type(f, kind)) { + (FnKind::Ctor { class }, _) => w.line(format!("{class}({params});")), + (FnKind::Method { .. }, Some(ret)) => w.line(format!("{ret} {cpp_name}({params}) const;")), + (FnKind::Static { .. }, Some(ret)) => w.line(format!("static {ret} {cpp_name}({params});")), + _ => unreachable!("free functions are defined inline, never declared"), + }; + w.blank(); + out.push_str(&w.finish()); +} + +/// Render the definition of a callable: an `inline` free function inside its +/// module namespace (with doc comment and deprecation marker), or the +/// out-of-line `inline` definition of an interface member declared earlier +/// by [`render_member_decl`]. /// /// Wrappers are deliberately never marked `noexcept`: a callable with /// `throws == false` still surfaces producer panics as the generic /// `WeaveFFIError`. -pub(crate) fn render_cpp_callable( +pub(crate) fn render_definition( out: &mut String, f: &FnBinding, cpp_name: &str, @@ -251,120 +327,108 @@ pub(crate) fn render_cpp_callable( module: &ModuleBinding, prefix: &str, ) { - match &f.shape { - CallShape::Sync(abi) => render_sync_callable(out, f, abi, cpp_name, kind, module, prefix), - CallShape::Iterator(it) => { - render_iterator_callable(out, f, it, cpp_name, kind, module, prefix) - } - CallShape::Async(a) => render_async_callable(out, f, a, cpp_name, kind, module, prefix), + let mut w = CodeWriter::four_space(); + if matches!(kind, FnKind::Free) { + emit_callable_attrs(&mut w, f, kind); } + let params = param_decls(f, prefix, false); + let ret = return_type(f, kind); + let header = match (kind, ret) { + (FnKind::Ctor { class }, _) => { + // The canonical constructor adopts the pointer the C constructor + // returns; `handle_` starts null so a throw from the error check + // leaves nothing for the destructor to release. + format!("inline {class}::{class}({params}) : handle_(nullptr) {{") + } + (FnKind::Method { class }, Some(ret)) => { + format!("inline {ret} {class}::{cpp_name}({params}) const {{") + } + (FnKind::Static { class }, Some(ret)) => { + format!("inline {ret} {class}::{cpp_name}({params}) {{") + } + (FnKind::Free, Some(ret)) => format!("inline {ret} {cpp_name}({params}) {{"), + _ => unreachable!("every non-constructor callable has a return type"), + }; + w.line(header); + w.scope(|w| match &f.shape { + CallShape::Sync(abi) => emit_sync_body(w, f, abi, kind, module, prefix), + CallShape::Iterator(it) => emit_iterator_launch_body(w, f, it, kind, module, prefix), + CallShape::Async(a) => emit_async_body(w, f, a, kind, module, prefix), + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); } -/// Render a synchronous callable: marshal the parameters (packing buffered -/// values into local buffers), call the C symbol, run the throws-split error -/// check, and marshal the return value (decoding buffered returns then -/// releasing the producer buffer). For a canonical constructor the "return" -/// adopts the handle instead. -fn render_sync_callable( - out: &mut String, +/// Emit a synchronous callable's body: marshal the parameters (packing +/// buffered values into local buffers), call the C symbol, run the +/// throws-split error check, and marshal the return value (decoding buffered +/// returns then releasing the producer buffer, adopting object returns). For +/// a canonical constructor the "return" adopts the pointer instead. +fn emit_sync_body( + w: &mut CodeWriter, f: &FnBinding, abi: &AbiFn, - cpp_name: &str, kind: FnKind<'_>, module: &ModuleBinding, prefix: &str, ) { - let mut w = CodeWriter::four_space().with_depth(kind.depth()); - emit_callable_attrs(&mut w, f); + let check = check_helper(f, module); + let mut c_args = Vec::new(); + if let Some(self_arg) = kind.self_arg() { + c_args.push(self_arg); + } + for p in &f.params { + c_args.extend(emit_param_setup(w, p, &module.path, prefix)); + } - let decls: Vec = f - .params - .iter() - .map(|p| cpp_param_decl(&p.ty, &cpp_ident(&p.name), p.mutable, &module.path, prefix)) - .collect(); + // A bytes or buffered return carries a trailing `size_t* out_len`. + let has_out_len = matches!( + ret_pass(f.ret.as_ref(), &module.path, prefix), + RetPass::Buffer | RetPass::Bytes + ); + if has_out_len { + w.line("size_t out_len = 0;"); + c_args.push("&out_len".into()); + } + c_args.push("&err".into()); + let args_str = c_args.join(", "); - let is_ctor = matches!(kind, FnKind::Ctor); - if is_ctor { - // The canonical constructor adopts the handle the C constructor - // returns; `handle_` starts null so a throw from the error check - // leaves nothing for the destructor to free. - w.line(format!( - "{cpp_name}({}) : handle_(nullptr) {{", - decls.join(", ") - )); + w.line(format!("{prefix}_error err{{}};")); + if f.ret.is_none() { + w.line(format!("{}({args_str});", abi.symbol)); } else { - let cpp_ret = f - .ret - .as_ref() - .map_or("void".to_string(), |r| cpp_type(r, &module.path, prefix)); - w.line(format!( - "{}{cpp_ret} {cpp_name}({}){} {{", - kind.keyword(), - decls.join(", "), - kind.const_qual() - )); + w.line(format!("auto result = {}({args_str});", abi.symbol)); } + w.line(format!("{check}(err);")); - let check = check_helper(f, module); - w.scope(|w| { - let mut c_args = Vec::new(); - if let Some(self_arg) = kind.self_arg() { - c_args.push(self_arg); - } - for p in &f.params { - c_args.extend(emit_param_setup(w, p, &module.path, prefix)); - } - - // A bytes or buffered return carries a trailing `size_t* out_len`. - let has_out_len = matches!( - ret_pass(f.ret.as_ref(), &module.path, prefix), - RetPass::Buffer | RetPass::Bytes - ); - if has_out_len { - w.line("size_t out_len = 0;"); - c_args.push("&out_len".into()); - } - c_args.push("&err".into()); - let args_str = c_args.join(", "); - - w.line(format!("{prefix}_error err{{}};")); - if f.ret.is_none() { - w.line(format!("{}({args_str});", abi.symbol)); - } else { - w.line(format!("auto result = {}({args_str});", abi.symbol)); - } - w.line(format!("{check}(err);")); - - if is_ctor { - w.line("handle_ = result;"); - } else if let Some(ret) = &f.ret { - emit_sync_return(w, ret, &module.path, prefix); - } - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + if matches!(kind, FnKind::Ctor { .. }) { + w.line("handle_ = result;"); + } else if let Some(ret) = &f.ret { + emit_sync_return(w, ret, &module.path, prefix); + } } -/// Render an iterator-returning callable as a lazy range. +// ── Iterators ── + +/// Render the lazy range class an iterator-returning callable yields. /// -/// The C ABI yields an opaque iterator handle plus `_next`/`_destroy`. The -/// wrapper emits a per-function move-only RAII range class (named -/// `{PascalName}Iterator`) that owns the handle and pulls exactly one element +/// The C ABI yields an opaque iterator pointer plus `_next`/`_destroy`. The +/// range class is move-only, owns the pointer, and pulls exactly one element /// per consumer step, honoring the `iter` streaming contract /// (`weaveffi_core::plan::IteratorProtocol`): /// /// * `begin()`/`end()` expose a single-pass input iterator with a sentinel /// end, so `for (auto&& item : fn())` streams in constant memory. -/// * Each pulled element is converted and then released per the plan's -/// `elem_free` (strings copied then `{prefix}_free_string`; bytes and -/// buffered values copied or decoded then `{prefix}_free_bytes`). +/// * Each pulled element is received per the plan's `elem` (strings copied +/// then `{prefix}_free_string`; bytes and buffered values copied or decoded +/// then `{prefix}_free_bytes`; objects adopted into their RAII wrapper). /// * `destroy` runs exactly once: eagerly on exhaustion or a `next` error, -/// from the destructor otherwise. The handle is nulled on every path. +/// from the destructor otherwise. The pointer is nulled on every path. /// * Launch and per-`next` errors follow the callable's [`ErrorStrategy`] /// (the typed domain exception for `Throws`, the generic `WeaveFFIError` /// trap otherwise). -fn render_iterator_callable( +pub(crate) fn render_iterator_range( out: &mut String, f: &FnBinding, it: &IteratorBinding, @@ -373,18 +437,21 @@ fn render_iterator_callable( module: &ModuleBinding, prefix: &str, ) { - let elem_cpp = cpp_type(&it.elem, &module.path, prefix); - let class_name = format!("{}Iterator", f.name.to_upper_camel_case()); + let elem_cpp = cpp_type(&it.elem); + let class_name = iterator_class_name(f, kind); let iter_tag = &it.iter_tag; let destroy = &it.destroy_symbol; let check = check_helper(f, module); + let owner = match kind.class() { + Some(class) => format!("{class}::{cpp_name}()"), + None => format!("{cpp_name}()"), + }; - // ── The lazy range class ── - let mut w = CodeWriter::four_space().with_depth(kind.depth()); + let mut w = CodeWriter::four_space(); w.doc( &Some(format!( "A lazy, move-only range over the `{elem_cpp}` elements produced by \ - `{cpp_name}()`.\n\nEach iteration step pulls exactly one element from the \ + `{owner}`.\n\nEach iteration step pulls exactly one element from the \ producer, so results stream in constant memory. The range owns the \ producer-side iterator and releases it exactly once: eagerly when the \ range is exhausted, or from the destructor when iteration is abandoned \ @@ -399,7 +466,7 @@ fn render_iterator_callable( }); w.line("public:"); w.scope(|w| { - w.line("/** Adopts ownership of the raw producer iterator handle. */"); + w.line("/** Adopts ownership of the raw producer iterator. */"); w.line(format!( "explicit {class_name}({iter_tag}* h) : handle_(h) {{}}" )); @@ -488,61 +555,45 @@ fn render_iterator_callable( }); w.line("};"); w.blank(); + out.push_str(&w.finish()); +} - // ── The launching wrapper ── - let return_doc = format!( - "@return A lazy `{class_name}` range that streams one element per iteration \ - step and releases the producer iterator when exhausted or destroyed." - ); - let fn_doc = match &f.doc { - Some(d) => format!("{d}\n\n{return_doc}"), - None => return_doc, - }; - w.doc(&Some(fn_doc), DocCommentStyle::Javadoc); - if let Some(msg) = &f.deprecated { - let escaped = msg.replace('"', "\\\""); - w.line(format!("[[deprecated(\"{escaped}\")]]")); +/// Emit an iterator-returning callable's body: marshal the parameters, call +/// the launcher, check `out_err`, and wrap the returned pointer in the range +/// class. +fn emit_iterator_launch_body( + w: &mut CodeWriter, + f: &FnBinding, + it: &IteratorBinding, + kind: FnKind<'_>, + module: &ModuleBinding, + prefix: &str, +) { + let class_name = iterator_class_name(f, kind); + let check = check_helper(f, module); + let mut c_args = Vec::new(); + if let Some(self_arg) = kind.self_arg() { + c_args.push(self_arg); } - - let decls: Vec = f - .params - .iter() - .map(|p| cpp_param_decl(&p.ty, &cpp_ident(&p.name), p.mutable, &module.path, prefix)) - .collect(); + for p in &f.params { + c_args.extend(emit_param_setup(w, p, &module.path, prefix)); + } + c_args.push("&err".into()); + w.line(format!("{prefix}_error err{{}};")); w.line(format!( - "{}{class_name} {cpp_name}({}){} {{", - kind.keyword(), - decls.join(", "), - kind.const_qual() + "{}* iter = {}({});", + it.iter_tag, + it.launch.symbol, + c_args.join(", ") )); - - w.scope(|w| { - let mut c_args = Vec::new(); - if let Some(self_arg) = kind.self_arg() { - c_args.push(self_arg); - } - for p in &f.params { - c_args.extend(emit_param_setup(w, p, &module.path, prefix)); - } - c_args.push("&err".into()); - w.line(format!("{prefix}_error err{{}};")); - w.line(format!( - "{iter_tag}* iter = {}({});", - it.launch.symbol, - c_args.join(", ") - )); - w.line(format!("{check}(err);")); - w.line(format!("return {class_name}(iter);")); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + w.line(format!("{check}(err);")); + w.line(format!("return {class_name}(iter);")); } /// Emit the range class's `next()` member: one producer `next` call that -/// yields the converted element (or `std::nullopt` on exhaustion), releasing -/// the pulled slot per the plan's `elem_free` and destroying the handle -/// exactly once on exhaustion or error. +/// yields the received element (or `std::nullopt` on exhaustion), releasing +/// the pulled slot per the plan's `elem` and destroying the iterator exactly +/// once on exhaustion or error. fn render_iterator_next_method( w: &mut CodeWriter, f: &FnBinding, @@ -551,11 +602,10 @@ fn render_iterator_next_method( prefix: &str, check: &str, ) { - let elem_cpp = cpp_type(&it.elem, &module.path, prefix); + let elem_cpp = cpp_type(&it.elem); let destroy = &it.destroy_symbol; let item_ret = abi::lower_return(&it.elem, &module.path); let item_ty = item_ret.ret.render_c(prefix); - let ef = elem_free(&it.elem); let strategy_doc = match f.error_strategy() { ErrorStrategy::Throws => "throws the module's typed exception", ErrorStrategy::Trap => "throws the generic WeaveFFIError", @@ -599,122 +649,118 @@ fn render_iterator_next_method( w.line("return std::nullopt;"); }); w.line("}"); - if it.elem.is_buffered() { - // A buffered element is producer-allocated: decode into an owned - // value, then release with free_bytes via the scope guard. - w.line("detail::BufferGuard item_guard{item, item_len};"); - w.line("detail::BufferReader item_r(item, item_len);"); - emit_read_decl(w, &it.elem, "value", "item_r", &module.path, prefix); - w.line("item_r.expect_end();"); - w.line("return value;"); - } else { - match (&it.elem, &ef) { - // Byte-buffer elements copy then release the producer buffer. - (Ty::Bytes | Ty::BorrowedBytes, _) => { - w.line("std::vector value(item, item + item_len);"); - w.line(format!( - "{prefix}_free_bytes(const_cast(item), item_len);" - )); - w.line("return value;"); - } - (_, ElemFree::String) => { - w.line("std::string value(item);"); - w.line(format!("{prefix}_free_string(item);")); + match ret_pass(Some(&it.elem), &module.path, prefix) { + RetPass::Buffer => { + // A buffered element is producer-allocated: decode into an + // owned value, then release with free_bytes via the scope + // guard. + w.line("detail::BufferGuard item_guard{item, item_len};"); + w.line("detail::BufferReader item_r(item, item_len);"); + emit_read_decl(w, &it.elem, "value", "item_r", &module.path, prefix); + w.line("item_r.expect_end();"); + w.line("return value;"); + } + RetPass::Bytes => { + w.line("std::vector value(item, item + item_len);"); + w.line(format!( + "{prefix}_free_bytes(const_cast(item), item_len);" + )); + w.line("return value;"); + } + RetPass::String => { + w.line("std::string value(item);"); + w.line(format!("{prefix}_free_string(item);")); + w.line("return value;"); + } + // An object element transfers one strong reference: adopt it. A + // nullable element yields an engaged outer optional holding an + // empty inner one for null, which is distinct from exhaustion. + RetPass::Object { nullable, .. } => { + let class = object_class(&it.elem); + if nullable { + w.line(format!("{elem_cpp} value;")); + w.line("if (item) value.emplace(item);"); w.line("return value;"); + } else { + w.line(format!("return {class}(item);")); } - (Ty::Enum(n), _) => { + } + RetPass::Direct => match &it.elem { + Ty::Enum(n) => { let n = local_type_name(n); w.line(format!("return static_cast<{n}>(item);")); } - (Ty::Handle, _) => { - w.line("return reinterpret_cast(static_cast(item));"); - } _ => { w.line("return item;"); } - } + }, + RetPass::Void => unreachable!("iterator elements are never void"), } }); w.line("}"); w.blank(); } -/// Render an asynchronous callable as a `std::future` wrapper. The promise is -/// heap-allocated, threaded through the C `context` pointer, settled by the -/// completion callback, and deleted exactly once. A callback error settles -/// the promise with the typed domain exception (payload fields decoded) when -/// the callable throws, or the generic `WeaveFFIError` otherwise. The -/// callback owns everything it receives: the boxed error and any string or -/// buffer result are released through the runtime free symbols after -/// copying. -fn render_async_callable( - out: &mut String, +// ── Async ── + +/// Emit an asynchronous callable's body as a `std::future` wrapper. The +/// promise is heap-allocated, threaded through the C `context` pointer, +/// settled by the completion callback, and deleted exactly once. A callback +/// error settles the promise with the typed domain exception (payload fields +/// decoded) when the callable throws, or the generic `WeaveFFIError` +/// otherwise. The callback owns everything it receives: the boxed error and +/// any string or buffer result are released through the runtime free symbols +/// after copying, and an object result is adopted. +fn emit_async_body( + w: &mut CodeWriter, f: &FnBinding, a: &AsyncBinding, - cpp_name: &str, kind: FnKind<'_>, module: &ModuleBinding, prefix: &str, ) { - let cpp_ret = f - .ret - .as_ref() - .map_or("void".to_string(), |r| cpp_type(r, &module.path, prefix)); - let mut w = CodeWriter::four_space().with_depth(kind.depth()); - emit_callable_attrs(&mut w, f); + let cpp_ret = f.ret.as_ref().map_or("void".to_string(), cpp_type); + let cb_params = render_param_decls(&a.callback_params, prefix).join(", "); + let make_error = make_error_call(f, module); - let mut decls: Vec = f - .params - .iter() - .map(|p| cpp_param_decl(&p.ty, &cpp_ident(&p.name), p.mutable, &module.path, prefix)) - .collect(); - if f.cancellable { - decls.push(format!("{prefix}_cancel_token* cancel_token = nullptr")); - } w.line(format!( - "{}std::future<{cpp_ret}> {cpp_name}({}){} {{", - kind.keyword(), - decls.join(", "), - kind.const_qual() + "auto* promise_ptr = new std::promise<{cpp_ret}>();" )); + w.line("auto future = promise_ptr->get_future();"); - let cb_params = render_param_decls(&a.callback_params, prefix).join(", "); - let make_error = make_error_call(f, module); + let mut c_args = Vec::new(); + if let Some(self_arg) = kind.self_arg() { + c_args.push(self_arg); + } + for p in &f.params { + c_args.extend(emit_param_setup(w, p, &module.path, prefix)); + } + if f.cancellable { + c_args.push("cancel_token".to_string()); + } + + if c_args.is_empty() { + w.line(format!("{}([]({cb_params}) {{", a.launch.symbol)); + } else { + w.line(format!( + "{}({}, []({cb_params}) {{", + a.launch.symbol, + c_args.join(", ") + )); + } w.scope(|w| { w.line(format!( - "auto* promise_ptr = new std::promise<{cpp_ret}>();" + "auto* p = static_cast*>(context);" )); - w.line("auto future = promise_ptr->get_future();"); - - let mut c_args = Vec::new(); - if let Some(self_arg) = kind.self_arg() { - c_args.push(self_arg); - } - for p in &f.params { - c_args.extend(emit_param_setup(w, p, &module.path, prefix)); - } - if f.cancellable { - c_args.push("cancel_token".to_string()); - } - - if c_args.is_empty() { - w.line(format!("{}([]({cb_params}) {{", a.launch.symbol)); - } else { - w.line(format!( - "{}({}, []({cb_params}) {{", - a.launch.symbol, - c_args.join(", ") - )); - } + // The callback runs on a producer thread inside a C frame, so a + // decode failure (of the result or of an error payload) must settle + // the promise, never unwind. + w.line("try {"); w.scope(|w| { - w.line(format!( - "auto* p = static_cast*>(context);" - )); w.line("if (err && err->code != 0) {"); w.scope(|w| { w.line("std::string msg(err->message ? err->message : \"unknown error\");"); w.line(format!("p->set_exception({make_error});")); - w.line(format!("{prefix}_error_free(err);")); }); w.line("} else {"); w.scope(|w| { @@ -725,14 +771,17 @@ fn render_async_callable( } }); w.line("}"); - w.line("delete p;"); }); - w.line("}, static_cast(promise_ptr));"); - w.line("return future;"); + w.line("} catch (...) {"); + w.scope(|w| { + w.line("p->set_exception(std::current_exception());"); + }); + w.line("}"); + w.line(format!("{prefix}_error_free(err);")); + w.line("delete p;"); }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + w.line("}, static_cast(promise_ptr));"); + w.line("return future;"); } /// Settle an async promise from the callback's result slots at the writer's @@ -746,16 +795,12 @@ fn render_async_callable( fn emit_async_set_value(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) { match ret_pass(Some(ty), module, prefix) { RetPass::Buffer => { - // Owned `(result_ptr, result_len)` buffer: decode, then free. + w.line("detail::BufferGuard result_guard{result_ptr, result_len};"); w.line("detail::BufferReader result_r(result_ptr, result_len);"); emit_read_decl(w, ty, "value", "result_r", module, prefix); w.line("result_r.expect_end();"); - w.line(format!( - "{prefix}_free_bytes(const_cast(result_ptr), result_len);" - )); w.line("p->set_value(std::move(value));"); } - // Owned by the consumer: copy, then free. RetPass::String => { w.line("std::string value(result ? result : \"\");"); w.line(format!("{prefix}_free_string(result);")); @@ -768,10 +813,8 @@ fn emit_async_set_value(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) )); w.line("p->set_value(std::move(value));"); } - // Owned interface result: the callback receives ownership; adopt it. - // A nullable result maps null to `std::nullopt`. RetPass::Object { nullable, .. } => { - let ln = local_type_name(object_iface_name(ty)); + let class = object_class(ty); if nullable { w.line("if (!result) {"); w.scope(|w| { @@ -779,24 +822,20 @@ fn emit_async_set_value(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) }); w.line("} else {"); w.scope(|w| { - w.line(format!("p->set_value({ln}(result));")); + w.line(format!("p->set_value({class}(result));")); }); w.line("}"); } else { - w.line(format!("p->set_value({ln}(result));")); + w.line(format!("p->set_value({class}(result));")); } } RetPass::Direct => match ty { - Ty::Handle => { - w.line("p->set_value(reinterpret_cast(static_cast(result)));"); - } Ty::Enum(n) => { w.line(format!( "p->set_value(static_cast<{}>(result));", local_type_name(n) )); } - // Scalars and typed handles (the raw tag pointer) pass through. _ => { w.line("p->set_value(result);"); } @@ -805,163 +844,24 @@ fn emit_async_set_value(w: &mut CodeWriter, ty: &Ty, module: &str, prefix: &str) } } -// ── Namespace: per-module function namespaces and listeners ── +// ── Namespace: per-module function namespaces ── -/// Emit one module's nested namespace holding its listeners and free -/// functions with bare snake_case names (`namespace kv::stats { ... }`). -/// Modules with no functions or listeners emit nothing; their types live at -/// the namespace root. +/// Emit one module's nested namespace holding its free functions with bare +/// snake_case names (`namespace kv::stats { ... }`). An iterator-returning +/// function is preceded by its range class. Modules with no functions emit +/// nothing; their types live at the namespace root. pub(crate) fn render_cpp_module_ns(out: &mut String, module: &ModuleBinding, prefix: &str) { - if module.functions.is_empty() && module.listeners.is_empty() { + if module.functions.is_empty() { return; } let ns = cpp_namespace_path(module); out.push_str(&format!("namespace {ns} {{\n\n")); - for l in &module.listeners { - render_cpp_listener(out, module, l, prefix); - } for f in &module.functions { - render_cpp_callable(out, f, &cpp_fn_name(&f.name), FnKind::Free, module, prefix); - } - out.push_str(&format!("}} // namespace {ns}\n\n")); -} - -/// The C++ type one callback parameter surfaces as in the user callback. -/// Buffered values are decoded before dispatch, so they surface as full C++ -/// value types. Interface and typed-handle parameters stay raw borrowed -/// pointers: wrapping a borrowed handle in the owning RAII class would -/// `_destroy` it on destruction. -fn cpp_cb_param_type(ty: &Ty, module: &str, prefix: &str) -> String { - match ty { - Ty::Interface(n) => format!("const {}*", c_abi_struct_name(n, module, prefix)), - Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { - cpp_cb_param_type(inner, module, prefix) + let name = cpp_fn_name(&f.name); + if let CallShape::Iterator(it) = &f.shape { + render_iterator_range(out, f, it, &name, FnKind::Free, module, prefix); } - other => cpp_type(other, module, prefix), + render_definition(out, f, &name, FnKind::Free, module, prefix); } -} - -/// Emit any decode statements for one callback parameter and return the -/// expression handed to the user's `std::function`, dispatching on the -/// parameter's [`ArgPass`] plan. Buffered arguments are borrowed `(ptr, len)` -/// pairs valid only during the dispatch, so they are decoded into owned C++ -/// values before the user callback runs. -fn emit_cb_arg(w: &mut CodeWriter, p: &ParamBinding, module: &str, prefix: &str) -> String { - match p.arg_pass() { - ArgPass::Buffer { ptr, len } => { - let n0 = &ptr.name; - let n1 = &len.name; - let var = format!("{}_val", p.name); - let rdr = format!("{}_r", p.name); - let cpp = cpp_type(&p.ty, module, prefix); - w.line(format!("{cpp} {var}{{}};")); - w.line(format!("if ({n0} != nullptr) {{")); - w.scope(|w| { - w.line(format!("detail::BufferReader {rdr}({n0}, {n1});")); - emit_read_into(w, &p.ty, &var, &var, &rdr, module, prefix); - w.line(format!("{rdr}.expect_end();")); - }); - w.line("}"); - format!("std::move({var})") - } - ArgPass::String { slot } => { - let n0 = &slot.name; - format!("std::string({n0} ? {n0} : \"\")") - } - ArgPass::Bytes { ptr, len } => { - let n0 = &ptr.name; - let n1 = &len.name; - format!("{n0} ? std::vector({n0}, {n0} + {n1}) : std::vector{{}}") - } - // Borrowed for the duration of the callback; passed through raw. - ArgPass::Object { slot, .. } => slot.name.clone(), - ArgPass::Direct { slot } => { - let n0 = &slot.name; - match &p.ty { - Ty::Enum(e) => format!( - "static_cast<{}>(static_cast({n0}))", - local_type_name(e) - ), - Ty::Handle => { - format!("reinterpret_cast(static_cast({n0}))") - } - // Typed handles stay the raw borrowed tag pointer. - _ => n0.clone(), - } - } - } -} - -/// The register/unregister pair for one listener. The user `std::function` is -/// heap-boxed and threaded through the C `context` pointer; a capture-free -/// lambda (convertible to the C function pointer) unboxes and invokes it, -/// decoding any borrowed buffered arguments first. -fn render_cpp_listener( - out: &mut String, - module: &ModuleBinding, - l: &ListenerBinding, - prefix: &str, -) { - let Some(cb) = module.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - - let fn_params: Vec = cb - .params - .iter() - .map(|p| cpp_cb_param_type(&p.ty, &module.path, prefix)) - .collect(); - let std_fn = format!("std::function", fn_params.join(", ")); - - let lambda_params = render_param_decls(&cb.abi_params, prefix).join(", "); - - let register_name = format!("register_{}", l.name.to_snake_case()); - let unregister_name = format!("unregister_{}", l.name.to_snake_case()); - - let mut w = CodeWriter::four_space(); - w.doc(&l.doc, DocCommentStyle::Javadoc); - w.line(format!( - "/** @return A subscription id for {unregister_name}(). */" - )); - w.line(format!( - "inline uint64_t {register_name}({std_fn} callback) {{" - )); - w.scope(|w| { - w.line(format!( - "auto fn = std::make_shared<{std_fn}>(std::move(callback));" - )); - w.line(format!("uint64_t id = {}(", l.register_symbol)); - w.scope(|w| { - w.line(format!("[]({lambda_params}) {{")); - w.scope(|w| { - w.line(format!("auto& cb = *static_cast<{std_fn}*>(context);")); - let args: Vec = cb - .params - .iter() - .map(|p| emit_cb_arg(w, p, &module.path, prefix)) - .collect(); - w.line(format!("cb({});", args.join(", "))); - }); - w.line("},"); - w.line("fn.get());"); - }); - w.line("std::lock_guard lock(detail::wv_listener_mutex());"); - w.line("detail::wv_listener_registry()[id] = fn;"); - w.line("return id;"); - }); - w.line("}"); - w.blank(); - - w.line(format!( - "/** Unregisters a listener previously registered with {register_name}(). */" - )); - w.line(format!("inline void {unregister_name}(uint64_t id) {{")); - w.scope(|w| { - w.line(format!("{}(id);", l.unregister_symbol)); - w.line("std::lock_guard lock(detail::wv_listener_mutex());"); - w.line("detail::wv_listener_registry().erase(id);"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + out.push_str(&format!("}} // namespace {ns}\n\n")); } diff --git a/crates/weaveffi-gen-cpp/src/codec.rs b/crates/weaveffi-gen-cpp/src/codec.rs index a2f33c03..c85446fa 100644 --- a/crates/weaveffi-gen-cpp/src/codec.rs +++ b/crates/weaveffi-gen-cpp/src/codec.rs @@ -2,10 +2,11 @@ //! values through the WeaveFFI wire format, plus the per-type pack and unpack //! routines. //! -//! Every encode/decode decision dispatches on [`wire::classify`], the shared -//! wire classification, so this backend cannot drift from the wire format's -//! canonical folds (handles as `u64` tokens, borrowed views as their owned -//! forms, records and rich enums as one user-codec shape). +//! Every encode/decode decision dispatches on [`Ty::wire`], the shared wire +//! classification, so this backend cannot drift from the wire format's +//! canonical shapes: primitives, enums as `i32`, records and rich enums as +//! one user-codec shape, and interfaces as `u64` object tokens carrying one +//! strong reference. use weaveffi_core::codegen::common::DocCommentStyle; use weaveffi_core::codegen::CodeWriter; @@ -14,11 +15,15 @@ use weaveffi_core::model::{EnumBinding, StructBinding}; use weaveffi_core::model::{Prim, WireType}; use weaveffi_core::utils::local_type_name; -use crate::types::{cpp_ident, cpp_type}; +use crate::types::{cpp_ident, cpp_type, interface_c_tag}; /// Emit statements appending `expr` (a C++ lvalue of IDL type `ty`) to the /// buffer writer variable `wtr`, in wire order. `depth` disambiguates nested /// loop variable names. +/// +/// An object token is written from a *fresh* strong reference: the wrapper's +/// `clone_handle()` calls the interface's `_clone` symbol, so the reader +/// adopts a reference of its own and the wrapper keeps the one it holds. pub(crate) fn emit_write_value(w: &mut CodeWriter, ty: &Ty, expr: &str, wtr: &str, depth: usize) { let leaf = |w: &mut CodeWriter, method: &str| { w.line(format!("{wtr}.{method}({expr});")); @@ -40,10 +45,9 @@ pub(crate) fn emit_write_value(w: &mut CodeWriter, ty: &Ty, expr: &str, wtr: &st WireType::Enum(_) => { w.line(format!("{wtr}.write_i32(static_cast({expr}));")); } - // Handles are opaque tokens encoded as their pointer bits in a u64. - WireType::Handle(_) => { + WireType::Object(_) => { w.line(format!( - "{wtr}.write_u64(static_cast(reinterpret_cast({expr})));" + "{wtr}.write_u64(static_cast(reinterpret_cast({expr}.clone_handle())));" )); } WireType::User(n) => { @@ -78,6 +82,9 @@ pub(crate) fn emit_write_value(w: &mut CodeWriter, ty: &Ty, expr: &str, wtr: &st /// The single expression decoding one leaf value from the reader variable /// `rdr`, or `None` when `ty` is a composite that needs statements. +/// +/// An object token decodes into a new wrapper that adopts the strong +/// reference the token carries; the wrapper's destructor releases it. fn read_leaf_expr(ty: &Ty, rdr: &str, module: &str, prefix: &str) -> Option { Some(match ty.wire() { WireType::Prim(Prim::Bool) => format!("{rdr}.read_bool()"), @@ -94,11 +101,10 @@ fn read_leaf_expr(ty: &Ty, rdr: &str, module: &str, prefix: &str) -> Option format!("{rdr}.read_string()"), WireType::Prim(Prim::Bytes) => format!("{rdr}.read_bytes()"), WireType::Enum(n) => format!("static_cast<{}>({rdr}.read_i32())", local_type_name(n)), - // Both handle spellings decode from the same u64 token; the C++ - // pointer type (`void*` or the prefixed tag) comes from the type map. - WireType::Handle(_) => format!( - "reinterpret_cast<{}>(static_cast({rdr}.read_u64()))", - cpp_type(ty, module, prefix) + WireType::Object(n) => format!( + "{}(reinterpret_cast<{}*>(static_cast({rdr}.read_u64())))", + local_type_name(n), + interface_c_tag(n, module, prefix) ), WireType::User(n) => format!("detail::read_{}({rdr})", local_type_name(n)), WireType::Optional(_) | WireType::List(_) | WireType::Map(_, _) => return None, @@ -174,7 +180,11 @@ pub(crate) fn emit_read_into( /// Emit statements declaring a fresh variable `var` and decoding one value of /// IDL type `ty` into it from the reader variable `rdr`. Leaf types decode in -/// a single declaration; composites declare then fill. +/// a single declaration; composites (optionals, lists, and maps, which are +/// always default constructible) declare then fill. Records are never +/// default constructed here: a record holding an interface has no default +/// constructor, so record values are only ever produced by their `read_` +/// routine. pub(crate) fn emit_read_decl( w: &mut CodeWriter, ty: &Ty, @@ -183,7 +193,7 @@ pub(crate) fn emit_read_decl( module: &str, prefix: &str, ) { - let cpp = cpp_type(ty, module, prefix); + let cpp = cpp_type(ty); if let Some(expr) = read_leaf_expr(ty, rdr, module, prefix) { w.line(format!("{cpp} {var} = {expr};")); } else { @@ -193,6 +203,12 @@ pub(crate) fn emit_read_decl( } /// Emit the pack and unpack routines for one record (inside `detail`). +/// +/// The unpack routine decodes every field into a local and then +/// aggregate-initializes the record in one expression, so a record that holds +/// an interface (and therefore has no default constructor) decodes like any +/// other. If a later field fails to decode, the locals already holding +/// adopted object references are destroyed on unwind, releasing them. pub(crate) fn render_record_codec(out: &mut String, s: &StructBinding, module: &str, prefix: &str) { let name = &s.name; let mut w = CodeWriter::four_space(); @@ -228,20 +244,13 @@ pub(crate) fn render_record_codec(out: &mut String, s: &StructBinding, module: & if s.fields.is_empty() { w.line("(void)r;"); } - w.line(format!("{name} out{{}};")); + let mut inits = Vec::new(); for f in &s.fields { - let member = cpp_ident(&f.name); - emit_read_into( - w, - &f.ty, - &format!("out.{member}"), - &format!("v_{}", f.name), - "r", - module, - prefix, - ); + let var = format!("f_{}", f.name); + emit_read_decl(w, &f.ty, &var, "r", module, prefix); + inits.push(format!("std::move({var})")); } - w.line("return out;"); + w.line(format!("return {name}{{{}}};", inits.join(", "))); }); w.line("}"); w.blank(); @@ -249,7 +258,8 @@ pub(crate) fn render_record_codec(out: &mut String, s: &StructBinding, module: & } /// Emit the pack and unpack routines for one rich enum (inside `detail`): -/// an `i32` tag followed by the active variant's fields in wire order. +/// an `i32` tag followed by the active variant's fields in wire order. Like +/// records, variant payloads are aggregate-initialized from decoded locals. pub(crate) fn render_rich_enum_codec( out: &mut String, e: &EnumBinding, @@ -306,19 +316,16 @@ pub(crate) fn render_rich_enum_codec( if variant.fields.is_empty() { w.line(format!("return {name}{{{name}::{vn}{{}}}};")); } else { - w.line(format!("{name}::{vn} p{{}};")); + let mut inits = Vec::new(); for f in &variant.fields { - emit_read_into( - w, - &f.ty, - &format!("p.{}", cpp_ident(&f.name)), - &format!("v_{}", f.name), - "r", - module, - prefix, - ); + let var = format!("f_{}", f.name); + emit_read_decl(w, &f.ty, &var, "r", module, prefix); + inits.push(format!("std::move({var})")); } - w.line(format!("return {name}{{std::move(p)}};")); + w.line(format!( + "return {name}{{{name}::{vn}{{{}}}}};", + inits.join(", ") + )); } }); w.line("}"); diff --git a/crates/weaveffi-gen-cpp/src/entities.rs b/crates/weaveffi-gen-cpp/src/entities.rs index e5ab0708..44c4cf46 100644 --- a/crates/weaveffi-gen-cpp/src/entities.rs +++ b/crates/weaveffi-gen-cpp/src/entities.rs @@ -8,11 +8,13 @@ use weaveffi_core::codegen::common::DocCommentStyle; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - CallShape, EnumBinding, ErrorBinding, InterfaceBinding, ModuleBinding, StructBinding, + CallShape, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, ModuleBinding, StructBinding, }; use weaveffi_core::utils::local_type_name; -use crate::calls::{render_cpp_callable, FnKind}; +use crate::calls::{ + iterator_class_name, render_definition, render_iterator_range, render_member_decl, FnKind, +}; use crate::codec::emit_read_decl; use crate::types::{cpp_error_class, cpp_fn_name, cpp_ident, cpp_type}; @@ -24,8 +26,6 @@ use crate::types::{cpp_error_class, cpp_fn_name, cpp_ident, cpp_type}; pub(crate) fn render_cpp_enums(out: &mut String, module: &ModuleBinding) { let mut w = CodeWriter::four_space(); for e in &module.enums { - // Rich (algebraic) enums are value types, emitted as variant structs - // alongside records; only plain C-style enums map to `enum class`. if e.is_rich() { continue; } @@ -86,7 +86,8 @@ impl ValueDef<'_> { } /// Collect the local names of value types (records and rich enums) reachable -/// from `ty`, recursing through optional/list/map wrappers. +/// from `ty`, recursing through optional/list/map wrappers. Interfaces are +/// not collected: every interface class is complete before any value type. fn collect_value_deps(ty: &Ty, deps: &mut Vec) { match ty { Ty::Record(n) | Ty::RichEnum(n) => deps.push(local_type_name(n).to_string()), @@ -99,34 +100,6 @@ fn collect_value_deps(ty: &Ty, deps: &mut Vec) { } } -/// Local names of other interfaces referenced by an interface's member -/// signatures (returned or accepted by value, so their classes must be -/// complete first). -pub(crate) fn interface_deps(i: &InterfaceBinding) -> Vec { - fn collect(ty: &Ty, deps: &mut Vec) { - match ty { - Ty::Interface(n) => deps.push(local_type_name(n).to_string()), - Ty::Optional(inner) | Ty::Iterator(inner) => collect(inner, deps), - _ => {} - } - } - let mut deps = Vec::new(); - for f in i - .constructors - .iter() - .chain(i.methods.iter()) - .chain(i.statics.iter()) - { - for p in &f.params { - collect(&p.ty, &mut deps); - } - if let Some(ret) = &f.ret { - collect(ret, &mut deps); - } - } - deps -} - /// Order entries so that anything an entry depends on is emitted before it. /// Pure DFS post-order; original walk order is the stable tiebreaker, and /// the first definition wins when two modules share a local name (the @@ -168,19 +141,17 @@ pub(crate) fn topo_order(names: &[String], deps: &[Vec]) -> Vec { } /// Render a record as a plain C++ value struct: typed members in declaration -/// (and wire) order, no handles, no destructors, no builders. -pub(crate) fn render_cpp_record(out: &mut String, s: &StructBinding, module: &str, prefix: &str) { +/// (and wire) order. An interface-typed member is the RAII wrapper held by +/// value, so copying the record clones the reference and destroying it +/// releases one. +pub(crate) fn render_cpp_record(out: &mut String, s: &StructBinding) { let mut w = CodeWriter::four_space(); w.doc(&s.doc, DocCommentStyle::Javadoc); w.line(format!("struct {} {{", s.name)); w.scope(|w| { for f in &s.fields { w.doc(&f.doc, DocCommentStyle::Javadoc); - w.line(format!( - "{} {};", - cpp_type(&f.ty, module, prefix), - cpp_ident(&f.name) - )); + w.line(format!("{} {};", cpp_type(&f.ty), cpp_ident(&f.name))); } }); w.line("};"); @@ -192,7 +163,7 @@ pub(crate) fn render_cpp_record(out: &mut String, s: &StructBinding, module: &st /// payload struct per variant, a `value` member holding the active payload, /// a nested `Tag` enum mirroring the wire discriminants, and a `tag()` reader. /// Construct one as `Shape{Shape::Circle{2.0}}`. -pub(crate) fn render_cpp_rich_enum(out: &mut String, e: &EnumBinding, module: &str, prefix: &str) { +pub(crate) fn render_cpp_rich_enum(out: &mut String, e: &EnumBinding) { let name = &e.name; let mut w = CodeWriter::four_space(); w.doc(&e.doc, DocCommentStyle::Javadoc); @@ -215,11 +186,7 @@ pub(crate) fn render_cpp_rich_enum(out: &mut String, e: &EnumBinding, module: &s w.scope(|w| { for f in &v.fields { w.doc(&f.doc, DocCommentStyle::Javadoc); - w.line(format!( - "{} {};", - cpp_type(&f.ty, module, prefix), - cpp_ident(&f.name) - )); + w.line(format!("{} {};", cpp_type(&f.ty), cpp_ident(&f.name))); } }); w.line("};"); @@ -260,10 +227,10 @@ pub(crate) fn render_cpp_rich_enum(out: &mut String, e: &EnumBinding, module: &s /// the payload buffer along the way. /// /// Domain codes are validated positive-only, and the runtime reserves every -/// negative code (generic error, producer panic, marshalling failure), so -/// the mapping helper routes negative codes to the generic `WeaveFFIError` -/// before consulting the domain's cases; unknown positive codes fall back to -/// the domain exception itself. +/// negative code (generic error, producer panic, marshalling failure, foreign +/// callback failure), so the mapping helper routes negative codes to the +/// generic `WeaveFFIError` before consulting the domain's cases; unknown +/// positive codes fall back to the domain exception itself. pub(crate) fn render_domain_error(out: &mut String, eb: &ErrorBinding, prefix: &str) { let domain = &eb.type_name; let path = &eb.owner_path; @@ -292,11 +259,7 @@ pub(crate) fn render_domain_error(out: &mut String, eb: &ErrorBinding, prefix: & w.scope(|w| { for fld in &code.fields { w.doc(&fld.doc, DocCommentStyle::Javadoc); - w.line(format!( - "{} {};", - cpp_type(&fld.ty, path, prefix), - cpp_ident(&fld.name) - )); + w.line(format!("{} {};", cpp_type(&fld.ty), cpp_ident(&fld.name))); } if code.fields.is_empty() { w.line(format!( @@ -309,7 +272,7 @@ pub(crate) fn render_domain_error(out: &mut String, eb: &ErrorBinding, prefix: & let mut inits = vec![format!("{domain}({}, msg)", code.value)]; for fld in &code.fields { let name = cpp_ident(&fld.name); - params.push(format!("{} {name}", cpp_type(&fld.ty, path, prefix))); + params.push(format!("{} {name}", cpp_type(&fld.ty))); inits.push(format!("{name}(std::move({name}))")); } w.line(format!( @@ -333,9 +296,14 @@ pub(crate) fn render_domain_error(out: &mut String, eb: &ErrorBinding, prefix: & )); w.scope(|w| { // The negative range is reserved for the runtime (-1 generic error, - // -2 producer panic, -3 marshalling failure); those never map to a - // typed domain error, on the throwing path or anywhere else. + // -2 producer panic, -3 marshalling failure, -4 foreign callback + // failure); those never map to a typed domain error, on the throwing + // path or anywhere else. w.line("if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg));"); + if eb.codes.iter().all(|c| c.fields.is_empty()) { + w.line("(void)payload_ptr;"); + w.line("(void)payload_len;"); + } w.line("switch (code) {"); for code in &eb.codes { let class = cpp_error_class(&code.name); @@ -394,24 +362,48 @@ pub(crate) fn render_domain_error(out: &mut String, eb: &ErrorBinding, prefix: & // ── Namespace: interfaces ── -/// Emit the shared move-only RAII skeleton an interface class uses: adopted -/// `void*` handle, destructor calling `destroy_symbol`, deleted copy, move -/// constructor and move assignment, and the raw `handle()` reader. -fn emit_raii_skeleton(w: &mut CodeWriter, name: &str, c_tag: &str, destroy_symbol: &str) { - w.line(format!("explicit {name}(void* h) : handle_(h) {{}}")); +/// Emit the reference-counting RAII skeleton of an interface class: the +/// adopted `{c_tag}*`, a destructor releasing one reference, copy operations +/// that take a new reference through the `_clone` symbol (so C++ copy +/// semantics equal a reference-count clone), move operations that transfer +/// the pointer, and the `handle()`/`clone_handle()` readers the marshalling +/// code uses to borrow or to mint a second reference. +fn emit_raii_skeleton(w: &mut CodeWriter, i: &InterfaceBinding) { + let name = &i.name; + let tag = &i.c_tag; + let clone = &i.clone_symbol; + let destroy = &i.destroy_symbol; + + w.line("/** Adopts one strong reference to a producer object. */"); + w.line(format!("explicit {name}({tag}* h) : handle_(h) {{}}")); w.blank(); + w.line("/** Releases this wrapper's reference; the object is dropped with its last one. */"); w.line(format!("~{name}() {{")); w.scope(|w| { - w.line(format!( - "if (handle_) {destroy_symbol}(static_cast<{c_tag}*>(handle_));" - )); + w.line(format!("if (handle_) {destroy}(handle_);")); }); w.line("}"); w.blank(); - w.line(format!("{name}(const {name}&) = delete;")); - w.line(format!("{name}& operator=(const {name}&) = delete;")); + w.line("/** Copies share the object: the copy takes a new strong reference. */"); + w.line(format!( + "{name}(const {name}& other) : handle_({clone}(other.handle_)) {{}}" + )); + w.blank(); + + w.line(format!("{name}& operator=(const {name}& other) {{")); + w.scope(|w| { + w.line("if (this != &other) {"); + w.scope(|w| { + w.line(format!("{tag}* h = {clone}(other.handle_);")); + w.line(format!("if (handle_) {destroy}(handle_);")); + w.line("handle_ = h;"); + }); + w.line("}"); + w.line("return *this;"); + }); + w.line("}"); w.blank(); w.line(format!( @@ -427,9 +419,7 @@ fn emit_raii_skeleton(w: &mut CodeWriter, name: &str, c_tag: &str, destroy_symbo w.scope(|w| { w.line("if (this != &other) {"); w.scope(|w| { - w.line(format!( - "if (handle_) {destroy_symbol}(static_cast<{c_tag}*>(handle_));" - )); + w.line(format!("if (handle_) {destroy}(handle_);")); w.line("handle_ = other.handle_;"); w.line("other.handle_ = nullptr;"); }); @@ -439,74 +429,107 @@ fn emit_raii_skeleton(w: &mut CodeWriter, name: &str, c_tag: &str, destroy_symbo w.line("}"); w.blank(); - w.line("void* handle() const { return handle_; }"); + w.line("/** The wrapped pointer, borrowed: this wrapper keeps its reference. */"); + w.line(format!("const {tag}* handle() const {{ return handle_; }}")); + w.blank(); + + w.line( + "/** A new strong reference the caller owns (for example to write into a value buffer). */", + ); + w.line(format!( + "{tag}* clone_handle() const {{ return {clone}(handle_); }}" + )); w.blank(); } -/// Render an interface as a move-only RAII class. The constructor named `new` -/// becomes the canonical C++ constructor (adopting the handle the C -/// constructor returns); every other constructor becomes a static factory. -/// Methods pass the wrapped handle as the leading C argument and are declared -/// `const` (the ABI receiver is a const pointer); statics are static member -/// functions. Sync, async, and iterator member shapes reuse the free-function -/// marshalling paths. -pub(crate) fn render_cpp_interface( - out: &mut String, - i: &InterfaceBinding, - module: &ModuleBinding, - prefix: &str, -) { - let name = &i.name; +/// The C++ name and declaration kind of one interface member. The constructor +/// named `new` becomes the canonical C++ constructor; every other constructor +/// becomes a static factory named after it. +fn member_kinds<'a>(i: &'a InterfaceBinding) -> Vec<(&'a FnBinding, String, FnKind<'a>)> { + let class = i.name.as_str(); + let mut members = Vec::new(); + for c in &i.constructors { + if c.name == "new" && matches!(c.shape, CallShape::Sync(_)) { + members.push((c, class.to_string(), FnKind::Ctor { class })); + } else { + members.push((c, cpp_fn_name(&c.name), FnKind::Static { class })); + } + } + for m in &i.methods { + members.push((m, cpp_fn_name(&m.name), FnKind::Method { class })); + } + for s in &i.statics { + members.push((s, cpp_fn_name(&s.name), FnKind::Static { class })); + } + members +} + +/// Emit the forward declarations an interface needs before any class body: +/// the wrapper class itself and the range class of every iterator-returning +/// member, so member declarations can name them as return types. +pub(crate) fn render_cpp_interface_forward_decls(out: &mut String, i: &InterfaceBinding) { + out.push_str(&format!("class {};\n", i.name)); + for (f, _, kind) in member_kinds(i) { + if matches!(f.shape, CallShape::Iterator(_)) { + out.push_str(&format!("class {};\n", iterator_class_name(f, kind))); + } + } +} +/// Render an interface's class definition: the reference-counting RAII +/// skeleton plus the *declarations* of its constructors, methods, and +/// statics. Member bodies are emitted later by +/// [`render_cpp_interface_members`], once every value type and codec they +/// marshal through is complete; the class itself must be complete first so +/// records can hold it by value. +pub(crate) fn render_cpp_interface_class(out: &mut String, i: &InterfaceBinding, prefix: &str) { + let name = &i.name; let mut w = CodeWriter::four_space(); w.doc(&i.doc, DocCommentStyle::Javadoc); w.line(format!("class {name} {{")); w.scope(|w| { - w.line("void* handle_;"); + w.line(format!("{}* handle_;", i.c_tag)); w.blank(); }); w.line("public:"); - w.scope(|w| { - emit_raii_skeleton(w, name, &i.c_tag, &i.destroy_symbol); - - let mut members = String::new(); - for c in &i.constructors { - if c.name == "new" && matches!(c.shape, CallShape::Sync(_)) { - render_cpp_callable(&mut members, c, name, FnKind::Ctor, module, prefix); - } else { - render_cpp_callable( - &mut members, - c, - &cpp_fn_name(&c.name), - FnKind::Static, - module, - prefix, - ); - } - } - for m in &i.methods { - render_cpp_callable( - &mut members, - m, - &cpp_fn_name(&m.name), - FnKind::Method { c_tag: &i.c_tag }, - module, - prefix, - ); - } - for s in &i.statics { - render_cpp_callable( - &mut members, - s, - &cpp_fn_name(&s.name), - FnKind::Static, - module, - prefix, - ); - } - w.raw(members); - }); + w.scope(|w| emit_raii_skeleton(w, i)); + let mut members = String::new(); + for (f, cpp_name, kind) in member_kinds(i) { + render_member_decl(&mut members, f, &cpp_name, kind, prefix); + } + w.raw(members); w.line("};"); w.blank(); out.push_str(&w.finish()); } + +/// Render the range classes of an interface's iterator-returning members at +/// namespace scope. They need every element type complete, so they follow +/// the value types and precede the member definitions that construct them. +pub(crate) fn render_cpp_interface_iterators( + out: &mut String, + i: &InterfaceBinding, + module: &ModuleBinding, + prefix: &str, +) { + for (f, cpp_name, kind) in member_kinds(i) { + if let CallShape::Iterator(it) = &f.shape { + render_iterator_range(out, f, it, &cpp_name, kind, module, prefix); + } + } +} + +/// Render the out-of-line `inline` definitions of an interface's members. +/// Methods pass the wrapped pointer as the leading C argument; constructors +/// adopt the returned reference; sync, async, and iterator shapes reuse the +/// free-function marshalling paths. +pub(crate) fn render_cpp_interface_members( + out: &mut String, + i: &InterfaceBinding, + module: &ModuleBinding, + prefix: &str, +) { + for (f, cpp_name, kind) in member_kinds(i) { + render_definition(out, f, &cpp_name, kind, module, prefix); + } +} diff --git a/crates/weaveffi-gen-cpp/src/lib.rs b/crates/weaveffi-gen-cpp/src/lib.rs index 89ca3b68..da25b5fb 100644 --- a/crates/weaveffi-gen-cpp/src/lib.rs +++ b/crates/weaveffi-gen-cpp/src/lib.rs @@ -1,13 +1,15 @@ //! C++ wrapper generator for WeaveFFI. //! //! Produces an idiomatic `weaveffi.hpp` header (value structs, `std::variant` -//! sum types, move semantics, `std::optional`, `std::vector`, exception-based -//! error handling) plus a `CMakeLists.txt` skeleton on top of the C ABI -//! emitted by [`weaveffi-gen-c`](../weaveffi_gen_c/index.html). Implements -//! [`LanguageBackend`]; the shared driver bridges it into the generator -//! pipeline. +//! sum types, reference-counted RAII object wrappers, `std::optional`, +//! `std::vector`, exception-based error handling) plus a `CMakeLists.txt` +//! skeleton on top of the C ABI emitted by +//! [`weaveffi-gen-c`](../weaveffi_gen_c/index.html). The header inlines the +//! same `extern "C"` declarations through `weaveffi_core::cabi`, so it is +//! self-contained. Implements [`LanguageBackend`]; the shared driver bridges +//! it into the generator pipeline. //! -//! The generated surface follows the 0.7.0 value-buffer layout: +//! The generated surface follows ABI revision 2: //! //! * Records are plain C++ value structs with typed members; rich (algebraic) //! enums are `std::variant`-backed sum types with one payload struct per @@ -15,32 +17,49 @@ //! the WeaveFFI value-buffer format as one `(const uint8_t*, size_t)` pair, //! through a small private reader/writer in `detail` plus one generated //! pack and unpack routine per type. -//! * Interfaces remain move-only RAII classes owning an opaque handle; -//! constructors, methods, and statics map onto class members and the -//! destructor calls the destroy symbol. -//! * Free functions and listeners live in a nested namespace per IDL module +//! * Interfaces are reference-counted objects owned by the producer. Each +//! becomes an RAII class holding one strong reference: the destructor calls +//! the `_destroy` symbol exactly once, copying calls `_clone` (so C++ copy +//! semantics equal a reference-count clone), and moving transfers the +//! pointer. A top-level object parameter is borrowed for the call; a +//! returned object, an async result, and an iterator element are adopted. +//! `Interface?` maps to `std::optional` with a null pointer as +//! `std::nullopt`. An interface inside a record, list, map value, optional, +//! or rich-enum payload is held by value and crosses the buffer as a `u64` +//! token minted with `_clone` on write and adopted on read. +//! * Callback interfaces become abstract classes with one pure virtual method +//! per IDL method. The consumer passes a `std::shared_ptr`; the +//! wrapper boxes it on the heap as `ctx`, hands the producer the one +//! process-wide static vtable for the interface, and the vtable's `free` +//! deletes the box. Trampolines decode borrowed arguments, adopt object +//! arguments, and report any exception through `{prefix}_error_set` with +//! the foreign-error code `-4` instead of unwinding through the C frame. +//! * Free functions live in a nested namespace per IDL module //! (`kv::stats::get_stats`), with bare snake_case names. -//! * An `iter` callable returns a move-only lazy range class -//! (`{PascalName}Iterator`) that pulls one element per iteration step and -//! releases the producer iterator from its destructor (or eagerly on -//! exhaustion), per the `weaveffi_core::plan` iterator contract. Buffered -//! elements are decoded per pull and released with `weaveffi_free_bytes`. +//! * An `iter` callable returns a move-only lazy range class that pulls +//! one element per iteration step and releases the producer iterator from +//! its destructor (or eagerly on exhaustion), per the `weaveffi_core::plan` +//! iterator contract. Buffered elements are decoded per pull and released +//! with `{prefix}_free_bytes`; object elements are adopted. //! * Each declaring module's error domain becomes an exception type derived //! from the generic `WeaveFFIError`, with one subclass per code. A code //! that declares payload fields exposes them as typed members on its //! subclass, decoded from the error's payload buffer. A callable with //! `throws == true` throws the typed domain exception; a callable with -//! `throws == false` still checks `out_err` (a nonzero code can only be a -//! producer panic) and throws the generic `WeaveFFIError`. Domain codes are -//! validated positive-only, so a negative runtime code (generic error, -//! producer panic, marshalling failure) always surfaces as the generic +//! `throws == false` still checks `out_err` and throws the generic +//! `WeaveFFIError`. Domain codes are validated positive-only, so a negative +//! runtime code (generic error, producer panic, marshalling failure, or a +//! callback implementation that threw) always surfaces as the generic //! `WeaveFFIError`, never a typed domain exception. No wrapper is marked //! `noexcept` for exactly that reason. +//! * `check_abi_version()` compares the producer's exported ABI revision with +//! the one this header was generated against. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] #![warn(clippy::doc_markdown)] +mod callbacks; mod calls; mod codec; mod entities; @@ -53,25 +72,26 @@ use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::cabi; use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::Ty; -use weaveffi_core::model::{BindingModel, CallShape, EnumBinding, InterfaceBinding, ModuleBinding}; +use weaveffi_core::model::{BindingModel, EnumBinding, ModuleBinding}; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{ render_abi_prefix_aliases, render_prelude, render_trailer, CommentStyle, }; +use crate::callbacks::{render_callback_class, render_callback_trampolines}; use crate::calls::render_cpp_module_ns; use crate::entities::{ - interface_deps, render_cpp_enums, render_cpp_interface, render_cpp_record, + render_cpp_enums, render_cpp_interface_class, render_cpp_interface_forward_decls, + render_cpp_interface_iterators, render_cpp_interface_members, render_cpp_record, render_cpp_rich_enum, render_domain_error, topo_order, ValueDef, }; use crate::package::{render_cmake, render_packaged_cmake, render_packaged_readme, render_readme}; -use crate::runtime::{render_buffer_runtime, render_generic_error, render_listener_registry}; +use crate::runtime::{render_buffer_runtime, render_generic_error}; /// Per-target configuration for [`CppGenerator`]. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct CppConfig { /// C++ namespace (default `"weaveffi"`). pub namespace: Option, @@ -130,7 +150,7 @@ impl LanguageBackend for CppGenerator { "cpp" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -198,7 +218,15 @@ impl LanguageBackend for CppGenerator { render_packaged_readme(lib, header_name, ctx, input_basename), ), ]; - for nb in &ctx.binaries.binaries { + // The packaged CMake selects among the desktop platforms only, so + // Android and Wasm binaries (which belong to other ecosystems' + // packages) are not bundled here. + for nb in ctx + .binaries + .binaries + .iter() + .filter(|nb| nb.platform.is_desktop()) + { let dest = dir .join("lib") .join(nb.platform.id()) @@ -209,38 +237,26 @@ impl LanguageBackend for CppGenerator { } } -/// True when the API surface moves any value through the WeaveFFI buffer -/// format, which requires emitting the private reader/writer runtime: any -/// record or rich enum exists, any error code declares payload fields, or any -/// callable, callback, or iterator moves a buffered value. -fn model_needs_buffers(model: &BindingModel) -> bool { - model.modules.iter().any(|m| { - !m.structs.is_empty() - || m.enums.iter().any(EnumBinding::is_rich) - || m.error - .as_ref() - .is_some_and(|e| e.declared_here && e.codes.iter().any(|c| !c.fields.is_empty())) - || m.callbacks - .iter() - .any(|cb| cb.params.iter().any(|p| p.ty.is_buffered())) - || m.callables().any(|f| { - f.params.iter().any(|p| p.ty.is_buffered()) - || f.ret.as_ref().is_some_and(|r| match r { - Ty::Iterator(inner) => inner.is_buffered(), - other => other.is_buffered(), - }) - }) - }) -} - /// Render the complete C++ header from the driver-built binding model. /// -/// Layout inside `namespace {namespace}`: the generic error surface, the -/// private value-buffer runtime (when any buffered value crosses the ABI), -/// plain enums, value types (record structs and rich-enum variants) in -/// dependency order with their pack/unpack routines, typed exception domains, -/// the listener registry, interface classes in dependency order, and finally -/// one nested namespace per module holding its listeners and free functions. +/// Layout inside `namespace {namespace}`, in an order that keeps every type +/// complete before it is held by value or marshalled: +/// +/// 1. the generic error surface and the private value-buffer runtime (when +/// any buffered value crosses the ABI); +/// 2. plain enums; +/// 3. forward declarations of every value type, interface class, +/// callback-interface class, and member iterator range class; +/// 4. interface class definitions (the reference-counting skeleton plus +/// member *declarations*), so records can hold objects by value; +/// 5. value types (record structs and rich-enum variants) in dependency +/// order with their pack/unpack routines; +/// 6. typed exception domains; +/// 7. callback-interface abstract classes and their trampolines and static +/// vtables; +/// 8. the range classes of iterator-returning interface members; +/// 9. the out-of-line definitions of every interface member; and +/// 10. one nested namespace per module holding its free functions. pub(crate) fn render_cpp_header( model: &BindingModel, namespace: &str, @@ -248,7 +264,7 @@ pub(crate) fn render_cpp_header( filename: &str, ) -> String { let prefix = model.prefix.as_str(); - let needs_buffers = model_needs_buffers(model); + let needs_buffers = model.has_buffers(); let has_rich_enums = model .modules .iter() @@ -265,35 +281,23 @@ pub(crate) fn render_cpp_header( out.push_str("#include \n"); out.push_str("#include \n"); out.push_str("#include \n"); + out.push_str("#include \n"); if has_rich_enums { out.push_str("#include \n"); } if needs_buffers { - // The buffer runtime needs memcpy (float bits) and std::move. + // The buffer runtime needs memcpy for float bits. out.push_str("#include \n"); - out.push_str("#include \n"); } - if model - .modules - .iter() - .any(|m| m.callables().any(|f| f.is_async)) - { + if model.has_async() { out.push_str("#include \n"); } // The lazy iterator range classes need std::input_iterator_tag and // std::ptrdiff_t. - if model.modules.iter().any(|m| { - m.callables() - .any(|f| matches!(f.shape, CallShape::Iterator(_))) - }) { + if model.has_iterators() { out.push_str("#include \n"); out.push_str("#include \n"); } - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_listeners { - out.push_str("#include \n"); - out.push_str("#include \n"); - } out.push('\n'); cabi::render_visibility_macros(&mut out, prefix); @@ -315,6 +319,38 @@ pub(crate) fn render_cpp_header( render_cpp_enums(&mut out, module); } + // Forward declarations let interface member declarations name any value + // type, interface, callback interface, or range class as a parameter or + // return type before its definition. + let forward_start = out.len(); + for module in &model.modules { + for s in &module.structs { + out.push_str(&format!("struct {};\n", s.name)); + } + for e in module.enums.iter().filter(|e| e.is_rich()) { + out.push_str(&format!("struct {};\n", e.name)); + } + for i in &module.interfaces { + render_cpp_interface_forward_decls(&mut out, i); + } + for cb in &module.callback_interfaces { + out.push_str(&format!("class {};\n", cb.name)); + } + } + if out.len() > forward_start { + out.push('\n'); + } + + // Interface classes: the RAII skeleton and member declarations only. A + // record may hold an object by value, so the class must be complete + // before the value types; the member bodies, which marshal value types, + // follow those. + for module in &model.modules { + for i in &module.interfaces { + render_cpp_interface_class(&mut out, i, prefix); + } + } + // Value types (records and rich enums) in dependency order: a member of // record type is held by value, which requires the member's type to be // complete, so nested types are emitted first. The pack/unpack routines @@ -344,10 +380,9 @@ pub(crate) fn render_cpp_header( .collect::>(), ); for &idx in &value_order { - let (v, module) = &value_entries[idx]; - match v { - ValueDef::Record(s) => render_cpp_record(&mut out, s, &module.path, prefix), - ValueDef::Rich(e) => render_cpp_rich_enum(&mut out, e, &module.path, prefix), + match &value_entries[idx].0 { + ValueDef::Record(s) => render_cpp_record(&mut out, s), + ValueDef::Rich(e) => render_cpp_rich_enum(&mut out, e), } } if !value_entries.is_empty() { @@ -375,37 +410,35 @@ pub(crate) fn render_cpp_header( } } - if has_listeners { - // Listener closures are heap-boxed and threaded through the C `context` - // pointer; the registry pins each box (type-erased) until unregistration. - render_listener_registry(&mut out); + // Callback interfaces: the abstract class, then the trampolines (which + // decode record arguments and adopt object arguments) and the static + // vtable in `detail`. + for m in &model.modules { + for cb in &m.callback_interfaces { + render_callback_class(&mut out, cb); + render_callback_trampolines(&mut out, cb, &m.path, prefix); + } } - // Interface classes in dependency order: a member that returns another - // interface constructs it inline, which needs that class complete. - let iface_entries: Vec<(&InterfaceBinding, &ModuleBinding)> = model - .modules - .iter() - .flat_map(|m| m.interfaces.iter().map(move |i| (i, m))) - .collect(); - let iface_order = topo_order( - &iface_entries - .iter() - .map(|(i, _)| i.name.clone()) - .collect::>(), - &iface_entries - .iter() - .map(|(i, _)| interface_deps(i)) - .collect::>(), - ); - for &idx in &iface_order { - let (i, module) = &iface_entries[idx]; - render_cpp_interface(&mut out, i, module, prefix); + // Range classes of iterator-returning members need their element types + // complete and are constructed by the member definitions that follow. + for m in &model.modules { + for i in &m.interfaces { + render_cpp_interface_iterators(&mut out, i, m, prefix); + } + } + + // Interface member definitions: every type they accept, return, or + // marshal through is now complete. + for m in &model.modules { + for i in &m.interfaces { + render_cpp_interface_members(&mut out, i, m, prefix); + } } // Module namespaces last: every type is defined, so a function may accept - // or return any of them by value. Functions and listeners get bare - // snake_case names inside `namespace {module path}`. + // or return any of them by value. Functions get bare snake_case names + // inside `namespace {module path}`. for module in &model.modules { render_cpp_module_ns(&mut out, module, prefix); } @@ -414,3 +447,6 @@ pub(crate) fn render_cpp_header( out } + +#[cfg(test)] +mod tests; diff --git a/crates/weaveffi-gen-cpp/src/package.rs b/crates/weaveffi-gen-cpp/src/package.rs index 7dff8b6e..6c1575ec 100644 --- a/crates/weaveffi-gen-cpp/src/package.rs +++ b/crates/weaveffi-gen-cpp/src/package.rs @@ -58,7 +58,8 @@ target_compile_features(weaveffi_cpp INTERFACE cxx_std_@STD@) format!("{prelude}{body}\n{trailer}") } -/// README for a packaged C++ artifact bundling the header and per-platform libs. +/// README for a packaged C++ artifact bundling the header and the desktop +/// per-platform libraries the packaged CMake can select among. pub(crate) fn render_packaged_readme( lib: &str, header_name: &str, @@ -69,6 +70,7 @@ pub(crate) fn render_packaged_readme( let platforms: Vec = ctx .binaries .platforms() + .filter(|p| p.is_desktop()) .map(|p| format!("- `lib/{}/`", p.id())) .collect(); let platform_list = platforms.join("\n"); diff --git a/crates/weaveffi-gen-cpp/src/runtime.rs b/crates/weaveffi-gen-cpp/src/runtime.rs index bd272acf..c2b98af5 100644 --- a/crates/weaveffi-gen-cpp/src/runtime.rs +++ b/crates/weaveffi-gen-cpp/src/runtime.rs @@ -1,15 +1,25 @@ //! The private runtime prelude of the generated header: the generic error -//! surface, the value-buffer reader/writer, and the listener registry. +//! surface, the ABI revision check, and the value-buffer reader/writer. use weaveffi_core::codegen::CodeWriter; -/// Emit the generic `WeaveFFIError` plus the `detail::check`/`detail::make_error` -/// helpers every non-throwing wrapper uses. A nonzero code on a non-throwing -/// callable can only be a producer panic or a marshalling failure, so it -/// surfaces as this generic exception rather than a typed domain error. +/// Emit the generic `WeaveFFIError`, the `check_abi_version()` helper, and +/// the `detail::check`/`detail::make_error` helpers every non-throwing wrapper +/// uses. A nonzero code on a non-throwing callable can only be a runtime trap +/// (a producer panic, a marshalling failure, or a callback-interface +/// implementation that raised), so it surfaces as this generic exception +/// rather than a typed domain error. pub(crate) fn render_generic_error(out: &mut String, prefix: &str) { + let upper = prefix.to_uppercase(); let mut w = CodeWriter::four_space(); - w.line("/** Base exception for every error reported through the C ABI. */"); + w.line("/**"); + w.line(" * Base exception for every error reported through the C ABI."); + w.line(" *"); + w.line(" * Positive codes are the module's declared error codes (see the typed"); + w.line(" * domain exceptions); negative codes are runtime traps: -1 a generic"); + w.line(" * producer error, -2 a producer panic, -3 a marshalling failure, -4 a"); + w.line(" * callback-interface implementation that threw."); + w.line(" */"); w.line("class WeaveFFIError : public std::runtime_error {"); w.scope(|w| { w.line("int32_t code_;"); @@ -23,6 +33,24 @@ pub(crate) fn render_generic_error(out: &mut String, prefix: &str) { w.line("};"); w.blank(); + w.line("/**"); + w.line(" * Throws WeaveFFIError when the loaded producer library was built for a"); + w.line(" * different WeaveFFI ABI revision than this header. Call once at startup."); + w.line(" */"); + w.line("inline void check_abi_version() {"); + w.scope(|w| { + w.line(format!("uint32_t actual = {prefix}_abi_version();")); + w.line(format!("if (actual != {upper}_ABI_VERSION) {{")); + w.scope(|w| { + w.line(format!( + "throw WeaveFFIError(-1, \"WeaveFFI ABI revision mismatch: header \" + std::to_string({upper}_ABI_VERSION) + \", library \" + std::to_string(actual));" + )); + }); + w.line("}"); + }); + w.line("}"); + w.blank(); + w.line("namespace detail {"); w.blank(); w.line("/** Throw the generic WeaveFFIError if `err` carries a nonzero code. */"); @@ -121,7 +149,8 @@ public: * Decodes values from the WeaveFFI value-buffer wire format. A malformed * buffer is a producer/consumer contract violation (both sides are generated * from one IDL), so every decode failure throws the generic WeaveFFIError, - * the same channel as a producer panic. + * the same channel as a producer panic. Object tokens adopted before a + * failure live in RAII wrappers that release them on unwind. */ class BufferReader { const uint8_t* data_; @@ -232,22 +261,3 @@ struct BufferGuard { "#; out.push_str(&body.replace("@PREFIX@", prefix)); } - -/// Emit the `detail` registry that pins each listener's heap-boxed -/// `std::function` (type-erased) until unregistration, plus the mutex -/// guarding it. Listener closures are threaded through the C `context` -/// pointer, so the box must outlive the registration. -pub(crate) fn render_listener_registry(out: &mut String) { - out.push_str("namespace detail {\n\n"); - out.push_str("inline std::mutex& wv_listener_mutex() {\n"); - out.push_str(" static std::mutex m;\n"); - out.push_str(" return m;\n"); - out.push_str("}\n\n"); - out.push_str( - "inline std::unordered_map>& wv_listener_registry() {\n", - ); - out.push_str(" static std::unordered_map> registry;\n"); - out.push_str(" return registry;\n"); - out.push_str("}\n\n"); - out.push_str("} // namespace detail\n\n"); -} diff --git a/crates/weaveffi-gen-cpp/src/tests.rs b/crates/weaveffi-gen-cpp/src/tests.rs new file mode 100644 index 00000000..c330396a --- /dev/null +++ b/crates/weaveffi-gen-cpp/src/tests.rs @@ -0,0 +1,642 @@ +//! Unit tests: render a small API that exercises every ABI revision 2 shape +//! and assert the header carries the key pieces of each contract. + +use weaveffi_core::model::{BindingModel, CallShape}; +use weaveffi_core::validate::validate_api; +use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, EnumDef, EnumVariant, ErrorCode, ErrorDomain, Function, + InterfaceDef, Module, Param, StructDef, StructField, TypeRef, +}; + +use crate::render_cpp_header; + +fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } +} + +fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } +} + +fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } +} + +fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) +} + +fn optional(ty: TypeRef) -> TypeRef { + TypeRef::Optional(Box::new(ty)) +} + +/// One module covering: an interface with a constructor, methods, a static, +/// an iterator of objects, and an async object result; a function taking and +/// returning `Interface?`; a record with `Interface`, `[Interface]`, and +/// `Interface?` fields; a callback interface with string, i32, record, +/// object, nullable object, and enum parameters and `bool`, void, and enum +/// returns; and a function taking that callback interface. +fn fixture() -> Api { + let store = InterfaceDef { + name: "Store".into(), + doc: Some("A reference-counted key-value store.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("path", TypeRef::StringUtf8)], None)], + methods: vec![ + func( + "get", + vec![param("key", TypeRef::StringUtf8)], + Some(optional(TypeRef::StringUtf8)), + ), + func("sibling", vec![], Some(optional(named("Store")))), + func( + "scan", + vec![], + Some(TypeRef::Iterator(Box::new(named("Store")))), + ), + Function { + throws: true, + ..func("save", vec![param("contact", named("Contact"))], None) + }, + Function { + r#async: true, + ..func("fetch", vec![], Some(named("Store"))) + }, + ], + statics: vec![func("open_default", vec![], Some(named("Store")))], + }; + let listener = CallbackInterfaceDef { + name: "Listener".into(), + doc: Some("Receives store events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("contact", named("Contact")), + param("store", named("Store")), + ], + Some(TypeRef::Bool), + ), + func( + "on_reset", + vec![param("alt", optional(named("Store")))], + None, + ), + func( + "pick", + vec![param("color", named("Color"))], + Some(named("Color")), + ), + ], + }; + Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "kv".into(), + doc: None, + functions: vec![ + func( + "lookup", + vec![param("store", optional(named("Store")))], + Some(optional(named("Store"))), + ), + func( + "subscribe", + vec![param("listener", named("Listener"))], + None, + ), + func( + "all_stores", + vec![], + Some(TypeRef::Iterator(Box::new(named("Store")))), + ), + func( + "first", + vec![param("stores", TypeRef::List(Box::new(named("Store"))))], + Some(named("Store")), + ), + ], + interfaces: vec![store], + callback_interfaces: vec![listener], + structs: vec![StructDef { + name: "Contact".into(), + doc: None, + deprecated: None, + fields: vec![ + field("name", TypeRef::StringUtf8), + field("store", named("Store")), + field("mirrors", TypeRef::List(Box::new(named("Store")))), + field("primary", optional(named("Store"))), + ], + }], + enums: vec![EnumDef { + name: "Color".into(), + doc: None, + deprecated: None, + variants: vec![ + EnumVariant { + name: "Red".into(), + value: 0, + doc: None, + fields: vec![], + }, + EnumVariant { + name: "Blue".into(), + value: 1, + doc: None, + fields: vec![], + }, + ], + }], + errors: Some(ErrorDomain { + name: "KvError".into(), + codes: vec![ErrorCode { + name: "NOT_FOUND".into(), + code: 1, + message: "not found".into(), + doc: None, + fields: vec![], + }], + }), + modules: vec![], + }], + } +} + +fn render() -> (BindingModel, String) { + let api = validate_api(fixture(), None).expect("fixture validates"); + let model = BindingModel::build(&api, "weaveffi"); + let header = render_cpp_header(&model, "weaveffi", "kv.yml", "weaveffi.hpp"); + (model, header) +} + +fn assert_contains(header: &str, needle: &str) { + assert!( + header.contains(needle), + "expected header to contain {needle:?}\n---\n{header}" + ); +} + +fn assert_before(header: &str, first: &str, second: &str) { + let a = header + .find(first) + .unwrap_or_else(|| panic!("missing {first:?}")); + let b = header + .find(second) + .unwrap_or_else(|| panic!("missing {second:?}")); + assert!(a < b, "{first:?} must precede {second:?}"); +} + +#[test] +fn interface_wrapper_is_reference_counted_raii() { + let (_, h) = render(); + assert_contains(&h, "class Store {"); + assert_contains(&h, "weaveffi_kv_Store* handle_;"); + assert_contains(&h, "explicit Store(weaveffi_kv_Store* h) : handle_(h) {}"); + assert_contains(&h, "if (handle_) weaveffi_kv_Store_destroy(handle_);"); + assert_contains( + &h, + "Store(const Store& other) : handle_(weaveffi_kv_Store_clone(other.handle_)) {}", + ); + assert_contains( + &h, + "Store(Store&& other) noexcept : handle_(other.handle_) {", + ); + assert_contains( + &h, + "const weaveffi_kv_Store* handle() const { return handle_; }", + ); + assert_contains( + &h, + "weaveffi_kv_Store* clone_handle() const { return weaveffi_kv_Store_clone(handle_); }", + ); + // Constructor adopts the returned reference; methods borrow `handle_`. + assert_contains(&h, " Store(const std::string& path);"); + assert_contains( + &h, + "inline Store::Store(const std::string& path) : handle_(nullptr) {", + ); + assert_contains( + &h, + "auto result = weaveffi_kv_Store_new(path.c_str(), &err);", + ); + assert_contains(&h, "handle_ = result;"); + assert_contains( + &h, + "inline std::optional Store::get(const std::string& key) const {", + ); + assert_contains( + &h, + "weaveffi_kv_Store_get(handle_, key.c_str(), &out_len, &err);", + ); + assert_contains(&h, "static Store open_default();"); + assert_contains(&h, "inline Store Store::open_default() {"); + // The throwing method routes through the typed domain check. + assert_contains( + &h, + "inline void Store::save(const Contact& contact) const {", + ); + assert_contains(&h, "detail::check_kv(err);"); + // Async object results are adopted. + assert_contains(&h, "inline std::future Store::fetch() const {"); + assert_contains(&h, "p->set_value(Store(result));"); +} + +#[test] +fn nullable_objects_map_to_optional() { + let (_, h) = render(); + assert_contains( + &h, + "inline std::optional lookup(const std::optional& store) {", + ); + assert_contains( + &h, + "weaveffi_kv_lookup(store.has_value() ? store->handle() : nullptr, &err);", + ); + assert_contains(&h, "if (!result) return std::nullopt;"); + assert_contains(&h, "return Store(result);"); + assert_contains(&h, "std::optional sibling() const;"); +} + +#[test] +fn records_hold_objects_as_cloned_tokens() { + let (_, h) = render(); + assert_contains(&h, "struct Contact {"); + assert_contains(&h, " Store store;"); + assert_contains(&h, " std::vector mirrors;"); + assert_contains(&h, " std::optional primary;"); + // The wrapper class is complete before the record that holds it, and + // the member bodies come after the record's codec. + assert_before(&h, "class Store {", "struct Contact {"); + assert_before(&h, "inline Contact read_Contact(", "inline Store::Store("); + // Writing mints a fresh reference; reading adopts the token. + assert_contains( + &h, + "w.write_u64(static_cast(reinterpret_cast(v.store.clone_handle())));", + ); + assert_contains( + &h, + "w.write_u64(static_cast(reinterpret_cast(item0.clone_handle())));", + ); + assert_contains( + &h, + "Store f_store = Store(reinterpret_cast(static_cast(r.read_u64())));", + ); + assert_contains( + &h, + "return Contact{std::move(f_name), std::move(f_store), std::move(f_mirrors), std::move(f_primary)};", + ); + // A list of objects as a parameter is encoded the same way. + assert_contains(&h, "inline Store first(const std::vector& stores) {"); + assert_contains(&h, "stores_buf.write_len(stores.size());"); +} + +#[test] +fn iterators_over_objects_adopt_each_element() { + let (model, h) = render(); + let scan = &model.modules[0].interfaces[0].methods[2]; + let CallShape::Iterator(it) = &scan.shape else { + panic!("scan is an iterator"); + }; + assert_contains(&h, "class StoreScanIterator;"); + assert_contains(&h, "class StoreScanIterator {"); + assert_contains(&h, &format!("{}* handle_;", it.iter_tag)); + assert_contains(&h, &format!("if (handle_) {}(handle_);", it.destroy_symbol)); + assert_contains(&h, "std::optional next() {"); + assert_contains(&h, "return Store(item);"); + assert_contains(&h, "StoreScanIterator scan() const;"); + assert_contains(&h, "inline StoreScanIterator Store::scan() const {"); + assert_contains(&h, "class AllStoresIterator {"); + assert_contains(&h, "inline AllStoresIterator all_stores() {"); + assert_before( + &h, + "class StoreScanIterator {", + "inline StoreScanIterator Store::scan()", + ); +} + +#[test] +fn callback_interface_renders_abstract_class_vtable_and_trampolines() { + let (_, h) = render(); + assert_contains(&h, "class Listener {"); + assert_contains(&h, "virtual ~Listener() = default;"); + assert_contains( + &h, + "virtual bool on_message(const std::string& text, int32_t weight, const Contact& contact, Store store) = 0;", + ); + assert_contains(&h, "virtual void on_reset(std::optional alt) = 0;"); + assert_contains(&h, "virtual Color pick(Color color) = 0;"); + + // Trampolines carry the exact vtable entry signatures. + assert_contains(&h, "struct Listener_trampolines {"); + assert_contains( + &h, + "static bool on_message(void* ctx, const char* text, int32_t weight, const uint8_t* contact_ptr, size_t contact_len, weaveffi_kv_Store* store, weaveffi_error* out_err) {", + ); + assert_contains( + &h, + "static void on_reset(void* ctx, weaveffi_kv_Store* alt, weaveffi_error* out_err) {", + ); + assert_contains( + &h, + "static weaveffi_kv_Color pick(void* ctx, weaveffi_kv_Color color, weaveffi_error* out_err) {", + ); + // Objects are adopted before anything that can throw; buffers and + // strings are decoded (borrowed, never freed). + assert_contains(&h, "Store store_val(store);"); + assert_before( + &h, + "Store store_val(store);", + "Listener& impl = **static_cast*>(ctx);", + ); + assert_contains(&h, "std::optional alt_val;"); + assert_contains(&h, "if (alt) alt_val.emplace(alt);"); + assert_contains(&h, "std::string text_val(text ? text : \"\");"); + assert_contains( + &h, + "detail::BufferReader contact_r(contact_ptr, contact_len);", + ); + assert_contains(&h, "Contact contact_val = detail::read_Contact(contact_r);"); + assert_contains( + &h, + "return impl.on_message(text_val, weight, contact_val, std::move(store_val));", + ); + assert_contains(&h, "impl.on_reset(std::move(alt_val));"); + assert_contains( + &h, + "return static_cast(static_cast(impl.pick(static_cast(static_cast(color)))));", + ); + // Failure path: foreign error code -4, default return, no unwinding. + assert_contains(&h, "} catch (const std::exception& e) {"); + assert_contains(&h, "weaveffi_error_set(out_err, -4, e.what());"); + assert_contains(&h, "return bool{};"); + assert_contains(&h, "return weaveffi_kv_Color{};"); + assert!(!h.contains("weaveffi_free_bytes(const_cast(contact_ptr)")); + + // Exactly one process-wide vtable, methods in declaration order then free. + assert_eq!( + h.matches("static const weaveffi_kv_Listener_vtable vtable = {") + .count(), + 1 + ); + assert_contains( + &h, + "inline const weaveffi_kv_Listener_vtable& Listener_vtable() {", + ); + let vtable_start = h + .find("static const weaveffi_kv_Listener_vtable vtable = {") + .unwrap(); + let vtable = &h[vtable_start..]; + let order: Vec = [ + "&Listener_trampolines::on_message,", + "&Listener_trampolines::on_reset,", + "&Listener_trampolines::pick,", + "&Listener_trampolines::free_ctx,", + ] + .iter() + .map(|entry| { + vtable + .find(entry) + .unwrap_or_else(|| panic!("missing {entry}")) + }) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); + assert_contains(&h, "delete static_cast*>(ctx);"); +} + +#[test] +fn passing_a_callback_interface_boxes_the_shared_ptr() { + let (_, h) = render(); + assert_contains( + &h, + "inline void subscribe(std::shared_ptr listener) {", + ); + assert_contains( + &h, + "if (!listener) throw std::invalid_argument(\"listener: null callback interface\");", + ); + assert_contains( + &h, + "auto* listener_ctx = new std::shared_ptr(std::move(listener));", + ); + assert_contains( + &h, + "weaveffi_kv_subscribe(static_cast(listener_ctx), &detail::Listener_vtable(), &err);", + ); +} + +#[test] +fn runtime_surface_matches_abi_revision_2() { + let (_, h) = render(); + assert_contains(&h, "#define WEAVEFFI_ABI_VERSION 2u"); + assert_contains(&h, "inline void check_abi_version() {"); + assert_contains( + &h, + "WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message);", + ); + assert_contains( + &h, + "WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self);", + ); + for legacy in [ + "arena", + "handle_t", + "register_", + "unregister_", + "std::function", + ] { + assert!(!h.contains(legacy), "legacy token {legacy:?} in header"); + } + // Negative codes, including -4, route to the generic error. + assert_contains( + &h, + "if (code < 0) return std::make_exception_ptr(WeaveFFIError(code, msg));", + ); +} + +#[test] +fn extended_shapes_render() { + let api = Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "ext".into(), + doc: None, + functions: vec![ + func( + "maybe_stores", + vec![], + Some(TypeRef::Iterator(Box::new(optional(named("Store"))))), + ), + func( + "roundtrip", + vec![ + param("blob", TypeRef::Bytes), + param( + "m", + TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(named("Store"))), + ), + param("shape", named("Shape")), + ], + Some(named("Shape")), + ), + Function { + r#async: true, + cancellable: true, + throws: true, + ..func("slow", vec![param("n", TypeRef::I64)], Some(TypeRef::Bytes)) + }, + Function { + r#async: true, + ..func("fire", vec![], None) + }, + Function { + throws: true, + ..func("fail", vec![], Some(TypeRef::F64)) + }, + func( + "watch", + vec![param("w", named("Watcher"))], + Some(TypeRef::Bool), + ), + ], + interfaces: vec![InterfaceDef { + name: "Store".into(), + doc: None, + deprecated: None, + constructors: vec![], + methods: vec![func("size", vec![], Some(TypeRef::U64))], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Watcher".into(), + doc: None, + deprecated: None, + methods: vec![ + func( + "on_data", + vec![ + param("blob", TypeRef::Bytes), + param("names", TypeRef::List(Box::new(TypeRef::StringUtf8))), + param("label", optional(TypeRef::StringUtf8)), + param("shape", named("Shape")), + param("ratio", TypeRef::F64), + ], + Some(TypeRef::I64), + ), + func( + "on_flag", + vec![param("flag", TypeRef::Bool)], + Some(TypeRef::Bool), + ), + ], + }], + structs: vec![], + enums: vec![EnumDef { + name: "Shape".into(), + doc: None, + deprecated: None, + variants: vec![ + EnumVariant { + name: "Dot".into(), + value: 0, + doc: None, + fields: vec![], + }, + EnumVariant { + name: "Boxed".into(), + value: 1, + doc: None, + fields: vec![ + field("store", named("Store")), + field("tags", TypeRef::List(Box::new(TypeRef::StringUtf8))), + ], + }, + ], + }], + errors: Some(ErrorDomain { + name: "ExtError".into(), + codes: vec![ErrorCode { + name: "TOO_BIG".into(), + code: 1, + message: "too big".into(), + doc: None, + fields: vec![ + field("limit", TypeRef::I64), + field("culprit", optional(named("Store"))), + ], + }], + }), + modules: vec![], + }], + }; + let api = validate_api(api, None).expect("fixture validates"); + let model = BindingModel::build(&api, "weaveffi"); + let h = render_cpp_header(&model, "weaveffi", "ext.yml", "weaveffi.hpp"); + + // Nullable iterator elements, object payloads in rich enums and error + // fields, maps of objects, and a cancellable async bytes result. + assert_contains(&h, "std::optional> next() {"); + assert_contains(&h, "if (item) value.emplace(item);"); + assert_contains( + &h, + "return Shape{Shape::Boxed{std::move(f_store), std::move(f_tags)}};", + ); + assert_contains( + &h, + "w.write_u64(static_cast(reinterpret_cast(p.store.clone_handle())));", + ); + assert_contains( + &h, + "TooBigError(const std::string& msg, int64_t limit, std::optional culprit)", + ); + assert_contains( + &h, + "m_buf.write_u64(static_cast(reinterpret_cast(kv0.second.clone_handle())));", + ); + assert_contains( + &h, + "inline std::future> slow(int64_t n, weaveffi_cancel_token* cancel_token) {", + ); + assert_contains( + &h, + "p->set_exception(detail::make_ext_error(err->code, msg, err->payload_ptr, err->payload_len));", + ); + // Callback parameters in the buffered family decode into owned values. + assert_contains( + &h, + "virtual int64_t on_data(const std::vector& blob, const std::vector& names, const std::optional& label, const Shape& shape, double ratio) = 0;", + ); + assert_contains( + &h, + "std::vector blob_val(blob_ptr, blob_ptr + blob_len);", + ); + assert_contains(&h, "Shape shape_val = detail::read_Shape(shape_r);"); + assert_contains(&h, "return int64_t{};"); +} + +#[test] +fn rendering_is_deterministic() { + let (_, a) = render(); + let (_, b) = render(); + assert_eq!(a, b); +} diff --git a/crates/weaveffi-gen-cpp/src/types.rs b/crates/weaveffi-gen-cpp/src/types.rs index a0fefb7b..591997a3 100644 --- a/crates/weaveffi-gen-cpp/src/types.rs +++ b/crates/weaveffi-gen-cpp/src/types.rs @@ -3,6 +3,7 @@ use heck::ToSnakeCase; use weaveffi_core::abi::AbiParam; +use weaveffi_core::cabi::c_param_name; use weaveffi_core::errors; use weaveffi_core::lang::{self, CPP_KEYWORDS}; use weaveffi_core::model::ModuleBinding; @@ -63,18 +64,45 @@ pub(crate) fn cpp_namespace_path(module: &ModuleBinding) -> String { .join("::") } +/// The C++ spelling of one ABI slot name: the IDL-chosen identifier with the +/// same keyword escape the shared C declarations apply, so the trampoline and +/// lambda parameter lists compile against the `extern "C"` prototypes. +pub(crate) fn slot_name(p: &AbiParam) -> String { + c_param_name(&p.name) +} + /// Renders ABI parameter slots to C declarations (` `), the form -/// used inside the generated `extern "C"` block and callback lambdas. +/// used inside async completion lambdas and callback-interface trampolines. pub(crate) fn render_param_decls(params: &[AbiParam], prefix: &str) -> Vec { params .iter() - .map(|p| format!("{} {}", p.ty.render_c(prefix), p.name)) + .map(|p| format!("{} {}", p.ty.render_c(prefix), slot_name(p))) .collect() } -/// The idiomatic C++ spelling of an IR type. `module` and `prefix` resolve -/// typed-handle tags against the declaring module. -pub(crate) fn cpp_type(ty: &Ty, module: &str, prefix: &str) -> String { +/// The opaque C tag of a (possibly dot-qualified) interface referenced from +/// `module`: `{prefix}_{module}_{Name}`. +pub(crate) fn interface_c_tag(name: &str, module: &str, prefix: &str) -> String { + c_abi_struct_name(name, module, prefix) +} + +/// The `detail` accessor returning the process-wide static vtable for a +/// callback interface, named by its local C++ class name. +pub(crate) fn vtable_accessor(local_name: &str) -> String { + format!("{local_name}_vtable") +} + +/// The `detail` struct holding a callback interface's trampolines, named by +/// its local C++ class name. +pub(crate) fn trampoline_struct(local_name: &str) -> String { + format!("{local_name}_trampolines") +} + +/// The idiomatic C++ spelling of an IR type. Interfaces map to their RAII +/// wrapper class, `Interface?` to `std::optional` of it, and callback +/// interfaces to a `std::shared_ptr` of the abstract class the consumer +/// implements. +pub(crate) fn cpp_type(ty: &Ty) -> String { match ty { Ty::I8 => "int8_t".into(), Ty::I16 => "int16_t".into(), @@ -87,12 +115,8 @@ pub(crate) fn cpp_type(ty: &Ty, module: &str, prefix: &str) -> String { Ty::F32 => "float".into(), Ty::F64 => "double".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "std::string".into(), - Ty::Bytes | Ty::BorrowedBytes => "std::vector".into(), - Ty::Handle => "void*".into(), - // A typed handle is an opaque token: it stays the raw prefixed tag - // pointer (there is no destroy symbol to wrap in a RAII class). - Ty::TypedHandle(n) => format!("{}*", c_abi_struct_name(n, module, prefix)), + Ty::StringUtf8 => "std::string".into(), + Ty::Bytes => "std::vector".into(), // Records and rich (algebraic) enums are plain value types; both are // named by their bare local C++ type. Ty::Record(n) | Ty::RichEnum(n) => local_type_name(n).to_string(), @@ -100,14 +124,11 @@ pub(crate) fn cpp_type(ty: &Ty, module: &str, prefix: &str) -> String { // local C++ type `Unit`; never the dot-qualified IR name (invalid C++). Ty::Enum(n) => local_type_name(n).to_string(), Ty::Interface(n) => local_type_name(n).to_string(), - Ty::Optional(inner) => format!("std::optional<{}>", cpp_type(inner, module, prefix)), - Ty::List(inner) => format!("std::vector<{}>", cpp_type(inner, module, prefix)), + Ty::CallbackInterface(n) => format!("std::shared_ptr<{}>", local_type_name(n)), + Ty::Optional(inner) => format!("std::optional<{}>", cpp_type(inner)), + Ty::List(inner) => format!("std::vector<{}>", cpp_type(inner)), Ty::Map(k, v) => { - format!( - "std::unordered_map<{}, {}>", - cpp_type(k, module, prefix), - cpp_type(v, module, prefix) - ) + format!("std::unordered_map<{}, {}>", cpp_type(k), cpp_type(v)) } // An `iter` return renders as a per-function lazy range class, not // through this generic mapping. @@ -116,32 +137,37 @@ pub(crate) fn cpp_type(ty: &Ty, module: &str, prefix: &str) -> String { } /// One C++ parameter declaration (` `) for a wrapper signature. -/// Heavier types borrow by const reference; scalars, enums, and raw handles -/// pass by value. A `mutable: true` string or bytes parameter borrows by -/// non-const reference so the producer's in-place writes reach the caller. -pub(crate) fn cpp_param_decl( - ty: &Ty, - name: &str, - mutable: bool, - module: &str, - prefix: &str, -) -> String { - let konst = if mutable { "" } else { "const " }; +/// Heavier types borrow by const reference; scalars and enums pass by value. +/// A callback interface is taken as a `std::shared_ptr` by value, which the +/// wrapper moves into the heap box it hands the producer as `ctx`. +pub(crate) fn cpp_param_decl(ty: &Ty, name: &str) -> String { match ty { - Ty::StringUtf8 | Ty::BorrowedStr => format!("{konst}std::string& {name}"), - Ty::Bytes | Ty::BorrowedBytes => { - format!("{konst}std::vector& {name}") - } - Ty::TypedHandle(_) => format!("{} {name}", cpp_type(ty, module, prefix)), + Ty::StringUtf8 => format!("const std::string& {name}"), + Ty::Bytes => format!("const std::vector& {name}"), // Records and rich enums borrow: the wrapper encodes them into a local // buffer, so the value stays with the caller. Interfaces borrow their - // handle for the call. + // pointer for the call. Ty::Record(n) | Ty::RichEnum(n) | Ty::Interface(n) => { format!("const {}& {name}", local_type_name(n)) } Ty::Optional(_) | Ty::List(_) | Ty::Map(_, _) => { - format!("const {}& {name}", cpp_type(ty, module, prefix)) + format!("const {}& {name}", cpp_type(ty)) + } + _ => format!("{} {name}", cpp_type(ty)), + } +} + +/// One C++ parameter declaration for a callback-interface method the consumer +/// implements. Borrowed strings, bytes, and buffered values arrive by const +/// reference (the trampoline owns the decoded copy for the call); object +/// arguments transfer one strong reference, so they arrive by value as the +/// RAII wrapper (or `std::optional` of it) the implementation now owns. +pub(crate) fn cpp_cb_param_decl(ty: &Ty, name: &str) -> String { + match ty { + Ty::Interface(n) => format!("{} {name}", local_type_name(n)), + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { + format!("{} {name}", cpp_type(ty)) } - _ => format!("{} {name}", cpp_type(ty, module, prefix)), + _ => cpp_param_decl(ty, name), } } diff --git a/crates/weaveffi-gen-dart/Cargo.toml b/crates/weaveffi-gen-dart/Cargo.toml index d3dd6870..a28dd404 100644 --- a/crates/weaveffi-gen-dart/Cargo.toml +++ b/crates/weaveffi-gen-dart/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-dart/src/callbacks.rs b/crates/weaveffi-gen-dart/src/callbacks.rs new file mode 100644 index 00000000..dcb37ef4 --- /dev/null +++ b/crates/weaveffi-gen-dart/src/callbacks.rs @@ -0,0 +1,372 @@ +//! Callback-interface rendering: the abstract class a consumer implements, +//! the `dart:ffi` `Struct` mirroring the C vtable, one trampoline per method +//! (plus `free`), and the single process-wide vtable instance whose entries +//! are `NativeCallable` function pointers pinned for the process lifetime. +//! +//! Trampolines follow the [`CallbackProtocol`] clauses: borrowed strings, +//! bytes, and buffers are copied or decoded inside the call and never freed; +//! object arguments transfer one strong reference and are adopted into a +//! wrapper; direct returns are written straight into the C return; a thrown +//! Dart exception is reported through `{prefix}_error_set` with the foreign +//! error code and a default value is returned, so nothing ever unwinds through +//! the C frame. +//! +//! [`CallbackProtocol`]: weaveffi_core::plan::CallbackProtocol + +use heck::ToUpperCamelCase; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::model::{CallbackInterfaceBinding, CallbackMethodBinding, Ty}; +use weaveffi_core::plan::ArgPass; + +use crate::calls::adopt_expr; +use crate::codec::read_expr; +use crate::docs::emit_doc; +use crate::types::{ + dart_class, dart_ident, dart_str_literal, dart_type, default_literal, input_slots, scalar_ffi, + vtable_var, +}; + +/// The Dart `Struct` subclass mirroring one callback interface's C vtable. +fn vtable_struct(class: &str) -> String { + format!("_{class}VtableStruct") +} + +/// The native function typedef of one vtable entry. +fn entry_typedef(class: &str, method: &str) -> String { + format!("_Native{class}Vt{}", method.to_upper_camel_case()) +} + +/// The Dart trampoline function bound into one vtable entry. +fn trampoline_fn(class: &str, method: &str) -> String { + format!("_{}Vt{}", lower_first(class), method.to_upper_camel_case()) +} + +/// Lower-case the first character of a PascalCase class name. +fn lower_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_lowercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// The (native, dart) return types of one vtable entry; a void method is +/// `(Void, void)`, everything else is a direct scalar. +fn entry_ret(m: &CallbackMethodBinding) -> (String, String) { + match &m.ret { + None => ("Void".into(), "void".into()), + Some(ty) => { + let (n, d) = scalar_ffi(ty); + (n.into(), d.into()) + } + } +} + +/// The `(native type, dart type, dart name)` slot triples of one vtable +/// entry, in ABI order: `ctx`, every parameter's slots, then `out_err`. +fn entry_slots(m: &CallbackMethodBinding) -> Vec<(String, String, String)> { + let mut slots = vec![( + "Pointer".to_string(), + "Pointer".to_string(), + "ctx".to_string(), + )]; + for p in &m.params { + for ((n, d), slot) in input_slots(p).into_iter().zip(p.abi.iter()) { + slots.push((n, d, dart_ident(&slot.name))); + } + } + slots.push(( + "Pointer<_WeaveFFIError>".into(), + "Pointer<_WeaveFFIError>".into(), + "outErr".into(), + )); + slots +} + +/// Render one callback interface: the consumer-facing abstract class, the +/// vtable `Struct`, the per-method trampolines and `free`, and the one static +/// vtable instance. +pub(crate) fn render_callback_interface(out: &mut String, cb: &CallbackInterfaceBinding) { + let class = dart_class(&cb.name); + render_abstract_class(out, cb, &class); + render_vtable_struct(out, cb, &class); + for m in &cb.methods { + render_trampoline(out, cb, m, &class); + } + render_free(out, &class); + render_vtable_instance(out, cb, &class); +} + +/// The abstract class a consumer extends or implements, one abstract method +/// per vtable entry with idiomatic Dart parameter and return types. +fn render_abstract_class(out: &mut String, cb: &CallbackInterfaceBinding, class: &str) { + let mut w = CodeWriter::two_space(); + w.blank(); + { + let mut d = String::new(); + emit_doc(&mut d, &cb.doc, ""); + w.raw(d); + } + if cb.doc.is_some() { + w.line("///"); + } + w.line("/// A callback interface: implement this class and pass an instance to any"); + w.line(format!( + "/// function taking a [{class}]. The producer keeps the instance alive" + )); + w.line("/// (through a private handle table) until it releases the callback, so no"); + w.line("/// other reference needs to be held."); + w.line("///"); + w.line("/// Object arguments are owned by the implementation: call `dispose()` on"); + w.line("/// them (or let the finalizer run) when done. A thrown exception is reported"); + w.line("/// to the producer, which aborts the call it was making; the original Dart"); + w.line("/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode]"); + w.line("/// carrying the exception's text."); + w.line("///"); + w.line("/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so"); + w.line("/// the producer may only invoke them on the thread of the isolate that"); + w.line("/// passed the callback. That holds when the producer calls them"); + w.line("/// synchronously during a call from Dart. A producer that invokes a method"); + w.line("/// from another thread (an async task, a background worker) is unsupported:"); + w.line("/// a `NativeCallable.listener` cannot return a value or read borrowed"); + w.line("/// arguments after the native frame returns."); + if let Some(msg) = &cb.deprecated { + w.line(format!("@Deprecated('{}')", dart_str_literal(msg))); + } + w.block(format!("abstract class {class} {{"), "}", |w| { + for m in &cb.methods { + { + let mut d = String::new(); + emit_doc(&mut d, &m.doc, " "); + w.raw(d); + } + if let Some(msg) = &m.deprecated { + w.line(format!("@Deprecated('{}')", dart_str_literal(msg))); + } + let params: Vec = m + .params + .iter() + .map(|p| format!("{} {}", dart_type(&p.ty), dart_ident(&p.name))) + .collect(); + let ret = m.ret.as_ref().map_or("void".to_string(), dart_type); + w.line(format!( + "{ret} {}({});", + dart_ident(&m.name), + params.join(", ") + )); + } + }); + out.push_str(&w.finish()); +} + +/// The `Struct` whose layout is the C vtable: one function pointer per +/// method in declaration order, then the trailing `free`. +fn render_vtable_struct(out: &mut String, cb: &CallbackInterfaceBinding, class: &str) { + let mut w = CodeWriter::two_space(); + w.blank(); + for m in &cb.methods { + let (native_ret, _) = entry_ret(m); + let natives: Vec = entry_slots(m).into_iter().map(|(n, _, _)| n).collect(); + w.line(format!( + "typedef {} = {native_ret} Function({});", + entry_typedef(class, &m.name), + natives.join(", ") + )); + } + w.line(format!( + "typedef {} = Void Function(Pointer);", + entry_typedef(class, "free") + )); + w.blank(); + w.line(format!("// The C `{}` layout.", cb.vtable_tag)); + w.block( + format!("final class {} extends Struct {{", vtable_struct(class)), + "}", + |w| { + for m in &cb.methods { + w.line(format!( + "external Pointer> {};", + entry_typedef(class, &m.name), + dart_ident(&m.name) + )); + } + w.line(format!( + "external Pointer> free;", + entry_typedef(class, "free") + )); + }, + ); + out.push_str(&w.finish()); +} + +/// Emit the statements converting one method's borrowed slots into the values +/// handed to the implementation, returning the argument expressions. Every +/// conversion happens inside the call (the borrow window); nothing is freed. +fn emit_trampoline_args(w: &mut CodeWriter, m: &CallbackMethodBinding) -> Vec { + let mut args = Vec::new(); + for p in &m.params { + let base = dart_ident(&p.name); + let n0 = dart_ident(&p.abi[0].name); + args.push(match p.arg_pass() { + // Decode the borrowed encoding now; object tokens inside it are + // adopted by the wrappers the codec constructs. + ArgPass::Buffer { len, .. } => { + let n1 = dart_ident(&len.name); + w.line(format!("final {base}Data = _copyNativeBytes({n0}, {n1});")); + w.line(format!("final {base}Reader = _BufferReader({base}Data);")); + w.line(format!( + "final {base}Value = {};", + read_expr(&format!("{base}Reader"), &p.ty) + )); + w.line(format!("{base}Reader.expectEnd();")); + format!("{base}Value") + } + ArgPass::String { .. } => { + format!("{n0} == nullptr ? '' : {n0}.toDartString()") + } + ArgPass::Bytes { len, .. } => { + let n1 = dart_ident(&len.name); + format!("{n0} == nullptr ? [] : {n0}.asTypedList({n1}).toList()") + } + // One strong reference transfers to the implementation: adopt it + // into a local first, so a later failure (a bad `ctx`, a malformed + // buffer) still leaves the reference owned by a finalizable + // wrapper instead of leaking it. + ArgPass::Object { nullable, .. } => { + w.line(format!( + "final {base}Value = {};", + adopt_expr(&n0, &p.ty, nullable) + )); + format!("{base}Value") + } + ArgPass::Callback { .. } => { + unreachable!("validation rejects callback interfaces as callback-method parameters") + } + ArgPass::Direct { .. } => match &p.ty { + Ty::Enum(name) => format!("{}.fromValue({n0})", dart_class(name)), + _ => n0, + }, + }); + } + args +} + +/// One method trampoline: look the implementation up by `ctx`, convert the +/// arguments, call the method, and write a direct return. Any exception is +/// reported through `_foreignError` (`{prefix}_error_set` with the foreign +/// code) and a default value is returned instead of unwinding. +fn render_trampoline( + out: &mut String, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + class: &str, +) { + let (_, dart_ret) = entry_ret(m); + let decls: Vec = entry_slots(m) + .into_iter() + .map(|(_, d, name)| format!("{d} {name}")) + .collect(); + let method = dart_ident(&m.name); + let default = m.ret.as_ref().map(default_literal); + + let mut w = CodeWriter::two_space(); + w.blank(); + w.line(format!("// Vtable entry `{}.{}`.", cb.vtable_tag, m.name)); + w.block( + format!( + "{dart_ret} {}({}) {{", + trampoline_fn(class, &m.name), + decls.join(", ") + ), + "}", + |w| { + w.line("try {"); + w.scope(|w| { + // Arguments first: object arguments must be adopted before + // anything that can throw. + let args = emit_trampoline_args(w, m); + w.line(format!("final impl = _callbackFor(ctx) as {class};")); + let call = format!("impl.{method}({})", args.join(", ")); + match &m.ret { + None => w.line(format!("{call};")), + Some(Ty::Enum(_)) => w.line(format!("return {call}.value;")), + Some(_) => w.line(format!("return {call};")), + }; + }); + w.line("} catch (e) {"); + w.scope(|w| { + w.line("_foreignError(outErr, e);"); + if let Some(d) = default { + w.line(format!("return {d};")); + } + }); + w.line("}"); + }, + ); + out.push_str(&w.finish()); +} + +/// The `free(ctx)` trampoline: drop the handle-table entry. The producer never +/// touches `ctx` again after this fires. It's a `NativeCallable.listener` +/// because the producer may release its last reference from any thread (an +/// async completion, a background drop) and the removal can safely run +/// later on the isolate. +fn render_free(out: &mut String, class: &str) { + let mut w = CodeWriter::two_space(); + w.blank(); + w.block( + format!( + "void {}(Pointer ctx) {{", + trampoline_fn(class, "free") + ), + "}", + |w| { + w.line("_callbackTable.remove(ctx.address);"); + }, + ); + out.push_str(&w.finish()); +} + +/// The one static vtable for the interface: allocated once with `calloc`, its +/// entries `NativeCallable.isolateLocal` trampolines (and a `listener` for +/// `free`) pinned in `_callbackCallables` so their native thunks live for the +/// process lifetime. +fn render_vtable_instance(out: &mut String, cb: &CallbackInterfaceBinding, class: &str) { + let strukt = vtable_struct(class); + let mut w = CodeWriter::two_space(); + w.blank(); + w.line(format!( + "// The process-wide static `{}` every {class} passed to the producer", + cb.vtable_tag + )); + w.line("// shares; the per-instance state travels in `ctx`."); + w.block( + format!("final Pointer<{strukt}> {} = () {{", vtable_var(&cb.name)), + "}();", + |w| { + w.line(format!("final vt = calloc<{strukt}>();")); + for m in &cb.methods { + let td = entry_typedef(class, &m.name); + let exceptional = match &m.ret { + None => String::new(), + Some(ty) => format!(", exceptionalReturn: {}", default_literal(ty)), + }; + w.line(format!( + "vt.ref.{} = _pinCallable(NativeCallable<{td}>.isolateLocal(", + dart_ident(&m.name) + )); + w.line(format!( + " {}{exceptional}));", + trampoline_fn(class, &m.name) + )); + } + w.line(format!( + "vt.ref.free = _pinCallable(NativeCallable<{}>.listener(", + entry_typedef(class, "free") + )); + w.line(format!(" {}));", trampoline_fn(class, "free"))); + w.line("return vt;"); + }, + ); + out.push_str(&w.finish()); +} diff --git a/crates/weaveffi-gen-dart/src/calls.rs b/crates/weaveffi-gen-dart/src/calls.rs index ad4addff..9a9b6b46 100644 --- a/crates/weaveffi-gen-dart/src/calls.rs +++ b/crates/weaveffi-gen-dart/src/calls.rs @@ -1,28 +1,23 @@ //! Callable rendering: the FFI typedef/lookup pairs and idiomatic Dart -//! wrappers for sync, async, and iterator callables, plus callback typedefs -//! and listener register/unregister pairs. +//! wrappers for sync, async, and iterator callables. //! //! Parameter marshalling dispatches on the shared [`ArgPass`] contract and //! return handling on [`RetPass`], so this module never re-derives the -//! buffered-versus-direct split from raw `Ty`s. +//! buffered-versus-direct split from raw [`Ty`]s. use heck::ToLowerCamelCase; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; -use weaveffi_core::model::{ - CallShape, CallbackBinding, FnBinding, IteratorBinding, ListenerBinding, ModuleBinding, - ParamBinding, -}; -use weaveffi_core::model::{Prim, WireType}; -use weaveffi_core::plan::{self, ArgPass, ElemFree, ErrorStrategy, RetPass}; +use weaveffi_core::model::{CallShape, FnBinding, IteratorBinding, ModuleBinding, ParamBinding}; +use weaveffi_core::plan::{self, ArgPass, ErrorStrategy, RetPass}; use crate::codec::{read_expr, write_stmts}; -use crate::docs::{emit_doc, emit_wrapper_doc}; +use crate::docs::emit_wrapper_doc; use crate::entities::dart_exception_name; use crate::runtime::emit_typedef_and_lookup; use crate::types::{ - dart_class, dart_ident, dart_type, dart_wrapper_fn_name, input_slots, return_ffi, - return_out_slots, returns_buffer, scalar_ffi, + dart_class, dart_ident, dart_type, dart_wrapper_fn_name, input_slots, object_class, return_ffi, + return_out_slots, returns_buffer, scalar_ffi, vtable_var, }; /// Error-reporting context for one wrapper: which check helper guards its @@ -31,7 +26,7 @@ use crate::types::{ /// The split follows [`ErrorStrategy`]: a throwing callable maps `out_err` /// onto the module's typed domain exception, while a non-throwing callable /// traps through the generic brand exception (a reported error there is only -/// ever a producer bug, never a domain error). +/// ever a producer bug or a runtime trap, never a domain error). #[derive(Clone, Copy)] pub(crate) struct ErrCtx<'a> { /// `true` when the wrapper surfaces typed domain errors (`throws: true`). @@ -190,9 +185,10 @@ pub(crate) fn render_callable( } // Each input parameter expands to its ABI slots (bytes and buffered - // values fan out to `(ptr, len)`); a bytes or buffered return adds its - // `out_len` slot; the trailing error slot closes the signature. An - // instance method's `AbiFn` carries an implicit leading `self` pointer. + // values fan out to `(ptr, len)`, callback interfaces to `(ctx, vtable)`); + // a bytes or buffered return adds its `out_len` slot; the trailing error + // slot closes the signature. An instance method's `AbiFn` carries an + // implicit leading `self` pointer. let mut native_params: Vec = Vec::new(); let mut dart_params: Vec = Vec::new(); if f.has_self { @@ -261,172 +257,6 @@ pub(crate) fn render_callable( decl.push_str(&w.finish()); } -/// The native FFI typedef for a module-level callback declaration, shared by -/// every listener that fires it. -pub(crate) fn render_callback_typedef(out: &mut String, cb: &CallbackBinding) { - let mut slots: Vec = Vec::new(); - for p in &cb.params { - for (n, _) in input_slots(p) { - slots.push(n); - } - } - slots.push("Pointer".into()); - out.push_str(&format!( - "\ntypedef _NativeCb_{} = Void Function({});\n", - cb.c_fn_type, - slots.join(", ") - )); -} - -/// Emit the statements converting one callback's trampoline slots into the -/// values handed to the user callback, returning the argument expressions. -/// Buffered arguments arrive as borrowed `(ptr, len)` pairs valid only for -/// the dispatch, so they are decoded here, inside the borrow window. Slot -/// names follow the lowered ABI (`{n}` or `{n}_ptr`/`{n}_len`). -fn emit_cb_args(w: &mut CodeWriter, cb: &CallbackBinding) -> Vec { - let mut args = Vec::new(); - for p in &cb.params { - let base = dart_ident(&p.name); - let n0 = dart_ident(&p.abi[0].name); - args.push(match p.arg_pass() { - ArgPass::Buffer { len, .. } => { - let n1 = dart_ident(&len.name); - w.line(format!("final {base}Data = _copyNativeBytes({n0}, {n1});")); - w.line(format!("final {base}Reader = _BufferReader({base}Data);")); - w.line(format!( - "final {base}Value = {};", - read_expr(&format!("{base}Reader"), &p.ty) - )); - w.line(format!("{base}Reader.expectEnd();")); - format!("{base}Value") - } - ArgPass::String { .. } => { - format!("{n0} == nullptr ? '' : {n0}.toDartString()") - } - ArgPass::Bytes { len, .. } => { - let n1 = dart_ident(&len.name); - format!("{n0} == nullptr ? [] : {n0}.asTypedList({n1}).toList()") - } - // Borrowed for the duration of the callback: do not dispose(). - ArgPass::Object { - nullable: false, .. - } => { - let Ty::Interface(name) = &p.ty else { - unreachable!("non-nullable object params are interfaces") - }; - format!("{}._({n0})", dart_class(name)) - } - // A nullable borrowed object pointer: null means none. - ArgPass::Object { nullable: true, .. } => { - let Ty::Optional(inner) = &p.ty else { - unreachable!("nullable object params are optional interfaces") - }; - let Ty::Interface(name) = inner.as_ref() else { - unreachable!("only optional interfaces stay unbuffered") - }; - format!("{n0} == nullptr ? null : {}._({n0})", dart_class(name)) - } - ArgPass::Direct { .. } => match &p.ty { - Ty::Enum(name) => format!("{}.fromValue({n0})", dart_class(name)), - // Borrowed for the duration of the callback: do not dispose(). - Ty::TypedHandle(name) => format!("{}._({n0})", dart_class(name)), - _ => n0, - }, - }); - } - args -} - -/// The register/unregister wrapper pair for one listener. The trampoline is an -/// `isolateLocal` NativeCallable: WeaveFFI listeners fire synchronously on the -/// thread calling the producer API, so arguments are converted inside the -/// borrow window (a `.listener` callable would read freed pointers later). -pub(crate) fn render_listener( - out: &mut String, - m: &ModuleBinding, - l: &ListenerBinding, - strip: bool, -) { - let Some(cb) = m.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - let cb_typedef = format!("_NativeCb_{}", cb.c_fn_type); - let register_name = dart_wrapper_fn_name(&m.path, &format!("register_{}", l.name), strip); - let unregister_name = dart_wrapper_fn_name(&m.path, &format!("unregister_{}", l.name), strip); - - emit_typedef_and_lookup( - out, - &l.register_symbol, - &format!("Pointer>, Pointer"), - &format!("Pointer>, Pointer"), - "Uint64", - "int", - ); - emit_typedef_and_lookup(out, &l.unregister_symbol, "Uint64", "int", "Void", "void"); - - let user_fn_params: Vec = cb - .params - .iter() - .map(|p| format!("{} {}", dart_type(&p.ty), dart_ident(&p.name))) - .collect(); - let mut tramp_decls: Vec = Vec::new(); - for p in &cb.params { - for ((_, d), slot) in input_slots(p).iter().zip(p.abi.iter()) { - tramp_decls.push(format!("{d} {}", dart_ident(&slot.name))); - } - } - tramp_decls.push("Pointer context".into()); - - let mut w = CodeWriter::two_space(); - w.blank(); - { - let mut d = String::new(); - emit_doc(&mut d, &l.doc, ""); - w.raw(d); - } - w.line(format!( - "/// Registers a {} listener. Returns a subscription id for {unregister_name}().", - cb.name - )); - w.block( - format!( - "int {register_name}(void Function({}) callback) {{", - user_fn_params.join(", ") - ), - "}", - |w| { - w.line(format!( - "final callable = NativeCallable<{cb_typedef}>.isolateLocal(({}) {{", - tramp_decls.join(", ") - )); - w.scope(|w| { - let call_args = emit_cb_args(w, cb); - w.line(format!("callback({});", call_args.join(", "))); - }); - w.line("});"); - w.line(format!( - "final id = _{}(callable.nativeFunction, nullptr);", - l.register_symbol.to_lower_camel_case() - )); - w.line("_listenerCallables[id] = callable;"); - w.line("return id;"); - }, - ); - - w.blank(); - w.line(format!( - "/// Unregisters a listener previously registered with {register_name}()." - )); - w.block(format!("void {unregister_name}(int id) {{"), "}", |w| { - w.line(format!( - "_{}(id);", - l.unregister_symbol.to_lower_camel_case() - )); - w.line("_listenerCallables.remove(id)?.close();"); - }); - out.push_str(&w.finish()); -} - /// The (native, dart, name) slot triples an async completion callback carries /// after its `(context, err)` prefix. Bytes and buffered results arrive as /// owned `(result, resultLen)` pairs the consumer frees; interfaces as @@ -444,14 +274,10 @@ fn async_cb_result_slots(ret: Option<&Ty>) -> Vec<(String, String, String)> { RetPass::String => vec![pair("Pointer", "Pointer", "result")], // A (possibly nullable) adopted object pointer. RetPass::Object { .. } => vec![pair("Pointer", "Pointer", "result")], - RetPass::Void | RetPass::Direct => match ty { - // A typed handle's slot is an opaque adopted pointer. - Ty::TypedHandle(_) => vec![pair("Pointer", "Pointer", "result")], - ty => { - let (n, d) = scalar_ffi(ty); - vec![pair(n, d, "result")] - } - }, + RetPass::Void | RetPass::Direct => { + let (n, d) = scalar_ffi(ty); + vec![pair(n, d, "result")] + } } } @@ -666,14 +492,10 @@ fn emit_async_complete(out: &mut String, ty: Option<&Ty>, indent: &str) { // The callback receives ownership of an object result; the wrapper // adopts the pointer and its `dispose()` owns the eventual destroy. RetPass::Object { nullable, .. } => { - let name = object_class(ty); - if nullable { - w.line(format!( - "completer.complete(result == nullptr ? null : {name}._(result));" - )); - } else { - w.line(format!("completer.complete({name}._(result));")); - } + w.line(format!( + "completer.complete({});", + adopt_expr("result", ty, nullable) + )); } RetPass::Void | RetPass::Direct => match ty { Ty::Enum(name) => { @@ -682,13 +504,6 @@ fn emit_async_complete(out: &mut String, ty: Option<&Ty>, indent: &str) { dart_class(name) )); } - // An adopted typed-handle pointer, wrapped like an interface. - Ty::TypedHandle(name) => { - w.line(format!( - "completer.complete({}._(result));", - dart_class(name) - )); - } _ => { w.line("completer.complete(result);"); } @@ -697,12 +512,14 @@ fn emit_async_complete(out: &mut String, ty: Option<&Ty>, indent: &str) { out.push_str(&w.finish()); } -/// The Dart wrapper class of a direct or nullable interface reference. -fn object_class(ty: &Ty) -> String { - match ty { - Ty::Interface(name) => dart_class(name), - Ty::Optional(inner) => object_class(inner), - _ => unreachable!("object returns are (optional) interfaces"), +/// The expression adopting the owned object pointer `expr` into its wrapper +/// class; when `nullable`, a null pointer becomes Dart `null`. +pub(crate) fn adopt_expr(expr: &str, ty: &Ty, nullable: bool) -> String { + let class = object_class(ty); + if nullable { + format!("{expr} == nullptr ? null : {class}._({expr})") + } else { + format!("{class}._({expr})") } } @@ -710,7 +527,10 @@ fn object_class(ty: &Ty) -> String { /// expressions it contributes (in ABI order) and appending any cleanup /// statements to `frees`. Dispatches on the parameter's [`ArgPass`] contract: /// a buffered value is encoded into a `_BufferWriter`, staged into native -/// memory, and passed as a borrowed `(ptr, len)` pair the callee never frees. +/// memory, and passed as a borrowed `(ptr, len)` pair the callee never frees; +/// an object passes the wrapper's borrowed handle; a callback interface is +/// registered in the handle table and passed as its key plus the interface's +/// static vtable. fn emit_input( w: &mut CodeWriter, p: &ParamBinding, @@ -747,17 +567,28 @@ fn emit_input( frees.push(format!("if ({ptr} != nullptr) calloc.free({ptr});")); vec![ptr, format!("{name}.length")] } - // A borrowed object pointer; when nullable, null means none. + // A borrowed object pointer: the wrapper keeps its own reference and + // the producer clones if it retains the object. When nullable, null + // means none. ArgPass::Object { nullable: false, .. } => vec![format!("{name}._handle")], ArgPass::Object { nullable: true, .. } => { vec![format!("{name}?._handle ?? nullptr")] } + // The implementation is parked in the handle table until the producer + // calls the vtable's `free(ctx)`; nothing to release here. + ArgPass::Callback { .. } => { + let Ty::CallbackInterface(cb) = &p.ty else { + unreachable!("callback family names a callback interface") + }; + vec![ + format!("_registerCallback({name})"), + format!("{}.cast()", vtable_var(cb)), + ] + } ArgPass::Direct { .. } => match &p.ty { Ty::Enum(_) => vec![format!("{name}.value")], - // A typed handle passes its wrapped pointer, like an interface. - Ty::TypedHandle(_) => vec![format!("{name}._handle")], _ => vec![name], }, } @@ -779,7 +610,8 @@ fn emit_return_alloc(w: &mut CodeWriter, ty: &Ty, frees: &mut Vec) -> Ve /// Emit the post-call decode of a return into the wrapper's Dart return /// value, dispatching on the return's [`RetPass`] contract. A buffered return /// is copied out of the producer's buffer, released with -/// `weaveffi_free_bytes`, and decoded through the buffer reader. +/// `weaveffi_free_bytes`, and decoded through the buffer reader; an object +/// return is adopted by its wrapper class. fn emit_return_decode(out: &mut String, ty: &Ty, indent: &str) { let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); match plan::ret_pass(Some(ty), "", "") { @@ -806,23 +638,15 @@ fn emit_return_decode(out: &mut String, ty: &Ty, indent: &str) { w.line("_weaveffiFreeString(result);"); w.line("return decoded;"); } - // An owned object pointer the wrapper class adopts; when nullable, + // One owned strong reference the wrapper class adopts; when nullable, // null means none. RetPass::Object { nullable, .. } => { - let name = object_class(ty); - if nullable { - w.line("if (result == nullptr) return null;"); - } - w.line(format!("return {name}._(result);")); + w.line(format!("return {};", adopt_expr("result", ty, nullable))); } RetPass::Void | RetPass::Direct => match ty { Ty::Enum(name) => { w.line(format!("return {}.fromValue(result);", dart_class(name))); } - // An adopted typed-handle pointer, wrapped like an interface. - Ty::TypedHandle(name) => { - w.line(format!("return {}._(result);", dart_class(name))); - } _ => { w.line("return result;"); } @@ -885,28 +709,13 @@ fn emit_function_body(out: &mut String, f: &FnBinding, c_sym: &str, err: ErrCtx) /// The dart:ffi pointee type of an iterator's `out_item` slot, plus whether /// the element also carries a `size_t* out_len` slot (bytes and every -/// buffered element do), driven by the element's [`ElemFree`] plan. +/// buffered element do), driven by the element's [`RetPass`] plan. fn iter_item_slot(elem: &Ty) -> (String, bool) { - match plan::elem_free(elem) { - ElemFree::Bytes => ("Pointer".into(), true), - ElemFree::String => ("Pointer".into(), false), - ElemFree::None => match elem { - Ty::Interface(_) | Ty::TypedHandle(_) => ("Pointer".into(), false), - _ => (scalar_ffi(elem).0.to_string(), false), - }, - } -} - -/// Convert a single native by-value element (`expr`) into its Dart -/// representation: enums map through `fromValue`, interface elements are -/// adopted by their wrapper class, scalars pass through. -fn direct_elem_read(expr: &str, ty: &Ty) -> String { - match ty { - Ty::Enum(n) => format!("{}.fromValue({expr})", dart_class(n)), - Ty::Interface(n) | Ty::TypedHandle(n) => { - format!("{}._({expr})", dart_class(n)) - } - _ => expr.to_string(), + match plan::ret_pass(Some(elem), "", "") { + RetPass::Bytes | RetPass::Buffer => ("Pointer".into(), true), + RetPass::String => ("Pointer".into(), false), + RetPass::Object { .. } => ("Pointer".into(), false), + RetPass::Void | RetPass::Direct => (scalar_ffi(elem).0.to_string(), false), } } @@ -946,10 +755,10 @@ fn emit_iter_lookups(out: &mut String, ib: &IteratorBinding) { /// /// The body runs lazily, on the first pull: it stages the inputs, launches /// the C iterator, and then issues exactly one producer `next` call per -/// yielded element, releasing each element per the plan's [`ElemFree`] after -/// copying or decoding (strings through `weaveffi_free_string`; bytes and -/// buffered elements through `weaveffi_free_bytes`; interface elements are -/// adopted by their wrapper class, whose `dispose()` owns the destroy). +/// yielded element, receiving each per the plan's [`RetPass`] (strings are +/// copied and freed through `weaveffi_free_string`; bytes and buffered +/// elements through `weaveffi_free_bytes`; interface elements are adopted by +/// their wrapper class, whose `dispose()` owns the destroy). /// /// The handle is destroyed exactly once. The `try`/`finally` destroys it when /// iteration exhausts, a launch or `next` error throws, or the generator is @@ -965,7 +774,7 @@ fn emit_iterator_body( ib: &IteratorBinding, err: ErrCtx, ) { - let free_plan = plan::elem_free(&ib.elem); + let elem_pass = plan::ret_pass(Some(&ib.elem), "", ""); let mut frees: Vec = Vec::new(); let mut call_args: Vec = Vec::new(); if f.has_self { @@ -1011,39 +820,46 @@ fn emit_iterator_body( w.line(format!("while (_{next_var}({next_args}) != 0) {{")); w.scope(|w| { w.line(err.check_stmt()); - match free_plan { - ElemFree::String => { + match &elem_pass { + RetPass::String => { w.line("final itemPtr = outItem.value;"); w.line("final item = itemPtr.toDartString();"); w.line("_weaveffiFreeString(itemPtr);"); w.line("yield item;"); } - // Bytes and buffered elements: copy or decode, then release - // the producer's buffer with weaveffi_free_bytes. A raw bytes - // element (wire shape Bytes) copies; every other buffered - // element decodes. - ElemFree::Bytes => { + // A raw bytes element copies, then releases the producer's + // buffer with weaveffi_free_bytes. + RetPass::Bytes => { w.line("final itemPtr = outItem.value;"); w.line("final itemLen = outLen.value;"); - if matches!(elem.wire(), WireType::Prim(Prim::Bytes)) { - w.line("final item = _copyNativeBytes(itemPtr, itemLen);"); - w.line("if (itemPtr != nullptr) _weaveffiFreeBytes(itemPtr, itemLen);"); - w.line("yield item;"); - } else { - w.line("final itemData = _copyNativeBytes(itemPtr, itemLen);"); - w.line("if (itemPtr != nullptr) _weaveffiFreeBytes(itemPtr, itemLen);"); - w.line("final itemReader = _BufferReader(itemData);"); - w.line(format!("final item = {};", read_expr("itemReader", elem))); - w.line("itemReader.expectEnd();"); - w.line("yield item;"); - } + w.line("final item = _copyNativeBytes(itemPtr, itemLen);"); + w.line("if (itemPtr != nullptr) _weaveffiFreeBytes(itemPtr, itemLen);"); + w.line("yield item;"); + } + // A buffered element decodes (adopting any object tokens it + // carries), then releases the producer's buffer. + RetPass::Buffer => { + w.line("final itemPtr = outItem.value;"); + w.line("final itemLen = outLen.value;"); + w.line("final itemData = _copyNativeBytes(itemPtr, itemLen);"); + w.line("if (itemPtr != nullptr) _weaveffiFreeBytes(itemPtr, itemLen);"); + w.line("final itemReader = _BufferReader(itemData);"); + w.line(format!("final item = {};", read_expr("itemReader", elem))); + w.line("itemReader.expectEnd();"); + w.line("yield item;"); + } + // One owned strong reference per element, adopted by the + // wrapper class. + RetPass::Object { nullable, .. } => { + w.line("final itemPtr = outItem.value;"); + w.line(format!("yield {};", adopt_expr("itemPtr", elem, *nullable))); } - // By-value element (or an adopted interface handle). - ElemFree::None => { - w.line(format!( - "yield {};", - direct_elem_read("outItem.value", elem) - )); + RetPass::Void | RetPass::Direct => { + let read = match elem { + Ty::Enum(n) => format!("{}.fromValue(outItem.value)", dart_class(n)), + _ => "outItem.value".to_string(), + }; + w.line(format!("yield {read};")); } } }); diff --git a/crates/weaveffi-gen-dart/src/codec.rs b/crates/weaveffi-gen-dart/src/codec.rs index 873d4a6a..6a830871 100644 --- a/crates/weaveffi-gen-dart/src/codec.rs +++ b/crates/weaveffi-gen-dart/src/codec.rs @@ -2,9 +2,15 @@ //! statements for any wire shape, naming the per-record and per-rich-enum //! `_pack{Name}`/`_unpack{Name}` helpers. //! -//! Every dispatch here goes through [`wire::classify`], so this module never -//! re-derives the wire folds (handles as `u64` tokens, borrowed views as -//! their owned forms, records and rich enums as one user-codec shape). +//! Every dispatch here goes through [`Ty::wire`], so this module never +//! re-derives the wire folds (interfaces as `u64` object tokens, records and +//! rich enums as one user-codec shape). +//! +//! An object token carries one strong reference. Writing one calls the +//! wrapper's private `_cloneRef()` (the interface's `_clone` symbol) so the +//! encoding owns a fresh reference and the wrapper keeps its own; reading one +//! adopts the pointer into a new wrapper whose `dispose()` (or finalizer) +//! owes the matching `_destroy`. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; @@ -49,18 +55,12 @@ pub(crate) fn read_expr(r: &str, ty: &Ty) -> String { WireType::Prim(Prim::U64) => format!("{r}.readUint64()"), WireType::Prim(Prim::F32) => format!("{r}.readFloat32()"), WireType::Prim(Prim::F64) => format!("{r}.readFloat64()"), - // Both handle kinds decode from one u64 token; only the Dart surface - // differs (a bare int versus a wrapper class adopting the address). - WireType::Handle(_) => { - if let Ty::TypedHandle(n) = ty { - format!( - "{}._(Pointer.fromAddress({r}.readUint64()))", - dart_class(n) - ) - } else { - format!("{r}.readUint64()") - } - } + // A token is one adopted strong reference: wrap it, never destroy it + // here. + WireType::Object(n) => format!( + "{}._(Pointer.fromAddress({r}.readUint64()))", + dart_class(n) + ), WireType::Enum(n) => format!("{}.fromValue({r}.readInt32())", dart_class(n)), WireType::Prim(Prim::String) => format!("{r}.readString()"), WireType::Prim(Prim::Bytes) => format!("{r}.readBytes()"), @@ -122,14 +122,10 @@ pub(crate) fn write_stmts(w: &mut CodeWriter, wr: &str, expr: &str, ty: &Ty, tmp WireType::Prim(Prim::F64) => { w.line(format!("{wr}.writeFloat64({expr});")); } - // Both handle kinds encode as one u64 token; a typed handle - // contributes its wrapped pointer's address. - WireType::Handle(_) => { - if matches!(ty, Ty::TypedHandle(_)) { - w.line(format!("{wr}.writeUint64({expr}._handle.address);")); - } else { - w.line(format!("{wr}.writeUint64({expr});")); - } + // The token must own its reference, so write a freshly cloned pointer + // rather than the one the wrapper still holds. + WireType::Object(_) => { + w.line(format!("{wr}.writeUint64({expr}._cloneRef().address);")); } WireType::Enum(_) => { w.line(format!("{wr}.writeInt32({expr}.value);")); diff --git a/crates/weaveffi-gen-dart/src/docs.rs b/crates/weaveffi-gen-dart/src/docs.rs index cc63d3e4..ea10e4db 100644 --- a/crates/weaveffi-gen-dart/src/docs.rs +++ b/crates/weaveffi-gen-dart/src/docs.rs @@ -39,11 +39,20 @@ pub(crate) fn emit_wrapper_doc(w: &mut CodeWriter, f: &FnBinding, err: ErrCtx) { w.line("/// The native iterator handle is destroyed exactly once: eagerly when"); w.line("/// the iteration completes or fails, or by a GC finalizer if the"); w.line("/// iteration is abandoned before it is exhausted."); - if matches!(ib.elem, Ty::Interface(_)) { + if ib.elem.interface_name().is_some() { w.line("///"); w.line("/// Each yielded element is owned by the caller: call its `dispose()`"); w.line("/// when you are done with it."); } + } else if let Some(ret) = f.ret.as_ref().filter(|r| r.interface_name().is_some()) { + separator(w, &mut has_content); + if matches!(ret, Ty::Optional(_)) { + w.line("/// Returns `null` when the producer reports no object. A non-null result"); + w.line("/// is owned by the caller: call its `dispose()` when you are done with it."); + } else { + w.line("/// The returned object is owned by the caller: call its `dispose()` when"); + w.line("/// you are done with it."); + } } if let Some(exc) = err.thrown_exception() { separator(w, &mut has_content); diff --git a/crates/weaveffi-gen-dart/src/entities.rs b/crates/weaveffi-gen-dart/src/entities.rs index ab604ef6..d6a09dd8 100644 --- a/crates/weaveffi-gen-dart/src/entities.rs +++ b/crates/weaveffi-gen-dart/src/entities.rs @@ -30,7 +30,8 @@ pub(crate) fn dart_exception_name(raw: &str) -> String { /// error's payload buffer into the exception's typed properties. Only /// declared (positive) codes gain a `case`; every other code, including the /// reserved negative runtime range (generic error, panic, marshalling -/// failure), falls through to the generic exception. +/// failure, and a callback-interface implementation that threw), falls +/// through to the generic exception. pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorBinding) { let exc = dart_exception_name(&eb.type_name); let brand = errors::EXCEPTION_BRAND; @@ -143,15 +144,33 @@ pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorB out.push_str(&w.finish()); } -/// Render one interface as an opaque-object wrapper class: it owns the C -/// handle behind a private `_handle`, frees it once in `dispose()` via the -/// interface's destroy symbol, and exposes the canonical `new` constructor as -/// an unnamed factory (`Store(...)`), every other constructor as a named -/// factory (`Store.open(...)`), instance methods that pass `_handle` as the -/// implicit leading FFI argument, and statics as `static` methods. Member FFI +/// Render one interface as a reference-counted object wrapper class. +/// +/// The wrapper adopts one strong reference (the pointer behind the private +/// `_handle`) and owes exactly one `_destroy` for it: `dispose()` releases it +/// eagerly and a `NativeFinalizer` attached in the constructor releases it +/// when an undisposed wrapper is collected. The two paths never both fire +/// because `dispose()` detaches the finalizer before destroying, and a second +/// `dispose()` is a no-op. Using a disposed wrapper throws a `StateError`. +/// The private `_cloneRef()` calls the interface's `_clone` symbol to mint a +/// second strong reference, which the value-buffer codec writes as an object +/// token. +/// +/// The canonical `new` constructor renders as an unnamed factory +/// (`Store(...)`), every other constructor as a named factory +/// (`Store.open(...)`), instance methods pass `_handle` as the implicit +/// leading FFI argument, and statics are `static` methods. Member FFI /// typedefs and lookups stay at file scope. pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &InterfaceBinding) { let class_name = i.name.to_upper_camel_case(); + emit_typedef_and_lookup( + out, + &i.clone_symbol, + "Pointer", + "Pointer", + "Pointer", + "Pointer", + ); emit_typedef_and_lookup( out, &i.destroy_symbol, @@ -160,6 +179,14 @@ pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &Int "Void", "void", ); + let destroy_var = i.destroy_symbol.to_lower_camel_case(); + let clone_var = i.clone_symbol.to_lower_camel_case(); + let finalizer = format!("_{destroy_var}Finalizer"); + out.push_str(&format!( + "final {finalizer} = NativeFinalizer(\n \ + _lib.lookup)>>('{}'));\n", + i.destroy_symbol + )); let exc = module .error @@ -211,20 +238,56 @@ pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &Int emit_doc(&mut d, &i.doc, ""); w.raw(d); } - w.block(format!("class {class_name} {{"), "}", |w| { - w.line("final Pointer _handle;"); - w.line(format!("{class_name}._(this._handle);")); - w.blank(); - w.line("/// Releases the native object reference."); - w.block("void dispose() {", "}", |w| { + if i.doc.is_some() { + w.line("///"); + } + w.line("/// A reference-counted native object. Each instance holds one strong"); + w.line("/// reference, released by [dispose] or, if the instance is collected"); + w.line("/// without being disposed, by a GC finalizer. Instances stay valid for as"); + w.line("/// long as the Dart object is reachable; the producer keeps its own"); + w.line("/// references to any object it retains."); + if let Some(msg) = &i.deprecated { + w.line(format!("@Deprecated('{}')", dart_str_literal(msg))); + } + w.block( + format!("class {class_name} implements Finalizable {{"), + "}", + |w| { + w.line("final Pointer _ptr;"); + w.line("bool _disposed = false;"); + w.blank(); + w.line("/// Adopts one strong reference to the native object."); + w.block(format!("{class_name}._(this._ptr) {{"), "}", |w| { + w.line(format!("{finalizer}.attach(this, _ptr, detach: this);")); + }); + w.blank(); + w.line("/// The borrowed native pointer for the duration of a call."); + w.block("Pointer get _handle {", "}", |w| { + w.line(format!( + "if (_disposed) throw StateError('{class_name} used after dispose()');" + )); + w.line("return _ptr;"); + }); + w.blank(); + w.line("/// Mints a second strong reference (the interface's `_clone` symbol) for"); + w.line("/// an object token written into a value buffer."); w.line(format!( - "_{}(_handle);", - i.destroy_symbol.to_lower_camel_case() + "Pointer _cloneRef() => _{clone_var}(_handle);" )); - }); - // Reindent the depth-0 member declarations into the class body. - w.block_raw(&members); - }); + w.blank(); + w.line("/// Releases this instance's native reference. Safe to call more than"); + w.line("/// once; the native object is dropped when its last reference (this"); + w.line("/// one, any other wrapper's, or the producer's) is released."); + w.block("void dispose() {", "}", |w| { + w.line("if (_disposed) return;"); + w.line("_disposed = true;"); + w.line(format!("{finalizer}.detach(this);")); + w.line(format!("_{destroy_var}(_ptr);")); + }); + // Reindent the depth-0 member declarations into the class body. + w.block_raw(&members); + }, + ); out.push_str(&w.finish()); } diff --git a/crates/weaveffi-gen-dart/src/lib.rs b/crates/weaveffi-gen-dart/src/lib.rs index 63f3aef4..426f1074 100644 --- a/crates/weaveffi-gen-dart/src/lib.rs +++ b/crates/weaveffi-gen-dart/src/lib.rs @@ -10,11 +10,26 @@ //! WeaveFFI value-buffer format as one `(ptr, len)` pair. The generated //! library ships a small private buffer writer/reader implementing that //! format, plus one pack and one unpack routine per record and rich enum. +//! +//! Interfaces render as reference-counted wrapper classes: each instance +//! adopts one strong reference, releases it through `dispose()` or a +//! `NativeFinalizer` backstop, and can mint a second reference (the +//! interface's `_clone` symbol) when the codec writes it into a value buffer +//! as an object token. `Interface?` is a nullable wrapper (`Store?`). +//! +//! Callback interfaces render as abstract classes the consumer implements. +//! Passing an implementation parks it in a handle table keyed by an integer +//! that crosses as `ctx`, alongside one process-wide static vtable per +//! interface whose entries are `NativeCallable.isolateLocal` trampolines. +//! Because `isolateLocal` callables can only run on the owning isolate's +//! thread, a producer must invoke callback methods synchronously during a +//! call from Dart; invocation from another producer thread is unsupported. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] #![warn(clippy::doc_markdown)] +mod callbacks; mod calls; mod codec; mod docs; @@ -28,33 +43,34 @@ use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; use weaveffi_core::model::{ - BindingModel, CallShape, CallbackBinding, EnumBinding, ErrorBinding, FnBinding, - InterfaceBinding, ListenerBinding, ModuleBinding, StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + ModuleBinding, StructBinding, }; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; -use crate::calls::{render_callback_typedef, render_function, render_listener}; +use crate::callbacks::render_callback_interface; +use crate::calls::render_function; use crate::entities::{render_enum, render_error, render_interface, render_struct}; use crate::package::{render_packaged_readme, render_pubspec, render_readme}; use crate::runtime::{ - dart_loader_original, dart_loader_packaged, render_abi_version_check, render_buffer_runtime, - render_error_plumbing, + bundles_platform, dart_loader_original, dart_loader_packaged, render_abi_version_check, + render_buffer_runtime, render_callback_runtime, render_error_plumbing, }; /// Per-target configuration for [`DartGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct DartConfig { /// Dart package name (recorded in `pubspec.yaml`). Defaults to /// `"weaveffi"`. pub package_name: Option, /// When `true` (the default), strip the IR module path from emitted - /// function and listener names, so a `contacts` module exports - /// `createContact` rather than `contactsCreateContact`. Set to `false` - /// to restore module-prefixed names. + /// free-function names, so a `contacts` module exports `createContact` + /// rather than `contactsCreateContact`. Set to `false` to restore + /// module-prefixed names. pub strip_module_prefix: bool, /// C ABI symbol prefix (default `"weaveffi"`). Normally set once globally /// via `[global] c_prefix`; honored so the `dart:ffi` bindings call the @@ -112,10 +128,7 @@ impl DartGenerator { ) -> String { let input_basename = config.input_basename(); let mut out = render_prelude(CommentStyle::DoubleSlash, input_basename); - let has_async = model - .modules - .iter() - .any(|m| m.callables().any(|f| f.is_async)); + let has_async = model.has_async(); // The default shared-library basename follows the package identity // (`lib`), matching the producer cdylib. WEAVEFFI_LIBRARY still wins. let resolved = pkg::resolve(api, None, Some(input_basename)); @@ -141,20 +154,11 @@ impl DartGenerator { render_error_plumbing(&mut out); render_buffer_runtime(&mut out); - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_listeners { - out.push_str("\n// Live listener trampolines by subscription id. Holding the\n"); - out.push_str( - "// NativeCallable here keeps its native thunk alive until unregistered.\n", - ); - out.push_str("final Map _listenerCallables = {};\n"); + if model.has_callback_interfaces() { + render_callback_runtime(&mut out); } - let has_iterators = model.modules.iter().any(|m| { - m.callables() - .any(|f| matches!(f.shape, CallShape::Iterator(_))) - }); - if has_iterators { + if model.has_iterators() { out.push_str("\n// Anchors one live native iteration for its GC-finalizer backstop.\n"); out.push_str( "// A suspended `sync*` frame keeps the anchor reachable; abandoning the\n", @@ -182,7 +186,7 @@ impl LanguageBackend for DartGenerator { "dart" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -224,24 +228,14 @@ impl LanguageBackend for DartGenerator { render_interface(out, module, i); } - fn render_callback( + fn render_callback_interface( &self, out: &mut String, _module: &ModuleBinding, - c: &CallbackBinding, + cb: &CallbackInterfaceBinding, _config: &Self::Config, ) { - render_callback_typedef(out, c); - } - - fn render_listener( - &self, - out: &mut String, - module: &ModuleBinding, - l: &ListenerBinding, - config: &Self::Config, - ) { - render_listener(out, module, l, config.strip_module_prefix); + render_callback_interface(out, cb); } fn render_function( @@ -330,8 +324,14 @@ impl LanguageBackend for DartGenerator { render_packaged_readme(&package, ctx, input_basename), ), ]; - // Bundle every prebuilt library under native//. - for nb in &ctx.binaries.binaries { + // Bundle every prebuilt desktop library under native//; + // the loader has no slot for Android or wasm32 builds. + for nb in ctx + .binaries + .binaries + .iter() + .filter(|nb| bundles_platform(nb.platform)) + { let dest = dart_dir .join("native") .join(nb.platform.id()) diff --git a/crates/weaveffi-gen-dart/src/package.rs b/crates/weaveffi-gen-dart/src/package.rs index f184ff37..a8349b2e 100644 --- a/crates/weaveffi-gen-dart/src/package.rs +++ b/crates/weaveffi-gen-dart/src/package.rs @@ -5,6 +5,8 @@ use weaveffi_core::package::PackageContext; use weaveffi_core::pkg::ResolvedPackage; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; +use crate::runtime::bundles_platform; + /// Render the `pubspec.yaml` manifest for the generated Dart package. pub(crate) fn render_pubspec(package: &ResolvedPackage, input_basename: &str) -> String { let prelude = render_prelude(CommentStyle::Hash, input_basename); @@ -21,7 +23,7 @@ pub(crate) fn render_pubspec(package: &ResolvedPackage, input_basename: &str) -> version: {version}\n\ {meta}\ environment:\n\ - \x20 sdk: '>=3.0.0 <4.0.0'\n\ + \x20 sdk: '>=3.1.0 <4.0.0'\n\ dependencies:\n\ \x20 ffi: ^2.0.0\n\n\ {trailer}" @@ -54,9 +56,23 @@ import 'package:{import_name}/weaveffi.dart'; the native library at runtime via `DynamicLibrary.open` and resolve symbols with `lookupFunction`. +## Objects and callbacks + +Interface objects are reference counted by the native library. Each Dart +wrapper holds one reference: call `dispose()` when you are done with it, or let +the garbage collector's finalizer release it. Objects nested inside records and +collections follow the same rule. + +Callback interfaces are abstract classes: implement one and pass an instance to +any function that takes it. Methods are bound with `NativeCallable.isolateLocal`, +so the native library may only invoke them on the isolate's own thread (which +holds when it calls them synchronously during a call from Dart). An exception +thrown by an implementation aborts the native call and surfaces to the original +Dart caller as a `WeaveFFIException` with `WeaveFFIException.foreignCode`. + ## Requirements -- Dart SDK >= 3.0.0 +- Dart SDK >= 3.1.0 (for `NativeCallable`) - The `ffi` package (`^2.0.0`) for `Utf8` and `calloc` helpers. {trailer}"# @@ -76,6 +92,7 @@ pub(crate) fn render_packaged_readme( let platforms: Vec = ctx .binaries .platforms() + .filter(|p| bundles_platform(*p)) .map(|p| format!("- `native/{}/`", p.id())) .collect(); let platform_list = platforms.join("\n"); diff --git a/crates/weaveffi-gen-dart/src/runtime.rs b/crates/weaveffi-gen-dart/src/runtime.rs index 64fa161d..ebe340e4 100644 --- a/crates/weaveffi-gen-dart/src/runtime.rs +++ b/crates/weaveffi-gen-dart/src/runtime.rs @@ -1,9 +1,15 @@ //! The private Dart runtime every generated library ships: the dynamic //! library loaders, the error struct and generic exception plumbing, the -//! value-buffer writer/reader pair, and the `lookupFunction` binding helper. +//! value-buffer writer/reader pair, the callback-interface handle table, and +//! the `lookupFunction` binding helper. use heck::{ToLowerCamelCase, ToUpperCamelCase}; use weaveffi_core::cabi::ABI_VERSION; +use weaveffi_core::platform::{Os, Platform}; + +/// The reserved `weaveffi_error.code` a callback-interface trampoline reports +/// when the Dart implementation raised (`FOREIGN_ERROR_CODE` in the ABI). +pub(crate) const FOREIGN_ERROR_CODE: i32 = -4; /// Emit `_checkAbiVersion`, which the `_lib` initializer routes the freshly /// opened library through. It runs before any other lookup, so a producer @@ -62,6 +68,30 @@ pub(crate) fn dart_loader_original(lib_base: &str) -> String { out } +/// Whether the Dart package bundles a prebuilt library for `platform`: the +/// `dart:ffi` loader resolves `native//` for the desktop matrix +/// only (Android libraries ship through Flutter's `jniLibs`, and a `.wasm` +/// module can't be opened with `DynamicLibrary`). +pub(crate) fn bundles_platform(platform: Platform) -> bool { + platform.is_desktop() +} + +/// The bundled candidate paths the packaged loader tries for one OS family, +/// in [`Platform::DESKTOP`] order, followed by the bare system library name. +fn loader_candidates(os: Os, lib: &str) -> String { + let mut candidates: Vec = Platform::DESKTOP + .iter() + .filter(|p| p.os() == os) + .map(|p| format!("'native/{}/{}'", p.id(), p.lib_filename(lib))) + .collect(); + let bare = Platform::DESKTOP + .iter() + .find(|p| p.os() == os) + .map_or_else(|| format!("lib{lib}.so"), |p| p.lib_filename(lib)); + candidates.push(format!("'{bare}'")); + candidates.join(", ") +} + /// The packaged `_openLibrary` for `lib`: try the bundled `native//` /// libraries (relative to the working directory) before the bare system name. /// `WEAVEFFI_LIBRARY` still overrides. @@ -75,15 +105,18 @@ pub(crate) fn dart_loader_packaged(lib: &str) -> String { out.push_str(" final candidates = [];\n"); out.push_str(" if (Platform.isMacOS) {\n"); out.push_str(&format!( - " candidates.addAll(['native/darwin-arm64/lib{lib}.dylib', 'native/darwin-x64/lib{lib}.dylib', 'lib{lib}.dylib']);\n" + " candidates.addAll([{}]);\n", + loader_candidates(Os::MacOs, lib) )); out.push_str(" } else if (Platform.isWindows) {\n"); out.push_str(&format!( - " candidates.addAll(['native/windows-x64/{lib}.dll', '{lib}.dll']);\n" + " candidates.addAll([{}]);\n", + loader_candidates(Os::Windows, lib) )); out.push_str(" } else {\n"); out.push_str(&format!( - " candidates.addAll(['native/linux-x64/lib{lib}.so', 'native/linux-arm64/lib{lib}.so', 'lib{lib}.so']);\n" + " candidates.addAll([{}]);\n", + loader_candidates(Os::Linux, lib) )); out.push_str(" }\n"); out.push_str(" for (final candidate in candidates) {\n"); @@ -350,6 +383,70 @@ final class _BufferReader { ); } +/// Emit the shared callback-interface runtime: the handle table that keeps +/// Dart implementations alive while the producer holds them, the key +/// allocator whose value crosses as `ctx`, the `NativeCallable` anchor list +/// that pins every vtable trampoline for the process lifetime, and the +/// `_foreignError` reporter trampolines route a thrown exception through. +pub(crate) fn render_callback_runtime(out: &mut String) { + out.push_str(&format!( + r#" +// ── WeaveFFI callback-interface runtime ── +// A Dart implementation passed to the producer is stored here under an +// integer key; the key (widened to a pointer) is what crosses as `ctx`, so the +// producer never holds a Dart object and the GC never sees a raw pointer. The +// entry lives until the producer calls the vtable's `free(ctx)`. +final Map _callbackTable = {{}}; +int _nextCallbackKey = 1; + +Pointer _registerCallback(Object impl) {{ + final key = _nextCallbackKey++; + _callbackTable[key] = impl; + return Pointer.fromAddress(key); +}} + +Object _callbackFor(Pointer ctx) {{ + final impl = _callbackTable[ctx.address]; + if (impl == null) {{ + throw StateError('callback interface context ${{ctx.address}} is not registered'); + }} + return impl; +}} + +// Vtable trampolines are process-wide: their NativeCallables are never closed, +// so they are anchored here to keep the native thunks alive. `keepIsolateAlive` +// is cleared on each so an idle isolate can still exit. +final List> _callbackCallables = []; + +Pointer> _pinCallable( + NativeCallable callable) {{ + callable.keepIsolateAlive = false; + _callbackCallables.add(callable); + return callable.nativeFunction; +}} + +// Reports a Dart exception thrown by a callback-interface implementation +// through `weaveffi_error_set` with the reserved foreign error code ({code}). +// The producer copies the message, so the temporary is freed right away. This +// must never throw: it runs in the catch path of a trampoline whose C frame +// an exception must not unwind through. +void _foreignError(Pointer<_WeaveFFIError> outErr, Object error) {{ + if (outErr == nullptr) return; + Pointer message = nullptr; + try {{ + message = error.toString().toNativeUtf8(); + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, message); + }} catch (_) {{ + _weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, nullptr); + }} finally {{ + if (message != nullptr) calloc.free(message); + }} +}} +"#, + code = FOREIGN_ERROR_CODE + )); +} + /// Emit the dart:ffi typedef pair and `lookupFunction` binding for one C /// symbol. pub(crate) fn emit_typedef_and_lookup( @@ -391,6 +488,18 @@ pub(crate) fn render_error_plumbing(out: &mut String) { out.push_str(" external int payloadLen;\n"); out.push_str("}\n"); + // Callback-interface trampolines report a failed Dart implementation + // through `weaveffi_error_set`, which copies the borrowed message with the + // producer's allocator (the producer frees `message` itself, so the + // consumer must never write the struct directly). + emit_typedef_and_lookup( + out, + "weaveffi_error_set", + "Pointer<_WeaveFFIError>, Int32, Pointer", + "Pointer<_WeaveFFIError>, int, Pointer", + "Void", + "void", + ); emit_typedef_and_lookup( out, "weaveffi_error_clear", @@ -434,10 +543,21 @@ pub(crate) fn render_error_plumbing(out: &mut String) { "void", ); - out.push_str( - "\n/// Generic WeaveFFI failure: panics, marshalling errors, and unknown codes.\n", - ); + out.push_str("\n/// Generic WeaveFFI failure: a producer panic ([panicCode]), a marshalling\n"); + out.push_str("/// error ([marshalCode]), a callback-interface implementation that threw\n"); + out.push_str("/// ([foreignCode], carrying the Dart exception's text), or an unknown code.\n"); out.push_str("class WeaveFFIException implements Exception {\n"); + out.push_str(" /// The producer reported an untyped error.\n"); + out.push_str(" static const int genericCode = -1;\n"); + out.push_str(" /// The producer panicked; [message] carries the panic text.\n"); + out.push_str(" static const int panicCode = -2;\n"); + out.push_str(" /// An argument could not be lifted by the producer.\n"); + out.push_str(" static const int marshalCode = -3;\n"); + out.push_str(" /// A Dart callback-interface implementation threw; [message] carries the\n"); + out.push_str(" /// exception's text.\n"); + out.push_str(&format!( + " static const int foreignCode = {FOREIGN_ERROR_CODE};\n" + )); out.push_str(" final int code;\n"); out.push_str(" final String message;\n"); out.push_str(" WeaveFFIException(this.code, this.message);\n"); diff --git a/crates/weaveffi-gen-dart/src/types.rs b/crates/weaveffi-gen-dart/src/types.rs index 7cb2a6f6..aad41583 100644 --- a/crates/weaveffi-gen-dart/src/types.rs +++ b/crates/weaveffi-gen-dart/src/types.rs @@ -16,9 +16,8 @@ pub(crate) fn dart_ident(name: &str) -> String { lang::escape_ident(&name.to_lower_camel_case(), lang::DART_KEYWORDS) } -/// The Dart spelling of a module-level wrapper (free function or listener -/// register/unregister pair): the module-path prefix applied per config, -/// then lowerCamelCase, then keyword escaping. +/// The Dart spelling of a module-level free function: the module-path prefix +/// applied per config, then lowerCamelCase, then keyword escaping. pub(crate) fn dart_wrapper_fn_name( module_path: &str, name: &str, @@ -33,26 +32,20 @@ pub(crate) fn dart_wrapper_fn_name( /// The idiomatic Dart type a [`Ty`] surfaces as. pub(crate) fn dart_type(ty: &Ty) -> String { match ty { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::I64 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::U64 - | Ty::Handle => "int".into(), + Ty::I8 | Ty::I16 | Ty::I32 | Ty::I64 | Ty::U8 | Ty::U16 | Ty::U32 | Ty::U64 => "int".into(), Ty::F32 | Ty::F64 => "double".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "String".into(), - Ty::Bytes | Ty::BorrowedBytes => "List".into(), - // Records, rich enums, C-style enums, typed handles, and interfaces - // all surface as bare local Dart classes. A cross-module reference - // (resolved to e.g. `kv.Store`) must still name the local `Store` - // class, not the qualified IR name. - Ty::TypedHandle(n) | Ty::Enum(n) | Ty::Record(n) | Ty::RichEnum(n) | Ty::Interface(n) => { - local_type_name(n).to_upper_camel_case() - } + Ty::StringUtf8 => "String".into(), + Ty::Bytes => "List".into(), + // Records, rich enums, C-style enums, interfaces, and callback + // interfaces all surface as bare local Dart classes. A cross-module + // reference (resolved to e.g. `kv.Store`) must still name the local + // `Store` class, not the qualified IR name. + Ty::Enum(n) + | Ty::Record(n) + | Ty::RichEnum(n) + | Ty::Interface(n) + | Ty::CallbackInterface(n) => local_type_name(n).to_upper_camel_case(), Ty::Optional(inner) => format!("{}?", dart_type(inner)), Ty::List(inner) => format!("List<{}>", dart_type(inner)), Ty::Iterator(inner) => format!("Iterable<{}>", dart_type(inner)), @@ -65,6 +58,26 @@ pub(crate) fn dart_class(name: &str) -> String { local_type_name(name).to_upper_camel_case() } +/// The Dart wrapper class of a direct or nullable interface reference. +/// +/// # Panics +/// +/// Panics when `ty` is not an interface or an optional interface; callers +/// dispatch on [`RetPass::Object`] or [`ArgPass::Object`] first. +pub(crate) fn object_class(ty: &Ty) -> String { + match ty { + Ty::Interface(name) => dart_class(name), + Ty::Optional(inner) => object_class(inner), + _ => unreachable!("object positions are (optional) interfaces"), + } +} + +/// The private Dart global holding a callback interface's one static vtable +/// pointer: `_{Class}Vtable`. +pub(crate) fn vtable_var(name: &str) -> String { + format!("_{}Vtable", dart_class(name)) +} + /// dart:ffi (native, dart) types of a leaf scalar passed by value. `Bool` is /// one byte, matching the producer's C `bool`, so by-value slots stay honest. pub(crate) fn scalar_ffi(ty: &Ty) -> (&'static str, &'static str) { @@ -77,20 +90,31 @@ pub(crate) fn scalar_ffi(ty: &Ty) -> (&'static str, &'static str) { Ty::U64 => ("Uint64", "int"), Ty::I32 | Ty::Enum(_) => ("Int32", "int"), Ty::Bool => ("Bool", "bool"), - Ty::I64 | Ty::Handle => ("Int64", "int"), + Ty::I64 => ("Int64", "int"), Ty::F32 => ("Float", "double"), Ty::F64 => ("Double", "double"), _ => ("Int64", "int"), } } +/// The Dart literal a trampoline returns when the implementation raised: the +/// zero value of the method's direct return type (`0`, `0.0`, or `false`). +pub(crate) fn default_literal(ty: &Ty) -> &'static str { + match ty { + Ty::Bool => "false", + Ty::F32 | Ty::F64 => "0.0", + _ => "0", + } +} + // ── ABI slot typing ── /// The (native, dart) FFI typedef slot pairs a single input parameter expands /// into, mirroring the C ABI and driven by the parameter's [`ArgPass`] /// contract: a buffered value is one borrowed `(const uint8_t*, size_t)` -/// pair; bytes fan out to `(ptr, len)`; strings, interfaces, and typed -/// handles stay one pointer slot; everything else is a by-value scalar. +/// pair; bytes fan out to `(ptr, len)`; strings and interfaces stay one +/// pointer slot; a callback interface is a `(ctx, vtable)` pointer pair; +/// everything else is a by-value scalar. pub(crate) fn input_slots(p: &ParamBinding) -> Vec<(String, String)> { let ptr = |s: &str| (s.to_string(), s.to_string()); match p.arg_pass() { @@ -101,37 +125,31 @@ pub(crate) fn input_slots(p: &ParamBinding) -> Vec<(String, String)> { // Interfaces and nullable interfaces are one (possibly null) object // pointer. ArgPass::Object { .. } => vec![ptr("Pointer")], - ArgPass::Direct { .. } => match &p.ty { - // A typed handle's direct slot is an opaque pointer. - Ty::TypedHandle(_) => vec![ptr("Pointer")], - ty => { - let (n, d) = scalar_ffi(ty); - vec![(n.into(), d.into())] - } - }, + // The consumer's handle-table key and its static vtable. + ArgPass::Callback { .. } => vec![ptr("Pointer"), ptr("Pointer")], + ArgPass::Direct { .. } => { + let (n, d) = scalar_ffi(&p.ty); + vec![(n.into(), d.into())] + } } } /// The FFI return type (native, dart) of a call symbol, driven by the /// return's [`RetPass`] contract. Buffered and bytes returns come back as a /// producer-allocated `Pointer`; strings as `Pointer`; -/// interfaces and typed handles as opaque pointers. +/// interfaces as opaque pointers. pub(crate) fn return_ffi(ty: &Ty) -> (String, String) { let ptr = |s: &str| (s.to_string(), s.to_string()); - // Module and prefix only shape an object return's destroy symbol, which - // the FFI typedef never names; empty context is fine here. + // Module and prefix only shape an object return's clone and destroy + // symbols, which the FFI typedef never names; empty context is fine here. match plan::ret_pass(Some(ty), "", "") { RetPass::Buffer | RetPass::Bytes => ptr("Pointer"), RetPass::String => ptr("Pointer"), RetPass::Object { .. } => ptr("Pointer"), - RetPass::Void | RetPass::Direct => match ty { - // A typed handle's direct slot is an opaque pointer. - Ty::TypedHandle(_) => ptr("Pointer"), - ty => { - let (n, d) = scalar_ffi(ty); - (n.into(), d.into()) - } - }, + RetPass::Void | RetPass::Direct => { + let (n, d) = scalar_ffi(ty); + (n.into(), d.into()) + } } } diff --git a/crates/weaveffi-gen-dart/tests/abi2.rs b/crates/weaveffi-gen-dart/tests/abi2.rs new file mode 100644 index 00000000..90b3fe69 --- /dev/null +++ b/crates/weaveffi-gen-dart/tests/abi2.rs @@ -0,0 +1,429 @@ +//! ABI revision 2 rendering checks: reference-counted objects, nullable +//! objects, object tokens inside value buffers, iterators of objects, and +//! callback-interface vtables with their trampolines. + +use camino::Utf8Path; +use weaveffi_core::backend::LanguageBackend; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::PackageContext; +use weaveffi_core::platform::{BinarySet, Platform}; +use weaveffi_core::validate::validate_api; +use weaveffi_gen_dart::{DartConfig, DartGenerator}; +use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, EnumDef, EnumVariant, Function, InterfaceDef, Module, Param, + StructDef, StructField, TypeRef, CURRENT_SCHEMA_VERSION, +}; + +fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } +} + +fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } +} + +fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } +} + +fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) +} + +/// One module exercising every revision-2 shape: an interface with a +/// constructor and a method, `Interface?` in and out, a record with an +/// `Interface` field and a `[Interface]` field, an iterator over interface +/// elements, a callback interface with string, `i32`, record, and object +/// parameters (one `bool` return, one `void`), and a function taking it. +fn fixture() -> Api { + Api { + version: CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "bus".into(), + doc: None, + functions: vec![ + func( + "lookup", + vec![param( + "fallback", + TypeRef::Optional(Box::new(named("Ticker"))), + )], + Some(TypeRef::Optional(Box::new(named("Ticker")))), + ), + func( + "tickers", + vec![], + Some(TypeRef::Iterator(Box::new(named("Ticker")))), + ), + func( + "subscribe", + vec![param("listener", named("Subscriber"))], + None, + ), + func( + "describe", + vec![param("env", named("Envelope"))], + Some(named("Envelope")), + ), + Function { + r#async: true, + ..func( + "fetch", + vec![param("id", TypeRef::I64)], + Some(TypeRef::Optional(Box::new(named("Ticker")))), + ) + }, + ], + interfaces: vec![InterfaceDef { + name: "Ticker".into(), + doc: Some("A counter.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("start", TypeRef::I64)], None)], + methods: vec![func("value", vec![], Some(TypeRef::I64))], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Subscriber".into(), + doc: Some("Receives bus events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("envelope", named("Envelope")), + ], + Some(TypeRef::Bool), + ), + func( + "on_ticker", + vec![ + param("ticker", named("Ticker")), + param("alt", TypeRef::Optional(Box::new(named("Ticker")))), + ], + None, + ), + func( + "classify", + vec![param("weight", TypeRef::I32)], + Some(named("Priority")), + ), + ], + }], + structs: vec![StructDef { + name: "Envelope".into(), + doc: None, + deprecated: None, + fields: vec![ + field("topic", TypeRef::StringUtf8), + field("primary", named("Ticker")), + field("others", TypeRef::List(Box::new(named("Ticker")))), + ], + }], + enums: vec![EnumDef { + name: "Priority".into(), + doc: None, + deprecated: None, + variants: vec![ + EnumVariant { + name: "Low".into(), + value: 0, + doc: None, + fields: vec![], + }, + EnumVariant { + name: "High".into(), + value: 1, + doc: None, + fields: vec![], + }, + ], + }], + errors: None, + modules: vec![], + }], + } +} + +fn render() -> String { + let api = validate_api(fixture(), None).expect("fixture validates"); + let config = DartConfig::default(); + let model = BindingModel::build(&api, DartGenerator.prefix(&config)); + let files = DartGenerator.files(&api, &model, Utf8Path::new("out"), &config); + files + .into_iter() + .find(|f| f.path.as_str().ends_with("weaveffi.dart")) + .expect("primary source") + .contents +} + +fn assert_has(src: &str, needle: &str) { + assert!(src.contains(needle), "missing `{needle}` in:\n{src}"); +} + +#[test] +fn objects_are_reference_counted_wrappers_with_finalizers() { + let src = render(); + assert_has(&src, "class Ticker implements Finalizable {"); + assert_has(&src, "_lib.lookupFunction<\n _NativeWeaveffiBusTickerClone, _DartWeaveffiBusTickerClone>('weaveffi_bus_Ticker_clone')"); + assert_has(&src, "_lib.lookupFunction<\n _NativeWeaveffiBusTickerDestroy, _DartWeaveffiBusTickerDestroy>('weaveffi_bus_Ticker_destroy')"); + assert_has( + &src, + "final _weaveffiBusTickerDestroyFinalizer = NativeFinalizer(\n _lib.lookup)>>('weaveffi_bus_Ticker_destroy'));", + ); + // Adopting attaches the finalizer; dispose detaches and destroys once. + assert_has(&src, "Ticker._(this._ptr) {\n _weaveffiBusTickerDestroyFinalizer.attach(this, _ptr, detach: this);"); + assert_has(&src, "void dispose() {\n if (_disposed) return;\n _disposed = true;\n _weaveffiBusTickerDestroyFinalizer.detach(this);\n _weaveffiBusTickerDestroy(_ptr);"); + assert_has( + &src, + "if (_disposed) throw StateError('Ticker used after dispose()');", + ); + assert_has( + &src, + "Pointer _cloneRef() => _weaveffiBusTickerClone(_handle);", + ); + // Constructor and method render as factory and instance method. + assert_has(&src, "factory Ticker(int start) {"); + assert_has(&src, "return Ticker._(result);"); + assert_has(&src, "int value() {"); + assert_has(&src, "_weaveffiBusTickerValue(_handle, err)"); +} + +#[test] +fn nullable_objects_are_nullable_pointers_both_ways() { + let src = render(); + assert_has(&src, "Ticker? lookup(Ticker? fallback) {"); + assert_has( + &src, + "_weaveffiBusLookup(fallback?._handle ?? nullptr, err)", + ); + assert_has(&src, "return result == nullptr ? null : Ticker._(result);"); + assert_has(&src, "Returns `null` when the producer reports no object."); +} + +#[test] +fn objects_inside_records_are_cloned_tokens() { + let src = render(); + assert_has(&src, "final Ticker primary;"); + assert_has(&src, "final List others;"); + // Encoding writes a fresh clone, never the wrapper's own pointer. + assert_has(&src, "w.writeUint64(v.primary._cloneRef().address);"); + assert_has(&src, "w.writeUint64(t1._cloneRef().address);"); + // Decoding adopts each token into a new wrapper. + assert_has( + &src, + "primary: Ticker._(Pointer.fromAddress(r.readUint64())),", + ); + assert_has( + &src, + "others: List.generate(r.readLength(), (_) => Ticker._(Pointer.fromAddress(r.readUint64()))),", + ); +} + +#[test] +fn iterators_adopt_object_elements() { + let src = render(); + assert_has(&src, "Iterable tickers() sync* {"); + assert_has(&src, "final outItem = calloc>();"); + assert_has( + &src, + "final itemPtr = outItem.value;\n yield Ticker._(itemPtr);", + ); + assert_has(&src, "_weaveffiBusTickersIteratorDestroy(iter);"); + assert_has(&src, "Each yielded element is owned by the caller"); +} + +#[test] +fn callback_interfaces_render_abstract_class_vtable_and_trampolines() { + let src = render(); + // Consumer-facing abstract class. + assert_has(&src, "abstract class Subscriber {"); + assert_has( + &src, + "bool onMessage(String text, int weight, Envelope envelope);", + ); + assert_has(&src, "void onTicker(Ticker ticker, Ticker? alt);"); + assert_has(&src, "Priority classify(int weight);"); + assert_has( + &src, + "Dart limitation: methods are bound with `NativeCallable.isolateLocal`", + ); + + // The vtable layout: one entry per method in order, then free. + assert_has( + &src, + "final class _SubscriberVtableStruct extends Struct {\n external Pointer> onMessage;\n external Pointer> onTicker;\n external Pointer> classify;\n external Pointer> free;\n}", + ); + // A C-style enum return crosses as its discriminant. + assert_has(&src, "typedef _NativeSubscriberVtClassify = Int32 Function(Pointer, Int32, Pointer<_WeaveFFIError>);"); + assert_has(&src, "return impl.classify(weight).value;"); + assert_has( + &src, + "typedef _NativeSubscriberVtOnMessage = Bool Function(Pointer, Pointer, Int32, Pointer, Size, Pointer<_WeaveFFIError>);", + ); + assert_has( + &src, + "typedef _NativeSubscriberVtOnTicker = Void Function(Pointer, Pointer, Pointer, Pointer<_WeaveFFIError>);", + ); + + // Trampolines decode borrowed args, adopt objects, and trap exceptions. + assert_has( + &src, + "bool _subscriberVtOnMessage(Pointer ctx, Pointer text, int weight, Pointer envelopePtr, int envelopeLen, Pointer<_WeaveFFIError> outErr) {", + ); + assert_has(&src, "final impl = _callbackFor(ctx) as Subscriber;"); + assert_has( + &src, + "final envelopeData = _copyNativeBytes(envelopePtr, envelopeLen);", + ); + assert_has( + &src, + "final envelopeValue = _unpackEnvelope(envelopeReader);", + ); + assert_has( + &src, + "return impl.onMessage(text == nullptr ? '' : text.toDartString(), weight, envelopeValue);", + ); + assert_has( + &src, + "} catch (e) {\n _foreignError(outErr, e);\n return false;\n }", + ); + // Object arguments are adopted before the implementation lookup so a + // failure never leaks the transferred reference. + assert_has( + &src, + "final tickerValue = Ticker._(ticker);\n final altValue = alt == nullptr ? null : Ticker._(alt);\n final impl = _callbackFor(ctx) as Subscriber;\n impl.onTicker(tickerValue, altValue);", + ); + assert_has(&src, "} catch (e) {\n _foreignError(outErr, e);\n }"); + // free drops the handle-table entry. + assert_has( + &src, + "void _subscriberVtFree(Pointer ctx) {\n _callbackTable.remove(ctx.address);", + ); + + // Exactly one static vtable, calloc-allocated, pinned callables. + assert_eq!( + src.matches("final Pointer<_SubscriberVtableStruct> _SubscriberVtable = () {") + .count(), + 1 + ); + assert_has(&src, "final vt = calloc<_SubscriberVtableStruct>();"); + assert_has( + &src, + "vt.ref.onMessage = _pinCallable(NativeCallable<_NativeSubscriberVtOnMessage>.isolateLocal(\n _subscriberVtOnMessage, exceptionalReturn: false));", + ); + assert_has( + &src, + "vt.ref.onTicker = _pinCallable(NativeCallable<_NativeSubscriberVtOnTicker>.isolateLocal(\n _subscriberVtOnTicker));", + ); + assert_has(&src, "vt.ref.free = _pinCallable(NativeCallable<_NativeSubscriberVtFree>.listener(\n _subscriberVtFree));"); + assert_has(&src, "callable.keepIsolateAlive = false;"); + + // Passing an implementation: handle-table key as ctx plus the vtable. + assert_has(&src, "void subscribe(Subscriber listener) {"); + assert_has( + &src, + "_weaveffiBusSubscribe(_registerCallback(listener), _SubscriberVtable.cast(), err)", + ); +} + +#[test] +fn runtime_binds_error_set_and_foreign_code() { + let src = render(); + assert_has(&src, "typedef _NativeWeaveffiErrorSet = Void Function(Pointer<_WeaveFFIError>, Int32, Pointer);"); + assert_has(&src, "'weaveffi_error_set'"); + assert_has(&src, "static const int foreignCode = -4;"); + assert_has( + &src, + "_weaveffiErrorSet(outErr, WeaveFFIException.foreignCode, message);", + ); + assert_has(&src, "const int _abiVersion = 2;"); + for gone in [ + "arena", + "weaveffi_handle_t", + "TypedHandle", + "register_", + "_listenerCallables", + ] { + assert!(!src.contains(gone), "stale `{gone}` in output"); + } +} + +#[test] +fn package_bundles_only_desktop_binaries() { + let api = validate_api(fixture(), None).expect("fixture validates"); + let config = DartConfig::default(); + let model = BindingModel::build(&api, DartGenerator.prefix(&config)); + let mut binaries = BinarySet::new("bus"); + for p in Platform::ALL { + binaries.insert(p, format!("/prebuilt/{}/lib", p.id())); + } + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("bus.yml"), + }; + let files = DartGenerator + .package(&api, &model, &ctx, Utf8Path::new("out"), &config) + .expect("dart packages"); + let bundled: Vec<&str> = files + .iter() + .filter(|f| f.is_binary()) + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(bundled.len(), Platform::DESKTOP.len()); + for p in Platform::DESKTOP { + assert!( + bundled + .iter() + .any(|b| b.contains(&format!("native/{}/", p.id()))), + "{} missing from {bundled:?}", + p.id() + ); + } + for p in [ + Platform::AndroidArm64, + Platform::AndroidX64, + Platform::Wasm32, + ] { + assert!( + !bundled.iter().any(|b| b.contains(p.id())), + "{} bundled", + p.id() + ); + } + let src = files + .iter() + .find(|f| f.path.as_str().ends_with("weaveffi.dart")) + .and_then(|f| match &f.content { + weaveffi_core::package::FileContent::Text(s) => Some(s.as_str()), + weaveffi_core::package::FileContent::Copy(_) => None, + }) + .expect("packaged source"); + assert_has( + src, + "'native/darwin-arm64/libbus.dylib', 'native/darwin-x64/libbus.dylib', 'libbus.dylib'", + ); + assert_has( + src, + "'native/linux-x64/libbus.so', 'native/linux-arm64/libbus.so', 'libbus.so'", + ); + assert_has(src, "'native/windows-x64/bus.dll', 'bus.dll'"); +} diff --git a/crates/weaveffi-gen-dotnet/Cargo.toml b/crates/weaveffi-gen-dotnet/Cargo.toml index 93191259..b68744d0 100644 --- a/crates/weaveffi-gen-dotnet/Cargo.toml +++ b/crates/weaveffi-gen-dotnet/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-dotnet/src/callbacks.rs b/crates/weaveffi-gen-dotnet/src/callbacks.rs new file mode 100644 index 00000000..8007ae3e --- /dev/null +++ b/crates/weaveffi-gen-dotnet/src/callbacks.rs @@ -0,0 +1,274 @@ +//! Callback-interface renderers: the consumer-facing C# `interface` plus the +//! process-wide vtable and its `[UnmanagedCallersOnly]` trampolines, honoring +//! the shared `CallbackProtocol` contract. +//! +//! * One static vtable per callback interface: a sequential struct of +//! function pointers written once into unmanaged memory that's never +//! freed, so its address is stable for the process lifetime. +//! * `ctx` is a `GCHandle` to the implementation, allocated when the wrapper +//! passes it and released by the vtable's `free` trampoline, so the GC +//! keeps the object alive exactly as long as the producer holds it. +//! * Trampoline arguments are received like returns: strings, bytes, and +//! buffers are borrowed (copied or decoded, never freed) and objects are +//! adopted into wrappers the implementation owns. +//! * A thrown exception never unwinds through the C frame: the trampoline +//! reports it with `{prefix}_error_set(out_err, -4, message)` and returns a +//! default value. + +use heck::{ToLowerCamelCase, ToUpperCamelCase}; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::model::{ + CallbackInterfaceBinding, CallbackMethodBinding, Family, ModuleBinding, ParamBinding, +}; + +use crate::calls::{adopt_object, direct_from_slot, direct_to_slot, object_class, write_obsolete}; +use crate::codec::emit_buffer_decode; +use crate::docs::{writer_doc, writer_fn_doc}; +use crate::pinvoke::pinvoke_slot; +use crate::types::{ + callback_interface_cs, cs_pinvoke_ctype, cs_type, safe_cs_name, vtable_class_cs, +}; + +/// Render one callback interface: the `public interface I{Name}` the consumer +/// implements, followed by the internal static class hosting the vtable and +/// trampolines that adapt an implementation to the C ABI. +pub(crate) fn render_callback_interface( + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, +) { + render_consumer_interface(out, cb); + render_vtable_class(out, module, cb); +} + +/// The C# method name of one callback method. +fn method_cs(m: &CallbackMethodBinding) -> String { + m.name.to_upper_camel_case() +} + +/// The consumer-facing `interface`: one method per callback method with +/// idiomatic C# types (objects as wrapper classes the implementation owns, +/// `Interface?` as a nullable wrapper). +fn render_consumer_interface(out: &mut String, cb: &CallbackInterfaceBinding) { + let iface = callback_interface_cs(&cb.name); + let mut w = CodeWriter::four_space().with_depth(1); + writer_doc(&mut w, &cb.doc); + w.line("/// Implement this interface and pass an instance wherever the"); + w.line("/// native library expects it. The library may call any method from any"); + w.line("/// thread until it releases its last reference to the instance. Object"); + w.line("/// arguments are owned by the implementation, which should dispose them"); + w.line("/// when done. An exception thrown by a method is reported to the native"); + w.line("/// caller as ."); + write_obsolete(&mut w, &cb.deprecated); + w.line(format!("public interface {iface}")); + w.block("{", "}", |w| { + for m in &cb.methods { + let params: Vec = m + .params + .iter() + .map(|p| { + let mut p = p.clone(); + p.name = p.name.to_lower_camel_case(); + p + }) + .collect(); + writer_fn_doc(w, &m.doc, ¶ms); + write_obsolete(w, &m.deprecated); + let ret = m.ret.as_ref().map(cs_type).unwrap_or_else(|| "void".into()); + let sig: Vec = params + .iter() + .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) + .collect(); + w.line(format!("{ret} {}({});", method_cs(m), sig.join(", "))); + } + }); + w.blank(); + out.push_str(&w.finish()); +} + +/// The `delegate* unmanaged[Cdecl]<...>` function-pointer type matching one +/// trampoline's C signature: every slot's P/Invoke type, then the return. +fn function_pointer_type(m: &CallbackMethodBinding) -> String { + let mut parts: Vec = m + .abi_params + .iter() + .map(|slot| cs_pinvoke_ctype(&slot.ty)) + .collect(); + parts.push(cs_pinvoke_ctype(&m.abi_ret)); + format!("delegate* unmanaged[Cdecl]<{}>", parts.join(", ")) +} + +/// The internal static class owning the vtable: a sequential struct of +/// function pointers (one per method in declaration order, then `free`), +/// allocated once in unmanaged memory, plus the trampolines it points at. +fn render_vtable_class(out: &mut String, module: &ModuleBinding, cb: &CallbackInterfaceBinding) { + let iface = callback_interface_cs(&cb.name); + let class = vtable_class_cs(&module.path, &cb.name); + let mut w = CodeWriter::four_space().with_depth(1); + w.line(format!( + "/// The process-wide {} vtable and the trampolines behind", + cb.vtable_tag + )); + w.line(format!( + "/// it, adapting an to the C ABI." + )); + w.line(format!("internal static unsafe class {class}")); + w.line("{"); + w.indent(); + + // The C struct: one function pointer per method in declaration order, + // then the trailing `free`. + w.line("[StructLayout(LayoutKind.Sequential)]"); + w.line("private struct Layout"); + w.block("{", "}", |w| { + for m in &cb.methods { + w.line(format!("public IntPtr {};", safe_cs_name(&m.name))); + } + w.line("public IntPtr free;"); + }); + w.blank(); + + w.line("/// Address of the one static vtable for this interface. It is"); + w.line("/// allocated once and never freed, so the producer may keep the pointer"); + w.line("/// for the process lifetime."); + w.line("internal static readonly IntPtr Pointer = Allocate();"); + w.blank(); + + w.line("private static IntPtr Allocate()"); + w.block("{", "}", |w| { + w.line("var layout = new Layout"); + w.line("{"); + w.scope(|w| { + for m in &cb.methods { + w.line(format!( + "{} = (IntPtr)({})&{}Trampoline,", + safe_cs_name(&m.name), + function_pointer_type(m), + method_cs(m) + )); + } + w.line("free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline,"); + }); + w.line("};"); + w.line("var mem = Marshal.AllocHGlobal(Marshal.SizeOf());"); + w.line("Marshal.StructureToPtr(layout, mem, false);"); + w.line("return mem;"); + }); + w.blank(); + + w.line(format!("private static {iface} Target(IntPtr ctx)")); + w.block("{", "}", |w| { + w.line(format!("return ({iface})GCHandle.FromIntPtr(ctx).Target!;")); + }); + w.blank(); + + for m in &cb.methods { + render_trampoline(&mut w, m); + } + + // Fires exactly once, when the producer drops its last reference; the + // GCHandle it frees is the one the wrapper allocated when passing the + // implementation. + w.line("[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]"); + w.line("private static void FreeTrampoline(IntPtr ctx)"); + w.block("{", "}", |w| { + w.line("GCHandle.FromIntPtr(ctx).Free();"); + }); + + w.dedent(); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// One `[UnmanagedCallersOnly]` trampoline: decode the borrowed slots, adopt +/// object slots, call the implementation, and write the direct return; any +/// exception is reported through `out_err` with the foreign error code and a +/// default value is returned instead of unwinding. +fn render_trampoline(w: &mut CodeWriter, m: &CallbackMethodBinding) { + let ret_cs = cs_pinvoke_ctype(&m.abi_ret); + let sig: Vec = m.abi_params.iter().map(pinvoke_slot).collect(); + let out_err = safe_cs_name( + &m.abi_params + .last() + .expect("callback methods always end in out_err") + .name, + ); + let ctx = safe_cs_name(&m.abi_params[0].name); + + w.line("[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]"); + w.line(format!( + "private static {ret_cs} {}Trampoline({})", + method_cs(m), + sig.join(", ") + )); + w.block("{", "}", |w| { + w.line("try"); + w.block("{", "}", |w| { + w.line(format!("var impl = Target({ctx});")); + let args: Vec = m + .params + .iter() + .enumerate() + .map(|(idx, p)| render_trampoline_arg(w, p, idx)) + .collect(); + let call = format!("impl.{}({})", method_cs(m), args.join(", ")); + match &m.ret { + None => w.line(format!("{call};")), + Some(ty) => w.line(format!("return {};", direct_to_slot(ty, &call))), + }; + }); + w.line("catch (Exception ex)"); + w.block("{", "}", |w| { + w.line(format!( + "NativeMethods.weaveffi_error_set({out_err}, WeaveFFIException.ForeignErrorCode, ex.Message);" + )); + if m.ret.is_some() { + w.line("return default;"); + } + }); + }); + w.blank(); +} + +/// Statements (written to `w`) plus the expression converting one callback +/// parameter's slots into the value handed to the implementation, receiving +/// it per its [`Family`] like a return of the same type: strings, bytes, and +/// buffers are borrowed for the dispatch and copied (never freed); objects +/// transfer one strong reference and are adopted into a wrapper. +fn render_trampoline_arg(w: &mut CodeWriter, p: &ParamBinding, idx: usize) -> String { + let n0 = safe_cs_name(&p.abi[0].name); + let arg = format!("arg{idx}"); + match p.ty.family() { + Family::Buffer => { + let len = safe_cs_name(&p.abi[1].name); + w.line(format!("var {arg}Buf = new byte[(int){len}];")); + w.line(format!( + "if ({n0} != IntPtr.Zero && (int){len} > 0) Marshal.Copy({n0}, {arg}Buf, 0, (int){len});" + )); + emit_buffer_decode(w, &p.ty, &arg, &format!("{arg}Buf")); + arg + } + Family::String => format!("Marshal.PtrToStringUTF8({n0}) ?? \"\""), + Family::Bytes => { + let len = safe_cs_name(&p.abi[1].name); + w.line(format!("var {arg} = new byte[(int){len}];")); + w.line(format!( + "if ({n0} != IntPtr.Zero && (int){len} > 0) Marshal.Copy({n0}, {arg}, 0, (int){len});" + )); + arg + } + Family::Object { nullable } => { + let adopt = adopt_object(object_class(&p.ty), &n0); + if nullable { + format!("{n0} == IntPtr.Zero ? null : {adopt}") + } else { + adopt + } + } + Family::Direct => direct_from_slot(&p.ty, &n0), + Family::Callback | Family::Iterator => { + unreachable!("{} is not a valid callback method parameter", p.ty) + } + } +} diff --git a/crates/weaveffi-gen-dotnet/src/calls.rs b/crates/weaveffi-gen-dotnet/src/calls.rs index f34f243c..52603ebb 100644 --- a/crates/weaveffi-gen-dotnet/src/calls.rs +++ b/crates/weaveffi-gen-dotnet/src/calls.rs @@ -1,24 +1,22 @@ //! Call-path renderers: the wrapper methods for every call shape (sync, //! async, iterator), parameter marshalling driven by the shared -//! [`ArgPass`] plan, return conversions, listener registration, and the +//! [`ArgPass`] plan, return conversions driven by [`Family`], and the //! per-module static wrapper classes. use heck::ToUpperCamelCase; -use weaveffi_core::abi; +use weaveffi_core::abi::CType; use weaveffi_core::codegen::CodeWriter; -use weaveffi_core::model::Ty; use weaveffi_core::model::{ - CallShape, ErrorBinding, FnBinding, IteratorBinding, ListenerBinding, ModuleBinding, - ParamBinding, + CallShape, ErrorBinding, Family, FnBinding, IteratorBinding, ModuleBinding, ParamBinding, Ty, }; -use weaveffi_core::plan::{self, ArgPass, ElemFree, ErrorStrategy}; +use weaveffi_core::plan::{ArgPass, ErrorStrategy}; use weaveffi_core::utils::{local_type_name, wrapper_name}; use crate::codec::{emit_buffer_decode, emit_buffer_write}; -use crate::docs::{writer_doc, writer_fn_doc}; +use crate::docs::writer_fn_doc; use crate::pinvoke::is_error_slot; use crate::runtime::{check_method_name, dotnet_exception_name}; -use crate::types::{camel_fn, cs_type, safe_cs_name, typed_handle_cs}; +use crate::types::{camel_fn, cs_type, safe_cs_name, vtable_class_cs}; /// How a wrapper surfaces a non-zero error slot, rendering /// [`ErrorStrategy`]: [`ErrorStrategy::Throws`] with a domain in scope raises @@ -87,11 +85,28 @@ impl<'a> ErrCtx<'a> { } } -/// True when a parameter needs setup/cleanup statements around the native -/// call, per its [`ArgPass`] plan: strings (`CoTaskMem` UTF-8 copies), bytes -/// (pinned arrays), and every buffered value (encoded into a pinned value -/// buffer). Direct and object arguments pass inline. -pub(crate) fn param_needs_marshal(p: &ParamBinding) -> bool { +/// The `public` wrapper signature parameter list for `params`. +pub(crate) fn params_sig(params: &[ParamBinding]) -> String { + params + .iter() + .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) + .collect::>() + .join(", ") +} + +/// The `[Obsolete]` attribute line for a deprecated callable. +pub(crate) fn write_obsolete(w: &mut CodeWriter, deprecated: &Option) { + if let Some(msg) = deprecated { + w.line(format!("[Obsolete(\"{}\")]", msg.replace('"', "\\\""))); + } +} + +/// True when a parameter allocates something the wrapper must release after +/// the native call, per its [`ArgPass`] plan: strings (`CoTaskMem` UTF-8 +/// copies), bytes (pinned arrays), and every buffered value (encoded into a +/// pinned value buffer). Direct and object arguments pass inline, and a +/// callback's `GCHandle` is released by the producer's `free(ctx)` instead. +pub(crate) fn param_needs_cleanup(p: &ParamBinding) -> bool { matches!( p.arg_pass(), ArgPass::String { .. } | ArgPass::Bytes { .. } | ArgPass::Buffer { .. } @@ -102,14 +117,15 @@ pub(crate) fn param_needs_marshal(p: &ParamBinding) -> bool { /// dispatching on its [`ArgPass`] plan. Strings copy to `CoTaskMem` UTF-8; /// bytes pin the managed array; buffered parameters encode into a `byte[]` /// value buffer (`{name}Buf`) and pin it (`{name}Pin`), which the caller -/// owns for the duration of the call. -pub(crate) fn render_marshal_setup(out: &mut String, p: &ParamBinding, indent: &str) { +/// owns for the duration of the call; a callback interface implementation is +/// registered in the `GCHandle` table and its handle (`{name}Ctx`) becomes +/// the `ctx` the producer hands back to every trampoline. +pub(crate) fn render_marshal_setup(w: &mut CodeWriter, p: &ParamBinding) { let name = safe_cs_name(&p.name); - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); match p.arg_pass() { ArgPass::Buffer { .. } => { w.line(format!("var {name}Writer = new WeaveFFIBufferWriter();")); - emit_buffer_write(&mut w, &p.ty, &name, &format!("{name}Writer"), 0); + emit_buffer_write(w, &p.ty, &name, &format!("{name}Writer"), 0); w.line(format!("var {name}Buf = {name}Writer.ToArray();")); w.line(format!( "var {name}Pin = GCHandle.Alloc({name}Buf, GCHandleType.Pinned);" @@ -125,16 +141,21 @@ pub(crate) fn render_marshal_setup(out: &mut String, p: &ParamBinding, indent: & "var {name}Pin = GCHandle.Alloc({name}, GCHandleType.Pinned);" )); } + // The handle is owned by the producer from here on: its vtable + // `free(ctx)` trampoline releases it when the last clone drops. + ArgPass::Callback { .. } => { + w.line(format!( + "var {name}Ctx = GCHandle.ToIntPtr(GCHandle.Alloc({name}));" + )); + } ArgPass::Direct { .. } | ArgPass::Object { .. } => {} } - out.push_str(&w.finish()); } /// Emit the cleanup statements releasing what [`render_marshal_setup`] /// allocated: the `CoTaskMem` string copy or the pinned array handle. -pub(crate) fn render_marshal_cleanup(out: &mut String, p: &ParamBinding, indent: &str) { +pub(crate) fn render_marshal_cleanup(w: &mut CodeWriter, p: &ParamBinding) { let name = safe_cs_name(&p.name); - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); match p.arg_pass() { ArgPass::Buffer { .. } | ArgPass::Bytes { .. } => { w.line(format!("{name}Pin.Free();")); @@ -142,9 +163,48 @@ pub(crate) fn render_marshal_cleanup(out: &mut String, p: &ParamBinding, indent: ArgPass::String { .. } => { w.line(format!("Marshal.FreeCoTaskMem({name}Ptr);")); } - ArgPass::Direct { .. } | ArgPass::Object { .. } => {} + ArgPass::Direct { .. } | ArgPass::Object { .. } | ArgPass::Callback { .. } => {} } - out.push_str(&w.finish()); +} + +/// Emit every parameter's setup, then `body` inside a `try`/`finally` that +/// runs the cleanups when any parameter needs one, or `body` bare otherwise. +pub(crate) fn render_marshalled_call( + w: &mut CodeWriter, + params: &[ParamBinding], + body: impl FnOnce(&mut CodeWriter), +) { + for p in params { + render_marshal_setup(w, p); + } + if params.iter().any(param_needs_cleanup) { + w.line("try"); + w.line("{"); + w.scope(body); + w.line("}"); + w.line("finally"); + w.line("{"); + w.scope(|w| { + for p in params { + render_marshal_cleanup(w, p); + } + }); + w.line("}"); + } else { + body(w); + } +} + +/// The C# class hosting the static vtable a callback slot points at, named +/// from the slot's `{prefix}_{module}_{Name}_vtable` C type. +fn vtable_class_for_slot(vtable: &CType) -> String { + let CType::Ptr { pointee, .. } = vtable else { + unreachable!("a callback vtable slot is a pointer") + }; + let CType::VtableTag { module, name } = pointee.as_ref() else { + unreachable!("a callback vtable slot points at a vtable tag") + }; + vtable_class_cs(module, name) } /// The joined native-call argument expressions for `params`, one expression @@ -166,9 +226,9 @@ pub(crate) fn build_call_args(params: &[ParamBinding]) -> String { format!("{name}Pin.AddrOfPinnedObject()"), format!("(UIntPtr){name}.Length"), ], - // Interface parameters borrow: pass the handle, ownership - // stays with the caller's wrapper. A nullable object passes - // null as IntPtr.Zero. + // Interface parameters borrow: pass the handle, the wrapper + // keeps its own reference. A nullable object passes null as + // IntPtr.Zero. ArgPass::Object { nullable: true, .. } => { vec![format!("{name}?.Handle ?? IntPtr.Zero")] } @@ -177,21 +237,31 @@ pub(crate) fn build_call_args(params: &[ParamBinding]) -> String { } => { vec![format!("{name}.Handle")] } + // The GCHandle key plus the one process-wide vtable. + ArgPass::Callback { vtable, .. } => vec![ + format!("{name}Ctx"), + format!("{}.Pointer", vtable_class_for_slot(&vtable.ty)), + ], // Direct slots pass by value; only the C# spelling of the // value expression depends on the surface type. - ArgPass::Direct { .. } => match &p.ty { - Ty::Bool => vec![format!("(byte)({name} ? 1 : 0)")], - Ty::Enum(_) => vec![format!("(int){name}")], - // A typed handle passes its raw pointer token by value. - Ty::TypedHandle(_) => vec![format!("{name}.Raw")], - _ => vec![name], - }, + ArgPass::Direct { .. } => vec![direct_to_slot(&p.ty, &name)], } }) .collect::>() .join(", ") } +/// The C# expression converting a surface value `expr` of direct-family type +/// `ty` into its by-value ABI slot: `bool` as a byte, a typed enum as its +/// `int`, every other scalar as is. +pub(crate) fn direct_to_slot(ty: &Ty, expr: &str) -> String { + match ty { + Ty::Bool => format!("(byte)({expr} ? 1 : 0)"), + Ty::Enum(_) => format!("(int){expr}"), + _ => expr.to_string(), + } +} + /// The joined native-call argument list: the implicit self handle (when /// `self_expr` is given) followed by every lowered parameter slot. pub(crate) fn full_call_args(f: &FnBinding, self_expr: Option<&str>) -> String { @@ -203,121 +273,128 @@ pub(crate) fn full_call_args(f: &FnBinding, self_expr: Option<&str>) -> String { } } +/// `args` followed by a separating comma, or empty when there are none, for +/// splicing ahead of the trailing out-params and error slot. +fn args_part(args: &str) -> String { + if args.is_empty() { + String::new() + } else { + format!("{args}, ") + } +} + +/// True when a value crosses back as an owned `ptr` + `len` pair delivered +/// through a trailing `out_len` slot: bytes and every buffered type (records, +/// rich enums, lists, maps, and non-interface optionals). +pub(crate) fn is_ptr_len_result(ty: &Ty) -> bool { + matches!(ty.family(), Family::Bytes | Family::Buffer) +} + /// Emit the native call, the error check, and the return conversion for one -/// synchronous callable at `indent`. +/// synchronous callable. pub(crate) fn render_pinvoke_call_and_return( - out: &mut String, + w: &mut CodeWriter, f: &FnBinding, self_expr: Option<&str>, err: ErrCtx, - indent: &str, ) { let c_sym = &f.c_base; - let call_args = full_call_args(f, self_expr); - - // Bytes and buffered returns deliver their length through the trailing - // `size_t* out_len` slot. - let has_out_len = f - .ret - .as_ref() - .is_some_and(|r| matches!(r, Ty::Bytes | Ty::BorrowedBytes) || r.is_buffered()); + let args = args_part(&full_call_args(f, self_expr)); - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); - if f.ret.is_some() { - let args_part = if call_args.is_empty() { - String::new() + if let Some(ret) = &f.ret { + let out_len_part = if is_ptr_len_result(ret) { + "out var outLen, " } else { - format!("{call_args}, ") + "" }; - let out_len_part = if has_out_len { "out var outLen, " } else { "" }; w.line(format!( - "var result = NativeMethods.{c_sym}({args_part}{out_len_part}ref err);" + "var result = NativeMethods.{c_sym}({args}{out_len_part}ref err);" )); } else { - let args_part = if call_args.is_empty() { - String::new() - } else { - format!("{call_args}, ") - }; - w.line(format!("NativeMethods.{c_sym}({args_part}ref err);")); + w.line(format!("NativeMethods.{c_sym}({args}ref err);")); } - w.line(err.check_stmt()); - out.push_str(&w.finish()); if let Some(ret_ty) = &f.ret { - render_return_conversion(out, ret_ty, indent); + render_return_conversion(w, ret_ty); } } /// Emit the statements converting the raw `result` slot (plus `outLen` for /// bytes and buffered returns) into the returned C# value, releasing -/// producer-owned memory along the way. -pub(crate) fn render_return_conversion(out: &mut String, ty: &Ty, indent: &str) { - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); - // A buffered return is a producer-allocated value buffer: copy the bytes, - // release them with `weaveffi_free_bytes`, then decode the copy. - if ty.is_buffered() { - w.line("var resultBuf = new byte[(int)outLen];"); - w.line( - "if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen);", - ); - w.line("NativeMethods.weaveffi_free_bytes(result, outLen);"); - // Named so it cannot shadow a user parameter (`value` is a common one). - emit_buffer_decode(&mut w, ty, "decoded", "resultBuf"); - w.line("return decoded;"); - out.push_str(&w.finish()); - return; - } - match ty { - Ty::Bool => { - w.line("return result != 0;"); +/// producer-owned memory along the way. Dispatches on the return type's +/// [`Family`], which is exactly the classification behind the shared +/// `RetPass` receiving plan: copy and free a string or bytes, decode and free +/// a buffer, adopt an object. +pub(crate) fn render_return_conversion(w: &mut CodeWriter, ty: &Ty) { + match ty.family() { + // A buffered return is a producer-allocated value buffer: copy the + // bytes, release them with `weaveffi_free_bytes`, then decode the + // copy (adopting any object tokens it carries). + Family::Buffer => { + w.line("var resultBuf = new byte[(int)outLen];"); + w.line( + "if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen);", + ); + w.line("NativeMethods.weaveffi_free_bytes(result, outLen);"); + // Named so it cannot shadow a user parameter (`value` is a common one). + emit_buffer_decode(w, ty, "decoded", "resultBuf"); + w.line("return decoded;"); } - Ty::StringUtf8 | Ty::BorrowedStr => { + Family::String => { w.line("var str = Marshal.PtrToStringUTF8(result);"); w.line("NativeMethods.weaveffi_free_string(result);"); w.line("return str ?? \"\";"); } - Ty::Enum(name) => { - let cn = local_type_name(name); - w.line(format!("return ({cn})result;")); - } - Ty::TypedHandle(name) => { - let cn = typed_handle_cs(name); - w.line(format!("return new {cn}(result);")); - } - // An interface return transfers ownership (`ReturnFree::OwnedObject`): - // wrap the pointer in a new instance whose Dispose() releases it. - Ty::Interface(name) => { - let cn = local_type_name(name); - w.line(format!("return new {cn}(result);")); - } - Ty::Bytes | Ty::BorrowedBytes => { + Family::Bytes => { w.line("if (result == IntPtr.Zero) return Array.Empty();"); w.line("var arr = new byte[(int)outLen];"); w.line("Marshal.Copy(result, arr, 0, (int)outLen);"); w.line("NativeMethods.weaveffi_free_bytes(result, outLen);"); w.line("return arr;"); } - // Only `Interface?` reaches here: a nullable owned object pointer. - Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(name) => { - let cn = local_type_name(name); - w.line(format!( - "return result == IntPtr.Zero ? null : new {cn}(result);" - )); + // One strong reference transfers: adopt it into a wrapper whose + // Dispose (or finalizer) releases it exactly once. + Family::Object { nullable } => { + let adopt = adopt_object(object_class(ty), "result"); + if nullable { + w.line(format!("return result == IntPtr.Zero ? null : {adopt};")); + } else { + w.line(format!("return {adopt};")); } - _ => unreachable!("non-interface optionals are buffered"), - }, - Ty::Iterator(_) => unreachable!("iterator functions render via CallShape::Iterator"), - Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { - unreachable!("buffered return handled above") } - _ => { - w.line("return result;"); + Family::Direct => { + w.line(format!("return {};", direct_from_slot(ty, "result"))); } + Family::Callback => unreachable!("callback interfaces are never returned"), + Family::Iterator => unreachable!("iterator functions render via CallShape::Iterator"), + } +} + +/// The C# expression adopting the strong reference in `ptr_expr` into a new +/// wrapper of class `cn`, through the `WeaveFFIHandle` marker the adopting +/// constructor takes. +pub(crate) fn adopt_object(cn: &str, ptr_expr: &str) -> String { + format!("new {cn}(new WeaveFFIHandle({ptr_expr}))") +} + +/// The wrapper class name for an object type (`Store` or `Store?`). +pub(crate) fn object_class(ty: &Ty) -> &str { + local_type_name( + ty.interface_name() + .expect("object family types name an interface"), + ) +} + +/// The C# expression converting a by-value slot named `slot` (holding a +/// direct-family value) into its surface type: `bool` from its byte, a typed +/// enum from its `int`, every other scalar as is. +pub(crate) fn direct_from_slot(ty: &Ty, slot: &str) -> String { + match ty { + Ty::Bool => format!("{slot} != 0"), + Ty::Enum(name) => format!("({}){slot}", local_type_name(name)), + _ => slot.to_string(), } - out.push_str(&w.finish()); } /// Render one wrapper method (any shape) named `method_name`. `self_expr` is @@ -343,124 +420,73 @@ pub(crate) fn render_wrapper_method( let ret_cs = f.ret.as_ref().map(cs_type).unwrap_or_else(|| "void".into()); let staticness = if self_expr.is_none() { "static " } else { "" }; - let params_sig: Vec = f - .params - .iter() - .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) - .collect(); - let mut w = CodeWriter::four_space().with_depth(2); writer_fn_doc(&mut w, &f.doc, &f.params); err.write_exception_doc(&mut w); - if let Some(msg) = &f.deprecated { - w.line(format!("[Obsolete(\"{}\")]", msg.replace('"', "\\\""))); - } + write_obsolete(&mut w, &f.deprecated); w.line(format!( "public {staticness}{ret_cs} {method_name}({})", - params_sig.join(", ") + params_sig(&f.params) )); w.line("{"); w.scope(|w| { w.line("var err = new WeaveFFIError();"); - - let needs_try = f.params.iter().any(param_needs_marshal); - - if needs_try { - for p in &f.params { - let mut tmp = String::new(); - render_marshal_setup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("try"); - w.line("{"); - let mut tmp = String::new(); - render_pinvoke_call_and_return(&mut tmp, &f, self_expr, err, " "); - w.raw(tmp); - w.line("}"); - w.line("finally"); - w.line("{"); - for p in &f.params { - let mut tmp = String::new(); - render_marshal_cleanup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("}"); - } else { - let mut tmp = String::new(); - render_pinvoke_call_and_return(&mut tmp, &f, self_expr, err, " "); - w.raw(tmp); - } + render_marshalled_call(w, &f.params, |w| { + render_pinvoke_call_and_return(w, &f, self_expr, err); + }); }); - w.line("}"); w.blank(); out.push_str(&w.finish()); } /// The statements converting one `_next` out-item into the yielded C# value, -/// freeing any producer-allocated memory per the shared [`plan::elem_free`] -/// classification (`ElemFree::String` via `weaveffi_free_string`, -/// `ElemFree::Bytes` via `weaveffi_free_bytes` for both bytes and buffered -/// elements). Returns the expression to `yield return`. -pub(crate) fn iterator_item_conversion(out: &mut String, elem: &Ty, indent: &str) -> String { - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); - if elem.is_buffered() { - w.line("var itemBuf = new byte[(int)out_len];"); - w.line("if (out_item != IntPtr.Zero && (int)out_len > 0) Marshal.Copy(out_item, itemBuf, 0, (int)out_len);"); - w.line("NativeMethods.weaveffi_free_bytes(out_item, out_len);"); - emit_buffer_decode(&mut w, elem, "item", "itemBuf"); - out.push_str(&w.finish()); - return "item".into(); - } - let expr = match plan::elem_free(elem) { - ElemFree::String => { +/// receiving it per the element's [`Family`] exactly as a return of the same +/// type (the `IteratorProtocol.elem` plan): copy and free a string +/// (`weaveffi_free_string`) or bytes (`weaveffi_free_bytes`), decode and free +/// a buffer, adopt an object into a wrapper the consumer owns. Returns the +/// expression to `yield return`. +pub(crate) fn iterator_item_conversion(w: &mut CodeWriter, elem: &Ty) -> String { + match elem.family() { + Family::Buffer => { + w.line("var itemBuf = new byte[(int)out_len];"); + w.line("if (out_item != IntPtr.Zero && (int)out_len > 0) Marshal.Copy(out_item, itemBuf, 0, (int)out_len);"); + w.line("NativeMethods.weaveffi_free_bytes(out_item, out_len);"); + emit_buffer_decode(w, elem, "item", "itemBuf"); + "item".into() + } + Family::String => { w.line("var item = Marshal.PtrToStringUTF8(out_item) ?? \"\";"); w.line("NativeMethods.weaveffi_free_string(out_item);"); "item".into() } - // The buffered flavor of `ElemFree::Bytes` returned above; only raw - // byte elements reach here. - ElemFree::Bytes => { + Family::Bytes => { w.line("var item = new byte[(int)out_len];"); w.line("if (out_item != IntPtr.Zero && (int)out_len > 0) Marshal.Copy(out_item, item, 0, (int)out_len);"); w.line("NativeMethods.weaveffi_free_bytes(out_item, out_len);"); "item".into() } - ElemFree::None => match elem { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::I64 - | Ty::U64 - | Ty::F32 - | Ty::F64 - | Ty::Handle => "out_item".into(), - Ty::Bool => "out_item != 0".into(), - Ty::Enum(name) => format!("({})out_item", local_type_name(name)), - Ty::TypedHandle(name) => { - format!("new {}(out_item)", typed_handle_cs(name)) + Family::Object { nullable } => { + let adopt = adopt_object(object_class(elem), "out_item"); + if nullable { + format!("out_item == IntPtr.Zero ? null : {adopt}") + } else { + adopt } - // The consumer owns each yielded wrapper; Dispose() destroys it - // (owned-object elements are adopted rather than freed eagerly). - Ty::Interface(name) => { - format!("new {}(out_item)", local_type_name(name)) - } - other => unreachable!("unsupported iterator element type {other:?}"), - }, - }; - out.push_str(&w.finish()); - expr + } + Family::Direct => direct_from_slot(elem, "out_item"), + Family::Callback | Family::Iterator => { + unreachable!("{elem} is not a valid iterator element") + } + } } /// An `iter` function surfaces as `IEnumerable`, rendering the /// `IteratorProtocol` pull contract: an eager launcher call (so launch errors /// throw immediately, per the function's `ErrorStrategy`), then a lazy /// `yield return` enumerator issuing exactly one C `next` call per -/// `MoveNext`. Each yielded element is released per its `ElemFree` plan after +/// `MoveNext`. Each yielded element is received per its element plan after /// conversion, and the compiler-generated `finally` destroys the native /// iterator exactly once, whether enumeration runs to exhaustion or is /// abandoned early (C# `foreach` disposes the enumerator). Wrapping the @@ -478,21 +504,10 @@ pub(crate) fn render_iterator_wrapper_method( let elem_cs = cs_type(&it.elem); let staticness = if self_expr.is_none() { "static " } else { "" }; - let params_sig: Vec = f - .params - .iter() - .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) - .collect(); - - let call_args = full_call_args(&f, self_expr); - let args_part = if call_args.is_empty() { - String::new() - } else { - format!("{call_args}, ") - }; let launch_call = format!( - "var iter = NativeMethods.{}({args_part}ref err);", - it.launch.symbol + "var iter = NativeMethods.{}({}ref err);", + it.launch.symbol, + args_part(&full_call_args(&f, self_expr)) ); let mut w = CodeWriter::four_space().with_depth(2); @@ -503,48 +518,22 @@ pub(crate) fn render_iterator_wrapper_method( w.line("/// automatically, including on early exit). The returned sequence can be"); w.line("/// enumerated only once."); err.write_exception_doc(&mut w); - if let Some(msg) = &f.deprecated { - w.line(format!("[Obsolete(\"{}\")]", msg.replace('"', "\\\""))); - } + write_obsolete(&mut w, &f.deprecated); let wrap_return = format!("return new WeaveFFIOnceEnumerable<{elem_cs}>(Enumerate{method_name}(iter));"); w.line(format!( "public {staticness}IEnumerable<{elem_cs}> {method_name}({})", - params_sig.join(", ") + params_sig(&f.params) )); w.line("{"); w.scope(|w| { w.line("var err = new WeaveFFIError();"); - - let needs_try = f.params.iter().any(param_needs_marshal); - if needs_try { - for p in &f.params { - let mut tmp = String::new(); - render_marshal_setup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("try"); - w.line("{"); - w.scope(|w| { - w.line(launch_call.clone()); - w.line(err.check_stmt()); - w.line(wrap_return.clone()); - }); - w.line("}"); - w.line("finally"); - w.line("{"); - for p in &f.params { - let mut tmp = String::new(); - render_marshal_cleanup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("}"); - } else { - w.line(launch_call.clone()); + render_marshalled_call(w, &f.params, |w| { + w.line(launch_call); w.line(err.check_stmt()); - w.line(wrap_return.clone()); - } + w.line(wrap_return); + }); }); w.line("}"); w.blank(); @@ -586,10 +575,7 @@ pub(crate) fn render_iterator_wrapper_method( }); w.line("}"); w.line(err.check_stmt_for("iterErr")); - let mut conv = String::new(); - let item_expr = - iterator_item_conversion(&mut conv, &it.elem, " "); - w.raw(conv); + let item_expr = iterator_item_conversion(w, &it.elem); w.line(format!("yield return {item_expr};")); }); w.line("}"); @@ -611,7 +597,7 @@ pub(crate) fn render_iterator_wrapper_method( /// `ptr` + `len` pair: bytes and every buffered type (records, rich enums, /// lists, maps, and non-interface optionals). pub(crate) fn async_result_is_ptr_len(ty: &Ty) -> bool { - matches!(ty, Ty::Bytes | Ty::BorrowedBytes) || ty.is_buffered() + is_ptr_len_result(ty) } /// The completion lambda's formal parameter list for one async return type, @@ -648,22 +634,14 @@ pub(crate) fn render_async_wrapper_method( let tcs_type = f.ret.as_ref().map(cs_type).unwrap_or_else(|| "bool".into()); - let params_sig: Vec = f - .params - .iter() - .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) - .collect(); - let mut w = CodeWriter::four_space().with_depth(2); writer_fn_doc(&mut w, &f.doc, &f.params); err.write_exception_doc(&mut w); - if let Some(msg) = &f.deprecated { - w.line(format!("[Obsolete(\"{}\")]", msg.replace('"', "\\\""))); - } + write_obsolete(&mut w, &f.deprecated); w.line(format!( "public {staticness}async {task_ret} {method_name}({})", - params_sig.join(", ") + params_sig(&f.params) )); w.line("{"); w.scope(|w| { @@ -699,10 +677,7 @@ pub(crate) fn render_async_wrapper_method( w.line("}"); }); w.line("}"); - - let mut tmp = String::new(); - render_async_set_result(&mut tmp, &f.ret, " "); - w.raw(tmp); + render_async_set_result(w, &f.ret); }); w.line("}"); w.line("finally"); @@ -721,56 +696,15 @@ pub(crate) fn render_async_wrapper_method( w.line("var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal);"); w.line("var ctx = GCHandle.ToIntPtr(gcHandle);"); - let needs_try = f.params.iter().any(param_needs_marshal); - let call_args = full_call_args(&f, self_expr); - let args_part = if call_args.is_empty() { - String::new() - } else { - format!("{call_args}, ") - }; + let args = args_part(&full_call_args(&f, self_expr)); let cancel_arg = if f.cancellable { "IntPtr.Zero, " } else { "" }; + let launch = format!("NativeMethods.{c_sym}_async({args}{cancel_arg}callback, ctx);"); - if needs_try { - for p in &f.params { - let mut tmp = String::new(); - render_marshal_setup(&mut tmp, p, " "); - w.raw(tmp); - } + render_marshalled_call(w, &f.params, |w| { w.line("try"); w.line("{"); w.scope(|w| { - w.line("try"); - w.line("{"); - w.scope(|w| { - w.line(format!( - "NativeMethods.{c_sym}_async({args_part}{cancel_arg}callback, ctx);" - )); - }); - w.line("}"); - w.line("catch"); - w.line("{"); - w.scope(|w| { - w.line("if (gcHandle.IsAllocated) gcHandle.Free();"); - w.line("throw;"); - }); - w.line("}"); - }); - w.line("}"); - w.line("finally"); - w.line("{"); - for p in &f.params { - let mut tmp = String::new(); - render_marshal_cleanup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("}"); - } else { - w.line("try"); - w.line("{"); - w.scope(|w| { - w.line(format!( - "NativeMethods.{c_sym}_async({args_part}{cancel_arg}callback, ctx);" - )); + w.line(launch); }); w.line("}"); w.line("catch"); @@ -780,7 +714,7 @@ pub(crate) fn render_async_wrapper_method( w.line("throw;"); }); w.line("}"); - } + }); if f.ret.is_some() { w.line("return await tcs.Task;"); @@ -797,13 +731,16 @@ pub(crate) fn render_async_wrapper_method( /// completion callback's result slots, honoring the `AsyncProtocol` owned /// results clause: string, bytes, and buffered result buffers are owned by /// the consumer, so they are deep-copied (and buffered results decoded) -/// here and then released through the runtime free symbols. Owned-object -/// results (interfaces, typed handles) transfer ownership instead: the -/// wrapper adopts the pointer. -pub(crate) fn render_async_set_result(out: &mut String, ret: &Option, indent: &str) { - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); - if let Some(ty) = ret { - if ty.is_buffered() { +/// here and then released through the runtime free symbols. An object +/// result transfers one strong reference instead: the wrapper adopts the +/// pointer. +pub(crate) fn render_async_set_result(w: &mut CodeWriter, ret: &Option) { + let Some(ty) = ret else { + w.line("tcs.SetResult(true);"); + return; + }; + match ty.family() { + Family::Buffer => { w.line("var resultBuf = new byte[(int)resultLen];"); w.line( "if (result != IntPtr.Zero && (int)resultLen > 0) Marshal.Copy(result, resultBuf, 0, (int)resultLen);", @@ -811,37 +748,15 @@ pub(crate) fn render_async_set_result(out: &mut String, ret: &Option, indent w.line( "if (result != IntPtr.Zero) NativeMethods.weaveffi_free_bytes(result, resultLen);", ); - emit_buffer_decode(&mut w, ty, "decoded", "resultBuf"); + emit_buffer_decode(w, ty, "decoded", "resultBuf"); w.line("tcs.SetResult(decoded);"); - out.push_str(&w.finish()); - return; - } - } - match ret { - None => { - w.line("tcs.SetResult(true);"); } - Some(Ty::Bool) => { - w.line("tcs.SetResult(result != 0);"); - } - Some(Ty::StringUtf8 | Ty::BorrowedStr) => { + Family::String => { w.line("var str = Marshal.PtrToStringUTF8(result) ?? \"\";"); w.line("if (result != IntPtr.Zero) NativeMethods.weaveffi_free_string(result);"); w.line("tcs.SetResult(str);"); } - Some(Ty::Enum(name)) => { - let cn = local_type_name(name); - w.line(format!("tcs.SetResult(({cn})result);")); - } - Some(Ty::TypedHandle(name)) => { - let cn = typed_handle_cs(name); - w.line(format!("tcs.SetResult(new {cn}(result));")); - } - Some(Ty::Interface(name)) => { - let cn = local_type_name(name); - w.line(format!("tcs.SetResult(new {cn}(result));")); - } - Some(Ty::Bytes | Ty::BorrowedBytes) => { + Family::Bytes => { w.line("var arr = new byte[(int)resultLen];"); w.line( "if (result != IntPtr.Zero && (int)resultLen > 0) Marshal.Copy(result, arr, 0, (int)resultLen);", @@ -851,202 +766,25 @@ pub(crate) fn render_async_set_result(out: &mut String, ret: &Option, indent ); w.line("tcs.SetResult(arr);"); } - // Only `Interface?` reaches here: a nullable owned object pointer. - Some(Ty::Optional(inner)) => match inner.as_ref() { - Ty::Interface(name) => { - let cn = local_type_name(name); + Family::Object { nullable } => { + let adopt = adopt_object(object_class(ty), "result"); + if nullable { w.line(format!( - "tcs.SetResult(result == IntPtr.Zero ? null : new {cn}(result));" + "tcs.SetResult(result == IntPtr.Zero ? null : {adopt});" )); + } else { + w.line(format!("tcs.SetResult({adopt});")); } - _ => unreachable!("non-interface optionals are buffered"), - }, - // Remaining scalars pass by value in the result slot. - Some(_) => { - w.line("tcs.SetResult(result);"); - } - } - out.push_str(&w.finish()); -} - -/// Statements (appended to `out`) plus the expression converting one callback -/// parameter's delegate slots into the value handed to the user callback. -/// Buffered parameters arrive as a borrowed `ptr` + `len` pair valid only for -/// the dispatch, so the bytes are copied and decoded before the user's -/// delegate runs, and never freed here. -pub(crate) fn render_cb_arg( - out: &mut String, - p: &ParamBinding, - idx: usize, - indent: &str, -) -> String { - let slots = abi::lower_param(&p.name, &p.ty, "", false); - let n0 = safe_cs_name(&slots[0].name); - let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); - if p.ty.is_buffered() { - let len = safe_cs_name(&slots[1].name); - let arg = format!("arg{idx}"); - w.line(format!("var {arg}Buf = new byte[(int){len}];")); - w.line(format!( - "if ({n0} != IntPtr.Zero && (int){len} > 0) Marshal.Copy({n0}, {arg}Buf, 0, (int){len});" - )); - emit_buffer_decode(&mut w, &p.ty, &arg, &format!("{arg}Buf")); - out.push_str(&w.finish()); - return arg; - } - let expr = match &p.ty { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::I64 - | Ty::U64 - | Ty::F32 - | Ty::F64 => n0, - Ty::Handle => n0, - Ty::Bool => format!("{n0} != 0"), - Ty::Enum(name) => format!("({}){n0}", local_type_name(name)), - Ty::StringUtf8 | Ty::BorrowedStr => { - format!("Marshal.PtrToStringUTF8({n0}) ?? \"\"") } - Ty::Bytes | Ty::BorrowedBytes => { - let len = safe_cs_name(&slots[1].name); - let arg = format!("arg{idx}"); - w.line(format!("var {arg} = new byte[(int){len}];")); + Family::Direct => { w.line(format!( - "if ({n0} != IntPtr.Zero && (int){len} > 0) Marshal.Copy({n0}, {arg}, 0, (int){len});" + "tcs.SetResult({});", + direct_from_slot(ty, "result") )); - arg } - Ty::TypedHandle(name) => { - format!("new {}({n0})", typed_handle_cs(name)) - } - // Borrowed for the duration of the callback; the consumer must not - // Dispose() the wrapper. - Ty::Interface(name) => { - format!("new {}({n0})", local_type_name(name)) - } - // Only `Interface?` reaches here: every other optional is buffered. - Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(name) => { - let cn = local_type_name(name); - format!("{n0} == IntPtr.Zero ? null : new {cn}({n0})") - } - _ => unreachable!("non-interface optionals are buffered"), - }, - Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { - unreachable!("buffered callback parameter handled above") - } - Ty::Iterator(_) => unreachable!("iterator not valid as callback parameter"), - }; - out.push_str(&w.finish()); - expr -} - -/// The register/unregister method pair for one listener, emitted into the -/// module's wrapper class alongside `_listenerRefs`. -pub(crate) fn render_listener_methods( - out: &mut String, - mb: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, -) { - let Some(cb) = mb.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - let register_name = wrapper_name( - &mb.path, - &format!("register_{}", l.name), - strip_module_prefix, - ) - .to_upper_camel_case(); - let unregister_name = wrapper_name( - &mb.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ) - .to_upper_camel_case(); - let delegate_name = format!("NativeMethods.Cb_{}", cb.c_fn_type); - - let action_type = if cb.params.is_empty() { - "Action".to_string() - } else { - format!( - "Action<{}>", - cb.params - .iter() - .map(|p| cs_type(&p.ty)) - .collect::>() - .join(", ") - ) - }; - - let lambda_formals: Vec = cb - .abi_params - .iter() - .map(|slot| safe_cs_name(&slot.name)) - .collect(); - - let mut w = CodeWriter::four_space().with_depth(2); - writer_doc(&mut w, &l.doc); - w.line(format!( - "/// A subscription id for {unregister_name}()." - )); - w.line(format!( - "public static ulong {register_name}({action_type} callback)" - )); - w.line("{"); - w.scope(|w| { - w.line(format!( - "{delegate_name} trampoline = ({}) =>", - lambda_formals.join(", ") - )); - w.line("{"); - w.scope(|w| { - let mut stmts = String::new(); - let mut args = Vec::new(); - for (idx, p) in cb.params.iter().enumerate() { - args.push(render_cb_arg(&mut stmts, p, idx, " ")); - } - w.raw(stmts); - w.line(format!("callback({});", args.join(", "))); - }); - w.line("};"); - w.line("ulong id;"); - w.line("lock (_listenerLock)"); - w.line("{"); - w.scope(|w| { - w.line(format!( - "id = NativeMethods.{}(trampoline, IntPtr.Zero);", - l.register_symbol - )); - w.line("_listenerRefs[id] = trampoline;"); - }); - w.line("}"); - w.line("return id;"); - }); - w.line("}"); - w.blank(); - - w.line(format!( - "/// Unregisters a listener previously registered with {register_name}()." - )); - w.line(format!("public static void {unregister_name}(ulong id)")); - w.line("{"); - w.scope(|w| { - w.line(format!("NativeMethods.{}(id);", l.unregister_symbol)); - w.line("lock (_listenerLock)"); - w.line("{"); - w.scope(|w| { - w.line("_listenerRefs.Remove(id);"); - }); - w.line("}"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + Family::Callback => unreachable!("callback interfaces are never returned"), + Family::Iterator => unreachable!("iterator functions render via CallShape::Iterator"), + } } /// Renders one module's static wrapper class. Submodules become sibling @@ -1065,22 +803,6 @@ pub(crate) fn render_wrapper_class( .collect(); out.push_str(&format!(" public static class {class_name}\n {{\n")); - if !mb.listeners.is_empty() { - out.push_str(" private static readonly object _listenerLock = new object();\n"); - out.push_str( - " // Live listener delegates by subscription id. Holding the delegate\n", - ); - out.push_str( - " // here keeps its native thunk alive until unregistered; without this\n", - ); - out.push_str(" // the GC could collect a delegate the producer still calls.\n"); - out.push_str( - " private static readonly Dictionary _listenerRefs = new Dictionary();\n\n", - ); - for l in &mb.listeners { - render_listener_methods(out, mb, l, strip_module_prefix); - } - } for f in &mb.functions { let method_name = wrapper_name(&mb.path, &f.name, strip_module_prefix).to_upper_camel_case(); diff --git a/crates/weaveffi-gen-dotnet/src/codec.rs b/crates/weaveffi-gen-dotnet/src/codec.rs index fa1c2de2..a46e1e6b 100644 --- a/crates/weaveffi-gen-dotnet/src/codec.rs +++ b/crates/weaveffi-gen-dotnet/src/codec.rs @@ -2,19 +2,20 @@ //! `WeaveFFIBufferWriter` and decoding one back from a //! `WeaveFFIBufferReader`. //! -//! Every dispatch here goes through [`wire::classify`], so this module never -//! re-derives the wire folds (handles as `u64` tokens, borrowed views as -//! their owned forms, records and rich enums as one user-codec shape). The -//! only peek back at the [`Ty`] is for `handle`, whose C# surface is -//! a `{T}Handle` wrapper struct even though the wire shape is one `u64` -//! token. +//! Every dispatch here goes through [`Ty::wire`], so this module never +//! re-derives the wire shapes (records and rich enums as one user-codec +//! shape, interfaces as `u64` object tokens). An object token carries one +//! strong reference: writing one clones the wrapper's handle through the +//! interface's `_clone` symbol, and reading one adopts the pointer into a +//! new wrapper whose `Dispose` (or finalizer) releases it. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{Prim, WireType}; use weaveffi_core::utils::local_type_name; -use crate::types::{cs_type, is_cs_value_type, typed_handle_cs}; +use crate::calls::adopt_object; +use crate::types::{cs_type, is_cs_value_type}; /// Emit statements serializing `expr` (a C# expression of the C# type mapped /// from `ty`) into the buffer writer named `writer_var`, following the wire @@ -61,14 +62,10 @@ pub(crate) fn emit_buffer_write( WireType::Prim(Prim::Bool) => { w.line(format!("{writer_var}.WriteBool({expr});")); } - // Both handle flavors encode as one u64 token; only the C# surface - // differs (a bare ulong versus the `{T}Handle` wrapper struct). - WireType::Handle(_) => { - if matches!(ty, Ty::TypedHandle(_)) { - w.line(format!("{writer_var}.WriteU64((ulong)(long){expr}.Raw);")); - } else { - w.line(format!("{writer_var}.WriteU64({expr});")); - } + // The token must carry its own strong reference, so clone the + // wrapper's handle rather than writing the pointer it still owns. + WireType::Object(_) => { + w.line(format!("{writer_var}.WriteObject({expr}.CloneHandle());")); } WireType::Enum(_) => { w.line(format!("{writer_var}.WriteI32((int){expr});")); @@ -162,17 +159,11 @@ pub(crate) fn emit_buffer_read( WireType::Prim(Prim::Bool) => { w.line(format!("var {var} = {reader_var}.ReadBool();")); } - // The u64 token decodes back into the C# surface: a bare ulong for - // `handle`, the `{T}Handle` wrapper for `handle`. - WireType::Handle(_) => { - if let Ty::TypedHandle(name) = ty { - let cn = typed_handle_cs(name); - w.line(format!( - "var {var} = new {cn}((IntPtr)(long){reader_var}.ReadU64());" - )); - } else { - w.line(format!("var {var} = {reader_var}.ReadU64();")); - } + // Adopt the token's strong reference into a fresh wrapper; its + // Dispose (or finalizer) owes the interface's `_destroy`. + WireType::Object(name) => { + let adopt = adopt_object(local_type_name(name), &format!("{reader_var}.ReadObject()")); + w.line(format!("var {var} = {adopt};")); } WireType::Enum(name) => { let cn = local_type_name(name); @@ -228,9 +219,6 @@ pub(crate) fn emit_buffer_read( } } -/// Emit the statements decoding a consumer-side copy of a value buffer -/// (`byte[]` local named `buf`) into a local named `var` of type `ty`, -/// validating that the buffer is fully consumed. /// `new T[len]` for an array whose element type is `elem`. /// /// C# puts the outermost rank first, so an array of `int[]` elements is @@ -242,6 +230,9 @@ fn cs_new_array(elem: &str, len: &str) -> String { format!("new {base}[{len}]{ranks}") } +/// Emit the statements decoding a consumer-side copy of a value buffer +/// (`byte[]` local named `buf`) into a local named `var` of type `ty`, +/// validating that the buffer is fully consumed. pub(crate) fn emit_buffer_decode(w: &mut CodeWriter, ty: &Ty, var: &str, buf: &str) { w.line(format!( "var {var}Reader = new WeaveFFIBufferReader({buf});" diff --git a/crates/weaveffi-gen-dotnet/src/entities.rs b/crates/weaveffi-gen-dotnet/src/entities.rs index bcc01151..5957ec55 100644 --- a/crates/weaveffi-gen-dotnet/src/entities.rs +++ b/crates/weaveffi-gen-dotnet/src/entities.rs @@ -1,102 +1,20 @@ -//! Entity renderers: C-style enums, rich (algebraic) enums, records, -//! typed-handle wrapper structs, and interface wrapper classes. +//! Entity renderers: C-style enums, rich (algebraic) enums, records, and +//! the reference-counted interface wrapper classes. use heck::{ToLowerCamelCase, ToUpperCamelCase}; use weaveffi_core::codegen::CodeWriter; -use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, CallShape, EnumBinding, EnumVariantBinding, ErrorBinding, FieldBinding, - FnBinding, InterfaceBinding, StructBinding, + CallShape, EnumBinding, EnumVariantBinding, ErrorBinding, FieldBinding, FnBinding, + InterfaceBinding, StructBinding, }; -use weaveffi_core::utils::local_type_name; use crate::calls::{ - build_call_args, param_needs_marshal, render_marshal_cleanup, render_marshal_setup, - render_wrapper_method, ErrCtx, + build_call_args, params_sig, render_marshalled_call, render_wrapper_method, write_obsolete, + ErrCtx, }; use crate::codec::{emit_buffer_read, emit_buffer_write}; use crate::docs::{writer_doc, writer_fn_doc}; -use crate::types::{camel_fn, cs_type, safe_cs_name, typed_handle_cs}; - -/// Collect the local referent names of every `handle` used anywhere in -/// the model (parameters, returns, fields, variant fields, callback -/// parameters, and error payload fields), so one `{T}Handle` wrapper struct -/// is emitted per referent. The `BTreeSet` keeps emission order stable. -pub(crate) fn collect_typed_handles(model: &BindingModel) -> std::collections::BTreeSet { - fn visit(ty: &Ty, acc: &mut std::collections::BTreeSet) { - match ty { - Ty::TypedHandle(name) => { - acc.insert(local_type_name(name).to_string()); - } - Ty::Optional(inner) | Ty::List(inner) | Ty::Iterator(inner) => { - visit(inner, acc); - } - Ty::Map(k, v) => { - visit(k, acc); - visit(v, acc); - } - _ => {} - } - } - let mut acc = std::collections::BTreeSet::new(); - for m in &model.modules { - for f in m.callables() { - for p in &f.params { - visit(&p.ty, &mut acc); - } - if let Some(r) = &f.ret { - visit(r, &mut acc); - } - } - for cb in &m.callbacks { - for p in &cb.params { - visit(&p.ty, &mut acc); - } - } - for s in &m.structs { - for f in &s.fields { - visit(&f.ty, &mut acc); - } - } - for e in &m.enums { - for v in &e.variants { - for f in &v.fields { - visit(&f.ty, &mut acc); - } - } - } - if let Some(eb) = &m.error { - for c in &eb.codes { - for f in &c.fields { - visit(&f.ty, &mut acc); - } - } - } - } - acc -} - -/// Render the `{T}Handle` wrapper struct for one typed-handle referent: a -/// readonly struct over the raw native pointer token. The token is opaque to -/// the consumer; the producer interprets it. -pub(crate) fn render_typed_handle_struct(out: &mut String, referent: &str) { - let name = typed_handle_cs(referent); - let mut w = CodeWriter::four_space().with_depth(1); - w.line(format!( - "/// A typed native handle referencing a {referent}." - )); - w.line(format!("public readonly struct {name}")); - w.block("{", "}", |w| { - w.line("internal readonly IntPtr Raw;"); - w.blank(); - w.line(format!("internal {name}(IntPtr raw)")); - w.block("{", "}", |w| { - w.line("Raw = raw;"); - }); - }); - w.blank(); - out.push_str(&w.finish()); -} +use crate::types::{camel_fn, cs_type, safe_cs_name}; /// Render a plain C-style enum as a C# `enum` with its ABI discriminants. pub(crate) fn render_enum(out: &mut String, e: &EnumBinding) { @@ -343,13 +261,15 @@ pub(crate) fn render_rich_variant_class( w.blank(); } -/// Render one interface as an opaque-handle class following the struct-wrapper -/// pattern: a private `IntPtr` handle with `IDisposable` plus a finalizer -/// calling the interface's destroy symbol. The `new` constructor maps to a -/// real C# constructor, other constructors become static factories, instance -/// methods pass the handle as the leading native argument, and statics are -/// plain static methods. All member shapes reuse the free-function -/// marshalling paths. +/// Render one interface as a reference-counted object wrapper: a private +/// `IntPtr` holding one strong reference, `IDisposable` plus a finalizer +/// backstop that release it through the interface's `destroy` symbol exactly +/// once, and an internal `CloneHandle` that mints a second strong reference +/// through the `clone` symbol (what the value-buffer codec writes as an +/// object token). The `new` constructor maps to a real C# constructor, other +/// constructors become static factories, instance methods pass the handle as +/// the leading native argument, and statics are plain static methods. All +/// member shapes reuse the free-function marshalling paths. pub(crate) fn render_interface_class( out: &mut String, i: &InterfaceBinding, @@ -362,14 +282,37 @@ pub(crate) fn render_interface_class( w.line("{"); w.indent(); w.line("private IntPtr _handle;"); - w.line("private bool _disposed;"); + w.line("private int _released;"); w.blank(); - w.line(format!("internal {name}(IntPtr handle)")); + w.line("/// Adopts one strong reference to a native object."); + w.line(format!("internal {name}(WeaveFFIHandle handle)")); w.block("{", "}", |w| { - w.line("_handle = handle;"); + w.line("_handle = handle.Value;"); }); w.blank(); - w.line("internal IntPtr Handle => _handle;"); + w.line("/// The borrowed native pointer for the duration of a call."); + w.line("/// The wrapper was disposed."); + w.line("internal IntPtr Handle"); + w.block("{", "}", |w| { + w.line("get"); + w.block("{", "}", |w| { + w.line("var h = _handle;"); + w.line("if (h == IntPtr.Zero)"); + w.block("{", "}", |w| { + w.line(format!( + "throw new ObjectDisposedException(nameof({name}));" + )); + }); + w.line("return h;"); + }); + }); + w.blank(); + w.line("/// Mints a second strong reference the caller owns (for"); + w.line("/// example to write this object into a value buffer)."); + w.line("internal IntPtr CloneHandle()"); + w.block("{", "}", |w| { + w.line(format!("return NativeMethods.{}(Handle);", i.clone_symbol)); + }); w.blank(); for c in &i.constructors { @@ -382,6 +325,9 @@ pub(crate) fn render_interface_class( } w.raw(tmp); } + // Methods borrow the receiver through the checked `Handle` property, so a + // call on a disposed wrapper throws ObjectDisposedException instead of + // handing the producer a null self pointer. for m in &i.methods { let err = ErrCtx::for_fn(m, error); let mut tmp = String::new(); @@ -389,7 +335,7 @@ pub(crate) fn render_interface_class( &mut tmp, m, &m.name.to_upper_camel_case(), - Some("_handle"), + Some("Handle"), err, ); w.raw(tmp); @@ -401,19 +347,34 @@ pub(crate) fn render_interface_class( w.raw(tmp); } + w.line("/// Releases this wrapper's reference. The native object is"); + w.line("/// dropped when the producer releases its last reference; other"); + w.line("/// wrappers to the same object stay valid."); w.line("public void Dispose()"); w.block("{", "}", |w| { - w.line("if (!_disposed)"); - w.block("{", "}", |w| { - w.line(format!("NativeMethods.{}(_handle);", i.destroy_symbol)); - w.line("_disposed = true;"); - }); + w.line("Release();"); w.line("GC.SuppressFinalize(this);"); }); w.blank(); w.line(format!("~{name}()")); w.block("{", "}", |w| { - w.line("Dispose();"); + w.line("Release();"); + }); + w.blank(); + // Interlocked makes the release idempotent even when Dispose races the + // finalizer or a concurrent Dispose; `destroy` runs at most once. + w.line("private void Release()"); + w.block("{", "}", |w| { + w.line("if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0)"); + w.block("{", "}", |w| { + w.line("return;"); + }); + w.line("var h = _handle;"); + w.line("_handle = IntPtr.Zero;"); + w.line("if (h != IntPtr.Zero)"); + w.block("{", "}", |w| { + w.line(format!("NativeMethods.{}(h);", i.destroy_symbol)); + }); }); w.dedent(); w.line("}"); @@ -431,19 +392,12 @@ pub(crate) fn render_interface_ctor( ) { let f = camel_fn(f); let c_sym = &f.c_base; - let params_sig: Vec = f - .params - .iter() - .map(|p| format!("{} {}", cs_type(&p.ty), safe_cs_name(&p.name))) - .collect(); let mut w = CodeWriter::four_space().with_depth(2); writer_fn_doc(&mut w, &f.doc, &f.params); err.write_exception_doc(&mut w); - if let Some(msg) = &f.deprecated { - w.line(format!("[Obsolete(\"{}\")]", msg.replace('"', "\\\""))); - } - w.line(format!("public {}({})", i.name, params_sig.join(", "))); + write_obsolete(&mut w, &f.deprecated); + w.line(format!("public {}({})", i.name, params_sig(&f.params))); w.line("{"); w.scope(|w| { w.line("var err = new WeaveFFIError();"); @@ -454,35 +408,11 @@ pub(crate) fn render_interface_ctor( format!("{call_args}, ") }; let call = format!("var result = NativeMethods.{c_sym}({args_part}ref err);"); - - let needs_try = f.params.iter().any(param_needs_marshal); - if needs_try { - for p in &f.params { - let mut tmp = String::new(); - render_marshal_setup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("try"); - w.line("{"); - w.scope(|w| { - w.line(call.clone()); - w.line(err.check_stmt()); - w.line("_handle = result;"); - }); - w.line("}"); - w.line("finally"); - w.line("{"); - for p in &f.params { - let mut tmp = String::new(); - render_marshal_cleanup(&mut tmp, p, " "); - w.raw(tmp); - } - w.line("}"); - } else { + render_marshalled_call(w, &f.params, |w| { w.line(call); w.line(err.check_stmt()); w.line("_handle = result;"); - } + }); }); w.line("}"); w.blank(); diff --git a/crates/weaveffi-gen-dotnet/src/lib.rs b/crates/weaveffi-gen-dotnet/src/lib.rs index 4b3df329..ee769149 100644 --- a/crates/weaveffi-gen-dotnet/src/lib.rs +++ b/crates/weaveffi-gen-dotnet/src/lib.rs @@ -1,20 +1,32 @@ //! .NET (P/Invoke) binding generator for WeaveFFI. //! //! Emits a C# project (`.csproj` + `.nuspec`) with P/Invoke declarations -//! and idiomatic wrappers over the C ABI. Async functions surface as -//! `Task`-returning methods. Implements [`LanguageBackend`]; the shared -//! driver bridges it into the generator pipeline. +//! and idiomatic wrappers over the C ABI, revision 2. Implements +//! [`LanguageBackend`]; the shared driver bridges it into the generator +//! pipeline. //! -//! Records, rich enums, optionals, lists, and maps are value types that -//! cross the C ABI serialized in the WeaveFFI value-buffer format (one -//! `const uint8_t*` + `size_t` pair). The generated file carries a small -//! internal writer/reader implementing the wire format, plus one -//! `WriteTo`/`ReadFrom` pair per record and rich enum. +//! * Records, rich enums, optionals, lists, and maps are value types that +//! cross the C ABI serialized in the WeaveFFI value-buffer format (one +//! `const uint8_t*` + `size_t` pair). The generated file carries a small +//! internal writer/reader implementing the wire format, plus one +//! `WriteTo`/`ReadFrom` pair per record and rich enum. +//! * Interfaces are reference-counted objects wrapped in `IDisposable` +//! classes with a finalizer backstop; a returned, async, iterated, or +//! buffered object is adopted into a fresh wrapper, and a top-level object +//! parameter is borrowed for the call. Objects inside value buffers are +//! `u64` tokens minted through the interface's `_clone` symbol. +//! * Callback interfaces surface as C# `interface`s the consumer implements; +//! passing one registers it in a `GCHandle` and hands the producer a +//! pointer to one process-wide static vtable of `[UnmanagedCallersOnly]` +//! trampolines. +//! * Async functions surface as `Task`-returning methods and `iter` +//! returns as lazily streamed `IEnumerable`. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] #![warn(clippy::doc_markdown)] +mod callbacks; mod calls; mod codec; mod docs; @@ -28,15 +40,15 @@ use camino::Utf8Path; use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::{BindingModel, CallShape, ErrorBinding}; +use weaveffi_core::model::{BindingModel, ErrorBinding}; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; +use crate::callbacks::render_callback_interface; use crate::calls::render_wrapper_class; use crate::entities::{ - collect_typed_handles, render_enum, render_interface_class, render_rich_enum_class, - render_struct_class, render_typed_handle_struct, + render_enum, render_interface_class, render_rich_enum_class, render_struct_class, }; use crate::package::{ render_csproj, render_csproj_with_assets, render_nuspec, render_packaged_readme, render_readme, @@ -50,7 +62,7 @@ use crate::runtime::{ /// Per-target configuration for [`DotnetGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct DotnetConfig { /// C# namespace (and on-disk basename used for `.cs`/`.csproj`/`.nuspec`). /// Defaults to `"WeaveFFI"`. @@ -109,7 +121,7 @@ impl LanguageBackend for DotnetGenerator { "dotnet" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -217,11 +229,15 @@ impl LanguageBackend for DotnetGenerator { ]; // Bundle each prebuilt library under the NuGet `runtimes//native/` - // layout NuGet auto-resolves at restore time. + // layout NuGet auto-resolves at restore time. Platforms NuGet has no + // RID for (Android, wasm32) have no slot in the package and are skipped. for nb in &ctx.binaries.binaries { + let Some(rid) = nb.platform.nuget_rid() else { + continue; + }; let dest = dir .join("runtimes") - .join(nb.platform.nuget_rid()) + .join(rid) .join("native") .join(ctx.binaries.bundled_filename(nb.platform)); files.push(PackagedFile::copy(dest, nb.source.clone())); @@ -232,8 +248,9 @@ impl LanguageBackend for DotnetGenerator { } /// Render the complete generated C# source file: the prelude, usings, the -/// shared runtime types, every module's entities, the `NativeMethods` extern -/// class, and the per-module static wrapper classes. +/// shared runtime types, every module's entities (enums, records, callback +/// interfaces with their vtables, interface wrappers), the `NativeMethods` +/// extern class, and the per-module static wrapper classes. pub(crate) fn render_csharp( model: &BindingModel, namespace: &str, @@ -249,12 +266,11 @@ pub(crate) fn render_csharp( out.push_str( "using System;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\n", ); - if model - .modules - .iter() - .flat_map(|m| m.callables()) - .any(|f| f.is_async) - { + if model.has_callback_interfaces() { + // `CallConvCdecl` for the `[UnmanagedCallersOnly]` trampolines. + out.push_str("using System.Runtime.CompilerServices;\n"); + } + if model.has_async() { out.push_str("using System.Threading.Tasks;\n"); } out.push('\n'); @@ -276,15 +292,7 @@ pub(crate) fn render_csharp( render_error_struct(&mut out, &domains); render_helpers_class(&mut out); render_buffer_classes(&mut out); - for referent in collect_typed_handles(model) { - render_typed_handle_struct(&mut out, &referent); - } - if model - .modules - .iter() - .flat_map(|m| m.callables()) - .any(|f| matches!(f.shape, CallShape::Iterator(_))) - { + if model.has_iterators() { render_once_enumerable_class(&mut out); } @@ -302,6 +310,9 @@ pub(crate) fn render_csharp( for s in &m.structs { render_struct_class(&mut out, s); } + for cb in &m.callback_interfaces { + render_callback_interface(&mut out, m, cb); + } for i in &m.interfaces { render_interface_class(&mut out, i, m.error.as_ref()); } @@ -317,3 +328,6 @@ pub(crate) fn render_csharp( out.push_str(&render_trailer(CommentStyle::DoubleSlash, filename)); out } + +#[cfg(test)] +mod tests; diff --git a/crates/weaveffi-gen-dotnet/src/package.rs b/crates/weaveffi-gen-dotnet/src/package.rs index 139bb23d..a4f1afbd 100644 --- a/crates/weaveffi-gen-dotnet/src/package.rs +++ b/crates/weaveffi-gen-dotnet/src/package.rs @@ -166,6 +166,7 @@ The resulting `.nupkg` will be in `bin/Debug/` (or `bin/Release/` with `-c Relea /// Render the README for a packaged .NET artifact, listing the bundled /// runtime identifiers so consumers know which platforms ship prebuilt. +/// Platforms without a NuGet RID aren't bundled and aren't listed. pub(crate) fn render_packaged_readme( package: &ResolvedPackage, ctx: &PackageContext, @@ -177,7 +178,8 @@ pub(crate) fn render_packaged_readme( let rids: Vec = ctx .binaries .platforms() - .map(|p| format!("- `{}`", p.nuget_rid())) + .filter_map(|p| p.nuget_rid()) + .map(|rid| format!("- `{rid}`")) .collect(); let rid_list = rids.join("\n"); format!( diff --git a/crates/weaveffi-gen-dotnet/src/pinvoke.rs b/crates/weaveffi-gen-dotnet/src/pinvoke.rs index f2d9749b..711daccb 100644 --- a/crates/weaveffi-gen-dotnet/src/pinvoke.rs +++ b/crates/weaveffi-gen-dotnet/src/pinvoke.rs @@ -2,41 +2,31 @@ //! delegate type) per lowered C symbol, matching each callable's shape //! exactly. -use weaveffi_core::abi::{self, AbiParam, CType}; +use weaveffi_core::abi::{AbiParam, CType}; use weaveffi_core::cabi::ABI_VERSION; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, CallShape, CallbackBinding, FnBinding, InterfaceBinding, IteratorBinding, - ListenerBinding, ParamBinding, + BindingModel, CallShape, FnBinding, InterfaceBinding, IteratorBinding, ParamBinding, }; use crate::calls::async_result_is_ptr_len; use crate::types::{cs_out_param, cs_pinvoke_ctype, pinvoke_type, safe_cs_name}; -/// The P/Invoke parameter list (one entry per ABI slot) for one IR-typed -/// parameter, keeping the IDL slot names with keyword escaping applied. -pub(crate) fn pinvoke_param_list(p: &ParamBinding) -> Vec { - abi::lower_param(&p.name, &p.ty, "", false) - .iter() - .map(|slot| { - format!( - "{} {}", - cs_pinvoke_ctype(&slot.ty), - safe_cs_name(&slot.name) - ) - }) - .collect() +/// The P/Invoke spelling of one ABI slot: its C type mapped onto the +/// P/Invoke vocabulary plus the IDL slot name with keyword escaping applied. +pub(crate) fn pinvoke_slot(slot: &AbiParam) -> String { + format!( + "{} {}", + cs_pinvoke_ctype(&slot.ty), + safe_cs_name(&slot.name) + ) } -/// The extern return type plus any trailing out-params (for example the -/// `size_t* out_len` slot of a buffered return) for one IR return type. -pub(crate) fn pinvoke_return_info(ty: &Ty) -> (String, Vec) { - let r = abi::lower_return(ty, ""); - ( - cs_pinvoke_ctype(&r.ret), - r.out_params.iter().map(cs_out_param).collect(), - ) +/// The P/Invoke parameter list (one entry per precomputed ABI slot) for one +/// IR-typed parameter. +pub(crate) fn pinvoke_param_list(p: &ParamBinding) -> Vec { + p.abi.iter().map(pinvoke_slot).collect() } /// Whether an ABI slot is the trailing `{prefix}_error* out_err`. @@ -46,8 +36,10 @@ pub(crate) fn is_error_slot(slot: &AbiParam) -> bool { /// Render the `NativeMethods` static class: the ABI-revision check in its /// static constructor, the shared runtime imports (`abi_version`, -/// `free_string`, `free_bytes`, `error_clear`, `error_free`), and then every -/// interface, callback, listener, and function extern in declaration order. +/// `error_set`, `free_string`, `free_bytes`, `error_clear`, `error_free`), +/// and then every interface and function extern in declaration order. +/// Callback interfaces declare no exported symbols; their vtables live next +/// to the consumer-facing interface type. pub(crate) fn render_native_methods(out: &mut String, model: &BindingModel) { let mut w = CodeWriter::four_space().with_depth(1); w.line("internal static class NativeMethods"); @@ -83,6 +75,12 @@ pub(crate) fn render_native_methods(out: &mut String, model: &BindingModel) { w.line("[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]"); w.line("internal static extern uint weaveffi_abi_version();"); w.blank(); + w.line("// Fills a producer-owned error slot with a copy of `message`; callback"); + w.line("// trampolines report a thrown exception through it so no managed"); + w.line("// allocation is ever handed to the producer's allocator."); + w.line("[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]"); + w.line("internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message);"); + w.blank(); w.line("[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]"); w.line("internal static extern void weaveffi_free_string(IntPtr ptr);"); w.blank(); @@ -97,24 +95,14 @@ pub(crate) fn render_native_methods(out: &mut String, model: &BindingModel) { w.blank(); w.dedent(); - // Records and rich enums are value types with no C symbols, so only - // interfaces, callbacks, listeners, and functions declare P/Invokes. + // Records, rich enums, and callback interfaces declare no C symbols, so + // only interfaces and functions declare P/Invokes. for m in &model.modules { for i in &m.interfaces { let mut tmp = String::new(); render_interface_pinvoke(&mut tmp, i); w.raw(tmp); } - for cb in &m.callbacks { - let mut tmp = String::new(); - render_callback_pinvoke(&mut tmp, cb); - w.raw(tmp); - } - for l in &m.listeners { - let mut tmp = String::new(); - render_listener_pinvoke(&mut tmp, l); - w.raw(tmp); - } for f in &m.functions { let mut tmp = String::new(); render_shaped_pinvoke(&mut tmp, f); @@ -127,56 +115,6 @@ pub(crate) fn render_native_methods(out: &mut String, model: &BindingModel) { out.push_str(&w.finish()); } -/// The unmanaged delegate type for one module callback declaration, shared by -/// every listener that fires it. -pub(crate) fn render_callback_pinvoke(out: &mut String, cb: &CallbackBinding) { - let delegate_name = format!("Cb_{}", cb.c_fn_type); - let params: Vec = cb - .abi_params - .iter() - .map(|slot| { - format!( - "{} {}", - cs_pinvoke_ctype(&slot.ty), - safe_cs_name(&slot.name) - ) - }) - .collect(); - let mut w = CodeWriter::four_space().with_depth(2); - w.line("[UnmanagedFunctionPointer(CallingConvention.Cdecl)]"); - w.line(format!( - "internal delegate void {delegate_name}({});", - params.join(", ") - )); - w.blank(); - out.push_str(&w.finish()); -} - -/// The register and unregister externs for one listener. -pub(crate) fn render_listener_pinvoke(out: &mut String, l: &ListenerBinding) { - let delegate_name = format!("Cb_{}", l.callback_c_fn_type); - let register_sym = &l.register_symbol; - let unregister_sym = &l.unregister_symbol; - - let mut w = CodeWriter::four_space().with_depth(2); - w.line(format!( - "[DllImport(LibName, EntryPoint = \"{register_sym}\", CallingConvention = CallingConvention.Cdecl)]" - )); - w.line(format!( - "internal static extern ulong {register_sym}({delegate_name} callback, IntPtr context);" - )); - w.blank(); - - w.line(format!( - "[DllImport(LibName, EntryPoint = \"{unregister_sym}\", CallingConvention = CallingConvention.Cdecl)]" - )); - w.line(format!( - "internal static extern void {unregister_sym}(ulong id);" - )); - w.blank(); - out.push_str(&w.finish()); -} - /// Emit the extern declaration set matching one callable's shape exactly: /// sync, async (delegate + launcher), or iterator (constructor, `next`, /// `destroy`). Shared by free functions and interface members. @@ -188,12 +126,20 @@ pub(crate) fn render_shaped_pinvoke(out: &mut String, f: &FnBinding) { } } -/// The `[DllImport]` set backing one interface: the destroy symbol plus one -/// shape-matched extern set per member. Instance members carry the implicit -/// leading `self` slot. +/// The `[DllImport]` set backing one interface: the `clone` and `destroy` +/// reference-count symbols plus one shape-matched extern set per member. +/// Instance members carry the implicit leading `self` slot. pub(crate) fn render_interface_pinvoke(out: &mut String, i: &InterfaceBinding) { + let clone_sym = &i.clone_symbol; let destroy_sym = &i.destroy_symbol; let mut w = CodeWriter::four_space().with_depth(2); + w.line(format!( + "[DllImport(LibName, EntryPoint = \"{clone_sym}\", CallingConvention = CallingConvention.Cdecl)]" + )); + w.line(format!( + "internal static extern IntPtr {clone_sym}(IntPtr self);" + )); + w.blank(); w.line(format!( "[DllImport(LibName, EntryPoint = \"{destroy_sym}\", CallingConvention = CallingConvention.Cdecl)]" )); @@ -213,30 +159,24 @@ pub(crate) fn render_interface_pinvoke(out: &mut String, i: &InterfaceBinding) { } } -/// The single extern behind one synchronous callable: lowered parameter -/// slots, any return out-params, and the trailing error slot. +/// The single extern behind one synchronous callable, rendered from its +/// precomputed [`CallShape::Sync`] slots: the optional `self`, every input +/// slot, any return out-params, and the trailing error slot. pub(crate) fn render_function_pinvoke(out: &mut String, f: &FnBinding) { - if let CallShape::Iterator(it) = &f.shape { - render_iterator_pinvoke(out, it); - return; - } - let c_sym = &f.c_base; - - let mut params: Vec = Vec::new(); - if f.has_self { - params.push("IntPtr self".into()); - } - params.extend(f.params.iter().flat_map(pinvoke_param_list)); - - let ret_type = if let Some(ret) = &f.ret { - let (ret_cs, extra) = pinvoke_return_info(ret); - params.extend(extra); - ret_cs - } else { - "void".into() + let abi = match &f.shape { + CallShape::Sync(abi) => abi, + CallShape::Iterator(it) => { + render_iterator_pinvoke(out, it); + return; + } + CallShape::Async(_) => { + render_async_function_pinvoke(out, f); + return; + } }; - - params.push("ref WeaveFFIError err".into()); + let c_sym = &abi.symbol; + let params: Vec = abi.params.iter().map(slot_param).collect(); + let ret_type = cs_pinvoke_ctype(&abi.ret); let mut w = CodeWriter::four_space().with_depth(2); w.line(format!( @@ -250,23 +190,23 @@ pub(crate) fn render_function_pinvoke(out: &mut String, f: &FnBinding) { out.push_str(&w.finish()); } -/// One P/Invoke parameter for an iterator-shape ABI slot: the trailing error -/// slot becomes `ref WeaveFFIError`, `out_*` pointer slots become `out` -/// pointee values, everything else is passed by value. -pub(crate) fn iterator_slot_param(slot: &AbiParam) -> String { +/// One P/Invoke parameter for a sync or iterator ABI slot: the trailing +/// error slot becomes `ref WeaveFFIError`, `out_*` pointer slots become +/// `out` pointee values, everything else is passed by value. +pub(crate) fn slot_param(slot: &AbiParam) -> String { if is_error_slot(slot) { return format!("ref WeaveFFIError {}", slot.name); } match &slot.ty { CType::Ptr { .. } if slot.name.starts_with("out_") => cs_out_param(slot), - ty => format!("{} {}", cs_pinvoke_ctype(ty), safe_cs_name(&slot.name)), + _ => pinvoke_slot(slot), } } /// The three entry points behind one `iter` function: the constructor /// returning the opaque iterator handle, `_next`, and `_destroy`. pub(crate) fn render_iterator_pinvoke(out: &mut String, it: &IteratorBinding) { - let launch_params: Vec = it.launch.params.iter().map(iterator_slot_param).collect(); + let launch_params: Vec = it.launch.params.iter().map(slot_param).collect(); let mut w = CodeWriter::four_space().with_depth(2); w.line(format!( "[DllImport(LibName, EntryPoint = \"{0}\", CallingConvention = CallingConvention.Cdecl)]", @@ -279,7 +219,7 @@ pub(crate) fn render_iterator_pinvoke(out: &mut String, it: &IteratorBinding) { )); w.blank(); - let next_params: Vec = it.next.params.iter().map(iterator_slot_param).collect(); + let next_params: Vec = it.next.params.iter().map(slot_param).collect(); w.line(format!( "[DllImport(LibName, EntryPoint = \"{0}\", CallingConvention = CallingConvention.Cdecl)]", it.next.symbol @@ -304,8 +244,9 @@ pub(crate) fn render_iterator_pinvoke(out: &mut String, it: &IteratorBinding) { } /// The completion delegate's result parameters for one async return type: -/// nothing for void, a borrowed `(ptr, len)` pair for bytes and buffered -/// values, and a single by-value slot otherwise. +/// nothing for void, an owned `(ptr, len)` pair for bytes and buffered +/// values, and a single by-value slot otherwise (an object result is the +/// adopted `IntPtr`). pub(crate) fn async_cb_delegate_result_params(ret: &Option) -> String { match ret { None => String::new(), diff --git a/crates/weaveffi-gen-dotnet/src/runtime.rs b/crates/weaveffi-gen-dotnet/src/runtime.rs index 13e183c8..447dca58 100644 --- a/crates/weaveffi-gen-dotnet/src/runtime.rs +++ b/crates/weaveffi-gen-dotnet/src/runtime.rs @@ -29,11 +29,27 @@ pub(crate) fn check_method_name(eb: &ErrorBinding) -> String { } /// Render the generic branded exception every failure surfaces through when -/// no typed domain applies. +/// no typed domain applies. The runtime trap codes are exposed as constants +/// so a caller can tell a producer panic (`-2`) from a callback-interface +/// implementation that threw (`-4`); both arrive through this class rather +/// than a typed domain exception. pub(crate) fn render_exception_class(out: &mut String) { let mut w = CodeWriter::four_space().with_depth(1); + w.line("/// Raised for any WeaveFFI failure that isn't a typed domain"); + w.line("/// error: producer bugs, panics, marshalling failures, and exceptions"); + w.line("/// thrown by a callback-interface implementation."); w.line("public class WeaveFFIException : Exception"); w.block("{", "}", |w| { + w.line("/// An untyped producer error."); + w.line("public const int GenericErrorCode = -1;"); + w.line("/// The producer panicked; the message carries the panic text."); + w.line("public const int PanicErrorCode = -2;"); + w.line("/// An argument couldn't be lifted by the producer."); + w.line("public const int MarshalErrorCode = -3;"); + w.line("/// A callback-interface implementation threw; the message"); + w.line("/// carries the original exception's message."); + w.line("public const int ForeignErrorCode = -4;"); + w.blank(); w.line("public int Code { get; }"); w.blank(); w.line("public WeaveFFIException(int code, string message) : base(message)"); @@ -50,7 +66,8 @@ pub(crate) fn render_exception_class(out: &mut String) { /// and `FromCode` maps a raw error slot to the typed exception, falling back /// to the generic `WeaveFFIException` for unknown codes. Declared codes are /// validated positive, so every negative runtime code (generic failures, -/// producer panics, marshalling failures) takes the fallback. When the +/// producer panics, marshalling failures, foreign callback failures) takes +/// the fallback. When the /// matched code declares payload fields, `FromCode` decodes them from the /// serialized payload buffer and exposes each field in the exception's /// `Data` dictionary, keyed by the IDL field name. @@ -192,9 +209,31 @@ pub(crate) fn render_error_struct(out: &mut String, domains: &[&ErrorBinding]) { out.push_str(&w.finish()); } -/// Render the internal string and pointer helpers shared by every wrapper. +/// Render the internal string and pointer helpers shared by every wrapper, +/// plus the `WeaveFFIHandle` marker every object wrapper's adopting +/// constructor takes. The marker exists because the generated file is often +/// compiled into the consumer's own assembly, where an `internal` constructor +/// taking a bare `IntPtr` would join overload resolution with the public IDL +/// constructors: an `int` literal converts implicitly to `IntPtr` (`nint`) and +/// C# prefers that over `long`, so `new Token(1)` would silently adopt the +/// pointer `0x1`. No IDL type lowers to `WeaveFFIHandle`, so the adopting +/// constructor can never be chosen by accident. pub(crate) fn render_helpers_class(out: &mut String) { let mut w = CodeWriter::four_space().with_depth(1); + w.line("/// One strong reference to a native object, on its way into"); + w.line("/// a wrapper that adopts it. A distinct type (rather than a bare"); + w.line("/// IntPtr) so the adopting constructor never competes with a"); + w.line("/// public constructor taking an integer."); + w.line("internal readonly struct WeaveFFIHandle"); + w.block("{", "}", |w| { + w.line("internal readonly IntPtr Value;"); + w.blank(); + w.line("internal WeaveFFIHandle(IntPtr value)"); + w.block("{", "}", |w| { + w.line("Value = value;"); + }); + }); + w.blank(); w.line("internal static class WeaveFFIHelpers"); w.block("{", "}", |w| { w.line("internal static IntPtr StringToPtr(string? s)"); @@ -219,9 +258,11 @@ pub(crate) fn render_helpers_class(out: &mut String) { /// The private buffer writer and reader implementing the WeaveFFI value-buffer /// wire format (little-endian, packed, no alignment) over managed byte arrays. /// The reader rejects malformed input (truncation, invalid bool or option -/// flags, oversized length prefixes, trailing bytes) by throwing -/// `InvalidOperationException`; a malformed buffer is always a producer or -/// consumer bug, never a recoverable domain error. +/// flags, oversized length prefixes, a zero object token, trailing bytes) by +/// throwing `InvalidOperationException`; a malformed buffer is always a +/// producer or consumer bug, never a recoverable domain error. Object tokens +/// are the pointer zero-extended to `u64`, so the conversions go through the +/// native-sized unsigned integer rather than `long`. pub(crate) fn render_buffer_classes(out: &mut String) { let mut w = CodeWriter::four_space().with_depth(1); w.block_raw( @@ -338,6 +379,11 @@ internal sealed class WeaveFFIBufferWriter _len += v.Length; } + internal void WriteObject(IntPtr token) + { + WriteU64((ulong)(nuint)(nint)token); + } + internal byte[] ToArray() { var outBuf = new byte[_len]; @@ -484,6 +530,16 @@ internal sealed class WeaveFFIBufferReader return outBuf; } + internal IntPtr ReadObject() + { + var token = ReadU64(); + if (token == 0) + { + throw new InvalidOperationException("malformed WeaveFFI value buffer: null object token"); + } + return (nint)(nuint)token; + } + internal void ExpectEnd() { if (_pos != _data.Length) diff --git a/crates/weaveffi-gen-dotnet/src/tests.rs b/crates/weaveffi-gen-dotnet/src/tests.rs new file mode 100644 index 00000000..584a9792 --- /dev/null +++ b/crates/weaveffi-gen-dotnet/src/tests.rs @@ -0,0 +1,452 @@ +//! Rendering tests over a small hand-built API exercising every ABI +//! revision 2 shape: reference-counted objects, nullable objects, objects in +//! records and lists, iterators of objects, and a callback interface. + +use camino::Utf8Path; +use weaveffi_core::backend::LanguageBackend; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::PackageContext; +use weaveffi_core::platform::{BinarySet, Platform}; +use weaveffi_core::resolved::ResolvedApi; +use weaveffi_core::validate::validate_api; +use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, Function, InterfaceDef, Module, Param, StructDef, StructField, + TypeRef, +}; + +use crate::{render_csharp, DotnetConfig, DotnetGenerator}; + +fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } +} + +fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } +} + +fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } +} + +fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) +} + +/// The fixture: a `bus` module with a `Ticker` interface, a `Bundle` record +/// holding a `Ticker` and a `[Ticker]`, a `Subscriber` callback interface, +/// and free functions covering nullable objects, iterators of objects, an +/// async object result, and a callback-interface parameter. +fn fixture() -> ResolvedApi { + let ticker = InterfaceDef { + name: "Ticker".into(), + doc: Some("A counter the producer owns.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("start", TypeRef::I32)], None)], + methods: vec![func("value", vec![], Some(TypeRef::I32))], + statics: vec![], + }; + let bundle = StructDef { + name: "Bundle".into(), + doc: None, + deprecated: None, + fields: vec![ + field("primary", named("Ticker")), + field("others", TypeRef::List(Box::new(named("Ticker")))), + field("label", TypeRef::StringUtf8), + ], + }; + let subscriber = CallbackInterfaceDef { + name: "Subscriber".into(), + doc: Some("Receives bus events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("bundle", named("Bundle")), + param("ticker", named("Ticker")), + ], + Some(TypeRef::Bool), + ), + func("on_close", vec![param("code", TypeRef::I64)], None), + ], + }; + let module = Module { + name: "bus".into(), + doc: None, + functions: vec![ + func( + "maybe_ticker", + vec![param("input", TypeRef::Optional(Box::new(named("Ticker"))))], + Some(TypeRef::Optional(Box::new(named("Ticker")))), + ), + func( + "tickers", + vec![], + Some(TypeRef::Iterator(Box::new(named("Ticker")))), + ), + func( + "subscribe", + vec![param("listener", named("Subscriber"))], + None, + ), + Function { + r#async: true, + ..func("spawn", vec![], Some(named("Ticker"))) + }, + func( + "roundtrip_bundle", + vec![param("b", named("Bundle"))], + Some(named("Bundle")), + ), + ], + interfaces: vec![ticker], + callback_interfaces: vec![subscriber], + structs: vec![bundle], + enums: vec![], + errors: None, + modules: vec![], + }; + validate_api( + Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![module], + }, + None, + ) + .expect("fixture validates") +} + +fn render() -> String { + let api = fixture(); + let model = BindingModel::build(&api, "weaveffi"); + render_csharp(&model, "Bus", true, "bus.yml", "Bus.cs") +} + +fn assert_has(out: &str, needle: &str) { + assert!(out.contains(needle), "missing `{needle}` in:\n{out}"); +} + +#[test] +fn interface_wrapper_is_reference_counted_and_disposes_once() { + let out = render(); + assert_has(&out, "public class Ticker : IDisposable"); + assert_has( + &out, + "internal static extern IntPtr weaveffi_bus_Ticker_clone(IntPtr self);", + ); + assert_has( + &out, + "internal static extern void weaveffi_bus_Ticker_destroy(IntPtr self);", + ); + // A second strong reference for the codec. + assert_has( + &out, + "return NativeMethods.weaveffi_bus_Ticker_clone(Handle);", + ); + // Exactly one destroy across Dispose and the finalizer. + assert_has( + &out, + "if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0)", + ); + assert_has(&out, "NativeMethods.weaveffi_bus_Ticker_destroy(h);"); + assert_has(&out, "~Ticker()"); + assert_has(&out, "throw new ObjectDisposedException(nameof(Ticker));"); + // The constructor adopts the returned reference; the method borrows + // through the checked property so a disposed wrapper throws. + assert_has(&out, "public Ticker(int start)"); + assert_has( + &out, + "var result = NativeMethods.weaveffi_bus_Ticker_new(start, ref err);", + ); + assert_has(&out, "_handle = result;"); + assert_has(&out, "public int Value()"); + assert_has( + &out, + "var result = NativeMethods.weaveffi_bus_Ticker_value(Handle, ref err);", + ); +} + +#[test] +fn adopting_constructor_takes_the_handle_marker_not_a_bare_pointer() { + let out = render(); + // Compiled into the consumer's assembly, an `internal Ticker(IntPtr)` + // would win overload resolution over the public `Ticker(int start)` for + // an integer literal (int -> nint is implicit and preferred over long), + // silently adopting a bogus pointer. The marker struct rules that out. + assert_has(&out, "internal readonly struct WeaveFFIHandle"); + assert_has(&out, "internal WeaveFFIHandle(IntPtr value)"); + assert_has(&out, "internal Ticker(WeaveFFIHandle handle)"); + assert_has(&out, "_handle = handle.Value;"); + assert!( + !out.contains("internal Ticker(IntPtr handle)"), + "adopting constructor must not take a bare IntPtr" + ); + assert!( + !out.contains("new Ticker(result)"), + "every adoption must go through the WeaveFFIHandle marker" + ); +} + +#[test] +fn nullable_objects_are_single_nullable_slots() { + let out = render(); + assert_has(&out, "public static Ticker? MaybeTicker(Ticker? input)"); + assert_has( + &out, + "NativeMethods.weaveffi_bus_maybe_ticker(input?.Handle ?? IntPtr.Zero, ref err);", + ); + assert_has( + &out, + "return result == IntPtr.Zero ? null : new Ticker(new WeaveFFIHandle(result));", + ); + assert_has( + &out, + "internal static extern IntPtr weaveffi_bus_maybe_ticker(IntPtr input, ref WeaveFFIError out_err);", + ); +} + +#[test] +fn objects_in_records_use_cloned_tokens_and_adopt_on_read() { + let out = render(); + assert_has(&out, "public sealed class Bundle"); + assert_has(&out, "public Ticker Primary { get; }"); + assert_has(&out, "public Ticker[] Others { get; }"); + // Writing clones through the interface's clone symbol. + assert_has(&out, "writer.WriteObject(Primary.CloneHandle());"); + assert_has(&out, "writer.WriteObject(item0.CloneHandle());"); + // Reading adopts the token into a fresh wrapper. + assert_has( + &out, + "var fPrimary = new Ticker(new WeaveFFIHandle(reader.ReadObject()));", + ); + assert_has( + &out, + "var fOthersItem = new Ticker(new WeaveFFIHandle(reader.ReadObject()));", + ); + // The runtime codec helpers exist and reject a null token. + assert_has(&out, "internal void WriteObject(IntPtr token)"); + assert_has(&out, "internal IntPtr ReadObject()"); + assert_has(&out, "null object token"); +} + +#[test] +fn iterator_of_objects_adopts_each_element() { + let out = render(); + assert_has(&out, "public static IEnumerable Tickers()"); + assert_has( + &out, + "if (NativeMethods.weaveffi_bus_TickersIterator_next(iter, out var out_item, ref iterErr) == 0)", + ); + assert_has( + &out, + "yield return new Ticker(new WeaveFFIHandle(out_item));", + ); + assert_has( + &out, + "NativeMethods.weaveffi_bus_TickersIterator_destroy(iter);", + ); +} + +#[test] +fn async_object_result_is_adopted() { + let out = render(); + assert_has(&out, "public static async Task Spawn()"); + assert_has( + &out, + "tcs.SetResult(new Ticker(new WeaveFFIHandle(result)));", + ); + assert_has( + &out, + "NativeMethods.weaveffi_bus_spawn_async(callback, ctx);", + ); +} + +#[test] +fn callback_interface_renders_interface_vtable_and_trampolines() { + let out = render(); + assert_has(&out, "using System.Runtime.CompilerServices;"); + // The consumer-facing interface. + assert_has(&out, "public interface ISubscriber"); + assert_has( + &out, + "bool OnMessage(string text, int weight, Bundle bundle, Ticker ticker);", + ); + assert_has(&out, "void OnClose(long code);"); + + // One static vtable, method order then `free`, allocated once. + assert_has( + &out, + "internal static unsafe class WeaveFFIVtable_bus_Subscriber", + ); + assert_has(&out, "[StructLayout(LayoutKind.Sequential)]"); + let layout_start = out.find("private struct Layout").expect("layout struct"); + let layout = &out[layout_start..]; + let on_message = layout.find("public IntPtr on_message;").unwrap(); + let on_close = layout.find("public IntPtr on_close;").unwrap(); + let free = layout.find("public IntPtr free;").unwrap(); + assert!(on_message < on_close && on_close < free); + assert_has( + &out, + "internal static readonly IntPtr Pointer = Allocate();", + ); + assert_has( + &out, + "on_message = (IntPtr)(delegate* unmanaged[Cdecl])&OnMessageTrampoline,", + ); + assert_has( + &out, + "on_close = (IntPtr)(delegate* unmanaged[Cdecl])&OnCloseTrampoline,", + ); + assert_has( + &out, + "free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline,", + ); + assert_has( + &out, + "var mem = Marshal.AllocHGlobal(Marshal.SizeOf());", + ); + + // Trampolines: borrowed string and buffer decoded, object adopted, + // direct return written through, failures reported via error_set. + assert_has( + &out, + "[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]", + ); + assert_has( + &out, + "private static byte OnMessageTrampoline(IntPtr ctx, IntPtr text, int weight, IntPtr bundle_ptr, UIntPtr bundle_len, IntPtr ticker, IntPtr out_err)", + ); + assert_has(&out, "var impl = Target(ctx);"); + assert_has(&out, "var arg2Buf = new byte[(int)bundle_len];"); + assert_has(&out, "var arg2Reader = new WeaveFFIBufferReader(arg2Buf);"); + assert_has( + &out, + "return (byte)(impl.OnMessage(Marshal.PtrToStringUTF8(text) ?? \"\", weight, arg2, new Ticker(new WeaveFFIHandle(ticker))) ? 1 : 0);", + ); + assert_has( + &out, + "private static void OnCloseTrampoline(IntPtr ctx, long code, IntPtr out_err)", + ); + assert_has(&out, "impl.OnClose(code);"); + assert_has( + &out, + "NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message);", + ); + assert_has(&out, "return default;"); + assert_has(&out, "public const int ForeignErrorCode = -4;"); + // No string or buffer is ever freed inside a trampoline. + let tramp_start = out.find("private static byte OnMessageTrampoline").unwrap(); + let tramp_end = out[tramp_start..].find("FreeTrampoline").unwrap() + tramp_start; + let tramp = &out[tramp_start..tramp_end]; + assert!(!tramp.contains("weaveffi_free_"), "{tramp}"); + + // `free` releases the GCHandle. + assert_has(&out, "private static void FreeTrampoline(IntPtr ctx)"); + assert_has(&out, "GCHandle.FromIntPtr(ctx).Free();"); + + // The error_set runtime symbol is bound. + assert_has( + &out, + "internal static extern void weaveffi_error_set(IntPtr err, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message);", + ); +} + +#[test] +fn passing_a_callback_interface_registers_a_gchandle_and_the_static_vtable() { + let out = render(); + assert_has(&out, "public static void Subscribe(ISubscriber listener)"); + assert_has( + &out, + "var listenerCtx = GCHandle.ToIntPtr(GCHandle.Alloc(listener));", + ); + assert_has( + &out, + "NativeMethods.weaveffi_bus_subscribe(listenerCtx, WeaveFFIVtable_bus_Subscriber.Pointer, ref err);", + ); + assert_has( + &out, + "internal static extern void weaveffi_bus_subscribe(IntPtr listener_ctx, IntPtr listener_vtable, ref WeaveFFIError out_err);", + ); +} + +#[test] +fn legacy_runtime_surface_is_gone() { + let out = render(); + for legacy in [ + "arena", + "weaveffi_handle_t", + "Handle(IntPtr raw)", + "_listenerRefs", + ] { + assert!(!out.contains(legacy), "found legacy `{legacy}` in:\n{out}"); + } + assert_has(&out, "internal const uint AbiVersion = 2;"); +} + +#[test] +fn output_is_deterministic() { + assert_eq!(render(), render()); +} + +#[test] +fn package_skips_platforms_without_a_nuget_rid() { + let api = fixture(); + let model = BindingModel::build(&api, "weaveffi"); + let mut binaries = BinarySet::new("bus"); + for p in Platform::ALL { + binaries.insert(p, format!("/tmp/{}/lib", p.id())); + } + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("bus"), + }; + let files = DotnetGenerator + .package( + &api, + &model, + &ctx, + Utf8Path::new("out"), + &DotnetConfig::default(), + ) + .expect("dotnet packages"); + let paths: Vec = files.iter().map(|f| f.path.to_string()).collect(); + let native: Vec<&String> = paths.iter().filter(|p| p.contains("/runtimes/")).collect(); + assert_eq!(native.len(), Platform::DESKTOP.len(), "{paths:?}"); + for rid in [ + "osx-arm64", + "osx-x64", + "linux-x64", + "linux-arm64", + "win-x64", + ] { + assert!( + paths + .iter() + .any(|p| p.contains(&format!("/runtimes/{rid}/native/"))), + "{paths:?}" + ); + } + assert!(!paths + .iter() + .any(|p| p.contains("android") || p.contains("wasm"))); +} diff --git a/crates/weaveffi-gen-dotnet/src/types.rs b/crates/weaveffi-gen-dotnet/src/types.rs index 457f3baf..aee539b5 100644 --- a/crates/weaveffi-gen-dotnet/src/types.rs +++ b/crates/weaveffi-gen-dotnet/src/types.rs @@ -8,10 +8,18 @@ use weaveffi_core::model::FnBinding; use weaveffi_core::model::Ty; use weaveffi_core::utils::local_type_name; -/// The C# type of a `handle` reference: a generated `{T}Handle` wrapper -/// struct named after the referent's bare local type name. -pub(crate) fn typed_handle_cs(name: &str) -> String { - format!("{}Handle", local_type_name(name)) +/// The C# interface a consumer implements for one callback interface: the +/// bare local name with the conventional `I` prefix (`ISubscriber` for +/// `Subscriber`, or for a cross-module `bus.Subscriber`). +pub(crate) fn callback_interface_cs(name: &str) -> String { + format!("I{}", local_type_name(name)) +} + +/// The internal static class hosting one callback interface's vtable and +/// trampolines, named from the C module path and the bare name so two +/// modules declaring the same callback interface name never collide. +pub(crate) fn vtable_class_cs(module_path: &str, name: &str) -> String { + format!("WeaveFFIVtable_{module_path}_{name}") } /// The idiomatic C# surface type for one IR type, as it appears in wrapper @@ -29,12 +37,8 @@ pub(crate) fn cs_type(ty: &Ty) -> String { Ty::F32 => "float".into(), Ty::F64 => "double".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "string".into(), - Ty::Handle => "ulong".into(), - // Typed handles surface as a generated `{T}Handle` wrapper struct; a - // cross-module referent (e.g. `kv.Token`) uses the bare local name. - Ty::TypedHandle(name) => typed_handle_cs(name), - Ty::Bytes | Ty::BorrowedBytes => "byte[]".into(), + Ty::StringUtf8 => "string".into(), + Ty::Bytes => "byte[]".into(), // Records are plain data classes; rich enums are abstract sum types. // Both are value types decoded from value buffers. Ty::Record(name) | Ty::RichEnum(name) => local_type_name(name).into(), @@ -51,21 +55,21 @@ pub(crate) fn cs_type(ty: &Ty) -> String { Ty::F32 => "float?".into(), Ty::F64 => "double?".into(), Ty::Bool => "bool?".into(), - Ty::Handle => "ulong?".into(), - Ty::TypedHandle(name) => format!("{}?", typed_handle_cs(name)), Ty::Enum(name) => format!("{}?", local_type_name(name)), - Ty::StringUtf8 | Ty::BorrowedStr => "string?".into(), + Ty::StringUtf8 => "string?".into(), Ty::Record(name) | Ty::RichEnum(name) => { format!("{}?", local_type_name(name)) } + // `Interface?` and nested composites are nullable reference types. _ => format!("{}?", cs_type(inner)), }, Ty::List(inner) => format!("{}[]", cs_type(inner)), Ty::Iterator(inner) => format!("IEnumerable<{}>", cs_type(inner)), Ty::Map(k, v) => format!("Dictionary<{}, {}>", cs_type(k), cs_type(v)), - // Interfaces surface as their opaque-handle wrapper class; a + // Interfaces surface as their reference-counted wrapper class; a // cross-module reference (`kv.Store`) uses the bare local name. Ty::Interface(name) => local_type_name(name).into(), + Ty::CallbackInterface(name) => callback_interface_cs(name), } } @@ -87,18 +91,15 @@ pub(crate) fn pinvoke_type(ty: &Ty) -> String { // slot in arrays and leave garbage in the upper bits of returns. Ty::Bool => "byte".into(), Ty::StringUtf8 - | Ty::BorrowedStr | Ty::Bytes - | Ty::BorrowedBytes | Ty::Record(_) | Ty::RichEnum(_) | Ty::Interface(_) + | Ty::CallbackInterface(_) | Ty::Optional(_) | Ty::List(_) | Ty::Iterator(_) | Ty::Map(_, _) => "IntPtr".into(), - Ty::Handle => "ulong".into(), - Ty::TypedHandle(_) => "IntPtr".into(), Ty::Enum(_) => "int".into(), } } @@ -113,7 +114,7 @@ pub(crate) fn cs_pinvoke_ctype(ty: &CType) -> String { CType::Bool => "byte".into(), CType::Uint32 => "uint".into(), CType::Int64 => "long".into(), - CType::Uint64 | CType::Handle => "ulong".into(), + CType::Uint64 => "ulong".into(), CType::Double => "double".into(), CType::Float => "float".into(), CType::Size => "UIntPtr".into(), @@ -125,6 +126,7 @@ pub(crate) fn cs_pinvoke_ctype(ty: &CType) -> String { CType::Char => "sbyte".into(), CType::Ptr { .. } | CType::StructTag { .. } + | CType::VtableTag { .. } | CType::CancelToken | CType::Error | CType::Named(_) => "IntPtr".into(), @@ -159,9 +161,7 @@ pub(crate) fn is_cs_value_type(ty: &Ty) -> bool { | Ty::F32 | Ty::F64 | Ty::Bool - | Ty::Handle | Ty::Enum(_) - | Ty::TypedHandle(_) ) } diff --git a/crates/weaveffi-gen-go/Cargo.toml b/crates/weaveffi-gen-go/Cargo.toml index 0bda4e2f..e17c09d1 100644 --- a/crates/weaveffi-gen-go/Cargo.toml +++ b/crates/weaveffi-gen-go/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-go/src/calls.rs b/crates/weaveffi-gen-go/src/calls.rs index 19de60e6..d46eaf2d 100644 --- a/crates/weaveffi-gen-go/src/calls.rs +++ b/crates/weaveffi-gen-go/src/calls.rs @@ -1,5 +1,5 @@ -//! Call rendering: sync, async, and iterator wrappers, callback and listener -//! trampolines, and the argument/return marshalling they share. +//! Call rendering: sync, async, and iterator wrappers, callback-interface +//! types and trampolines, and the argument/return marshalling they share. //! //! Marshalling dispatch follows the shared plan layer: each parameter's //! passing contract comes from [`ParamBinding::arg_pass`], each result's @@ -7,21 +7,22 @@ //! those contracts in Go rather than re-deriving them from `Ty`. use heck::ToUpperCamelCase; -use weaveffi_core::abi::AbiParam; +use weaveffi_core::abi::{AbiParam, CType}; use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::lang; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - AsyncBinding, BindingModel, CallShape, CallbackBinding, FnBinding, IteratorBinding, - ListenerBinding, ModuleBinding, ParamBinding, + AsyncBinding, BindingModel, CallShape, CallbackInterfaceBinding, CallbackMethodBinding, + FnBinding, IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{self, elem_free, ArgPass, ElemFree, ErrorStrategy, RetPass}; -use weaveffi_core::utils::{c_abi_struct_name, wrapper_name}; +use weaveffi_core::plan::{self, ArgPass, ErrorStrategy, RetPass}; +use weaveffi_core::utils::c_abi_struct_name; use crate::codec::{emit_buffer_read, emit_buffer_write}; use crate::docs::{emit_doc, emit_fn_doc}; use crate::types::{ - c_scalar_conv, c_scalar_type, cgo_slot_type, go_local, go_param_ident, go_scalar_conv, go_type, - go_wrap_expr, go_zero, strip_const, + c_scalar_conv, cgo_slot_type, go_adopt_expr, go_param_ident, go_scalar_conv, go_type, go_zero, + strip_const, vtable_accessor, vtable_var, }; // ── Errors ── @@ -33,7 +34,8 @@ use crate::types::{ /// the generic [`ERROR_BRAND`](weaveffi_core::errors::ERROR_BRAND) struct /// when no domain is in scope. A callable with `throws == false` has a plain /// signature and panics via `wvTrap` instead, since a reported error can only -/// be a producer panic or an argument-marshalling failure. +/// be a producer panic, an argument-marshalling failure, or a callback +/// implementation that panicked. #[derive(Clone, Copy)] pub(crate) struct ErrCtx<'a> { /// `true` when the wrapper returns `(T, error)` and surfaces typed errors. @@ -103,75 +105,172 @@ impl<'a> ErrCtx<'a> { } } -// ── Callbacks, listeners, and async support ── +// ── Trampolines and the cgo preamble ── -/// The C name of the exported Go trampoline for a callback/async typedef. +/// The Go spelling of one C ABI slot name inside an exported trampoline's +/// formal list: a slot named after a Go keyword (an IDL parameter called +/// `type`, `func`, `range`) gains the trailing-underscore escape. The same +/// spelling is used in the preamble `extern` so the two prototypes agree. +fn go_slot_ident(name: &str) -> String { + lang::escape_ident(name, lang::GO_KEYWORDS) +} + +/// The C name of the exported Go trampoline for an async completion typedef. pub(crate) fn trampoline_name(c_type_name: &str) -> String { format!("goWv_{c_type_name}") } -/// The preamble `extern` declaration for one exported trampoline. -fn extern_decl(c_type_name: &str, params: &[AbiParam], prefix: &str) -> String { +/// The C name of the exported Go trampoline behind one callback-interface +/// vtable entry (`goWv_{c_tag}_{method}`); `free` names the trailing entry. +fn cb_trampoline_name(c_tag: &str, method: &str) -> String { + format!("goWv_{c_tag}_{method}") +} + +/// The preamble `extern` declaration for one exported trampoline. Pointer +/// types are rendered const-free to match the prototypes cgo writes into +/// `_cgo_export.h` from the Go signature. +fn extern_decl(name: &str, ret: &CType, params: &[AbiParam], prefix: &str) -> String { let args: Vec = params .iter() - .map(|p| format!("{} {}", strip_const(&p.ty).render_c(prefix), p.name)) + .map(|p| { + format!( + "{} {}", + strip_const(&p.ty).render_c(prefix), + go_slot_ident(&p.name) + ) + }) .collect(); format!( - "extern void {}({});", - trampoline_name(c_type_name), + "extern {} {name}({});", + ret.render_c(prefix), args.join(", ") ) } -/// Every `extern` declaration the preamble needs: one per module callback -/// (shared by all listeners firing it) and one per async completion callback, -/// including async interface members. -pub(crate) fn collect_trampoline_externs(model: &BindingModel, prefix: &str) -> Vec { +/// The preamble definition of the one process-wide static vtable for `cb`: +/// one trampoline per method in declaration order, then `free`. An entry +/// whose C signature carries `const` pointers is cast back to the vtable's +/// exact field type, since the exported Go function is declared const-free. +fn vtable_def(cb: &CallbackInterfaceBinding, prefix: &str) -> String { + let mut s = format!( + "static const {} {} = {{\n", + cb.vtable_tag, + vtable_var(&cb.c_tag) + ); + for m in &cb.methods { + let tramp = cb_trampoline_name(&cb.c_tag, &m.name); + let needs_cast = m.abi_params.iter().any(|p| strip_const(&p.ty) != p.ty); + if needs_cast { + let types: Vec = m.abi_params.iter().map(|p| p.ty.render_c(prefix)).collect(); + s.push_str(&format!( + " ({} (*)({})){tramp},\n", + m.abi_ret.render_c(prefix), + types.join(", ") + )); + } else { + s.push_str(&format!(" {tramp},\n")); + } + } + s.push_str(&format!( + " {},\n}};\n", + cb_trampoline_name(&cb.c_tag, "free") + )); + // cgo reaches a C variable through `//go:cgo_import_static`, which needs + // external linkage, so a `static const` table can't be named as + // `&C.wvVtable_...` from Go. A static accessor function is callable + // through the ordinary cgo stub in the same translation unit. + s.push_str(&format!( + "static const {}* {}(void) {{ return &{}; }}", + cb.vtable_tag, + vtable_accessor(&cb.c_tag), + vtable_var(&cb.c_tag) + )); + s +} + +/// Every declaration the cgo preamble needs beyond the header include: for +/// each callback interface, the `extern` prototypes of its method and `free` +/// trampolines followed by its static vtable and accessor; then one `extern` +/// per async completion callback, including async interface members. +/// +/// A file that uses `//export` may only put declarations in its preamble +/// (the preamble is compiled into two C translation units); the vtable is a +/// `static const` so each unit gets a private copy and no symbol is +/// duplicated. Go takes the address of the copy in its own unit through the +/// static accessor, so the producer always sees one vtable whose entries +/// live for the process. +pub(crate) fn collect_preamble_decls(model: &BindingModel, prefix: &str) -> Vec { let mut decls = Vec::new(); for m in &model.modules { - for cb in &m.callbacks { - decls.push(extern_decl(&cb.c_fn_type, &cb.abi_params, prefix)); + for cb in &m.callback_interfaces { + for meth in &cb.methods { + decls.push(extern_decl( + &cb_trampoline_name(&cb.c_tag, &meth.name), + &meth.abi_ret, + &meth.abi_params, + prefix, + )); + } + decls.push(extern_decl( + &cb_trampoline_name(&cb.c_tag, "free"), + &CType::Void, + &[AbiParam::new("ctx", CType::ptr(CType::Void))], + prefix, + )); + decls.push(vtable_def(cb, prefix)); } for f in m.callables() { if let CallShape::Async(ab) = &f.shape { - decls.push(extern_decl(&ab.callback_type, &ab.callback_params, prefix)); + decls.push(extern_decl( + &trampoline_name(&ab.callback_type), + &CType::Void, + &ab.callback_params, + prefix, + )); } } } decls } -/// The Go signature of the user-facing callback for a module callback decl, -/// e.g. `func(key string)`. -fn go_callback_sig(cb: &CallbackBinding) -> String { - let params: Vec = cb +// ── Callback interfaces ── + +/// The Go method signature of one callback-interface method, as it appears +/// in the consumer-implemented interface type: `OnMessage(text string, +/// weight int32) int64`. +fn cb_method_sig(m: &CallbackMethodBinding) -> String { + let params: Vec = m .params .iter() .map(|p| format!("{} {}", go_param_ident(&p.name), go_type(&p.ty))) .collect(); - format!("func({})", params.join(", ")) + let ret = match &m.ret { + Some(ty) => format!(" {}", go_type(ty)), + None => String::new(), + }; + format!( + "{}({}){ret}", + m.name.to_upper_camel_case(), + params.join(", ") + ) } -/// Emit statements converting one callback parameter's C slots into a Go -/// value bound to `arg{idx}`, returning that local's name. +/// Emit statements converting one callback-method parameter's C slots into +/// a Go value bound to `arg{idx}`, returning that local's name. /// -/// Every callback argument is borrowed for the dispatch: buffered values are -/// decoded from the borrowed `(ptr, len)` pair, strings and bytes are copied, -/// and object pointers are wrapped without adopting ownership. -fn emit_cb_param_arg( - out: &mut String, - idx: usize, - p: &ParamBinding, - prefix: &str, - module: &str, -) -> String { +/// Strings, bytes, and buffers arriving in a trampoline are borrowed for the +/// dispatch: they are copied or decoded and nothing is freed. An object +/// argument transfers one strong reference, which is adopted into a wrapper +/// (a null `Interface?` adopts to nil). +fn emit_cb_param_arg(out: &mut String, idx: usize, p: &ParamBinding) -> String { let arg = format!("arg{idx}"); let mut w = CodeWriter::tabs().with_depth(1); match p.arg_pass() { ArgPass::Buffer { ptr, len } => { w.line(format!( "rArg{idx} := &wvReader{{buf: wvBorrowBuffer({}, {})}}", - ptr.name, len.name + go_slot_ident(&ptr.name), + go_slot_ident(&len.name) )); w.line(format!("var {arg} {}", go_type(&p.ty))); emit_buffer_read( @@ -181,173 +280,185 @@ fn emit_cb_param_arg( &p.ty, &format!("Arg{idx}"), 0, - prefix, - module, ); w.line(format!("rArg{idx}.expectEnd()")); } ArgPass::String { slot } => { - let n = &slot.name; + let n = go_slot_ident(&slot.name); w.line(format!("{arg} := \"\"")); w.block(format!("if {n} != nil {{"), "}", |w| { w.line(format!("{arg} = C.GoString({n})")); }); } ArgPass::Bytes { ptr, len } => { + let pn = go_slot_ident(&ptr.name); + let ln = go_slot_ident(&len.name); w.line(format!("var {arg} []byte")); - w.block(format!("if {} != nil {{", ptr.name), "}", |w| { + w.block(format!("if {pn} != nil {{"), "}", |w| { w.line(format!( - "{arg} = C.GoBytes(unsafe.Pointer({}), C.int({}))", - ptr.name, len.name + "{arg} = C.GoBytes(unsafe.Pointer({pn}), C.int({ln}))" )); }); } - // Object pointers are borrowed for the duration of the callback; the - // wrapper must not be Closed by the consumer. - ArgPass::Object { slot, nullable } => { - let n = &slot.name; - if nullable { - let Ty::Optional(inner) = &p.ty else { - unreachable!("nullable object params are optional interfaces") - }; - let Ty::Interface(name) = inner.as_ref() else { - unreachable!("every other optional is buffered") - }; - let g = go_local(name); - w.line(format!("var {arg} *{g}")); - w.block(format!("if {n} != nil {{"), "}", |w| { - w.line(format!("{arg} = &{g}{{ptr: {n}}}")); - }); - } else { - w.line(format!("{arg} := {}", go_wrap_expr(&p.ty, n))); - } + ArgPass::Object { slot, .. } => { + w.line(format!( + "{arg} := {}", + go_adopt_expr(&p.ty, &go_slot_ident(&slot.name)) + )); } ArgPass::Direct { slot } => { - let n = &slot.name; - match &p.ty { - Ty::Bool => { - w.line(format!("{arg} := cToBool({n})")); - } - // A typed handle is a borrowed opaque pointer wrapped - // without ownership, like an interface. - Ty::TypedHandle(_) => { - w.line(format!("{arg} := {}", go_wrap_expr(&p.ty, n))); - } - _ => { - w.line(format!("{arg} := {}", go_scalar_conv(n, &p.ty))); - } - } + let n = go_slot_ident(&slot.name); + w.line(format!("{arg} := {}", go_scalar_conv(&n, &p.ty))); + } + ArgPass::Callback { .. } => { + unreachable!("validation rejects callback interfaces as callback-method parameters") } } out.push_str(&w.finish()); arg } -/// One exported trampoline per module callback declaration; every listener -/// firing this callback shares it, with the registry id in `context` selecting -/// the Go callback. -pub(crate) fn render_callback_trampoline( - out: &mut String, +/// Emit the exported trampoline behind one vtable entry. It recovers the +/// implementation from the `cgo.Handle` passed as `ctx`, converts the +/// borrowed arguments, calls the Go method, and writes a direct-family +/// result into the C return. A panic in the implementation is recovered, +/// reported through `weaveffi_error_set(out_err, -4, message)`, and the +/// zero value is returned; nothing ever unwinds through the C frame. +fn render_cb_trampoline( + w: &mut CodeWriter, prefix: &str, module: &str, - cb: &CallbackBinding, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + iface_name: &str, ) { - let tramp = trampoline_name(&cb.c_fn_type); - let formals: Vec = cb + let tramp = cb_trampoline_name(&cb.c_tag, &m.name); + let formals: Vec = m .abi_params .iter() - .map(|s| format!("{} {}", s.name, cgo_slot_type(&s.ty, prefix))) + .map(|s| { + format!( + "{} {}", + go_slot_ident(&s.name), + cgo_slot_type(&s.ty, prefix) + ) + }) .collect(); - - let mut w = CodeWriter::tabs(); + let ret_sig = match &m.ret { + Some(_) => format!(" (ret {})", cgo_slot_type(&m.abi_ret, prefix)), + None => String::new(), + }; w.line(format!("//export {tramp}")); w.block( - format!("func {tramp}({}) {{", formals.join(", ")), + format!("func {tramp}({}){ret_sig} {{", formals.join(", ")), "}", |w| { - w.line("v := wvCallbackLoad(uint64(uintptr(context)))"); - w.block("if v == nil {", "}", |w| { - w.line("return"); + w.block("defer func() {", "}()", |w| { + w.block("if r := recover(); r != nil {", "}", |w| { + w.line("wvForeignError(out_err, r)"); + }); }); - w.line(format!("cb := v.({})", go_callback_sig(cb))); + w.line(format!( + "impl := cgo.Handle(uintptr(ctx)).Value().({iface_name})" + )); let mut args = Vec::new(); - for (idx, p) in cb.params.iter().enumerate() { + for (idx, p) in m.params.iter().enumerate() { let mut body = String::new(); - args.push(emit_cb_param_arg(&mut body, idx, p, prefix, module)); + args.push(emit_cb_param_arg(&mut body, idx, p)); w.raw(body); } - w.line(format!("cb({})", args.join(", "))); + let call = format!("impl.{}({})", m.name.to_upper_camel_case(), args.join(", ")); + match &m.ret { + Some(ty) => { + w.line(format!( + "ret = {}", + c_scalar_conv(&call, ty, prefix, module) + )); + w.line("return"); + } + None => { + w.line(call); + } + } }, ); w.blank(); - out.push_str(&w.finish()); } -/// The register/unregister wrapper pair for one listener. The wrapper names -/// follow the module-prefix-stripping default like free functions -/// (`RegisterEvictionListener` rather than `KvRegisterEvictionListener`). -pub(crate) fn render_listener_api( +/// Render one callback interface: the Go `interface` type the consumer +/// implements (one method per IDL method, PascalCase, direct-family or void +/// returns), the exported trampoline behind each vtable entry, and the +/// `free` trampoline that deletes the `cgo.Handle` once the producer drops +/// its last reference to the callback. +/// +/// Passing an implementation to a producer function stores it in a +/// `cgo.Handle` (the `void* ctx` slot) and passes the address of the +/// interface's static vtable from the cgo preamble (see +/// [`collect_preamble_decls`]). The producer may invoke any trampoline from +/// any thread; cgo attaches the calling thread to the Go runtime. +pub(crate) fn render_callback_interface( out: &mut String, - m: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, + prefix: &str, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, ) { - let Some(cb) = m.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - let register_go = wrapper_name( - &m.path, - &format!("register_{}", l.name), - strip_module_prefix, - ) - .to_upper_camel_case(); - let unregister_go = wrapper_name( - &m.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ) - .to_upper_camel_case(); - let tramp = trampoline_name(&cb.c_fn_type); - + let name = cb.name.to_upper_camel_case(); let mut w = CodeWriter::tabs(); let mut d = String::new(); - emit_doc(&mut d, &l.doc, "", Some(®ister_go)); - w.raw(d); - w.line(format!("// Returns a subscription id for {unregister_go}.")); - w.block( - format!("func {register_go}(callback {}) uint64 {{", go_callback_sig(cb)), - "}", - |w| { - w.line("ctxID := wvCallbackStore(callback)"); - w.line(format!( - "id := uint64(C.{}(C.{}(unsafe.Pointer(C.{tramp})), unsafe.Pointer(uintptr(ctxID))))", - l.register_symbol, cb.c_fn_type - )); - w.line("wvCallbackMu.Lock()"); - w.line("wvListenerCtx[id] = ctxID"); - w.line("wvCallbackMu.Unlock()"); - w.line("return id"); - }, - ); + emit_doc(&mut d, &cb.doc, "", Some(&name)); + if d.is_empty() { + w.line(format!( + "// {name} is a callback interface: implement it in Go and pass the value to" + )); + w.line("// native functions that accept it."); + } else { + w.raw(d); + w.line("//"); + w.line("// Implement this interface in Go and pass the value to native functions"); + w.line("// that accept it."); + } + w.line("//"); + w.line("// The native library may call any method from any thread until it releases"); + w.line("// the implementation. A panic in a method is reported to the native caller"); + w.line("// as a foreign error (code -4) instead of crashing the process."); + if let Some(msg) = &cb.deprecated { + w.line("//"); + w.line(format!("// Deprecated: {msg}")); + } + w.block(format!("type {name} interface {{"), "}", |w| { + for m in &cb.methods { + let mut md = String::new(); + emit_fn_doc( + &mut md, + &m.doc, + &m.params, + "\t", + &m.name.to_upper_camel_case(), + ); + w.raw(md); + if let Some(msg) = &m.deprecated { + w.line(format!("// Deprecated: {msg}")); + } + w.line(cb_method_sig(m)); + } + }); w.blank(); - w.line(format!( - "// {unregister_go} unregisters a listener previously registered with {register_go}." - )); - w.block(format!("func {unregister_go}(id uint64) {{"), "}", |w| { - w.line(format!("C.{}(C.uint64_t(id))", l.unregister_symbol)); - w.line("wvCallbackMu.Lock()"); - w.line("ctxID, ok := wvListenerCtx[id]"); - w.line("delete(wvListenerCtx, id)"); - w.line("wvCallbackMu.Unlock()"); - w.block("if ok {", "}", |w| { - w.line("wvCallbackDelete(ctxID)"); - }); + for m in &cb.methods { + render_cb_trampoline(&mut w, prefix, &module.path, cb, m, &name); + } + + let free = cb_trampoline_name(&cb.c_tag, "free"); + w.line(format!("//export {free}")); + w.block(format!("func {free}(ctx unsafe.Pointer) {{"), "}", |w| { + w.line("cgo.Handle(uintptr(ctx)).Delete()"); }); w.blank(); out.push_str(&w.finish()); } +// ── Async ── + /// The per-async-function outcome payload type name, derived from the /// (unique) base C symbol with the ABI prefix dropped: free function /// `weaveffi_io_read` names `wvOutcomeIoRead`, interface member @@ -365,8 +476,8 @@ fn async_outcome_type(prefix: &str, f: &FnBinding) -> String { /// /// Result buffers (strings, bytes, value buffers) are owned by the consumer /// per the shared async protocol: they are copied or decoded here and then -/// released through the runtime free symbols. Owned interface results are -/// adopted by the wrapper instead (its `Close` calls the destroy symbol). +/// released through the runtime free symbols. An owned object result +/// transfers one strong reference, adopted into a wrapper. fn emit_async_result_send( out: &mut String, ret: &Option, @@ -386,7 +497,7 @@ fn emit_async_result_send( // Owned by the consumer: wvCopyBuffer copies, then frees. w.line("rRes := &wvReader{buf: wvCopyBuffer(result_ptr, result_len)}"); w.line(format!("var val {}", go_type(ty))); - emit_buffer_read(&mut w, "rRes", "val", ty, "Res", 0, prefix, module); + emit_buffer_read(&mut w, "rRes", "val", ty, "Res", 0); w.line("rRes.expectEnd()"); w.line(format!("ch <- {outcome}{{val: val}}")); } @@ -408,54 +519,27 @@ fn emit_async_result_send( }); w.line(format!("ch <- {outcome}{{val: val}}")); } - // An owned interface result is adopted by the wrapper (its Close - // calls the destroy symbol). - RetPass::Object { nullable, .. } => { - if nullable { - let Ty::Optional(inner) = ty else { - unreachable!("nullable object results are optional interfaces") - }; - let Ty::Interface(name) = inner.as_ref() else { - unreachable!("every other optional is buffered") - }; - let g = go_local(name); - w.line(format!("var val *{g}")); - w.block("if result != nil {", "}", |w| { - w.line(format!("val = &{g}{{ptr: result}}")); - }); - w.line(format!("ch <- {outcome}{{val: val}}")); - } else { - w.line(format!( - "ch <- {outcome}{{val: {}}}", - go_wrap_expr(ty, "result") - )); - } + RetPass::Object { .. } => { + w.line(format!( + "ch <- {outcome}{{val: {}}}", + go_adopt_expr(ty, "result") + )); + } + RetPass::Direct => { + w.line(format!( + "ch <- {outcome}{{val: {}}}", + go_scalar_conv("result", ty) + )); } - RetPass::Direct => match ty { - Ty::Bool => { - w.line(format!("ch <- {outcome}{{val: cToBool(result)}}")); - } - // A typed handle is a borrowed id wrapped without ownership. - Ty::TypedHandle(_) => { - w.line(format!( - "ch <- {outcome}{{val: {}}}", - go_wrap_expr(ty, "result") - )); - } - _ => { - w.line(format!( - "ch <- {outcome}{{val: {}}}", - go_scalar_conv("result", ty) - )); - } - }, } out.push_str(&w.finish()); } /// An async callable: a blocking Go wrapper that launches the C call with a /// completion trampoline and waits on a buffered channel, plus the outcome -/// type and the exported trampoline itself. +/// type and the exported trampoline itself. The channel travels to the +/// producer as a `cgo.Handle` in the `context` slot; the trampoline resolves +/// and deletes it, so the completion is delivered exactly once. /// /// The error split follows the shared plan's [`ErrorStrategy`]. A throwing /// wrapper returns `(T, error)` and the trampoline maps a reported error @@ -465,7 +549,7 @@ fn emit_async_result_send( /// with it on the calling goroutine (the trampoline itself must never panic: /// it runs on a producer thread entered from C). With `receiver` set, the /// wrapper is a method on that wrapper type passing `s.ptr` as the leading -/// launch argument. +/// launch argument and keeping the receiver alive until completion. #[allow(clippy::too_many_arguments)] pub(crate) fn render_async_function( out: &mut String, @@ -497,7 +581,13 @@ pub(crate) fn render_async_function( let formals: Vec = ab .callback_params .iter() - .map(|s| format!("{} {}", s.name, cgo_slot_type(&s.ty, prefix))) + .map(|s| { + format!( + "{} {}", + go_slot_ident(&s.name), + cgo_slot_type(&s.ty, prefix) + ) + }) .collect(); let mut tramp_body = String::new(); emit_async_result_send(&mut tramp_body, &f.ret, &outcome, prefix, module); @@ -513,11 +603,9 @@ pub(crate) fn render_async_function( format!("func {tramp}({}) {{", formals.join(", ")), "}", |w| { - w.line("v := wvCallbackTake(uint64(uintptr(context)))"); - w.block("if v == nil {", "}", |w| { - w.line("return"); - }); - w.line(format!("ch := v.(chan {outcome})")); + w.line("h := cgo.Handle(uintptr(context))"); + w.line(format!("ch := h.Value().(chan {outcome})")); + w.line("h.Delete()"); w.block("if err != nil && err.code != 0 {", "}", |w| { w.line(format!("ch <- {outcome}{{err: {map_err}}}")); w.line("return"); @@ -554,7 +642,7 @@ pub(crate) fn render_async_function( c_args.push("nil".into()); } c_args.push(format!("C.{}(unsafe.Pointer(C.{tramp}))", ab.callback_type)); - c_args.push("unsafe.Pointer(uintptr(ctxID))".into()); + c_args.push("C.wvHandlePtr(C.uintptr_t(h))".into()); let launch_args = c_args.join(", "); let header = match receiver { @@ -565,8 +653,11 @@ pub(crate) fn render_async_function( None => format!("func {go_name}({}){ret_sig} {{", go_params.join(", ")), }; w.block(header, "}", |w| { + if let Some(ty) = receiver { + w.raw(receiver_guard(ty, "\t")); + } w.line(format!("ch := make(chan {outcome}, 1)")); - w.line("ctxID := wvCallbackStore(ch)"); + w.line("h := cgo.NewHandle(ch)"); w.raw(pre.as_str()); w.line(format!("C.{}({})", ab.launch.symbol, launch_args)); w.line("outcome := <-ch"); @@ -600,7 +691,7 @@ pub(crate) fn render_async_function( /// result out. An iterator-returning callable renders through /// [`render_iterator_fn`] as a lazy sequence instead. With `receiver` set, /// the wrapper is a method on that wrapper type passing `s.ptr` as the -/// leading C argument. +/// leading C argument and keeping the receiver alive across the call. #[allow(clippy::too_many_arguments)] pub(crate) fn render_function( out: &mut String, @@ -643,7 +734,8 @@ pub(crate) fn render_function( let mut pre = String::new(); let mut c_args: Vec = Vec::new(); - if receiver.is_some() { + if let Some(ty) = receiver { + pre.push_str(&receiver_guard(ty, "\t")); c_args.push("s.ptr".into()); } @@ -684,19 +776,15 @@ pub(crate) fn render_function( } /// Go type of the `out_item` local whose address is passed to an iterator's -/// `next` (the C slot is `T*`, so the local is one indirection less). -/// Buffered and bytes elements arrive as a `const uint8_t*` buffer pointer. -fn iter_out_item_type(inner: &Ty, prefix: &str, module: &str) -> String { - match elem_free(inner) { - ElemFree::String => "*C.char".into(), - ElemFree::Bytes => "*C.uint8_t".into(), - ElemFree::None => match inner { - Ty::TypedHandle(n) | Ty::Interface(n) => { - format!("*C.{}", c_abi_struct_name(n, module, prefix)) - } - _ => c_scalar_type(inner, prefix, module).unwrap_or_else(|| "C.int64_t".into()), - }, - } +/// `next`: the pointee of the `T* out_item` slot, so one indirection less +/// than the C slot. Strings arrive as `*C.char`, bytes and buffered elements +/// as a `*C.uint8_t` buffer pointer, objects as `*C.{tag}`, and direct +/// values as their scalar C type. +fn iter_out_item_type(ib: &IteratorBinding, prefix: &str) -> String { + let CType::Ptr { pointee, .. } = &ib.next.params[1].ty else { + unreachable!("an iterator's out_item slot is always a pointer") + }; + cgo_slot_type(pointee, prefix) } /// Re-indent `block` by one tab per non-empty line, used to move depth-1 @@ -717,40 +805,32 @@ fn indent_block(block: &str) -> String { /// Emit the statements converting one freshly-pulled `next` slot (`outItem`, /// plus `outLen` for bytes/buffered elements) into a Go value bound to -/// `item`, releasing the slot per the protocol's [`ElemFree`] plan: strings -/// are freed after copying, bytes and buffered elements are copied/decoded -/// and released with `weaveffi_free_bytes` (via `wvCopyBuffer`), and by-value -/// elements owe nothing. -fn emit_iter_elem_bind(w: &mut CodeWriter, inner: &Ty, ef: &ElemFree, prefix: &str, module: &str) { - match ef { - ElemFree::String => { +/// `item`, receiving it per the protocol's [`RetPass`] plan: strings are +/// copied then freed, bytes and buffered elements are copied/decoded and +/// released with `weaveffi_free_bytes` (via `wvCopyBuffer`), objects are +/// adopted into a wrapper, and by-value elements owe nothing. +fn emit_iter_elem_bind(w: &mut CodeWriter, inner: &Ty, elem: &RetPass) { + match elem { + RetPass::Void => unreachable!("an iterator element is never void"), + RetPass::String => { w.line("item := C.GoString(outItem)"); w.line("C.weaveffi_free_string(outItem)"); } - ElemFree::Bytes => { - if matches!(inner, Ty::Bytes | Ty::BorrowedBytes) { - w.line("item := wvCopyBuffer(outItem, outLen)"); - } else { - w.line("rItem := &wvReader{buf: wvCopyBuffer(outItem, outLen)}"); - w.line(format!("var item {}", go_type(inner))); - emit_buffer_read(w, "rItem", "item", inner, "Item", 0, prefix, module); - w.line("rItem.expectEnd()"); - } + RetPass::Bytes => { + w.line("item := wvCopyBuffer(outItem, outLen)"); + } + RetPass::Buffer => { + w.line("rItem := &wvReader{buf: wvCopyBuffer(outItem, outLen)}"); + w.line(format!("var item {}", go_type(inner))); + emit_buffer_read(w, "rItem", "item", inner, "Item", 0); + w.line("rItem.expectEnd()"); + } + RetPass::Object { .. } => { + w.line(format!("item := {}", go_adopt_expr(inner, "outItem"))); + } + RetPass::Direct => { + w.line(format!("item := {}", go_scalar_conv("outItem", inner))); } - ElemFree::None => match inner { - Ty::Bool => { - w.line("item := cToBool(outItem)"); - } - // Typed handles and interfaces are opaque pointers the consumer - // adopts, even though `elem_free` owes no runtime call for them. - Ty::TypedHandle(_) | Ty::Interface(_) => { - w.line(format!("item := {}", go_wrap_expr(inner, "outItem"))); - } - _ => { - let conv = go_scalar_conv("outItem", inner); - w.line(format!("item := {conv}")); - } - }, } } @@ -767,8 +847,8 @@ fn emit_iter_elem_bind(w: &mut CodeWriter, inner: &Ty, ef: &ElemFree, prefix: &s /// /// The producer iterator is launched lazily inside the returned closure, so /// an unused sequence allocates nothing on the producer side. One C `next` -/// call runs per consumer step, each yielded element is released per the -/// protocol's [`ElemFree`] plan after conversion, and the destroy runs +/// call runs per consumer step, each yielded element is received per the +/// protocol's element [`RetPass`] after conversion, and the destroy runs /// exactly once through a `defer` inside the closure, whether the sequence /// is exhausted, stops on an error, or is abandoned by an early `break`. #[allow(clippy::too_many_arguments)] @@ -782,12 +862,12 @@ fn render_iterator_fn( receiver: Option<&str>, err: ErrCtx, ) { - let proto = ib.protocol(f); + let proto = ib.protocol(f, module, prefix); let throws = matches!(proto.error, ErrorStrategy::Throws); let elem = &ib.elem; let elem_go = go_type(elem); - let item_ty = iter_out_item_type(elem, prefix, module); - let has_len = matches!(proto.elem_free, ElemFree::Bytes); + let item_ty = iter_out_item_type(ib, prefix); + let has_len = matches!(proto.elem, RetPass::Bytes | RetPass::Buffer); let zero = go_zero(elem); let go_params: Vec = f @@ -837,7 +917,8 @@ fn render_iterator_fn( // live at launch time and each range restages them. let mut pre = String::new(); let mut c_args: Vec = Vec::new(); - if receiver.is_some() { + if let Some(ty) = receiver { + pre.push_str(&receiver_guard(ty, "\t")); c_args.push("s.ptr".into()); } for p in &f.params { @@ -887,7 +968,7 @@ fn render_iterator_fn( w.block("if !ok {", "}", |w| { w.line("return"); }); - emit_iter_elem_bind(w, elem, &proto.elem_free, prefix, module); + emit_iter_elem_bind(w, elem, &proto.elem); let yield_call = if throws { "if !yield(item, nil) {" } else { @@ -905,11 +986,27 @@ fn render_iterator_fn( // ── Parameter and return marshalling ── +/// The statements every method opens with: reject a wrapper whose reference +/// was already released by `Close` (the ABI never accepts a null object +/// there, so a Go panic beats a producer-side abort), then pin the wrapper +/// so its finalizer cannot release the object while the call is in flight. +fn receiver_guard(ty: &str, indent: &str) -> String { + format!( + "{indent}if s.ptr == nil {{\n{indent}\tpanic(\"weaveffi: {ty} used after Close\")\n{indent}}}\n{indent}defer runtime.KeepAlive(s)\n" + ) +} + /// Emit the staging statements and C argument expressions for one Go -/// parameter, dispatching on the shared [`ArgPass`] contract. A buffered -/// parameter is packed into a `wvWriter` and passed as a borrowed -/// `(ptr, len)` pair; the C-owned encoding lives in Go memory kept alive for -/// the duration of the call by cgo's argument-pinning rules. +/// parameter, dispatching on the shared [`ArgPass`] contract. +/// +/// A buffered parameter is packed into a `wvWriter` and passed as a borrowed +/// `(ptr, len)` pair; the encoding lives in Go memory kept alive for the +/// duration of the call by cgo's argument-pinning rules. An object parameter +/// passes the wrapper's own pointer (borrowed; the wrapper keeps its +/// reference) and pins the wrapper with `runtime.KeepAlive` so its finalizer +/// cannot release the object mid-call; a nil `Interface?` stages a NULL +/// slot. A callback interface stores the implementation in a `cgo.Handle` +/// passed as `ctx` alongside the address of the interface's static vtable. fn emit_param( pre: &mut String, args: &mut Vec, @@ -950,8 +1047,8 @@ fn emit_param( args.push(pv); args.push(lv); } - // A borrowed object pointer; when nullable, nil stages a NULL slot. ArgPass::Object { slot, nullable } => { + w.line(format!("defer runtime.KeepAlive({name})")); if nullable { let cv = format!("c{}", name.to_upper_camel_case()); w.line(format!("var {cv} {}", cgo_slot_type(&slot.ty, prefix))); @@ -963,12 +1060,19 @@ fn emit_param( args.push(format!("{name}.ptr")); } } - ArgPass::Direct { .. } => match &p.ty { - Ty::Handle => args.push(format!("C.weaveffi_handle_t({name})")), - // A typed handle passes its wrapped opaque pointer by value. - Ty::TypedHandle(_) => args.push(format!("{name}.ptr")), - _ => args.push(c_scalar_conv(&name, &p.ty, prefix, module)), - }, + ArgPass::Callback { .. } => { + let Ty::CallbackInterface(cb) = &p.ty else { + unreachable!("callback family names a callback interface") + }; + let hv = format!("h{}", name.to_upper_camel_case()); + w.line(format!("{hv} := cgo.NewHandle({name})")); + args.push(format!("C.wvHandlePtr(C.uintptr_t({hv}))")); + args.push(format!( + "C.{}()", + vtable_accessor(&c_abi_struct_name(cb, module, prefix)) + )); + } + ArgPass::Direct { .. } => args.push(c_scalar_conv(&name, &p.ty, prefix, module)), } pre.push_str(&w.finish()); } @@ -1000,7 +1104,8 @@ fn emit_return_out_params( /// /// A buffered return is copied out of the producer-allocated buffer (which /// `wvCopyBuffer` releases with `weaveffi_free_bytes`), decoded, and checked -/// for trailing bytes. +/// for trailing bytes. An object return transfers one strong reference that +/// the wrapper adopts; a null `Interface?` adopts to nil. fn emit_return(out: &mut String, ty: &Ty, prefix: &str, module: &str, tail: &str) { let mut w = CodeWriter::tabs().with_depth(1); match plan::ret_pass(Some(ty), module, prefix) { @@ -1008,7 +1113,7 @@ fn emit_return(out: &mut String, ty: &Ty, prefix: &str, module: &str, tail: &str RetPass::Buffer => { w.line("rRes := &wvReader{buf: wvCopyBuffer(result, cOutLen)}"); w.line(format!("var goResult {}", go_type(ty))); - emit_buffer_read(&mut w, "rRes", "goResult", ty, "Res", 0, prefix, module); + emit_buffer_read(&mut w, "rRes", "goResult", ty, "Res", 0); w.line("rRes.expectEnd()"); w.line(format!("return goResult{tail}")); } @@ -1025,38 +1130,13 @@ fn emit_return(out: &mut String, ty: &Ty, prefix: &str, module: &str, tail: &str w.line("C.weaveffi_free_bytes(result, cOutLen)"); w.line(format!("return goResult{tail}")); } - // An owned object pointer the wrapper adopts; when nullable, a NULL - // return means none. - RetPass::Object { nullable, .. } => { - if nullable { - let Ty::Optional(inner) = ty else { - unreachable!("nullable object returns are optional interfaces") - }; - let Ty::Interface(n) = inner.as_ref() else { - unreachable!("every other optional is buffered") - }; - let g = go_local(n); - w.block("if result == nil {", "}", |w| { - w.line(format!("return nil{tail}")); - }); - w.line(format!("return &{g}{{ptr: result}}{tail}")); - } else { - w.line(format!("return {}{tail}", go_wrap_expr(ty, "result"))); - } + RetPass::Object { .. } => { + w.line(format!("return {}{tail}", go_adopt_expr(ty, "result"))); + } + RetPass::Direct => { + let conv = go_scalar_conv("result", ty); + w.line(format!("return {conv}{tail}")); } - RetPass::Direct => match ty { - Ty::Bool => { - w.line(format!("return cToBool(result){tail}")); - } - // A typed handle is a borrowed id wrapped without ownership. - Ty::TypedHandle(_) => { - w.line(format!("return {}{tail}", go_wrap_expr(ty, "result"))); - } - _ => { - let conv = go_scalar_conv("result", ty); - w.line(format!("return {conv}{tail}")); - } - }, } out.push_str(&w.finish()); } diff --git a/crates/weaveffi-gen-go/src/codec.rs b/crates/weaveffi-gen-go/src/codec.rs index ef463779..85cf8103 100644 --- a/crates/weaveffi-gen-go/src/codec.rs +++ b/crates/weaveffi-gen-go/src/codec.rs @@ -1,21 +1,24 @@ //! Value-buffer codec emitters: the Go statements serializing and decoding //! one value in the wire format. //! -//! Both emitters dispatch on the shared [`wire::classify`] classification, -//! so the non-obvious folds (handles as `u64` tokens, borrowed views like -//! their owned forms, records and rich enums through one user codec) are -//! decided centrally rather than re-derived from `Ty` here. +//! Both emitters dispatch on the shared [`Ty::wire`] classification, so the +//! non-obvious folds (objects as `u64` tokens carrying one strong reference, +//! records and rich enums through one user codec) are decided centrally +//! rather than re-derived from `Ty` here. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{Prim, WireType}; -use weaveffi_core::utils::c_abi_struct_name; -use crate::types::{go_local, go_type, handle_wrapper, optional_derefs}; +use crate::types::{go_local, go_type, optional_derefs, token_fn, untoken_fn}; /// Emit statements appending `expr` (a Go value of type `ty`) to the /// `wvWriter` named `writer`, following the wire format. `site` and `depth` /// uniquify the loop locals generated for nested lists and maps. +/// +/// An object is written as a token carrying a *fresh* strong reference: the +/// per-interface `wvToken{Name}` helper calls the interface's `_clone` symbol +/// and the wrapper keeps its own reference. pub(crate) fn emit_buffer_write( w: &mut CodeWriter, writer: &str, @@ -58,16 +61,8 @@ pub(crate) fn emit_buffer_write( WireType::Prim(Prim::F64) => { w.line(format!("{writer}.writeF64({expr})")); } - // Both handle flavors encode as one u64 token; only the Go-side - // representation differs (a bare int64 versus a wrapper pointer). - WireType::Handle(_) => { - if matches!(ty, Ty::TypedHandle(_)) { - w.line(format!( - "{writer}.writeU64(uint64(uintptr(unsafe.Pointer({expr}.ptr))))" - )); - } else { - w.line(format!("{writer}.writeU64(uint64({expr}))")); - } + WireType::Object(n) => { + w.line(format!("{writer}.writeU64({}({expr}))", token_fn(n))); } WireType::Prim(Prim::String) => { w.line(format!("{writer}.writeString({expr})")); @@ -120,7 +115,10 @@ pub(crate) fn emit_buffer_write( /// Emit statements decoding one value of type `ty` from the `wvReader` named /// `reader` and assigning it into the pre-declared destination `dst`. /// `site` and `depth` uniquify the locals generated for nested containers. -#[allow(clippy::too_many_arguments)] +/// +/// An object token is adopted into a new wrapper by the per-interface +/// `wvUntoken{Name}` helper; the wrapper's `Close` (or finalizer) releases +/// the reference the token carried. pub(crate) fn emit_buffer_read( w: &mut CodeWriter, reader: &str, @@ -128,8 +126,6 @@ pub(crate) fn emit_buffer_read( ty: &Ty, site: &str, depth: usize, - prefix: &str, - module: &str, ) { match ty.wire() { WireType::Prim(Prim::Bool) => { @@ -165,19 +161,8 @@ pub(crate) fn emit_buffer_read( WireType::Prim(Prim::F64) => { w.line(format!("{dst} = {reader}.readF64()")); } - // The u64 token decodes back into the Go representation: a bare - // int64 for `handle`, a wrapper around the C pointer for - // `handle`. - WireType::Handle(_) => { - if let Ty::TypedHandle(n) = ty { - let g = handle_wrapper(n); - let tag = c_abi_struct_name(n, module, prefix); - w.line(format!( - "{dst} = &{g}{{ptr: (*C.{tag})(unsafe.Pointer(uintptr({reader}.readU64())))}}" - )); - } else { - w.line(format!("{dst} = int64({reader}.readU64())")); - } + WireType::Object(n) => { + w.line(format!("{dst} = {}({reader}.readU64())", untoken_fn(n))); } WireType::Prim(Prim::String) => { w.line(format!("{dst} = {reader}.readString()")); @@ -195,7 +180,7 @@ pub(crate) fn emit_buffer_read( let o = format!("o{site}{depth}"); w.block(format!("if {reader}.readOptionFlag() {{"), "}", |w| { w.line(format!("var {o} {}", go_type(inner))); - emit_buffer_read(w, reader, &o, inner, site, depth + 1, prefix, module); + emit_buffer_read(w, reader, &o, inner, site, depth + 1); if optional_derefs(inner) { w.line(format!("{dst} = &{o}")); } else { @@ -209,16 +194,7 @@ pub(crate) fn emit_buffer_read( w.line(format!("{n} := {reader}.readLen()")); w.line(format!("{dst} = make([]{}, {n})", go_type(inner))); w.block(format!("for {i} := range {dst} {{"), "}", |w| { - emit_buffer_read( - w, - reader, - &format!("{dst}[{i}]"), - inner, - site, - depth + 1, - prefix, - module, - ); + emit_buffer_read(w, reader, &format!("{dst}[{i}]"), inner, site, depth + 1); }); } WireType::Map(k, v) => { @@ -232,9 +208,9 @@ pub(crate) fn emit_buffer_read( w.line(format!("{dst} = make(map[{gk}]{gv}, {n})")); w.block(format!("for {i} := 0; {i} < {n}; {i}++ {{"), "}", |w| { w.line(format!("var {kv} {gk}")); - emit_buffer_read(w, reader, &kv, k, site, depth + 1, prefix, module); + emit_buffer_read(w, reader, &kv, k, site, depth + 1); w.line(format!("var {vv} {gv}")); - emit_buffer_read(w, reader, &vv, v, site, depth + 1, prefix, module); + emit_buffer_read(w, reader, &vv, v, site, depth + 1); w.line(format!("{dst}[{kv}] = {vv}")); }); } diff --git a/crates/weaveffi-gen-go/src/entities.rs b/crates/weaveffi-gen-go/src/entities.rs index 5e97a630..e5af71c8 100644 --- a/crates/weaveffi-gen-go/src/entities.rs +++ b/crates/weaveffi-gen-go/src/entities.rs @@ -1,22 +1,58 @@ -//! Entity rendering: plain and rich enums, records, typed error domains, -//! typed-handle wrappers, and interface wrapper types. - -use std::collections::HashSet; +//! Entity rendering: plain and rich enums, records, typed error domains, and +//! interface (object) wrapper types. use heck::ToUpperCamelCase; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; -use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, CallShape, EnumBinding, ErrorBinding, InterfaceBinding, ModuleBinding, - StructBinding, + CallShape, EnumBinding, ErrorBinding, InterfaceBinding, ModuleBinding, StructBinding, }; -use weaveffi_core::utils::{c_abi_struct_name, local_type_name}; +use weaveffi_core::utils::local_type_name; use crate::calls::{render_async_function, render_function, ErrCtx}; use crate::codec::{emit_buffer_read, emit_buffer_write}; use crate::docs::emit_doc; -use crate::types::{go_str, go_type, handle_wrapper}; +use crate::types::{adopt_fn, go_str, go_type, token_fn, untoken_fn}; + +/// One `name rest` line inside a struct or const block, with its optional +/// doc comment, for [`emit_aligned`]. +struct AlignedEntry { + doc: Option, + name: String, + rest: String, +} + +/// Emit `entries` as the body of a struct or const block the way gofmt lays +/// it out: gofmt aligns the second column of consecutive lines and starts a +/// fresh column after a comment line, so each run of entries following a +/// documented one (or the start) is padded to the widest name in that run. +fn emit_aligned(w: &mut CodeWriter, entries: &[AlignedEntry]) { + let docs: Vec = entries + .iter() + .map(|e| { + let mut d = String::new(); + emit_doc(&mut d, &e.doc, "\t", Some(&e.name)); + d + }) + .collect(); + let mut i = 0; + while i < entries.len() { + let mut j = i + 1; + while j < entries.len() && docs[j].is_empty() { + j += 1; + } + let width = entries[i..j] + .iter() + .map(|e| e.name.len()) + .max() + .unwrap_or(0); + for (e, d) in entries[i..j].iter().zip(&docs[i..j]) { + w.raw(d.clone()); + w.line(format!("{: Option { /// negative codes (generic error, producer panic, marshalling failure) can /// never match a `case` arm here: they fall through to the generic /// [`ERROR_BRAND`] fallback rather than a typed domain case. -pub(crate) fn render_error( - out: &mut String, - module: &ModuleBinding, - eb: &ErrorBinding, - prefix: &str, -) { +pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorBinding) { let stem = eb.owner_path.to_upper_camel_case(); let ty = &eb.type_name; let dotted = module.segments.join("."); @@ -79,14 +110,16 @@ pub(crate) fn render_error( w.line(format!("// {ty} codes.")); w.block("const (", ")", |w| { - for c in &eb.codes { - let cname = format!("{ty}{}", c.name.to_upper_camel_case()); - let doc = c.doc.clone().unwrap_or_else(|| c.message.clone()); - let mut cd = String::new(); - emit_doc(&mut cd, &Some(doc), "\t", Some(&cname)); - w.raw(cd); - w.line(format!("{cname} int32 = {}", c.value)); - } + let entries: Vec = eb + .codes + .iter() + .map(|c| AlignedEntry { + doc: Some(c.doc.clone().unwrap_or_else(|| c.message.clone())), + name: format!("{ty}{}", c.name.to_upper_camel_case()), + rest: format!("int32 = {}", c.value), + }) + .collect(); + emit_aligned(w, &entries); }); w.blank(); @@ -101,13 +134,16 @@ pub(crate) fn render_error( "// {pname} carries the structured fields of {cname}." )); w.block(format!("type {pname} struct {{"), "}", |w| { - for f in &c.fields { - let fname = f.name.to_upper_camel_case(); - let mut fd = String::new(); - emit_doc(&mut fd, &f.doc, "\t", Some(&fname)); - w.raw(fd); - w.line(format!("{fname} {}", go_type(&f.ty))); - } + let entries: Vec = c + .fields + .iter() + .map(|f| AlignedEntry { + doc: f.doc.clone(), + name: f.name.to_upper_camel_case(), + rest: go_type(&f.ty), + }) + .collect(); + emit_aligned(w, &entries); }); w.blank(); } @@ -140,16 +176,7 @@ pub(crate) fn render_error( w.line(format!("p := &{pname}{{}}")); for f in &c.fields { let fname = f.name.to_upper_camel_case(); - emit_buffer_read( - w, - "r", - &format!("p.{fname}"), - &f.ty, - &fname, - 0, - prefix, - &eb.owner_path, - ); + emit_buffer_read(w, "r", &format!("p.{fname}"), &f.ty, &fname, 0); } w.line("r.expectEnd()"); w.line("e.Payload = p"); @@ -184,13 +211,16 @@ pub(crate) fn render_enum(out: &mut String, e: &EnumBinding) { w.line(format!("type {name} int32")); w.blank(); w.block("const (", ")", |w| { - for v in &e.variants { - let vname = format!("{name}{}", v.name.to_upper_camel_case()); - let mut vd = String::new(); - emit_doc(&mut vd, &v.doc, "\t", Some(&vname)); - w.raw(vd); - w.line(format!("{vname} {name} = {}", v.value)); - } + let entries: Vec = e + .variants + .iter() + .map(|v| AlignedEntry { + doc: v.doc.clone(), + name: format!("{name}{}", v.name.to_upper_camel_case()), + rest: format!("{name} = {}", v.value), + }) + .collect(); + emit_aligned(w, &entries); }); w.blank(); out.push_str(&w.finish()); @@ -204,7 +234,7 @@ pub(crate) fn render_enum(out: &mut String, e: &EnumBinding) { /// values only cross the ABI inside value buffers. /// /// A plain C-style enum is skipped here (it is handled by [`render_enum`]). -pub(crate) fn render_rich_enum(out: &mut String, prefix: &str, module: &str, e: &EnumBinding) { +pub(crate) fn render_rich_enum(out: &mut String, e: &EnumBinding) { if !e.is_rich() { return; } @@ -239,13 +269,16 @@ pub(crate) fn render_rich_enum(out: &mut String, prefix: &str, module: &str, e: w.line(format!("type {vn} struct{{}}")); } else { w.block(format!("type {vn} struct {{"), "}", |w| { - for f in &v.fields { - let fname = f.name.to_upper_camel_case(); - let mut fd = String::new(); - emit_doc(&mut fd, &f.doc, "\t", Some(&fname)); - w.raw(fd); - w.line(format!("{fname} {}", go_type(&f.ty))); - } + let entries: Vec = v + .fields + .iter() + .map(|f| AlignedEntry { + doc: f.doc.clone(), + name: f.name.to_upper_camel_case(), + rest: go_type(&f.ty), + }) + .collect(); + emit_aligned(w, &entries); }); } w.blank(); @@ -301,16 +334,7 @@ pub(crate) fn render_rich_enum(out: &mut String, prefix: &str, module: &str, e: for f in &v.fields { let fname = f.name.to_upper_camel_case(); let site = format!("{}{fname}", v.name.to_upper_camel_case()); - emit_buffer_read( - w, - "r", - &format!("x.{fname}"), - &f.ty, - &site, - 0, - prefix, - module, - ); + emit_buffer_read(w, "r", &format!("x.{fname}"), &f.ty, &site, 0); } w.line("return x"); } @@ -332,8 +356,10 @@ pub(crate) fn render_rich_enum(out: &mut String, prefix: &str, module: &str, e: /// Render one record as a plain Go value struct with exported, typed fields, /// plus its pack/unpack pair serializing the fields in declaration (wire) /// order. Records have no C symbols: no create, no destroy, no getters, no -/// builders; instances only cross the ABI inside value buffers. -pub(crate) fn render_struct(out: &mut String, prefix: &str, module: &str, s: &StructBinding) { +/// builders; instances only cross the ABI inside value buffers. An interface +/// field holds a wrapper pointer and crosses as an object token (see +/// [`crate::codec`]). +pub(crate) fn render_struct(out: &mut String, s: &StructBinding) { let name = s.name.to_upper_camel_case(); let mut w = CodeWriter::tabs(); @@ -341,13 +367,16 @@ pub(crate) fn render_struct(out: &mut String, prefix: &str, module: &str, s: &St emit_doc(&mut d, &s.doc, "", Some(&name)); w.raw(d); w.block(format!("type {name} struct {{"), "}", |w| { - for f in &s.fields { - let fname = f.name.to_upper_camel_case(); - let mut fd = String::new(); - emit_doc(&mut fd, &f.doc, "\t", Some(&fname)); - w.raw(fd); - w.line(format!("{fname} {}", go_type(&f.ty))); - } + let entries: Vec = s + .fields + .iter() + .map(|f| AlignedEntry { + doc: f.doc.clone(), + name: f.name.to_upper_camel_case(), + rest: go_type(&f.ty), + }) + .collect(); + emit_aligned(w, &entries); }); w.blank(); @@ -374,16 +403,7 @@ pub(crate) fn render_struct(out: &mut String, prefix: &str, module: &str, s: &St w.line(format!("var v {name}")); for f in &s.fields { let fname = f.name.to_upper_camel_case(); - emit_buffer_read( - w, - "r", - &format!("v.{fname}"), - &f.ty, - &fname, - 0, - prefix, - module, - ); + emit_buffer_read(w, "r", &format!("v.{fname}"), &f.ty, &fname, 0); } w.line("return v"); }, @@ -392,9 +412,17 @@ pub(crate) fn render_struct(out: &mut String, prefix: &str, module: &str, s: &St out.push_str(&w.finish()); } -/// Render one interface as an opaque-object wrapper: a struct owning the -/// `*C.{c_tag}` handle, freed by an explicit `Close` (idempotent, nils the -/// pointer). +/// Render one interface as a reference-counted object wrapper: a struct +/// holding one strong reference (`*C.{c_tag}`) released by an explicit +/// `Close` (idempotent, nils the pointer) or, as a backstop, by a +/// `runtime.SetFinalizer` hook, so `destroy` runs exactly once per wrapper. +/// +/// Three private helpers accompany the type: `wvAdopt{Name}` wraps an owned +/// pointer coming back from the producer (a return, an async result, an +/// iterator element, a callback-method argument) and returns nil for a null +/// pointer, so `Interface?` needs no separate path; `wvToken{Name}` clones +/// the wrapper's reference into a value-buffer object token; and +/// `wvUntoken{Name}` adopts the reference a token carries. /// /// Constructors become package-level factory functions named /// `{PascalCtor}{Type}` (`new` gives `NewStore`, `open` gives `OpenStore`); @@ -411,15 +439,84 @@ pub(crate) fn render_interface( ) { let name = local_type_name(&iface.name).to_upper_camel_case(); let c_tag = &iface.c_tag; + let adopt = adopt_fn(&iface.name); + let token = token_fn(&iface.name); + let untoken = untoken_fn(&iface.name); let mut w = CodeWriter::tabs(); let mut d = String::new(); emit_doc(&mut d, &iface.doc, "", Some(&name)); - w.raw(d); + if d.is_empty() { + w.line(format!( + "// {name} is a reference-counted object owned by the native library." + )); + } else { + w.raw(d); + w.line("//"); + } + w.line("// Each wrapper holds one strong reference; Close releases it, and a"); + w.line("// finalizer releases it if the wrapper is garbage collected first."); + if let Some(msg) = &iface.deprecated { + w.line("//"); + w.line(format!("// Deprecated: {msg}")); + } w.block(format!("type {name} struct {{"), "}", |w| { w.line(format!("ptr *C.{c_tag}")); }); w.blank(); + + w.line(format!( + "// {adopt} adopts one owned strong reference into a new wrapper. A null" + )); + w.line("// pointer adopts to nil."); + w.block( + format!("func {adopt}(ptr *C.{c_tag}) *{name} {{"), + "}", + |w| { + w.block("if ptr == nil {", "}", |w| { + w.line("return nil"); + }); + w.line(format!("s := &{name}{{ptr: ptr}}")); + w.line(format!("runtime.SetFinalizer(s, (*{name}).Close)")); + w.line("return s"); + }, + ); + w.blank(); + + w.line(format!( + "// {token} clones o's reference into a value-buffer object token. The" + )); + w.line("// wrapper keeps its own reference; the token carries the new one."); + w.block(format!("func {token}(o *{name}) uint64 {{"), "}", |w| { + w.block("if o == nil || o.ptr == nil {", "}", |w| { + w.line(format!( + "panic(\"weaveffi: nil or closed {name} cannot be encoded in a non-optional position\")" + )); + }); + w.line(format!( + "return uint64(uintptr(unsafe.Pointer(C.{}(o.ptr))))", + iface.clone_symbol + )); + }); + w.blank(); + + w.line(format!( + "// {untoken} adopts the strong reference carried by a value-buffer object" + )); + w.line("// token."); + w.block( + format!("func {untoken}(token uint64) *{name} {{"), + "}", + |w| { + w.block("if token == 0 {", "}", |w| { + w.line("panic(\"weaveffi: malformed value buffer: null object token\")"); + }); + w.line(format!( + "return {adopt}((*C.{c_tag})(C.wvHandlePtr(C.uintptr_t(token))))" + )); + }, + ); + w.blank(); out.push_str(&w.finish()); for c in &iface.constructors { @@ -449,100 +546,16 @@ pub(crate) fn render_interface( } let mut w = CodeWriter::tabs(); + w.line("// Close releases the wrapper's strong reference. Calling it more than once"); + w.line("// is harmless; the object itself is dropped by the native library when its"); + w.line("// last reference (from any wrapper, record, or producer) is released."); w.block(format!("func (s *{name}) Close() {{"), "}", |w| { w.block("if s.ptr != nil {", "}", |w| { w.line(format!("C.{}(s.ptr)", iface.destroy_symbol)); w.line("s.ptr = nil"); + w.line("runtime.SetFinalizer(s, nil)"); }); }); w.blank(); out.push_str(&w.finish()); } - -/// Collect every typed-handle referent reachable from the model's type -/// positions, deduplicated by wrapper name in first-occurrence order (which -/// keeps the emitted set deterministic). -pub(crate) fn collect_typed_handles(model: &BindingModel, prefix: &str) -> Vec<(String, String)> { - fn visit( - ty: &Ty, - module: &str, - prefix: &str, - seen: &mut HashSet, - out: &mut Vec<(String, String)>, - ) { - match ty { - Ty::TypedHandle(n) => { - let name = handle_wrapper(n); - if seen.insert(name.clone()) { - out.push((name, c_abi_struct_name(n, module, prefix))); - } - } - Ty::Optional(i) | Ty::List(i) | Ty::Iterator(i) => { - visit(i, module, prefix, seen, out); - } - Ty::Map(k, v) => { - visit(k, module, prefix, seen, out); - visit(v, module, prefix, seen, out); - } - _ => {} - } - } - - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for m in &model.modules { - for s in &m.structs { - for f in &s.fields { - visit(&f.ty, &m.path, prefix, &mut seen, &mut out); - } - } - for e in &m.enums { - for v in &e.variants { - for f in &v.fields { - visit(&f.ty, &m.path, prefix, &mut seen, &mut out); - } - } - } - if let Some(eb) = &m.error { - if eb.declared_here { - for c in &eb.codes { - for f in &c.fields { - visit(&f.ty, &m.path, prefix, &mut seen, &mut out); - } - } - } - } - for cb in &m.callbacks { - for p in &cb.params { - visit(&p.ty, &m.path, prefix, &mut seen, &mut out); - } - } - for f in m.callables() { - for p in &f.params { - visit(&p.ty, &m.path, prefix, &mut seen, &mut out); - } - if let Some(ret) = &f.ret { - visit(ret, &m.path, prefix, &mut seen, &mut out); - } - } - } - out -} - -/// Render one wrapper struct per typed-handle referent. A typed handle is a -/// borrowed opaque id with no destroy symbol, so the wrapper carries no -/// `Close`. -pub(crate) fn render_typed_handles(out: &mut String, handles: &[(String, String)]) { - let mut w = CodeWriter::tabs(); - for (name, tag) in handles { - w.line(format!( - "// {name} is a typed handle naming a producer-owned resource. It wraps" - )); - w.line("// the opaque C pointer and owes no release call."); - w.block(format!("type {name} struct {{"), "}", |w| { - w.line(format!("ptr *C.{tag}")); - }); - w.blank(); - } - out.push_str(&w.finish()); -} diff --git a/crates/weaveffi-gen-go/src/lib.rs b/crates/weaveffi-gen-go/src/lib.rs index ab3c41b6..fe0dd548 100644 --- a/crates/weaveffi-gen-go/src/lib.rs +++ b/crates/weaveffi-gen-go/src/lib.rs @@ -1,14 +1,23 @@ -//! Go (CGo) binding generator for WeaveFFI. +//! Go (cgo) binding generator for WeaveFFI. //! -//! Emits a Go module (`go.mod` + package) with CGo bindings over the C -//! ABI exposed by the underlying cdylib. Implements [`LanguageBackend`]; -//! the shared driver bridges it into the generator pipeline. +//! Emits a Go module (`go.mod` + package) with cgo bindings over the C ABI +//! (revision 2) exposed by the underlying cdylib. Implements +//! [`LanguageBackend`]; the shared driver bridges it into the generator +//! pipeline. //! //! Records, rich enums, optionals, lists, and maps are value types that //! cross the C ABI serialized in the WeaveFFI value-buffer format (one //! `const uint8_t*` + `size_t` pair). The generated package carries a small //! private writer/reader implementing the wire format, plus one pack and one //! unpack function per record and rich enum. +//! +//! Interfaces are reference-counted objects: each Go wrapper holds one strong +//! reference released by `Close` or, as a backstop, by a finalizer, and an +//! object inside a value buffer crosses as a cloned-reference token. +//! Callback interfaces are Go `interface` types the consumer implements; an +//! implementation crosses as a `cgo.Handle` plus the address of one static +//! vtable per interface, filled with exported Go trampolines that recover +//! panics into the producer's error slot. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] @@ -27,31 +36,32 @@ use heck::ToUpperCamelCase; use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::Ty; -use weaveffi_core::model::{BindingModel, CallShape}; +use weaveffi_core::model::{ + BindingModel, CallShape, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, + InterfaceBinding, ModuleBinding, StructBinding, Ty, +}; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; -use weaveffi_core::plan::{elem_free, ElemFree}; +use weaveffi_core::plan::{elem_free, Free}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, wrapper_name, CommentStyle}; use crate::calls::{ - collect_trampoline_externs, render_async_function, render_callback_trampoline, render_function, - render_listener_api, ErrCtx, + collect_preamble_decls, render_async_function, render_callback_interface, render_function, + ErrCtx, }; use crate::entities::{ - collect_typed_handles, domain_stem, render_enum, render_error, render_interface, - render_rich_enum, render_struct, render_typed_handles, + domain_stem, render_enum, render_error, render_interface, render_rich_enum, render_struct, }; use crate::package::{package_files, render_go_mod, render_readme}; use crate::runtime::{ - render_abi_version_check, render_bool_helpers, render_buffer_runtime, render_callback_registry, - render_error_infra, + render_abi_version_check, render_bool_helpers, render_buffer_runtime, render_error_infra, + render_foreign_error, }; /// Per-target configuration for [`GoGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct GoConfig { /// Go module path written to `go.mod` (default `"weaveffi"`). pub module_path: Option, @@ -99,7 +109,7 @@ impl GoConfig { } } -/// Go backend: emits a CGo package (`weaveffi.go`, `go.mod`, and a README) +/// Go backend: emits a cgo package (`weaveffi.go`, `go.mod`, and a README) /// binding the C ABI exposed by the underlying cdylib. pub struct GoGenerator; @@ -110,7 +120,7 @@ impl LanguageBackend for GoGenerator { "go" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -118,6 +128,76 @@ impl LanguageBackend for GoGenerator { config.prefix() } + fn render_enum(&self, out: &mut String, e: &EnumBinding, _config: &Self::Config) { + // A plain C-style enum becomes an `int32` + constants; a rich + // (algebraic) enum becomes a sealed sum type. Each renderer skips + // the other kind. + render_enum(out, e); + render_rich_enum(out, e); + } + + fn render_struct( + &self, + out: &mut String, + _module: &ModuleBinding, + s: &StructBinding, + _config: &Self::Config, + ) { + render_struct(out, s); + } + + fn render_error( + &self, + out: &mut String, + module: &ModuleBinding, + e: &ErrorBinding, + _config: &Self::Config, + ) { + // Emitted once, in the declaring module; inheriting submodules + // reference the ancestor's type through `wvMap{Stem}`. + render_error(out, module, e); + } + + fn render_callback_interface( + &self, + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + config: &Self::Config, + ) { + render_callback_interface(out, config.prefix(), module, cb); + } + + fn render_interface( + &self, + out: &mut String, + module: &ModuleBinding, + i: &InterfaceBinding, + config: &Self::Config, + ) { + let stem = domain_stem(module); + render_interface(out, config.prefix(), module, i, stem.as_deref()); + } + + fn render_function( + &self, + out: &mut String, + module: &ModuleBinding, + f: &FnBinding, + config: &Self::Config, + ) { + let go_name = + wrapper_name(&module.path, &f.name, config.strip_module_prefix).to_upper_camel_case(); + let stem = domain_stem(module); + let err = ErrCtx::of(f, stem.as_deref()); + let prefix = config.prefix(); + if let CallShape::Async(ab) = &f.shape { + render_async_function(out, prefix, &module.path, f, ab, &go_name, None, err); + } else { + render_function(out, prefix, &module.path, f, &go_name, None, err); + } + } + fn files( &self, api: &ResolvedApi, @@ -128,16 +208,7 @@ impl LanguageBackend for GoGenerator { let dir = out_dir.join("go"); let input_basename = config.input_basename(); vec![ - OutputFile::new( - dir.join("weaveffi.go"), - render_go( - api, - model, - config.prefix(), - config.strip_module_prefix, - input_basename, - ), - ), + OutputFile::new(dir.join("weaveffi.go"), render_go(api, model, config)), OutputFile::new( dir.join("go.mod"), render_go_mod( @@ -184,13 +255,16 @@ fn ret_direct_bool(ty: &Ty) -> bool { struct Imports { /// `iter` (lazy sequences returned by `iter` functions). iter: bool, - /// `unsafe` (pointer staging for strings/bytes/buffers, callback - /// contexts). + /// `unsafe` (pointer staging for strings/bytes/buffers, object tokens, + /// callback contexts). unsafe_ptr: bool, /// The `boolToC`/`cToBool` helpers. bool_helpers: bool, - /// `sync` (the callback registry mutex). - sync: bool, + /// `runtime` (`SetFinalizer` and `KeepAlive` on object wrappers). + runtime: bool, + /// `runtime/cgo` (`cgo.Handle` for callback-interface implementations + /// and async completion channels). + cgo: bool, /// The shared error plumbing: the /// [`ERROR_BRAND`](weaveffi_core::errors::ERROR_BRAND) type plus the /// `wvTakeError`/`wvBrandError`/`wvTrap` helpers. @@ -198,6 +272,13 @@ struct Imports { /// The value-buffer runtime (`wvWriter`/`wvReader` and buffer copy /// helpers), pulling in `encoding/binary`, `math`, and `unicode/utf8`. buffer_runtime: bool, + /// The `wvForeignError` reporter used by callback-interface trampolines. + foreign_error: bool, + /// The `wvHandlePtr` preamble helper that widens a `cgo.Handle` or an + /// object token into the `void*` the ABI carries. Doing the integer to + /// pointer conversion in C keeps `go vet` from flagging the generated + /// file for a "possible misuse of unsafe.Pointer". + handle_ptr: bool, } /// Scan the lowered model for everything [`Imports`] tracks. Interface @@ -207,14 +288,13 @@ fn scan_imports(model: &BindingModel) -> Imports { let mut any_callable = false; let mut has_async = false; let mut has_iter = false; - let mut has_listeners = false; let mut has_domain = false; - let mut any_callbacks = false; let mut bool_helpers = false; let mut buffer_runtime = false; + let has_interfaces = model.has_interfaces(); + let has_callback_interfaces = model.has_callback_interfaces(); for m in &model.modules { - has_listeners |= !m.listeners.is_empty(); has_domain |= m.declares_error(); // Records and rich enums always carry pack/unpack functions; a // declared domain with payload fields decodes them through a reader. @@ -235,13 +315,15 @@ fn scan_imports(model: &BindingModel) -> Imports { } if let CallShape::Iterator(ib) = &f.shape { // Bytes and buffered elements copy through wvCopyBuffer. - buffer_runtime |= matches!(elem_free(&ib.elem), ElemFree::Bytes); + buffer_runtime |= matches!(elem_free(&ib.elem), Free::Bytes); } } - for cb in &m.callbacks { - any_callbacks = true; - buffer_runtime |= cb.params.iter().any(|p| p.ty.is_buffered()); - bool_helpers |= cb.params.iter().any(|p| matches!(p.ty, Ty::Bool)); + for cb in &m.callback_interfaces { + for meth in &cb.methods { + buffer_runtime |= meth.params.iter().any(|p| p.ty.is_buffered()); + bool_helpers |= meth.params.iter().any(|p| matches!(p.ty, Ty::Bool)); + bool_helpers |= matches!(meth.ret, Some(Ty::Bool)); + } } } @@ -250,30 +332,33 @@ fn scan_imports(model: &BindingModel) -> Imports { // needs it for the brand-error fallback of its mapping helper. let err_infra = any_callable || has_domain; // wvTakeError copies the payload through unsafe.Pointer; the buffer - // runtime copies C buffers; trampolines carry `void* context`. - let unsafe_ptr = err_infra || buffer_runtime || any_callbacks || has_listeners || has_async; + // runtime copies C buffers; object token helpers widen pointers; callback + // contexts and async trampolines carry `void*`. + let unsafe_ptr = + err_infra || buffer_runtime || has_interfaces || has_callback_interfaces || has_async; Imports { iter: has_iter, unsafe_ptr, bool_helpers, - sync: has_async || has_listeners, + runtime: has_interfaces, + cgo: has_callback_interfaces || has_async, err_infra, buffer_runtime, + foreign_error: has_callback_interfaces, + handle_ptr: has_interfaces || has_callback_interfaces || has_async, } } // ── Top-level rendering ── /// Render the complete generated Go source file: the cgo preamble, imports, -/// runtime prelude, and every module's entities and wrappers. -pub(crate) fn render_go( - api: &ResolvedApi, - model: &BindingModel, - prefix: &str, - strip_module_prefix: bool, - input_basename: &str, -) -> String { +/// runtime prelude, and every module's entities and wrappers in the canonical +/// member order (error domain, enums, structs, callback interfaces, +/// interfaces, functions). +pub(crate) fn render_go(api: &ResolvedApi, model: &BindingModel, config: &GoConfig) -> String { + let prefix = config.prefix(); + let input_basename = config.input_basename(); let imports = scan_imports(model); let mut out = render_prelude(CommentStyle::DoubleSlash, input_basename); @@ -285,22 +370,19 @@ pub(crate) fn render_go( let go_pkg = resolved.ident_name(); let link_name = resolved.ident_name(); - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - let has_async = model - .modules - .iter() - .any(|m| m.callables().any(|f| f.is_async)); - out.push_str(&format!("package {go_pkg}\n\n")); out.push_str("/*\n"); out.push_str(&format!("#cgo LDFLAGS: -l{link_name}\n")); out.push_str(&format!("#include \"{prefix}.h\"\n")); out.push_str("#include \n"); - // Forward declarations for the //export trampolines below. These must - // mirror the prototypes cgo emits into _cgo_export.h (const-free), and the - // preamble of a file using //export may only contain declarations. - for decl in collect_trampoline_externs(model, prefix) { + if imports.handle_ptr { + out.push_str("static void* wvHandlePtr(uintptr_t h) { return (void*)h; }\n"); + } + // Forward declarations for the //export trampolines below (mirroring the + // const-free prototypes cgo emits into _cgo_export.h) and the static + // vtable of each callback interface. + for decl in collect_preamble_decls(model, prefix) { out.push_str(&decl); out.push('\n'); } @@ -320,8 +402,11 @@ pub(crate) fn render_go( if imports.buffer_runtime { out.push_str("\t\"math\"\n"); } - if imports.sync { - out.push_str("\t\"sync\"\n"); + if imports.runtime { + out.push_str("\t\"runtime\"\n"); + } + if imports.cgo { + out.push_str("\t\"runtime/cgo\"\n"); } if imports.buffer_runtime { out.push_str("\t\"unicode/utf8\"\n"); @@ -345,55 +430,447 @@ pub(crate) fn render_go( render_buffer_runtime(&mut out); } - if has_async || has_listeners { - render_callback_registry(&mut out, has_listeners); + if imports.foreign_error { + render_foreign_error(&mut out); } - let handles = collect_typed_handles(model, prefix); - if !handles.is_empty() { - render_typed_handles(&mut out, &handles); + for m in &model.modules { + GoGenerator.emit_members(&mut out, m, config); } - for m in &model.modules { - let stem = domain_stem(m); - if let Some(eb) = &m.error { - // Emit the typed domain once, in its declaring module; inheriting - // submodules reference the ancestor's type through `wvMap{Stem}`. - if eb.declared_here { - render_error(&mut out, m, eb, prefix); - } - } - for e in &m.enums { - // A plain C-style enum becomes an `int32` + constants; a rich - // (algebraic) enum becomes a sealed sum type. Each renderer skips - // the other kind. - render_enum(&mut out, e); - render_rich_enum(&mut out, prefix, &m.path, e); - } - for s in &m.structs { - render_struct(&mut out, prefix, &m.path, s); - } - for i in &m.interfaces { - render_interface(&mut out, prefix, m, i, stem.as_deref()); - } - for cb in &m.callbacks { - render_callback_trampoline(&mut out, prefix, &m.path, cb); + // Exactly one blank line before the trailer keeps the file gofmt-clean. + out.truncate(out.trim_end().len()); + out.push_str("\n\n"); + out.push_str(&render_trailer(CommentStyle::DoubleSlash, "weaveffi.go")); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, Function, InterfaceDef, Module, Param, StructDef, StructField, + TypeRef, + }; + + fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, } - for l in &m.listeners { - render_listener_api(&mut out, m, l, strip_module_prefix); + } + + fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, } - for f in &m.functions { - let go_name = wrapper_name(&m.path, &f.name, strip_module_prefix).to_upper_camel_case(); - let err = ErrCtx::of(f, stem.as_deref()); - if let CallShape::Async(ab) = &f.shape { - render_async_function(&mut out, prefix, &m.path, f, ab, &go_name, None, err); - } else { - render_function(&mut out, prefix, &m.path, f, &go_name, None, err); - } + } + + fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, } } - out.push('\n'); - out.push_str(&render_trailer(CommentStyle::DoubleSlash, "weaveffi.go")); - out + fn named(n: &str) -> TypeRef { + TypeRef::Named(n.into()) + } + + fn optional(t: TypeRef) -> TypeRef { + TypeRef::Optional(Box::new(t)) + } + + /// One module exercising every revision-2 shape: an interface with a + /// constructor and a method, `Interface?` in and out, a record with + /// `Interface` and `[Interface]` fields, an iterator of interfaces, a + /// callback interface taking a string, an i32, a record, and an object + /// (one method returning `bool`, one returning void), and a function + /// taking that callback interface. + fn fixture() -> ResolvedApi { + let module = Module { + name: "bus".into(), + doc: None, + functions: vec![ + func( + "pick", + vec![param("preferred", optional(named("Ticker")))], + Some(optional(named("Ticker"))), + ), + func( + "all_tickers", + vec![], + Some(TypeRef::Iterator(Box::new(named("Ticker")))), + ), + func( + "subscribe", + vec![param("subscriber", named("Subscriber"))], + None, + ), + Function { + r#async: true, + ..func( + "fetch", + vec![param("id", TypeRef::I32)], + Some(named("Ticker")), + ) + }, + ], + interfaces: vec![InterfaceDef { + name: "Ticker".into(), + doc: Some("A ticking counter.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("start", TypeRef::I64)], None)], + methods: vec![func("value", vec![], Some(TypeRef::I64))], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Subscriber".into(), + doc: Some("Receives bus events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("envelope", named("Envelope")), + ], + None, + ), + func( + "on_ticker", + vec![param("ticker", named("Ticker"))], + Some(TypeRef::Bool), + ), + ], + }], + structs: vec![StructDef { + name: "Envelope".into(), + doc: None, + deprecated: None, + fields: vec![ + field("topic", TypeRef::StringUtf8), + field("primary", named("Ticker")), + field("others", TypeRef::List(Box::new(named("Ticker")))), + ], + }], + enums: vec![], + errors: None, + modules: vec![], + }; + ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![module], + }) + } + + fn render(config: &GoConfig) -> String { + let api = fixture(); + let model = BindingModel::build(&api, config.prefix()); + render_go(&api, &model, config) + } + + fn assert_has(src: &str, needle: &str) { + assert!(src.contains(needle), "missing `{needle}` in:\n{src}"); + } + + #[test] + fn preamble_declares_trampolines_and_one_static_vtable() { + let src = render(&GoConfig::default()); + assert_has(&src, "#include \"weaveffi.h\""); + assert_has( + &src, + "extern void goWv_weaveffi_bus_Subscriber_on_message(void* ctx, char* text, int32_t weight, uint8_t* envelope_ptr, size_t envelope_len, weaveffi_error* out_err);", + ); + assert_has( + &src, + "extern bool goWv_weaveffi_bus_Subscriber_on_ticker(void* ctx, weaveffi_bus_Ticker* ticker, weaveffi_error* out_err);", + ); + assert_has( + &src, + "extern void goWv_weaveffi_bus_Subscriber_free(void* ctx);", + ); + assert_has( + &src, + "static const weaveffi_bus_Subscriber_vtable wvVtable_weaveffi_bus_Subscriber = {", + ); + // The const-carrying entry is cast back to the vtable's field type; + // the const-free one and `free` are used directly. + assert_has( + &src, + "(void (*)(void*, const char*, int32_t, const uint8_t*, size_t, weaveffi_error*))goWv_weaveffi_bus_Subscriber_on_message,", + ); + assert_has(&src, " goWv_weaveffi_bus_Subscriber_on_ticker,"); + assert_has(&src, " goWv_weaveffi_bus_Subscriber_free,\n};"); + assert_eq!(src.matches("wvVtable_weaveffi_bus_Subscriber =").count(), 1); + // A `static` table has no external linkage for cgo to import, so a + // static accessor hands its address to Go. + assert_has( + &src, + "static const weaveffi_bus_Subscriber_vtable* wvVtablePtr_weaveffi_bus_Subscriber(void) { return &wvVtable_weaveffi_bus_Subscriber; }", + ); + assert!( + !src.contains("&C.wvVtable_"), + "Go must not take a static C variable's address directly" + ); + // The async completion trampoline is declared too. + assert_has( + &src, + "extern void goWv_weaveffi_bus_fetch_callback(void* context, weaveffi_error* err, weaveffi_bus_Ticker* result);", + ); + // Imports needed by objects and callbacks. + assert_has(&src, "\t\"runtime\"\n"); + assert_has(&src, "\t\"runtime/cgo\"\n"); + assert_has(&src, "\t\"unsafe\"\n"); + assert!(!src.contains("\"sync\""), "the registry mutex is gone"); + } + + #[test] + fn callback_interface_renders_go_interface_and_trampolines() { + let src = render(&GoConfig::default()); + assert_has(&src, "type Subscriber interface {"); + assert_has( + &src, + "\tOnMessage(text string, weight int32, envelope Envelope)\n", + ); + assert_has(&src, "\tOnTicker(ticker *Ticker) bool\n"); + + // The void-returning trampoline: borrowed string and buffer, direct + // i32, recover into error_set with -4. + assert_has( + &src, + "//export goWv_weaveffi_bus_Subscriber_on_message\nfunc goWv_weaveffi_bus_Subscriber_on_message(ctx unsafe.Pointer, text *C.char, weight C.int32_t, envelope_ptr *C.uint8_t, envelope_len C.size_t, out_err *C.weaveffi_error) {", + ); + assert_has( + &src, + "impl := cgo.Handle(uintptr(ctx)).Value().(Subscriber)", + ); + assert_has(&src, "wvForeignError(out_err, r)"); + assert_has(&src, "C.weaveffi_error_set(outErr, -4, msg)"); + assert_has(&src, "arg0 = C.GoString(text)"); + assert_has(&src, "arg1 := int32(weight)"); + assert_has( + &src, + "rArg2 := &wvReader{buf: wvBorrowBuffer(envelope_ptr, envelope_len)}", + ); + assert_has(&src, "arg2 = wvUnpackEnvelope(rArg2)"); + assert_has(&src, "impl.OnMessage(arg0, arg1, arg2)\n"); + assert!( + !src.contains("C.weaveffi_free_bytes(envelope_ptr"), + "borrowed callback buffers are never freed" + ); + + // The bool-returning trampoline adopts its object argument and + // writes a direct return. + assert_has( + &src, + "func goWv_weaveffi_bus_Subscriber_on_ticker(ctx unsafe.Pointer, ticker *C.weaveffi_bus_Ticker, out_err *C.weaveffi_error) (ret C._Bool) {", + ); + assert_has(&src, "arg0 := wvAdoptTicker(ticker)"); + assert_has(&src, "ret = boolToC(impl.OnTicker(arg0))"); + + // `free` deletes the handle. + assert_has( + &src, + "//export goWv_weaveffi_bus_Subscriber_free\nfunc goWv_weaveffi_bus_Subscriber_free(ctx unsafe.Pointer) {\n\tcgo.Handle(uintptr(ctx)).Delete()\n}", + ); + + // Passing an implementation: handle as ctx, static vtable address. + assert_has(&src, "func Subscribe(subscriber Subscriber) {"); + assert_has(&src, "hSubscriber := cgo.NewHandle(subscriber)"); + assert_has( + &src, + "C.weaveffi_bus_subscribe(C.wvHandlePtr(C.uintptr_t(hSubscriber)), C.wvVtablePtr_weaveffi_bus_Subscriber(), &cErr)", + ); + } + + #[test] + fn interface_wrapper_adopts_clones_and_destroys_once() { + let src = render(&GoConfig::default()); + assert_has( + &src, + "type Ticker struct {\n\tptr *C.weaveffi_bus_Ticker\n}", + ); + assert_has( + &src, + "func wvAdoptTicker(ptr *C.weaveffi_bus_Ticker) *Ticker {\n\tif ptr == nil {\n\t\treturn nil\n\t}\n\ts := &Ticker{ptr: ptr}\n\truntime.SetFinalizer(s, (*Ticker).Close)\n\treturn s\n}", + ); + assert_has( + &src, + "func (s *Ticker) Close() {\n\tif s.ptr != nil {\n\t\tC.weaveffi_bus_Ticker_destroy(s.ptr)\n\t\ts.ptr = nil\n\t\truntime.SetFinalizer(s, nil)\n\t}\n}", + ); + // Token helpers use clone for writing and adopt for reading. + assert_has( + &src, + "return uint64(uintptr(unsafe.Pointer(C.weaveffi_bus_Ticker_clone(o.ptr))))", + ); + assert_has( + &src, + "func wvUntokenTicker(token uint64) *Ticker {\n\tif token == 0 {", + ); + assert_has( + &src, + "return wvAdoptTicker((*C.weaveffi_bus_Ticker)(C.wvHandlePtr(C.uintptr_t(token))))", + ); + + // Constructor and method: a returned object is adopted, the receiver + // is kept alive across the call. + assert_has(&src, "func NewTicker(start int64) *Ticker {"); + assert_has(&src, "return wvAdoptTicker(result)"); + assert_has( + &src, + "func (s *Ticker) Value() int64 {\n\tif s.ptr == nil {\n\t\tpanic(\"weaveffi: Ticker used after Close\")\n\t}\n\tdefer runtime.KeepAlive(s)\n\tvar cErr C.weaveffi_error\n\tresult := C.weaveffi_bus_Ticker_value(s.ptr, &cErr)", + ); + } + + #[test] + fn nullable_objects_are_nil_wrapper_pointers() { + let src = render(&GoConfig::default()); + assert_has(&src, "func Pick(preferred *Ticker) *Ticker {"); + assert_has(&src, "defer runtime.KeepAlive(preferred)"); + assert_has( + &src, + "var cPreferred *C.weaveffi_bus_Ticker\n\tif preferred != nil {\n\t\tcPreferred = preferred.ptr\n\t}", + ); + assert_has(&src, "result := C.weaveffi_bus_pick(cPreferred, &cErr)"); + // A null return adopts to nil through the same helper. + assert_has(&src, "\treturn wvAdoptTicker(result)\n}"); + } + + #[test] + fn records_carry_object_tokens() { + let src = render(&GoConfig::default()); + assert_has( + &src, + "type Envelope struct {\n\tTopic string\n\tPrimary *Ticker\n\tOthers []*Ticker\n}", + ); + assert_has(&src, "w.writeU64(wvTokenTicker(v.Primary))"); + assert_has( + &src, + "for _, eOthers0 := range v.Others {\n\t\tw.writeU64(wvTokenTicker(eOthers0))", + ); + assert_has(&src, "v.Primary = wvUntokenTicker(r.readU64())"); + assert_has(&src, "v.Others[iOthers0] = wvUntokenTicker(r.readU64())"); + } + + #[test] + fn iterator_and_async_adopt_object_elements() { + let src = render(&GoConfig::default()); + assert_has(&src, "func AllTickers() iter.Seq[*Ticker] {"); + assert_has(&src, "var outItem *C.weaveffi_bus_Ticker"); + assert_has( + &src, + "ok := C.weaveffi_bus_AllTickersIterator_next(it, &outItem, &iterErr) != 0", + ); + assert_has(&src, "item := wvAdoptTicker(outItem)"); + assert_has(&src, "defer C.weaveffi_bus_AllTickersIterator_destroy(it)"); + + assert_has(&src, "func Fetch(id int32) *Ticker {"); + assert_has(&src, "h := cgo.NewHandle(ch)"); + assert_has( + &src, + "C.weaveffi_bus_fetch_async(C.int32_t(id), C.weaveffi_bus_fetch_callback(unsafe.Pointer(C.goWv_weaveffi_bus_fetch_callback)), C.wvHandlePtr(C.uintptr_t(h)))", + ); + assert_has( + &src, + "h := cgo.Handle(uintptr(context))\n\tch := h.Value().(chan wvOutcomeBusFetch)\n\th.Delete()", + ); + assert_has(&src, "ch <- wvOutcomeBusFetch{val: wvAdoptTicker(result)}"); + } + + #[test] + fn custom_prefix_keeps_runtime_symbols_canonical() { + let config = GoConfig { + prefix: Some("acme".into()), + ..GoConfig::default() + }; + let src = render(&config); + assert_has(&src, "#include \"acme.h\""); + assert_has(&src, "C.acme_bus_Ticker_clone(o.ptr)"); + assert_has(&src, "C.wvVtablePtr_acme_bus_Subscriber()"); + assert_has(&src, "C.weaveffi_error_set(outErr, -4, msg)"); + assert_has(&src, "C.weaveffi_abi_version()"); + assert!(!src.contains("arena"), "no arena bindings remain"); + assert!( + !src.contains("handle_t"), + "no untyped handle typedef remains" + ); + } + + #[test] + fn undocumented_enum_members_are_gofmt_aligned() { + use weaveffi_ir::ir::{EnumDef, EnumVariant}; + let variant = |name: &str, value: i32, fields: Vec| EnumVariant { + name: name.into(), + value, + doc: None, + fields, + }; + let module = Module { + name: "paint".into(), + doc: None, + functions: vec![], + interfaces: vec![], + callback_interfaces: vec![], + structs: vec![], + enums: vec![ + EnumDef { + name: "Channel".into(), + doc: None, + deprecated: None, + variants: vec![ + variant("red", 0, vec![]), + variant("green", 1, vec![]), + variant("blue", 2, vec![]), + ], + }, + EnumDef { + name: "Shape".into(), + doc: None, + deprecated: None, + variants: vec![variant( + "rectangle", + 0, + vec![field("width", TypeRef::F32), field("height", TypeRef::F32)], + )], + }, + ], + errors: None, + modules: vec![], + }; + let api = ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![module], + }); + let config = GoConfig::default(); + let model = BindingModel::build(&api, config.prefix()); + let src = render_go(&api, &model, &config); + // gofmt pads a run of undocumented const and field names to one + // column; emitting it pre-aligned keeps the file gofmt-clean. + assert_has( + &src, + "const (\n\tChannelRed Channel = 0\n\tChannelGreen Channel = 1\n\tChannelBlue Channel = 2\n)", + ); + assert_has( + &src, + "type ShapeRectangle struct {\n\tWidth float32\n\tHeight float32\n}", + ); + } + + #[test] + fn output_is_deterministic() { + let a = render(&GoConfig::default()); + let b = render(&GoConfig::default()); + assert_eq!(a, b); + } } diff --git a/crates/weaveffi-gen-go/src/package.rs b/crates/weaveffi-gen-go/src/package.rs index 8da14686..5eb4cf4b 100644 --- a/crates/weaveffi-gen-go/src/package.rs +++ b/crates/weaveffi-gen-go/src/package.rs @@ -5,24 +5,37 @@ use camino::Utf8Path; use weaveffi_core::model::BindingModel; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; -use weaveffi_core::platform::Platform; +use weaveffi_core::platform::{NativeBinary, Platform}; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use crate::{render_go, GoConfig}; /// The `(GOOS, GOARCH)` build-constraint tokens for a [`Platform`], used on -/// `#cgo` directive lines. -fn go_build_tags(p: Platform) -> (&'static str, &'static str) { +/// `#cgo` directive lines, or `None` for a platform the cgo package has no +/// slot for (Android and wasm32 binaries are skipped when packaging). +fn go_build_tags(p: Platform) -> Option<(&'static str, &'static str)> { match p { - Platform::MacosArm64 => ("darwin", "arm64"), - Platform::MacosX64 => ("darwin", "amd64"), - Platform::LinuxX64 => ("linux", "amd64"), - Platform::LinuxArm64 => ("linux", "arm64"), - Platform::WindowsX64 => ("windows", "amd64"), + Platform::MacosArm64 => Some(("darwin", "arm64")), + Platform::MacosX64 => Some(("darwin", "amd64")), + Platform::LinuxX64 => Some(("linux", "amd64")), + Platform::LinuxArm64 => Some(("linux", "arm64")), + Platform::WindowsX64 => Some(("windows", "amd64")), + Platform::AndroidArm64 | Platform::AndroidX64 | Platform::Wasm32 => None, } } +/// The bundled binaries this package has a `#cgo` slot for, in the binary +/// set's order. +fn bundled<'a>( + ctx: &'a PackageContext<'a>, +) -> impl Iterator { + ctx.binaries + .binaries + .iter() + .filter_map(|nb| go_build_tags(nb.platform).map(|(goos, goarch)| (nb, goos, goarch))) +} + /// The generated `go.mod` for the emitted package. pub(crate) fn render_go_mod(module_path: &str, input_basename: &str) -> String { let prelude = render_prelude(CommentStyle::DoubleSlash, input_basename); @@ -68,13 +81,20 @@ go build ./... ## How It Works -The generated `weaveffi.go` file uses a CGo preamble to `#include "weaveffi.h"` +The generated `weaveffi.go` file uses a cgo preamble to `#include "weaveffi.h"` and link against `-lweaveffi`. Each API function is exposed as an idiomatic Go function that marshals arguments to C types, calls the C ABI function, and converts the result back to Go types. Records, rich enums, optionals, lists, and maps cross the boundary serialized in the WeaveFFI value-buffer format. Errors are returned as Go `error` values. +Interfaces are reference-counted objects: each wrapper holds one strong +reference that `Close()` releases (a finalizer releases it if you forget), and +a nullable interface is a nil wrapper pointer. Callback interfaces are Go +`interface` types: implement one and pass the value to any function that +accepts it; the native library may call it from any thread, and a panic in +your implementation is reported to the native caller instead of crashing. + {trailer}"# ) } @@ -83,10 +103,8 @@ Errors are returned as Go `error` values. fn render_packaged_readme(ctx: &PackageContext, input_basename: &str) -> String { let prelude = render_prelude(CommentStyle::Xml, input_basename); let trailer = render_trailer(CommentStyle::Xml, "README.md"); - let platforms: Vec = ctx - .binaries - .platforms() - .map(|p| format!("- `lib/{}/`", p.id())) + let platforms: Vec = bundled(ctx) + .map(|(nb, _, _)| format!("- `lib/{}/`", nb.platform.id())) .collect(); let platform_list = platforms.join("\n"); format!( @@ -110,7 +128,8 @@ alongside Go, for example `weaveffi package --target c,go`). /// The full packaged file set: the Go source with a self-contained cgo /// preamble, `go.mod`, the packaged README, and one bundled shared library -/// per platform. +/// per desktop platform. Binaries for platforms without a `GOOS,GOARCH` cgo +/// slot (Android, wasm32) are skipped. pub(crate) fn package_files( api: &ResolvedApi, model: &BindingModel, @@ -120,7 +139,6 @@ pub(crate) fn package_files( ) -> Vec { let dir = out_dir.join("go"); let input_basename = config.input_basename(); - let prefix = config.prefix(); let link_name = pkg::resolve(api, None, Some(input_basename)).ident_name(); let module_path = pkg::resolve( api, @@ -135,8 +153,7 @@ pub(crate) fn package_files( // relative). cgo selects the matching line at build time. let original = format!("#cgo LDFLAGS: -l{link_name}\n"); let mut cgo = String::from("#cgo CFLAGS: -I${SRCDIR}/../c/include\n"); - for nb in &ctx.binaries.binaries { - let (goos, goarch) = go_build_tags(nb.platform); + for (nb, goos, goarch) in bundled(ctx) { let id = nb.platform.id(); if nb.platform == Platform::WindowsX64 { cgo.push_str(&format!( @@ -149,14 +166,7 @@ pub(crate) fn package_files( } } cgo.push_str(&format!("#cgo LDFLAGS: -l{link_name}\n")); - let go_src = render_go( - api, - model, - prefix, - config.strip_module_prefix, - input_basename, - ) - .replace(&original, &cgo); + let go_src = render_go(api, model, config).replace(&original, &cgo); let mut files = vec![ PackagedFile::text(dir.join("weaveffi.go"), go_src), @@ -169,7 +179,7 @@ pub(crate) fn package_files( render_packaged_readme(ctx, input_basename), ), ]; - for nb in &ctx.binaries.binaries { + for (nb, _, _) in bundled(ctx) { let dest = dir .join("lib") .join(nb.platform.id()) diff --git a/crates/weaveffi-gen-go/src/runtime.rs b/crates/weaveffi-gen-go/src/runtime.rs index dec8e159..37185f41 100644 --- a/crates/weaveffi-gen-go/src/runtime.rs +++ b/crates/weaveffi-gen-go/src/runtime.rs @@ -1,11 +1,15 @@ //! The emitted Go runtime prelude: bool conversion helpers, the shared -//! error plumbing, the value-buffer reader/writer pair, and the callback -//! registry. +//! error plumbing, the value-buffer reader/writer pair, and the foreign-error +//! reporter callback-interface trampolines use. use weaveffi_core::cabi::ABI_VERSION; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; +/// The runtime error code a callback-interface trampoline reports when the +/// Go implementation panicked (`FOREIGN_ERROR_CODE` in the ABI contract). +pub(crate) const FOREIGN_ERROR_CODE: i32 = -4; + /// Emit the package `init` that compares the producer's exported /// `weaveffi_abi_version()` against the revision these bindings were /// generated for. cgo resolves the symbol at link time, so a producer that @@ -43,17 +47,25 @@ pub(crate) fn render_bool_helpers(out: &mut String) { } /// The shared error plumbing: the generic [`ERROR_BRAND`] struct implementing -/// `error` (unknown codes, marshalling failures), plus the `wvTakeError` slot -/// reader (returning code, message, and a copy of the structured payload -/// buffer), the `wvBrandError` constructor, and the `wvTrap` panic helper -/// non-throwing wrappers check their slot with. +/// `error` (unknown codes, marshalling failures, producer panics, and foreign +/// callback failures), plus the `wvTakeError` slot reader (returning code, +/// message, and a copy of the structured payload buffer), the `wvBrandError` +/// constructor, and the `wvTrap` panic helper non-throwing wrappers check +/// their slot with (it panics with a `*WeaveFFIError` value rather than a +/// bare string so a `recover()` site can still read the code). +/// +/// The runtime's negative codes (`-1` generic, `-2` producer panic, `-3` +/// marshalling failure, `-4` a Go callback-interface implementation +/// panicked) all surface through the brand error: the code is preserved on +/// the value so a caller can still tell them apart. pub(crate) fn render_error_infra(out: &mut String) { let mut w = CodeWriter::tabs(); w.line(format!( "// {ERROR_BRAND} reports a failure crossing the C boundary that no typed" )); - w.line("// error domain claims: an unknown code, a marshalling failure, or a"); - w.line("// producer panic."); + w.line("// error domain claims: an unknown code, a marshalling failure, a"); + w.line("// producer panic (code -2), or a Go callback-interface implementation"); + w.line("// that panicked (code -4)."); w.block(format!("type {ERROR_BRAND} struct {{"), "}", |w| { w.line("// Code is the numeric ABI error code."); w.line("Code int32"); @@ -130,11 +142,14 @@ pub(crate) fn render_error_infra(out: &mut String) { w.line("// wvTrap panics when the C error slot reports a failure. Non-throwing"); w.line("// wrappers check their slot with it: a non-zero code there can only be"); - w.line("// a producer panic or a marshalling failure."); + w.line("// a producer panic, a marshalling failure, or a callback-interface"); + w.line(format!( + "// implementation that panicked. The panic value is a *{ERROR_BRAND}, so a" + )); + w.line("// recover() site can still inspect the code."); w.block("func wvTrap(cErr *C.weaveffi_error) {", "}", |w| { w.block("if cErr.code != 0 {", "}", |w| { - w.line("code, msg, _ := wvTakeError(cErr)"); - w.line("panic(fmt.Sprintf(\"weaveffi: %s (code %d)\", msg, code))"); + w.line("panic(wvBrandError(wvTakeError(cErr)))"); }); }); w.blank(); @@ -393,53 +408,29 @@ pub(crate) fn render_buffer_runtime(out: &mut String) { out.push_str(&w.finish()); } -/// The registry mapping opaque context ids to Go callbacks/channels. Only the -/// integer id (never a Go pointer) crosses the C boundary as `void*`, so the -/// GC stays unaware of C-held references and trampolines recover the Go value -/// from the map. -pub(crate) fn render_callback_registry(out: &mut String, has_listeners: bool) { +/// The `wvForeignError` reporter every callback-interface trampoline calls +/// from its `recover` path: it formats the recovered panic value and hands a +/// borrowed C string to `weaveffi_error_set` with [`FOREIGN_ERROR_CODE`]. +/// The producer copies the message, so the C string is freed before +/// returning, and the trampoline itself returns its zero value; the panic +/// never unwinds through the C frame. Like every other runtime symbol the +/// bindings call, the helper keeps its canonical `weaveffi_` spelling. +pub(crate) fn render_foreign_error(out: &mut String) { let mut w = CodeWriter::tabs(); - w.block("var (", ")", |w| { - w.line("wvCallbackMu sync.Mutex"); - w.line("wvCallbackSeq uint64"); - w.line("wvCallbacks = map[uint64]interface{}{}"); - if has_listeners { - w.line("// Subscription id -> registry id, so unregister can release the Go callback."); - w.line("wvListenerCtx = map[uint64]uint64{}"); - } - }); - w.blank(); - - w.block("func wvCallbackStore(v interface{}) uint64 {", "}", |w| { - w.line("wvCallbackMu.Lock()"); - w.line("defer wvCallbackMu.Unlock()"); - w.line("wvCallbackSeq++"); - w.line("wvCallbacks[wvCallbackSeq] = v"); - w.line("return wvCallbackSeq"); - }); - w.blank(); - - w.block("func wvCallbackLoad(id uint64) interface{} {", "}", |w| { - w.line("wvCallbackMu.Lock()"); - w.line("defer wvCallbackMu.Unlock()"); - w.line("return wvCallbacks[id]"); - }); - w.blank(); - - w.block("func wvCallbackTake(id uint64) interface{} {", "}", |w| { - w.line("wvCallbackMu.Lock()"); - w.line("defer wvCallbackMu.Unlock()"); - w.line("v := wvCallbacks[id]"); - w.line("delete(wvCallbacks, id)"); - w.line("return v"); - }); - w.blank(); - - w.block("func wvCallbackDelete(id uint64) {", "}", |w| { - w.line("wvCallbackMu.Lock()"); - w.line("defer wvCallbackMu.Unlock()"); - w.line("delete(wvCallbacks, id)"); - }); + w.line("// wvForeignError reports a panic recovered inside a callback-interface"); + w.line("// trampoline through the producer's error slot (code -4). The message is"); + w.line("// borrowed for the call: the producer copies it with its own allocator."); + w.block( + "func wvForeignError(outErr *C.weaveffi_error, recovered any) {", + "}", + |w| { + w.line("msg := C.CString(fmt.Sprint(recovered))"); + w.line("defer C.free(unsafe.Pointer(msg))"); + w.line(format!( + "C.weaveffi_error_set(outErr, {FOREIGN_ERROR_CODE}, msg)" + )); + }, + ); w.blank(); out.push_str(&w.finish()); } diff --git a/crates/weaveffi-gen-go/src/types.rs b/crates/weaveffi-gen-go/src/types.rs index 1c13f5bb..c40fdd32 100644 --- a/crates/weaveffi-gen-go/src/types.rs +++ b/crates/weaveffi-gen-go/src/types.rs @@ -14,12 +14,6 @@ pub(crate) fn go_local(n: &str) -> String { local_type_name(n).to_upper_camel_case() } -/// The Go wrapper type name for a typed-handle referent: `{Name}Handle`. -/// The suffix keeps the wrapper distinct from the referent's value struct. -pub(crate) fn handle_wrapper(n: &str) -> String { - format!("{}Handle", go_local(n)) -} - /// The Go spelling of a user-chosen parameter name: lowerCamelCase, with a /// trailing `_` appended when the conversion lands on a Go keyword (a param /// named `type` surfaces as `type_`). @@ -40,26 +34,29 @@ pub(crate) fn go_type(ty: &Ty) -> String { Ty::U16 => "uint16".into(), Ty::U32 => "uint32".into(), Ty::U64 => "uint64".into(), - Ty::I64 | Ty::Handle => "int64".into(), + Ty::I64 => "int64".into(), Ty::F32 => "float32".into(), Ty::F64 => "float64".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "string".into(), - Ty::Bytes | Ty::BorrowedBytes => "[]byte".into(), + Ty::StringUtf8 => "string".into(), + Ty::Bytes => "[]byte".into(), // Records are plain value structs; rich enums are sealed interfaces // (nil-able), so neither takes a pointer at the type site. A // cross-module reference (resolved to e.g. `kv.Entry`) must name the // local `Entry` type rather than the qualified `KvEntry`. Ty::Record(n) | Ty::RichEnum(n) => go_local(n), + // An object wrapper is always handled through a pointer so one Go + // value owns the strong reference; a callback interface is the Go + // interface type the consumer implements. Ty::Interface(n) => format!("*{}", go_local(n)), - Ty::TypedHandle(n) => format!("*{}", handle_wrapper(n)), + Ty::CallbackInterface(n) => go_local(n), Ty::Enum(n) => go_local(n), Ty::Optional(inner) => { if optional_derefs(inner) { format!("*{}", go_type(inner)) } else { // Already nil-able in Go (interface, slice, map, byte slice, - // handle wrapper): nil is the none marker. + // object wrapper pointer): nil is the none marker. go_type(inner) } } @@ -73,18 +70,12 @@ pub(crate) fn go_type(ty: &Ty) -> String { /// `true` when `T?` surfaces as `*T` in Go (the value must be dereferenced /// when present). Types that are already nil-able (rich enums, slices, maps, -/// byte slices, typed handles, interfaces) use nil directly as the none -/// marker instead. +/// byte slices, object wrapper pointers) use nil directly as the none marker +/// instead. pub(crate) fn optional_derefs(inner: &Ty) -> bool { !matches!( inner, - Ty::RichEnum(_) - | Ty::List(_) - | Ty::Map(_, _) - | Ty::Bytes - | Ty::BorrowedBytes - | Ty::TypedHandle(_) - | Ty::Interface(_) + Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) | Ty::Bytes | Ty::Interface(_) ) } @@ -100,11 +91,10 @@ pub(crate) fn go_zero(ty: &Ty) -> String { | Ty::U16 | Ty::U32 | Ty::U64 - | Ty::Handle | Ty::F32 | Ty::F64 => "0".into(), Ty::Bool => "false".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "\"\"".into(), + Ty::StringUtf8 => "\"\"".into(), Ty::Enum(_) => "0".into(), // A record is a value struct: its zero is the empty literal. Ty::Record(n) => format!("{}{{}}", go_local(n)), @@ -124,7 +114,7 @@ pub(crate) fn c_scalar_type(ty: &Ty, prefix: &str, module: &str) -> Option Some("C.uint16_t".into()), Ty::U32 => Some("C.uint32_t".into()), Ty::U64 => Some("C.uint64_t".into()), - Ty::I64 | Ty::Handle => Some("C.int64_t".into()), + Ty::I64 => Some("C.int64_t".into()), Ty::F32 => Some("C.float".into()), Ty::F64 => Some("C.double".into()), Ty::Bool => Some("C._Bool".into()), @@ -159,7 +149,7 @@ pub(crate) fn go_scalar_conv(expr: &str, ty: &Ty) -> String { Ty::U16 => format!("uint16({expr})"), Ty::U32 => format!("uint32({expr})"), Ty::U64 => format!("uint64({expr})"), - Ty::I64 | Ty::Handle => format!("int64({expr})"), + Ty::I64 => format!("int64({expr})"), Ty::F32 => format!("float32({expr})"), Ty::F64 => format!("float64({expr})"), Ty::Bool => format!("cToBool({expr})"), @@ -168,14 +158,47 @@ pub(crate) fn go_scalar_conv(expr: &str, ty: &Ty) -> String { } } -/// The Go expression wrapping an opaque C pointer (`ptr_expr`) into the -/// wrapper type for an interface or typed-handle reference. -pub(crate) fn go_wrap_expr(ty: &Ty, ptr_expr: &str) -> String { - match ty { - Ty::Interface(n) => format!("&{}{{ptr: {ptr_expr}}}", go_local(n)), - Ty::TypedHandle(n) => format!("&{}{{ptr: {ptr_expr}}}", handle_wrapper(n)), - _ => unreachable!("only interfaces and typed handles wrap C pointers"), - } +/// The name of the per-interface adopt helper (`wvAdoptStore`) that wraps one +/// owned strong reference in a new wrapper (or returns nil for a null +/// pointer). `n` may be dot-qualified. +pub(crate) fn adopt_fn(n: &str) -> String { + format!("wvAdopt{}", go_local(n)) +} + +/// The name of the per-interface token writer (`wvTokenStore`) that clones a +/// wrapper's reference into a value-buffer object token. +pub(crate) fn token_fn(n: &str) -> String { + format!("wvToken{}", go_local(n)) +} + +/// The name of the per-interface token reader (`wvUntokenStore`) that adopts +/// the reference carried by a value-buffer object token. +pub(crate) fn untoken_fn(n: &str) -> String { + format!("wvUntoken{}", go_local(n)) +} + +/// The Go expression adopting the owned object pointer `ptr_expr` into a +/// wrapper for the interface named by `ty` (a bare or optional interface). +/// A null pointer adopts to nil, so the same expression serves `Interface` +/// and `Interface?`. +pub(crate) fn go_adopt_expr(ty: &Ty, ptr_expr: &str) -> String { + let n = ty + .interface_name() + .expect("only interfaces and optional interfaces adopt C pointers"); + format!("{}({ptr_expr})", adopt_fn(n)) +} + +/// The C identifier of the process-wide static vtable emitted in the cgo +/// preamble for the callback interface whose C tag is `c_tag`. +pub(crate) fn vtable_var(c_tag: &str) -> String { + format!("wvVtable_{c_tag}") +} + +/// The C identifier of the static preamble function returning the address +/// of [`vtable_var`]'s table; Go can't take a `static` variable's address +/// through cgo directly, so wrappers call this instead. +pub(crate) fn vtable_accessor(c_tag: &str) -> String { + format!("wvVtablePtr_{c_tag}") } /// Quote `s` as a Go string literal, escaping backslashes, quotes, and @@ -212,12 +235,12 @@ pub(crate) fn cgo_slot_type(ct: &CType, prefix: &str) -> String { CType::Bool => "C._Bool".into(), CType::Size => "C.size_t".into(), CType::Char => "C.char".into(), - CType::Handle => format!("C.{prefix}_handle_t"), CType::CancelToken => format!("C.{prefix}_cancel_token"), CType::Error => format!("C.{prefix}_error"), CType::Enum { module, name } | CType::StructTag { module, name } => { format!("C.{prefix}_{module}_{name}") } + CType::VtableTag { module, name } => format!("C.{prefix}_{module}_{name}_vtable"), CType::Named(core) => format!("C.{prefix}_{core}"), CType::Ptr { pointee, .. } => { if **pointee == CType::Void { diff --git a/crates/weaveffi-gen-android/Cargo.toml b/crates/weaveffi-gen-kotlin/Cargo.toml similarity index 56% rename from crates/weaveffi-gen-android/Cargo.toml rename to crates/weaveffi-gen-kotlin/Cargo.toml index 81ad901b..8cbcfb59 100644 --- a/crates/weaveffi-gen-android/Cargo.toml +++ b/crates/weaveffi-gen-kotlin/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "weaveffi-gen-android" +name = "weaveffi-gen-kotlin" version = "0.22.0" edition.workspace = true license.workspace = true authors.workspace = true repository.workspace = true -description = "Android JNI and Kotlin binding generator for WeaveFFI" -keywords = ["ffi", "codegen", "bindings", "android", "kotlin"] +description = "Kotlin binding generator (JNI shim, Android and desktop JVM) for WeaveFFI" +keywords = ["ffi", "codegen", "bindings", "kotlin", "jni"] categories = ["development-tools::ffi"] [dependencies] @@ -14,5 +14,8 @@ weaveffi-core = { path = "../weaveffi-core", version = "0.22.0" } camino = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-android/src/calls.rs b/crates/weaveffi-gen-kotlin/src/calls.rs similarity index 75% rename from crates/weaveffi-gen-android/src/calls.rs rename to crates/weaveffi-gen-kotlin/src/calls.rs index 5ffdca18..0eba56cc 100644 --- a/crates/weaveffi-gen-android/src/calls.rs +++ b/crates/weaveffi-gen-kotlin/src/calls.rs @@ -1,5 +1,5 @@ -//! Per-callable Kotlin wrappers: free functions, `suspend fun` shims, -//! listener registration, and the interface-member native declarations. +//! Per-callable Kotlin wrappers: free functions, `suspend fun` shims, and the +//! interface-member native declarations. use std::fmt::Write as _; @@ -7,18 +7,15 @@ use weaveffi_core::codegen::common::pascal_case; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors; use weaveffi_core::model::Ty; -use weaveffi_core::model::{ - CallShape, ErrorBinding, FnBinding, ListenerBinding, ModuleBinding, ParamBinding, -}; +use weaveffi_core::model::{CallShape, ErrorBinding, FnBinding, ModuleBinding, ParamBinding}; use weaveffi_core::plan::ErrorStrategy; use weaveffi_core::utils::local_type_name; use crate::codec::{kt_decode_expr, kt_encode_expr}; -use crate::docs::emit_doc; use crate::entities::kotlin_exception_name; use crate::types::{ - camel_params, kotlin_cb_jni_type, kotlin_cb_type, kotlin_fn_name, kotlin_iterator_class_name, - kotlin_jni_type, kotlin_type, kt_param, needs_wrapper_split, + camel_params, kotlin_fn_name, kotlin_iterator_class_name, kotlin_jni_type, kotlin_type, + kt_param, needs_wrapper_split, }; /// The Kotlin lambda mapping an async error `(code, message, payload)` triple @@ -42,94 +39,11 @@ pub(crate) fn kotlin_error_mapper(f: &FnBinding, error: Option<&ErrorBinding>) - } } -/// Render one listener's registration surface into the `WeaveFFI` companion: -/// the register external (behind a decoding wrapper when the callback carries -/// buffered arguments) and the unregister external, with the exception policy -/// documented in the KDoc. -pub(crate) fn render_listener_api( - out: &mut String, - m: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, -) { - let Some(cb) = m.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - let cb_params: Vec = cb.params.iter().map(|p| kotlin_cb_type(&p.ty)).collect(); - let register = kotlin_fn_name( - &m.path, - &format!("register_{}", l.name), - strip_module_prefix, - ); - let unregister = kotlin_fn_name( - &m.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ); - // Listener callbacks run on producer threads with no Kotlin - // caller up-stack, so the exception policy is part of the - // listener's public contract and belongs in its KDoc. - let policy = "Exceptions thrown by the callback cannot propagate to a caller \ - (events fire on a producer thread); they are delivered to the \ - handler installed via [setCallbackExceptionHandler], or logged \ - with their stack trace and dropped when no handler is set."; - let doc = match &l.doc { - Some(d) => format!("{}\n\n{policy}", d.trim_end()), - None => policy.to_string(), - }; - emit_doc(out, &Some(doc), " "); - // Buffered callback arguments arrive as raw `ByteArray` copies: - // the public register wrapper decodes them before the user's - // lambda runs, so the JNI-facing external takes a wrapper lambda. - let has_buffered = cb.params.iter().any(|p| p.ty.is_buffered()); - if has_buffered { - let jni_params: Vec = cb - .params - .iter() - .map(|p| kotlin_cb_jni_type(&p.ty)) - .collect(); - let _ = writeln!( - out, - " @JvmStatic private external fun {register}Jni(callback: ({}) -> Unit): Long", - jni_params.join(", ") - ); - let args: Vec = (0..cb.params.len()).map(|i| format!("a{i}")).collect(); - let decoded: Vec = cb - .params - .iter() - .enumerate() - .map(|(i, p)| { - if p.ty.is_buffered() { - kt_decode_expr(&p.ty, &format!("a{i}")) - } else { - format!("a{i}") - } - }) - .collect(); - let _ = writeln!( - out, - " @JvmStatic fun {register}(callback: ({}) -> Unit): Long = {register}Jni {{ {} -> callback({}) }}", - cb_params.join(", "), - args.join(", "), - decoded.join(", ") - ); - } else { - let _ = writeln!( - out, - " @JvmStatic external fun {register}(callback: ({}) -> Unit): Long", - cb_params.join(", ") - ); - } - let _ = writeln!( - out, - " @JvmStatic external fun {unregister}(id: Long)" - ); -} - /// Render one free function into the `WeaveFFI` companion: a bare `external -/// fun` when every type crosses JNI unchanged, otherwise a private `{name}Jni` -/// external plus a public wrapper that unwraps handles and enums on the way in -/// and re-wraps class returns on the way out. +/// fun` when every type crosses JNI unchanged (callback interface +/// implementations included: they cross as the object itself), otherwise a +/// private `{name}Jni` external plus a public wrapper that unwraps handles and +/// enums on the way in and re-wraps class returns on the way out. pub(crate) fn render_kotlin_free_fn( out: &mut String, m: &ModuleBinding, @@ -207,8 +121,10 @@ pub(crate) fn render_kotlin_free_fn( } /// The Kotlin expression that lowers one public argument for a JNI call: -/// buffered values pack into a `ByteArray`, enums pass `.value`, interfaces -/// pass the raw `.handle` (nullable via `?.`). +/// buffered values pack into a `ByteArray` (cloning any object they carry), +/// enums pass `.value`, interfaces lend the raw `.handle` (nullable via +/// `?.`), and a callback interface implementation passes as the object the +/// JNI shim pins. pub(crate) fn kotlin_unwrap_arg(p: &ParamBinding) -> String { let n = kt_param(&p.name); if p.ty.is_buffered() { @@ -226,7 +142,7 @@ pub(crate) fn kotlin_unwrap_arg(p: &ParamBinding) -> String { /// The Kotlin expression re-wrapping a lowered JNI value `expr` into the /// public return type, or `None` when the lowered value already is the public /// type: buffered returns decode the `ByteArray`, enums round-trip through -/// `fromValue`, interfaces through the class constructor (nullable via +/// `fromValue`, interfaces through the companion's `fromHandle` (nullable via /// `?.let`). pub(crate) fn kotlin_wrap_return(ret: Option<&Ty>, expr: &str) -> Option { let ret = ret?; @@ -235,10 +151,13 @@ pub(crate) fn kotlin_wrap_return(ret: Option<&Ty>, expr: &str) -> Option } match ret { Ty::Enum(name) => Some(format!("{}.fromValue({expr})", local_type_name(name))), - Ty::Interface(name) => Some(format!("{}({expr})", local_type_name(name))), + Ty::Interface(name) => Some(format!("{}.fromHandle({expr})", local_type_name(name))), // Only `Interface?` reaches here (every other optional is buffered). Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(name) => Some(format!("{expr}?.let {{ {}(it) }}", local_type_name(name))), + Ty::Interface(name) => Some(format!( + "{expr}?.let {{ {}.fromHandle(it) }}", + local_type_name(name) + )), _ => unreachable!("buffered optionals are handled above"), }, _ => None, diff --git a/crates/weaveffi-gen-android/src/codec.rs b/crates/weaveffi-gen-kotlin/src/codec.rs similarity index 75% rename from crates/weaveffi-gen-android/src/codec.rs rename to crates/weaveffi-gen-kotlin/src/codec.rs index 1efe961d..2ba6858f 100644 --- a/crates/weaveffi-gen-android/src/codec.rs +++ b/crates/weaveffi-gen-kotlin/src/codec.rs @@ -1,8 +1,7 @@ //! Value-buffer codec expressions: the Kotlin read and write expression for -//! every wire shape, dispatched on the shared [`wire::classify`] fold. +//! every wire shape, dispatched on the shared [`Ty::wire`] classification. use weaveffi_core::model::Ty; -use weaveffi_core::model::{BindingModel, CallShape, EnumBinding}; use weaveffi_core::model::{Prim, WireType}; use weaveffi_core::utils::local_type_name; @@ -12,6 +11,11 @@ use crate::types::kt_escape; /// `t`) into the value-buffer writer named `w`. Optionals, lists, and maps /// recurse through the writer's lambda helpers; `depth` uniquifies the lambda /// parameter names at each nesting level. +/// +/// An object inside a buffer is written as a token carrying one strong +/// reference: the wrapper's `cloneHandle()` calls the interface's `_clone` +/// symbol, so the encoding never captures the pointer the wrapper still +/// owns. pub(crate) fn kt_write_expr(t: &Ty, w: &str, expr: &str, depth: usize) -> String { match t.wire() { WireType::Prim(Prim::Bool) => format!("{w}.writeBool({expr})"), @@ -19,14 +23,13 @@ pub(crate) fn kt_write_expr(t: &Ty, w: &str, expr: &str, depth: usize) -> String WireType::Prim(Prim::I16) | WireType::Prim(Prim::U16) => format!("{w}.writeI16({expr})"), WireType::Prim(Prim::I32) => format!("{w}.writeI32({expr})"), WireType::Prim(Prim::U32) => format!("{w}.writeU32({expr})"), - WireType::Prim(Prim::I64) | WireType::Prim(Prim::U64) | WireType::Handle(_) => { - format!("{w}.writeI64({expr})") - } + WireType::Prim(Prim::I64) | WireType::Prim(Prim::U64) => format!("{w}.writeI64({expr})"), WireType::Prim(Prim::F32) => format!("{w}.writeF32({expr})"), WireType::Prim(Prim::F64) => format!("{w}.writeF64({expr})"), WireType::Enum(_) => format!("{w}.writeI32({expr}.value)"), WireType::Prim(Prim::String) => format!("{w}.writeString({expr})"), WireType::Prim(Prim::Bytes) => format!("{w}.writeBytes({expr})"), + WireType::Object(_) => format!("{w}.writeI64({expr}.cloneHandle())"), WireType::User(name) => format!("pack{}({w}, {expr})", local_type_name(name)), WireType::Optional(inner) => { let v = format!("v{depth}"); @@ -55,7 +58,8 @@ pub(crate) fn kt_write_expr(t: &Ty, w: &str, expr: &str, depth: usize) -> String } /// The Kotlin expression reading a value of type `t` from the value-buffer -/// reader named `r`. The inverse of [`kt_write_expr`]. +/// reader named `r`. The inverse of [`kt_write_expr`]. An object token is +/// adopted into a new wrapper instance, which owes the reference's `_destroy`. pub(crate) fn kt_read_expr(t: &Ty, r: &str) -> String { match t.wire() { WireType::Prim(Prim::Bool) => format!("{r}.readBool()"), @@ -63,14 +67,15 @@ pub(crate) fn kt_read_expr(t: &Ty, r: &str) -> String { WireType::Prim(Prim::I16) | WireType::Prim(Prim::U16) => format!("{r}.readI16()"), WireType::Prim(Prim::I32) => format!("{r}.readI32()"), WireType::Prim(Prim::U32) => format!("{r}.readU32()"), - WireType::Prim(Prim::I64) | WireType::Prim(Prim::U64) | WireType::Handle(_) => { - format!("{r}.readI64()") - } + WireType::Prim(Prim::I64) | WireType::Prim(Prim::U64) => format!("{r}.readI64()"), WireType::Prim(Prim::F32) => format!("{r}.readF32()"), WireType::Prim(Prim::F64) => format!("{r}.readF64()"), WireType::Enum(name) => format!("{}.fromValue({r}.readI32())", local_type_name(name)), WireType::Prim(Prim::String) => format!("{r}.readString()"), WireType::Prim(Prim::Bytes) => format!("{r}.readBytes()"), + WireType::Object(name) => { + format!("{}.fromHandle({r}.readObject())", local_type_name(name)) + } WireType::User(name) => format!("unpack{}({r})", local_type_name(name)), WireType::Optional(inner) => { format!("{r}.readOptional {{ {} }}", kt_read_expr(inner, r)) @@ -102,25 +107,3 @@ pub(crate) fn kt_decode_expr(t: &Ty, expr: &str) -> String { pub(crate) fn kt_write_field(ty: &Ty, w: &str, field: &str) -> String { kt_write_expr(ty, w, &format!("v.{}", kt_escape(field)), 0) } - -/// Whether any surface in the model moves a value buffer across the boundary, -/// requiring the private Kotlin writer/reader runtime: records or rich enums -/// exist, an error code declares payload fields, or a callable, callback, or -/// iterator element is buffered. -pub(crate) fn model_uses_buffers(model: &BindingModel) -> bool { - model.modules.iter().any(|m| { - !m.structs.is_empty() - || m.enums.iter().any(EnumBinding::is_rich) - || m.error - .as_ref() - .is_some_and(|e| e.codes.iter().any(|c| !c.fields.is_empty())) - || m.callbacks - .iter() - .any(|cb| cb.params.iter().any(|p| p.ty.is_buffered())) - || m.callables().any(|f| { - f.params.iter().any(|p| p.ty.is_buffered()) - || f.ret.as_ref().is_some_and(Ty::is_buffered) - || matches!(&f.shape, CallShape::Iterator(it) if it.elem.is_buffered()) - }) - }) -} diff --git a/crates/weaveffi-gen-android/src/docs.rs b/crates/weaveffi-gen-kotlin/src/docs.rs similarity index 100% rename from crates/weaveffi-gen-android/src/docs.rs rename to crates/weaveffi-gen-kotlin/src/docs.rs diff --git a/crates/weaveffi-gen-android/src/entities.rs b/crates/weaveffi-gen-kotlin/src/entities.rs similarity index 70% rename from crates/weaveffi-gen-android/src/entities.rs rename to crates/weaveffi-gen-kotlin/src/entities.rs index 524b09d9..72b684ec 100644 --- a/crates/weaveffi-gen-android/src/entities.rs +++ b/crates/weaveffi-gen-kotlin/src/entities.rs @@ -1,14 +1,15 @@ //! Kotlin entity rendering: plain and rich enums, record data classes, the -//! exception hierarchy, interface wrapper classes, and the lazy iterator -//! classes, plus the per-type buffer codecs. +//! exception hierarchy, `Cleaner`-backed interface wrapper classes, the lazy +//! iterator classes, and callback interfaces with their JNI dispatch objects, +//! plus the per-type buffer codecs. use weaveffi_core::codegen::common::pascal_case; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, IteratorBinding, - StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + IteratorBinding, StructBinding, }; use weaveffi_core::utils::local_type_name; @@ -18,7 +19,10 @@ use crate::calls::{ }; use crate::codec::{kt_decode_expr, kt_read_expr, kt_write_field}; use crate::docs::{splice, writer_doc, writer_fn_doc}; -use crate::types::{camel_params, kotlin_iterator_class_name, kotlin_type, kt_escape, kt_param}; +use crate::types::{ + camel_params, kotlin_callback_dispatch_name, kotlin_iterator_class_name, + kotlin_shim_param_type, kotlin_type, kt_escape, kt_param, +}; /// The Kotlin exception type for an error domain: the shared exception brand /// naming, so `KvError` becomes `KvException`. @@ -169,12 +173,13 @@ pub(crate) fn render_kotlin_rich_enum_codecs(out: &mut String, e: &EnumBinding) /// /// Domain codes are validated positive-only, and the negative range is /// reserved for the runtime (generic error, producer panic, marshalling -/// failure), so `fromCode` maps only the declared codes and every other code -/// (all negatives included) falls through to the generic branded exception. +/// failure, a callback interface implementation raised), so `fromCode` maps +/// only the declared codes and every other code (all negatives included) +/// falls through to the generic branded exception. pub(crate) fn render_kotlin_error_types(out: &mut String, model: &BindingModel) { let mut w = CodeWriter::four_space(); w.blank(); - w.line("/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */"); + w.line("/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */"); w.line(format!( "open class {}(val code: Int, message: String) : Exception(message)", errors::EXCEPTION_BRAND @@ -351,14 +356,14 @@ pub(crate) fn kotlin_iter_elem_convert(elem: &Ty) -> String { } match elem { Ty::Enum(name) => format!("{}.fromValue(raw as Int)", local_type_name(name)), - Ty::Interface(name) => format!("{}(raw as Long)", local_type_name(name)), + Ty::Interface(name) => format!("{}.fromHandle(raw as Long)", local_type_name(name)), // Only `Interface?` reaches here: 0L crosses for none. Ty::Optional(inner) => { let Ty::Interface(name) = inner.as_ref() else { unreachable!("non-interface optionals are buffered") }; format!( - "(raw as Long).takeIf {{ it != 0L }}?.let {{ {}(it) }}", + "(raw as Long).takeIf {{ it != 0L }}?.let {{ {}.fromHandle(it) }}", local_type_name(name) ) } @@ -368,8 +373,9 @@ pub(crate) fn kotlin_iter_elem_convert(elem: &Ty) -> String { /// Render the lazy Kotlin iterator wrapper class for one `iter` callable. /// The class implements `Iterator` with a lookahead slot (one producer -/// `next` per consumer step), `java.io.Closeable` disposal, and a finalizer so -/// an abandoned iterator's native handle is destroyed exactly once. +/// `next` per consumer step) and `AutoCloseable` disposal through a +/// `WeaveNativeRef` registered with the shared `Cleaner`, so an abandoned +/// iterator's native handle is still destroyed exactly once. pub(crate) fn render_kotlin_iterator_class( out: &mut String, f: &FnBinding, @@ -389,19 +395,21 @@ pub(crate) fn render_kotlin_iterator_class( )); w.line(" * exactly one element from the native producer. The native handle is"); w.line(" * released when the producer is exhausted, when [close] is called, or by"); - w.line(" * the finalizer if the iterator is abandoned, whichever comes first."); + w.line(" * the `Cleaner` if the iterator is abandoned, whichever comes first."); w.line(" */"); w.line(format!( - "class {class} internal constructor(private var handle: Long) : Iterator<{elem_pub}>, java.io.Closeable {{" + "class {class} internal constructor(handle: Long) : Iterator<{elem_pub}>, AutoCloseable {{" )); w.scope(|w| { + w.line("private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) }"); + w.line("private val cleanable = weaveCleaner.register(this, ref)"); w.line("private var nextSlot: Array? = null"); w.blank(); w.line("override fun hasNext(): Boolean {"); w.scope(|w| { w.line("if (nextSlot != null) return true"); - w.line("if (handle == 0L) return false"); - w.line("val slot = nativeNext(handle)"); + w.line("if (ref.peek() == 0L) return false"); + w.line("val slot = nativeNext(ref.get())"); w.line("if (slot == null) {"); w.scope(|w| { w.line("close()"); @@ -422,26 +430,16 @@ pub(crate) fn render_kotlin_iterator_class( }); w.line("}"); w.blank(); + w.line("/** Releases the native iterator; safe to call more than once. */"); w.line("override fun close() {"); w.scope(|w| { - w.line("if (handle != 0L) {"); - w.scope(|w| { - w.line("nativeDestroy(handle)"); - w.line("handle = 0L"); - }); - w.line("}"); - }); - w.line("}"); - w.blank(); - w.line("protected fun finalize() {"); - w.scope(|w| { - w.line("close()"); + w.line("cleanable.clean()"); }); w.line("}"); w.blank(); w.line("companion object {"); w.scope(|w| { - w.line("init { System.loadLibrary(\"weaveffi\") }"); + w.line("init { WeaveNativeLibrary.ensureLoaded() }"); w.blank(); w.line("@JvmStatic private external fun nativeNext(handle: Long): Array?"); w.line("@JvmStatic private external fun nativeDestroy(handle: Long)"); @@ -452,13 +450,15 @@ pub(crate) fn render_kotlin_iterator_class( out.push_str(&w.finish()); } -/// Render the Kotlin class for one interface, mirroring the opaque-struct -/// wrapper pattern: an internal `Long` handle, `java.io.Closeable` disposal -/// backed by the destroy symbol, companion factories for constructors (the -/// `new` constructor becomes `operator fun invoke`), companion functions for -/// statics, and instance methods that pass the handle as the leading native -/// argument. Async members become `suspend fun`s resuming through -/// `WeaveContinuation` with `error`-typed exception mapping. +/// Render the Kotlin class for one interface: the wrapper owns one strong +/// native reference through a `WeaveNativeRef` registered with the shared +/// `Cleaner`, so `destroy` runs exactly once, from `close()` (`AutoCloseable`) +/// or from the cleaner backstop. `cloneHandle()` exposes the `_clone` symbol +/// for the value-buffer codec. Companion factories wrap constructors (the +/// `new` constructor becomes `operator fun invoke`) and statics; instance +/// methods pass the borrowed handle as the leading native argument. Async +/// members become `suspend fun`s resuming through `WeaveContinuation` with +/// `error`-typed exception mapping. pub(crate) fn render_kotlin_interface( out: &mut String, i: &InterfaceBinding, @@ -468,21 +468,41 @@ pub(crate) fn render_kotlin_interface( let mut w = CodeWriter::four_space(); w.blank(); writer_doc(&mut w, &i.doc); + // The raw-handle constructor is private (not internal) so that a `new` + // constructor taking one integer, exposed as `operator fun invoke(Long)`, + // can't be shadowed by it when a consumer is compiled in the same module: + // Kotlin prefers a visible constructor over the invoke convention, and an + // `internal` one would silently adopt the consumer's integer as a pointer. w.line(format!( - "class {} internal constructor(internal var handle: Long) : java.io.Closeable {{", + "class {} private constructor(handle: Long) : AutoCloseable {{", i.name )); w.scope(|w| { + w.line("private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) }"); + w.line("private val cleanable = weaveCleaner.register(this, ref)"); + w.blank(); + w.line("/** The native pointer, borrowed for one call; throws after [close]. */"); + w.line("internal val handle: Long get() = ref.get()"); + w.blank(); + w.line("/** A new strong reference, written into value buffers as an object token. */"); + w.line("internal fun cloneHandle(): Long = nativeClone(ref.get())"); + w.blank(); w.line("companion object {"); w.scope(|w| { - w.line("init { System.loadLibrary(\"weaveffi\") }"); + w.line("init { WeaveNativeLibrary.ensureLoaded() }"); w.blank(); + w.line("/** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */"); + w.line(format!( + "internal fun fromHandle(handle: Long): {} = {}(handle)", + i.name, i.name + )); for f in i.constructors.iter().chain(i.statics.iter()) { w.line(interface_native_decl(f, false)); } for f in &i.methods { w.line(interface_native_decl(f, true)); } + w.line("@JvmStatic private external fun nativeClone(handle: Long): Long"); w.line("@JvmStatic private external fun nativeDestroy(handle: Long)"); // Constructors are never async (validation rejects that), so each @@ -552,23 +572,123 @@ pub(crate) fn render_kotlin_interface( } w.blank(); + w.line("/** Releases this wrapper's native reference; safe to call more than once. */"); w.line("override fun close() {"); w.scope(|w| { - w.line("if (handle != 0L) {"); - w.scope(|w| { - w.line("nativeDestroy(handle)"); - w.line("handle = 0L"); - }); - w.line("}"); - }); - w.line("}"); - w.blank(); - w.line("protected fun finalize() {"); - w.scope(|w| { - w.line("close()"); + w.line("cleanable.clean()"); }); w.line("}"); }); w.line("}"); out.push_str(&w.finish()); } + +/// The Kotlin expression converting one callback-method argument as the JNI +/// trampoline delivers it (see [`kotlin_shim_param_type`]) into the public +/// type the implementation receives: buffers decode, enums round-trip through +/// `fromValue`, and object pointers are adopted into a new wrapper (`0L` +/// becomes `null` for `Interface?`). +fn kotlin_shim_arg_convert(t: &Ty, expr: &str) -> String { + if t.is_buffered() { + return kt_decode_expr(t, expr); + } + match t { + Ty::Enum(name) => format!("{}.fromValue({expr})", local_type_name(name)), + Ty::Interface(name) => format!("{}.fromHandle({expr})", local_type_name(name)), + Ty::Optional(inner) => { + let Ty::Interface(name) = inner.as_ref() else { + unreachable!("non-interface optionals are buffered") + }; + format!( + "{expr}.takeIf {{ it != 0L }}?.let {{ {}.fromHandle(it) }}", + local_type_name(name) + ) + } + _ => expr.to_string(), + } +} + +/// Render one callback interface: the public Kotlin `interface` the consumer +/// implements, plus the internal `{Name}Jni` dispatch object whose +/// `@JvmStatic` shims the native vtable trampolines call through cached +/// `jmethodID`s. Each shim takes the implementing object first, then every +/// argument in its trampoline form, converts them (decoding buffers and +/// adopting object references), and forwards to the implementation; an enum +/// return is lowered back to its raw `Int`. +pub(crate) fn render_kotlin_callback_interface(out: &mut String, cb: &CallbackInterfaceBinding) { + let name = &cb.name; + let dispatch = kotlin_callback_dispatch_name(name); + let mut w = CodeWriter::four_space(); + w.blank(); + writer_doc(&mut w, &cb.doc); + if let Some(msg) = &cb.deprecated { + w.line(format!("@Deprecated(\"{}\")", msg.replace('"', "\\\""))); + } + w.line(format!("interface {name} {{")); + w.scope(|w| { + for m in &cb.methods { + writer_fn_doc(w, &m.doc, &camel_params(&m.params)); + if let Some(msg) = &m.deprecated { + w.line(format!("@Deprecated(\"{}\")", msg.replace('"', "\\\""))); + } + let params: Vec = m + .params + .iter() + .map(|p| format!("{}: {}", kt_param(&p.name), kotlin_type(&p.ty))) + .collect(); + let ret = m + .ret + .as_ref() + .map(|t| format!(": {}", kotlin_type(t))) + .unwrap_or_default(); + w.line(format!( + "fun {}({}){ret}", + kt_param(&m.name), + params.join(", ") + )); + } + }); + w.line("}"); + w.blank(); + w.line(format!( + "/** JNI dispatch shims for [{name}]; the native vtable trampolines call these, nothing else does. */" + )); + w.line(format!("internal object {dispatch} {{")); + w.scope(|w| { + for m in &cb.methods { + let mut params = vec![format!("impl: {name}")]; + params.extend( + m.params + .iter() + .map(|p| format!("{}: {}", kt_param(&p.name), kotlin_shim_param_type(&p.ty))), + ); + let args: Vec = m + .params + .iter() + .map(|p| kotlin_shim_arg_convert(&p.ty, &kt_param(&p.name))) + .collect(); + let call = format!("impl.{}({})", kt_param(&m.name), args.join(", ")); + let line = match &m.ret { + None => format!( + "@JvmStatic fun {}({}) {{ {call} }}", + kt_param(&m.name), + params.join(", ") + ), + Some(Ty::Enum(_)) => format!( + "@JvmStatic fun {}({}): Int = {call}.value", + kt_param(&m.name), + params.join(", ") + ), + Some(t) => format!( + "@JvmStatic fun {}({}): {} = {call}", + kt_param(&m.name), + params.join(", "), + kotlin_shim_param_type(t) + ), + }; + w.line(line); + } + }); + w.line("}"); + out.push_str(&w.finish()); +} diff --git a/crates/weaveffi-gen-android/src/jni.rs b/crates/weaveffi-gen-kotlin/src/jni.rs similarity index 61% rename from crates/weaveffi-gen-android/src/jni.rs rename to crates/weaveffi-gen-kotlin/src/jni.rs index d4a07a61..f066b834 100644 --- a/crates/weaveffi-gen-android/src/jni.rs +++ b/crates/weaveffi-gen-kotlin/src/jni.rs @@ -1,25 +1,33 @@ -//! JNI C bridge emission: sync and async exports, interface natives, the -//! callback trampolines and listener register/unregister exports, iterator -//! natives, and the parameter/return marshalling helpers they share. +//! JNI C bridge emission: sync and async exports, interface natives +//! (`nativeClone`/`nativeDestroy` included), the callback-interface vtable +//! trampolines and static vtables, iterator natives, and the parameter and +//! return marshalling helpers they share. use std::fmt::Write as _; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - CallShape, CallbackBinding, FnBinding, InterfaceBinding, IteratorBinding, ListenerBinding, - ModuleBinding, ParamBinding, + CallShape, CallbackInterfaceBinding, CallbackMethodBinding, FnBinding, InterfaceBinding, + IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{self, ArgPass, ElemFree, RetPass}; +use weaveffi_core::plan::{self, ArgPass, Free, RetPass}; +use weaveffi_core::utils::c_abi_struct_name; use crate::calls::interface_native_name; use crate::docs::splice; -use crate::runtime::jni_thrower_for; +use crate::runtime::{jni_cb_class_var, jni_cb_method_var, jni_thrower_for}; use crate::types::{ - c_local, c_type_for_return, jni_cast_for, jni_default_return, jni_mangle, jni_param_type, - jni_ret_type, kotlin_fn_name, kotlin_iterator_class_name, + c_default_value, c_local, c_type_for_return, jni_call_kind, jni_cast_for, jni_default_return, + jni_mangle, jni_param_type, jni_ret_type, kotlin_fn_name, kotlin_iterator_class_name, }; +/// The C identifier of the process-wide static vtable the shim passes for a +/// callback interface whose C tag is `c_tag`. +pub(crate) fn jni_vtable_var(c_tag: &str) -> String { + format!("{c_tag}_jni_vtable") +} + /// Emit one synchronous JNI export (`Java___`). Interface /// methods pass `self_cast` (the C expression casting `selfHandle` back to the /// receiver pointer), which becomes the leading C call argument. @@ -70,8 +78,6 @@ pub(crate) fn render_jni_sync_export( // An iterator-returning callable launches the C iterator and hands the // opaque handle back as a `jlong`; the Kotlin wrapper class then pulls one // element per `nativeNext` call (see `render_jni_iterator_natives`). - // This needs the launcher symbol carried by the iterator shape, so it is - // handled here rather than in the value-passing return dispatcher. if let CallShape::Iterator(it) = &f.shape { write_iterator_launch(jni_c, it, &call_args, &f.params, thrower); let _ = writeln!(jni_c, "}}\n"); @@ -80,8 +86,10 @@ pub(crate) fn render_jni_sync_export( // Bytes and buffered returns share the `const uint8_t*` + trailing // `size_t* out_len` shape. - let needs_out_len = matches!(f.ret, Some(Ty::Bytes | Ty::BorrowedBytes)) - || f.ret.as_ref().is_some_and(Ty::is_buffered); + let needs_out_len = matches!( + plan::ret_pass(f.ret.as_ref(), module_path, c_prefix), + RetPass::Bytes | RetPass::Buffer + ); if needs_out_len { let _ = writeln!(jni_c, " size_t out_len = 0;"); } @@ -115,8 +123,8 @@ pub(crate) fn render_jni_sync_export( /// Emit the JNI bridge for one interface: constructor, static, and method /// exports named `Java___native` (methods take the -/// leading `selfHandle`), plus the `nativeDestroy` export releasing the -/// object through the interface's destroy symbol. +/// leading `selfHandle`), plus the `nativeClone` and `nativeDestroy` exports +/// wrapping the interface's reference-count pair. pub(crate) fn render_jni_interface( jni_c: &mut String, m: &ModuleBinding, @@ -180,6 +188,18 @@ pub(crate) fn render_jni_interface( } } let mut w = CodeWriter::four_space(); + w.line(format!( + "JNIEXPORT jlong JNICALL Java_{}_{}_nativeClone(JNIEnv* env, jclass clazz, jlong handle) {{", + jni_prefix, i.name + )); + w.scope(|w| { + w.line(format!( + "return (jlong)(intptr_t){}((const {}*)(intptr_t)handle);", + i.clone_symbol, i.c_tag + )); + }); + w.line("}"); + w.blank(); w.line(format!( "JNIEXPORT void JNICALL Java_{}_{}_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) {{", jni_prefix, i.name @@ -196,93 +216,53 @@ pub(crate) fn render_jni_interface( } /// Box one owned async result into the JVM local `boxed` for delivery to -/// the pinned `WeaveContinuation`. Buffered results arrive as an owned -/// `(result_ptr, result_len)` pair, copied into a `jbyteArray` the Kotlin -/// wrapper decodes and then released with `weaveffi_free_bytes`. -fn write_jni_box_result(out: &mut String, ret: Option<&Ty>) { +/// the pinned `WeaveContinuation`, dispatched on the [`RetPass`] plan. +/// Buffered results arrive as an owned `(result_ptr, result_len)` pair, +/// copied into a `jbyteArray` the Kotlin wrapper decodes and then released +/// with `weaveffi_free_bytes`; an object result is one strong reference boxed +/// as a `Long` for the wrapper to adopt. +fn write_jni_box_result(out: &mut String, ret: Option<&Ty>, module: &str, c_prefix: &str) { let mut w = CodeWriter::four_space().with_depth(2); - if ret.is_some_and(Ty::is_buffered) { - w.line("jbyteArray boxed = (*env)->NewByteArray(env, (jsize)result_len);"); - w.line("if (boxed && result_ptr) { (*env)->SetByteArrayRegion(env, boxed, 0, (jsize)result_len, (const jbyte*)result_ptr); }"); - w.line("weaveffi_free_bytes((uint8_t*)result_ptr, result_len);"); - w.line("jclass cls = (*env)->GetObjectClass(env, ctx->callback);"); - w.line( - "jmethodID mid = (*env)->GetMethodID(env, cls, \"onSuccess\", \"(Ljava/lang/Object;)V\");", - ); - w.line("(*env)->CallVoidMethod(env, ctx->callback, mid, boxed);"); - out.push_str(&w.finish()); - return; - } - match ret { - None => { + match plan::ret_pass(ret, module, c_prefix) { + RetPass::Void => { w.line("jobject boxed = NULL;"); } - Some(Ty::I8 | Ty::U8) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Byte\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(B)Ljava/lang/Byte;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jbyte)result);"); - } - Some(Ty::I16 | Ty::U16) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Short\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(S)Ljava/lang/Short;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jshort)result);"); - } - Some(Ty::I32 | Ty::Enum(_)) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Integer\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(I)Ljava/lang/Integer;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jint)result);"); - } - Some(Ty::U32 | Ty::I64 | Ty::U64 | Ty::Handle) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Long\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(J)Ljava/lang/Long;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jlong)result);"); - } - // A typed handle or owned interface result arrives as a pointer slot; - // the boxed `Long` carries the pointer bits for the wrapper to adopt. - Some(Ty::TypedHandle(_) | Ty::Interface(_)) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Long\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(J)Ljava/lang/Long;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jlong)(intptr_t)result);"); - } - Some(Ty::F64) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Double\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(D)Ljava/lang/Double;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jdouble)result);"); - } - Some(Ty::F32) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Float\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(F)Ljava/lang/Float;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, (jfloat)result);"); - } - Some(Ty::Bool) => { - w.line("jclass boxCls = (*env)->FindClass(env, \"java/lang/Boolean\");"); - w.line("jmethodID valueOf = (*env)->GetStaticMethodID(env, boxCls, \"valueOf\", \"(Z)Ljava/lang/Boolean;\");"); - w.line("jobject boxed = (*env)->CallStaticObjectMethod(env, boxCls, valueOf, result ? JNI_TRUE : JNI_FALSE);"); - } - Some(Ty::StringUtf8 | Ty::BorrowedStr) => { - // Owned by the consumer: copy, then free. - w.line("jobject boxed = result ? (jobject)(*env)->NewStringUTF(env, result) : (jobject)(*env)->NewStringUTF(env, \"\");"); - w.line("weaveffi_free_string(result);"); + RetPass::Buffer => { + w.line("jbyteArray boxed = (*env)->NewByteArray(env, (jsize)result_len);"); + w.line("if (boxed && result_ptr) { (*env)->SetByteArrayRegion(env, boxed, 0, (jsize)result_len, (const jbyte*)result_ptr); }"); + w.line("weaveffi_free_bytes((uint8_t*)result_ptr, result_len);"); } - Some(Ty::Bytes | Ty::BorrowedBytes) => { - // Owned by the consumer: copy, then free. + RetPass::Bytes => { w.line("jbyteArray boxed = (*env)->NewByteArray(env, (jsize)result_len);"); w.line("if (boxed && result) { (*env)->SetByteArrayRegion(env, boxed, 0, (jsize)result_len, (const jbyte*)result); }"); w.line("weaveffi_free_bytes((uint8_t*)result, result_len);"); } - // Only `Interface?` reaches here (every other optional is buffered): - // a nullable owned pointer boxed as `Long`, null crossing as `null`. - Some(Ty::Optional(_)) => { - w.line("jobject boxed = NULL;"); - w.block("if (result != NULL) {", "}", |w| { - splice(w, |o| { - write_boxed_scalar(o, &Ty::Handle, "_opt", "(intptr_t)result", " ") + RetPass::String => { + w.line("jobject boxed = (jobject)weaveffi_jni_utf8_to_string(env, result);"); + w.line("weaveffi_free_string(result);"); + } + // One strong reference: the boxed `Long` carries the pointer bits for + // the wrapper to adopt; a null nullable result crosses as `null`. + RetPass::Object { nullable, .. } => { + if nullable { + w.line("jobject boxed = NULL;"); + w.block("if (result != NULL) {", "}", |w| { + splice(w, |o| { + write_boxed_scalar(o, &Ty::I64, "_opt", "(intptr_t)result", " ") + }); + w.line("boxed = _opt;"); }); - w.line("boxed = _opt;"); - }); + } else { + splice(&mut w, |o| { + write_boxed_scalar(o, &Ty::I64, "boxed", "(intptr_t)result", " ") + }); + } } - Some(Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) | Ty::Iterator(_)) => { - unreachable!("buffered results are handled above; iterators cannot be async") + RetPass::Direct => { + let ty = ret.expect("direct results have a type"); + splice(&mut w, |o| { + write_boxed_scalar(o, ty, "boxed", "result", " ") + }); } } w.line("jclass cls = (*env)->GetObjectClass(env, ctx->callback);"); @@ -329,21 +309,11 @@ pub(crate) fn render_jni_async_function( )); w.scope(|w| { w.line("weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context;"); - // The producer invokes this from its own worker thread, which usually is - // not a JVM thread: attach if needed and detach before the thread exits. - // A thread that dies while still attached leaves the JVM with a zombie - // attachment record, hanging process shutdown (DestroyJavaVM never sees - // the thread terminate cleanly). + // The producer invokes this from its own worker thread, which usually + // is not a JVM thread: attach if needed and detach before returning. w.line("JNIEnv* env = NULL;"); - w.line("int attached = 0;"); - w.block( - "if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {", - "}", - |w| { - w.line("if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; }"); - w.line("attached = 1;"); - }, - ); + w.line("int attached = weaveffi_jni_attach(&env);"); + w.line("if (env == NULL) { free(ctx); return; }"); w.line("if (err != NULL && err->code != 0) {"); w.scope(|w| { // The raw `(code, message, payload)` triple crosses to Kotlin, @@ -353,7 +323,7 @@ pub(crate) fn render_jni_async_function( // error is owned: copy its fields, then release it with // `weaveffi_error_free`. w.line("const char* msg = err->message ? err->message : \"WeaveFFI error\";"); - w.line("jstring jmsg = (*env)->NewStringUTF(env, msg);"); + w.line("jstring jmsg = weaveffi_jni_utf8_to_string(env, msg);"); w.line("jbyteArray jpayload = NULL;"); w.block("if (err->payload_ptr != NULL) {", "}", |w| { w.line("jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len);"); @@ -367,7 +337,9 @@ pub(crate) fn render_jni_async_function( }); w.line("} else {"); w.scope(|w| { - splice(w, |o| write_jni_box_result(o, f.ret.as_ref())); + splice(w, |o| { + write_jni_box_result(o, f.ret.as_ref(), module_name, c_prefix) + }); }); w.line("}"); // An exception thrown by the continuation's resume path has no Kotlin @@ -375,9 +347,8 @@ pub(crate) fn render_jni_async_function( // or log it via ExceptionDescribe before clearing. w.line("weaveffi_jni_handle_uncaught(env);"); w.line("(*env)->DeleteGlobalRef(env, ctx->callback);"); - w.line("JavaVM* jvm = ctx->jvm;"); w.line("free(ctx);"); - w.line("if (attached) (*jvm)->DetachCurrentThread(jvm);"); + w.line("weaveffi_jni_detach(attached);"); }); w.line("}"); w.blank(); @@ -405,7 +376,6 @@ pub(crate) fn render_jni_async_function( )); w.scope(|w| { w.line("weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx));"); - w.line("(*env)->GetJavaVM(env, &ctx->jvm);"); w.line("ctx->callback = (*env)->NewGlobalRef(env, callback);"); for p in &f.params { @@ -434,245 +404,193 @@ pub(crate) fn render_jni_async_function( out.push_str(&w.finish()); } -/// Box one C ABI callback argument into a JVM local reference named `var`. -/// Buffered arguments arrive as a borrowed `(ptr, len)` pair, valid only for -/// the dispatch: they are deep-copied into a `jbyteArray` the Kotlin wrapper -/// decodes. Only bootstrap classes (`java/lang/*`) are used: trampolines run -/// on producer threads whose class loader cannot see app classes. -fn write_jni_cb_box_arg(out: &mut String, p: &ParamBinding, var: &str) { - let slots = &p.abi; - let n0 = c_local(&slots[0].name); - let mut w = CodeWriter::four_space().with_depth(1); - if p.ty.is_buffered() { - let n1 = &slots[1].name; - w.line(format!( - "jbyteArray {var} = (*env)->NewByteArray(env, (jsize){n1});" - )); - w.line(format!( - "if ({var} && {n0}) {{ (*env)->SetByteArrayRegion(env, {var}, 0, (jsize){n1}, (const jbyte*){n0}); }}" - )); - out.push_str(&w.finish()); - return; - } - match &p.ty { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::I64 - | Ty::U64 - | Ty::F32 - | Ty::F64 - | Ty::Bool - | Ty::Enum(_) - | Ty::Handle => { - splice(&mut w, |o| write_boxed_scalar(o, &p.ty, var, &n0, " ")); +/// Emit the C local `var` a callback trampoline hands to the Kotlin dispatch +/// shim for one method argument, received like a return ([`RetPass`]): +/// strings, bytes, and buffers are borrowed for the call and deep-copied into +/// JVM values (the trampoline frees nothing); an object argument is one strong +/// reference passed as its raw pointer bits for the shim to adopt (`0` for a +/// null `Interface?`); direct scalars are cast to their JNI type. +fn write_jni_cb_arg(w: &mut CodeWriter, p: &ParamBinding, var: &str) { + match p.arg_pass() { + ArgPass::Direct { slot } => { + let src = c_local(&slot.name); + let jt = jni_param_type(&p.ty); + if matches!(p.ty, Ty::Bool) { + w.line(format!("{jt} {var} = {src} ? JNI_TRUE : JNI_FALSE;")); + } else { + w.line(format!("{jt} {var} = ({jt}){src};")); + } } - Ty::StringUtf8 | Ty::BorrowedStr => { + ArgPass::String { slot } => { + let src = c_local(&slot.name); w.line(format!( - "jobject {var} = {n0} ? (jobject)(*env)->NewStringUTF(env, {n0}) : (jobject)(*env)->NewStringUTF(env, \"\");" + "jstring {var} = weaveffi_jni_utf8_to_string(env, {src});" )); } - Ty::Bytes | Ty::BorrowedBytes => { - let n1 = &slots[1].name; + ArgPass::Bytes { ptr, len } | ArgPass::Buffer { ptr, len } => { + let (ptr, len) = (c_local(&ptr.name), c_local(&len.name)); w.line(format!( - "jbyteArray {var} = (*env)->NewByteArray(env, (jsize){n1});" + "jbyteArray {var} = (*env)->NewByteArray(env, (jsize){len});" )); w.line(format!( - "if ({var} && {n0}) {{ (*env)->SetByteArrayRegion(env, {var}, 0, (jsize){n1}, (const jbyte*){n0}); }}" + "if ({var} && {ptr}) {{ (*env)->SetByteArrayRegion(env, {var}, 0, (jsize){len}, (const jbyte*){ptr}); }}" )); } - Ty::TypedHandle(_) | Ty::Interface(_) => { - splice(&mut w, |o| { - write_boxed_scalar(o, &Ty::Handle, var, &format!("(intptr_t){n0}"), " ") - }); - } - // Only `Interface?` reaches here: a nullable borrowed pointer boxed - // as `Long`, null crossing as `null`. - Ty::Optional(_) => { - w.line(format!("jobject {var} = NULL;")); - w.block(format!("if ({n0}) {{"), "}", |w| { - splice(w, |o| { - write_boxed_scalar( - o, - &Ty::Handle, - &format!("{var}_box"), - &format!("(intptr_t){n0}"), - " ", - ) - }); - w.line(format!("{var} = {var}_box;")); - }); + ArgPass::Object { slot, .. } => { + w.line(format!( + "jlong {var} = (jlong)(intptr_t){};", + c_local(&slot.name) + )); } - Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { - unreachable!("buffered callback arguments are handled above") + ArgPass::Callback { .. } => { + unreachable!("validation rejects callback interfaces inside callback methods") } - Ty::Iterator(_) => unreachable!("validation rejects iterator callback params"), } - out.push_str(&w.finish()); } -/// The producer-thread trampoline for one callback type: attach to the JVM if -/// needed, box every C argument, and invoke the pinned Kotlin lambda through -/// the erased `kotlin.jvm.functions.FunctionN.invoke(Object...)` method. -pub(crate) fn render_jni_cb_tramp(out: &mut String, cb: &CallbackBinding, c_prefix: &str) { - // The precomputed ABI slot list already carries the trailing `void* - // context` and module-qualified slot types. - let decls: Vec = cb +/// Emit the producer-thread trampoline for one callback-interface method: +/// attach to the `JavaVM` when the calling thread isn't one, convert every C +/// argument, invoke the cached dispatch shim with the global-ref `ctx` as the +/// implementing object, and, when the JVM raised, report through +/// `{prefix}_error_set(out_err, -4, throwable.toString())` and return the +/// default value. Nothing ever unwinds through the C frame. +fn render_jni_cb_trampoline( + out: &mut String, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + c_prefix: &str, +) { + let decls: Vec = m .abi_params .iter() .map(|slot| format!("{} {}", slot.ty.render_c(c_prefix), c_local(&slot.name))) .collect(); + let out_err = m + .abi_params + .last() + .map(|s| c_local(&s.name)) + .unwrap_or_else(|| "out_err".to_string()); + let (call_kind, jret) = jni_call_kind(m.ret.as_ref()); + let default = c_default_value(m.ret.as_ref()); + let ret_stmt = if m.ret.is_some() { + format!("return {default};") + } else { + "return;".to_string() + }; + let cls = jni_cb_class_var(cb); + let mid = jni_cb_method_var(cb, &m.name); + let mut w = CodeWriter::four_space(); w.line(format!( - "static void {}_jni_tramp({}) {{", - cb.c_fn_type, + "static {} {}_jni_{}({}) {{", + m.abi_ret.render_c(c_prefix), + cb.c_tag, + m.name, decls.join(", ") )); w.scope(|w| { - w.line("weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context;"); w.line("JNIEnv* env = NULL;"); - w.line("int attached = 0;"); - w.block( - "if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {", - "}", - |w| { - w.line("if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return;"); - w.line("attached = 1;"); - }, - ); - // A local frame bounds every reference created while boxing, so event - // bursts on a long-lived JVM thread cannot exhaust the local-ref table. - w.block("if ((*env)->PushLocalFrame(env, 32) != 0) {", "}", |w| { - w.line("if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);"); - w.line("return;"); + w.line("int attached = weaveffi_jni_attach(&env);"); + w.block("if (env == NULL) {", "}", |w| { + w.line(format!( + "{c_prefix}_error_set({out_err}, -4, \"WeaveFFI: could not attach the calling thread to the JavaVM\");" + )); + w.line(&ret_stmt); }); - let mut arg_vars: Vec = Vec::new(); - for (i, p) in cb.params.iter().enumerate() { + // A local frame bounds every reference created while converting, so + // bursts of calls on a long-lived producer thread cannot exhaust the + // local-ref table. + w.block("if ((*env)->PushLocalFrame(env, 16) != 0) {", "}", |w| { + w.line("(*env)->ExceptionClear(env);"); + w.line(format!( + "{c_prefix}_error_set({out_err}, -4, \"WeaveFFI: JNI local frame exhausted\");" + )); + w.line("weaveffi_jni_detach(attached);"); + w.line(&ret_stmt); + }); + let mut arg_vars: Vec = vec!["(jobject)ctx".to_string()]; + for (i, p) in m.params.iter().enumerate() { let var = format!("_a{i}"); - splice(w, |o| write_jni_cb_box_arg(o, p, &var)); + write_jni_cb_arg(w, p, &var); arg_vars.push(var); } - w.line("jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback);"); - let sig = format!( - "({})Ljava/lang/Object;", - "Ljava/lang/Object;".repeat(cb.params.len()) + let call = format!( + "(*env)->CallStatic{call_kind}Method(env, {cls}, {mid}, {})", + arg_vars.join(", ") ); - w.line(format!( - "jmethodID invoke = (*env)->GetMethodID(env, fn_cls, \"invoke\", \"{sig}\");" - )); - let call_args = if arg_vars.is_empty() { - String::new() + if m.ret.is_some() { + w.line(format!("{jret} _rv = {call};")); } else { - format!(", {}", arg_vars.join(", ")) - }; - w.line(format!( - "(*env)->CallObjectMethod(env, ctx->callback, invoke{call_args});" - )); - // A listener exception has no Kotlin caller on this producer thread: - // route it to the installed handler, or log it via ExceptionDescribe - // before clearing. - w.line("weaveffi_jni_handle_uncaught(env);"); + w.line(format!("{call};")); + } + w.block("if ((*env)->ExceptionCheck(env)) {", "}", |w| { + w.line(format!("weaveffi_jni_report_foreign(env, {out_err});")); + if m.ret.is_some() { + w.line(format!("_rv = ({jret}){default};")); + } + }); w.line("(*env)->PopLocalFrame(env, NULL);"); - w.line("if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);"); + w.line("weaveffi_jni_detach(attached);"); + if m.ret.is_some() { + let cast = if matches!(m.ret, Some(Ty::Bool)) { + "_rv == JNI_TRUE".to_string() + } else { + format!("({})_rv", m.abi_ret.render_c(c_prefix)) + }; + w.line(format!("return {cast};")); + } }); w.line("}"); w.blank(); out.push_str(&w.finish()); } -/// The JNI register/unregister exports for one listener. Register pins the -/// Kotlin lambda with a global ref and links the context into the registry; -/// unregister stops producer-side delivery first, then unpins and frees. -pub(crate) fn render_jni_listener_fns( +/// Emit the vtable surface for one callback interface: one trampoline per +/// method, the `free` entry that deletes the global reference pinning the +/// Kotlin implementation, and the process-wide static vtable the exports pass +/// alongside that global reference as `ctx`. +pub(crate) fn render_jni_callback_interface( out: &mut String, - module_path: &str, - l: &ListenerBinding, - cb: &CallbackBinding, - jni_prefix: &str, - strip_module_prefix: bool, + cb: &CallbackInterfaceBinding, + c_prefix: &str, ) { - let mut register_kt = kotlin_fn_name( - module_path, - &format!("register_{}", l.name), - strip_module_prefix, - ); - // When the callback has buffered params, the Kotlin external is the - // private `{register}Jni` behind the decoding wrapper. - if cb.params.iter().any(|p| p.ty.is_buffered()) { - register_kt.push_str("Jni"); - } - let unregister_kt = kotlin_fn_name( - module_path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ); - - { - let mut w = CodeWriter::four_space(); - w.line(format!( - "JNIEXPORT jlong JNICALL Java_{}_WeaveFFI_{}(JNIEnv* env, jclass clazz, jobject callback) {{", - jni_prefix, - jni_mangle(®ister_kt) - )); - w.scope(|w| { - w.line("weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx));"); - w.line("(*env)->GetJavaVM(env, &ctx->jvm);"); - w.line("ctx->callback = (*env)->NewGlobalRef(env, callback);"); - w.line(format!( - "uint64_t id = {}({}_jni_tramp, ctx);", - l.register_symbol, cb.c_fn_type - )); - w.line("ctx->id = id;"); - w.line("pthread_mutex_lock(&weaveffi_jni_listener_lock);"); - w.line("ctx->next = weaveffi_jni_listeners;"); - w.line("weaveffi_jni_listeners = ctx;"); - w.line("pthread_mutex_unlock(&weaveffi_jni_listener_lock);"); - w.line("return (jlong)id;"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); - } - - { - let mut w = CodeWriter::four_space(); - w.line(format!( - "JNIEXPORT void JNICALL Java_{}_WeaveFFI_{}(JNIEnv* env, jclass clazz, jlong id) {{", - jni_prefix, - jni_mangle(&unregister_kt) - )); - w.scope(|w| { - // Stop producer-side delivery before unpinning so no trampoline can fire - // against a deleted global ref. - w.line(format!("{}((uint64_t)id);", l.unregister_symbol)); - w.line("pthread_mutex_lock(&weaveffi_jni_listener_lock);"); - w.line("weaveffi_jni_listener_ctx** link = &weaveffi_jni_listeners;"); - w.block("while (*link != NULL) {", "}", |w| { - w.block("if ((*link)->id == (uint64_t)id) {", "}", |w| { - w.line("weaveffi_jni_listener_ctx* found = *link;"); - w.line("*link = found->next;"); - w.line("(*env)->DeleteGlobalRef(env, found->callback);"); - w.line("free(found);"); - w.line("break;"); - }); - w.line("link = &(*link)->next;"); - }); - w.line("pthread_mutex_unlock(&weaveffi_jni_listener_lock);"); - }); - w.line("}"); - w.blank(); - out.push_str(&w.finish()); + for m in &cb.methods { + render_jni_cb_trampoline(out, cb, m, c_prefix); } + let mut w = CodeWriter::four_space(); + w.line(format!("static void {}_jni_free(void* ctx) {{", cb.c_tag)); + w.scope(|w| { + w.line("if (ctx == NULL) { return; }"); + w.line("JNIEnv* env = NULL;"); + w.line("int attached = weaveffi_jni_attach(&env);"); + w.line("if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); }"); + w.line("weaveffi_jni_detach(attached);"); + }); + w.line("}"); + w.blank(); + let mut entries: Vec = cb + .methods + .iter() + .map(|m| format!("{}_jni_{}", cb.c_tag, m.name)) + .collect(); + entries.push(format!("{}_jni_free", cb.c_tag)); + w.line(format!( + "static const {} {} = {{ {} }};", + cb.vtable_tag, + jni_vtable_var(&cb.c_tag), + entries.join(", ") + )); + w.blank(); + out.push_str(&w.finish()); } /// Emit the acquisition statements one parameter needs before the C call, /// dispatched on its [`ArgPass`] plan: buffered and bytes parameters pin -/// their `jbyteArray` elements, strings pin UTF-8 chars, and a nullable -/// object unboxes its `java.lang.Long` into the raw pointer value (0 = none). -/// Direct scalars and non-null objects need nothing. +/// their `jbyteArray` elements, strings pin UTF-8 chars, a nullable object +/// unboxes its `java.lang.Long` into the raw pointer value (0 = none), and a +/// callback interface pins the implementing object with a global reference +/// that becomes the vtable `ctx`. Direct scalars and non-null objects need +/// nothing. fn write_param_acquire(out: &mut String, p: &ParamBinding) { let name = c_local(&p.name); let mut w = CodeWriter::four_space().with_depth(1); @@ -692,7 +610,7 @@ fn write_param_acquire(out: &mut String, p: &ParamBinding) { } ArgPass::String { .. } => { w.line(format!( - "const char* {n}_chars = (*env)->GetStringUTFChars(env, {n}, NULL);", + "char* {n}_chars = weaveffi_jni_string_to_utf8(env, {n});", n = name )); } @@ -726,6 +644,14 @@ fn write_param_acquire(out: &mut String, p: &ParamBinding) { )); }); } + // The global reference keeps the implementation alive for as long as + // the producer holds the callback; the vtable's `free` deletes it. + ArgPass::Callback { .. } => { + w.line(format!( + "jobject {n}_ref = (*env)->NewGlobalRef(env, {n});", + n = name + )); + } ArgPass::Direct { .. } | ArgPass::Object { nullable: false, .. @@ -737,8 +663,9 @@ fn write_param_acquire(out: &mut String, p: &ParamBinding) { /// Append the C call arguments one parameter lowers to, dispatched on its /// [`ArgPass`] plan: buffered and bytes parameters pass their pinned /// `(ptr, len)` pair, strings their pinned chars, objects the borrowed -/// pointer cast to the interface's C struct type, and direct scalars a -/// value cast matching the C header's declaration type. +/// pointer cast to the interface's C struct type, callback interfaces the +/// global reference as `ctx` plus the interface's static vtable, and direct +/// scalars a value cast matching the C header's declaration type. fn build_c_call_args(args: &mut Vec, p: &ParamBinding, module: &str, c_prefix: &str) { let name = c_local(&p.name); match p.arg_pass() { @@ -755,21 +682,24 @@ fn build_c_call_args(args: &mut Vec, p: &ParamBinding, module: &str, c_p // Kotlin wrapper keeps ownership and only lends the pointer. The // nullable spelling passes the unboxed pointer value acquired above. ArgPass::Object { nullable, .. } => { - let iname = match &p.ty { - Ty::Interface(iname) => iname, - Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(iname) => iname, - _ => unreachable!("non-interface optionals are buffered"), - }, - _ => unreachable!("object-passed parameters are interfaces"), - }; - let c_struct = weaveffi_core::utils::c_abi_struct_name(iname, module, c_prefix); + let iname = + p.ty.interface_name() + .expect("object-passed parameters are interfaces"); + let c_struct = c_abi_struct_name(iname, module, c_prefix); if nullable { args.push(format!("(const {}*)(intptr_t){}_val", c_struct, name)); } else { args.push(format!("(const {}*)(intptr_t){}", c_struct, name)); } } + ArgPass::Callback { .. } => { + let Ty::CallbackInterface(cname) = &p.ty else { + unreachable!("callback-passed parameters are callback interfaces") + }; + let c_tag = c_abi_struct_name(cname, module, c_prefix); + args.push(format!("(void*){name}_ref")); + args.push(format!("&{}", jni_vtable_var(&c_tag))); + } ArgPass::Direct { .. } => match &p.ty { Ty::Bool => args.push(format!("(bool)({} == JNI_TRUE)", name)), Ty::I8 => args.push(format!("(int8_t){}", name)), @@ -782,14 +712,6 @@ fn build_c_call_args(args: &mut Vec, p: &ParamBinding, module: &str, c_p Ty::U64 => args.push(format!("(uint64_t){}", name)), Ty::F32 => args.push(format!("(float){}", name)), Ty::F64 => args.push(format!("(double){}", name)), - Ty::Handle => args.push(format!("(weaveffi_handle_t){}", name)), - // A typed handle lowers to the owner-qualified C struct pointer - // (mutable receiver), so the cross-module JNI shim must cast - // through that pointer rather than the generic integer handle. - Ty::TypedHandle(sname) => { - let c_struct = weaveffi_core::utils::c_abi_struct_name(sname, module, c_prefix); - args.push(format!("({}*)(intptr_t){}", c_struct, name)); - } Ty::Enum(_) => args.push(format!("(int32_t){}", name)), other => unreachable!("{other:?} is not passed directly"), }, @@ -856,52 +778,39 @@ fn write_return_handling( w.line(format!("const char* rv = {}({});", c_sym, call_with_err)); splice(&mut w, |o| release_jni_resources(o, params)); splice(&mut w, |o| write_error_check(o, returns, thrower)); - w.line("jstring out = rv ? (*env)->NewStringUTF(env, rv) : (*env)->NewStringUTF(env, \"\");"); + w.line("jstring out = weaveffi_jni_utf8_to_string(env, rv);"); w.line("weaveffi_free_string(rv);"); w.line("return out;"); } - // Only `Interface?` is a nullable object return: the C function - // returns a nullable owned pointer, boxed for Kotlin's `Long?` as a - // `java.lang.Long` or NULL. - RetPass::Object { nullable: true, .. } => { - let Ty::Optional(inner) = ret_type else { - unreachable!("nullable object returns are optionals") - }; - let Ty::Interface(iname) = inner.as_ref() else { - unreachable!("non-interface optionals are buffered") - }; - let c_ty = weaveffi_core::utils::c_abi_struct_name(iname, module, c_prefix); + // One strong reference the wrapper adopts. Only `Interface?` is a + // nullable object return: the C function returns a nullable owned + // pointer, boxed for Kotlin's `Long?` as a `java.lang.Long` or NULL. + RetPass::Object { nullable, .. } => { + let iname = ret_type + .interface_name() + .expect("object returns are interfaces"); + let c_ty = c_abi_struct_name(iname, module, c_prefix); w.line(format!("{}* rv = {}({});", c_ty, c_sym, call_with_err)); splice(&mut w, |o| release_jni_resources(o, params)); splice(&mut w, |o| write_error_check(o, returns, thrower)); - w.line("if (rv == NULL) { return NULL; }"); - w.line("jclass box_cls = (*env)->FindClass(env, \"java/lang/Long\");"); - w.line("jmethodID box_mid = (*env)->GetStaticMethodID(env, box_cls, \"valueOf\", \"(J)Ljava/lang/Long;\");"); - w.line( - "return (*env)->CallStaticObjectMethod(env, box_cls, box_mid, (jlong)(intptr_t)rv);", - ); + if nullable { + w.line("if (rv == NULL) { return NULL; }"); + w.line("jclass box_cls = (*env)->FindClass(env, \"java/lang/Long\");"); + w.line("jmethodID box_mid = (*env)->GetStaticMethodID(env, box_cls, \"valueOf\", \"(J)Ljava/lang/Long;\");"); + w.line( + "return (*env)->CallStaticObjectMethod(env, box_cls, box_mid, (jlong)(intptr_t)rv);", + ); + } else { + w.line("return (jlong)(intptr_t)rv;"); + } } - RetPass::Direct - | RetPass::Object { - nullable: false, .. - } => match ret_type { + RetPass::Direct => match ret_type { Ty::Bool => { w.line(format!("bool rv = {}({});", c_sym, call_with_err)); splice(&mut w, |o| release_jni_resources(o, params)); splice(&mut w, |o| write_error_check(o, returns, thrower)); w.line("return rv ? JNI_TRUE : JNI_FALSE;"); } - // A typed handle lowers to the owner-qualified C struct pointer, - // so the return variable must be that pointer (not the generic - // integer handle) and round-trip through `intptr_t`. The untyped - // `Handle` case stays in the scalar fallthrough below. - Ty::TypedHandle(name) => { - let c_ty = weaveffi_core::utils::c_abi_struct_name(name, module, c_prefix); - w.line(format!("{}* rv = {}({});", c_ty, c_sym, call_with_err)); - splice(&mut w, |o| release_jni_resources(o, params)); - splice(&mut w, |o| write_error_check(o, returns, thrower)); - w.line("return (jlong)(intptr_t)rv;"); - } ret_type => { let c_ty = c_type_for_return(ret_type); let jcast = jni_cast_for(ret_type); @@ -921,15 +830,18 @@ fn iter_item_c_type(elem: &Ty, module: &str, c_prefix: &str) -> String { weaveffi_core::model::iterator_item_ctype(elem, module).render_c(c_prefix) } -/// Box one iterator element scalar `src` (a plain lvalue) into a JVM -/// reference `var`. +/// Box one scalar or pointer `src` (a plain lvalue or cast expression) into a +/// JVM reference `var`. Interfaces box their pointer bits as a `Long` the +/// wrapper adopts. Only bootstrap classes are looked up, so this is safe on +/// attached producer threads. fn write_boxed_scalar(out: &mut String, ty: &Ty, var: &str, src: &str, indent: &str) { let mut w = CodeWriter::four_space().with_depth(indent.len() / 4); match ty { - Ty::StringUtf8 | Ty::BorrowedStr => { + Ty::StringUtf8 => { w.line(format!( - "jstring {v} = {s} ? (*env)->NewStringUTF(env, {s}) : (*env)->NewStringUTF(env, \"\");", - v = var, s = src + "jstring {v} = weaveffi_jni_utf8_to_string(env, {s});", + v = var, + s = src )); } Ty::I8 | Ty::U8 => { @@ -960,7 +872,7 @@ fn write_boxed_scalar(out: &mut String, ty: &Ty, var: &str, src: &str, indent: & )); w.line(format!("jobject {v} = (*env)->CallStaticObjectMethod(env, {v}_cls, (*env)->GetStaticMethodID(env, {v}_cls, \"valueOf\", \"(J)Ljava/lang/Long;\"), (jlong){s});", v = var, s = src)); } - Ty::TypedHandle(_) | Ty::Handle | Ty::Interface(_) => { + Ty::Interface(_) => { w.line(format!( "jclass {v}_cls = (*env)->FindClass(env, \"java/lang/Long\");", v = var @@ -988,13 +900,7 @@ fn write_boxed_scalar(out: &mut String, ty: &Ty, var: &str, src: &str, indent: & )); w.line(format!("jobject {v} = (*env)->CallStaticObjectMethod(env, {v}_cls, (*env)->GetStaticMethodID(env, {v}_cls, \"valueOf\", \"(Z)Ljava/lang/Boolean;\"), {s} ? JNI_TRUE : JNI_FALSE);", v = var, s = src)); } - _ => { - w.line(format!( - "jobject {v} = (jobject)(intptr_t){s};", - v = var, - s = src - )); - } + other => unreachable!("{other:?} is never boxed as a scalar"), } out.push_str(&w.finish()); } @@ -1031,10 +937,12 @@ fn write_iterator_launch( /// generated Kotlin iterator class. `nativeNext` pulls exactly one element: /// it returns a one-slot `Object[]` holding the boxed element, or `NULL` when /// the producer is exhausted (a pending JNI exception distinguishes the error -/// case). Each element is freed per its [`ElemFree`] plan: strings are -/// released with `weaveffi_free_string` after `NewStringUTF`; bytes and -/// buffered elements are copied into a `jbyteArray` and released with -/// `weaveffi_free_bytes`. +/// case). Each element is received like a return and freed per its [`Free`] +/// plan: strings are released with `weaveffi_free_string` after conversion +/// to a `jstring`; bytes and buffered elements are copied into a `jbyteArray` +/// and released with `weaveffi_free_bytes`; an object element's strong +/// reference is boxed as a `Long` the wrapper adopts (`0L` for a null +/// `Interface?`). pub(crate) fn render_jni_iterator_natives( out: &mut String, it: &IteratorBinding, @@ -1088,16 +996,16 @@ pub(crate) fn render_jni_iterator_natives( }); w.line("if (_has == 0) { return NULL; }"); match free { - ElemFree::Bytes => { + Free::Bytes => { w.line("jbyteArray _jitem = (*env)->NewByteArray(env, (jsize)_item_len);"); w.line("if (_jitem && _item) { (*env)->SetByteArrayRegion(env, _jitem, 0, (jsize)_item_len, (const jbyte*)_item); }"); w.line("weaveffi_free_bytes((uint8_t*)_item, _item_len);"); } - ElemFree::String => { + Free::String => { splice(w, |o| write_boxed_scalar(o, leaf, "_jitem", "_item", " ")); w.line("weaveffi_free_string(_item);"); } - ElemFree::None => { + Free::None => { splice(w, |o| write_boxed_scalar(o, leaf, "_jitem", "_item", " ")); } } @@ -1146,8 +1054,9 @@ fn write_error_check(out: &mut String, ret_type: Option<&Ty>, thrower: &str) { /// Release the borrowed JNI resources acquired for `params`, dispatched on /// each parameter's [`ArgPass`] plan: pinned buffer encodings are released /// with `JNI_ABORT` (the callee never mutates them), pinned byte arrays with -/// copy-back, and pinned strings through `ReleaseStringUTFChars`. Direct and -/// object parameters pinned nothing. +/// copy-back, and the UTF-8 copy of a string parameter with `free`. Direct +/// and object parameters pinned nothing, and a callback interface's global +/// reference stays alive until the producer calls the vtable's `free`. fn release_jni_resources(out: &mut String, params: &[ParamBinding]) { let mut w = CodeWriter::four_space().with_depth(1); for p in params { @@ -1162,10 +1071,7 @@ fn release_jni_resources(out: &mut String, params: &[ParamBinding]) { )); } ArgPass::String { .. } => { - w.line(format!( - "(*env)->ReleaseStringUTFChars(env, {n}, {n}_chars);", - n = name - )); + w.line(format!("free({n}_chars);", n = name)); } ArgPass::Bytes { .. } => { w.line(format!( @@ -1173,10 +1079,22 @@ fn release_jni_resources(out: &mut String, params: &[ParamBinding]) { n = name )); } - // Objects (an unboxed pointer value, nothing pinned) and direct - // scalars need no release. - ArgPass::Direct { .. } | ArgPass::Object { .. } => {} + ArgPass::Direct { .. } | ArgPass::Object { .. } | ArgPass::Callback { .. } => {} } } out.push_str(&w.finish()); } + +/// The JNI export name of a free function's Kotlin external: the public name +/// when it is a bare `external fun`, `{name}Jni` when it sits behind a +/// wrapper, `{name}Async` for an async launcher. +pub(crate) fn free_fn_jni_name(module_path: &str, f: &FnBinding, strip: bool) -> String { + let func_name = kotlin_fn_name(module_path, &f.name, strip); + if f.is_async { + format!("{func_name}Async") + } else if crate::types::needs_wrapper_split(f) { + format!("{func_name}Jni") + } else { + func_name + } +} diff --git a/crates/weaveffi-gen-kotlin/src/lib.rs b/crates/weaveffi-gen-kotlin/src/lib.rs new file mode 100644 index 00000000..c3a809cf --- /dev/null +++ b/crates/weaveffi-gen-kotlin/src/lib.rs @@ -0,0 +1,443 @@ +//! Kotlin binding generator for WeaveFFI. +//! +//! Emits a Gradle library module (`kotlin/`) holding a Kotlin wrapper over a +//! JNI C shim that calls the C ABI. Android is the primary runtime (the module +//! is an Android library and the shim builds through the NDK's CMake), and the +//! same sources run on a desktop JVM when the shim is built against a JDK. +//! +//! The wrapper renders records and rich enums as value classes decoded from +//! value buffers, interfaces as `AutoCloseable` wrapper classes whose native +//! reference is released exactly once (from `close()` or a +//! `java.lang.ref.Cleaner` backstop), `iter` callables as lazy `Iterator` +//! classes, async callables as `suspend fun`s, and callback interfaces as +//! Kotlin `interface`s whose implementations are pinned by a JNI global +//! reference and driven through a process-wide static vtable. +//! +//! Implements [`LanguageBackend`]; the shared driver bridges it into the +//! generator pipeline, and [`KotlinGenerator::package`] assembles an +//! AAR-style module bundling prebuilt native libraries. +#![deny(missing_docs)] +#![warn(clippy::missing_errors_doc)] +#![warn(clippy::missing_panics_doc)] +#![warn(clippy::doc_markdown)] + +mod calls; +mod codec; +mod docs; +mod entities; +mod jni; +mod package; +mod runtime; +mod types; + +use camino::Utf8Path; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; +use weaveffi_core::backend::{LanguageBackend, OutputFile}; +use weaveffi_core::capabilities::TargetCapabilities; +use weaveffi_core::model::{BindingModel, CallShape}; +use weaveffi_core::package::{PackageContext, PackagedFile}; +use weaveffi_core::pkg; +use weaveffi_core::resolved::ResolvedApi; +use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; + +use crate::calls::render_kotlin_free_fn; +use crate::entities::{ + render_kotlin_callback_interface, render_kotlin_enum, render_kotlin_error_types, + render_kotlin_interface, render_kotlin_iterator_class, render_kotlin_struct, +}; +use crate::jni::{ + free_fn_jni_name, render_jni_async_function, render_jni_callback_interface, + render_jni_interface, render_jni_iterator_natives, render_jni_sync_export, +}; +use crate::package::{ + build_gradle_kts, cmake_lists, packaged_build_gradle_kts, packaged_cmake_lists, + packaged_header, packaged_readme, settings_gradle_kts, +}; +use crate::runtime::{ + domain_thrower_used, jni_thrower_for, render_jni_domain_thrower, + render_jni_foreign_error_support, render_jni_generic_thrower, render_jni_onload, + render_jni_string_helpers, render_jni_thread_helpers, render_jni_uncaught_support, + render_kotlin_buffer_runtime, render_kotlin_exception_handler_api, + render_kotlin_native_library, render_kotlin_object_runtime, render_weave_continuation, + LibraryLoading, +}; + +/// Per-target configuration for [`KotlinGenerator`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct KotlinConfig { + /// JVM package for the generated Kotlin wrapper (default + /// `"com.weaveffi"`). + pub package: Option, + /// When `true` (the default), strip the IR module name prefix from + /// emitted Kotlin function names. Set to `false` to keep the prefixed + /// spelling (`contactsCreateContact` rather than `createContact`). + pub strip_module_prefix: bool, + /// C ABI symbol prefix (default `"weaveffi"`). Normally set once globally + /// via `[global] c_prefix`; honored so the JNI shim calls the same + /// exported symbols the producer emits. + pub prefix: Option, + /// Basename of the IDL the CLI was invoked with. + #[serde(skip)] + pub input_basename: Option, +} + +impl Default for KotlinConfig { + /// The default configuration strips module prefixes; every other field + /// falls back to `None` and resolves through its accessor. + fn default() -> Self { + Self { + package: None, + strip_module_prefix: true, + prefix: None, + input_basename: None, + } + } +} + +impl KotlinConfig { + /// Returns the configured JVM package, falling back to `"com.weaveffi"`. + pub fn package(&self) -> &str { + self.package.as_deref().unwrap_or("com.weaveffi") + } + + /// Returns the configured C ABI symbol prefix, falling back to + /// `"weaveffi"`. + pub fn prefix(&self) -> &str { + self.prefix.as_deref().unwrap_or("weaveffi") + } + + /// Returns the input IDL basename embedded in generated file headers, + /// falling back to `"weaveffi.yml"`. + pub fn input_basename(&self) -> &str { + self.input_basename.as_deref().unwrap_or("weaveffi.yml") + } +} + +/// Kotlin backend: emits a Gradle library module with a Kotlin wrapper over a +/// JNI shim that calls into the C ABI, for Android and desktop JVMs. +pub struct KotlinGenerator; + +impl LanguageBackend for KotlinGenerator { + type Config = KotlinConfig; + + fn name(&self) -> &'static str { + "kotlin" + } + + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { + TargetCapabilities::full() + } + + fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str { + config.prefix() + } + + fn files( + &self, + api: &ResolvedApi, + model: &BindingModel, + out_dir: &Utf8Path, + config: &Self::Config, + ) -> Vec { + let package = config.package(); + let strip = config.strip_module_prefix; + let input_basename = config.input_basename(); + let dir = out_dir.join("kotlin"); + let pkg_path = package.replace('.', "/"); + let src_dir = dir.join(format!("src/main/kotlin/{pkg_path}")); + let jni_dir = dir.join("src/main/cpp"); + let project_name = pkg::resolve(api, None, config.input_basename.as_deref()).name; + // The generate layout builds the shim as `lib{prefix}.so` from the + // sibling `c/` output; the consumer links the producer library in. + let loading = LibraryLoading::SystemLibrary(model.prefix.clone()); + vec![ + OutputFile::new( + dir.join("settings.gradle.kts"), + settings_gradle_kts(&project_name, input_basename), + ), + OutputFile::new( + dir.join("build.gradle.kts"), + build_gradle_kts(package, input_basename), + ), + OutputFile::new( + src_dir.join("WeaveFFI.kt"), + render_kotlin(model, package, strip, input_basename, &loading), + ), + OutputFile::new( + jni_dir.join("CMakeLists.txt"), + cmake_lists(&model.prefix, input_basename), + ), + OutputFile::new( + jni_dir.join("weaveffi_jni.c"), + render_jni_c(model, package, strip, input_basename), + ), + ] + } + + fn package( + &self, + api: &ResolvedApi, + model: &BindingModel, + ctx: &PackageContext, + out_dir: &Utf8Path, + config: &Self::Config, + ) -> Option> { + let package = config.package(); + let strip = config.strip_module_prefix; + let input_basename = config.input_basename(); + let dir = out_dir.join("kotlin"); + let pkg_path = package.replace('.', "/"); + let src_dir = dir.join(format!("src/main/kotlin/{pkg_path}")); + let jni_dir = dir.join("src/main/cpp"); + let project_name = pkg::resolve(api, None, config.input_basename.as_deref()).name; + let lib = ctx.binaries.lib_name.as_str(); + let loading = LibraryLoading::Packaged { + lib_name: lib.to_string(), + }; + let header_name = format!("{}.h", model.prefix); + + let mut files = vec![ + PackagedFile::text( + dir.join("settings.gradle.kts"), + settings_gradle_kts(&project_name, input_basename), + ), + PackagedFile::text( + dir.join("build.gradle.kts"), + packaged_build_gradle_kts(package, input_basename), + ), + PackagedFile::text( + dir.join("README.md"), + packaged_readme( + &project_name, + package, + lib, + &model.prefix, + ctx, + input_basename, + ), + ), + PackagedFile::text( + src_dir.join("WeaveFFI.kt"), + render_kotlin(model, package, strip, input_basename, &loading), + ), + PackagedFile::text( + jni_dir.join("CMakeLists.txt"), + packaged_cmake_lists(lib, &model.prefix, input_basename), + ), + PackagedFile::text( + jni_dir.join("weaveffi_jni.c"), + render_jni_c(model, package, strip, input_basename), + ), + PackagedFile::text( + jni_dir.join("include").join(&header_name), + packaged_header(model, input_basename, &header_name), + ), + ]; + // Android binaries land in the AAR's `jniLibs//` so the packaged + // shim links against them; desktop JVM binaries are classpath + // resources the Kotlin loader extracts at runtime. WebAssembly builds + // belong to another package format. + for nb in &ctx.binaries.binaries { + let filename = ctx.binaries.bundled_filename(nb.platform); + if let Some(abi) = nb.platform.android_abi() { + let dest = dir.join("src/main/jniLibs").join(abi).join(filename); + files.push(PackagedFile::copy(dest, nb.source.clone())); + } else if nb.platform.is_desktop() { + let dest = dir + .join("src/main/resources/natives") + .join(nb.platform.id()) + .join(filename); + files.push(PackagedFile::copy(dest, nb.source.clone())); + } + } + Some(files) + } +} + +/// Render the complete `WeaveFFI.kt`: the `WeaveFFI` entry class (free +/// functions inside the companion), then the entity surface (enums, records, +/// callback interfaces, interfaces, iterator classes), the exception +/// hierarchy, and the native-library loader, object, buffer, and continuation +/// runtimes as the model needs them. +fn render_kotlin( + model: &BindingModel, + package: &str, + strip_module_prefix: bool, + input_basename: &str, + loading: &LibraryLoading, +) -> String { + let c_prefix = model.prefix.as_str(); + let has_async = model.has_async(); + let has_objects = model.modules.iter().any(|m| !m.interfaces.is_empty()); + let mut kotlin = render_prelude(CommentStyle::DoubleSlash, input_basename); + kotlin.push_str(&format!("package {package}\n\n")); + if has_async { + kotlin.push_str("import kotlinx.coroutines.suspendCancellableCoroutine\n"); + kotlin.push_str("import kotlin.coroutines.resume\n"); + kotlin.push_str("import kotlin.coroutines.resumeWithException\n\n"); + } + kotlin.push_str( + "class WeaveFFI {\n companion object {\n init { WeaveNativeLibrary.ensureLoaded() }\n\n", + ); + if has_async { + render_kotlin_exception_handler_api(&mut kotlin); + } + for m in &model.modules { + for f in &m.functions { + render_kotlin_free_fn(&mut kotlin, m, f, strip_module_prefix, c_prefix); + } + } + kotlin.push_str(" }\n}\n"); + for m in &model.modules { + for e in &m.enums { + render_kotlin_enum(&mut kotlin, e); + } + for s in &m.structs { + render_kotlin_struct(&mut kotlin, s); + } + for cb in &m.callback_interfaces { + render_kotlin_callback_interface(&mut kotlin, cb); + } + for i in &m.interfaces { + render_kotlin_interface(&mut kotlin, i, m.error.as_ref(), c_prefix); + } + // One lazy iterator wrapper class per `iter` callable, streaming + // one producer `next` per consumer step. + for f in m.callables() { + if let CallShape::Iterator(it) = &f.shape { + render_kotlin_iterator_class(&mut kotlin, f, it, c_prefix); + } + } + } + render_kotlin_error_types(&mut kotlin, model); + render_kotlin_native_library(&mut kotlin, loading); + if has_objects || model.has_iterators() { + render_kotlin_object_runtime(&mut kotlin); + } + if model.has_buffers() { + render_kotlin_buffer_runtime(&mut kotlin); + } + if has_async { + render_weave_continuation(&mut kotlin); + } + kotlin.push('\n'); + kotlin.push_str(&render_trailer(CommentStyle::DoubleSlash, "WeaveFFI.kt")); + kotlin +} + +/// Render the complete `weaveffi_jni.c`: the error throwers, `JNI_OnLoad` +/// with its cached VM, classes, and method IDs, the thread-attach, uncaught +/// exception, and foreign-error helpers, the callback-interface trampolines +/// and static vtables, then one JNI export per callable (sync, async, +/// interface member, and iterator native). +fn render_jni_c( + model: &BindingModel, + package: &str, + strip_module_prefix: bool, + input_basename: &str, +) -> String { + let c_prefix = model.prefix.as_str(); + let jni_prefix = package.replace('.', "_"); + let jni_pkg_path = package.replace('.', "/"); + let mut jni_c = render_prelude(CommentStyle::DoubleSlash, input_basename); + jni_c.push_str("#include \n#include \n#include \n#include \n#include \n#include \n"); + let _ = writeln!(jni_c, "#include \"{c_prefix}.h\"\n"); + + render_jni_string_helpers(&mut jni_c); + render_jni_generic_thrower(&mut jni_c, &jni_pkg_path); + for m in &model.modules { + if let Some(eb) = m.error.as_ref().filter(|e| e.declared_here) { + if domain_thrower_used(model, &eb.c_tag) { + render_jni_domain_thrower(&mut jni_c, eb, &jni_pkg_path); + } + } + } + + let has_async = model.has_async(); + let callback_interfaces: Vec<_> = model.callback_interfaces().collect(); + render_jni_onload( + &mut jni_c, + &jni_pkg_path, + c_prefix, + has_async, + &callback_interfaces, + ); + if has_async || !callback_interfaces.is_empty() { + render_jni_thread_helpers(&mut jni_c); + } + if has_async { + jni_c.push_str("typedef struct {\n"); + jni_c.push_str(" jobject callback;\n"); + jni_c.push_str("} weaveffi_jni_async_ctx;\n\n"); + render_jni_uncaught_support(&mut jni_c); + } + if !callback_interfaces.is_empty() { + render_jni_foreign_error_support(&mut jni_c, c_prefix); + for (_, cb) in &callback_interfaces { + render_jni_callback_interface(&mut jni_c, cb, c_prefix); + } + } + + for m in &model.modules { + for f in &m.functions { + let thrower = jni_thrower_for(f, m.error.as_ref()); + let jni_name = free_fn_jni_name(&m.path, f, strip_module_prefix); + if f.is_async { + render_jni_async_function( + &mut jni_c, + &m.path, + f, + "WeaveFFI", + &jni_name, + None, + &jni_prefix, + c_prefix, + ); + continue; + } + render_jni_sync_export( + &mut jni_c, + f, + "WeaveFFI", + &jni_name, + None, + &thrower, + &jni_prefix, + &m.path, + c_prefix, + ); + } + } + for m in &model.modules { + // Records and rich enums are value types with no C symbols: their + // packing and decoding happens entirely in Kotlin, so no JNI bridge + // is emitted for them. Plain C-style enums need no natives either. + for i in &m.interfaces { + render_jni_interface(&mut jni_c, m, i, &jni_prefix, c_prefix); + } + // The `nativeNext`/`nativeDestroy` exports backing each generated + // lazy iterator class, covering free functions, interface methods, + // and statics returning `iter`. + for f in m.callables() { + if let CallShape::Iterator(it) = &f.shape { + let thrower = jni_thrower_for(f, m.error.as_ref()); + render_jni_iterator_natives( + &mut jni_c, + it, + &thrower, + &jni_prefix, + &m.path, + c_prefix, + ); + } + } + } + jni_c.push('\n'); + jni_c.push_str(&render_trailer(CommentStyle::DoubleSlash, "weaveffi_jni.c")); + jni_c +} + +#[cfg(test)] +mod tests; diff --git a/crates/weaveffi-gen-kotlin/src/package.rs b/crates/weaveffi-gen-kotlin/src/package.rs new file mode 100644 index 00000000..8e5c879f --- /dev/null +++ b/crates/weaveffi-gen-kotlin/src/package.rs @@ -0,0 +1,264 @@ +//! Gradle module scaffolding: `settings.gradle.kts`, `build.gradle.kts`, the +//! CMake build script compiling the JNI shim, and the packaged layout's +//! README and bundled C header. + +use std::fmt::Write as _; + +use weaveffi_core::cabi; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::PackageContext; +use weaveffi_core::utils::{ + render_abi_prefix_aliases, render_prelude, render_trailer, CommentStyle, +}; + +use crate::runtime::jni_lib_name; + +/// Escape `s` for placement inside a double-quoted Kotlin (Gradle Kotlin DSL) +/// string literal: backslashes, double quotes, and `$` gain a backslash, so a +/// package name or project name containing any of them can't terminate the +/// literal or start a template. +pub(crate) fn kts_quote(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") +} + +/// Render `settings.gradle.kts`, naming the root project after the resolved +/// package identity. +pub(crate) fn settings_gradle_kts(project_name: &str, input_basename: &str) -> String { + format!( + "{}rootProject.name = \"{}\"\n\n{}", + render_prelude(CommentStyle::DoubleSlash, input_basename), + kts_quote(project_name), + render_trailer(CommentStyle::DoubleSlash, "settings.gradle.kts"), + ) +} + +/// The shared body of the Android library module's `build.gradle.kts`: the +/// Kotlin plugin, the JVM `namespace`, the SDK levels the generated code +/// needs (`java.lang.ref.Cleaner` is API 33), and the JNI shim wired through +/// `externalNativeBuild`. `extra` is spliced inside the `android {}` block. +fn gradle_kts_body(namespace: &str, extra: &str) -> String { + let namespace = kts_quote(namespace); + format!( + r#"plugins {{ + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +}} + +android {{ + namespace = "{namespace}" + compileSdk = 34 + defaultConfig {{ + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild {{ + cmake {{ + cppFlags("") + }} + }} + }} + externalNativeBuild {{ + cmake {{ + path = file("src/main/cpp/CMakeLists.txt") + }} + }} +{extra}}} + +dependencies {{ + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +}} +"# + ) +} + +/// Render `build.gradle.kts` for the generated (source-only) module. +pub(crate) fn build_gradle_kts(namespace: &str, input_basename: &str) -> String { + format!( + "{}{}\n{}", + render_prelude(CommentStyle::DoubleSlash, input_basename), + gradle_kts_body(namespace, ""), + render_trailer(CommentStyle::DoubleSlash, "build.gradle.kts"), + ) +} + +/// Render `build.gradle.kts` for the packaged module: the same Android +/// library, plus the bundled `jniLibs/` directory as a JNI source set. +pub(crate) fn packaged_build_gradle_kts(namespace: &str, input_basename: &str) -> String { + let extra = " sourceSets {\n getByName(\"main\") {\n jniLibs.srcDirs(\"src/main/jniLibs\")\n }\n }\n"; + format!( + "{}{}\n{}", + render_prelude(CommentStyle::DoubleSlash, input_basename), + gradle_kts_body(namespace, extra), + render_trailer(CommentStyle::DoubleSlash, "build.gradle.kts"), + ) +} + +/// Render `CMakeLists.txt` for the generated layout: one shared library +/// `lib{prefix}.so` built from the JNI shim, with the sibling `c/` output +/// (the generated `{prefix}.h`) on the include path. Linking the producer +/// library is left to the consumer's build. +pub(crate) fn cmake_lists(c_prefix: &str, input_basename: &str) -> String { + let body = format!( + "cmake_minimum_required(VERSION 3.22)\n\ + project({c_prefix}_jni C)\n\ + add_library({c_prefix} SHARED weaveffi_jni.c)\n\ + target_include_directories({c_prefix} PRIVATE ../../../../c)\n" + ); + format!( + "{}{body}\n{}", + render_prelude(CommentStyle::Hash, input_basename), + render_trailer(CommentStyle::Hash, "CMakeLists.txt"), + ) +} + +/// Render `CMakeLists.txt` for the packaged layout: builds `lib{lib}_jni` from +/// the shim against the bundled `include/{prefix}.h`, and links the bundled +/// producer library for the current Android ABI (from `jniLibs//`) or, +/// on a desktop JVM build, from `src/main/resources/natives//` +/// when the caller passes `WEAVEFFI_PLATFORM_ID`. +pub(crate) fn packaged_cmake_lists(lib: &str, c_prefix: &str, input_basename: &str) -> String { + let jni = jni_lib_name(lib); + let body = format!( + r#"cmake_minimum_required(VERSION 3.22) +project({jni} C) + +# The shim compiles against the bundled include/{c_prefix}.h. +add_library({jni} SHARED weaveffi_jni.c) +target_include_directories({jni} PRIVATE include) + +if(ANDROID) + # The AAR bundles the producer library per ABI under jniLibs/. + add_library({lib} SHARED IMPORTED) + set_target_properties({lib} PROPERTIES IMPORTED_LOCATION + ${{CMAKE_CURRENT_SOURCE_DIR}}/../jniLibs/${{ANDROID_ABI}}/lib{lib}.so) + target_link_libraries({jni} PRIVATE {lib}) +else() + # Desktop JVM: build against a JDK and the bundled desktop binary. + find_package(JNI REQUIRED) + target_include_directories({jni} PRIVATE ${{JNI_INCLUDE_DIRS}}) + if(NOT DEFINED WEAVEFFI_PLATFORM_ID) + message(FATAL_ERROR "set -DWEAVEFFI_PLATFORM_ID= to pick the bundled producer library") + endif() + set(WEAVEFFI_NATIVES ${{CMAKE_CURRENT_SOURCE_DIR}}/../resources/natives/${{WEAVEFFI_PLATFORM_ID}}) + target_link_directories({jni} PRIVATE ${{WEAVEFFI_NATIVES}}) + target_link_libraries({jni} PRIVATE {lib}) + set_target_properties({jni} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY ${{WEAVEFFI_NATIVES}} + RUNTIME_OUTPUT_DIRECTORY ${{WEAVEFFI_NATIVES}}) +endif() +"# + ); + format!( + "{}{body}\n{}", + render_prelude(CommentStyle::Hash, input_basename), + render_trailer(CommentStyle::Hash, "CMakeLists.txt"), + ) +} + +/// Render the `{prefix}.h` the packaged shim compiles against, so the module +/// is self-contained without the sibling `c/` output: the same include +/// guard, runtime declarations, prefix aliases, and per-module declarations +/// the C generator emits, composed from [`weaveffi_core::cabi`]. +pub(crate) fn packaged_header( + model: &BindingModel, + input_basename: &str, + filename: &str, +) -> String { + let prefix = model.prefix.as_str(); + let guard = format!("{}_H", prefix.to_uppercase()); + let mut out = render_prelude(CommentStyle::DoubleSlash, input_basename); + let _ = write!(out, "#ifndef {guard}\n#define {guard}\n\n"); + out.push_str("#include \n#include \n#include \n\n"); + cabi::render_visibility_macros(&mut out, prefix); + out.push_str(&render_abi_prefix_aliases(prefix)); + out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n"); + cabi::render_runtime_decls(&mut out, prefix); + cabi::render_decls(&mut out, &model.modules, prefix, true); + out.push_str("\n#ifdef __cplusplus\n}\n#endif\n\n"); + let _ = write!(out, "#endif // {guard}\n\n"); + out.push_str(&render_trailer(CommentStyle::DoubleSlash, filename)); + out +} + +/// Render the packaged module's README: the layout, how Android consumers +/// depend on the module, how a desktop JVM build compiles the shim against +/// the bundled binary, and the list of bundled platforms. +pub(crate) fn packaged_readme( + project_name: &str, + package: &str, + lib: &str, + c_prefix: &str, + ctx: &PackageContext, + input_basename: &str, +) -> String { + let prelude = render_prelude(CommentStyle::Xml, input_basename); + let trailer = render_trailer(CommentStyle::Xml, "README.md"); + let jni = jni_lib_name(lib); + let mut android: Vec = Vec::new(); + let mut desktop: Vec = Vec::new(); + for nb in &ctx.binaries.binaries { + let filename = ctx.binaries.bundled_filename(nb.platform); + if let Some(abi) = nb.platform.android_abi() { + android.push(format!("- `src/main/jniLibs/{abi}/{filename}`")); + } else if nb.platform.is_desktop() { + desktop.push(format!( + "- `src/main/resources/natives/{}/{filename}`", + nb.platform.id() + )); + } + } + let android_list = if android.is_empty() { + "- (none bundled)".to_string() + } else { + android.join("\n") + }; + let desktop_list = if desktop.is_empty() { + "- (none bundled)".to_string() + } else { + desktop.join("\n") + }; + format!( + r#"{prelude}# {project_name} (Kotlin) + +An Android library module (Gradle, Kotlin DSL) exposing the `{package}` API +over a JNI shim. The Kotlin sources live under `src/main/kotlin/`, the shim +under `src/main/cpp/` with the C header it compiles against in +`src/main/cpp/include/{c_prefix}.h`, and the prebuilt producer libraries under +`src/main/jniLibs//` (Android) and `src/main/resources/natives//` +(desktop JVMs). + +## Android + +Add the module to your project (`include(":{project_name}")` in +`settings.gradle.kts`, or publish it as an AAR) and depend on it. The NDK's +CMake builds `lib{jni}.so` against the bundled `lib{lib}.so` for each ABI, and +the Kotlin wrapper loads it with `System.loadLibrary("{jni}")`. `minSdk` is +33 because the wrappers' disposal backstop is `java.lang.ref.Cleaner`. + +## Desktop JVM + +Build the shim once per platform against a JDK, next to the bundled producer +binary, so the wrapper can extract both from the classpath at runtime: + +```bash +cmake -S src/main/cpp -B build -DWEAVEFFI_PLATFORM_ID=darwin-arm64 +cmake --build build +``` + +The wrapper picks `natives/-/` from `os.name` and `os.arch`, loads +the producer library first and the shim second, and falls back to +`System.loadLibrary("{jni}")` (the `java.library.path`) when the resources +are absent. + +## Bundled Android ABIs + +{android_list} + +## Bundled desktop platforms + +{desktop_list} + +{trailer}"# + ) +} diff --git a/crates/weaveffi-gen-kotlin/src/runtime.rs b/crates/weaveffi-gen-kotlin/src/runtime.rs new file mode 100644 index 00000000..09860f89 --- /dev/null +++ b/crates/weaveffi-gen-kotlin/src/runtime.rs @@ -0,0 +1,798 @@ +//! Once-per-file runtime support: the Kotlin native-library loader, the +//! native-reference holder and its `Cleaner`, the value-buffer writer/reader, +//! the async exception hook and continuation shim, and the JNI `JNI_OnLoad`, +//! thread-attach helpers, foreign-error reporter, and error throwers. + +use std::fmt::Write as _; + +use weaveffi_core::cabi::ABI_VERSION; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::errors; +use weaveffi_core::model::{ + BindingModel, CallbackInterfaceBinding, ErrorBinding, FnBinding, ModuleBinding, +}; +use weaveffi_core::plan::ErrorStrategy; + +use crate::entities::kotlin_exception_name; +use crate::types::{jni_descriptor, kotlin_callback_dispatch_name, kt_param}; + +/// The reserved runtime error code a callback trampoline reports when the +/// Kotlin implementation threw: "a consumer callback failed". +pub(crate) const FOREIGN_ERROR_CODE: i32 = -4; + +/// How the generated Kotlin loads the JNI shim (and, when packaged, the +/// producer library it links against). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LibraryLoading { + /// `System.loadLibrary(name)`: the shim is on the JVM's library path or + /// bundled as an Android `jniLibs` entry. + SystemLibrary(String), + /// The packaged layout: on Android, `System.loadLibrary("{lib}_jni")` + /// resolves the AAR's `jniLibs`; on a desktop JVM the producer library + /// and the shim are extracted from `natives//` classpath + /// resources and loaded with `System.load`, falling back to + /// `System.loadLibrary` when the resources are absent. + Packaged { + /// The logical producer library base name (`libcontacts.so`). + lib_name: String, + }, +} + +/// The JNI shim library base name the packaged layout builds and loads: +/// `{lib}_jni`, so it never collides with the producer library itself. +pub(crate) fn jni_lib_name(lib_name: &str) -> String { + format!("{lib_name}_jni") +} + +/// Render the `WeaveNativeLibrary` object every class with `external` +/// members touches from its companion `init`, so the shim is loaded exactly +/// once before any native declaration is bound. +pub(crate) fn render_kotlin_native_library(out: &mut String, loading: &LibraryLoading) { + let mut w = CodeWriter::four_space(); + w.blank(); + w.line("/** Loads the native library once; every companion `init` calls [ensureLoaded]. */"); + w.line("internal object WeaveNativeLibrary {"); + w.scope(|w| match loading { + LibraryLoading::SystemLibrary(name) => { + w.line(format!( + "init {{ System.loadLibrary(\"{}\") }}", + name.replace('"', "\\\"") + )); + w.blank(); + w.line("fun ensureLoaded() {}"); + } + LibraryLoading::Packaged { lib_name } => { + let lib = lib_name.replace('"', "\\\""); + let jni = jni_lib_name(&lib); + w.line(format!("private const val LIB = \"{lib}\"")); + w.line(format!("private const val JNI_LIB = \"{jni}\"")); + w.blank(); + w.line("init {"); + w.scope(|w| { + w.line("val vendor = System.getProperty(\"java.vm.vendor\") ?: \"\""); + w.line("if (vendor.contains(\"Android\")) {"); + w.scope(|w| { + w.line("System.loadLibrary(JNI_LIB)"); + }); + w.line("} else {"); + w.scope(|w| { + w.line("loadFromResources()"); + }); + w.line("}"); + }); + w.line("}"); + w.blank(); + w.line("fun ensureLoaded() {}"); + w.blank(); + w.line("private fun loadFromResources() {"); + w.scope(|w| { + w.line("val id = platformId()"); + w.line("val dir = java.nio.file.Files.createTempDirectory(\"weaveffi-natives\")"); + w.line("dir.toFile().deleteOnExit()"); + w.line("// The producer library first, so the shim's dependency resolves."); + w.line("extract(id, fileName(LIB), dir)?.let { System.load(it) }"); + w.line("val shim = extract(id, fileName(JNI_LIB), dir)"); + w.line("if (shim != null) System.load(shim) else System.loadLibrary(JNI_LIB)"); + }); + w.line("}"); + w.blank(); + w.line("private fun platformId(): String {"); + w.scope(|w| { + w.line("val os = System.getProperty(\"os.name\").lowercase()"); + w.line("val arch = System.getProperty(\"os.arch\").lowercase()"); + w.line("val osId = when {"); + w.scope(|w| { + w.line("os.contains(\"mac\") || os.contains(\"darwin\") -> \"darwin\""); + w.line("os.contains(\"win\") -> \"windows\""); + w.line("else -> \"linux\""); + }); + w.line("}"); + w.line("val archId = if (arch == \"aarch64\" || arch == \"arm64\") \"arm64\" else \"x64\""); + w.line("return \"$osId-$archId\""); + }); + w.line("}"); + w.blank(); + w.line("private fun fileName(base: String): String {"); + w.scope(|w| { + w.line("val os = System.getProperty(\"os.name\").lowercase()"); + w.line("return when {"); + w.scope(|w| { + w.line("os.contains(\"mac\") || os.contains(\"darwin\") -> \"lib$base.dylib\""); + w.line("os.contains(\"win\") -> \"$base.dll\""); + w.line("else -> \"lib$base.so\""); + }); + w.line("}"); + }); + w.line("}"); + w.blank(); + w.line("private fun extract(id: String, name: String, dir: java.nio.file.Path): String? {"); + w.scope(|w| { + w.line("val stream = WeaveNativeLibrary::class.java.getResourceAsStream(\"/natives/$id/$name\") ?: return null"); + w.line("val target = dir.resolve(name)"); + w.line("stream.use { java.nio.file.Files.copy(it, target) }"); + w.line("target.toFile().deleteOnExit()"); + w.line("return target.toAbsolutePath().toString()"); + }); + w.line("}"); + } + }); + w.line("}"); + out.push_str(&w.finish()); +} + +/// Render the native-reference holder shared by every object wrapper and +/// iterator class: a `Runnable` owning one strong reference that is released +/// exactly once, either from the wrapper's `close()` or from the +/// `java.lang.ref.Cleaner` backstop when the wrapper becomes unreachable +/// without being closed. The holder never references the wrapper, which is +/// what lets the `Cleaner` collect it. +pub(crate) fn render_kotlin_object_runtime(out: &mut String) { + let _ = write!( + out, + r#" +/** + * One strong native reference, released exactly once. Wrappers register an + * instance with [weaveCleaner] so an unreachable, never-closed wrapper still + * releases its reference; an explicit `close()` runs the same action first + * and the cleaner then does nothing. + */ +internal class WeaveNativeRef(handle: Long, private val release: (Long) -> Unit) : Runnable {{ + private val handle = java.util.concurrent.atomic.AtomicLong(handle) + + /** The live pointer, borrowed for the duration of one native call. */ + fun get(): Long {{ + val h = handle.get() + check(h != 0L) {{ "WeaveFFI object used after close()" }} + return h + }} + + /** The live pointer, or `0L` once released. */ + fun peek(): Long = handle.get() + + override fun run() {{ + val h = handle.getAndSet(0L) + if (h != 0L) release(h) + }} +}} + +/** The process-wide cleaner backing every generated wrapper's disposal backstop. */ +internal val weaveCleaner: java.lang.ref.Cleaner = java.lang.ref.Cleaner.create() +"# + ); +} + +/// Render the settable uncaught-exception hook into the `WeaveFFI` companion. +/// Async continuation resume paths run on native producer threads with no +/// Kotlin caller up-stack, so a thrown exception has nowhere to propagate; +/// the JNI glue routes it here. When no handler is installed, +/// `dispatchCallbackException` rethrows, and the glue falls back to +/// `ExceptionDescribe` (logging the stack trace) before clearing. +pub(crate) fn render_kotlin_exception_handler_api(out: &mut String) { + let mut w = CodeWriter::four_space().with_depth(2); + w.line("@Volatile private var callbackExceptionHandler: ((Throwable) -> Unit)? = null"); + w.blank(); + w.line("/**"); + w.line(" * Installs a handler for exceptions thrown while an async result is"); + w.line(" * delivered on a native producer thread. These exceptions have no Kotlin"); + w.line(" * caller to propagate to; when no handler is installed, they are logged"); + w.line(" * with their stack trace and dropped. Pass `null` to restore the default"); + w.line(" * logging behavior. Exceptions thrown by callback interface methods are"); + w.line(" * not routed here: they abort the producer call that invoked them and"); + w.line(" * surface to the original caller as a [WeaveFFIException] with code -4."); + w.line(" */"); + w.line("@JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) {"); + w.scope(|w| { + w.line("callbackExceptionHandler = handler"); + }); + w.line("}"); + w.blank(); + w.line("// Invoked from the JNI glue; rethrowing signals \"no handler\" so the"); + w.line("// glue falls back to ExceptionDescribe."); + w.line("@JvmStatic private fun dispatchCallbackException(t: Throwable) {"); + w.scope(|w| { + w.line("callbackExceptionHandler?.invoke(t) ?: throw t"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the private Kotlin value-buffer runtime: a growable little-endian +/// writer, a validating reader (rejecting truncated buffers, invalid +/// bool/flag bytes, zero object tokens, oversized length prefixes, and +/// trailing bytes), and the `weaveEncode`/`weaveDecode` entry points the +/// generated wrappers call. +pub(crate) fn render_kotlin_buffer_runtime(out: &mut String) { + let brand = errors::EXCEPTION_BRAND; + let _ = write!( + out, + r#" +/** Growable little-endian writer implementing the WeaveFFI value-buffer wire format. */ +internal class WeaveBufferWriter {{ + private var buf = ByteArray(32) + private var len = 0 + + private fun reserve(extra: Int) {{ + if (len + extra <= buf.size) return + var cap = buf.size * 2 + while (cap < len + extra) cap *= 2 + buf = buf.copyOf(cap) + }} + + fun toByteArray(): ByteArray = buf.copyOf(len) + + fun writeBool(v: Boolean) {{ reserve(1); buf[len++] = if (v) 1 else 0 }} + fun writeI8(v: Byte) {{ reserve(1); buf[len++] = v }} + fun writeI16(v: Short) {{ + reserve(2) + val b = v.toInt() + buf[len++] = (b and 0xFF).toByte() + buf[len++] = ((b shr 8) and 0xFF).toByte() + }} + fun writeI32(v: Int) {{ + reserve(4) + buf[len++] = (v and 0xFF).toByte() + buf[len++] = ((v shr 8) and 0xFF).toByte() + buf[len++] = ((v shr 16) and 0xFF).toByte() + buf[len++] = ((v shr 24) and 0xFF).toByte() + }} + fun writeU32(v: Long) = writeI32(v.toInt()) + fun writeI64(v: Long) {{ + writeI32(v.toInt()) + writeI32((v ushr 32).toInt()) + }} + fun writeF32(v: Float) = writeI32(v.toRawBits()) + fun writeF64(v: Double) = writeI64(v.toRawBits()) + fun writeString(v: String) = writeBytes(v.toByteArray(Charsets.UTF_8)) + fun writeBytes(v: ByteArray) {{ + writeI32(v.size) + reserve(v.size) + v.copyInto(buf, len) + len += v.size + }} + fun writeOptional(v: T?, write: (T) -> Unit) {{ + if (v == null) writeBool(false) else {{ writeBool(true); write(v) }} + }} + fun writeList(v: List, write: (T) -> Unit) {{ + writeI32(v.size) + for (e in v) write(e) + }} + fun writeMap(v: Map, writeKey: (K) -> Unit, writeValue: (V) -> Unit) {{ + writeI32(v.size) + for ((k, e) in v) {{ writeKey(k); writeValue(e) }} + }} +}} + +/** Validating little-endian reader for the WeaveFFI value-buffer wire format. */ +internal class WeaveBufferReader(private val buf: ByteArray) {{ + private var pos = 0 + + private fun malformed(detail: String): Nothing = + throw {brand}(-2, "malformed WeaveFFI value buffer: " + detail) + + private fun take(n: Int): Int {{ + if (n > buf.size - pos) malformed("truncated buffer") + val at = pos + pos += n + return at + }} + + fun readBool(): Boolean = when (buf[take(1)].toInt()) {{ + 0 -> false + 1 -> true + else -> malformed("invalid bool byte") + }} + fun readI8(): Byte = buf[take(1)] + fun readI16(): Short {{ + val at = take(2) + return ((buf[at].toInt() and 0xFF) or ((buf[at + 1].toInt() and 0xFF) shl 8)).toShort() + }} + fun readI32(): Int {{ + val at = take(4) + return (buf[at].toInt() and 0xFF) or + ((buf[at + 1].toInt() and 0xFF) shl 8) or + ((buf[at + 2].toInt() and 0xFF) shl 16) or + ((buf[at + 3].toInt() and 0xFF) shl 24) + }} + fun readU32(): Long = readI32().toLong() and 0xFFFFFFFFL + fun readI64(): Long {{ + val lo = readI32().toLong() and 0xFFFFFFFFL + val hi = readI32().toLong() + return lo or (hi shl 32) + }} + fun readF32(): Float = Float.fromBits(readI32()) + fun readF64(): Double = Double.fromBits(readI64()) + /** An object token: one strong reference the caller adopts. Zero is never valid here. */ + fun readObject(): Long {{ + val token = readI64() + if (token == 0L) malformed("null object token") + return token + }} + private fun readLen(): Int {{ + val n = readI32() + if (n < 0 || n > buf.size - pos) malformed("length prefix exceeds remaining bytes") + return n + }} + fun readString(): String {{ + val n = readLen() + val at = take(n) + return try {{ + Charsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(buf, at, n)).toString() + }} catch (e: java.nio.charset.CharacterCodingException) {{ + malformed("string is not valid UTF-8") + }} + }} + fun readBytes(): ByteArray {{ + val n = readLen() + val at = take(n) + return buf.copyOfRange(at, at + n) + }} + fun readOptional(read: () -> T): T? = if (readBool()) read() else null + fun readList(read: () -> T): List {{ + val n = readLen() + val out = ArrayList(n) + repeat(n) {{ out.add(read()) }} + return out + }} + fun readMap(readKey: () -> K, readValue: () -> V): Map {{ + val n = readLen() + val out = LinkedHashMap() + repeat(n) {{ out[readKey()] = readValue() }} + return out + }} + fun expectEnd() {{ + if (pos != buf.size) malformed("trailing bytes after value") + }} +}} + +internal fun weaveEncode(write: (WeaveBufferWriter) -> Unit): ByteArray {{ + val w = WeaveBufferWriter() + write(w) + return w.toByteArray() +}} + +internal fun weaveDecode(bytes: ByteArray, read: (WeaveBufferReader) -> T): T {{ + val r = WeaveBufferReader(bytes) + val v = read(r) + r.expectEnd() + return v +}} +"# + ); +} + +/// Render the `WeaveContinuation` shim boxing a cancellable continuation and +/// its error mapper for delivery through the JNI async callback. +pub(crate) fn render_weave_continuation(out: &mut String) { + out.push_str("\ninternal class WeaveContinuation(\n"); + out.push_str(" private val cont: kotlinx.coroutines.CancellableContinuation,\n"); + out.push_str(" private val mapError: (Int, String, ByteArray?) -> Throwable\n"); + out.push_str(") {\n"); + out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n"); + out.push_str(" fun onSuccess(result: Any?) { cont.resume(result as T) }\n"); + out.push_str(" fun onError(code: Int, message: String, payload: ByteArray?) { cont.resumeWithException(mapError(code, message, payload)) }\n"); + out.push_str("}\n"); +} + +/// The C identifier of the cached `jclass` global reference for a callback +/// interface's Kotlin dispatch object. +pub(crate) fn jni_cb_class_var(cb: &CallbackInterfaceBinding) -> String { + format!("{}_jni_cls", cb.c_tag) +} + +/// The C identifier of the cached `jmethodID` for one dispatch shim of a +/// callback interface. +pub(crate) fn jni_cb_method_var(cb: &CallbackInterfaceBinding, method: &str) -> String { + format!("{}_jni_mid_{method}", cb.c_tag) +} + +/// Emit the file-scope JNI state and `JNI_OnLoad`: the cached `JavaVM*` every +/// producer-thread entry attaches through, the ABI revision check, and, as +/// the model needs them, the cached `WeaveFFI` entry class for async +/// exception routing and one cached class plus per-method `jmethodID` set for +/// each callback interface's Kotlin dispatch object. Everything is resolved +/// here because `System.loadLibrary` runs `JNI_OnLoad` on an app thread whose +/// class loader can see the generated classes; producer threads cannot +/// `FindClass` them later. +pub(crate) fn render_jni_onload( + out: &mut String, + jni_pkg_path: &str, + c_prefix: &str, + has_async: bool, + callback_interfaces: &[(&ModuleBinding, &CallbackInterfaceBinding)], +) { + let mut w = CodeWriter::four_space(); + w.line("static JavaVM* weaveffi_jni_vm = NULL;"); + if has_async { + w.line("static jclass weaveffi_jni_entry_class = NULL;"); + w.line("static jmethodID weaveffi_jni_dispatch_exc = NULL;"); + } + if !callback_interfaces.is_empty() { + w.line("static jmethodID weaveffi_jni_throwable_to_string = NULL;"); + for (_, cb) in callback_interfaces { + w.line(format!("static jclass {} = NULL;", jni_cb_class_var(cb))); + for m in &cb.methods { + w.line(format!( + "static jmethodID {} = NULL;", + jni_cb_method_var(cb, &m.name) + )); + } + } + } + w.blank(); + w.line("static jint weaveffi_jni_load_error(JNIEnv* env, const char* message) {"); + w.scope(|w| { + w.line("jclass cls = (*env)->FindClass(env, \"java/lang/UnsatisfiedLinkError\");"); + w.line("if (cls != NULL) { (*env)->ThrowNew(env, cls, message); }"); + w.line("return JNI_ERR;"); + }); + w.line("}"); + w.blank(); + w.line("JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {"); + w.scope(|w| { + w.line("(void)reserved;"); + w.line("weaveffi_jni_vm = vm;"); + w.line("JNIEnv* env = NULL;"); + w.line("if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; }"); + w.block( + format!("if ({c_prefix}_abi_version() != {ABI_VERSION}u) {{"), + "}", + |w| { + w.line(format!( + "return weaveffi_jni_load_error(env, \"WeaveFFI: the native library implements a different C ABI revision than these bindings (expected {ABI_VERSION})\");" + )); + }, + ); + if has_async { + w.line(format!( + "jclass entry = (*env)->FindClass(env, \"{jni_pkg_path}/WeaveFFI\");" + )); + w.line("if (entry == NULL) { return JNI_ERR; }"); + w.line("weaveffi_jni_entry_class = (jclass)(*env)->NewGlobalRef(env, entry);"); + w.line("weaveffi_jni_dispatch_exc = (*env)->GetStaticMethodID(env, weaveffi_jni_entry_class, \"dispatchCallbackException\", \"(Ljava/lang/Throwable;)V\");"); + w.line("if (weaveffi_jni_dispatch_exc == NULL) { (*env)->ExceptionClear(env); }"); + } + if !callback_interfaces.is_empty() { + w.line("jclass throwable = (*env)->FindClass(env, \"java/lang/Throwable\");"); + w.line("if (throwable == NULL) { return JNI_ERR; }"); + w.line("weaveffi_jni_throwable_to_string = (*env)->GetMethodID(env, throwable, \"toString\", \"()Ljava/lang/String;\");"); + w.line("if (weaveffi_jni_throwable_to_string == NULL) { return JNI_ERR; }"); + for (_, cb) in callback_interfaces { + let cls = jni_cb_class_var(cb); + let dispatch = kotlin_callback_dispatch_name(&cb.name); + w.line(format!( + "jclass {cls}_local = (*env)->FindClass(env, \"{jni_pkg_path}/{dispatch}\");" + )); + w.line(format!("if ({cls}_local == NULL) {{ return JNI_ERR; }}")); + w.line(format!( + "{cls} = (jclass)(*env)->NewGlobalRef(env, {cls}_local);" + )); + for m in &cb.methods { + let sig = jni_cb_shim_descriptor(cb, m, jni_pkg_path); + let mid = jni_cb_method_var(cb, &m.name); + w.line(format!( + "{mid} = (*env)->GetStaticMethodID(env, {cls}, \"{}\", \"{sig}\");", + kt_param(&m.name) + )); + w.line(format!("if ({mid} == NULL) {{ return JNI_ERR; }}")); + } + } + } + w.line("return JNI_VERSION_1_6;"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// The JNI method descriptor of a callback interface's dispatch shim: the +/// implementing object first, then every parameter as the shim receives it, +/// then the direct-family (or `V`) return. +pub(crate) fn jni_cb_shim_descriptor( + cb: &CallbackInterfaceBinding, + m: &weaveffi_core::model::CallbackMethodBinding, + jni_pkg_path: &str, +) -> String { + let mut sig = format!("(L{jni_pkg_path}/{};", cb.name); + for p in &m.params { + sig.push_str(jni_descriptor(Some(&p.ty))); + } + sig.push(')'); + sig.push_str(jni_descriptor(m.ret.as_ref())); + sig +} + +/// Emit the two string converters every `string` crossing goes through. +/// JNI's own `GetStringUTFChars`/`NewStringUTF` speak *modified* UTF-8, which +/// encodes supplementary characters (emoji, for instance) as CESU-8 surrogate +/// pairs: a producer expecting standard UTF-8 rejects such a parameter as +/// invalid, and a standard 4-byte sequence handed to `NewStringUTF` is +/// mangled. The converters go through UTF-16 (`GetStringChars`/`NewString`) +/// instead, so every Kotlin `String` round-trips byte-exact. +pub(crate) fn render_jni_string_helpers(out: &mut String) { + let mut w = CodeWriter::four_space(); + w.line("/* Copies `s` as a NUL-terminated standard UTF-8 string (JNI's modified"); + w.line(" UTF-8 would encode supplementary characters as surrogate pairs the"); + w.line(" producer rejects). Lone surrogates become U+FFFD. Release with free()."); + w.line(" Returns NULL for a NULL string, for a string holding U+0000 (which no"); + w.line(" C string can carry), or on allocation failure; the producer then"); + w.line(" reports the null argument through its marshalling error. */"); + w.line("static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) {"); + w.scope(|w| { + w.line("if (s == NULL) { return NULL; }"); + w.line("jsize n = (*env)->GetStringLength(env, s);"); + w.line("const jchar* u = (*env)->GetStringChars(env, s, NULL);"); + w.line("if (u == NULL) { return NULL; }"); + w.line("char* out = (char*)malloc((size_t)n * 4u + 1u);"); + w.line("if (out == NULL) { (*env)->ReleaseStringChars(env, s, u); return NULL; }"); + w.line("size_t o = 0;"); + w.block("for (jsize i = 0; i < n; i++) {", "}", |w| { + w.line("uint32_t c = u[i];"); + w.line("if (c == 0u) { free(out); (*env)->ReleaseStringChars(env, s, u); return NULL; }"); + w.block( + "if (c >= 0xD800u && c <= 0xDBFFu && i + 1 < n && u[i + 1] >= 0xDC00u && u[i + 1] <= 0xDFFFu) {", + "}", + |w| { + w.line("c = 0x10000u + ((c - 0xD800u) << 10) + ((uint32_t)u[i + 1] - 0xDC00u);"); + w.line("i++;"); + }, + ); + w.line("else if (c >= 0xD800u && c <= 0xDFFFu) { c = 0xFFFDu; }"); + w.line("if (c < 0x80u) { out[o++] = (char)c; }"); + w.line("else if (c < 0x800u) { out[o++] = (char)(0xC0u | (c >> 6)); out[o++] = (char)(0x80u | (c & 0x3Fu)); }"); + w.line("else if (c < 0x10000u) { out[o++] = (char)(0xE0u | (c >> 12)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); }"); + w.line("else { out[o++] = (char)(0xF0u | (c >> 18)); out[o++] = (char)(0x80u | ((c >> 12) & 0x3Fu)); out[o++] = (char)(0x80u | ((c >> 6) & 0x3Fu)); out[o++] = (char)(0x80u | (c & 0x3Fu)); }"); + }); + w.line("out[o] = 0;"); + w.line("(*env)->ReleaseStringChars(env, s, u);"); + w.line("return out;"); + }); + w.line("}"); + w.blank(); + w.line("/* Builds a jstring from standard UTF-8 (NewStringUTF expects modified UTF-8"); + w.line(" and mangles supplementary characters). NULL yields the empty string and"); + w.line(" a malformed sequence yields U+FFFD, so this never fails on producer"); + w.line(" output; it returns NULL only when the JVM could not allocate. */"); + w.line("static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) {"); + w.scope(|w| { + w.line("if (s == NULL) { s = \"\"; }"); + w.line("size_t n = strlen(s);"); + w.line("jchar* buf = (jchar*)malloc((n + 1u) * sizeof(jchar));"); + w.line("if (buf == NULL) { return NULL; }"); + w.line("size_t i = 0, o = 0;"); + w.block("while (i < n) {", "}", |w| { + w.line("unsigned char b = (unsigned char)s[i];"); + w.line("uint32_t c; size_t len;"); + w.line("if (b < 0x80u) { c = b; len = 1; }"); + w.line("else if ((b & 0xE0u) == 0xC0u) { c = b & 0x1Fu; len = 2; }"); + w.line("else if ((b & 0xF0u) == 0xE0u) { c = b & 0x0Fu; len = 3; }"); + w.line("else if ((b & 0xF8u) == 0xF0u) { c = b & 0x07u; len = 4; }"); + w.line("else { c = 0xFFFDu; len = 1; }"); + w.line("if (i + len > n) { c = 0xFFFDu; len = 1; }"); + w.block("for (size_t k = 1; k < len; k++) {", "}", |w| { + w.line("unsigned char cb = (unsigned char)s[i + k];"); + w.line("if ((cb & 0xC0u) != 0x80u) { c = 0xFFFDu; len = k; break; }"); + w.line("c = (c << 6) | (cb & 0x3Fu);"); + }); + w.line("i += len;"); + w.line("if (c > 0x10FFFFu) { c = 0xFFFDu; }"); + w.line("if (c >= 0x10000u) { c -= 0x10000u; buf[o++] = (jchar)(0xD800u + (c >> 10)); buf[o++] = (jchar)(0xDC00u + (c & 0x3FFu)); }"); + w.line("else { buf[o++] = (jchar)c; }"); + }); + w.line("jstring out = (*env)->NewString(env, buf, (jsize)o);"); + w.line("free(buf);"); + w.line("return out;"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the thread-attach helpers every producer-thread entry point (async +/// completion callbacks, callback-interface trampolines, and vtable `free`) +/// uses. A producer thread is usually not a JVM thread: attach when needed +/// and detach before returning, because a thread that dies while still +/// attached leaves the JVM with a zombie attachment record that hangs +/// process shutdown. +pub(crate) fn render_jni_thread_helpers(out: &mut String) { + let mut w = CodeWriter::four_space(); + w.line("/* Returns 1 when this call attached the thread (the caller must detach),"); + w.line(" 0 otherwise. *env is NULL when no JNIEnv could be obtained. */"); + w.line("static int weaveffi_jni_attach(JNIEnv** env) {"); + w.scope(|w| { + w.line("*env = NULL;"); + w.line("if (weaveffi_jni_vm == NULL) { return 0; }"); + w.line("if ((*weaveffi_jni_vm)->GetEnv(weaveffi_jni_vm, (void**)env, JNI_VERSION_1_6) == JNI_OK) { return 0; }"); + // `AttachCurrentThread` takes `JNIEnv**` on Android and `void**` + // elsewhere; a `void*` converts implicitly to either in C. + w.line("if ((*weaveffi_jni_vm)->AttachCurrentThread(weaveffi_jni_vm, (void*)env, NULL) != JNI_OK) { *env = NULL; return 0; }"); + w.line("return 1;"); + }); + w.line("}"); + w.blank(); + w.line("static void weaveffi_jni_detach(int attached) {"); + w.scope(|w| { + w.line("if (attached) { (*weaveffi_jni_vm)->DetachCurrentThread(weaveffi_jni_vm); }"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the uncaught-exception helper for async continuation resumes: a +/// pending exception is routed to the settable Kotlin handler through the +/// cached `WeaveFFI` class. When no handler is installed (the dispatcher +/// rethrows) or the handler itself throws, the helper falls back to +/// `ExceptionDescribe`, so the exception is logged with its stack trace +/// before being cleared; it is never silently swallowed. +pub(crate) fn render_jni_uncaught_support(out: &mut String) { + let mut w = CodeWriter::four_space(); + w.line("static void weaveffi_jni_handle_uncaught(JNIEnv* env) {"); + w.scope(|w| { + w.line("if (!(*env)->ExceptionCheck(env)) { return; }"); + w.line("jthrowable ex = (*env)->ExceptionOccurred(env);"); + w.line("(*env)->ExceptionClear(env);"); + w.block( + "if (weaveffi_jni_entry_class != NULL && weaveffi_jni_dispatch_exc != NULL) {", + "}", + |w| { + w.line("(*env)->CallStaticVoidMethod(env, weaveffi_jni_entry_class, weaveffi_jni_dispatch_exc, ex);"); + w.line("if (!(*env)->ExceptionCheck(env)) { (*env)->DeleteLocalRef(env, ex); return; }"); + w.line("(*env)->ExceptionClear(env);"); + }, + ); + w.line("(*env)->Throw(env, ex);"); + w.line("(*env)->ExceptionDescribe(env);"); + w.line("(*env)->ExceptionClear(env);"); + w.line("(*env)->DeleteLocalRef(env, ex);"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the foreign-failure reporter callback trampolines call when the +/// Kotlin implementation (or the decoding shim in front of it) threw: the +/// pending exception is cleared, its `toString()` is borrowed, and +/// `{prefix}_error_set(out_err, -4, message)` hands the producer a copy. The +/// exception never unwinds through the C frame. +pub(crate) fn render_jni_foreign_error_support(out: &mut String, c_prefix: &str) { + let mut w = CodeWriter::four_space(); + w.line("static void weaveffi_jni_report_foreign(JNIEnv* env, weaveffi_error* out_err) {"); + w.scope(|w| { + w.line("jthrowable ex = (*env)->ExceptionOccurred(env);"); + w.line("(*env)->ExceptionClear(env);"); + w.line("jstring jmsg = NULL;"); + w.block( + "if (ex != NULL && weaveffi_jni_throwable_to_string != NULL) {", + "}", + |w| { + w.line("jmsg = (jstring)(*env)->CallObjectMethod(env, ex, weaveffi_jni_throwable_to_string);"); + w.line("if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); jmsg = NULL; }"); + }, + ); + w.line("char* msg = weaveffi_jni_string_to_utf8(env, jmsg);"); + w.line(format!( + "{c_prefix}_error_set(out_err, {FOREIGN_ERROR_CODE}, msg != NULL ? msg : \"callback interface implementation threw\");" + )); + w.line("free(msg);"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the generic thrower: constructs the brand exception with the raw +/// `(code, message)` pair via `NewObject` (so unknown codes keep their numeric +/// code) and throws it. Every non-throwing callable dispatches here, and it +/// is the trap channel for the runtime's reserved negative codes (`-1` +/// generic, `-2` producer panic, `-3` marshalling failure, `-4` a callback +/// interface implementation raised). +pub(crate) fn render_jni_generic_thrower(out: &mut String, jni_pkg_path: &str) { + let mut w = CodeWriter::four_space(); + w.line("static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {"); + w.scope(|w| { + w.line("const char* msg = err->message ? err->message : \"WeaveFFI error\";"); + w.line(format!( + "jclass exClass = (*env)->FindClass(env, \"{}/{}\");", + jni_pkg_path, + errors::EXCEPTION_BRAND + )); + w.block("if (exClass != NULL) {", "}", |w| { + w.line("jmethodID ctor = (*env)->GetMethodID(env, exClass, \"\", \"(ILjava/lang/String;)V\");"); + w.line("jstring jmsg = weaveffi_jni_utf8_to_string(env, msg);"); + w.line("jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg);"); + w.line("if (ex != NULL) { (*env)->Throw(env, ex); }"); + }); + w.line("weaveffi_error_clear(err);"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit the thrower for one declared error domain: the raw `(code, message)` +/// pair and the serialized payload (copied to a `jbyteArray`, or `NULL` when +/// absent) are handed to the sealed exception's static `fromCode` factory, +/// which decodes payload fields into the typed subclass; unknown codes fall +/// back to the generic exception inside `fromCode` itself. Both the message +/// and the payload buffer are released via `weaveffi_error_clear`. +pub(crate) fn render_jni_domain_thrower(out: &mut String, eb: &ErrorBinding, jni_pkg_path: &str) { + let exc = kotlin_exception_name(eb); + let brand = errors::EXCEPTION_BRAND; + let mut w = CodeWriter::four_space(); + w.line(format!( + "static void throw_{}(JNIEnv* env, weaveffi_error* err) {{", + eb.c_tag + )); + w.scope(|w| { + w.line(format!( + "jclass exClass = (*env)->FindClass(env, \"{jni_pkg_path}/{exc}\");" + )); + w.line(format!( + "jmethodID fromCode = exClass ? (*env)->GetStaticMethodID(env, exClass, \"fromCode\", \"(ILjava/lang/String;[B)L{jni_pkg_path}/{brand};\") : NULL;" + )); + w.block("if (fromCode == NULL) {", "}", |w| { + w.line("(*env)->ExceptionClear(env);"); + w.line("throw_weaveffi_error(env, err);"); + w.line("return;"); + }); + w.line("const char* msg = err->message ? err->message : \"WeaveFFI error\";"); + w.line("jstring jmsg = weaveffi_jni_utf8_to_string(env, msg);"); + w.line("jbyteArray jpayload = NULL;"); + w.block("if (err->payload_ptr != NULL) {", "}", |w| { + w.line("jpayload = (*env)->NewByteArray(env, (jsize)err->payload_len);"); + w.line("if (jpayload != NULL) { (*env)->SetByteArrayRegion(env, jpayload, 0, (jsize)err->payload_len, (const jbyte*)err->payload_ptr); }"); + }); + w.line("jthrowable ex = (jthrowable)(*env)->CallStaticObjectMethod(env, exClass, fromCode, (jint)err->code, jmsg, jpayload);"); + // A pending exception from fromCode itself (e.g. a malformed payload + // buffer) is left in place; otherwise the mapped exception is thrown. + w.line("if (ex != NULL && !(*env)->ExceptionCheck(env)) { (*env)->Throw(env, ex); }"); + w.line("weaveffi_error_clear(err);"); + }); + w.line("}"); + w.blank(); + out.push_str(&w.finish()); +} + +/// The C thrower a sync callable's error check dispatches to: the typed +/// domain thrower when the callable's [`ErrorStrategy`] is +/// [`Throws`](ErrorStrategy::Throws) inside a module with an error domain, +/// the generic thrower otherwise (the trap channel). +pub(crate) fn jni_thrower_for(f: &FnBinding, error: Option<&ErrorBinding>) -> String { + match (error, f.error_strategy()) { + (Some(eb), ErrorStrategy::Throws) => format!("throw_{}", eb.c_tag), + _ => "throw_weaveffi_error".to_string(), + } +} + +/// Whether any sync or iterator callable dispatches to the domain thrower for +/// `c_tag`, counting inheriting submodules. Async errors bypass the C +/// throwers (they resume the continuation), so an async-only domain emits no +/// thrower. +pub(crate) fn domain_thrower_used(model: &BindingModel, c_tag: &str) -> bool { + model.modules.iter().any(|m| { + m.error.as_ref().is_some_and(|e| e.c_tag == c_tag) + && m.callables().any(|f| f.throws && !f.is_async) + }) +} diff --git a/crates/weaveffi-gen-kotlin/src/tests.rs b/crates/weaveffi-gen-kotlin/src/tests.rs new file mode 100644 index 00000000..a09a469f --- /dev/null +++ b/crates/weaveffi-gen-kotlin/src/tests.rs @@ -0,0 +1,395 @@ +//! Unit tests rendering an IR fixture through the generator: the fixture +//! covers an interface (constructor, methods, statics), a nullable interface +//! parameter and return, a record carrying an interface, iterators over +//! objects and records, a callback interface with buffered, object, nullable +//! object, and enum traffic, and an async callable. + +use camino::{Utf8Path, Utf8PathBuf}; +use weaveffi_core::backend::LanguageBackend; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::{FileContent, PackageContext}; +use weaveffi_core::platform::{BinarySet, NativeBinary, Platform}; +use weaveffi_core::resolved::ResolvedApi; + +use crate::{KotlinConfig, KotlinGenerator}; + +const FIXTURE: &str = r#" +version: "0.9.0" +modules: + - name: bus + enums: + - name: Level + variants: + - { name: Low, value: 0 } + - { name: High, value: 1 } + structs: + - name: Event + doc: A record carrying an object + fields: + - { name: id, type: i64 } + - { name: source, type: Store } + - { name: parent, type: "Store?" } + callback_interfaces: + - name: Subscriber + doc: Consumer-implemented event sink + methods: + - name: on_event + params: + - { name: event, type: Event } + - { name: store, type: Store } + - { name: maybe, type: "Store?" } + - { name: note, type: string } + - { name: level, type: Level } + return: bool + - name: pick_level + params: + - { name: hint, type: i32 } + return: Level + - name: on_close + params: [] + errors: + name: BusError + codes: + - { name: Closed, code: 1, message: "Bus is closed" } + interfaces: + - name: Store + doc: A reference-counted object + constructors: + - name: new + params: + - { name: name, type: string } + methods: + - name: describe + params: [] + return: string + - name: parent + params: [] + return: "Store?" + - name: adopt + params: + - { name: other, type: "Store?" } + - name: subscribe + params: + - { name: sub, type: Subscriber } + throws: true + - name: events + params: [] + return: "iter" + - name: children + params: [] + return: "iter" + statics: + - name: open_async + params: + - { name: name, type: string } + return: Store + async: true + functions: + - name: publish + params: + - { name: store, type: Store } + - { name: sub, type: Subscriber } + return: Event +"#; + +fn api() -> ResolvedApi { + let api = weaveffi_ir::parse::parse_api_str(FIXTURE, "yaml").expect("fixture parses"); + weaveffi_core::validate::validate_api(api, None).expect("fixture validates") +} + +fn render() -> (String, String) { + let api = api(); + let model = BindingModel::build(&api, "weaveffi"); + let files = KotlinGenerator.files(&api, &model, Utf8Path::new("out"), &KotlinConfig::default()); + let find = |name: &str| { + files + .iter() + .find(|f| f.path.as_str().ends_with(name)) + .unwrap_or_else(|| { + panic!( + "{name} missing from {:?}", + files.iter().map(|f| &f.path).collect::>() + ) + }) + .contents + .clone() + }; + (find("WeaveFFI.kt"), find("weaveffi_jni.c")) +} + +#[test] +fn layout_is_a_kotlin_gradle_module() { + let api = api(); + let model = BindingModel::build(&api, "weaveffi"); + let files = KotlinGenerator.files(&api, &model, Utf8Path::new("out"), &KotlinConfig::default()); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!( + paths, + [ + "out/kotlin/settings.gradle.kts", + "out/kotlin/build.gradle.kts", + "out/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt", + "out/kotlin/src/main/cpp/CMakeLists.txt", + "out/kotlin/src/main/cpp/weaveffi_jni.c", + ] + ); + assert_eq!(KotlinGenerator.name(), "kotlin"); +} + +#[test] +fn interface_wrapper_is_cleaner_backed_and_exposes_clone() { + let (kt, c) = render(); + // The raw-handle constructor is private so a `new(Long)` exposed as + // `operator fun invoke(Long)` can't be shadowed by it inside the module; + // the generated code adopts handles through `fromHandle` instead. + assert!(kt.contains("class Store private constructor(handle: Long) : AutoCloseable {")); + assert!(kt.contains("internal fun fromHandle(handle: Long): Store = Store(handle)")); + assert!(!kt.contains("internal constructor(handle: Long) : AutoCloseable")); + assert!(kt.contains("private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) }")); + assert!(kt.contains("private val cleanable = weaveCleaner.register(this, ref)")); + assert!(kt.contains("internal fun cloneHandle(): Long = nativeClone(ref.get())")); + assert!(kt.contains("override fun close() {\n cleanable.clean()\n }")); + assert!(kt.contains("@JvmStatic private external fun nativeClone(handle: Long): Long")); + assert!(kt.contains( + "internal val weaveCleaner: java.lang.ref.Cleaner = java.lang.ref.Cleaner.create()" + )); + assert!(!kt.contains("finalize()")); + assert!( + c.contains("Java_com_weaveffi_Store_nativeClone(JNIEnv* env, jclass clazz, jlong handle)") + ); + assert!(c.contains("return (jlong)(intptr_t)weaveffi_bus_Store_clone((const weaveffi_bus_Store*)(intptr_t)handle);")); + assert!(c.contains("weaveffi_bus_Store_destroy((weaveffi_bus_Store*)(intptr_t)handle);")); +} + +#[test] +fn nullable_interface_maps_to_nullable_wrapper() { + let (kt, c) = render(); + // Return: the JNI layer boxes the owned pointer as `Long?`; the wrapper adopts it. + assert!(kt.contains("@JvmStatic private external fun nativeParent(selfHandle: Long): Long?")); + assert!( + kt.contains("fun parent(): Store? = nativeParent(handle)?.let { Store.fromHandle(it) }") + ); + assert!(c.contains("if (rv == NULL) { return NULL; }")); + // Parameter: the wrapper lends its handle, null crosses as null. + assert!(kt.contains("fun adopt(other: Store?) { nativeAdopt(handle, other?.handle) }")); + assert!(c.contains("int64_t other_val = 0;")); + assert!(c.contains("(const weaveffi_bus_Store*)(intptr_t)other_val")); +} + +#[test] +fn record_with_object_field_uses_object_tokens() { + let (kt, _) = render(); + assert!(kt.contains("data class Event(val id: Long, val source: Store, val parent: Store?)")); + // Encoding clones so the wrapper keeps its own reference; decoding adopts. + assert!(kt.contains("w.writeI64(v.source.cloneHandle())")); + assert!(kt.contains("w.writeOptional(v.parent) { v0 -> w.writeI64(v0.cloneHandle()) }")); + assert!(kt.contains("Store.fromHandle(r.readObject())")); + assert!(kt.contains("r.readOptional { Store.fromHandle(r.readObject()) }")); + assert!(kt.contains("fun readObject(): Long {")); +} + +#[test] +fn iterators_are_lazy_cleaner_backed_classes() { + let (kt, c) = render(); + assert!(kt.contains("class BusStoreEventsIterator internal constructor(handle: Long) : Iterator, AutoCloseable {")); + assert!(kt.contains("class BusStoreChildrenIterator internal constructor(handle: Long) : Iterator, AutoCloseable {")); + assert!(kt.contains("val slot = nativeNext(ref.get())")); + assert!(kt.contains("return Store.fromHandle(raw as Long)")); + assert!(kt.contains("return weaveDecode((raw as ByteArray)) { r -> unpackEvent(r) }")); + assert!( + kt.contains("fun events(): Iterator = BusStoreEventsIterator(nativeEvents(handle))") + ); + assert!(c.contains("Java_com_weaveffi_BusStoreChildrenIterator_nativeNext")); + assert!(c.contains("weaveffi_bus_Store_EventsIterator_destroy")); +} + +#[test] +fn callback_interface_renders_interface_and_dispatch_object() { + let (kt, _) = render(); + assert!(kt.contains("interface Subscriber {")); + assert!(kt.contains( + "fun onEvent(event: Event, store: Store, maybe: Store?, note: String, level: Level): Boolean" + )); + assert!(kt.contains("fun pickLevel(hint: Int): Level")); + assert!(kt.contains("fun onClose()")); + assert!(kt.contains("internal object SubscriberJni {")); + // The shim decodes the buffer, adopts both object references, and lowers the enum. + assert!(kt.contains( + "@JvmStatic fun onEvent(impl: Subscriber, event: ByteArray, store: Long, maybe: Long, note: String, level: Int): Boolean = impl.onEvent(weaveDecode(event) { r -> unpackEvent(r) }, Store.fromHandle(store), maybe.takeIf { it != 0L }?.let { Store.fromHandle(it) }, note, Level.fromValue(level))" + )); + assert!(kt.contains( + "@JvmStatic fun pickLevel(impl: Subscriber, hint: Int): Int = impl.pickLevel(hint).value" + )); + assert!(kt.contains("@JvmStatic fun onClose(impl: Subscriber) { impl.onClose() }")); + // Passing an implementation needs no wrapper split: the object crosses as itself. + assert!(kt.contains("fun subscribe(sub: Subscriber) { nativeSubscribe(handle, sub) }")); + assert!(kt.contains( + "@JvmStatic private external fun publishJni(store: Long, sub: Subscriber): ByteArray" + )); +} + +#[test] +fn callback_interface_shim_pins_global_ref_and_uses_static_vtable() { + let (_, c) = render(); + // JNI_OnLoad caches the VM, the dispatch class, and every method ID. + assert!(c.contains("static JavaVM* weaveffi_jni_vm = NULL;")); + assert!(c.contains("JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {")); + assert!(c.contains("if (weaveffi_abi_version() != 2u) {")); + assert!(c.contains("FindClass(env, \"com/weaveffi/SubscriberJni\")")); + assert!(c.contains( + "GetStaticMethodID(env, weaveffi_bus_Subscriber_jni_cls, \"onEvent\", \"(Lcom/weaveffi/Subscriber;[BJJLjava/lang/String;I)Z\");" + )); + assert!(c.contains( + "GetStaticMethodID(env, weaveffi_bus_Subscriber_jni_cls, \"pickLevel\", \"(Lcom/weaveffi/Subscriber;I)I\");" + )); + assert!(c.contains( + "GetStaticMethodID(env, weaveffi_bus_Subscriber_jni_cls, \"onClose\", \"(Lcom/weaveffi/Subscriber;)V\");" + )); + // Trampolines attach, convert, call, and report foreign failures. + // The trampoline signature is the header's vtable entry: object arguments + // arrive as owned (non-const) pointers the shim adopts. + assert!(c.contains("static bool weaveffi_bus_Subscriber_jni_on_event(void* ctx, const uint8_t* event_ptr, size_t event_len, weaveffi_bus_Store* store, weaveffi_bus_Store* maybe, const char* note, weaveffi_bus_Level level, weaveffi_error* out_err) {")); + assert!(c.contains("int attached = weaveffi_jni_attach(&env);")); + assert!(c.contains("jbyteArray _a0 = (*env)->NewByteArray(env, (jsize)event_len);")); + assert!(c.contains("jlong _a1 = (jlong)(intptr_t)store;")); + assert!(c.contains("jlong _a2 = (jlong)(intptr_t)maybe;")); + assert!(c.contains("jstring _a3 = weaveffi_jni_utf8_to_string(env, note);")); + assert!(c.contains("jint _a4 = (jint)level;")); + assert!(c.contains("jboolean _rv = (*env)->CallStaticBooleanMethod(env, weaveffi_bus_Subscriber_jni_cls, weaveffi_bus_Subscriber_jni_mid_on_event, (jobject)ctx, _a0, _a1, _a2, _a3, _a4);")); + assert!(c.contains("weaveffi_jni_report_foreign(env, out_err);")); + assert!(c.contains("weaveffi_error_set(out_err, -4, msg != NULL ? msg : \"callback interface implementation threw\");")); + assert!(c.contains("return _rv == JNI_TRUE;")); + assert!(c.contains("jint _rv = (*env)->CallStaticIntMethod(env, weaveffi_bus_Subscriber_jni_cls, weaveffi_bus_Subscriber_jni_mid_pick_level, (jobject)ctx, _a0);")); + assert!(c.contains("weaveffi_jni_detach(attached);")); + // `free` deletes the global ref; the vtable is one static value. + assert!(c.contains("static void weaveffi_bus_Subscriber_jni_free(void* ctx) {")); + assert!(c.contains("(*env)->DeleteGlobalRef(env, (jobject)ctx);")); + assert!(c.contains("static const weaveffi_bus_Subscriber_vtable weaveffi_bus_Subscriber_jni_vtable = { weaveffi_bus_Subscriber_jni_on_event, weaveffi_bus_Subscriber_jni_pick_level, weaveffi_bus_Subscriber_jni_on_close, weaveffi_bus_Subscriber_jni_free };")); + // The export pins the implementation and passes ctx + vtable. + assert!(c.contains("jobject sub_ref = (*env)->NewGlobalRef(env, sub);")); + assert!(c.contains("weaveffi_bus_Store_subscribe((const weaveffi_bus_Store*)(intptr_t)selfHandle, (void*)sub_ref, &weaveffi_bus_Subscriber_jni_vtable, &err);")); + // The throwing method dispatches to the domain thrower. + assert!(c.contains("throw_weaveffi_bus_BusError(env, &err);")); +} + +#[test] +fn strings_cross_as_standard_utf8_not_modified_utf8() { + let (_, c) = render(); + // Both converters are emitted once, ahead of every user. + assert!(c.contains("#include ")); + assert!(c.contains("static char* weaveffi_jni_string_to_utf8(JNIEnv* env, jstring s) {")); + assert!(c.contains("static jstring weaveffi_jni_utf8_to_string(JNIEnv* env, const char* s) {")); + assert!( + c.find("weaveffi_jni_string_to_utf8").unwrap() < c.find("throw_weaveffi_error").unwrap() + ); + // A string parameter is copied as standard UTF-8 and freed after the call; + // a string return is rebuilt from UTF-8. JNI's modified-UTF-8 entry points + // never appear, since they mangle supplementary characters. + assert!(c.contains("char* name_chars = weaveffi_jni_string_to_utf8(env, name);")); + assert!(c.contains("free(name_chars);")); + assert!(c.contains("jstring out = weaveffi_jni_utf8_to_string(env, rv);")); + assert!(c.contains("jstring jmsg = weaveffi_jni_utf8_to_string(env, msg);")); + assert!(c.contains("char* msg = weaveffi_jni_string_to_utf8(env, jmsg);")); + assert!(!c.contains("->GetStringUTFChars(")); + assert!(!c.contains("->ReleaseStringUTFChars(")); + assert!(!c.contains("->NewStringUTF(")); +} + +#[test] +fn async_uses_cached_vm_and_adopts_object_result() { + let (kt, c) = render(); + assert!(kt.contains("suspend fun openAsync(name: String): Store {")); + assert!(kt.contains("return Store.fromHandle(raw)")); + assert!(c.contains("static void weaveffi_bus_Store_open_async_jni_cb(void* context, weaveffi_error* err, weaveffi_bus_Store* result) {")); + assert!(c.contains("weaveffi_jni_handle_uncaught(env);")); + assert!(c.contains("(jlong)(intptr_t)result);")); + assert!(!c.contains("GetJavaVM")); +} + +#[test] +fn no_arena_or_listener_bindings_remain() { + let (kt, c) = render(); + for stale in ["arena", "listener", "Listener", "pthread"] { + assert!(!kt.contains(stale), "Kotlin still mentions {stale}"); + assert!(!c.contains(stale), "C shim still mentions {stale}"); + } + // Every class touching natives goes through the single loader object. + assert!(kt.contains("init { WeaveNativeLibrary.ensureLoaded() }")); + assert_eq!(kt.matches("System.loadLibrary(").count(), 1); + assert!(kt.contains("init { System.loadLibrary(\"weaveffi\") }")); +} + +#[test] +fn package_lays_out_an_aar_style_module() { + let api = api(); + let model = BindingModel::build(&api, "weaveffi"); + let mut binaries = BinarySet::new("bus"); + binaries.binaries.push(NativeBinary { + platform: Platform::AndroidArm64, + source: Utf8PathBuf::from("/prebuilt/android-arm64/libbus.so"), + }); + binaries.binaries.push(NativeBinary { + platform: Platform::MacosArm64, + source: Utf8PathBuf::from("/prebuilt/darwin-arm64/libbus.dylib"), + }); + binaries.binaries.push(NativeBinary { + platform: Platform::Wasm32, + source: Utf8PathBuf::from("/prebuilt/wasm32/bus.wasm"), + }); + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("bus.yml"), + }; + let files = KotlinGenerator + .package( + &api, + &model, + &ctx, + Utf8Path::new("out"), + &KotlinConfig::default(), + ) + .expect("kotlin supports packaging"); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!( + paths, + [ + "out/kotlin/settings.gradle.kts", + "out/kotlin/build.gradle.kts", + "out/kotlin/README.md", + "out/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt", + "out/kotlin/src/main/cpp/CMakeLists.txt", + "out/kotlin/src/main/cpp/weaveffi_jni.c", + "out/kotlin/src/main/cpp/include/weaveffi.h", + "out/kotlin/src/main/jniLibs/arm64-v8a/libbus.so", + "out/kotlin/src/main/resources/natives/darwin-arm64/libbus.dylib", + ] + ); + let text = |name: &str| match &files + .iter() + .find(|f| f.path.as_str().ends_with(name)) + .unwrap() + .content + { + FileContent::Text(t) => t.clone(), + FileContent::Copy(_) => panic!("{name} is a binary"), + }; + let kt = text("WeaveFFI.kt"); + assert!(kt.contains("private const val LIB = \"bus\"")); + assert!(kt.contains("private const val JNI_LIB = \"bus_jni\"")); + assert!(kt.contains("System.loadLibrary(JNI_LIB)")); + assert!(kt.contains("getResourceAsStream(\"/natives/$id/$name\")")); + let cmake = text("CMakeLists.txt"); + assert!(cmake.contains("add_library(bus_jni SHARED weaveffi_jni.c)")); + assert!(cmake.contains("../jniLibs/${ANDROID_ABI}/libbus.so")); + let gradle = text("build.gradle.kts"); + assert!(gradle.contains("namespace = \"com.weaveffi\"")); + assert!(gradle.contains("minSdk = 33")); + assert!(gradle.contains("jniLibs.srcDirs(\"src/main/jniLibs\")")); + let header = text("weaveffi.h"); + assert!(header.contains("typedef struct weaveffi_bus_Subscriber_vtable {")); + assert!(header.contains("weaveffi_bus_Store_clone")); + let readme = text("README.md"); + assert!(readme.contains("- `src/main/jniLibs/arm64-v8a/libbus.so`")); + assert!(readme.contains("- `src/main/resources/natives/darwin-arm64/libbus.dylib`")); + assert!(!readme.contains("wasm32")); +} diff --git a/crates/weaveffi-gen-android/src/types.rs b/crates/weaveffi-gen-kotlin/src/types.rs similarity index 65% rename from crates/weaveffi-gen-android/src/types.rs rename to crates/weaveffi-gen-kotlin/src/types.rs index 1d2016af..61ae361d 100644 --- a/crates/weaveffi-gen-android/src/types.rs +++ b/crates/weaveffi-gen-kotlin/src/types.rs @@ -1,6 +1,7 @@ //! Type mapping and naming policy: the Kotlin surface type, the lowered JNI -//! type, and the C declaration type for every IR type, plus identifier -//! casing, JNI name mangling, and reserved-word escaping. +//! type, the JNI method descriptor, and the C declaration type for every IR +//! type, plus identifier casing, JNI name mangling, and reserved-word +//! escaping. use std::fmt::Write as _; @@ -32,8 +33,9 @@ pub(crate) fn c_local(name: &str) -> String { } /// The idiomatic (public) Kotlin type for an IR type: records and rich enums -/// surface as their generated value classes, lists and maps as `List`/`Map`, -/// optionals as nullable types, and handles as raw `Long` tokens. +/// surface as their generated value classes, interfaces as their wrapper +/// classes, callback interfaces as the generated Kotlin `interface`, lists +/// and maps as `List`/`Map`, and optionals as nullable types. pub(crate) fn kotlin_type(t: &Ty) -> String { match t { Ty::I8 | Ty::U8 => "Byte".to_string(), @@ -44,19 +46,20 @@ pub(crate) fn kotlin_type(t: &Ty) -> String { Ty::F32 => "Float".to_string(), Ty::F64 => "Double".to_string(), Ty::Bool => "Boolean".to_string(), - Ty::StringUtf8 | Ty::BorrowedStr => "String".to_string(), - Ty::Bytes | Ty::BorrowedBytes => "ByteArray".to_string(), - // Handles (typed or not) are opaque u64 tokens; both surface as `Long`. - Ty::Handle | Ty::TypedHandle(_) => "Long".to_string(), + Ty::StringUtf8 => "String".to_string(), + Ty::Bytes => "ByteArray".to_string(), // An interface surfaces as its generated Kotlin wrapper class; the - // JNI layer carries the raw `Long` pointer. - Ty::Interface(name) => local_type_name(name).to_string(), + // JNI layer carries the raw `Long` pointer. A callback interface is + // the generated Kotlin `interface` the consumer implements. // Records and rich enums are value types: the generated data class or // sealed class, decoded from the value buffer by the wrapper layer. // Cross-module references (e.g. `geo.Point`) name the bare local // Kotlin class `Point`, never the dot-qualified IR name. - Ty::Record(name) | Ty::RichEnum(name) => local_type_name(name).to_string(), - Ty::Enum(name) => local_type_name(name).to_string(), + Ty::Interface(name) + | Ty::CallbackInterface(name) + | Ty::Record(name) + | Ty::RichEnum(name) + | Ty::Enum(name) => local_type_name(name).to_string(), Ty::Optional(inner) => format!("{}?", kotlin_type(inner)), Ty::List(inner) => format!("List<{}>", kotlin_type(inner)), Ty::Iterator(inner) => format!("Iterator<{}>", kotlin_type(inner)), @@ -66,7 +69,8 @@ pub(crate) fn kotlin_type(t: &Ty) -> String { /// The Kotlin type a value crosses the JNI boundary as: buffered values pack /// into a `ByteArray`, enums cross as their raw `Int`, interfaces and -/// iterator handles as raw `Long`s. Everything else matches [`kotlin_type`]. +/// iterator handles as raw `Long`s, and callback interfaces as the +/// implementing object itself. Everything else matches [`kotlin_type`]. pub(crate) fn kotlin_jni_type(t: &Ty) -> String { if t.is_buffered() { return "ByteArray".to_string(); @@ -84,38 +88,27 @@ pub(crate) fn kotlin_jni_type(t: &Ty) -> String { } } -/// The public Kotlin parameter types of a listener callback lambda. Buffered -/// arguments are decoded by the generated wrapper before the user's lambda -/// runs, so they surface as their idiomatic value types. Enums stay raw `Int` -/// and interfaces raw `Long`: trampolines box arguments on arbitrary producer -/// threads where only bootstrap classes (`java/lang/*`) are loadable. -pub(crate) fn kotlin_cb_type(t: &Ty) -> String { +/// The Kotlin parameter type of a callback-interface dispatch shim (the +/// static method the native vtable trampoline calls): buffered arguments +/// arrive as a borrowed `ByteArray` copy, enums as their raw `Int`, and +/// objects (nullable or not) as the raw pointer `Long` the shim adopts +/// (`0L` is none). Everything else matches [`kotlin_type`]. +pub(crate) fn kotlin_shim_param_type(t: &Ty) -> String { if t.is_buffered() { - return kotlin_type(t); + return "ByteArray".to_string(); } match t { Ty::Enum(_) => "Int".to_string(), - Ty::TypedHandle(_) | Ty::Interface(_) => "Long".to_string(), - // Only `Interface?` reaches here: a nullable raw pointer. - Ty::Optional(_) => "Long?".to_string(), - Ty::Iterator(_) => unreachable!("validation rejects iterator callback params"), + // Only `Interface?` reaches the optional arm (every other optional is + // buffered); the trampoline passes the raw pointer, `0` for none. + Ty::Interface(_) | Ty::Optional(_) => "Long".to_string(), other => kotlin_type(other), } } -/// The Kotlin parameter types of the JNI-facing listener callback lambda the -/// trampoline invokes: buffered arguments arrive as a raw `ByteArray` copy; -/// everything else matches [`kotlin_cb_type`]. -pub(crate) fn kotlin_cb_jni_type(t: &Ty) -> String { - if t.is_buffered() { - return "ByteArray".to_string(); - } - kotlin_cb_type(t) -} - /// The JNI C parameter type (`jint`, `jbyteArray`, ...) an IR type crosses /// the JNI export boundary as. Buffered values arrive as one packed -/// `jbyteArray`. +/// `jbyteArray`; a callback interface arrives as the implementing `jobject`. pub(crate) fn jni_param_type(t: &Ty) -> String { if t.is_buffered() { return "jbyteArray".to_string(); @@ -124,17 +117,16 @@ pub(crate) fn jni_param_type(t: &Ty) -> String { Ty::I8 | Ty::U8 => "jbyte".to_string(), Ty::I16 | Ty::U16 => "jshort".to_string(), Ty::I32 | Ty::Enum(_) => "jint".to_string(), - Ty::U32 | Ty::I64 | Ty::U64 | Ty::TypedHandle(_) | Ty::Handle | Ty::Interface(_) => { - "jlong".to_string() - } + Ty::U32 | Ty::I64 | Ty::U64 | Ty::Interface(_) => "jlong".to_string(), Ty::F32 => "jfloat".to_string(), Ty::F64 => "jdouble".to_string(), Ty::Bool => "jboolean".to_string(), - Ty::StringUtf8 | Ty::BorrowedStr => "jstring".to_string(), - Ty::Bytes | Ty::BorrowedBytes => "jbyteArray".to_string(), + Ty::StringUtf8 => "jstring".to_string(), + Ty::Bytes => "jbyteArray".to_string(), // Only `Interface?` reaches here: Kotlin's `Long?` boxes to - // `java.lang.Long`, so the slot is a nullable `jobject`. - Ty::Optional(_) => "jobject".to_string(), + // `java.lang.Long`, so the slot is a nullable `jobject`. A callback + // interface is the implementing object, pinned by the export. + Ty::Optional(_) | Ty::CallbackInterface(_) => "jobject".to_string(), // An iterator return crosses as the raw handle (`jlong`); it is never // a parameter (validation rejects that position). Ty::Iterator(_) => "jlong".to_string(), @@ -153,9 +145,68 @@ pub(crate) fn jni_ret_type(t: Option<&Ty>) -> String { } } +/// The JNI type descriptor of a callback-interface dispatch shim parameter +/// or return, matching [`kotlin_shim_param_type`]: `Ljava/lang/String;` for +/// strings, `[B` for bytes and buffered values, `J` for object pointers, and +/// the primitive letters for the direct family. `None` is `V`. +pub(crate) fn jni_descriptor(t: Option<&Ty>) -> &'static str { + let Some(t) = t else { + return "V"; + }; + if t.is_buffered() { + return "[B"; + } + match t { + Ty::I8 | Ty::U8 => "B", + Ty::I16 | Ty::U16 => "S", + Ty::I32 | Ty::Enum(_) => "I", + Ty::U32 | Ty::I64 | Ty::U64 | Ty::Interface(_) | Ty::Optional(_) => "J", + Ty::F32 => "F", + Ty::F64 => "D", + Ty::Bool => "Z", + Ty::StringUtf8 => "Ljava/lang/String;", + Ty::Bytes => "[B", + Ty::CallbackInterface(_) | Ty::Iterator(_) => { + unreachable!("validation rejects {t} inside a callback method") + } + Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { + unreachable!("buffered types are handled above") + } + } +} + +/// The `CallStatic{X}Method` suffix and matching `j{x}` C type used to invoke +/// a callback-interface dispatch shim returning `t` (a direct-family type or +/// `None` for void). +pub(crate) fn jni_call_kind(t: Option<&Ty>) -> (&'static str, &'static str) { + match t { + None => ("Void", "void"), + Some(Ty::I8 | Ty::U8) => ("Byte", "jbyte"), + Some(Ty::I16 | Ty::U16) => ("Short", "jshort"), + Some(Ty::I32 | Ty::Enum(_)) => ("Int", "jint"), + Some(Ty::U32 | Ty::I64 | Ty::U64) => ("Long", "jlong"), + Some(Ty::F32) => ("Float", "jfloat"), + Some(Ty::F64) => ("Double", "jdouble"), + Some(Ty::Bool) => ("Boolean", "jboolean"), + Some(other) => unreachable!("callback methods return direct values only, not {other}"), + } +} + +/// The C default value a trampoline returns when the consumer implementation +/// raised: `0`, `false`, or `0.0`, matching the vtable entry's C return type. +pub(crate) fn c_default_value(t: Option<&Ty>) -> &'static str { + match t { + None => "", + Some(Ty::Bool) => "false", + Some(Ty::F32) => "0.0f", + Some(Ty::F64) => "0.0", + Some(_) => "0", + } +} + /// The C declaration type for the scalar-shaped returns handled by the /// generic fallthrough in `write_return_handling`. Buffered, string, bytes, -/// optional, iterator, and typed-handle returns have dedicated emitters. +/// object, and iterator returns have dedicated emitters. pub(crate) fn c_type_for_return(t: &Ty) -> &'static str { match t { Ty::I8 => "int8_t", @@ -169,8 +220,6 @@ pub(crate) fn c_type_for_return(t: &Ty) -> &'static str { Ty::F32 => "float", Ty::F64 => "double", Ty::Bool => "bool", - Ty::Handle => "weaveffi_handle_t", - Ty::Interface(_) => "void*", other => unreachable!("return type {other:?} is handled by a dedicated emitter"), } } @@ -187,17 +236,17 @@ pub(crate) fn jni_default_return(t: Option<&Ty>) -> &'static str { match t { Ty::I8 | Ty::U8 | Ty::I16 | Ty::U16 => "return 0;", Ty::I32 | Ty::Enum(_) => "return 0;", - Ty::U32 | Ty::I64 | Ty::U64 | Ty::TypedHandle(_) | Ty::Handle => "return 0;", + Ty::U32 | Ty::I64 | Ty::U64 => "return 0;", Ty::F32 => "return 0.0f;", Ty::F64 => "return 0.0;", Ty::Bool => "return JNI_FALSE;", - Ty::StringUtf8 | Ty::BorrowedStr => "return NULL;", - Ty::Bytes | Ty::BorrowedBytes => "return NULL;", + Ty::StringUtf8 | Ty::Bytes => "return NULL;", Ty::Interface(_) => "return 0;", // Only `Interface?` reaches here (a nullable boxed `Long`). Ty::Optional(_) => "return NULL;", // The iterator launcher returns the handle as a `jlong`; 0 = failed. Ty::Iterator(_) => "return 0;", + Ty::CallbackInterface(_) => unreachable!("callback interfaces are never returned"), Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { unreachable!("buffered types are handled above") } @@ -211,10 +260,9 @@ pub(crate) fn jni_cast_for(t: &Ty) -> &'static str { Ty::I8 | Ty::U8 => "(jbyte)", Ty::I16 | Ty::U16 => "(jshort)", Ty::I32 | Ty::Enum(_) => "(jint)", - Ty::U32 | Ty::I64 | Ty::U64 | Ty::Handle => "(jlong)", + Ty::U32 | Ty::I64 | Ty::U64 => "(jlong)", Ty::F32 => "(jfloat)", Ty::F64 => "(jdouble)", - Ty::TypedHandle(_) | Ty::Interface(_) => "(jlong)(intptr_t)", _ => "", } } @@ -242,7 +290,7 @@ pub(crate) fn jni_mangle(ident: &str) -> String { /// Lower-camelCase an identifier (e.g. a PascalCase variant name) for use as a /// Kotlin factory method / property-prefix. Reuses [`pascal_case`] (which also /// normalizes `snake_case`) and then lowercases only the leading character, so -/// `Circle` → `circle`, `rich_variant` → `richVariant`. +/// `Circle` becomes `circle` and `rich_variant` becomes `richVariant`. pub(crate) fn lower_camel(s: &str) -> String { let pascal = pascal_case(s); let mut chars = pascal.chars(); @@ -252,10 +300,10 @@ pub(crate) fn lower_camel(s: &str) -> String { } } -/// The Kotlin name of a free function or listener registration: the module -/// prefix is applied (or stripped) first, then the result is lowerCamelCased -/// and keyword-escaped, so `contacts` + `create_contact` is `createContact` -/// when stripping (the default) and `contactsCreateContact` otherwise. +/// The Kotlin name of a free function: the module prefix is applied (or +/// stripped) first, then the result is lowerCamelCased and keyword-escaped, +/// so `contacts` + `create_contact` is `createContact` when stripping (the +/// default) and `contactsCreateContact` otherwise. pub(crate) fn kotlin_fn_name(module_path: &str, name: &str, strip_module_prefix: bool) -> String { kt_escape(&lower_camel(&wrapper_name( module_path, @@ -282,7 +330,8 @@ pub(crate) fn camel_params(params: &[ParamBinding]) -> Vec { /// type: buffered values (pack/decode a `ByteArray`), enums /// (`.value`/`fromValue`), interfaces (`.handle` / re-wrap into the class), /// and iterator returns (the raw `Long` handle is adopted into the generated -/// iterator class). +/// iterator class). Callback interfaces cross as the implementing object and +/// need no split. pub(crate) fn needs_wrapper_split(f: &FnBinding) -> bool { fn differs(t: &Ty) -> bool { t.is_buffered() @@ -303,3 +352,9 @@ pub(crate) fn kotlin_iterator_class_name(it: &IteratorBinding, c_prefix: &str) - let stripped = it.iter_tag.strip_prefix(&prefix).unwrap_or(&it.iter_tag); stripped.split('_').map(pascal_case).collect() } + +/// The Kotlin name of the dispatch object that adapts a callback interface +/// implementation to the JNI trampolines: `{Name}Jni`. +pub(crate) fn kotlin_callback_dispatch_name(cb_name: &str) -> String { + format!("{}Jni", local_type_name(cb_name)) +} diff --git a/crates/weaveffi-gen-node/Cargo.toml b/crates/weaveffi-gen-node/Cargo.toml index 706c00ca..63a99780 100644 --- a/crates/weaveffi-gen-node/Cargo.toml +++ b/crates/weaveffi-gen-node/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-node/src/addon.rs b/crates/weaveffi-gen-node/src/addon.rs index 30b0f928..6e354cca 100644 --- a/crates/weaveffi-gen-node/src/addon.rs +++ b/crates/weaveffi-gen-node/src/addon.rs @@ -1,23 +1,33 @@ //! The native N-API addon (`weaveffi_addon.c`): one C entry point per -//! callable, plus the iterator, listener, and async machinery. +//! callable, the per-interface `clone`/`destroy` entry points, the lazy +//! iterator and async machinery, and one static vtable plus trampolines per +//! callback interface. //! //! Marshalling dispatch is driven by the shared plan layer: //! [`ParamBinding::arg_pass`] decides how each incoming JS argument crosses -//! into its ABI slots and [`plan::ret_pass`] decides what the entry point does -//! with the result; only the N-API spellings live here. +//! into its ABI slots, [`plan::ret_pass`] decides what the entry point does +//! with a result, and [`CallbackInterfaceBinding::protocol`] decides how a +//! trampoline receives each argument; only the N-API spellings live here. +//! +//! Object handles cross the addon boundary as `bigint`s holding the pointer +//! value, 64-bit integers as `bigint`s (never `number`, so nothing above +//! 2^53 is rounded), and every other direct value as the obvious JS type. -use weaveffi_core::abi; +use weaveffi_core::abi::{self, split_qualified}; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - iterator_item_ctype, BindingModel, CallShape, CallbackBinding, FnBinding, InterfaceBinding, - IteratorBinding, ListenerBinding, ParamBinding, + iterator_item_ctype, BindingModel, CallShape, CallbackInterfaceBinding, CallbackMethodBinding, + FnBinding, InterfaceBinding, IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{self, elem_free, ArgPass, ElemFree, RetPass}; +use weaveffi_core::plan::{self, elem_free, ArgPass, Free, RetPass}; use weaveffi_core::utils::{render_prelude, render_trailer, wrapper_name, CommentStyle}; -use crate::runtime::model_has_iterators; -use crate::types::{iface_member_base, js_fn_name}; +use crate::types::{iface_lifecycle_base, iface_member_base, js_fn_name}; + +/// The ABI error code a callback trampoline writes when the consumer's +/// implementation raised (the producer runtime's `FOREIGN_ERROR_CODE`). +const FOREIGN_ERROR_CODE: i32 = -4; /// The C return-type spelling of `ty` at a call site. Buffered values render /// as `const uint8_t*` (the encoded buffer); an iterator launcher's handle is @@ -29,7 +39,96 @@ fn c_ret_type_str(ty: &Ty, module: &str, prefix: &str) -> String { abi::lower_return(ty, module).ret.render_c(prefix) } -/// The bare C type of a scalar (or C-enum-free leaf) parameter temporary. +/// The `{c_tag}` stem of a (possibly dot-qualified) callback interface name +/// referenced from `module`; its vtable is `{c_tag}_vtable` and its addon +/// helpers hang off the same stem. +fn callback_c_tag(name: &str, module: &str, prefix: &str) -> String { + let (m, n) = split_qualified(name, module); + format!("{prefix}_{m}_{n}") +} + +/// The C statement that creates napi value `target` from a direct-family C +/// expression `expr` (scalars, bools, C-style enums). 64-bit integers become +/// `bigint`s. +fn napi_create_leaf(ty: &Ty, expr: &str, target: &str) -> String { + match ty { + Ty::I8 | Ty::I16 | Ty::I32 => format!("napi_create_int32(env, {expr}, &{target});"), + Ty::U8 | Ty::U16 | Ty::U32 => format!("napi_create_uint32(env, {expr}, &{target});"), + Ty::I64 => format!("napi_create_bigint_int64(env, {expr}, &{target});"), + Ty::U64 => format!("napi_create_bigint_uint64(env, {expr}, &{target});"), + Ty::F32 | Ty::F64 => format!("napi_create_double(env, {expr}, &{target});"), + Ty::Bool => format!("napi_get_boolean(env, {expr}, &{target});"), + Ty::Enum(_) => format!("napi_create_int32(env, (int32_t)({expr}), &{target});"), + other => unreachable!("direct leaf with non-direct type {other:?}"), + } +} + +/// Emit the statements reading a direct-family JS value `val` into the C +/// lvalue `target` of type `ty`, recording the N-API status in `status`. +/// N-API only exposes 32/64-bit int and `double` getters, so narrower +/// scalars are read into a wider temporary and narrowed with an explicit +/// cast; 64-bit integers are read from `bigint`s losslessly (a `number` is +/// accepted too, and an out-of-range `bigint` is a failure). +fn emit_leaf_read(out: &mut String, indent: &str, ty: &Ty, val: &str, target: &str, status: &str) { + let tmp = format!("{}_tmp", target.replace(['.', '>', '-', '[', ']'], "_")); + match ty { + Ty::I32 => out.push_str(&format!( + "{indent}{status} = napi_get_value_int32(env, {val}, &{target});\n" + )), + Ty::U32 => out.push_str(&format!( + "{indent}{status} = napi_get_value_uint32(env, {val}, &{target});\n" + )), + Ty::I8 | Ty::I16 => { + out.push_str(&format!("{indent}int32_t {tmp} = 0;\n")); + out.push_str(&format!( + "{indent}{status} = napi_get_value_int32(env, {val}, &{tmp});\n" + )); + out.push_str(&format!( + "{indent}{target} = ({}){tmp};\n", + c_scalar_type(ty) + )); + } + Ty::U8 | Ty::U16 => { + out.push_str(&format!("{indent}uint32_t {tmp} = 0;\n")); + out.push_str(&format!( + "{indent}{status} = napi_get_value_uint32(env, {val}, &{tmp});\n" + )); + out.push_str(&format!( + "{indent}{target} = ({}){tmp};\n", + c_scalar_type(ty) + )); + } + Ty::I64 => out.push_str(&format!( + "{indent}{status} = weaveffi_napi_get_i64(env, {val}, &{target});\n" + )), + Ty::U64 => out.push_str(&format!( + "{indent}{status} = weaveffi_napi_get_u64(env, {val}, &{target});\n" + )), + Ty::F64 => out.push_str(&format!( + "{indent}{status} = napi_get_value_double(env, {val}, &{target});\n" + )), + Ty::F32 => { + out.push_str(&format!("{indent}double {tmp} = 0;\n")); + out.push_str(&format!( + "{indent}{status} = napi_get_value_double(env, {val}, &{tmp});\n" + )); + out.push_str(&format!("{indent}{target} = (float){tmp};\n")); + } + Ty::Bool => out.push_str(&format!( + "{indent}{status} = napi_get_value_bool(env, {val}, &{target});\n" + )), + Ty::Enum(_) => { + out.push_str(&format!("{indent}int32_t {tmp} = 0;\n")); + out.push_str(&format!( + "{indent}{status} = napi_get_value_int32(env, {val}, &{tmp});\n" + )); + out.push_str(&format!("{indent}{target} = {tmp};\n")); + } + other => unreachable!("direct leaf read with non-direct type {other:?}"), + } +} + +/// The bare C type of a scalar parameter temporary. fn c_scalar_type(ty: &Ty) -> &'static str { match ty { Ty::I8 => "int8_t", @@ -47,35 +146,88 @@ fn c_scalar_type(ty: &Ty) -> &'static str { } } -/// The N-API getter that reads one direct-slot JS argument. -fn napi_getter(ty: &Ty) -> &'static str { - match ty { - // i8/i16 are read through the 32-bit signed getter (N-API has no - // narrower int getter) and narrowed at the use site. - Ty::I8 | Ty::I16 | Ty::I32 | Ty::Enum(_) => "napi_get_value_int32", - Ty::U8 | Ty::U16 | Ty::U32 => "napi_get_value_uint32", - // u64 mirrors i64/handle: read as a 64-bit int, reinterpreted as needed. - Ty::I64 | Ty::U64 | Ty::Handle | Ty::TypedHandle(_) => "napi_get_value_int64", - // f32 is read as a double then narrowed to float at the use site. - Ty::F32 | Ty::F64 => "napi_get_value_double", - Ty::Bool => "napi_get_value_bool", - _ => "napi_get_value_int64", +/// Emit the shared value helpers every entry point uses: lossless 64-bit +/// readers (a `bigint` that does not fit is a `RangeError`, a non-integer a +/// `TypeError`, so no value is ever silently truncated), and the object +/// handle codec (`bigint` holding the pointer value; `null`/`undefined` read +/// as `NULL` and a `NULL` pointer surfaces as `null`). All `static inline` +/// so an addon that never needs one compiles without an unused warning. +fn render_value_helpers_c(out: &mut String) { + out.push_str( + r#"static inline napi_status weaveffi_napi_get_i64(napi_env env, napi_value v, int64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_int64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in a signed 64-bit integer"); + return napi_generic_failure; } + return s; + } + if (t == napi_number) { + return napi_get_value_int64(env, v, out); + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; } -/// The C type of the temporary an N-API getter writes into for a scalar that is -/// narrower than the getter's natural width. N-API only exposes 32/64-bit int -/// and `double` getters, so `i8/i16/u8/u16/f32` must be read into a wider -/// temporary and then narrowed with an explicit cast to the real ABI type; -/// `u64` is read as `int64_t` then reinterpreted. -fn napi_read_tmp_type(ty: &Ty) -> &'static str { - match ty { - Ty::I8 | Ty::I16 => "int32_t", - Ty::U8 | Ty::U16 => "uint32_t", - Ty::U64 => "int64_t", - Ty::F32 => "double", - _ => "int64_t", +static inline napi_status weaveffi_napi_get_u64(napi_env env, napi_value v, uint64_t* out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_bigint) { + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, out, &lossless); + if (s == napi_ok && !lossless) { + napi_throw_range_error(env, NULL, "bigint does not fit in an unsigned 64-bit integer"); + return napi_generic_failure; + } + return s; + } + if (t == napi_number) { + double d = 0; + napi_status s = napi_get_value_double(env, v, &d); + if (s == napi_ok && (d < 0 || d != d)) { + napi_throw_range_error(env, NULL, "negative number for an unsigned 64-bit integer"); + return napi_generic_failure; } + *out = (uint64_t)d; + return s; + } + napi_throw_type_error(env, NULL, "expected a bigint"); + return napi_bigint_expected; +} + +// An object handle is the pointer value carried as a bigint. null and +// undefined read as NULL (the absent case of a nullable object). +static inline napi_status weaveffi_napi_get_handle(napi_env env, napi_value v, void** out) { + napi_valuetype t; + napi_typeof(env, v, &t); + if (t == napi_null || t == napi_undefined) { + *out = NULL; + return napi_ok; + } + if (t == napi_bigint) { + uint64_t raw = 0; + bool lossless = false; + napi_status s = napi_get_value_bigint_uint64(env, v, &raw, &lossless); + *out = (void*)(uintptr_t)raw; + return s; + } + napi_throw_type_error(env, NULL, "expected an object handle"); + return napi_bigint_expected; +} + +static inline napi_status weaveffi_napi_make_handle(napi_env env, const void* p, napi_value* out) { + if (p == NULL) { + return napi_get_null(env, out); + } + return napi_create_bigint_uint64(env, (uint64_t)(uintptr_t)p, out); +} + +"#, + ); } /// Emit `{prefix}_napi_error_value`, the shared constructor of the JS error @@ -118,7 +270,7 @@ fn emit_error_check_c(out: &mut String, prefix: &str) { out.push_str(&format!( " napi_throw(env, {prefix}_napi_error_value(env, err.code, err.message, err.payload_ptr, err.payload_len));\n" )); - out.push_str(" weaveffi_error_clear(&err);\n"); + out.push_str(&format!(" {prefix}_error_clear(&err);\n")); out.push_str(" return NULL;\n"); out.push_str(" }\n"); } @@ -155,9 +307,11 @@ fn emit_iter_state_read(out: &mut String, prefix: &str) { /// per-step fault then throws the code-carrying error, which the JS wrapper /// maps per the callable's error strategy. A produced element is converted /// and released per its element plan: strings are freed with -/// `weaveffi_free_string` after the JS string is created, and byte or -/// buffered elements are copied into a JS `Buffer` and released with -/// `weaveffi_free_bytes` (the JS wrapper decodes buffered elements). +/// `{prefix}_free_string` after the JS string is created, byte or buffered +/// elements are copied into a JS `Buffer` and released with +/// `{prefix}_free_bytes` (the JS wrapper decodes buffered elements), and an +/// object element's strong reference is surfaced as a handle the JS wrapper +/// adopts. fn render_iterator_napi_fns( out: &mut String, f: &FnBinding, @@ -199,11 +353,11 @@ fn render_iterator_napi_fns( out.push_str(" }\n"); let et = iterator_item_ctype(&ib.elem, module).render_c(prefix); out.push_str(&format!(" {et} iter_item;\n")); - if ef == ElemFree::Bytes { + if ef == Free::Bytes { out.push_str(" size_t iter_item_len = 0;\n"); } - out.push_str(" weaveffi_error iter_err = {0};\n"); - let next_args = if ef == ElemFree::Bytes { + out.push_str(&format!(" {prefix}_error iter_err = {{0}};\n")); + let next_args = if ef == Free::Bytes { format!("({tag}*)state->iter, &iter_item, &iter_item_len, &iter_err") } else { format!("({tag}*)state->iter, &iter_item, &iter_err") @@ -215,29 +369,36 @@ fn render_iterator_napi_fns( out.push_str(&format!( " napi_throw(env, {prefix}_napi_error_value(env, iter_err.code, iter_err.message, iter_err.payload_ptr, iter_err.payload_len));\n" )); - out.push_str(" weaveffi_error_clear(&iter_err);\n"); + out.push_str(&format!(" {prefix}_error_clear(&iter_err);\n")); out.push_str(" return NULL;\n"); out.push_str(" }\n"); out.push_str(" napi_get_undefined(env, &ret);\n"); out.push_str(" return ret;\n"); out.push_str(" }\n"); match ef { - ElemFree::String => { + Free::String => { out.push_str( " napi_create_string_utf8(env, iter_item ? iter_item : \"\", NAPI_AUTO_LENGTH, &ret);\n", ); - out.push_str(" weaveffi_free_string((char*)iter_item);\n"); + out.push_str(&format!(" {prefix}_free_string((char*)iter_item);\n")); } - ElemFree::Bytes => { + Free::Bytes => { out.push_str(" napi_create_buffer_copy(env, iter_item_len, iter_item, NULL, &ret);\n"); - out.push_str(" weaveffi_free_bytes((uint8_t*)iter_item, iter_item_len);\n"); - } - ElemFree::None => { out.push_str(&format!( - " {}\n", - napi_create_leaf("env", &ib.elem, "iter_item", "ret") + " {prefix}_free_bytes((uint8_t*)iter_item, iter_item_len);\n" )); } + Free::None => { + if ib.elem.interface_name().is_some() { + // One strong reference per element; the JS wrapper adopts it. + out.push_str(" weaveffi_napi_make_handle(env, iter_item, &ret);\n"); + } else { + out.push_str(&format!( + " {}\n", + napi_create_leaf(&ib.elem, "iter_item", "ret") + )); + } + } } out.push_str(" return ret;\n"); out.push_str("}\n\n"); @@ -260,7 +421,7 @@ fn render_iterator_napi_fns( /// Emit one callable's `Napi_*` entry point (plus its async or iterator /// machinery when needed) and register its JS export(s). `self_tag` is the -/// interface `c_tag` for an instance method, whose wrapped pointer arrives as +/// interface `c_tag` for an instance method, whose wrapped handle arrives as /// `args[0]`. An iterator-returning callable additionally exports its /// per-iterator `next`/`destroy` entry points under `{js_name}_iterNext` and /// `{js_name}_iterDestroy`, which the JS wrapper drives lazily. @@ -296,7 +457,7 @@ fn render_callable_napi( "static napi_value {napi_name}(napi_env env, napi_callback_info info) {{\n" )); if f.is_async { - render_async_napi_body(out, f, prefix, self_tag); + render_async_napi_body(out, f, module, prefix, self_tag); } else { render_napi_body(out, f, module, prefix, self_tag); } @@ -310,13 +471,24 @@ pub(crate) fn render_addon_c( input_basename: &str, ) -> String { let prefix = model.prefix.as_str(); + let has_callbacks = model.has_callback_interfaces(); let mut out = render_prelude(CommentStyle::DoubleSlash, input_basename); + // The bigint and threadsafe-function APIs need Node-API version 6 or + // later; pin the version the addon is written against unless the build + // already chose one. + out.push_str("#ifndef NAPI_VERSION\n#define NAPI_VERSION 8\n#endif\n"); out.push_str(&format!( - "#include \n#include \"{prefix}.h\"\n#include \n#include \n#include \n\n" + "#include \n#include \"{prefix}.h\"\n#include \n#include \n#include \n#include \n#include \n" )); + if has_callbacks { + out.push_str("#include \n"); + } + out.push('\n'); let mut all_exports: Vec<(String, String)> = Vec::new(); + render_value_helpers_c(&mut out); + // Every error path (sync throws, iterator faults, async rejections) // funnels through one code-and-payload-carrying error constructor. let has_error_paths = model @@ -327,23 +499,27 @@ pub(crate) fn render_addon_c( render_error_value_helper_c(&mut out, prefix); } - if model_has_iterators(model) { + if model.has_iterators() { render_iter_state_c(&mut out, prefix); } - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_listeners { - render_listener_support_c(&mut out, prefix); + if has_callbacks { + render_callback_runtime_c(&mut out, prefix); } for m in &model.modules { + // Callback interfaces get their frames, trampolines, dispatcher, and + // static vtable before any callable that may take one. + for cb in &m.callback_interfaces { + render_callback_interface_c(&mut out, m, cb, prefix, &model.prefix); + } // Records and rich enums are value types crossing the ABI serialized // in value buffers, so they need no native helpers here; the JS // loader packs and unpacks them. Interfaces get one native entry // point per member (constructors and statics marshal like free - // functions; methods additionally read the wrapped pointer from the - // leading argument) plus the destructor the JS class's disposal path - // calls. + // functions; methods additionally read the wrapped handle from the + // leading argument) plus the `clone` and `destroy` entry points the + // JS class's codec and disposal paths call. for i in &m.interfaces { for f in i.constructors.iter().chain(i.statics.iter()) { render_callable_napi( @@ -375,48 +551,22 @@ pub(crate) fn render_addon_c( Some(&i.c_tag), ); } - render_interface_destroy_napi(&mut out, i); + render_interface_lifecycle_napi(&mut out, i); all_exports.push(( wrapper_name( &m.path, - &iface_member_base(&i.name, "destroy"), + &iface_lifecycle_base(&i.name, "destroy"), strip_module_prefix, ), format!("Napi_{}", i.destroy_symbol), )); - } - // Callbacks referenced by listeners get a payload struct, a producer- - // thread trampoline, and a JS-thread marshaller (threadsafe function). - let used_callbacks: Vec<&CallbackBinding> = m - .listeners - .iter() - .filter_map(|l| m.callback(&l.event_callback)) - .collect(); - for cb in &used_callbacks { - render_cb_payload_struct(&mut out, cb, prefix); - render_cb_tramp(&mut out, cb, prefix); - render_cb_calljs(&mut out, cb); - } - for l in &m.listeners { - let Some(cb) = m.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - render_listener_napi_fns(&mut out, l, cb, prefix); all_exports.push(( - js_fn_name( - &m.path, - &format!("register_{}", l.name), - strip_module_prefix, - ), - format!("Napi_{}", l.register_symbol), - )); - all_exports.push(( - js_fn_name( + wrapper_name( &m.path, - &format!("unregister_{}", l.name), + &iface_lifecycle_base(&i.name, "clone"), strip_module_prefix, ), - format!("Napi_{}", l.unregister_symbol), + format!("Napi_{}", i.clone_symbol), )); } for f in &m.functions { @@ -450,6 +600,11 @@ pub(crate) fn render_addon_c( out.push_str(" return NULL;\n"); out.push_str(" }\n"); out.push_str(" }\n"); + if has_callbacks { + // Trampolines compare against this to decide whether they may call + // into JS directly or must hop through the threadsafe function. + out.push_str(&format!(" {prefix}_napi_js_thread = uv_thread_self();\n")); + } if !all_exports.is_empty() { out.push_str(" napi_property_descriptor props[] = {\n"); for (js_name, napi_fn) in &all_exports { @@ -473,321 +628,465 @@ pub(crate) fn render_addon_c( out } -/// Read `args[0]` as the opaque handle and bind it to a typed `self` pointer. -/// Used by the interface destructor entry point. +/// Read `args[0]` as an object handle and bind it to a typed `self` pointer. +/// Used by the interface lifecycle entry points. fn emit_self_handle_read(out: &mut String, c_tag: &str) { out.push_str(" size_t argc = 1;\n"); out.push_str(" napi_value args[1];\n"); out.push_str(" napi_get_cb_info(env, info, &argc, args, NULL, NULL);\n"); - out.push_str(" int64_t self_raw;\n"); - out.push_str(" napi_get_value_int64(env, args[0], &self_raw);\n"); - out.push_str(&format!( - " {c_tag}* self = ({c_tag}*)(intptr_t)self_raw;\n" - )); + out.push_str(" void* self_raw = NULL;\n"); + out.push_str(" if (weaveffi_napi_get_handle(env, args[0], &self_raw) != napi_ok || self_raw == NULL) {\n"); + out.push_str(" return NULL;\n"); + out.push_str(" }\n"); + out.push_str(&format!(" {c_tag}* self = ({c_tag}*)self_raw;\n")); } -/// The `Napi_*` destructor entry point for one interface: reads the wrapped -/// pointer from `args[0]` and releases the object via the destroy symbol. -/// Called by the JS class's `destroy()` and its `FinalizationRegistry` net. -fn render_interface_destroy_napi(out: &mut String, i: &InterfaceBinding) { - let napi_destroy = format!("Napi_{}", i.destroy_symbol); +/// The `Napi_*` lifecycle entry points for one interface. `destroy` reads +/// the wrapped handle from `args[0]` and releases the strong reference it +/// carries; the JS class's `close()` and its `FinalizationRegistry` backstop +/// call it exactly once per wrapper. `clone` produces a second strong +/// reference (as a fresh handle) for the value-buffer codec to write as an +/// object token. +fn render_interface_lifecycle_napi(out: &mut String, i: &InterfaceBinding) { out.push_str(&format!( - "static napi_value {napi_destroy}(napi_env env, napi_callback_info info) {{\n" + "static napi_value Napi_{}(napi_env env, napi_callback_info info) {{\n", + i.destroy_symbol )); emit_self_handle_read(out, &i.c_tag); out.push_str(&format!(" {}(self);\n", i.destroy_symbol)); out.push_str(" napi_value ret;\n"); out.push_str(" napi_get_undefined(env, &ret);\n"); out.push_str(" return ret;\n}\n\n"); + + out.push_str(&format!( + "static napi_value Napi_{}(napi_env env, napi_callback_info info) {{\n", + i.clone_symbol + )); + emit_self_handle_read(out, &i.c_tag); + out.push_str(&format!( + " {}* cloned = {}(self);\n", + i.c_tag, i.clone_symbol + )); + out.push_str(" napi_value ret;\n"); + out.push_str(" weaveffi_napi_make_handle(env, cloned, &ret);\n"); + out.push_str(" return ret;\n}\n\n"); } -/// The listener context + registry shared by every generated listener. The -/// registry is only mutated from the JS thread (register/unregister are plain -/// N-API calls), so a simple singly-linked list suffices. -fn render_listener_support_c(out: &mut String, prefix: &str) { - let mut w = CodeWriter::four_space(); +/// Emit the callback-interface runtime shared by every interface: the JS +/// thread identity captured at addon init, the handle-table entry +/// (`{prefix}_napi_cb_ctx`: the `napi_ref` holding the consumer's adapter +/// object plus the threadsafe function used to reach the JS thread), the +/// cross-thread request cell, and the helpers that register an entry, +/// release it, hop a blocking request to the JS thread, and report a JS +/// exception through `out_err` with [`FOREIGN_ERROR_CODE`]. +/// +/// A trampoline that finds itself on the JS thread calls the adapter +/// directly. From any other thread it queues a request through the +/// threadsafe function and waits on a condition variable until the JS thread +/// has run the call and filled in the result. `free` follows the same +/// discipline: the `napi_ref` can only be deleted on the JS thread, so an +/// off-thread `free` queues a release request instead. +fn render_callback_runtime_c(out: &mut String, prefix: &str) { + let mut w = CodeWriter::two_space(); + w.line(format!("static uv_thread_t {prefix}_napi_js_thread;")); + w.blank(); w.block( - format!("typedef struct {prefix}_napi_listener_ctx {{"), - format!("}} {prefix}_napi_listener_ctx;"), + format!("static inline bool {prefix}_napi_on_js_thread(void) {{"), + "}", |w| { + w.line("uv_thread_t self = uv_thread_self();"); + w.line(format!( + "return uv_thread_equal(&self, &{prefix}_napi_js_thread) != 0;" + )); + }, + ); + w.blank(); + w.line("// One handle-table entry per registered callback-interface implementation;"); + w.line("// its address is the `ctx` the producer passes back to every method."); + w.block( + "typedef struct {", + format!("}} {prefix}_napi_cb_ctx;"), + |w| { + w.line("napi_env env;"); + w.line("napi_ref ref;"); w.line("napi_threadsafe_function tsfn;"); - w.line("uint64_t id;"); - w.line(format!("struct {prefix}_napi_listener_ctx* next;")); }, ); w.blank(); - w.line(format!( - "static {prefix}_napi_listener_ctx* {prefix}_napi_listeners = NULL;" - )); + w.line("// Every method frame starts with this header so the dispatcher can reach"); + w.line("// `out_err` without knowing the method."); + w.block( + "typedef struct {", + format!("}} {prefix}_napi_cb_frame_hdr;"), + |w| { + w.line(format!("{prefix}_error* out_err;")); + }, + ); w.blank(); - out.push_str(&w.finish()); -} - -/// The `{c_fn_type}_payload` struct name of one callback. -fn cb_payload_name(cb: &CallbackBinding) -> String { - format!("{}_payload", cb.c_fn_type) -} - -/// The deep-copy payload carried from the producer thread to the JS thread. -/// Every pointer field is owned by the payload (strdup/memcpy in the -/// trampoline, freed in the call-js marshaller). Buffered arguments arrive as -/// borrowed `ptr` + `len` pairs valid only for the dispatch, so their bytes -/// are copied exactly like a `bytes` argument; the JS loader decodes the -/// copied buffer before invoking the user callback. -fn render_cb_payload_struct(out: &mut String, cb: &CallbackBinding, prefix: &str) { - let mut w = CodeWriter::four_space(); + w.line("// A request hopped from a producer thread to the JS thread. `method` is the"); + w.line("// vtable index to run, or -1 to release the context."); w.block( "typedef struct {", - format!("}} {};", cb_payload_name(cb)), + format!("}} {prefix}_napi_cb_req;"), |w| { - for p in &cb.params { - let n0 = &p.abi[0].name; - match p.arg_pass() { - ArgPass::Buffer { len, .. } | ArgPass::Bytes { len, .. } => { - w.line(format!("uint8_t* {n0};")); - w.line(format!("size_t {};", len.name)); - } - ArgPass::String { .. } => { - w.line(format!("char* {n0};")); - } - // Only `Interface?` reaches here (validation rejects bare - // interface callback params): a nullable borrowed object - // pointer. - ArgPass::Object { .. } => { - w.line(format!("void* {n0};")); - } - ArgPass::Direct { slot } => { - if matches!(p.ty, Ty::TypedHandle(_)) { - w.line(format!("void* {n0};")); - } else { - w.line(format!("{} {n0};", slot.ty.render_c(prefix))); - } - } - } - } + w.line(format!("{prefix}_napi_cb_ctx* ctx;")); + w.line("int method;"); + w.line("void* frame;"); + w.line("uv_mutex_t mu;"); + w.line("uv_cond_t cv;"); + w.line("bool done;"); + }, + ); + w.blank(); + w.block( + format!("static {prefix}_napi_cb_ctx* {prefix}_napi_cb_register(napi_env env, napi_value target, const char* name, napi_threadsafe_function_call_js dispatch) {{"), + "}", + |w| { + w.line(format!( + "{prefix}_napi_cb_ctx* ctx = ({prefix}_napi_cb_ctx*)calloc(1, sizeof({prefix}_napi_cb_ctx));" + )); + w.line("ctx->env = env;"); + w.line("napi_create_reference(env, target, 1, &ctx->ref);"); + w.line("napi_value resource_name;"); + w.line("napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name);"); + w.line("napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, dispatch, &ctx->tsfn);"); + w.line("// A live implementation must not pin the event loop by itself."); + w.line("napi_unref_threadsafe_function(env, ctx->tsfn);"); + w.line("return ctx;"); + }, + ); + w.blank(); + w.line("// Release one entry on the JS thread (env is NULL only during teardown, when"); + w.line("// the reference is already gone)."); + w.block( + format!("static void {prefix}_napi_cb_release(napi_env env, {prefix}_napi_cb_ctx* ctx) {{"), + "}", + |w| { + w.line("if (env != NULL) {"); + w.line(" napi_delete_reference(env, ctx->ref);"); + w.line("}"); + w.line("napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release);"); + w.line("free(ctx);"); + }, + ); + w.blank(); + w.line("// The vtable's `free` entry: the producer is done with `ctx`."); + w.block( + format!("static void {prefix}_napi_cb_free(void* ctx_) {{"), + "}", + |w| { + w.line(format!( + "{prefix}_napi_cb_ctx* ctx = ({prefix}_napi_cb_ctx*)ctx_;" + )); + w.line("if (ctx == NULL) {"); + w.line(" return;"); + w.line("}"); + w.line(format!("if ({prefix}_napi_on_js_thread()) {{")); + w.line(format!(" {prefix}_napi_cb_release(ctx->env, ctx);")); + w.line(" return;"); + w.line("}"); + w.line(format!( + "{prefix}_napi_cb_req* req = ({prefix}_napi_cb_req*)calloc(1, sizeof({prefix}_napi_cb_req));" + )); + w.line("req->ctx = ctx;"); + w.line("req->method = -1;"); + w.line("if (napi_call_threadsafe_function(ctx->tsfn, req, napi_tsfn_nonblocking) != napi_ok) {"); + w.line(" free(req);"); + w.line("}"); + }, + ); + w.blank(); + w.line("// Run `req` on the JS thread and block until it has completed."); + w.block( + format!("static void {prefix}_napi_cb_hop({prefix}_napi_cb_req* req) {{"), + "}", + |w| { + w.line("uv_mutex_init(&req->mu);"); + w.line("uv_cond_init(&req->cv);"); + w.line("req->done = false;"); + w.line("if (napi_call_threadsafe_function(req->ctx->tsfn, req, napi_tsfn_blocking) == napi_ok) {"); + w.line(" uv_mutex_lock(&req->mu);"); + w.line(" while (!req->done) {"); + w.line(" uv_cond_wait(&req->cv, &req->mu);"); + w.line(" }"); + w.line(" uv_mutex_unlock(&req->mu);"); + w.line("} else {"); + w.line(format!( + " {prefix}_error_set((({prefix}_napi_cb_frame_hdr*)req->frame)->out_err, {FOREIGN_ERROR_CODE}, \"callback interface implementation is no longer reachable\");" + )); + w.line("}"); + w.line("uv_cond_destroy(&req->cv);"); + w.line("uv_mutex_destroy(&req->mu);"); + }, + ); + w.blank(); + w.block( + format!("static void {prefix}_napi_cb_finish({prefix}_napi_cb_req* req) {{"), + "}", + |w| { + w.line("uv_mutex_lock(&req->mu);"); + w.line("req->done = true;"); + w.line("uv_cond_signal(&req->cv);"); + w.line("uv_mutex_unlock(&req->mu);"); + }, + ); + w.blank(); + w.line("// Report a JS exception raised by a callback implementation (or a return"); + w.line("// value of the wrong type) through `out_err` as a foreign error, clearing"); + w.line("// the pending exception so nothing unwinds through the C frame."); + w.block( + format!("static void {prefix}_napi_cb_report(napi_env env, {prefix}_error* out_err, const char* fallback) {{"), + "}", + |w| { + w.line("char msg[512];"); + w.line("msg[0] = 0;"); + w.line("bool pending = false;"); + w.line("napi_is_exception_pending(env, &pending);"); + w.line("if (pending) {"); + w.line(" napi_value exc;"); + w.line(" napi_get_and_clear_last_exception(env, &exc);"); + w.line(" napi_valuetype t;"); + w.line(" napi_typeof(env, exc, &t);"); + w.line(" napi_value text = exc;"); + w.line(" if (t == napi_object) {"); + w.line(" napi_get_named_property(env, exc, \"message\", &text);"); + w.line(" }"); + w.line(" napi_value str;"); + w.line(" if (napi_coerce_to_string(env, text, &str) == napi_ok) {"); + w.line(" napi_get_value_string_utf8(env, str, msg, sizeof msg, NULL);"); + w.line(" }"); + w.line(" napi_is_exception_pending(env, &pending);"); + w.line(" if (pending) {"); + w.line(" napi_get_and_clear_last_exception(env, &exc);"); + w.line(" }"); + w.line("}"); + w.line(format!( + "{prefix}_error_set(out_err, {FOREIGN_ERROR_CODE}, msg[0] ? msg : fallback);" + )); }, ); w.blank(); out.push_str(&w.finish()); } -/// The producer-thread trampoline: deep-copies the C arguments into a payload -/// and queues it onto the threadsafe function. Runs on whatever thread the -/// producer fires the event from; never touches `napi_env`. -fn render_cb_tramp(out: &mut String, cb: &CallbackBinding, prefix: &str) { - let payload = cb_payload_name(cb); - // The callback's full ABI slot list (including the trailing context) is - // precomputed on the model, so the trampoline's signature matches the - // producer's typedef by construction. - let decls: Vec = cb - .abi_params - .iter() - .map(|slot| format!("{} {}", slot.ty.render_c(prefix), slot.name)) - .collect(); - out.push_str(&format!( - "static void {}_napi_tramp({}) {{\n", - cb.c_fn_type, - decls.join(", ") - )); - out.push_str(&format!( - " {prefix}_napi_listener_ctx* ctx = ({prefix}_napi_listener_ctx*)context;\n" - )); - out.push_str(&format!( - " {payload}* p = ({payload}*)calloc(1, sizeof({payload}));\n" - )); - for p in &cb.params { - let n0 = &p.abi[0].name; - match p.arg_pass() { - ArgPass::Buffer { len, .. } | ArgPass::Bytes { len, .. } => { - let n1 = &len.name; - out.push_str(&format!(" p->{n1} = {n1};\n")); - out.push_str(&format!( - " if ({n0} != NULL && {n1} > 0) {{ p->{n0} = (uint8_t*)malloc({n1}); memcpy(p->{n0}, {n0}, {n1}); }}\n" - )); - } - ArgPass::String { .. } => { - out.push_str(&format!(" p->{n0} = {n0} ? strdup({n0}) : NULL;\n")); - } - ArgPass::Object { .. } => { - out.push_str(&format!(" p->{n0} = (void*){n0};\n")); - } - ArgPass::Direct { .. } => { - if matches!(p.ty, Ty::TypedHandle(_)) { - out.push_str(&format!(" p->{n0} = (void*){n0};\n")); - } else { - out.push_str(&format!(" p->{n0} = {n0};\n")); - } - } - } - } - out.push_str(" napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking);\n"); - out.push_str("}\n\n"); +/// The frame struct name of one callback method. +fn cb_frame_name(c_tag: &str, m: &CallbackMethodBinding) -> String { + format!("{c_tag}_{}_frame", m.name) } -/// One payload field rendered to a `napi_value` in `argv[idx]` (call-js side). -fn emit_payload_to_napi(out: &mut String, p: &ParamBinding, idx: usize) { - let n0 = &p.abi[0].name; - let target = format!("argv[{idx}]"); - match p.arg_pass() { - ArgPass::Buffer { len, .. } | ArgPass::Bytes { len, .. } => { - let n1 = &len.name; +/// Emit one callback interface's native side: a frame struct per method (the +/// C arguments as the producer passed them plus the result slot), the +/// JS-thread invoker per method (convert arguments, call the adapter's +/// method by its IDL name, convert the return or report the exception), the +/// trampoline per method that the vtable points at (call the invoker +/// directly on the JS thread, or hop and wait), the dispatcher the +/// interface's threadsafe functions run, and the one process-wide static +/// vtable. +/// +/// Inside an invoker, strings, bytes, and buffers are borrowed (copied into +/// JS values, never freed), object arguments carry one strong reference each +/// (surfaced as a handle the JS adapter adopts), and 64-bit integers cross +/// as `bigint`s. +fn render_callback_interface_c( + out: &mut String, + m: &ModuleBinding, + cb: &CallbackInterfaceBinding, + prefix: &str, + model_prefix: &str, +) { + let c_tag = &cb.c_tag; + let protocol = cb.protocol(&m.path, model_prefix); + + for (idx, method) in cb.methods.iter().enumerate() { + let frame = cb_frame_name(c_tag, method); + let invoke = format!("{c_tag}_{}_invoke", method.name); + let tramp = format!("{c_tag}_{}_tramp", method.name); + let ret_c = method.abi_ret.render_c(prefix); + let is_void = method.ret.is_none(); + let slots = &method.abi_params[1..method.abi_params.len() - 1]; + let out_err_name = &method + .abi_params + .last() + .expect("callback method has an out_err slot") + .name; + + // -- frame -- + out.push_str("typedef struct {\n"); + out.push_str(&format!(" {prefix}_napi_cb_frame_hdr hdr;\n")); + for slot in slots { out.push_str(&format!( - " napi_create_buffer_copy(env, p->{n1}, p->{n0} ? (const void*)p->{n0} : (const void*)\"\", NULL, &{target});\n" + " {} {};\n", + slot.ty.render_c(prefix), + slot.name )); } - ArgPass::String { .. } => out.push_str(&format!( - " napi_create_string_utf8(env, p->{n0} ? p->{n0} : \"\", NAPI_AUTO_LENGTH, &{target});\n" - )), - // Only `Interface?` reaches here: nullable object pointer. - ArgPass::Object { .. } => out.push_str(&format!( - " if (p->{n0}) napi_create_int64(env, (int64_t)(intptr_t)p->{n0}, &{target}); else napi_get_null(env, &{target});\n" - )), - ArgPass::Direct { .. } => { - if matches!(p.ty, Ty::TypedHandle(_)) { - out.push_str(&format!( - " napi_create_int64(env, (int64_t)(intptr_t)p->{n0}, &{target});\n" - )); - } else { - let leaf = payload_leaf_to_napi(&p.ty, &format!("p->{n0}"), &target); - out.push_str(&format!(" {leaf}\n")); - } + if !is_void { + out.push_str(&format!(" {ret_c} result;\n")); } - } -} - -/// One scalar-ish payload value to a `napi_value` (single statement). -fn payload_leaf_to_napi(ty: &Ty, expr: &str, target: &str) -> String { - match ty { - Ty::I32 => format!("napi_create_int32(env, {expr}, &{target});"), - Ty::U32 => format!("napi_create_uint32(env, {expr}, &{target});"), - Ty::I64 => format!("napi_create_int64(env, {expr}, &{target});"), - Ty::F64 => format!("napi_create_double(env, {expr}, &{target});"), - Ty::I8 | Ty::I16 => format!("napi_create_int32(env, {expr}, &{target});"), - Ty::U8 | Ty::U16 => format!("napi_create_uint32(env, {expr}, &{target});"), - Ty::U64 => format!("napi_create_int64(env, (int64_t){expr}, &{target});"), - Ty::F32 => format!("napi_create_double(env, {expr}, &{target});"), - Ty::Bool => format!("napi_get_boolean(env, {expr}, &{target});"), - Ty::Handle => format!("napi_create_int64(env, (int64_t){expr}, &{target});"), - Ty::Enum(_) => format!("napi_create_int32(env, (int32_t){expr}, &{target});"), - _ => format!("napi_get_null(env, &{target});"), - } -} + out.push_str(&format!("}} {frame};\n\n")); -/// Frees one payload field after the JS call. -fn emit_payload_free(out: &mut String, p: &ParamBinding) { - if matches!( - p.arg_pass(), - ArgPass::Buffer { .. } | ArgPass::Bytes { .. } | ArgPass::String { .. } - ) { - out.push_str(&format!(" free(p->{});\n", p.abi[0].name)); - } -} + // -- JS-thread invoker -- + out.push_str(&format!( + "static void {invoke}(napi_env env, {prefix}_napi_cb_ctx* ctx, {frame}* f) {{\n" + )); + out.push_str(" napi_handle_scope scope;\n"); + out.push_str(" napi_open_handle_scope(env, &scope);\n"); + out.push_str(" napi_value target;\n"); + out.push_str(" napi_get_reference_value(env, ctx->ref, &target);\n"); + out.push_str(" napi_value fn;\n"); + out.push_str(&format!( + " napi_get_named_property(env, target, \"{}\", &fn);\n", + method.name + )); + let argc = method.params.len(); + if argc > 0 { + out.push_str(&format!(" napi_value argv[{argc}];\n")); + } + for (i, (p, pass)) in method + .params + .iter() + .zip(&protocol.method_args[idx]) + .enumerate() + { + emit_cb_arg_to_napi(out, p, pass, i); + } + out.push_str(" napi_value result;\n"); + out.push_str(" napi_valuetype fn_type;\n"); + out.push_str(" napi_typeof(env, fn, &fn_type);\n"); + let argv = if argc > 0 { "argv" } else { "NULL" }; + out.push_str(" if (fn_type != napi_function) {\n"); + out.push_str(&format!( + " {prefix}_napi_cb_report(env, f->hdr.out_err, \"{} implementation has no {} method\");\n", + cb.name, method.name + )); + out.push_str(&format!( + " }} else if (napi_call_function(env, target, fn, {argc}, {argv}, &result) != napi_ok) {{\n" + )); + out.push_str(&format!( + " {prefix}_napi_cb_report(env, f->hdr.out_err, \"{}.{} threw\");\n", + cb.name, method.name + )); + if let Some(ret) = &method.ret { + out.push_str(" } else {\n"); + out.push_str(" napi_status rs;\n"); + emit_leaf_read(out, " ", ret, "result", "f->result", "rs"); + out.push_str(" if (rs != napi_ok) {\n"); + out.push_str(&format!( + " {prefix}_napi_cb_report(env, f->hdr.out_err, \"{}.{} returned a value of the wrong type\");\n", + cb.name, method.name + )); + out.push_str(" }\n"); + } + out.push_str(" }\n"); + out.push_str(" napi_close_handle_scope(env, scope);\n"); + out.push_str("}\n\n"); -/// The JS-thread marshaller invoked by the threadsafe function: converts the -/// payload into JS arguments, calls the user callback, and frees the payload. -fn render_cb_calljs(out: &mut String, cb: &CallbackBinding) { - let payload = cb_payload_name(cb); - out.push_str(&format!( - "static void {}_napi_calljs(napi_env env, napi_value js_cb, void* context, void* data) {{\n", - cb.c_fn_type - )); - out.push_str(" (void)context;\n"); - out.push_str(&format!(" {payload}* p = ({payload}*)data;\n")); - out.push_str(" if (env != NULL) {\n"); - out.push_str(" napi_value undefined;\n"); - out.push_str(" napi_get_undefined(env, &undefined);\n"); - let argc = cb.params.len(); - if argc > 0 { - out.push_str(&format!(" napi_value argv[{argc}];\n")); - for (i, p) in cb.params.iter().enumerate() { - emit_payload_to_napi(out, p, i); + // -- trampoline (any producer thread) -- + let decls: Vec = method + .abi_params + .iter() + .map(|slot| format!("{} {}", slot.ty.render_c(prefix), slot.name)) + .collect(); + out.push_str(&format!( + "static {ret_c} {tramp}({}) {{\n", + decls.join(", ") + )); + out.push_str(&format!(" {frame} f;\n")); + out.push_str(" memset(&f, 0, sizeof f);\n"); + out.push_str(&format!(" f.hdr.out_err = {out_err_name};\n")); + for slot in slots { + out.push_str(&format!(" f.{0} = {0};\n", slot.name)); } out.push_str(&format!( - " napi_call_function(env, undefined, js_cb, {argc}, argv, NULL);\n" + " {prefix}_napi_cb_ctx* c = ({prefix}_napi_cb_ctx*){};\n", + method.abi_params[0].name )); - } else { - out.push_str(" napi_call_function(env, undefined, js_cb, 0, NULL, NULL);\n"); - } - out.push_str(" }\n"); - for p in &cb.params { - emit_payload_free(out, p); + out.push_str(&format!(" if ({prefix}_napi_on_js_thread()) {{\n")); + out.push_str(&format!(" {invoke}(c->env, c, &f);\n")); + out.push_str(" } else {\n"); + out.push_str(&format!(" {prefix}_napi_cb_req req;\n")); + out.push_str(" req.ctx = c;\n"); + out.push_str(&format!(" req.method = {idx};\n")); + out.push_str(" req.frame = &f;\n"); + out.push_str(&format!(" {prefix}_napi_cb_hop(&req);\n")); + out.push_str(" }\n"); + if !is_void { + out.push_str(" return f.result;\n"); + } + out.push_str("}\n\n"); } - out.push_str(" free(p);\n"); - out.push_str("}\n\n"); -} -/// The `Napi_*` register/unregister entry points for one listener. Register -/// wraps the JS callback in an unref'd threadsafe function (so live listeners -/// don't pin the event loop) and stores it in the registry; unregister stops -/// the producer first, then releases the threadsafe function. -fn render_listener_napi_fns( - out: &mut String, - l: &ListenerBinding, - cb: &CallbackBinding, - prefix: &str, -) { - let register_sym = &l.register_symbol; - let unregister_sym = &l.unregister_symbol; - let tramp = format!("{}_napi_tramp", cb.c_fn_type); - let calljs = format!("{}_napi_calljs", cb.c_fn_type); - - out.push_str(&format!( - "static napi_value Napi_{register_sym}(napi_env env, napi_callback_info info) {{\n" - )); - out.push_str(" size_t argc = 1;\n"); - out.push_str(" napi_value args[1];\n"); - out.push_str(" napi_get_cb_info(env, info, &argc, args, NULL, NULL);\n"); + // -- dispatcher: runs queued requests on the JS thread -- out.push_str(&format!( - " {prefix}_napi_listener_ctx* ctx = ({prefix}_napi_listener_ctx*)calloc(1, sizeof({prefix}_napi_listener_ctx));\n" + "static void {c_tag}_napi_dispatch(napi_env env, napi_value js_cb, void* context, void* data) {{\n" )); - out.push_str(" napi_value resource_name;\n"); + out.push_str(" (void)js_cb;\n"); + out.push_str(" (void)context;\n"); out.push_str(&format!( - " napi_create_string_utf8(env, \"{register_sym}\", NAPI_AUTO_LENGTH, &resource_name);\n" + " {prefix}_napi_cb_req* req = ({prefix}_napi_cb_req*)data;\n" )); + out.push_str(" if (req->method < 0) {\n"); + out.push_str(&format!(" {prefix}_napi_cb_release(env, req->ctx);\n")); + out.push_str(" free(req);\n"); + out.push_str(" return;\n"); + out.push_str(" }\n"); + out.push_str(" if (env == NULL) {\n"); out.push_str(&format!( - " napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, {calljs}, &ctx->tsfn);\n" + " {prefix}_error_set((({prefix}_napi_cb_frame_hdr*)req->frame)->out_err, {FOREIGN_ERROR_CODE}, \"Node environment is shutting down\");\n" )); - out.push_str(" napi_unref_threadsafe_function(env, ctx->tsfn);\n"); - out.push_str(&format!(" uint64_t id = {register_sym}({tramp}, ctx);\n")); - out.push_str(" ctx->id = id;\n"); - out.push_str(&format!(" ctx->next = {prefix}_napi_listeners;\n")); - out.push_str(&format!(" {prefix}_napi_listeners = ctx;\n")); - out.push_str(" napi_value ret;\n"); - out.push_str(" napi_create_double(env, (double)id, &ret);\n"); - out.push_str(" return ret;\n"); + out.push_str(" } else {\n"); + out.push_str(" switch (req->method) {\n"); + for (idx, method) in cb.methods.iter().enumerate() { + let frame = cb_frame_name(c_tag, method); + out.push_str(&format!( + " case {idx}: {c_tag}_{}_invoke(env, req->ctx, ({frame}*)req->frame); break;\n", + method.name + )); + } + out.push_str(" default: break;\n"); + out.push_str(" }\n"); + out.push_str(" }\n"); + out.push_str(&format!(" {prefix}_napi_cb_finish(req);\n")); out.push_str("}\n\n"); + // -- the one static vtable -- + let entries: Vec = cb + .methods + .iter() + .map(|m| format!("{c_tag}_{}_tramp", m.name)) + .chain(std::iter::once(format!("{prefix}_napi_cb_free"))) + .collect(); out.push_str(&format!( - "static napi_value Napi_{unregister_sym}(napi_env env, napi_callback_info info) {{\n" - )); - out.push_str(" size_t argc = 1;\n"); - out.push_str(" napi_value args[1];\n"); - out.push_str(" napi_get_cb_info(env, info, &argc, args, NULL, NULL);\n"); - out.push_str(" double id_d = 0;\n"); - out.push_str(" napi_get_value_double(env, args[0], &id_d);\n"); - out.push_str(" uint64_t id = (uint64_t)id_d;\n"); - // Stop producer-side delivery before tearing down the tsfn so no new - // payloads are queued against a released function. - out.push_str(&format!(" {unregister_sym}(id);\n")); - out.push_str(&format!( - " {prefix}_napi_listener_ctx** link = &{prefix}_napi_listeners;\n" - )); - out.push_str(" while (*link != NULL) {\n"); - out.push_str(" if ((*link)->id == id) {\n"); - out.push_str(&format!( - " {prefix}_napi_listener_ctx* found = *link;\n" + "static const {} {c_tag}_napi_vtable = {{ {} }};\n\n", + cb.vtable_tag, + entries.join(", ") )); - out.push_str(" *link = found->next;\n"); - out.push_str(" napi_release_threadsafe_function(found->tsfn, napi_tsfn_release);\n"); - out.push_str(" free(found);\n"); - out.push_str(" break;\n"); - out.push_str(" }\n"); - out.push_str(" link = &(*link)->next;\n"); - out.push_str(" }\n"); - out.push_str(" napi_value ret;\n"); - out.push_str(" napi_get_undefined(env, &ret);\n"); - out.push_str(" return ret;\n"); - out.push_str("}\n\n"); +} + +/// Emit the conversion of one callback-method argument from its frame slots +/// to `argv[idx]`, per its receiving plan. +fn emit_cb_arg_to_napi(out: &mut String, p: &ParamBinding, pass: &RetPass, idx: usize) { + let n0 = &p.abi[0].name; + let target = format!("argv[{idx}]"); + match pass { + RetPass::Void => unreachable!("callback parameters are never void"), + RetPass::Direct => { + let leaf = napi_create_leaf(&p.ty, &format!("f->{n0}"), &target); + out.push_str(&format!(" {leaf}\n")); + } + RetPass::String => out.push_str(&format!( + " napi_create_string_utf8(env, f->{n0} ? f->{n0} : \"\", NAPI_AUTO_LENGTH, &{target});\n" + )), + RetPass::Bytes | RetPass::Buffer => { + let n1 = &p.abi[1].name; + out.push_str(&format!( + " napi_create_buffer_copy(env, f->{n1}, f->{n0} ? (const void*)f->{n0} : (const void*)\"\", NULL, &{target});\n" + )); + } + RetPass::Object { .. } => out.push_str(&format!( + " weaveffi_napi_make_handle(env, f->{n0}, &{target});\n" + )), + } } /// The classified shape of an async function's result, driving what the @@ -795,7 +1094,7 @@ fn render_listener_napi_fns( enum AsyncResultShape { /// No result: the promise resolves `undefined`. None, - /// A by-value scalar, bool, C-style enum, or bare handle. + /// A by-value scalar, bool, or C-style enum. Value, /// An owned `const char*` string (nullable). Str, @@ -804,20 +1103,14 @@ enum AsyncResultShape { /// A borrowed value-buffer pair (slots `result_ptr` + `result_len`); the /// callback must copy it before returning, and the JS wrapper decodes it. Buffered, - /// An owned object pointer the callback adopts (interface, typed handle, - /// iterator, or nullable interface). + /// An owned object reference the JS wrapper adopts (`Interface` or + /// `Interface?`). Object, } /// Classify an async result type into its marshalling shape via the shared -/// receiving plan. Typed handles and iterator handles are carved out first: -/// they cross as owned pointers the callback adopts, which [`plan::ret_pass`] -/// does not distinguish from by-value returns (and iterator returns have no -/// `RetPass` at all). +/// receiving plan. fn async_result_shape(ret: Option<&Ty>, module: &str, prefix: &str) -> AsyncResultShape { - if let Some(Ty::TypedHandle(_) | Ty::Iterator(_)) = ret { - return AsyncResultShape::Object; - } match plan::ret_pass(ret, module, prefix) { RetPass::Void => AsyncResultShape::None, RetPass::Direct => AsyncResultShape::Value, @@ -848,10 +1141,11 @@ fn async_cb_result_params_node(ret: Option<&Ty>, module: &str, prefix: &str) -> /// /// The completion callback may fire on any thread, so it must never touch /// `napi_env`; the ref'd threadsafe function also keeps the event loop alive -/// until the promise settles. Borrowed results (strings, bytes, and buffered -/// values) are deep-copied inside the callback because the producer frees -/// them after it returns; owned object results are adopted. The error's -/// message and payload are copied for the same reason. +/// until the promise settles. Owned results (strings, bytes, and buffered +/// values) are deep-copied inside the callback and released with the runtime +/// free symbols; owned object results are adopted (the pointer stays valid +/// across the thread hop). The error's message and payload are copied for +/// the same reason. fn render_async_machinery( out: &mut String, f: &FnBinding, @@ -899,7 +1193,7 @@ fn render_async_machinery( // -- producer-thread completion callback: deep-copy + queue -- out.push_str(&format!( - "static void {cb_name}(void* context, weaveffi_error* err{cb_result}) {{\n" + "static void {cb_name}(void* context, {prefix}_error* err{cb_result}) {{\n" )); out.push_str(&format!(" {actx}* ctx = ({actx}*)context;\n")); out.push_str(" if (err != NULL && err->code != 0) {\n"); @@ -922,14 +1216,16 @@ fn render_async_machinery( AsyncResultShape::Str => { out.push_str(" ctx->result_null = result == NULL;\n"); out.push_str(" ctx->result = result ? strdup(result) : NULL;\n"); - out.push_str(" weaveffi_free_string(result);\n"); + out.push_str(&format!(" {prefix}_free_string(result);\n")); } AsyncResultShape::Bytes => { out.push_str(" ctx->result_len = result_len;\n"); out.push_str( " if (result != NULL && result_len > 0) { ctx->result = (uint8_t*)malloc(result_len); memcpy(ctx->result, result, result_len); }\n", ); - out.push_str(" weaveffi_free_bytes((uint8_t*)result, result_len);\n"); + out.push_str(&format!( + " {prefix}_free_bytes((uint8_t*)result, result_len);\n" + )); } // The value buffer is owned: copy, then release the producer // allocation; the JS wrapper decodes the copy after the promise @@ -939,11 +1235,12 @@ fn render_async_machinery( out.push_str( " if (result_ptr != NULL && result_len > 0) { ctx->result = (uint8_t*)malloc(result_len); memcpy(ctx->result, result_ptr, result_len); }\n", ); - out.push_str(" weaveffi_free_bytes((uint8_t*)result_ptr, result_len);\n"); + out.push_str(&format!( + " {prefix}_free_bytes((uint8_t*)result_ptr, result_len);\n" + )); } - // Owned-object results (interfaces, typed handles, iterators) are - // adopted by the receiver, so the pointer stays valid across the - // thread hop. + // The strong reference is adopted by the receiver, so the pointer + // stays valid across the thread hop. AsyncResultShape::Object => { out.push_str(" ctx->result = (void*)result;\n"); } @@ -951,7 +1248,7 @@ fn render_async_machinery( out.push_str(" }\n"); // The heap-boxed error transfers ownership too; a null or zero-code // error is a safe no-op to free. - out.push_str(" weaveffi_error_free(err);\n"); + out.push_str(&format!(" {prefix}_error_free(err);\n")); out.push_str(" napi_call_threadsafe_function(ctx->tsfn, ctx, napi_tsfn_blocking);\n"); out.push_str("}\n\n"); @@ -972,27 +1269,13 @@ fn render_async_machinery( out.push_str(" napi_value val;\n"); match &shape { AsyncResultShape::None => out.push_str(" napi_get_undefined(env, &val);\n"), - AsyncResultShape::Value => match f.ret.as_ref() { - Some(Ty::I32) => out.push_str(" napi_create_int32(env, ctx->result, &val);\n"), - Some(Ty::U32) => out.push_str(" napi_create_uint32(env, ctx->result, &val);\n"), - Some(Ty::I64) => out.push_str(" napi_create_int64(env, ctx->result, &val);\n"), - Some(Ty::F64) => out.push_str(" napi_create_double(env, ctx->result, &val);\n"), - Some(Ty::I8 | Ty::I16) => { - out.push_str(" napi_create_int32(env, ctx->result, &val);\n"); - } - Some(Ty::U8 | Ty::U16) => { - out.push_str(" napi_create_uint32(env, ctx->result, &val);\n"); - } - Some(Ty::U64 | Ty::Handle) => { - out.push_str(" napi_create_int64(env, (int64_t)ctx->result, &val);\n"); - } - Some(Ty::F32) => out.push_str(" napi_create_double(env, ctx->result, &val);\n"), - Some(Ty::Bool) => out.push_str(" napi_get_boolean(env, ctx->result, &val);\n"), - Some(Ty::Enum(_)) => { - out.push_str(" napi_create_int32(env, (int32_t)ctx->result, &val);\n"); - } - _ => unreachable!("value shape covers scalars, bools, enums, and handles"), - }, + AsyncResultShape::Value => { + let ty = f.ret.as_ref().expect("value shape has a type"); + out.push_str(&format!( + " {}\n", + napi_create_leaf(ty, "ctx->result", "val") + )); + } AsyncResultShape::Str => { out.push_str( " if (ctx->result_null) napi_get_null(env, &val); else napi_create_string_utf8(env, ctx->result ? ctx->result : \"\", NAPI_AUTO_LENGTH, &val);\n", @@ -1003,19 +1286,11 @@ fn render_async_machinery( " napi_create_buffer_copy(env, ctx->result_len, ctx->result ? (const void*)ctx->result : (const void*)\"\", NULL, &val);\n", ); } + // A nullable interface result resolves `null` for the absent case; + // otherwise the strong reference is surfaced as the handle the JS + // class adopts. AsyncResultShape::Object => { - // A nullable interface result resolves `null` for the absent - // case; every other object pointer is surfaced as the raw - // handle the JS class adopts. - if matches!(f.ret.as_ref(), Some(Ty::Optional(_))) { - out.push_str( - " if (ctx->result == NULL) napi_get_null(env, &val); else napi_create_int64(env, (int64_t)(intptr_t)ctx->result, &val);\n", - ); - } else { - out.push_str( - " napi_create_int64(env, (int64_t)(intptr_t)ctx->result, &val);\n", - ); - } + out.push_str(" weaveffi_napi_make_handle(env, ctx->result, &val);\n"); } } out.push_str(" napi_resolve_deferred(env, ctx->deferred, val);\n"); @@ -1034,19 +1309,33 @@ fn render_async_machinery( out.push_str("}\n\n"); } -/// Read the wrapped interface pointer from `args[0]` and push it as the -/// leading C argument. Instance methods carry this implicit `self` slot in -/// their [`AbiFn`](weaveffi_core::model::AbiFn) signatures; the JS class -/// passes its own handle there. -fn emit_self_arg(out: &mut String, c_args: &mut Vec, self_tag: &str) { - out.push_str(" int64_t self_raw;\n"); - out.push_str(" napi_get_value_int64(env, args[0], &self_raw);\n"); - c_args.push(format!("(const {self_tag}*)(intptr_t)self_raw")); +/// The marshalled arguments of one entry point: the C argument expressions +/// in slot order, the cleanups to run after the call, and whether any read +/// can fail (in which case the entry point bails before calling the +/// producer). +struct MarshalledArgs { + c_args: Vec, + cleanups: Vec, + /// Extra releases that only run when marshalling bails before the call + /// (a registered callback context the producer never received). + fail_cleanups: Vec, + checked: bool, } /// Read `argc`/`args` for a callable with `n` incoming JS arguments -/// (including the leading handle of an instance method). -fn emit_args_read(out: &mut String, n: usize) { +/// (including the leading handle of an instance method), then marshal each +/// argument into its C slots. Instance methods carry the implicit `self` +/// slot in their [`AbiFn`](weaveffi_core::model::AbiFn) signatures; the JS +/// class passes its own handle there. +fn emit_args( + out: &mut String, + f: &FnBinding, + module: &str, + prefix: &str, + self_tag: Option<&str>, +) -> MarshalledArgs { + let offset = usize::from(self_tag.is_some()); + let n = f.params.len() + offset; if n > 0 { out.push_str(&format!(" size_t argc = {n};\n")); out.push_str(&format!(" napi_value args[{n}];\n")); @@ -1055,27 +1344,71 @@ fn emit_args_read(out: &mut String, n: usize) { out.push_str(" size_t argc = 0;\n"); out.push_str(" napi_get_cb_info(env, info, &argc, NULL, NULL, NULL);\n"); } + + let checked = self_tag.is_some() + || f.params.iter().any(|p| { + matches!( + p.arg_pass(), + ArgPass::Object { .. } | ArgPass::Direct { .. } | ArgPass::Callback { .. } + ) + }); + if checked { + out.push_str(" napi_status arg_status = napi_ok;\n"); + } + + let mut m = MarshalledArgs { + c_args: Vec::new(), + cleanups: Vec::new(), + fail_cleanups: Vec::new(), + checked, + }; + if let Some(tag) = self_tag { + out.push_str(" void* self_raw = NULL;\n"); + out.push_str(" arg_status = weaveffi_napi_get_handle(env, args[0], &self_raw);\n"); + out.push_str(" if (arg_status == napi_ok && self_raw == NULL) {\n"); + out.push_str(" napi_throw_type_error(env, NULL, \"object used after close()\");\n"); + out.push_str(" arg_status = napi_invalid_arg;\n"); + out.push_str(" }\n"); + m.c_args.push(format!("(const {tag}*)self_raw")); + } + for (i, p) in f.params.iter().enumerate() { + emit_param(out, &mut m, p, i + offset, module, prefix); + } + m +} + +/// Emit the bail-out that runs after argument marshalling when any read +/// failed (a pending JS exception is already set): release what was +/// allocated so far and return without calling the producer. +fn emit_arg_check(out: &mut String, m: &MarshalledArgs) { + if !m.checked { + return; + } + out.push_str(" if (arg_status != napi_ok) {\n"); + for cleanup in m.cleanups.iter().chain(&m.fail_cleanups) { + out.push_str(" "); + out.push_str(cleanup); + } + out.push_str(" return NULL;\n"); + out.push_str(" }\n"); } /// The body of an async callable's `Napi_*` entry point: marshal arguments, /// allocate the context, create the promise and threadsafe function, launch, /// and return the pending promise. -fn render_async_napi_body(out: &mut String, f: &FnBinding, prefix: &str, self_tag: Option<&str>) { +fn render_async_napi_body( + out: &mut String, + f: &FnBinding, + module: &str, + prefix: &str, + self_tag: Option<&str>, +) { let c_name = &f.c_base; let CallShape::Async(ab) = &f.shape else { unreachable!("async body rendered for a non-async callable"); }; - let offset = usize::from(self_tag.is_some()); - emit_args_read(out, f.params.len() + offset); - - let mut c_args: Vec = Vec::new(); - let mut cleanups: Vec = Vec::new(); - if let Some(tag) = self_tag { - emit_self_arg(out, &mut c_args, tag); - } - for (i, p) in f.params.iter().enumerate() { - emit_param(out, &mut c_args, &mut cleanups, p, i + offset, prefix); - } + let mut m = emit_args(out, f, module, prefix, self_tag); + emit_arg_check(out, &m); let actx = format!("{c_name}_napi_actx"); out.push_str(&format!( @@ -1087,22 +1420,23 @@ fn render_async_napi_body(out: &mut String, f: &FnBinding, prefix: &str, self_ta out.push_str(&format!( " napi_create_string_utf8(env, \"{c_name}\", NAPI_AUTO_LENGTH, &resource_name);\n" )); - // Ref'd (unlike listeners): a pending promise must keep the loop alive. + // Ref'd (unlike callback interfaces): a pending promise must keep the + // loop alive. out.push_str(&format!( " napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, {c_name}_napi_settle, &ctx->tsfn);\n" )); if f.cancellable { - c_args.push("NULL".into()); + m.c_args.push("NULL".into()); } let cb_name = format!("{c_name}_napi_cb"); - c_args.push(cb_name); - c_args.push("ctx".into()); - let args_str = c_args.join(", "); + m.c_args.push(cb_name); + m.c_args.push("ctx".into()); + let args_str = m.c_args.join(", "); out.push_str(&format!(" {}({args_str});\n", ab.launch.symbol)); - for cleanup in &cleanups { + for cleanup in &m.cleanups { out.push_str(cleanup); } @@ -1126,26 +1460,17 @@ fn render_napi_body( CallShape::Iterator(ib) => &ib.launch.symbol, CallShape::Async(_) => unreachable!("sync body rendered for an async callable"), }; - let offset = usize::from(self_tag.is_some()); - emit_args_read(out, f.params.len() + offset); + let mut m = emit_args(out, f, module, prefix, self_tag); + emit_arg_check(out, &m); - let mut c_args: Vec = Vec::new(); - let mut cleanups: Vec = Vec::new(); - if let Some(tag) = self_tag { - emit_self_arg(out, &mut c_args, tag); - } - for (i, p) in f.params.iter().enumerate() { - emit_param(out, &mut c_args, &mut cleanups, p, i + offset, prefix); - } - - out.push_str(" weaveffi_error err = {0};\n"); + out.push_str(&format!(" {prefix}_error err = {{0}};\n")); if let Some(ret) = &f.ret { - emit_ret_out_params(out, &mut c_args, ret, module, prefix); + emit_ret_out_params(out, &mut m.c_args, ret, module, prefix); } - c_args.push("&err".to_string()); + m.c_args.push("&err".to_string()); - let args_str = c_args.join(", "); + let args_str = m.c_args.join(", "); match &f.ret { Some(ret) => { let rt = c_ret_type_str(ret, module, prefix); @@ -1156,7 +1481,7 @@ fn render_napi_body( } } - for cleanup in &cleanups { + for cleanup in &m.cleanups { out.push_str(cleanup); } @@ -1175,29 +1500,32 @@ fn render_napi_body( /// Marshal one incoming JS argument into its C ABI slot(s), dispatching on /// the parameter's passing contract. A buffered parameter arrives as a /// `Buffer` the JS loader packed; it lowers to the borrowed -/// `(const uint8_t*, size_t)` pair the callee decodes and never frees. -/// Everything else keeps its direct slot lowering. +/// `(const uint8_t*, size_t)` pair the callee decodes and never frees. An +/// object arrives as the handle the JS class borrowed from its instance +/// (`null` for an absent `Interface?`). A callback interface arrives as the +/// adapter object the JS loader built; it is registered in the handle table +/// and passed as the entry's address plus the interface's static vtable. fn emit_param( out: &mut String, - c_args: &mut Vec, - cleanups: &mut Vec, + m: &mut MarshalledArgs, p: &ParamBinding, idx: usize, + module: &str, prefix: &str, ) { let name = p.name.as_str(); match p.arg_pass() { - ArgPass::Buffer { .. } => { - out.push_str(&format!(" void* {name}_raw;\n")); - out.push_str(&format!(" size_t {name}_len;\n")); + ArgPass::Buffer { .. } | ArgPass::Bytes { .. } => { + out.push_str(&format!(" void* {name}_raw = NULL;\n")); + out.push_str(&format!(" size_t {name}_len = 0;\n")); out.push_str(&format!( " napi_get_buffer_info(env, args[{idx}], &{name}_raw, &{name}_len);\n" )); - c_args.push(format!("(const uint8_t*){name}_raw")); - c_args.push(format!("{name}_len")); + m.c_args.push(format!("(const uint8_t*){name}_raw")); + m.c_args.push(format!("{name}_len")); } ArgPass::String { .. } => { - out.push_str(&format!(" size_t {name}_len;\n")); + out.push_str(&format!(" size_t {name}_len = 0;\n")); out.push_str(&format!( " napi_get_value_string_utf8(env, args[{idx}], NULL, 0, &{name}_len);\n" )); @@ -1207,91 +1535,71 @@ fn emit_param( out.push_str(&format!( " napi_get_value_string_utf8(env, args[{idx}], {name}, {name}_len + 1, &{name}_len);\n" )); - c_args.push(name.into()); - cleanups.push(format!(" free({name});\n")); + m.c_args.push(name.into()); + m.cleanups.push(format!(" free({name});\n")); } - ArgPass::Bytes { .. } => { - out.push_str(&format!(" void* {name}_raw;\n")); - out.push_str(&format!(" size_t {name}_len;\n")); + // The callee borrows the pointer for the call; the JS wrapper keeps + // its own reference. `null` (an absent `Interface?`) passes NULL. + ArgPass::Object { slot, .. } => { + let ptr_ty = slot.ty.render_c(prefix); + out.push_str(&format!(" void* {name}_raw = NULL;\n")); out.push_str(&format!( - " napi_get_buffer_info(env, args[{idx}], &{name}_raw, &{name}_len);\n" + " if (arg_status == napi_ok) arg_status = weaveffi_napi_get_handle(env, args[{idx}], &{name}_raw);\n" )); - c_args.push(format!("(const uint8_t*){name}_raw")); - c_args.push(format!("{name}_len")); + m.c_args.push(format!("({ptr_ty}){name}_raw")); } - // An interface arrives as the int64 handle the JS class unwrapped - // from its instance; the callee borrows the pointer for the call. - // When nullable (`Interface?`), JS null/undefined passes NULL. - ArgPass::Object { slot, nullable } => { - let ptr_ty = slot.ty.render_c(prefix); - if nullable { - out.push_str(&format!(" napi_valuetype {name}_type;\n")); - out.push_str(&format!(" napi_typeof(env, args[{idx}], &{name}_type);\n")); - out.push_str(&format!(" int64_t {name}_raw = 0;\n")); - out.push_str(&format!( - " if ({name}_type != napi_null && {name}_type != napi_undefined) {{\n" - )); - out.push_str(&format!( - " napi_get_value_int64(env, args[{idx}], &{name}_raw);\n" - )); - out.push_str(" }\n"); - c_args.push(format!( - "{name}_raw ? ({ptr_ty})(intptr_t){name}_raw : NULL" - )); + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback-passed parameter names a callback interface"); + let c_tag = callback_c_tag(cb, module, prefix); + out.push_str(&format!(" napi_valuetype {name}_type;\n")); + out.push_str(&format!(" napi_typeof(env, args[{idx}], &{name}_type);\n")); + out.push_str(&format!(" {prefix}_napi_cb_ctx* {name}_ctx = NULL;\n")); + out.push_str(&format!( + " if ({name}_type == napi_object || {name}_type == napi_function) {{\n" + )); + out.push_str(&format!( + " {name}_ctx = {prefix}_napi_cb_register(env, args[{idx}], \"{c_tag}\", {c_tag}_napi_dispatch);\n" + )); + out.push_str(" } else {\n"); + out.push_str(&format!( + " napi_throw_type_error(env, NULL, \"expected a {} implementation\");\n", + cb + )); + out.push_str(" arg_status = napi_object_expected;\n"); + out.push_str(" }\n"); + m.c_args.push(format!("(void*){name}_ctx")); + m.c_args.push(format!("&{c_tag}_napi_vtable")); + m.fail_cleanups.push(format!( + " if ({name}_ctx != NULL) {prefix}_napi_cb_release(env, {name}_ctx);\n" + )); + } + ArgPass::Direct { slot } => { + let is_enum = matches!(p.ty, Ty::Enum(_)); + let ct = if is_enum { + "int32_t" } else { - out.push_str(&format!(" int64_t {name}_raw;\n")); - out.push_str(&format!( - " napi_get_value_int64(env, args[{idx}], &{name}_raw);\n" - )); - c_args.push(format!("({ptr_ty})(intptr_t){name}_raw")); + c_scalar_type(&p.ty) + }; + out.push_str(&format!(" {ct} {name} = 0;\n")); + out.push_str(" if (arg_status == napi_ok) {\n"); + emit_leaf_read( + out, + " ", + &p.ty, + &format!("args[{idx}]"), + name, + "arg_status", + ); + out.push_str(" }\n"); + if is_enum { + m.c_args + .push(format!("({}){name}", slot.ty.render_c(prefix))); + } else { + m.c_args.push(name.into()); } } - ArgPass::Direct { slot } => match &p.ty { - Ty::I32 | Ty::U32 | Ty::I64 | Ty::F64 | Ty::Bool => { - let ct = c_scalar_type(&p.ty); - let getter = napi_getter(&p.ty); - out.push_str(&format!(" {ct} {name};\n")); - out.push_str(&format!(" {getter}(env, args[{idx}], &{name});\n")); - c_args.push(name.into()); - } - // N-API has no narrower-than-32-bit / float getter, so read into a - // correctly-sized temporary and narrow to the real ABI type. - Ty::I8 | Ty::I16 | Ty::U8 | Ty::U16 | Ty::U64 | Ty::F32 => { - let ct = c_scalar_type(&p.ty); - let getter = napi_getter(&p.ty); - let raw = napi_read_tmp_type(&p.ty); - out.push_str(&format!(" {raw} {name}_raw;\n")); - out.push_str(&format!(" {getter}(env, args[{idx}], &{name}_raw);\n")); - c_args.push(format!("({ct}){name}_raw")); - } - // The untyped handle keeps the runtime's literal type name; the - // runtime types stay `weaveffi_*` regardless of the configured - // business-symbol prefix. - Ty::Handle => { - out.push_str(&format!(" int64_t {name}_raw;\n")); - out.push_str(&format!( - " napi_get_value_int64(env, args[{idx}], &{name}_raw);\n" - )); - c_args.push(format!("(weaveffi_handle_t){name}_raw")); - } - Ty::TypedHandle(_) => { - let ptr_ty = slot.ty.render_c(prefix); - out.push_str(&format!(" int64_t {name}_raw;\n")); - out.push_str(&format!( - " napi_get_value_int64(env, args[{idx}], &{name}_raw);\n" - )); - c_args.push(format!("({ptr_ty})(intptr_t){name}_raw")); - } - Ty::Enum(_) => { - let etype = slot.ty.render_c(prefix); - out.push_str(&format!(" int32_t {name};\n")); - out.push_str(&format!( - " napi_get_value_int32(env, args[{idx}], &{name});\n" - )); - c_args.push(format!("({etype}){name}")); - } - other => unreachable!("direct-slot parameter with non-direct type {other:?}"), - }, } } @@ -1312,37 +1620,18 @@ fn emit_ret_out_params( plan::ret_pass(Some(ty), module, prefix), RetPass::Bytes | RetPass::Buffer ) { - out.push_str(" size_t out_len;\n"); + out.push_str(" size_t out_len = 0;\n"); c_args.push("&out_len".into()); } } -/// The C statement that creates a napi value `target` from a leaf C expression -/// `expr` (scalars, bools, enums, handles). -fn napi_create_leaf(env: &str, ty: &Ty, expr: &str, target: &str) -> String { - match ty { - Ty::I32 => format!("napi_create_int32({env}, {expr}, &{target});"), - Ty::U32 => format!("napi_create_uint32({env}, {expr}, &{target});"), - Ty::I64 => format!("napi_create_int64({env}, {expr}, &{target});"), - Ty::F64 => format!("napi_create_double({env}, {expr}, &{target});"), - Ty::I8 | Ty::I16 => format!("napi_create_int32({env}, {expr}, &{target});"), - Ty::U8 | Ty::U16 => format!("napi_create_uint32({env}, {expr}, &{target});"), - Ty::U64 => format!("napi_create_int64({env}, (int64_t)({expr}), &{target});"), - Ty::F32 => format!("napi_create_double({env}, {expr}, &{target});"), - Ty::Bool => format!("napi_get_boolean({env}, {expr}, &{target});"), - Ty::Enum(_) => format!("napi_create_int32({env}, (int32_t)({expr}), &{target});"), - Ty::Handle | Ty::TypedHandle(_) => { - format!("napi_create_int64({env}, (int64_t)(intptr_t)({expr}), &{target});") - } - _ => format!("napi_get_null({env}, &{target});"), - } -} - /// Convert the C `result` (plus `out_len` when present) into the JS return /// value and release what the consumer owes, dispatching on the shared /// receiving plan. A buffered return is copied into a JS `Buffer` and -/// released with `weaveffi_free_bytes`; the JS loader decodes it into the -/// idiomatic value. +/// released with `{prefix}_free_bytes`; the JS loader decodes it into the +/// idiomatic value. A returned object is one strong reference surfaced as a +/// handle the JS class adopts (and eventually destroys); the addon never +/// releases it here. fn emit_ret_to_napi(out: &mut String, ty: &Ty, module: &str, prefix: &str, f: &FnBinding) { out.push_str(" napi_value ret;\n"); if matches!(ty, Ty::Iterator(_)) { @@ -1369,46 +1658,21 @@ fn emit_ret_to_napi(out: &mut String, ty: &Ty, module: &str, prefix: &str, f: &F // differs. RetPass::Bytes | RetPass::Buffer => { out.push_str(" napi_create_buffer_copy(env, out_len, result, NULL, &ret);\n"); - out.push_str(" weaveffi_free_bytes((uint8_t*)result, out_len);\n"); + out.push_str(&format!( + " {prefix}_free_bytes((uint8_t*)result, out_len);\n" + )); } RetPass::String => { out.push_str(" napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, &ret);\n"); - out.push_str(" weaveffi_free_string(result);\n"); + out.push_str(&format!(" {prefix}_free_string(result);\n")); } - // A returned interface is an owned object reference surfaced as the - // raw handle; the JS loader wraps it in its class (which owns - // disposal), so the addon must not destroy it here. A nullable - // return surfaces JS null for the absent case. - RetPass::Object { nullable, .. } => { - if nullable { - out.push_str(" if (result == NULL) {\n"); - out.push_str(" napi_get_null(env, &ret);\n"); - out.push_str(" } else {\n"); - out.push_str(" napi_create_int64(env, (int64_t)(intptr_t)result, &ret);\n"); - out.push_str(" }\n"); - } else { - out.push_str(" napi_create_int64(env, (int64_t)(intptr_t)result, &ret);\n"); - } + // A NULL result (the absent case of `Interface?`) surfaces as null. + RetPass::Object { .. } => { + out.push_str(" weaveffi_napi_make_handle(env, result, &ret);\n"); + } + RetPass::Direct => { + out.push_str(&format!(" {}\n", napi_create_leaf(ty, "result", "ret"))); } - RetPass::Direct => match ty { - Ty::I32 => out.push_str(" napi_create_int32(env, result, &ret);\n"), - Ty::U32 => out.push_str(" napi_create_uint32(env, result, &ret);\n"), - Ty::I64 => out.push_str(" napi_create_int64(env, result, &ret);\n"), - Ty::F64 => out.push_str(" napi_create_double(env, result, &ret);\n"), - Ty::I8 | Ty::I16 => out.push_str(" napi_create_int32(env, result, &ret);\n"), - Ty::U8 | Ty::U16 => out.push_str(" napi_create_uint32(env, result, &ret);\n"), - Ty::U64 => out.push_str(" napi_create_int64(env, (int64_t)result, &ret);\n"), - Ty::F32 => out.push_str(" napi_create_double(env, result, &ret);\n"), - Ty::Bool => out.push_str(" napi_get_boolean(env, result, &ret);\n"), - Ty::Enum(_) => { - out.push_str(" napi_create_int32(env, (int32_t)result, &ret);\n"); - } - // Handles are opaque tokens surfaced as int64. - Ty::Handle | Ty::TypedHandle(_) => { - out.push_str(" napi_create_int64(env, (int64_t)(intptr_t)result, &ret);\n"); - } - other => unreachable!("direct return with non-direct type {other:?}"), - }, } out.push_str(" return ret;\n"); } diff --git a/crates/weaveffi-gen-node/src/codec.rs b/crates/weaveffi-gen-node/src/codec.rs index 7cfadc58..4fe66515 100644 --- a/crates/weaveffi-gen-node/src/codec.rs +++ b/crates/weaveffi-gen-node/src/codec.rs @@ -5,25 +5,30 @@ //! buffer encoding. This module emits the JS expressions that read and write //! that encoding: generic combinators for optionals, lists, and maps, and one //! generated pack/unpack function per record and rich enum. Every dispatch -//! goes through [`wire::classify`], so the wire-shape folds (handles as `u64` -//! tokens, borrowed views as their owned forms, records and rich enums as one -//! user-codec shape) live in `weaveffi-core`, not here. The composition is -//! fixed at generation time from the IR; no runtime type dispatch happens. +//! goes through [`Ty::wire`], so the wire-shape classification (records and +//! rich enums as one user-codec shape, interfaces as object tokens) lives in +//! `weaveffi-core`, not here. The composition is fixed at generation time +//! from the IR; no runtime type dispatch happens. +//! +//! Object tokens follow the reference-counting contract: writing an interface +//! field calls the wrapper's `_cloneHandle()` (which invokes the producer's +//! `_clone` symbol) so the buffer carries a fresh strong reference, and +//! reading one adopts the token into a new wrapper via `_adopt`. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; +use weaveffi_core::model::ModuleBinding; use weaveffi_core::model::Ty; -use weaveffi_core::model::{BindingModel, ModuleBinding}; use weaveffi_core::model::{Prim, WireType}; use weaveffi_core::utils::local_type_name; /// The reader/writer method of the private buffer runtime for a scalar wire /// shape (including strings and bytes, whose length-prefixed reads live in -/// the runtime too). +/// the runtime too). The 64-bit methods read and write JS `bigint`s. /// /// # Panics /// -/// Panics on a composite wire shape; callers dispatch enums, handles, user +/// Panics on a composite wire shape; callers dispatch enums, objects, user /// codecs, and containers before falling through to a scalar method. fn scalar_method(wt: WireType<'_>) -> &'static str { match wt { @@ -44,13 +49,28 @@ fn scalar_method(wt: WireType<'_>) -> &'static str { } } +/// The JS expression writing the object token of wrapper `val` (an instance +/// of the interface's class): a freshly cloned strong reference, never the +/// handle the wrapper keeps for itself. +fn js_object_token_expr(val: &str) -> String { + format!("w.u64({val}._cloneHandle())") +} + +/// The JS expression adopting an object token read from `r` into a new +/// wrapper of interface `name` (possibly dot-qualified; the class is named by +/// its local type name). +fn js_object_adopt_expr(name: &str) -> String { + format!("{}._adopt(r.u64())", local_type_name(name)) +} + /// A JS function expression `(w, v) => void` writing one value of `ty` in the /// wire format. Records and rich enums name their generated pack function; -/// optionals, lists, and maps compose the generic combinators. +/// optionals, lists, and maps compose the generic combinators; interfaces +/// write a cloned object token. pub(crate) fn js_writer_fn(ty: &Ty) -> String { match ty.wire() { WireType::Enum(_) => "(w, v) => w.i32(v)".into(), - WireType::Handle(_) => "(w, v) => w.u64(v)".into(), + WireType::Object(_) => format!("(w, v) => {}", js_object_token_expr("v")), WireType::User(n) => format!("__pack{}", local_type_name(n)), WireType::Optional(inner) => format!("(w, v) => __wOpt(w, v, {})", js_writer_fn(inner)), WireType::List(inner) => format!("(w, v) => __wList(w, v, {})", js_writer_fn(inner)), @@ -71,23 +91,18 @@ fn js_map_key_writer_fn(ty: &Ty) -> String { WireType::Prim(Prim::String) => "(w, k) => w.str(k)".into(), WireType::Prim(Prim::Bool) => "(w, k) => w.bool(k === true || k === 'true')".into(), WireType::Prim(Prim::I64) => "(w, k) => w.i64(k)".into(), - WireType::Prim(Prim::U64) | WireType::Handle(_) => "(w, k) => w.u64(k)".into(), + WireType::Prim(Prim::U64) => "(w, k) => w.u64(k)".into(), WireType::Enum(_) => "(w, k) => w.i32(Number(k))".into(), leaf => format!("(w, k) => w.{}(Number(k))", scalar_method(leaf)), } } /// A JS function expression `(r) => value` reading one value of `ty` from the -/// wire format. 64-bit integers surface as numbers (matching the TS surface); -/// handles surface as `BigInt`s except typed handles, which keep the numeric -/// handle spelling the addon uses. +/// wire format. 64-bit integers surface as `bigint`s (matching the TS +/// surface); object tokens are adopted into wrapper instances. pub(crate) fn js_reader_fn(ty: &Ty) -> String { match ty.wire() { - WireType::Prim(Prim::I64) => "(r) => Number(r.i64())".into(), - WireType::Prim(Prim::U64) => "(r) => Number(r.u64())".into(), - // Both handle kinds share the u64 token; only the JS surface differs. - WireType::Handle(_) if matches!(ty, Ty::TypedHandle(_)) => "(r) => Number(r.u64())".into(), - WireType::Handle(_) => "(r) => r.u64()".into(), + WireType::Object(n) => format!("(r) => {}", js_object_adopt_expr(n)), WireType::Enum(_) => "(r) => r.i32()".into(), WireType::User(n) => format!("__unpack{}", local_type_name(n)), WireType::Optional(inner) => format!("(r) => __rOpt(r, {})", js_reader_fn(inner)), @@ -105,7 +120,7 @@ pub(crate) fn js_reader_fn(ty: &Ty) -> String { fn js_write_expr(ty: &Ty, val: &str) -> String { match ty.wire() { WireType::Enum(_) => format!("w.i32({val})"), - WireType::Handle(_) => format!("w.u64({val})"), + WireType::Object(_) => js_object_token_expr(val), WireType::User(n) => format!("__pack{}(w, {val})", local_type_name(n)), WireType::Optional(inner) => format!("__wOpt(w, {val}, {})", js_writer_fn(inner)), WireType::List(inner) => format!("__wList(w, {val}, {})", js_writer_fn(inner)), @@ -121,11 +136,7 @@ fn js_write_expr(ty: &Ty, val: &str) -> String { /// The JS expression reading one value of type `ty` from reader `r`. pub(crate) fn js_read_expr(ty: &Ty) -> String { match ty.wire() { - WireType::Prim(Prim::I64) => "Number(r.i64())".into(), - WireType::Prim(Prim::U64) => "Number(r.u64())".into(), - // As in [`js_reader_fn`]: one wire token, two JS spellings. - WireType::Handle(_) if matches!(ty, Ty::TypedHandle(_)) => "Number(r.u64())".into(), - WireType::Handle(_) => "r.u64()".into(), + WireType::Object(n) => js_object_adopt_expr(n), WireType::Enum(_) => "r.i32()".into(), WireType::User(n) => format!("__unpack{}(r)", local_type_name(n)), WireType::Optional(inner) => format!("__rOpt(r, {})", js_reader_fn(inner)), @@ -135,28 +146,6 @@ pub(crate) fn js_read_expr(ty: &Ty) -> String { } } -/// True when the loader must embed the buffer runtime: any record or rich -/// enum is declared, any signature position carries a buffered type, or any -/// error code declares payload fields. -pub(crate) fn model_uses_buffers(model: &BindingModel) -> bool { - model.modules.iter().any(|m| { - !m.structs.is_empty() - || m.enums.iter().any(|e| e.is_rich()) - || m.error - .as_ref() - .is_some_and(|e| e.declared_here && e.codes.iter().any(|c| !c.fields.is_empty())) - || m.callbacks - .iter() - .any(|cb| cb.params.iter().any(|p| p.ty.is_buffered())) - || m.callables().any(|f| { - f.params.iter().any(|p| p.ty.is_buffered()) - || f.ret.as_ref().is_some_and(|t| { - t.is_buffered() || matches!(t, Ty::Iterator(inner) if inner.is_buffered()) - }) - }) - }) -} - /// Emit one module's pack/unpack functions: one pair per record and one pair /// per rich enum, with fields written and read in declaration (wire) order. pub(crate) fn render_pack_fns_js(out: &mut String, m: &ModuleBinding) { diff --git a/crates/weaveffi-gen-node/src/entities.rs b/crates/weaveffi-gen-node/src/entities.rs index eb5b73c4..4ca4ef51 100644 --- a/crates/weaveffi-gen-node/src/entities.rs +++ b/crates/weaveffi-gen-node/src/entities.rs @@ -1,73 +1,100 @@ //! The idiomatic JS surface layered over the addon: error classes, interface -//! classes, listener wrappers, function wrappers, and the `index.js` -//! assembler that composes them with the runtime prelude. +//! classes, callback-interface adapters, function wrappers, and the +//! `index.js` assembler that composes them with the runtime prelude. use heck::ToLowerCamelCase; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::{type_name as error_type_name, ERROR_BRAND}; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, ErrorBinding, FnBinding, InterfaceBinding, ListenerBinding, ModuleBinding, - ParamBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::ArgPass; +use weaveffi_core::plan::{ArgPass, RetPass}; use weaveffi_core::utils::{ local_type_name, render_prelude, render_trailer, wrapper_name, CommentStyle, }; -use crate::codec::{ - js_read_expr, js_reader_fn, js_writer_fn, model_uses_buffers, render_pack_fns_js, -}; +use crate::codec::{js_read_expr, js_reader_fn, js_writer_fn, render_pack_fns_js}; use crate::runtime::{ - model_has_iterators, render_error_brand_js, render_iterator_class_js, render_loader_js, + render_error_brand_js, render_iterator_class_js, render_loader_js, render_object_helpers_js, BUFFER_RUNTIME_JS, }; -use crate::types::{iface_member_base, js_fn_name, js_param_name, js_str_literal}; +use crate::types::{ + iface_lifecycle_base, iface_member_base, js_adapter_name, js_fn_name, js_method_name, + js_param_name, js_str_literal, +}; -/// Recognize an interface-typed return carried directly or as `Interface?` -/// (the only optional that stays a nullable pointer). Buffered returns are +/// Recognize an interface-typed value carried directly or as `Interface?` +/// (the only optional that stays a nullable pointer). Buffered values are /// handled separately by the wrapper body. -struct RetWrap { +struct ObjectShape { /// The local JS class name. cls: String, /// `true` for `Interface?`: the addon surfaces `null` for the absent case. - optional: bool, + nullable: bool, } -/// Recognize a class-typed (interface) return, direct or optional. -fn js_ret_wrap(ret: Option<&Ty>) -> Option { - fn direct(ty: &Ty, optional: bool) -> Option { - match ty { - Ty::Interface(n) => Some(RetWrap { - cls: local_type_name(n).to_string(), - optional, - }), - _ => None, - } +/// Recognize a class-typed (interface) value, direct or optional. +fn js_object_shape(ty: Option<&Ty>) -> Option { + let ty = ty?; + let nullable = matches!(ty, Ty::Optional(_)); + ty.interface_name().map(|n| ObjectShape { + cls: local_type_name(n).to_string(), + nullable, + }) +} + +/// The JS expression adopting one raw addon value `expr` (an object handle, +/// or `null` for an absent `Interface?`) into a wrapper instance. +fn js_adopt_expr(shape: &ObjectShape, expr: &str) -> String { + let cls = &shape.cls; + if shape.nullable { + format!("({expr} == null ? null : {cls}._adopt({expr}))") + } else { + format!("{cls}._adopt({expr})") } - match ret? { - Ty::Optional(inner) => direct(inner, true), - ty => direct(ty, false), +} + +/// The JS expression converting one raw addon value `expr` of type `ty` +/// received *from* the producer (a return, an async result, an iterator +/// element, or a callback-method argument) into its idiomatic form: buffers +/// decode, object handles are adopted, everything else passes through. +fn js_receive_expr(ty: &Ty, expr: &str) -> String { + if ty.is_buffered() { + return format!("__decode({}, {expr})", js_reader_fn(ty)); + } + match js_object_shape(Some(ty)) { + Some(shape) => js_adopt_expr(&shape, expr), + None => expr.to_string(), } } /// The addon-argument expression for one logical parameter, dispatching on /// its passing contract. Buffered values pack into a `Buffer` via the -/// generated writer; interface instances unwrap to their raw `_handle` (a -/// borrow; the callee never takes ownership); everything else passes through. +/// generated writer; interface instances are borrowed to their raw handle +/// (the callee never takes ownership; `Interface?` passes `null` for none); +/// callback-interface implementations are wrapped in the interface's adapter +/// so the addon can hand them raw values; everything else passes through. fn js_arg_expr(js_name: &str, p: &ParamBinding) -> String { match p.arg_pass() { ArgPass::Buffer { .. } => format!("__encode({}, {js_name})", js_writer_fn(&p.ty)), - ArgPass::Object { .. } => { - let cls = match &p.ty { - Ty::Interface(n) => local_type_name(n), - Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(n) => local_type_name(n), - other => unreachable!("non-interface optional is buffered: {other:?}"), - }, - other => unreachable!("object-passed parameter with type {other:?}"), - }; - format!("{js_name} instanceof {cls} ? {js_name}._handle : {js_name}") + ArgPass::Object { nullable, .. } => { + let cls = local_type_name( + p.ty.interface_name() + .expect("object-passed parameter names an interface"), + ); + if nullable { + format!("({js_name} == null ? null : __borrow({js_name}, {cls}))") + } else { + format!("__borrow({js_name}, {cls})") + } + } + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback-passed parameter names a callback interface"); + format!("{}({js_name})", js_adapter_name(cb)) } ArgPass::Direct { .. } | ArgPass::String { .. } | ArgPass::Bytes { .. } => { js_name.to_string() @@ -77,7 +104,9 @@ fn js_arg_expr(js_name: &str, p: &ParamBinding) -> String { /// The rebranding factory a callable's failures route through: the declaring /// module's domain factory when the callable `throws`, the generic -/// [`ERROR_BRAND`] constructor otherwise (panics and marshalling failures). +/// [`ERROR_BRAND`] constructor otherwise. Negative runtime codes (a producer +/// panic, a marshalling failure, a callback implementation that raised) never +/// match a domain table, so both paths surface them as the generic brand. fn js_error_map_expr(f: &FnBinding, error: Option<&ErrorBinding>) -> String { match error { Some(eb) if f.throws => js_error_factory_name(eb), @@ -92,18 +121,39 @@ fn js_error_factory_name(eb: &ErrorBinding) -> String { format!("__{}ErrorFrom", eb.owner_path.to_lower_camel_case()) } +/// Emit one C-style enum as a frozen runtime object on `wv`, the value a +/// `types.d.ts` `export enum` declaration promises: every variant name maps +/// to its discriminant and, as TypeScript's own `enum` lowering does, every +/// discriminant maps back to its variant name. Rich enums are tagged unions +/// with no runtime value and are skipped. +fn render_enum_js(out: &mut String, e: &EnumBinding) { + if e.is_rich() { + return; + } + let mut w = CodeWriter::two_space(); + w.block(format!("wv.{} = Object.freeze({{", e.name), "});", |w| { + for v in &e.variants { + w.line(format!("{}: {},", v.name, v.value)); + } + for v in &e.variants { + w.line(format!("{}: '{}',", v.value, v.name)); + } + }); + out.push_str(&w.finish()); +} + /// Emit one declaring module's typed error surface onto `wv`: the domain /// class extending the generic brand, one subclass per code carrying its /// stable `CODE` and default message, and the factory mapping a raw ABI code /// (plus the raw payload buffer) to the matching class. Codes that declare /// payload fields get a decoder that unpacks the buffer and attaches the /// fields as properties on the error instance; unknown codes fall back to -/// the generic brand (panics and marshalling failures). +/// the generic brand. /// /// Negative codes are reserved by the runtime (generic error, producer -/// panic, marshalling failure) and domain codes are validated positive-only, -/// so the table lookup never matches a negative code and the trap idiom -/// (fall back to [`ERROR_BRAND`]) holds by construction. +/// panic, marshalling failure, foreign callback failure) and domain codes are +/// validated positive-only, so the table lookup never matches a negative code +/// and the trap idiom (fall back to [`ERROR_BRAND`]) holds by construction. fn render_error_classes_js(out: &mut String, eb: &ErrorBinding) { let domain = &eb.type_name; let factory = js_error_factory_name(eb); @@ -190,13 +240,13 @@ fn render_error_classes_js(out: &mut String, eb: &ErrorBinding) { out.push_str(&w.finish()); } -/// Emit one wrapper callable's body: pack buffered arguments and unwrap -/// class-typed ones, invoke the addon binding through the rebranding helper, -/// then decode a buffered result or wrap an interface-typed one. -/// Iterator-returning callables launch the native iterator and hand its -/// external to the shared lazy iterator class, decoding buffered elements per -/// step. Shared by free functions and interface members (`self_expr` supplies -/// the leading handle of an instance method). +/// Emit one wrapper callable's body: pack buffered arguments, borrow +/// class-typed ones, and adapt callback implementations; invoke the addon +/// binding through the rebranding helper; then decode a buffered result or +/// adopt an object-typed one. Iterator-returning callables launch the native +/// iterator and hand its external to the shared lazy iterator class, decoding +/// or adopting each element per step. Shared by free functions and interface +/// members (`self_expr` supplies the leading handle of an instance method). fn emit_wrapper_body_js( w: &mut CodeWriter, f: &FnBinding, @@ -222,11 +272,13 @@ fn emit_wrapper_body_js( if let Some(Ty::Iterator(inner)) = f.ret.as_ref() { // Launch, then wrap the external in the lazy iterator: one native // `next` per consumer step, `destroy` on exhaustion or early exit. - // Buffered elements arrive as encoded buffers decoded per step. - let wrap_elem = if inner.is_buffered() { - format!("(_e) => __decode({}, _e)", js_reader_fn(inner)) - } else { + // Buffered elements arrive as encoded buffers decoded per step; + // object elements arrive as owned handles adopted per step. + let received = js_receive_expr(inner, "_e"); + let wrap_elem = if received == "_e" { "null".to_string() + } else { + format!("(_e) => {received}") }; w.line(format!("const _it = {call};")); w.line(format!( @@ -235,52 +287,33 @@ fn emit_wrapper_body_js( return; } - if let Some(ret) = f.ret.as_ref() { - if ret.is_buffered() { - let reader = js_reader_fn(ret); - if f.is_async { - w.line(format!( - "return {call}.then((_r) => __decode({reader}, _r));" - )); - } else { - w.line(format!("const _r = {call};")); - w.line(format!("return __decode({reader}, _r);")); - } - return; - } - } - - let Some(wrap) = js_ret_wrap(f.ret.as_ref()) else { + let Some(ret) = f.ret.as_ref() else { w.line(format!("return {call};")); return; }; - let cls = &wrap.cls; - let rewrap = format!("{cls}._fromHandle(_r)"); - match (f.is_async, wrap.optional) { - (false, false) => { - w.line(format!("const _r = {call};")); - w.line(format!("return {rewrap};")); - } - (false, true) => { - w.line(format!("const _r = {call};")); - w.line(format!("return _r == null ? null : {rewrap};")); - } - (true, false) => { - w.line(format!("return {call}.then((_r) => {rewrap});")); - } - (true, true) => { - w.line(format!( - "return {call}.then((_r) => (_r == null ? null : {rewrap}));" - )); - } + let received = js_receive_expr(ret, "_r"); + if received == "_r" { + w.line(format!("return {call};")); + } else if f.is_async { + w.line(format!("return {call}.then((_r) => {received});")); + } else { + w.line(format!("const _r = {call};")); + w.line(format!("return {received};")); } } -/// Emit one interface's JS class onto `wv`. The class owns the opaque handle -/// and frees it once, via explicit `destroy()` or a `FinalizationRegistry` -/// safety net. A sync constructor named `new` becomes the JS `constructor`; -/// every other constructor becomes a static factory; methods pass the wrapped -/// handle as the leading addon argument; statics are static methods. +/// Emit one interface's JS class onto `wv`. The class holds the object's +/// native handle (one strong reference, as a `bigint`) and releases it +/// exactly once: through `close()`, through `Symbol.dispose` (which aliases +/// `close`, for `using` declarations), or through a `FinalizationRegistry` +/// backstop when the wrapper is collected unclosed. `_adopt` wraps a handle +/// received from the producer without running the public constructor, and +/// `_cloneHandle` produces a second strong reference (through the producer's +/// `_clone` symbol) for the value-buffer codec to write as an object token. +/// +/// A sync constructor named `new` becomes the JS `constructor`; every other +/// constructor becomes a static factory; methods pass the wrapped handle as +/// the leading addon argument; statics are static methods. fn render_interface_class_js( out: &mut String, i: &InterfaceBinding, @@ -288,7 +321,8 @@ fn render_interface_class_js( strip: bool, ) { let name = &i.name; - let destroy_js = wrapper_name(&m.path, &iface_member_base(name, "destroy"), strip); + let destroy_js = wrapper_name(&m.path, &iface_lifecycle_base(name, "destroy"), strip); + let clone_js = wrapper_name(&m.path, &iface_lifecycle_base(name, "clone"), strip); let error = m.error.as_ref(); let mut w = CodeWriter::two_space(); @@ -338,7 +372,13 @@ fn render_interface_class_js( let params: Vec = f.params.iter().map(|p| js_param_name(&p.name)).collect(); let map = js_error_map_expr(f, error); w.block(format!("{method}({}) {{", params.join(", ")), "}", |w| { - emit_wrapper_body_js(w, f, &addon_name, Some("this._handle"), &map); + emit_wrapper_body_js( + w, + f, + &addon_name, + Some(&format!("__borrow(this, {name})")), + &map, + ); }); } for f in &i.statics { @@ -354,29 +394,31 @@ fn render_interface_class_js( }, ); } - // Explicit cleanup; guarded so a double `destroy()` (or destroy-then-GC) - // is a no-op rather than a double free. - w.block("destroy() {", "}", |w| { + // Explicit release; guarded so a double `close()` (or close-then-GC) + // is a no-op rather than a double release. + w.block("close() {", "}", |w| { w.block("if (this._handle) {", "}", |w| { w.line(format!("{name}._cleanup.unregister(this);")); w.line(format!("addon.{destroy_js}(this._handle);")); - w.line("this._handle = 0;"); + w.line("this._handle = 0n;"); }); }); + // A second strong reference for the value-buffer codec: the object + // token written into a buffer must never be the handle this wrapper + // still owns. + w.block("_cloneHandle() {", "}", |w| { + w.line(format!("return addon.{clone_js}(__borrow(this, {name}));")); + }); }); - // Wrap an owned handle returned by the addon without running the public - // constructor (which would invoke the native constructor again). - w.block( - format!("{name}._fromHandle = function (handle) {{"), - "};", - |w| { - w.line(format!("const _o = Object.create({name}.prototype);")); - w.line("_o._handle = handle;"); - w.line(format!("{name}._cleanup.register(_o, handle, _o);")); - w.line("return _o;"); - }, - ); + // Adopt an owned handle received from the addon without running the + // public constructor (which would invoke the native constructor again). + w.block(format!("{name}._adopt = function (handle) {{"), "};", |w| { + w.line(format!("const _o = Object.create({name}.prototype);")); + w.line("_o._handle = handle;"); + w.line(format!("{name}._cleanup.register(_o, handle, _o);")); + w.line("return _o;"); + }); w.block( format!("{name}._cleanup = new FinalizationRegistry((handle) => {{"), "});", @@ -384,59 +426,77 @@ fn render_interface_class_js( w.line(format!("if (handle) {{ addon.{destroy_js}(handle); }}")); }, ); + w.block("if (typeof Symbol.dispose === 'symbol') {", "}", |w| { + w.line(format!( + "{name}.prototype[Symbol.dispose] = {name}.prototype.close;" + )); + }); w.line(format!("wv.{name} = {name};")); w.blank(); out.push_str(&w.finish()); } -/// Emit the wrapping `wv.registerX` for a listener whose callback carries -/// buffered arguments: the addon delivers those as borrowed-then-copied -/// `Buffer`s, so the wrapper decodes them before invoking the user's -/// callback. Listeners with only direct arguments keep the plain addon -/// re-export. -fn render_listener_wrapper_js( +/// Emit the adapter for one callback interface. The consumer passes any +/// object with the interface's camelCase methods; the adapter is what the +/// addon actually holds (behind a `napi_ref`) and calls by raw IDL method +/// name with raw values: strings, numbers, `bigint`s, booleans, and `Buffer`s +/// for buffered arguments (borrowed and already copied by the addon), and +/// `bigint` handles (or `null` for an absent `Interface?`) for object +/// arguments, each carrying one strong reference the adapter adopts into a +/// wrapper before invoking the implementation. Return values pass straight +/// back; the addon converts them to the method's C return type. An exception +/// thrown by the implementation propagates to the addon's trampoline, which +/// reports it through `out_err` as a foreign error. +fn render_callback_adapter_js( out: &mut String, m: &ModuleBinding, - l: &ListenerBinding, - strip: bool, + cb: &CallbackInterfaceBinding, + prefix: &str, ) { - let Some(cb) = m.callback(&l.event_callback) else { - return; - }; - if !cb.params.iter().any(|p| p.ty.is_buffered()) { - return; - } - let register = js_fn_name(&m.path, &format!("register_{}", l.name), strip); - let params: Vec = cb.params.iter().map(|p| js_param_name(&p.name)).collect(); - let args: Vec = cb - .params - .iter() - .map(|p| { - let n = js_param_name(&p.name); - if p.ty.is_buffered() { - format!("__decode({}, {n})", js_reader_fn(&p.ty)) - } else { - n - } - }) - .collect(); + let adapter = js_adapter_name(&cb.name); + let protocol = cb.protocol(&m.path, prefix); let mut w = CodeWriter::two_space(); - w.block( - format!("wv.{register} = function (callback) {{"), - "};", - |w| { - w.block( - format!( - "return addon.{register}(function ({}) {{", - params.join(", ") - ), - "});", - |w| { - w.line(format!("callback({});", args.join(", "))); - }, - ); - }, - ); + w.block(format!("function {adapter}(impl) {{"), "}", |w| { + w.block("if (impl === null || impl === undefined) {", "}", |w| { + w.line(format!( + "throw new {ERROR_BRAND}(-3, '{} implementation must be an object');", + cb.name + )); + }); + w.block("return {", "};", |w| { + for (method, args) in cb.methods.iter().zip(&protocol.method_args) { + let params: Vec = method + .params + .iter() + .map(|p| js_param_name(&p.name)) + .collect(); + let converted: Vec = method + .params + .iter() + .zip(args) + .map(|(p, pass)| { + let n = js_param_name(&p.name); + match pass { + RetPass::Buffer | RetPass::Object { .. } => js_receive_expr(&p.ty, &n), + _ => n, + } + }) + .collect(); + w.block( + format!("{}({}) {{", method.name, params.join(", ")), + "},", + |w| { + w.line(format!( + "return impl.{}({});", + js_method_name(&method.name), + converted.join(", ") + )); + }, + ); + } + }); + }); + w.blank(); out.push_str(&w.finish()); } @@ -444,23 +504,27 @@ fn render_listener_wrapper_js( /// layers the idiomatic surface on top: the generic error brand plus one /// typed error class per declared domain, the private value-buffer runtime /// with one pack/unpack pair per record and rich enum, wrapper classes for -/// interfaces, and one wrapper per module function so failures rebrand as the -/// right error class and value types cross as plain objects rather than raw -/// buffers. +/// interfaces, one adapter per callback interface, and one wrapper per module +/// function so failures rebrand as the right error class and value types +/// cross as plain objects rather than raw buffers. pub(crate) fn render_node_index(model: &BindingModel, strip: bool, input_basename: &str) -> String { let dbl = CommentStyle::DoubleSlash; let mut out = render_prelude(dbl, input_basename); render_loader_js(&mut out); render_error_brand_js(&mut out); - if model_uses_buffers(model) { + if model.has_interfaces() { + render_object_helpers_js(&mut out); + } + + if model.has_buffers() { out.push_str(BUFFER_RUNTIME_JS); for m in &model.modules { render_pack_fns_js(&mut out, m); } } - if model_has_iterators(model) { + if model.has_iterators() { render_iterator_class_js(&mut out); } @@ -468,12 +532,15 @@ pub(crate) fn render_node_index(model: &BindingModel, strip: bool, input_basenam if let Some(eb) = m.error.as_ref().filter(|e| e.declared_here) { render_error_classes_js(&mut out, eb); } + for e in &m.enums { + render_enum_js(&mut out, e); + } + for cb in &m.callback_interfaces { + render_callback_adapter_js(&mut out, m, cb, &model.prefix); + } for i in &m.interfaces { render_interface_class_js(&mut out, i, m, strip); } - for l in &m.listeners { - render_listener_wrapper_js(&mut out, m, l, strip); - } } // One wrapper per module function, so every failure is rebranded and diff --git a/crates/weaveffi-gen-node/src/lib.rs b/crates/weaveffi-gen-node/src/lib.rs index 114fb34e..d8107fdd 100644 --- a/crates/weaveffi-gen-node/src/lib.rs +++ b/crates/weaveffi-gen-node/src/lib.rs @@ -1,17 +1,32 @@ //! Node.js (N-API) binding generator for WeaveFFI. //! //! Emits a JavaScript loader plus TypeScript type definitions for the -//! companion N-API addon. Records and rich enums are value types: they cross -//! the ABI serialized in WeaveFFI value buffers, so records surface as plain -//! JS objects, rich enums as tagged unions, and the loader carries a small -//! private buffer writer/reader plus one pack and one unpack function per -//! type. Async functions surface as `Promise`-returning functions, `iter` -//! functions surface as lazy `IterableIterator` wrappers that pull one -//! element per step, interfaces surface as JS classes over opaque native -//! handles, and each declared error domain surfaces as an `Error` subclass -//! extending the generic `WeaveFFIError` brand. Implements -//! [`LanguageBackend`]; the shared driver bridges it into the generator -//! pipeline. +//! companion N-API addon (ABI revision 2). Records and rich enums are value +//! types: they cross the ABI serialized in WeaveFFI value buffers, so records +//! surface as plain JS objects, rich enums as tagged unions, and the loader +//! carries a small private buffer writer/reader plus one pack and one unpack +//! function per type. 64-bit integers are `bigint`s everywhere (parameters, +//! returns, record fields, callback arguments), so nothing above 2^53 is +//! silently rounded. +//! +//! Interfaces surface as JS classes holding one strong reference to a +//! reference-counted native object: `close()` (or `Symbol.dispose`, for +//! `using` declarations) releases it, and a `FinalizationRegistry` backstop +//! releases a wrapper that is collected unclosed, so the producer's +//! `destroy` runs exactly once. Object-typed parameters are borrowed for the +//! call; returned, awaited, and iterated objects are adopted into fresh +//! wrappers; `Interface?` maps to `Wrapper | null`; and objects inside value +//! buffers cross as freshly cloned object tokens. Callback interfaces surface +//! as TypeScript `interface`s any object with the right methods satisfies: +//! the addon keeps the implementation behind a `napi_ref`, installs one +//! static vtable per interface, and its trampolines call the implementation +//! directly on the JS thread or hop through a `napi_threadsafe_function` and +//! wait when the producer calls from another thread. Async functions surface +//! as `Promise`-returning functions, `iter` functions as lazy +//! `IterableIterator` wrappers that pull one element per step, and each +//! declared error domain as an `Error` subclass extending the generic +//! `WeaveFFIError` brand. Implements [`LanguageBackend`]; the shared driver +//! bridges it into the generator pipeline. //! //! The crate is split by emitted artifact: `types` holds JS/TS naming and //! type mapping, `codec` the value-buffer codec emitters, `runtime` the @@ -34,21 +49,23 @@ use camino::Utf8Path; use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::BindingModel; +use weaveffi_core::model::{BindingModel, CallbackInterfaceBinding, ModuleBinding}; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; +use weaveffi_core::platform::Platform; use weaveffi_core::resolved::ResolvedApi; use crate::addon::render_addon_c; use crate::entities::render_node_index; use crate::package::{ - render_binding_gyp, render_node_dts, render_package_json, render_packaged_binding_gyp, - render_packaged_package_json, render_packaged_readme, render_platform_package_json, + node_platform_tokens, render_binding_gyp, render_callback_interface_dts, render_node_dts, + render_package_json, render_packaged_binding_gyp, render_packaged_package_json, + render_packaged_readme, render_platform_package_json, }; /// Per-target configuration for [`NodeGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct NodeConfig { /// npm package name (default `"weaveffi"`). pub package_name: Option, @@ -107,7 +124,7 @@ impl LanguageBackend for NodeGenerator { "node" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -115,6 +132,19 @@ impl LanguageBackend for NodeGenerator { config.prefix() } + /// The consumer-facing TypeScript `interface` for one callback interface, + /// the same declaration `types.d.ts` carries. The JS adapter and the + /// native vtable are emitted by the `index.js` and addon assemblers. + fn render_callback_interface( + &self, + out: &mut String, + _module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + _config: &Self::Config, + ) { + render_callback_interface_dts(out, cb); + } + fn files( &self, api: &ResolvedApi, @@ -173,17 +203,21 @@ impl LanguageBackend for NodeGenerator { // The per-platform package names follow the esbuild/swc convention: // `--` constrained by npm `os`/`cpu`, so npm - // installs only the matching one. - let platform_pkgs: Vec<(weaveffi_core::platform::Platform, String)> = ctx + // installs only the matching one. Platforms Node does not run on + // (Android, Wasm) have no npm tokens and are skipped. + let platform_pkgs: Vec<(Platform, (&str, &str), String)> = ctx .binaries .platforms() - .map(|p| { - ( - p, - format!("{}-{}-{}", package.name, p.node_os(), p.node_cpu()), - ) + .filter_map(|p| { + let tokens = node_platform_tokens(p)?; + let (os, cpu) = tokens; + Some((p, tokens, format!("{}-{os}-{cpu}", package.name))) }) .collect(); + let dep_pkgs: Vec<(Platform, String)> = platform_pkgs + .iter() + .map(|(p, _, name)| (*p, name.clone())) + .collect(); let mut files = vec![ PackagedFile::text( @@ -196,7 +230,7 @@ impl LanguageBackend for NodeGenerator { ), PackagedFile::text( dir.join("package.json"), - render_packaged_package_json(&package, &platform_pkgs, input_basename), + render_packaged_package_json(&package, &dep_pkgs, input_basename), ), PackagedFile::text( dir.join("binding.gyp"), @@ -214,11 +248,11 @@ impl LanguageBackend for NodeGenerator { // Each platform package bundles its prebuilt library and is gated by // npm `os`/`cpu` so only the matching one installs. - for (platform, pkg_name) in &platform_pkgs { + for (platform, tokens, pkg_name) in &platform_pkgs { let pkg_dir = dir.join("npm").join(pkg_name); files.push(PackagedFile::text( pkg_dir.join("package.json"), - render_platform_package_json(pkg_name, &package.version, *platform), + render_platform_package_json(pkg_name, &package.version, *tokens), )); let nb = ctx.binaries.get(*platform).expect("platform has a binary"); files.push(PackagedFile::copy( @@ -229,3 +263,468 @@ impl LanguageBackend for NodeGenerator { Some(files) } } + +#[cfg(test)] +mod tests { + use super::*; + use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, EnumDef, EnumVariant, Function, InterfaceDef, Module, Param, + StructDef, StructField, TypeRef, + }; + + fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } + } + + fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } + } + + fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } + } + + fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) + } + + fn variant(name: &str, value: i32, fields: Vec) -> EnumVariant { + EnumVariant { + name: name.into(), + value, + doc: None, + fields, + } + } + + /// One module exercising every revision-2 shape: an interface with a + /// constructor and a method, `Store?` in and out, a record holding a + /// `Store` and a `[Store]`, an iterator of `Store`, an async function + /// returning a `Store`, a 64-bit round trip, a callback interface with + /// string, i32, record, and object parameters (one method returning + /// `bool`, one void), a function taking that callback, a C-style enum, + /// and a rich enum. + fn fixture() -> BindingModel { + let mut fetch = func( + "fetch", + vec![param("path", TypeRef::StringUtf8)], + Some(named("Store")), + ); + fetch.r#async = true; + let kv = Module { + name: "kv".into(), + doc: None, + functions: vec![ + func( + "maybe_store", + vec![param("store", TypeRef::Optional(Box::new(named("Store"))))], + Some(TypeRef::Optional(Box::new(named("Store")))), + ), + func( + "scan", + vec![], + Some(TypeRef::Iterator(Box::new(named("Store")))), + ), + func( + "subscribe", + vec![param("listener", named("Listener"))], + None, + ), + func( + "describe", + vec![param("bundle", named("Bundle"))], + Some(named("Bundle")), + ), + func( + "widen", + vec![param("n", TypeRef::I64), param("m", TypeRef::U64)], + Some(TypeRef::U64), + ), + fetch, + ], + interfaces: vec![InterfaceDef { + name: "Store".into(), + doc: Some("A key-value store.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("path", TypeRef::StringUtf8)], None)], + methods: vec![func( + "get", + vec![param("key", TypeRef::StringUtf8)], + Some(TypeRef::StringUtf8), + )], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Listener".into(), + doc: Some("Receives store events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + ], + Some(TypeRef::Bool), + ), + func( + "on_bundle", + vec![ + param("bundle", named("Bundle")), + param("store", named("Store")), + param("alt", TypeRef::Optional(Box::new(named("Store")))), + ], + None, + ), + ], + }], + structs: vec![StructDef { + name: "Bundle".into(), + doc: None, + deprecated: None, + fields: vec![ + field("primary", named("Store")), + field("extras", TypeRef::List(Box::new(named("Store")))), + field("stamp", TypeRef::I64), + ], + }], + enums: vec![ + EnumDef { + name: "Mode".into(), + doc: None, + deprecated: None, + variants: vec![variant("Fast", 0, vec![]), variant("Safe", 7, vec![])], + }, + EnumDef { + name: "Shape".into(), + doc: None, + deprecated: None, + variants: vec![ + variant("Empty", 0, vec![]), + variant("Circle", 1, vec![field("radius", TypeRef::F64)]), + ], + }, + ], + errors: None, + modules: vec![], + }; + let api = ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![kv], + }); + BindingModel::build(&api, "weaveffi") + } + + fn index_js() -> String { + render_node_index(&fixture(), true, "weaveffi.yml") + } + + fn addon_c() -> String { + render_addon_c(&fixture(), true, "weaveffi.yml") + } + + fn dts() -> String { + render_node_dts(&fixture(), true, "weaveffi.yml") + } + + fn assert_has(src: &str, needle: &str) { + assert!(src.contains(needle), "missing `{needle}` in:\n{src}"); + } + + fn assert_lacks(src: &str, needle: &str) { + assert!(!src.contains(needle), "unexpected `{needle}` in:\n{src}"); + } + + #[test] + fn addon_checks_abi_revision_and_binds_runtime_symbols() { + let c = addon_c(); + assert_has(&c, "WEAVEFFI_ABI_VERSION"); + assert_has(&c, "weaveffi_error_set("); + assert_has(&c, "weaveffi_error_clear(&err);"); + assert_lacks(&c, "arena"); + assert_lacks(&c, "weaveffi_handle_t"); + } + + #[test] + fn interface_wrapper_closes_exactly_once_and_exposes_clone() { + let js = index_js(); + assert_has(&js, "class Store {"); + assert_has( + &js, + "this._handle = __invoke(addon.Store_new, [path], __generic);", + ); + assert_has(&js, "Store._cleanup.register(this, this._handle, this);"); + assert_has(&js, "close() {"); + assert_has(&js, "Store._cleanup.unregister(this);"); + assert_has(&js, "addon.Store__destroy(this._handle);"); + assert_has(&js, "this._handle = 0n;"); + assert_has( + &js, + "Store.prototype[Symbol.dispose] = Store.prototype.close;", + ); + assert_has(&js, "Store._cleanup = new FinalizationRegistry("); + assert_has(&js, "return addon.Store__clone(__borrow(this, Store));"); + assert_has(&js, "Store._adopt = function (handle) {"); + assert_has( + &js, + "__invoke(addon.Store_get, [__borrow(this, Store), key], __generic)", + ); + + let c = addon_c(); + assert_has(&c, "static napi_value Napi_weaveffi_kv_Store_destroy("); + assert_has(&c, "weaveffi_kv_Store_destroy(self);"); + assert_has(&c, "static napi_value Napi_weaveffi_kv_Store_clone("); + assert_has( + &c, + "weaveffi_kv_Store* cloned = weaveffi_kv_Store_clone(self);", + ); + assert_has( + &c, + "{ \"Store__destroy\", NULL, Napi_weaveffi_kv_Store_destroy,", + ); + assert_has( + &c, + "{ \"Store__clone\", NULL, Napi_weaveffi_kv_Store_clone,", + ); + // Methods borrow the wrapped handle as the leading const pointer. + assert_has(&c, "(const weaveffi_kv_Store*)self_raw"); + + let d = dts(); + assert_has(&d, "export class Store {"); + assert_has(&d, "close(): void;"); + assert_has(&d, "[Symbol.dispose](): void;"); + assert_lacks(&d, "destroy(): void;"); + } + + #[test] + fn nullable_objects_map_to_wrapper_or_null() { + let js = index_js(); + assert_has( + &js, + "__invoke(addon.maybeStore, [(store == null ? null : __borrow(store, Store))], __generic)", + ); + assert_has(&js, "return (_r == null ? null : Store._adopt(_r));"); + + let c = addon_c(); + assert_has(&c, "weaveffi_napi_get_handle(env, args[0], &store_raw)"); + assert_has(&c, "(const weaveffi_kv_Store*)store_raw"); + assert_has(&c, "weaveffi_napi_make_handle(env, result, &ret);"); + assert_has(&c, "if (p == NULL) {\n return napi_get_null(env, out);"); + + let d = dts(); + assert_has( + &d, + "export function maybeStore(store: Store | null): Store | null", + ); + } + + #[test] + fn objects_inside_records_clone_on_write_and_adopt_on_read() { + let js = index_js(); + assert_has(&js, "function __packBundle(w, v) {"); + assert_has(&js, "w.u64(v.primary._cloneHandle());"); + assert_has( + &js, + "__wList(w, v.extras, (w, v) => w.u64(v._cloneHandle()));", + ); + assert_has(&js, "w.i64(v.stamp);"); + assert_has(&js, "primary: Store._adopt(r.u64()),"); + assert_has(&js, "extras: __rList(r, (r) => Store._adopt(r.u64())),"); + assert_has(&js, "stamp: r.i64(),"); + + let d = dts(); + assert_has(&d, "primary: Store;"); + assert_has(&d, "extras: Store[];"); + assert_has(&d, "stamp: bigint;"); + } + + #[test] + fn iterator_and_async_object_results_are_adopted() { + let js = index_js(); + assert_has( + &js, + "new WeaveFFIIterator(_it, addon.scan_iterNext, addon.scan_iterDestroy, __generic, (_e) => Store._adopt(_e));", + ); + assert_has( + &js, + "__invokeAsync(addon.fetch, [path], __generic).then((_r) => Store._adopt(_r));", + ); + + let c = addon_c(); + assert_has(&c, "weaveffi_napi_make_handle(env, iter_item, &ret);"); + assert_has(&c, "weaveffi_napi_make_handle(env, ctx->result, &val);"); + + let d = dts(); + assert_has(&d, "export function scan(): IterableIterator"); + assert_has(&d, "export function fetch(path: string): Promise"); + } + + #[test] + fn sixty_four_bit_integers_are_bigints() { + let c = addon_c(); + assert_has(&c, "napi_get_value_bigint_int64(env, v, out, &lossless)"); + assert_has(&c, "napi_get_value_bigint_uint64(env, v, out, &lossless)"); + assert_has( + &c, + "napi_throw_range_error(env, NULL, \"bigint does not fit", + ); + assert_has(&c, "arg_status = weaveffi_napi_get_i64(env, args[0], &n);"); + assert_has(&c, "arg_status = weaveffi_napi_get_u64(env, args[1], &m);"); + assert_has(&c, "napi_create_bigint_uint64(env, result, &ret);"); + assert_lacks(&c, "napi_create_int64("); + assert_lacks(&c, "napi_get_value_int64(env, args"); + + let d = dts(); + assert_has(&d, "export function widen(n: bigint, m: bigint): bigint"); + } + + #[test] + fn plain_enums_have_a_runtime_value_matching_their_declaration() { + let js = index_js(); + // The `.d.ts` promises `export enum Mode`, so `index.js` must export + // the value, with TypeScript's forward and reverse mappings. + assert_has( + &js, + "wv.Mode = Object.freeze({\n Fast: 0,\n Safe: 7,\n 0: 'Fast',\n 7: 'Safe',\n});", + ); + // A rich enum is a tagged union: pack/unpack functions, no value. + assert_has(&js, "function __packShape(w, v) {"); + assert_lacks(&js, "wv.Shape ="); + + let d = dts(); + assert_has(&d, "export enum Mode {\n Fast = 0,\n Safe = 7,\n}"); + assert_has(&d, "export type Shape ="); + } + + #[test] + fn callback_interface_renders_ts_interface_and_js_adapter() { + let d = dts(); + assert_has(&d, "export interface Listener {"); + assert_has(&d, "onMessage(text: string, weight: number): boolean;"); + assert_has( + &d, + "onBundle(bundle: Bundle, store: Store, alt: Store | null): void;", + ); + assert_has(&d, "export function subscribe(listener: Listener): void"); + + let mut via_trait = String::new(); + let model = fixture(); + let m = &model.modules[0]; + NodeGenerator.render_callback_interface( + &mut via_trait, + m, + &m.callback_interfaces[0], + &NodeConfig::default(), + ); + assert_has(&via_trait, "export interface Listener {"); + + let js = index_js(); + assert_has(&js, "function __adaptListener(impl) {"); + assert_has(&js, "on_message(text, weight) {"); + assert_has(&js, "return impl.onMessage(text, weight);"); + assert_has(&js, "on_bundle(bundle, store, alt) {"); + assert_has( + &js, + "return impl.onBundle(__decode(__unpackBundle, bundle), Store._adopt(store), (alt == null ? null : Store._adopt(alt)));", + ); + assert_has( + &js, + "__invoke(addon.subscribe, [__adaptListener(listener)], __generic)", + ); + } + + #[test] + fn callback_interface_renders_static_vtable_and_thread_hopping_trampolines() { + let c = addon_c(); + // Handle table entry, thread identity, and the hop machinery. + assert_has(&c, "#include "); + assert_has(&c, "weaveffi_napi_js_thread = uv_thread_self();"); + assert_has(&c, "napi_create_reference(env, target, 1, &ctx->ref);"); + assert_has(&c, "napi_create_threadsafe_function(env, NULL, NULL, resource_name, 0, 1, NULL, NULL, NULL, dispatch, &ctx->tsfn);"); + assert_has(&c, "uv_cond_wait(&req->cv, &req->mu);"); + // Trampolines match the vtable signatures and route by thread. + assert_has( + &c, + "static bool weaveffi_kv_Listener_on_message_tramp(void* ctx, const char* text, int32_t weight, weaveffi_error* out_err) {", + ); + assert_has( + &c, + "static void weaveffi_kv_Listener_on_bundle_tramp(void* ctx, const uint8_t* bundle_ptr, size_t bundle_len, weaveffi_kv_Store* store, weaveffi_kv_Store* alt, weaveffi_error* out_err) {", + ); + assert_has(&c, "if (weaveffi_napi_on_js_thread()) {"); + assert_has(&c, "weaveffi_napi_cb_hop(&req);"); + // The invoker looks the method up by IDL name, borrows buffers, and + // hands object references over as handles. + assert_has( + &c, + "napi_get_named_property(env, target, \"on_bundle\", &fn);", + ); + assert_has( + &c, + "napi_create_buffer_copy(env, f->bundle_len, f->bundle_ptr ?", + ); + assert_has(&c, "weaveffi_napi_make_handle(env, f->store, &argv[1]);"); + assert_has(&c, "weaveffi_napi_make_handle(env, f->alt, &argv[2]);"); + assert_has(&c, "napi_get_value_bool(env, result, &f->result);"); + // Failure path: a JS exception becomes a foreign error, never an unwind. + assert_has( + &c, + "weaveffi_error_set(out_err, -4, msg[0] ? msg : fallback);", + ); + assert_has( + &c, + "weaveffi_napi_cb_report(env, f->hdr.out_err, \"Listener.on_message threw\");", + ); + // One static vtable per interface, methods in order then `free`. + assert_has( + &c, + "static const weaveffi_kv_Listener_vtable weaveffi_kv_Listener_napi_vtable = { weaveffi_kv_Listener_on_message_tramp, weaveffi_kv_Listener_on_bundle_tramp, weaveffi_napi_cb_free };", + ); + // The entry point registers the adapter and passes ctx plus vtable. + assert_has( + &c, + "listener_ctx = weaveffi_napi_cb_register(env, args[0], \"weaveffi_kv_Listener\", weaveffi_kv_Listener_napi_dispatch);", + ); + assert_has( + &c, + "weaveffi_kv_subscribe((void*)listener_ctx, &weaveffi_kv_Listener_napi_vtable, &err);", + ); + } + + #[test] + fn package_skips_platforms_node_does_not_run_on() { + assert_eq!( + node_platform_tokens(Platform::MacosArm64), + Some(("darwin", "arm64")) + ); + assert_eq!(node_platform_tokens(Platform::AndroidArm64), None); + assert_eq!(node_platform_tokens(Platform::Wasm32), None); + } + + #[test] + fn legacy_features_are_gone() { + for src in [index_js(), dts(), addon_c()] { + assert_lacks(&src, "_napi_listener"); + assert_lacks(&src, "weaveffi_handle_t"); + assert_lacks(&src, "arena"); + assert_lacks(&src, "destroy()"); + } + } +} diff --git a/crates/weaveffi-gen-node/src/package.rs b/crates/weaveffi-gen-node/src/package.rs index 33cc506c..46e06697 100644 --- a/crates/weaveffi-gen-node/src/package.rs +++ b/crates/weaveffi-gen-node/src/package.rs @@ -11,14 +11,17 @@ use weaveffi_core::cache::CLI_VERSION; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::{type_name as error_type_name, ERROR_BRAND}; use weaveffi_core::manifest::{json_escape, JsonObject, JsonValue}; -use weaveffi_core::model::{BindingModel, EnumBinding, ErrorBinding, InterfaceBinding}; +use weaveffi_core::model::{ + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, InterfaceBinding, +}; use weaveffi_core::package::PackageContext; use weaveffi_core::pkg::ResolvedPackage; use weaveffi_core::platform::Platform; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use crate::types::{ - emit_doc, emit_fn_doc, js_fn_name, js_param_name, ts_fn_tags, ts_params, ts_ret, ts_type_for, + emit_doc, emit_fn_doc, js_fn_name, js_method_name, js_param_name, ts_fn_tags, ts_params, + ts_ret, ts_type_for, }; /// Prepend the generated-file prelude entries (`//`, `//warning`, @@ -100,16 +103,20 @@ pub(crate) fn render_packaged_package_json( .render() } +/// The npm `os`/`cpu` pair of a platform, or `None` for a platform Node does +/// not run on (Android, Wasm), which the packaged layout skips. +pub(crate) fn node_platform_tokens(platform: Platform) -> Option<(&'static str, &'static str)> { + Some((platform.node_os()?, platform.node_cpu()?)) +} + /// Render a per-platform native package's `package.json`, gated by npm `os` and /// `cpu` so npm installs only the matching one. The single-element `os` and /// `cpu` arrays keep npm's conventional inline spelling. pub(crate) fn render_platform_package_json( pkg_name: &str, version: &str, - platform: Platform, + (os, cpu): (&str, &str), ) -> String { - let os = platform.node_os(); - let cpu = platform.node_cpu(); JsonObject::new() .str_entry("name", pkg_name) .str_entry("version", version) @@ -210,7 +217,8 @@ pub(crate) fn render_packaged_readme( let platforms: Vec = ctx .binaries .platforms() - .map(|p| format!("- `{}-{}-{}`", name, p.node_os(), p.node_cpu())) + .filter_map(node_platform_tokens) + .map(|(os, cpu)| format!("- `{name}-{os}-{cpu}`")) .collect(); let platform_list = platforms.join("\n"); format!( @@ -348,15 +356,54 @@ fn render_interface_dts(out: &mut String, i: &InterfaceBinding, error: Option<&E ts_ret(f) )); } - w.line("/** Free the underlying native object. */"); - w.line("destroy(): void;"); + w.line("/**"); + w.line(" * Release this wrapper's reference to the native object. Safe to call"); + w.line(" * more than once; a wrapper that is never closed is released when it is"); + w.line(" * garbage collected. Using the wrapper after `close()` throws."); + w.line(" */"); + w.line("close(): void;"); + w.line("/** Alias of `close()` for `using` declarations. */"); + w.line("[Symbol.dispose](): void;"); + }); + out.push_str(&w.finish()); +} + +/// `.d.ts` for one callback interface: a TS `interface` with one camelCase +/// method per IDL method. Any object with those methods (a class instance, a +/// literal) can be passed wherever the interface is expected; 64-bit +/// integers arrive as `bigint`s and object arguments as wrapper instances +/// the implementation owns (and should `close()` when done). +pub(crate) fn render_callback_interface_dts(out: &mut String, cb: &CallbackInterfaceBinding) { + let mut w = CodeWriter::two_space(); + { + let mut d = String::new(); + emit_doc(&mut d, &cb.doc, ""); + w.raw(d); + } + w.block(format!("export interface {} {{", cb.name), "}", |w| { + for m in &cb.methods { + let mut d = String::new(); + emit_fn_doc(&mut d, &m.doc, &m.params, " ", &[]); + w.raw(d); + let params: Vec = m + .params + .iter() + .map(|p| format!("{}: {}", js_param_name(&p.name), ts_type_for(&p.ty))) + .collect(); + let ret = m.ret.as_ref().map_or("void".to_string(), ts_type_for); + w.line(format!( + "{}({}): {ret};", + js_method_name(&m.name), + params.join(", ") + )); + } }); out.push_str(&w.finish()); } /// Render the TypeScript declarations (`types.d.ts`): the generic error /// brand, then per module the typed error surface, record interfaces, enums -/// (numeric or tagged-union), interface classes, listener endpoints, and one +/// (numeric or tagged-union), callback interfaces, interface classes, and one /// function signature per exported wrapper. pub(crate) fn render_node_dts( model: &BindingModel, @@ -367,8 +414,10 @@ pub(crate) fn render_node_dts( out.push_str("// Generated types for WeaveFFI functions\n"); out.push_str("/**\n"); out.push_str(" * Base class of every error thrown by these bindings. Non-throwing\n"); - out.push_str(" * functions reject or throw it directly for panics and marshalling\n"); - out.push_str(" * failures; throwing functions surface a module domain subclass.\n"); + out.push_str(" * functions reject or throw it directly for runtime traps (code -2 a\n"); + out.push_str(" * producer panic, -3 a marshalling failure, -4 a callback interface\n"); + out.push_str(" * implementation that threw); throwing functions surface a module\n"); + out.push_str(" * domain subclass for their declared codes.\n"); out.push_str(" */\n"); out.push_str(&format!("export class {ERROR_BRAND} extends Error {{\n")); out.push_str(" /** The numeric ABI error code. */\n"); @@ -408,35 +457,12 @@ pub(crate) fn render_node_dts( out.push_str("}\n"); } out.push_str(&format!("// module {}\n", m.path)); + for cb in &m.callback_interfaces { + render_callback_interface_dts(&mut out, cb); + } for i in &m.interfaces { render_interface_dts(&mut out, i, m.error.as_ref()); } - for l in &m.listeners { - let Some(cb) = m.callback(&l.event_callback) else { - continue; - }; - let cb_params: Vec = cb - .params - .iter() - .map(|p| format!("{}: {}", js_param_name(&p.name), ts_type_for(&p.ty))) - .collect(); - let register = js_fn_name( - &m.path, - &format!("register_{}", l.name), - strip_module_prefix, - ); - let unregister = js_fn_name( - &m.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ); - emit_doc(&mut out, &l.doc, ""); - out.push_str(&format!( - "export function {register}(callback: ({}) => void): number\n", - cb_params.join(", ") - )); - out.push_str(&format!("export function {unregister}(id: number): void\n")); - } for f in &m.functions { let ts_name = js_fn_name(&m.path, &f.name, strip_module_prefix); emit_fn_doc( diff --git a/crates/weaveffi-gen-node/src/runtime.rs b/crates/weaveffi-gen-node/src/runtime.rs index 86c18042..c9b22d96 100644 --- a/crates/weaveffi-gen-node/src/runtime.rs +++ b/crates/weaveffi-gen-node/src/runtime.rs @@ -1,18 +1,19 @@ //! The emitted JS runtime prelude: addon loading, the generic error brand -//! and invoke helpers, the private value-buffer reader/writer, and the shared -//! lazy iterator class. +//! and invoke helpers, the object borrow helper, the private value-buffer +//! reader/writer, and the shared lazy iterator class. //! //! Everything here is fixed text (or near-fixed text) embedded at the top of //! `index.js`; the per-entity wrappers in [`crate::entities`] call into it. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; -use weaveffi_core::model::{BindingModel, CallShape}; /// The private buffer writer/reader runtime embedded in `index.js` whenever /// the model uses value buffers. Little-endian, packed, no alignment; decode /// failures throw the generic error brand (a malformed buffer is a contract -/// violation, not a typed domain error). +/// violation, not a typed domain error). The 64-bit methods take and return +/// `bigint`s (the writers also accept numbers and decimal strings, which is +/// what map keys arrive as). pub(crate) const BUFFER_RUNTIME_JS: &str = r#"// --- Private value-buffer runtime (WeaveFFI wire format) -------------------- // Little-endian, packed, no alignment. Decoders reject truncated buffers, // invalid bool/flag bytes, hostile length prefixes, and trailing bytes. @@ -197,13 +198,25 @@ pub(crate) fn render_error_brand_js(out: &mut String) { )); } -/// True when any callable in the model returns `iter`, so the addon and -/// loader must emit the shared lazy-iterator support. -pub(crate) fn model_has_iterators(model: &BindingModel) -> bool { - model.modules.iter().any(|m| { - m.callables() - .any(|f| matches!(f.shape, CallShape::Iterator(_))) - }) +/// Emit the shared object helper every interface wrapper and every +/// object-typed argument routes through. `__borrow` unwraps a live wrapper +/// to the handle the callee borrows for the call (the wrapper keeps its own +/// reference); a closed wrapper or a foreign value is a programming error +/// reported through the generic brand with the marshalling code. +pub(crate) fn render_object_helpers_js(out: &mut String) { + out.push_str(&format!( + "// Borrow a live object wrapper's native handle for one call. The wrapper\n\ + // keeps its own reference; the producer clones if it retains the object.\n\ + function __borrow(o, cls) {{\n \ + if (!(o instanceof cls)) {{\n \ + throw new {ERROR_BRAND}(-3, 'expected an instance of ' + cls.name);\n \ + }}\n \ + if (!o._handle) {{\n \ + throw new {ERROR_BRAND}(-3, cls.name + ' used after close()');\n \ + }}\n \ + return o._handle;\n\ + }}\n\n" + )); } /// Emit the shared lazy iterator class the JS loader hands out for every diff --git a/crates/weaveffi-gen-node/src/types.rs b/crates/weaveffi-gen-node/src/types.rs index daf1caa6..01e0b800 100644 --- a/crates/weaveffi-gen-node/src/types.rs +++ b/crates/weaveffi-gen-node/src/types.rs @@ -12,14 +12,37 @@ use weaveffi_core::model::Ty; use weaveffi_core::model::{ErrorBinding, FnBinding, ParamBinding}; use weaveffi_core::utils::{local_type_name, wrapper_name}; -/// The exported JS name of a free function or listener endpoint: -/// [`wrapper_name`] (module-prefixed or stripped per config) converted to -/// lowerCamelCase, so module `kv`'s `open_store` exports as `openStore` -/// (stripped, the default) or `kvOpenStore`. +/// The exported JS name of a free function: [`wrapper_name`] +/// (module-prefixed or stripped per config) converted to lowerCamelCase, so +/// module `kv`'s `open_store` exports as `openStore` (stripped, the default) +/// or `kvOpenStore`. pub(crate) fn js_fn_name(module: &str, func: &str, strip: bool) -> String { wrapper_name(module, func, strip).to_lower_camel_case() } +/// The camelCase JS spelling of a callback-interface method as the consumer +/// implements it (`on_message` becomes `onMessage`). The addon looks methods +/// up on the generated adapter object by their raw IDL name, so only the +/// consumer-facing spelling routes through here. +pub(crate) fn js_method_name(name: &str) -> String { + name.to_lower_camel_case() +} + +/// The JS name of the adapter that bridges a consumer's callback-interface +/// implementation to the raw values the addon delivers (`__adaptListener`). +/// Cross-module references (`events.Listener`) use the local type name, the +/// same flat namespace the interface classes share. +pub(crate) fn js_adapter_name(cb_name: &str) -> String { + format!("__adapt{}", local_type_name(cb_name)) +} + +/// The addon-internal JS export of one interface's lifecycle entry point +/// (`{Interface}__clone` or `{Interface}__destroy`). The double underscore +/// keeps these clear of any user member named `clone` or `destroy`. +pub(crate) fn iface_lifecycle_base(iface: &str, op: &str) -> String { + format!("{iface}__{op}") +} + /// The camelCase JS spelling of an IDL parameter name, escaped when the /// camel-cased form collides with a JS/TS reserved word (a parameter named /// `import` becomes `import_`; property and method positions never need the @@ -42,32 +65,27 @@ pub(crate) fn js_str_literal(s: &str) -> String { .replace('\n', "\\n") } -/// The TS annotation of one IR type. +/// The TS annotation of one IR type. 64-bit integers are `bigint` everywhere +/// (parameters, returns, record fields, callback arguments) so no value above +/// 2^53 is silently rounded. pub(crate) fn ts_type_for(ty: &Ty) -> String { match ty { - Ty::I8 - | Ty::I16 - | Ty::U8 - | Ty::U16 - | Ty::I32 - | Ty::U32 - | Ty::I64 - | Ty::U64 - | Ty::F32 - | Ty::F64 => "number".into(), + Ty::I8 | Ty::I16 | Ty::U8 | Ty::U16 | Ty::I32 | Ty::U32 | Ty::F32 | Ty::F64 => { + "number".into() + } + Ty::I64 | Ty::U64 => "bigint".into(), Ty::Bool => "boolean".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "string".into(), - Ty::Bytes | Ty::BorrowedBytes => "Buffer".into(), - Ty::Handle => "bigint".into(), - // Records, rich enums, plain enums, interfaces, and typed handles - // surface as bare local TS names. A cross-module reference (e.g. - // `handle` resolved to `kv.Store`) must annotate the *local* - // type `Store`; the qualified IR name is not a declared TS type in - // this module. - Ty::TypedHandle(name) => local_type_name(name).to_string(), - Ty::Record(name) | Ty::RichEnum(name) => local_type_name(name).to_string(), - Ty::Interface(name) => local_type_name(name).to_string(), - Ty::Enum(name) => local_type_name(name).to_string(), + Ty::StringUtf8 => "string".into(), + Ty::Bytes => "Buffer".into(), + // Records, rich enums, plain enums, interfaces, and callback + // interfaces surface as bare local TS names. A cross-module reference + // (e.g. `kv.Store`) must annotate the *local* type `Store`; the + // qualified IR name is not a declared TS type in this module. + Ty::Record(name) + | Ty::RichEnum(name) + | Ty::Interface(name) + | Ty::CallbackInterface(name) + | Ty::Enum(name) => local_type_name(name).to_string(), Ty::Optional(inner) => format!("{} | null", ts_type_for(inner)), Ty::List(inner) => { let inner_ts = ts_type_for(inner); @@ -77,7 +95,7 @@ pub(crate) fn ts_type_for(ty: &Ty) -> String { format!("{inner_ts}[]") } } - Ty::Map(k, v) => format!("Record<{}, {}>", ts_type_for(k), ts_type_for(v)), + Ty::Map(k, v) => format!("Record<{}, {}>", ts_map_key_type(k), ts_type_for(v)), // `iter` is a lazy pull stream, not a materialized array. Ty::Iterator(inner) => { let t = ts_type_for(inner); @@ -86,6 +104,18 @@ pub(crate) fn ts_type_for(ty: &Ty) -> String { } } +/// The TS key type of a map. JS object keys are always strings at runtime; +/// `Record` and `Record` are the idiomatic spellings TS +/// accepts for numeric keys, but `Record` is not a valid TS type, +/// so 64-bit keys are annotated as their decimal string form (which is also +/// what `Object.keys` yields after decoding). +fn ts_map_key_type(ty: &Ty) -> String { + match ty { + Ty::I64 | Ty::U64 => "string".into(), + other => ts_type_for(other), + } +} + /// Emits a JSDoc comment at `indent`. Single-line docs collapse to /// `/** text */`; multi-line docs expand to a block with ` * ` prefixed lines. pub(crate) fn emit_doc(out: &mut String, doc: &Option, indent: &str) { diff --git a/crates/weaveffi-gen-python/Cargo.toml b/crates/weaveffi-gen-python/Cargo.toml index 4bab89a8..90ab4535 100644 --- a/crates/weaveffi-gen-python/Cargo.toml +++ b/crates/weaveffi-gen-python/Cargo.toml @@ -14,5 +14,9 @@ weaveffi-core = { path = "../weaveffi-core", version = "0.22.0" } camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-python/src/calls.rs b/crates/weaveffi-gen-python/src/calls.rs index 6b2a0065..4a865f0a 100644 --- a/crates/weaveffi-gen-python/src/calls.rs +++ b/crates/weaveffi-gen-python/src/calls.rs @@ -1,10 +1,12 @@ -//! Call rendering: sync, async, and iterator wrappers, plus callback -//! typedefs and listener register/unregister pairs. +//! Call rendering: sync, async, and iterator wrappers, plus the +//! consumer-facing abstract base class, static vtable, and trampolines of +//! each callback interface. //! //! Marshalling dispatch is driven by the shared plans: [`ArgPass`] decides -//! how each parameter crosses, [`RetPass`] how the result comes back, and -//! [`plan::elem_free`] what an iterator step owes, so this module never -//! re-derives those shapes from `Ty` matches. +//! how each parameter crosses, [`RetPass`] how a result (or a trampoline +//! argument) is received, [`plan::elem_free`] what an iterator step owes, and +//! [`CallbackProtocol`] how each trampoline receives its arguments, so this +//! module never re-derives those shapes from `Ty` matches. use weaveffi_core::abi; use weaveffi_core::codegen::common::pascal_case; @@ -12,22 +14,22 @@ use weaveffi_core::codegen::CodeWriter; use weaveffi_core::lang; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - CallShape, CallbackBinding, ErrorBinding, FnBinding, ListenerBinding, ModuleBinding, - ParamBinding, + CallShape, CallbackInterfaceBinding, CallbackMethodBinding, ErrorBinding, FnBinding, + ModuleBinding, ParamBinding, }; use weaveffi_core::model::{Prim, WireType}; -use weaveffi_core::plan::{self, ArgPass, ElemFree, ErrorStrategy, RetPass}; +use weaveffi_core::plan::{self, ArgPass, CallbackProtocol, ErrorStrategy, Free, RetPass}; use weaveffi_core::utils::local_type_name; use crate::codec::{ py_decode_borrowed_expr, py_decode_owned_expr, py_pack_fn_name, py_read_expr, py_unpack_fn_name, py_write_stmts, }; -use crate::docs::{emit_doc, emit_docstring, emit_fn_docstring}; +use crate::docs::{emit_docstring, emit_fn_docstring}; use crate::entities::{py_checker_name, py_error_factory_name}; use crate::types::{ - py_callable_hint, py_ctype, py_ctypes_scalar, py_member_name, py_name, py_param_argtypes, - py_return_info, py_type_hint, py_wrapper_fn_name, + py_ctype, py_ctypes_scalar, py_member_name, py_name, py_param_argtypes, py_return_info, + py_type_hint, py_vtable_class_name, py_vtable_instance_name, py_wrapper_fn_name, }; /// How a rendered callable is scoped and spelled in the generated Python. @@ -257,7 +259,7 @@ pub(crate) fn render_callable( let mut call_args: Vec = Vec::new(); if scope.has_self_slot() { - call_args.push("self._ptr".into()); + call_args.push("_borrow(self)".into()); } for p in &f.params { call_args.extend(py_param_call_args(p)); @@ -317,10 +319,15 @@ pub(crate) fn render_callable( /// The statements preparing one parameter's locals ahead of the C call: a /// packed `_{name}_buf` for buffered types, a `_{name}_arr` element array -/// for bytes. Direct types need no preparation. +/// for bytes, a `_{name}_ctx` handle-table key for a callback interface. +/// Direct, string, and object types need no preparation. fn py_param_conversion(p: &ParamBinding, ind: &str) -> Vec { let name = py_name(&p.name); match p.arg_pass() { + // Register the implementation in the handle table; the integer key + // is what the producer receives as `ctx` and hands back to every + // trampoline and, finally, to `free`. + ArgPass::Callback { .. } => vec![format!("{ind}_{name}_ctx = _cb_register({name})")], // Records and rich enums pack through their dedicated helpers; other // buffered shapes (optionals, lists, maps) inline their write // statements through a per-parameter writer (so several buffered @@ -357,20 +364,31 @@ fn py_param_call_args(p: &ParamBinding) -> Vec { ArgPass::Buffer { .. } => vec![format!("_{name}_buf"), format!("len(_{name}_buf)")], ArgPass::Bytes { .. } => vec![format!("_{name}_arr"), format!("len({name})")], ArgPass::String { .. } => vec![format!("_string_to_bytes({name})")], - // Interface parameters are borrowed: pass the wrapper's raw pointer; - // the callee never takes ownership. A nullable `Interface?` maps - // `None` onto a null pointer. + // Interface parameters are borrowed: lend the wrapper's raw pointer + // and keep the wrapper's own reference; the producer clones if it + // retains the object. A nullable `Interface?` maps `None` onto a + // null pointer. ArgPass::Object { nullable: false, .. - } => vec![format!("{name}._ptr")], + } => vec![format!("_borrow({name})")], ArgPass::Object { nullable: true, .. } => { - vec![format!("{name}._ptr if {name} is not None else None")] + vec![format!("(_borrow({name}) if {name} is not None else None)")] + } + // The handle-table key as `ctx`, and the address of the interface's + // one static vtable. + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback family names a callback interface"); + vec![ + format!("_{name}_ctx"), + format!("ctypes.byref({})", py_vtable_instance_name(cb)), + ] } ArgPass::Direct { .. } => match &p.ty { Ty::Bool => vec![format!("1 if {name} else 0")], Ty::Enum(_) => vec![format!("{name}.value")], - // Scalars, handles, and typed handles are already the raw slot - // value the C signature wants. + // Scalars are already the raw slot value the C signature wants. _ => vec![name], }, } @@ -380,11 +398,8 @@ fn py_param_call_args(p: &ParamBinding) -> Vec { /// The interface name behind a direct or optional interface return. fn object_interface_name(ty: &Ty) -> &str { - match ty { - Ty::Interface(name) => name, - Ty::Optional(inner) => object_interface_name(inner), - _ => unreachable!("object returns are direct or optional interfaces"), - } + ty.interface_name() + .expect("object returns are direct or optional interfaces") } /// Append the statements converting `_result` (and `_out_len` when present) @@ -443,11 +458,6 @@ fn render_return_value(out: &mut String, ty: &Ty, ind: &str) { w.line(format!("return {name}._from_ptr(_result)")); } RetPass::Direct => match ty { - // The `c_void_p` restype surfaces null as `None`; normalize to - // the integer handle representation. - Ty::TypedHandle(_) => { - w.line("return _result or 0"); - } Ty::Bool => { w.line("return bool(_result)"); } @@ -548,10 +558,6 @@ fn append_async_success_handler(out: &mut String, ret: &Option, ind: &str) { } } RetPass::Direct => match ty { - // A typed handle's `c_void_p` slot surfaces as `int | None`. - Ty::TypedHandle(_) => { - out.push_str(&format!("{ind}_state[\"val\"] = result or 0\n")); - } Ty::Bool => { out.push_str(&format!("{ind}_state[\"val\"] = bool(result)\n")); } @@ -703,7 +709,7 @@ fn render_async_ffi_call_body( let mut call_args: Vec = Vec::new(); if has_self { - call_args.push("self._ptr".into()); + call_args.push("_borrow(self)".into()); } for p in &f.params { call_args.extend(py_param_call_args(p)); @@ -730,9 +736,9 @@ fn render_async_ffi_call_body( /// so they survive to be freed, per [`plan::elem_free`]. fn py_iter_slots(inner: &Ty) -> (String, bool) { match plan::elem_free(inner) { - ElemFree::Bytes => ("ctypes.c_void_p".into(), true), - ElemFree::String => ("ctypes.c_void_p".into(), false), - ElemFree::None => (py_ctypes_scalar(inner).into(), false), + Free::Bytes => ("ctypes.c_void_p".into(), true), + Free::String => ("ctypes.c_void_p".into(), false), + Free::None => (py_ctypes_scalar(inner).into(), false), } } @@ -744,7 +750,7 @@ fn py_read_iter_item(inner: &Ty) -> String { // A `ptr` + `len` element: copy the bytes, free them with // `weaveffi_free_bytes` (via `_take_buffer`), then decode any // buffered value. - ElemFree::Bytes => match inner.wire() { + Free::Bytes => match inner.wire() { WireType::Prim(Prim::Bytes) => "_take_buffer(_out_item.value, _out_len.value)".into(), WireType::User(name) => format!( "{}(_take_buffer(_out_item.value, _out_len.value))", @@ -757,16 +763,21 @@ fn py_read_iter_item(inner: &Ty) -> String { }, // Owned string element: copy, then `weaveffi_free_string`. The out // slot is a raw `c_void_p`, so the address survives to be freed. - ElemFree::String => "_take_string(_out_item.value)".into(), - ElemFree::None => match inner { + Free::String => "_take_string(_out_item.value)".into(), + Free::None => match inner { Ty::Enum(name) => { format!("{}(_out_item.value)", local_type_name(name)) } - // An owned interface element is adopted by its wrapper class. + // An owned interface element transfers one strong reference, + // adopted by its wrapper class. A nullable element maps null to + // `None`. Ty::Interface(name) => { format!("{}._from_ptr(_out_item.value)", local_type_name(name)) } - Ty::TypedHandle(_) => "_out_item.value or 0".into(), + Ty::Optional(obj) if matches!(obj.as_ref(), Ty::Interface(_)) => format!( + "({}._from_ptr(_out_item.value) if _out_item.value else None)", + local_type_name(object_interface_name(inner)) + ), Ty::Bool => "bool(_out_item.value)".into(), _ => "_out_item.value".into(), }, @@ -868,156 +879,277 @@ pub(crate) fn render_iterator_class( out.push('\n'); } -// ── Callbacks & listeners ── +// ── Callback interfaces ── -/// The module-level `ctypes.CFUNCTYPE` alias for one callback type. Listener -/// registration binds against this; the C side sees the matching -/// `typedef void (*{c_fn_type})(…, void* context)`. -pub(crate) fn render_callback_type(out: &mut String, c: &CallbackBinding) { - let mut parts: Vec = vec!["None".into()]; - parts.extend(c.abi_params.iter().map(|p| py_ctype(&p.ty))); - let mut w = CodeWriter::four_space(); - w.blank().blank(); - let mut doc = String::new(); - emit_doc(&mut doc, &c.doc, ""); - w.raw(doc); - w.line(format!( - "# Callback type {}: {}", - c.name, - py_callable_hint(&c.params) - )); - w.line(format!( - "_CFUNC_{} = ctypes.CFUNCTYPE({})", - c.c_fn_type, - parts.join(", ") - )); - out.push_str(&w.finish()); +/// The Python spelling of a trampoline slot name: the ABI slot name, +/// keyword-escaped. Derived slots (`{name}_ptr`, `out_err`) never collide +/// with a keyword, so only single-slot parameters can change spelling. +fn py_slot_name(slot: &str) -> String { + lang::escape_ident(slot, lang::PYTHON_KEYWORDS) } -/// The Python expression converting one trampoline parameter's C slots into -/// the idiomatic value passed to the user callback. Slot names derive from -/// the raw IR parameter name (mirroring the ABI lowering), so multi-slot -/// references keep the raw stem while single-slot references use the -/// keyword-escaped spelling the trampoline declares. -fn py_cb_param_expr(p: &ParamBinding) -> String { +/// The expression converting one trampoline parameter's borrowed (or, for +/// objects, transferred) C slots into the idiomatic value handed to the +/// implementation, per the receiving contract in `pass`. +fn py_trampoline_arg_expr(p: &ParamBinding, pass: &RetPass) -> String { let n = &p.name; - let esc = lang::escape_ident(n, lang::PYTHON_KEYWORDS); - match p.arg_pass() { - // A buffered argument arrives as a borrowed `({n}_ptr, {n}_len)` - // pair, valid only during the dispatch; copy and decode before - // invoking the user callable. - ArgPass::Buffer { .. } => { - py_decode_borrowed_expr(&format!("{n}_ptr"), &format!("{n}_len"), &p.ty) + let esc = py_slot_name(n); + match pass { + // A buffered argument is a borrowed `({n}_ptr, {n}_len)` pair valid + // only during the dispatch: copy and decode before returning. Object + // tokens inside are adopted by the decoded wrappers. + RetPass::Buffer => py_decode_borrowed_expr(&format!("{n}_ptr"), &format!("{n}_len"), &p.ty), + RetPass::Bytes => { + format!("(ctypes.string_at({n}_ptr, {n}_len) if {n}_ptr else b\"\")") } - ArgPass::Bytes { .. } => { - format!("bytes({n}_ptr[:{n}_len]) if {n}_ptr else b\"\"") + // `c_char_p` already delivered a `bytes` copy of the borrowed string. + RetPass::String => format!("_bytes_to_string({esc})"), + // An object argument transfers one strong reference: adopt it into a + // wrapper whose disposal owes the destroy symbol. Null is `None` for + // an `Interface?` slot. + RetPass::Object { nullable, .. } => { + let class = local_type_name(object_interface_name(&p.ty)); + if *nullable { + format!("({class}._from_ptr({esc}) if {esc} else None)") + } else { + format!("{class}._from_ptr({esc})") + } } - ArgPass::String { .. } => format!("_bytes_to_string({esc})"), - // Borrowed by contract: the producer owns callback arguments for the - // duration of the call, so interface references and nullable - // `Interface?` slots pass through raw rather than being wrapped in - // an owning class whose `__del__` would free them. - ArgPass::Object { .. } => esc, - ArgPass::Direct { .. } => match &p.ty { + RetPass::Direct => match &p.ty { Ty::Bool => format!("bool({esc})"), Ty::Enum(name) => format!("{}({esc})", local_type_name(name)), - // Scalars, handles, and typed handles pass through unchanged. _ => esc, }, + RetPass::Void => unreachable!("parameters are never void"), + } +} + +/// `(coercion, default)` for a callback method's direct return: the Python +/// conversion applied to the implementation's result before it is written +/// into the C return slot, and the value returned after a failure has been +/// reported through `out_err`. `None` for a void method. +fn py_trampoline_return(ret: Option<&Ty>) -> Option<(&'static str, &'static str)> { + match ret { + None => None, + Some(Ty::Bool) => Some(("1 if {} else 0", "0")), + Some(Ty::F32 | Ty::F64) => Some(("float({})", "0.0")), + // Integers and C-style enums (an `IntEnum` is an `int`). + Some(_) => Some(("int({})", "0")), } } -/// Register/unregister wrapper pair for one listener. The trampoline converts -/// each C slot to its idiomatic value, and the `ctypes` function object is -/// pinned in `_listener_refs` until `unregister` so the producer never calls -/// a collected trampoline. -pub(crate) fn render_listener( +/// The module-level stems of one callback method's trampoline objects: +/// `_{Name}_{method}_cfunctype`, `_{Name}_{method}_trampoline`, and +/// `_{Name}_{method}_cfunc`. +fn py_trampoline_stem(cb: &CallbackInterfaceBinding, method: &str) -> String { + format!("_{}_{method}", cb.name) +} + +/// Render one callback interface: the abstract base class the consumer +/// subclasses, then the ABI side that satisfies every clause of +/// [`CallbackProtocol`]: one `CFUNCTYPE` per method plus the trailing `free`, +/// the `ctypes.Structure` mirroring the C vtable, one trampoline per method, +/// and the single process-wide static vtable instance whose function objects +/// are pinned at module scope for the process lifetime. +/// +/// Each trampoline looks its implementation up by the integer `ctx`, decodes +/// borrowed string, bytes, and buffer arguments (freeing nothing), adopts +/// object arguments, and calls the method. Any exception is reported through +/// `{prefix}_error_set(out_err, -4, message)` and a default value is +/// returned, so nothing ever unwinds through the C frame. `free` removes the +/// handle-table entry. `ctypes` acquires the GIL on entry, so the producer +/// may call from any thread. +pub(crate) fn render_callback_interface( out: &mut String, module: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, + cb: &CallbackInterfaceBinding, + prefix: &str, ) { - let Some(cb) = module.callbacks.iter().find(|c| c.name == l.event_callback) else { - // Validation guarantees the referenced callback exists in-module. - unreachable!("listener '{}' references unknown callback", l.name); - }; - let register_name = py_wrapper_fn_name( - &module.path, - &format!("register_{}", l.name), - strip_module_prefix, - ); - let unregister_name = py_wrapper_fn_name( - &module.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ); - let cfunc = format!("_CFUNC_{}", cb.c_fn_type); - let ind = " "; + let protocol: CallbackProtocol<'_> = cb.protocol(&module.path, prefix); + let name = &cb.name; + let vtable_class = py_vtable_class_name(name); + let vtable_instance = py_vtable_instance_name(name); + let free_stem = format!("_{name}_vtable_free"); let mut w = CodeWriter::four_space(); - // register_{listener}(callback) -> int + // The consumer-facing abstract base class. w.blank().blank(); - w.line(format!( - "def {register_name}(callback: {}) -> int:", - py_callable_hint(&cb.params) - )); + w.line(format!("class {name}(abc.ABC):")); w.indent(); - let reg_doc = match &l.doc { - Some(d) => format!( - "{}\n\nReturns a subscription id for {unregister_name}().", - d.trim() - ), - None => format!( - "Register a {} listener. Returns a subscription id for {unregister_name}().", - cb.name - ), + let base_doc = "Consumer-implemented callback interface. Subclass it, implement every \ + abstract method, and pass an instance wherever the API takes a \ + `{name}`; the producer may call the methods from any thread until it \ + releases the instance. An exception raised by a method is reported to \ + the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the \ + call that was in progress." + .replace("{name}", name); + let class_doc = match &cb.doc { + Some(d) if !d.trim().is_empty() => format!("{}\n\n{base_doc}", d.trim()), + _ => base_doc, }; let mut doc = String::new(); - emit_docstring(&mut doc, &Some(reg_doc), ind); + emit_docstring(&mut doc, &Some(class_doc), &w.indent_str()); w.raw(doc); - - let tramp_params: Vec = cb - .params - .iter() - .flat_map(|p| { - p.abi + for m in &cb.methods { + let mut sig: Vec = vec!["self".into()]; + sig.extend( + m.params .iter() - .map(|slot| lang::escape_ident(&slot.name, lang::PYTHON_KEYWORDS)) - }) - .chain(std::iter::once("_context".to_string())) - .collect(); - let call_args: Vec = cb.params.iter().map(py_cb_param_expr).collect(); - w.line(format!("def _trampoline({}):", tramp_params.join(", "))); + .map(|p| format!("{}: {}", py_name(&p.name), py_type_hint(&p.ty))), + ); + let ret_hint = m + .ret + .as_ref() + .map(py_type_hint) + .unwrap_or_else(|| "None".to_string()); + w.blank(); + w.line("@abc.abstractmethod"); + w.line(format!( + "def {}({}) -> {ret_hint}:", + py_member_name(&m.name), + sig.join(", ") + )); + w.indent(); + let mut mdoc = String::new(); + emit_fn_docstring(&mut mdoc, &m.doc, &m.params, &w.indent_str(), None); + if mdoc.is_empty() { + w.line("..."); + } else { + w.raw(mdoc); + } + w.dedent(); + } + w.dedent(); + + // One CFUNCTYPE per vtable entry, in declaration order, then `free`. + w.blank().blank(); + for m in &cb.methods { + let parts: Vec = std::iter::once(py_ctype(&m.abi_ret)) + .chain(m.abi_params.iter().map(|p| py_ctype(&p.ty))) + .collect(); + w.line(format!( + "{}_cfunctype = ctypes.CFUNCTYPE({})", + py_trampoline_stem(cb, &m.name), + parts.join(", ") + )); + } + w.line(format!( + "{free_stem}_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p)" + )); + + // The vtable layout: the C struct `{vtable_tag}`. + w.blank().blank(); + w.line(format!("class {vtable_class}(ctypes.Structure):")); + w.scope(|w| { + w.line(format!("\"\"\"The C vtable `{}`.\"\"\"", cb.vtable_tag)); + w.blank(); + w.line("_fields_ = ["); + w.scope(|w| { + for m in &cb.methods { + w.line(format!( + "(\"{}\", {}_cfunctype),", + m.name, + py_trampoline_stem(cb, &m.name) + )); + } + w.line(format!("(\"free\", {free_stem}_cfunctype),")); + }); + w.line("]"); + }); + + // One trampoline per method. + for (m, args) in cb.methods.iter().zip(&protocol.method_args) { + render_callback_trampoline(&mut w, cb, m, args); + } + + w.blank().blank(); + w.line(format!("def {free_stem}_trampoline(ctx):")); w.scope(|w| { - w.line(format!("callback({})", call_args.join(", "))); + w.line("# The producer's last reference is gone; it never touches `ctx` again."); + w.line("_cb_impls.pop(ctx, None)"); }); - w.line(format!("_cfunc = {cfunc}(_trampoline)")); - w.line(format!("_fn = _lib.{}", l.register_symbol)); - w.line(format!("_fn.argtypes = [{cfunc}, ctypes.c_void_p]")); - w.line("_fn.restype = ctypes.c_uint64"); - w.line("_listener_id = int(_fn(_cfunc, None))"); - w.line("_listener_refs[_listener_id] = _cfunc"); - w.line("return _listener_id"); - w.dedent(); - // unregister_{listener}(listener_id) -> None + // The single static vtable, with every function object pinned. w.blank().blank(); - w.line(format!("def {unregister_name}(listener_id: int) -> None:")); - w.indent(); - let mut unreg_doc = String::new(); - emit_docstring( - &mut unreg_doc, - &Some(format!( - "Unregister a listener previously registered with {register_name}()." - )), - ind, - ); - w.raw(unreg_doc); - w.line(format!("_fn = _lib.{}", l.unregister_symbol)); - w.line("_fn.argtypes = [ctypes.c_uint64]"); - w.line("_fn.restype = None"); - w.line("_fn(ctypes.c_uint64(listener_id))"); - w.line("_listener_refs.pop(listener_id, None)"); + w.line(format!( + "# The one process-wide vtable for {name}. The CFUNCTYPE objects are held at" + )); + w.line("# module scope so their function pointers stay valid for the process lifetime."); + for m in &cb.methods { + let stem = py_trampoline_stem(cb, &m.name); + w.line(format!( + "{stem}_cfunc = {stem}_cfunctype({stem}_trampoline)" + )); + } + w.line(format!( + "{free_stem}_cfunc = {free_stem}_cfunctype({free_stem}_trampoline)" + )); + let entries: Vec = cb + .methods + .iter() + .map(|m| format!("{}_cfunc", py_trampoline_stem(cb, &m.name))) + .chain(std::iter::once(format!("{free_stem}_cfunc"))) + .collect(); + w.line(format!( + "{vtable_instance} = {vtable_class}({})", + entries.join(", ") + )); + out.push_str(&w.finish()); } + +/// Render the trampoline for one callback method: the `def` whose parameters +/// are the vtable entry's C slots (`ctx`, the parameter slots, `out_err`), +/// receiving each argument per `args` and reporting any exception through +/// `error_set` with the foreign error code. +fn render_callback_trampoline( + w: &mut CodeWriter, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + args: &[RetPass], +) { + let stem = py_trampoline_stem(cb, &m.name); + let slots: Vec = m.abi_params.iter().map(|p| py_slot_name(&p.name)).collect(); + let call_args: Vec = m + .params + .iter() + .zip(args) + .map(|(p, pass)| py_trampoline_arg_expr(p, pass)) + .collect(); + let call = format!( + "_impl.{}({})", + py_member_name(&m.name), + call_args.join(", ") + ); + let ret = py_trampoline_return(m.ret.as_ref()); + + w.blank().blank(); + w.line(format!("def {stem}_trampoline({}):", slots.join(", "))); + w.scope(|w| { + w.line("try:"); + w.scope(|w| { + w.line("_impl = _cb_impls[ctx]"); + match ret { + Some((coerce, _)) => { + w.line(format!("_ret = {call}")); + w.line(format!("return {}", coerce.replace("{}", "_ret"))); + } + None => { + w.line(call); + } + } + }); + w.line("except Exception as _exc:"); + w.scope(|w| { + w.line("# Never unwind through the C frame: report the failure and return a"); + w.line("# default; the producer aborts its call with FOREIGN_ERROR_CODE."); + w.line( + "_lib.weaveffi_error_set(out_err, -4, str(_exc).encode(\"utf-8\", \"replace\"))", + ); + if let Some((_, default)) = ret { + w.line(format!("return {default}")); + } + }); + }); +} diff --git a/crates/weaveffi-gen-python/src/codec.rs b/crates/weaveffi-gen-python/src/codec.rs index 6415eef4..abffaee9 100644 --- a/crates/weaveffi-gen-python/src/codec.rs +++ b/crates/weaveffi-gen-python/src/codec.rs @@ -2,9 +2,9 @@ //! statements for any wire shape, plus the per-record and per-rich-enum //! codec functions (`_write_X`, `_read_X`, `_pack_X`, `_unpack_X`). //! -//! Every dispatch here goes through [`wire::classify`], so this module never -//! re-derives the wire folds (handles as `u64` tokens, borrowed views as -//! their owned forms, records and rich enums as one user-codec shape). +//! Every dispatch here goes through [`Ty::wire`], so this module never +//! re-derives the wire folds (interfaces as `u64` object tokens carrying one +//! strong reference, records and rich enums as one user-codec shape). use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; @@ -59,8 +59,13 @@ pub(crate) fn py_read_expr(ty: &Ty, depth: usize) -> String { WireType::Prim(Prim::U64) => "_r.read_u64()".into(), WireType::Prim(Prim::F32) => "_r.read_f32()".into(), WireType::Prim(Prim::F64) => "_r.read_f64()".into(), - // Handles serialize as u64 tokens inside buffers. - WireType::Handle(_) => "_r.read_u64()".into(), + // An object token carries one strong reference: adopt it into a new + // wrapper, whose disposal owes the interface's destroy symbol. A + // decode that fails later in the buffer drops the wrapper, and its + // finalizer releases the adopted reference. + WireType::Object(name) => { + format!("{}._from_ptr(_r.read_object())", local_type_name(name)) + } WireType::Prim(Prim::String) => "_r.read_string()".into(), WireType::Prim(Prim::Bytes) => "_r.read_bytes()".into(), WireType::Enum(name) => format!("{}(_r.read_i32())", local_type_name(name)), @@ -119,8 +124,12 @@ pub(crate) fn py_write_stmts(w: &mut CodeWriter, writer: &str, expr: &str, ty: & WireType::Prim(Prim::F64) => { w.line(format!("{writer}.write_f64({expr})")); } - WireType::Handle(_) => { - w.line(format!("{writer}.write_u64({expr})")); + // The token written must be a reference the reader can own: the + // writer clones the wrapper's pointer (a new strong reference to the + // same object) when the buffer is finished, never writing the + // pointer the wrapper still holds. + WireType::Object(_) => { + w.line(format!("{writer}.write_object({expr})")); } WireType::Prim(Prim::String) => { w.line(format!("{writer}.write_string({expr})")); @@ -164,9 +173,11 @@ pub(crate) fn py_write_stmts(w: &mut CodeWriter, writer: &str, expr: &str, ty: & } } -/// The expression decoding a borrowed `(ptr, len)` buffer pair (a callback -/// or listener argument) into its idiomatic value. The producer owns the -/// buffer for the dispatch, so the bytes are copied before decoding. +/// The expression decoding a borrowed `(ptr, len)` buffer pair (a +/// callback-interface method argument) into its idiomatic value. The producer +/// owns the buffer for the dispatch, so the bytes are copied before decoding; +/// any object tokens inside still transfer one strong reference, which the +/// decoded wrappers adopt. pub(crate) fn py_decode_borrowed_expr(ptr: &str, len: &str, ty: &Ty) -> String { let data = format!("ctypes.string_at({ptr}, {len}) if {ptr} else b\"\""); match ty.wire() { diff --git a/crates/weaveffi-gen-python/src/entities.rs b/crates/weaveffi-gen-python/src/entities.rs index af4c0bdf..7450264d 100644 --- a/crates/weaveffi-gen-python/src/entities.rs +++ b/crates/weaveffi-gen-python/src/entities.rs @@ -371,14 +371,17 @@ fn render_dataclass_fields(w: &mut CodeWriter, fields: &[FieldBinding]) { // ── Interfaces ── -/// Render one interface as an opaque-object wrapper class, following the -/// struct wrapper's ownership pattern: the class owns the raw C pointer and -/// releases it exactly once, calling the interface's destroy symbol from -/// `__del__`. A constructor named `new` becomes `__init__`; every other -/// constructor becomes a `@classmethod` factory; methods pass `self._ptr` as -/// the leading C argument; statics are `@staticmethod`s. `_from_ptr` wraps a -/// pointer the producer already handed over (a C return value) without -/// re-running the FFI constructor. +/// Render one interface as a reference-counted object wrapper class. The +/// class holds one strong reference (the raw C pointer) and releases it +/// exactly once through the interface's destroy symbol, from `close()`, the +/// context-manager exit, or the `__del__` backstop. `_from_ptr` adopts a +/// pointer the producer handed over (a return, an async result, an iterator +/// element, or a buffer token); `_clone_ptr` produces a second strong +/// reference through the clone symbol, which the buffer codec uses when it +/// writes the object into a value buffer. A constructor named `new` becomes +/// `__init__`; every other constructor becomes a `@classmethod` factory; +/// methods lend `self` as the leading borrowed C argument; statics are +/// `@staticmethod`s. pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &InterfaceBinding) { let error = module.error.as_ref(); @@ -398,13 +401,26 @@ pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &Int } } - out.push_str(&format!("\n\nclass {}:\n", i.name)); + let name = &i.name; + let clone = &i.clone_symbol; + let destroy = &i.destroy_symbol; + + // The reference-count pair is bound once, at module scope, so the + // wrapper's disposal path never touches the binding tables. + out.push_str(&format!( + "\n\n_lib.{clone}.argtypes = [ctypes.c_void_p]\n\ + _lib.{clone}.restype = ctypes.c_void_p\n\ + _lib.{destroy}.argtypes = [ctypes.c_void_p]\n\ + _lib.{destroy}.restype = None\n" + )); + + out.push_str(&format!("\n\nclass {name}:\n")); emit_docstring(out, &i.doc, " "); out.push_str(&format!( - "\n @classmethod\n def _from_ptr(cls, ptr) -> \"{}\":", - i.name + "\n @classmethod\n def _from_ptr(cls, ptr) -> \"{name}\":" )); + out.push_str("\n \"\"\"Adopt one strong reference the producer handed over.\"\"\""); out.push_str("\n _obj = cls.__new__(cls)"); out.push_str("\n _obj._ptr = ptr"); out.push_str("\n return _obj"); @@ -413,21 +429,38 @@ pub(crate) fn render_interface(out: &mut String, module: &ModuleBinding, i: &Int if let Some(c) = new_ctor { render_callable(out, c, error, &FnScope::Init); } else { - // No canonical constructor: expose the same raw-pointer `__init__` - // the struct wrappers use, so factories stay the only public path. - out.push_str("\n\n def __init__(self, _ptr: int) -> None:"); - out.push_str("\n self._ptr = _ptr\n"); + // No canonical constructor: instances only come from factories and + // producer returns, so `__init__` is not a public path. + out.push_str("\n\n def __init__(self) -> None:"); + out.push_str(&format!( + "\n raise TypeError(\"{name} cannot be instantiated directly\")\n" + )); } - let destroy = &i.destroy_symbol; + out.push_str("\n\n def _clone_ptr(self):"); + out.push_str("\n \"\"\"A new strong reference to the same object (a raw pointer the"); + out.push_str("\n receiver must eventually release), leaving this wrapper's own"); + out.push_str("\n reference untouched.\"\"\""); + out.push_str(&format!("\n return _lib.{clone}(_borrow(self))")); + + out.push_str("\n\n def close(self) -> None:"); + out.push_str("\n \"\"\"Release this wrapper's reference. The object itself is dropped"); + out.push_str("\n when its last reference (here or in the producer) is released."); + out.push_str("\n Idempotent; the wrapper is unusable afterwards.\"\"\""); + out.push_str("\n _p = getattr(self, \"_ptr\", None)"); + out.push_str("\n self._ptr = None"); + out.push_str("\n if _p is not None:"); + out.push_str(&format!("\n _lib.{destroy}(_p)")); + + out.push_str(&format!("\n\n def __enter__(self) -> \"{name}\":")); + out.push_str("\n return self"); + + out.push_str("\n\n def __exit__(self, *exc) -> bool:"); + out.push_str("\n self.close()"); + out.push_str("\n return False"); + out.push_str("\n\n def __del__(self) -> None:"); - out.push_str("\n if self._ptr is not None:"); - out.push_str(&format!( - "\n _lib.{destroy}.argtypes = [ctypes.c_void_p]" - )); - out.push_str(&format!("\n _lib.{destroy}.restype = None")); - out.push_str(&format!("\n _lib.{destroy}(self._ptr)")); - out.push_str("\n self._ptr = None"); + out.push_str("\n self.close()"); for c in &i.constructors { if c.name != "new" { diff --git a/crates/weaveffi-gen-python/src/lib.rs b/crates/weaveffi-gen-python/src/lib.rs index 77675511..e8433920 100644 --- a/crates/weaveffi-gen-python/src/lib.rs +++ b/crates/weaveffi-gen-python/src/lib.rs @@ -1,9 +1,14 @@ //! Python (`ctypes`) binding generator for WeaveFFI. //! //! Emits a pip-installable package containing `ctypes`-based bindings and -//! `.pyi` type stubs over the C ABI. Async functions surface as -//! `async def` wrappers. Implements [`LanguageBackend`]; the shared driver -//! bridges it into the generator pipeline. +//! `.pyi` type stubs over the C ABI (revision 2). Records and rich enums are +//! dataclasses crossing the boundary as value buffers; interfaces are +//! reference-counted wrapper classes with `close()` and a `__del__` backstop; +//! callback interfaces are abstract base classes the consumer subclasses, +//! backed by one static vtable of `ctypes` trampolines per interface; async +//! functions surface as `async def` wrappers and `iter` returns as lazy +//! Python iterators. Implements [`LanguageBackend`]; the shared driver bridges +//! it into the generator pipeline. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] @@ -16,6 +21,8 @@ mod entities; mod package; mod runtime; mod stubs; +#[cfg(test)] +mod tests; mod types; use camino::Utf8Path; @@ -23,26 +30,28 @@ use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; use weaveffi_core::model::{ - BindingModel, CallbackBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, - ListenerBinding, ModuleBinding, StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + ModuleBinding, StructBinding, }; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; -use crate::calls::{render_callable, render_callback_type, render_listener, FnScope}; +use crate::calls::{render_callable, render_callback_interface, FnScope}; use crate::entities::{render_enum, render_error, render_interface, render_struct}; use crate::package::{ - render_packaged_readme, render_packaged_setup_py, render_pyproject_toml, render_readme, - render_setup_py, + render_packaged_readme, render_packaged_setup_py, render_py_typed, render_pyproject_toml, + render_readme, render_setup_py, +}; +use crate::runtime::{ + py_loader_packaged, render_feature_runtime, render_preamble, PY_LOADER_ORIGINAL, }; -use crate::runtime::{py_loader_packaged, render_preamble, PY_LOADER_ORIGINAL}; use crate::stubs::render_pyi_module; /// Per-target configuration for [`PythonGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct PythonConfig { /// pip-installable Python package name (default `"weaveffi"`). Also /// determines the on-disk package directory inside `python/`. @@ -97,57 +106,16 @@ pub struct PythonGenerator; impl PythonGenerator { /// Render the primary `weaveffi.py` source by composing the shared /// [`LanguageBackend::emit_members`] walk over every module. - fn render_py_source( - &self, - model: &BindingModel, - strip_module_prefix: bool, - input_basename: &str, - ) -> String { - let config = PythonConfig { - strip_module_prefix, - ..PythonConfig::default() - }; - let mut out = render_prelude(CommentStyle::Hash, input_basename); + fn render_py_source(&self, model: &BindingModel, config: &PythonConfig) -> String { + let mut out = render_prelude(CommentStyle::Hash, config.input_basename()); render_preamble(&mut out); - let has_async = model - .modules - .iter() - .flat_map(|m| m.callables()) - .any(|f| f.is_async); - if has_async { - out.push_str( - "\nimport asyncio\nimport threading\n\n\n\ - # Pending async completion trampolines, keyed by an integer token.\n\ - # Holding the ctypes function objects here keeps them alive until the\n\ - # producer fires the completion callback, even when the awaiting\n\ - # coroutine has been cancelled; each entry is removed on completion.\n\ - _async_pending: Dict[int, object] = {}\n\ - _async_lock = threading.Lock()\n\ - _async_next_token = 0\n\n\n\ - def _async_register(cb) -> int:\n \ - global _async_next_token\n \ - with _async_lock:\n \ - _async_next_token += 1\n \ - _token = _async_next_token\n \ - _async_pending[_token] = cb\n \ - return _token\n", - ); - } - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_listeners { - out.push_str( - "\n\n# Registered listener trampolines, keyed by subscription id. Holding\n\ - # the ctypes function objects here keeps them alive until unregistered;\n\ - # without this the GC could collect a trampoline the producer still calls.\n\ - _listener_refs: Dict[int, object] = {}\n", - ); - } + render_feature_runtime(&mut out, model.has_async(), model.has_callback_interfaces()); // The model is a flat, pre-order list of modules, each carrying its // joined symbol path, the same traversal order the recursive walk // produced. for m in &model.modules { out.push_str(&format!("\n\n# === Module: {} ===", m.path)); - self.emit_members(&mut out, m, &config); + self.emit_members(&mut out, m, config); } out.push('\n'); out.push_str(&render_trailer(CommentStyle::Hash, "weaveffi.py")); @@ -162,7 +130,7 @@ impl LanguageBackend for PythonGenerator { "python" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -204,24 +172,14 @@ impl LanguageBackend for PythonGenerator { render_struct(out, s); } - fn render_callback( - &self, - out: &mut String, - _module: &ModuleBinding, - c: &CallbackBinding, - _config: &Self::Config, - ) { - render_callback_type(out, c); - } - - fn render_listener( + fn render_callback_interface( &self, out: &mut String, module: &ModuleBinding, - l: &ListenerBinding, + cb: &CallbackInterfaceBinding, config: &Self::Config, ) { - render_listener(out, module, l, config.strip_module_prefix); + render_callback_interface(out, module, cb, config.prefix()); } fn render_function( @@ -270,12 +228,13 @@ impl LanguageBackend for PythonGenerator { ), OutputFile::new( pkg_dir.join("weaveffi.py"), - self.render_py_source(model, config.strip_module_prefix, input_basename), + self.render_py_source(model, config), ), OutputFile::new( pkg_dir.join("weaveffi.pyi"), render_pyi_module(model, config.strip_module_prefix, input_basename), ), + OutputFile::new(pkg_dir.join("py.typed"), render_py_typed(input_basename)), OutputFile::new( dir.join("pyproject.toml"), render_pyproject_toml(&package, &import_name, input_basename), @@ -310,12 +269,10 @@ impl LanguageBackend for PythonGenerator { // Render the binding source once with the bundled-first loader, then // reuse it across every per-platform wheel tree. - let py_source = self - .render_py_source(model, config.strip_module_prefix, input_basename) - .replace( - PY_LOADER_ORIGINAL, - &py_loader_packaged(&ctx.binaries.lib_name), - ); + let py_source = self.render_py_source(model, config).replace( + PY_LOADER_ORIGINAL, + &py_loader_packaged(&ctx.binaries.lib_name), + ); let init_py = format!( "{}from .weaveffi import * # noqa: F401,F403\n\n{}", render_prelude(hash, input_basename), @@ -327,8 +284,14 @@ impl LanguageBackend for PythonGenerator { let py_dir = out_dir.join("python"); let mut files = Vec::new(); + // Wheels exist only for the platforms that have a wheel platform tag; + // a binary for any other platform (Android, wasm32) has no wheel to + // land in and is skipped. for nb in &ctx.binaries.binaries { let platform = nb.platform; + let Some(tag) = platform.python_platform_tag() else { + continue; + }; let tree = py_dir.join(platform.id()); let pkg_dir = tree.join(&import_name); files.push(PackagedFile::text( @@ -343,6 +306,10 @@ impl LanguageBackend for PythonGenerator { pkg_dir.join("weaveffi.pyi"), pyi.clone(), )); + files.push(PackagedFile::text( + pkg_dir.join("py.typed"), + render_py_typed(input_basename), + )); files.push(PackagedFile::copy( pkg_dir.join(ctx.binaries.bundled_filename(platform)), nb.source.clone(), @@ -354,7 +321,7 @@ impl LanguageBackend for PythonGenerator { files.push(PackagedFile::text(tree.join("setup.py"), setup_py.clone())); files.push(PackagedFile::text( tree.join("README.md"), - render_packaged_readme(&package, &import_name, platform, input_basename), + render_packaged_readme(&package, &import_name, platform, tag, input_basename), )); } Some(files) diff --git a/crates/weaveffi-gen-python/src/package.rs b/crates/weaveffi-gen-python/src/package.rs index b4a3af42..7b0ed050 100644 --- a/crates/weaveffi-gen-python/src/package.rs +++ b/crates/weaveffi-gen-python/src/package.rs @@ -49,10 +49,20 @@ requires-python = ">=3.8" [tool.setuptools] packages = ["{import_name}"] +[tool.setuptools.package-data] +"{import_name}" = ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"] + {trailer}"#, ) } +/// Render the PEP 561 `py.typed` marker. Type checkers only look for the +/// file's presence (its content is ignored unless it says `partial`), so it +/// carries the standard prelude like every other generated file. +pub(crate) fn render_py_typed(input_basename: &str) -> String { + render_prelude(CommentStyle::Hash, input_basename) +} + /// Render the plain `setup.py` for a generated (non-packaged) tree. pub(crate) fn render_setup_py( package: &ResolvedPackage, @@ -141,7 +151,7 @@ setup( name="{name}", version="{version}", packages=["{import_name}"], - package_data={{"{import_name}": ["*.so", "*.dylib", "*.dll"]}}, + package_data={{"{import_name}": ["py.typed", "*.pyi", "*.so", "*.dylib", "*.dll"]}}, include_package_data=True, distclass=_BinaryDistribution, ) @@ -150,17 +160,19 @@ setup( ) } -/// README for a packaged per-platform Python wheel tree. +/// README for a packaged per-platform Python wheel tree. `tag` is the +/// platform's wheel tag (`platform.python_platform_tag()`), which the caller +/// has already established exists. pub(crate) fn render_packaged_readme( package: &ResolvedPackage, import_name: &str, platform: weaveffi_core::platform::Platform, + tag: &str, input_basename: &str, ) -> String { let prelude = render_prelude(CommentStyle::Xml, input_basename); let trailer = render_trailer(CommentStyle::Xml, "README.md"); let name = &package.name; - let tag = platform.python_platform_tag(); format!( r#"{prelude}# {name} (Python, {plat}) diff --git a/crates/weaveffi-gen-python/src/runtime.rs b/crates/weaveffi-gen-python/src/runtime.rs index bc224a3a..b63e710e 100644 --- a/crates/weaveffi-gen-python/src/runtime.rs +++ b/crates/weaveffi-gen-python/src/runtime.rs @@ -1,7 +1,9 @@ //! The emitted Python runtime prelude: error base classes, library loading, -//! the ABI-revision check, the owned-pointer helpers, and the -//! `_BufferWriter`/`_BufferReader` pair implementing the value-buffer wire -//! format. +//! the ABI-revision check, the runtime symbol bindings (`error_set`, +//! `error_clear`, `error_free`, `free_string`, `free_bytes`), the +//! owned-pointer helpers, the `_BufferWriter`/`_BufferReader` pair +//! implementing the value-buffer wire format (including object tokens), and +//! the feature-gated async and callback-interface registries. use weaveffi_core::cabi::ABI_VERSION; @@ -58,6 +60,19 @@ from typing import Callable, Dict, Iterator, List, Optional class WeaveFFIError(Exception): + """An error reported through the C ABI. + + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ + + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 + def __init__(self, code: int, message: str) -> None: self.code = code self.message = message @@ -94,7 +109,15 @@ _lib = _load_library() ); render_abi_version_check(out); out.push_str( - r#"_lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] + r#"# Callback-interface trampolines report a failed implementation through +# weaveffi_error_set, which copies the message with the producer's allocator. +_lib.weaveffi_error_set.argtypes = [ + ctypes.POINTER(_WeaveFFIErrorStruct), + ctypes.c_int32, + ctypes.c_char_p, +] +_lib.weaveffi_error_set.restype = None +_lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] _lib.weaveffi_error_clear.restype = None # Async completion callbacks receive a heap-boxed error the consumer owns; # weaveffi_error_free releases the message, the payload, and the box. @@ -149,14 +172,30 @@ def _take_string(ptr) -> Optional[str]: return _s +def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + + class _BufferWriter: """Encodes values into the WeaveFFI value-buffer wire format: little-endian, packed, no alignment.""" def __init__(self) -> None: self._buf = bytearray() + self._objects = [] def finish(self) -> bytes: + # Mint the object tokens last: an encoding that raised part-way + # (a closed wrapper, a field of the wrong type) never reaches here, + # so it leaks no strong references. + for _pos, _obj in self._objects: + struct.pack_into(" None: @@ -207,6 +246,14 @@ class _BufferWriter: self.write_len(len(v)) self._buf += bytes(v) + def write_object(self, obj) -> None: + """Write an object token: one strong reference to `obj` the reader + will adopt. The wrapper is checked to be live now (a closed one + raises), but the reference itself is cloned in finish().""" + _borrow(obj) + self._objects.append((len(self._buf), obj)) + self._buf += bytes(8) + class _BufferReader: """Decodes values from the WeaveFFI value-buffer wire format, rejecting @@ -287,6 +334,14 @@ class _BufferReader: _n = self.read_len() return bytes(self._take(_n, "bytes data")) + def read_object(self) -> int: + """Read an object token: one strong reference the caller adopts into + a wrapper. Zero never appears in a non-optional position.""" + _p = struct.unpack(" None: if self._pos != len(self._data): raise WeaveFFIError(-1, "malformed value buffer: trailing bytes") @@ -312,6 +367,72 @@ def _take_buffer(ptr, length) -> bytes: ); } +/// Append the feature-gated runtime pieces after the fixed preamble: the +/// `asyncio` completion registry when the API has async callables, and the +/// callback-interface handle table (plus the `abc` import the abstract base +/// classes need) when it declares callback interfaces. Both registries share +/// one `threading` import. +pub(crate) fn render_feature_runtime(out: &mut String, has_async: bool, has_callbacks: bool) { + if !has_async && !has_callbacks { + return; + } + out.push('\n'); + if has_callbacks { + out.push_str("import abc\n"); + } + if has_async { + out.push_str("import asyncio\n"); + } + out.push_str("import threading\n"); + if has_async { + out.push_str( + r#" + +# Pending async completion trampolines, keyed by an integer token. +# Holding the ctypes function objects here keeps them alive until the +# producer fires the completion callback, even when the awaiting +# coroutine has been cancelled; each entry is removed on completion. +_async_pending: Dict[int, object] = {} +_async_lock = threading.Lock() +_async_next_token = 0 + + +def _async_register(cb) -> int: + global _async_next_token + with _async_lock: + _async_next_token += 1 + _token = _async_next_token + _async_pending[_token] = cb + return _token +"#, + ); + } + if has_callbacks { + out.push_str( + r#" + +# Live callback-interface implementations, keyed by the integer the producer +# receives as `ctx`. An entry is added when an implementation is passed to a +# call and removed when the producer invokes the vtable's `free(ctx)`, so the +# implementation stays alive exactly as long as the producer may call it and +# the producer never holds a raw reference to a Python object. +_cb_impls: Dict[int, object] = {} +_cb_lock = threading.Lock() +_cb_next_ctx = 0 + + +def _cb_register(impl) -> int: + global _cb_next_ctx + with _cb_lock: + _cb_next_ctx += 1 + _ctx = _cb_next_ctx + _cb_impls[_ctx] = impl + return _ctx +"#, + ); + } +} + /// The exact `_load_library` block [`render_preamble`] emits in `generate` /// mode, so the packager can swap it for a bundled-first variant. pub(crate) const PY_LOADER_ORIGINAL: &str = r#"def _load_library() -> ctypes.CDLL: diff --git a/crates/weaveffi-gen-python/src/stubs.rs b/crates/weaveffi-gen-python/src/stubs.rs index 16d52920..8620085d 100644 --- a/crates/weaveffi-gen-python/src/stubs.rs +++ b/crates/weaveffi-gen-python/src/stubs.rs @@ -2,16 +2,15 @@ //! generated `weaveffi.py` module exposes. use weaveffi_core::model::{ - BindingModel, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, ListenerBinding, - ModuleBinding, StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + StructBinding, }; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use crate::docs::emit_doc; use crate::entities::py_code_class_name; use crate::types::{ - py_callable_hint, py_field, py_member_name, py_name, py_type_hint, py_variant, - py_wrapper_fn_name, + py_field, py_member_name, py_name, py_type_hint, py_variant, py_wrapper_fn_name, }; /// Render the full `weaveffi.pyi` stub for the model. @@ -22,9 +21,14 @@ pub(crate) fn render_pyi_module( ) -> String { let mut out = render_prelude(CommentStyle::Hash, input_basename); out.push_str( - "from enum import IntEnum\nfrom typing import Callable, Dict, Iterator, List, Optional, Type\n", + "from abc import ABC, abstractmethod\nfrom enum import IntEnum\n\ + from typing import Callable, Dict, Iterator, List, Optional, Type\n", ); out.push_str("\nclass WeaveFFIError(Exception):\n"); + out.push_str(" GENERIC_ERROR_CODE: int\n"); + out.push_str(" PANIC_ERROR_CODE: int\n"); + out.push_str(" MARSHAL_ERROR_CODE: int\n"); + out.push_str(" FOREIGN_ERROR_CODE: int\n"); out.push_str(" code: int\n"); out.push_str(" message: str\n"); out.push_str(" def __init__(self, code: int, message: str) -> None: ...\n"); @@ -42,12 +46,12 @@ pub(crate) fn render_pyi_module( for s in &m.structs { render_pyi_struct(&mut out, s); } + for cb in &m.callback_interfaces { + render_pyi_callback_interface(&mut out, cb); + } for i in &m.interfaces { render_pyi_interface(&mut out, i); } - for l in &m.listeners { - render_pyi_listener(&mut out, m, l, strip_module_prefix); - } for f in &m.functions { render_pyi_function(&mut out, &m.path, f, strip_module_prefix); } @@ -87,9 +91,39 @@ fn render_pyi_error(out: &mut String, eb: &ErrorBinding) { } } +/// `.pyi` stub for one callback interface: the abstract base class with one +/// abstract method per IDL method, mirroring +/// [`crate::calls::render_callback_interface`]. +fn render_pyi_callback_interface(out: &mut String, cb: &CallbackInterfaceBinding) { + out.push('\n'); + emit_doc(out, &cb.doc, ""); + out.push_str(&format!("class {}(ABC):\n", cb.name)); + for m in &cb.methods { + let mut params = vec!["self".to_string()]; + params.extend( + m.params + .iter() + .map(|p| format!("{}: {}", py_name(&p.name), py_type_hint(&p.ty))), + ); + let ret = m + .ret + .as_ref() + .map(py_type_hint) + .unwrap_or_else(|| "None".into()); + emit_doc(out, &m.doc, " "); + out.push_str(&format!( + " @abstractmethod\n def {}({}) -> {}: ...\n", + py_member_name(&m.name), + params.join(", "), + ret + )); + } +} + /// `.pyi` stub for one interface wrapper class: `__init__` for the canonical -/// `new` constructor, a classmethod per remaining constructor, then methods -/// and statics, mirroring [`crate::entities::render_interface`]. +/// `new` constructor, a classmethod per remaining constructor, the +/// reference-releasing `close()` and context-manager pair, then methods and +/// statics, mirroring [`crate::entities::render_interface`]. fn render_pyi_interface(out: &mut String, i: &InterfaceBinding) { out.push('\n'); emit_doc(out, &i.doc, ""); @@ -110,6 +144,9 @@ fn render_pyi_interface(out: &mut String, i: &InterfaceBinding) { member_sig(c, Some("self")) )); } + out.push_str(" def close(self) -> None: ...\n"); + out.push_str(&format!(" def __enter__(self) -> \"{}\": ...\n", i.name)); + out.push_str(" def __exit__(self, *exc: object) -> bool: ...\n"); for c in i.constructors.iter().filter(|c| c.name != "new") { out.push_str(&format!( " @classmethod\n def {}({}) -> \"{}\": ...\n", @@ -146,9 +183,6 @@ fn render_pyi_interface(out: &mut String, i: &InterfaceBinding) { ret )); } - if i.constructors.is_empty() && i.methods.is_empty() && i.statics.is_empty() { - out.push_str(" ...\n"); - } } /// `.pyi` stub for a plain C-style enum: an `IntEnum` with typed members. @@ -235,37 +269,6 @@ fn render_pyi_struct(out: &mut String, s: &StructBinding) { )); } -/// `.pyi` stub for one listener's register/unregister wrapper pair. -fn render_pyi_listener( - out: &mut String, - module: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, -) { - let Some(cb) = module.callbacks.iter().find(|c| c.name == l.event_callback) else { - unreachable!("listener '{}' references unknown callback", l.name); - }; - let register_name = py_wrapper_fn_name( - &module.path, - &format!("register_{}", l.name), - strip_module_prefix, - ); - let unregister_name = py_wrapper_fn_name( - &module.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ); - out.push('\n'); - emit_doc(out, &l.doc, ""); - out.push_str(&format!( - "def {register_name}(callback: {}) -> int: ...\n", - py_callable_hint(&cb.params) - )); - out.push_str(&format!( - "def {unregister_name}(listener_id: int) -> None: ...\n" - )); -} - /// `.pyi` stub for one module-level free function. fn render_pyi_function( out: &mut String, diff --git a/crates/weaveffi-gen-python/src/tests.rs b/crates/weaveffi-gen-python/src/tests.rs new file mode 100644 index 00000000..b37ad6fb --- /dev/null +++ b/crates/weaveffi-gen-python/src/tests.rs @@ -0,0 +1,471 @@ +//! Unit tests: render a small API that exercises every ABI revision 2 shape +//! (reference-counted objects, nullable objects, objects inside buffers, +//! iterators of objects, and callback interfaces) and assert the generated +//! Python carries the pieces each contract clause requires. + +use camino::Utf8Path; +use weaveffi_core::backend::LanguageBackend; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::PackageContext; +use weaveffi_core::platform::{BinarySet, Platform}; +use weaveffi_core::resolved::ResolvedApi; +use weaveffi_core::validate::validate_api; +use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, Function, InterfaceDef, Module, Param, StructDef, StructField, + TypeRef, CURRENT_SCHEMA_VERSION, +}; + +use crate::{PythonConfig, PythonGenerator}; + +fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } +} + +fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } +} + +fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } +} + +fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) +} + +fn module(name: &str) -> Module { + Module { + name: name.into(), + doc: None, + functions: vec![], + interfaces: vec![], + callback_interfaces: vec![], + structs: vec![], + enums: vec![], + errors: None, + modules: vec![], + } +} + +/// The fixture the brief asks for: an interface with a constructor and a +/// method; a function taking and returning `Interface?`; a record with an +/// `Interface` field and a `[Interface]` field; an iterator over interface +/// elements; a callback interface whose methods take a string, an `i32`, a +/// record, and an object, one returning `bool` and one returning void; and a +/// function taking that callback interface. Routed through validation so the +/// fixture is known to be a legal IDL. +fn kv_api() -> ResolvedApi { + let kv = Module { + structs: vec![StructDef { + name: "Entry".into(), + doc: None, + deprecated: None, + fields: vec![ + field("key", TypeRef::StringUtf8), + field("store", named("Store")), + field("mirrors", TypeRef::List(Box::new(named("Store")))), + field("backup", TypeRef::Optional(Box::new(named("Store")))), + ], + }], + interfaces: vec![InterfaceDef { + name: "Store".into(), + doc: Some("A key-value store.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("path", TypeRef::StringUtf8)], None)], + methods: vec![ + func( + "get", + vec![param("key", TypeRef::StringUtf8)], + Some(TypeRef::StringUtf8), + ), + func( + "sibling", + vec![], + Some(TypeRef::Optional(Box::new(named("Store")))), + ), + ], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Watcher".into(), + doc: Some("Observes store changes.".into()), + deprecated: None, + methods: vec![ + func( + "on_change", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("entry", named("Entry")), + param("store", named("Store")), + ], + None, + ), + func( + "should_stop", + vec![param("count", TypeRef::I32)], + Some(TypeRef::Bool), + ), + ], + }], + functions: vec![ + func( + "pick", + vec![param("store", TypeRef::Optional(Box::new(named("Store"))))], + Some(TypeRef::Optional(Box::new(named("Store")))), + ), + func("watch", vec![param("watcher", named("Watcher"))], None), + func( + "all_stores", + vec![], + Some(TypeRef::Iterator(Box::new(named("Store")))), + ), + Function { + r#async: true, + ..func( + "fetch", + vec![param("id", TypeRef::I64)], + Some(named("Store")), + ) + }, + ], + ..module("kv") + }; + let api = Api { + version: CURRENT_SCHEMA_VERSION.into(), + modules: vec![kv], + }; + validate_api(api, None).unwrap_or_else(|d| panic!("fixture must validate: {d:?}")) +} + +/// Render the fixture and return `(weaveffi.py, weaveffi.pyi)`. +fn render(api: &ResolvedApi) -> (String, String) { + let config = PythonConfig::default(); + let model = BindingModel::build(api, config.prefix()); + let files = PythonGenerator.files(api, &model, Utf8Path::new("out"), &config); + let find = |suffix: &str| { + files + .iter() + .find(|f| f.path.as_str().ends_with(suffix)) + .unwrap_or_else(|| panic!("missing {suffix}")) + .contents + .clone() + }; + (find("weaveffi.py"), find("weaveffi.pyi")) +} + +fn assert_has(hay: &str, needle: &str) { + assert!(hay.contains(needle), "missing `{needle}` in:\n{hay}"); +} + +#[test] +fn runtime_binds_error_set_and_drops_legacy_symbols() { + let (py, pyi) = render(&kv_api()); + assert_has(&py, "_ABI_VERSION = 2\n"); + assert_has(&py, "_lib.weaveffi_error_set.argtypes = ["); + assert_has(&py, "FOREIGN_ERROR_CODE = -4"); + for legacy in ["arena", "handle_t", "listener", "register_"] { + assert!(!py.contains(legacy), "legacy `{legacy}` survives in:\n{py}"); + assert!( + !pyi.contains(legacy), + "legacy `{legacy}` survives in:\n{pyi}" + ); + } + assert_has(&pyi, " FOREIGN_ERROR_CODE: int\n"); +} + +#[test] +fn objects_are_reference_counted_wrappers() { + let (py, pyi) = render(&kv_api()); + // The clone/destroy pair is bound once at module scope. + assert_has( + &py, + "_lib.weaveffi_kv_Store_clone.argtypes = [ctypes.c_void_p]", + ); + assert_has( + &py, + "_lib.weaveffi_kv_Store_clone.restype = ctypes.c_void_p", + ); + assert_has( + &py, + "_lib.weaveffi_kv_Store_destroy.argtypes = [ctypes.c_void_p]", + ); + // Adoption, a second reference, and single disposal. + assert_has(&py, "def _from_ptr(cls, ptr) -> \"Store\":"); + assert_has(&py, "return _lib.weaveffi_kv_Store_clone(_borrow(self))"); + assert_has(&py, " def close(self) -> None:\n"); + assert_has(&py, " _lib.weaveffi_kv_Store_destroy(_p)"); + assert_has(&py, " def __del__(self) -> None:\n self.close()"); + assert_has( + &py, + " def __exit__(self, *exc) -> bool:\n self.close()", + ); + // The constructor adopts the returned reference; methods lend `self`. + assert_has(&py, "def __init__(self, path: str) -> None:"); + assert_has(&py, "_fn = _lib.weaveffi_kv_Store_new"); + assert_has(&py, "self._ptr = _result"); + assert_has( + &py, + "_result = _fn(_borrow(self), _string_to_bytes(key), ctypes.byref(_err))", + ); + assert_has(&py, "return _take_string(_result) or \"\""); + // Stubs mirror the disposal surface. + assert_has(&pyi, " def close(self) -> None: ...\n"); + assert_has(&pyi, " def __enter__(self) -> \"Store\": ...\n"); +} + +#[test] +fn nullable_objects_map_none_to_null_in_both_directions() { + let (py, pyi) = render(&kv_api()); + assert_has( + &py, + "def pick(store: Optional[\"Store\"]) -> Optional[\"Store\"]:", + ); + assert_has( + &py, + "_result = _fn((_borrow(store) if store is not None else None), ctypes.byref(_err))", + ); + assert_has( + &py, + " if _result is None:\n return None\n return Store._from_ptr(_result)", + ); + // A non-nullable object return traps on null instead. + assert_has( + &py, + "if _result is None:\n raise WeaveFFIError(-1, \"null pointer\")", + ); + assert_has(&py, "def sibling(self) -> Optional[\"Store\"]:"); + assert_has( + &pyi, + "def pick(store: Optional[\"Store\"]) -> Optional[\"Store\"]: ...", + ); +} + +#[test] +fn objects_inside_buffers_are_cloned_on_write_and_adopted_on_read() { + let (py, _) = render(&kv_api()); + // Record field: hand the wrapper to the writer (which clones it when the + // buffer is finished), adopt the token on read. + assert_has(&py, "_w.write_object(value.store)"); + assert_has(&py, "store=Store._from_ptr(_r.read_object()),"); + // List field. + assert_has( + &py, + "for _e0 in value.mirrors:\n _w.write_object(_e0)", + ); + assert_has( + &py, + "mirrors=[Store._from_ptr(_r.read_object()) for _i0 in range(_r.read_len())],", + ); + // Optional field keeps the ordinary flag byte. + assert_has( + &py, + "backup=(Store._from_ptr(_r.read_object()) if _r.read_option_flag() else None),", + ); + assert_has(&py, "def read_object(self) -> int:"); + assert_has(&py, "def write_object(self, obj) -> None:"); + // Tokens are minted in finish(), so a failed encoding leaks no reference. + assert_has( + &py, + "struct.pack_into(\" \"Store\":"); + assert_has(&py, "_state[\"val\"] = Store._from_ptr(result)"); +} + +#[test] +fn callback_interfaces_render_abc_vtable_and_trampolines() { + let (py, pyi) = render(&kv_api()); + assert_has(&py, "import abc\n"); + assert_has(&py, "_cb_impls: Dict[int, object] = {}"); + + // The consumer-facing abstract base class. + assert_has(&py, "class Watcher(abc.ABC):"); + assert_has(&py, "Observes store changes."); + assert_has( + &py, + " @abc.abstractmethod\n def on_change(self, text: str, weight: int, entry: \"Entry\", store: \"Store\") -> None:", + ); + assert_has( + &py, + " @abc.abstractmethod\n def should_stop(self, count: int) -> bool:", + ); + + // CFUNCTYPEs mirror the vtable entry signatures: ctx, slots, out_err. + assert_has( + &py, + "_Watcher_on_change_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int32, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct))", + ); + assert_has( + &py, + "_Watcher_should_stop_cfunctype = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.c_int32, ctypes.POINTER(_WeaveFFIErrorStruct))", + ); + assert_has( + &py, + "_Watcher_vtable_free_cfunctype = ctypes.CFUNCTYPE(None, ctypes.c_void_p)", + ); + + // The vtable struct: methods in declaration order, then `free`. + assert_has(&py, "class _WatcherVtable(ctypes.Structure):"); + assert_has(&py, "The C vtable `weaveffi_kv_Watcher_vtable`."); + assert_has( + &py, + " (\"on_change\", _Watcher_on_change_cfunctype),\n (\"should_stop\", _Watcher_should_stop_cfunctype),\n (\"free\", _Watcher_vtable_free_cfunctype),\n", + ); + + // Trampolines decode borrowed args, adopt objects, and never unwind. + assert_has( + &py, + "def _Watcher_on_change_trampoline(ctx, text, weight, entry_ptr, entry_len, store, out_err):", + ); + assert_has(&py, "_impl = _cb_impls[ctx]"); + assert_has( + &py, + "_impl.on_change(_bytes_to_string(text), weight, _unpack_Entry(ctypes.string_at(entry_ptr, entry_len) if entry_ptr else b\"\"), Store._from_ptr(store))", + ); + assert_has( + &py, + "def _Watcher_should_stop_trampoline(ctx, count, out_err):", + ); + assert_has( + &py, + "_ret = _impl.should_stop(count)\n return 1 if _ret else 0", + ); + assert_has(&py, "except Exception as _exc:"); + assert_has( + &py, + "_lib.weaveffi_error_set(out_err, -4, str(_exc).encode(\"utf-8\", \"replace\"))\n return 0\n", + ); + assert_has( + &py, + "def _Watcher_vtable_free_trampoline(ctx):\n # The producer's last reference is gone; it never touches `ctx` again.\n _cb_impls.pop(ctx, None)", + ); + + // Exactly one static vtable whose function objects are pinned. + assert_has( + &py, + "_Watcher_on_change_cfunc = _Watcher_on_change_cfunctype(_Watcher_on_change_trampoline)", + ); + assert_has( + &py, + "_Watcher_vtable = _WatcherVtable(_Watcher_on_change_cfunc, _Watcher_should_stop_cfunc, _Watcher_vtable_free_cfunc)", + ); + assert_eq!(py.matches("_Watcher_vtable = _WatcherVtable(").count(), 1); + + // Passing an implementation: register, then ctx + vtable address. + assert_has(&py, "def watch(watcher: \"Watcher\") -> None:"); + assert_has( + &py, + "_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(_WatcherVtable), ctypes.POINTER(_WeaveFFIErrorStruct)]", + ); + assert_has(&py, "_watcher_ctx = _cb_register(watcher)"); + assert_has( + &py, + "_fn(_watcher_ctx, ctypes.byref(_Watcher_vtable), ctypes.byref(_err))", + ); + + // Stubs. + assert_has(&pyi, "from abc import ABC, abstractmethod\n"); + assert_has(&pyi, "class Watcher(ABC):"); + assert_has( + &pyi, + " @abstractmethod\n def on_change(self, text: str, weight: int, entry: \"Entry\", store: \"Store\") -> None: ...\n", + ); + assert_has(&pyi, "def watch(watcher: \"Watcher\") -> None: ..."); +} + +#[test] +fn feature_runtime_is_emitted_only_when_used() { + let plain = Module { + functions: vec![func( + "add", + vec![param("a", TypeRef::I32), param("b", TypeRef::I32)], + Some(TypeRef::I32), + )], + ..module("math") + }; + let api = validate_api( + Api { + version: CURRENT_SCHEMA_VERSION.into(), + modules: vec![plain], + }, + None, + ) + .unwrap(); + let (py, _) = render(&api); + assert!(!py.contains("import abc")); + assert!(!py.contains("import asyncio")); + assert!(!py.contains("_cb_impls")); + assert_has(&py, "def add(a: int, b: int) -> int:"); +} + +#[test] +fn generated_source_is_deterministic() { + let api = kv_api(); + assert_eq!(render(&api), render(&api)); +} + +#[test] +fn package_skips_platforms_without_a_wheel_tag() { + let api = kv_api(); + let config = PythonConfig::default(); + let model = BindingModel::build(&api, config.prefix()); + let mut binaries = BinarySet::new("kv"); + for p in Platform::ALL { + binaries.insert( + p, + format!("/tmp/{}/{}", p.id(), binaries.bundled_filename(p)), + ); + } + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("kv.yml"), + }; + let files = PythonGenerator + .package(&api, &model, &ctx, Utf8Path::new("out"), &config) + .expect("python packages"); + let trees: std::collections::BTreeSet<&str> = files + .iter() + .filter_map(|f| f.path.as_str().strip_prefix("out/python/")) + .filter_map(|rest| rest.split('/').next()) + .collect(); + let expected: std::collections::BTreeSet<&str> = + Platform::DESKTOP.iter().map(|p| p.id()).collect(); + assert_eq!(trees, expected); + for p in [ + Platform::AndroidArm64, + Platform::AndroidX64, + Platform::Wasm32, + ] { + assert!(!files.iter().any(|f| f.path.as_str().contains(p.id()))); + } +} diff --git a/crates/weaveffi-gen-python/src/types.rs b/crates/weaveffi-gen-python/src/types.rs index c8c8a386..db1c21a9 100644 --- a/crates/weaveffi-gen-python/src/types.rs +++ b/crates/weaveffi-gen-python/src/types.rs @@ -28,45 +28,40 @@ pub(crate) fn py_ctypes_scalar(ty: &Ty) -> &'static str { Ty::F32 => "ctypes.c_float", Ty::F64 => "ctypes.c_double", Ty::Bool => "ctypes.c_int32", - Ty::StringUtf8 | Ty::BorrowedStr => "ctypes.c_char_p", - Ty::Handle => "ctypes.c_uint64", - // Typed handles, interfaces, and iterators cross as opaque pointers. - Ty::TypedHandle(_) | Ty::Interface(_) | Ty::Iterator(_) => "ctypes.c_void_p", - Ty::Bytes | Ty::BorrowedBytes => "ctypes.c_uint8", + Ty::StringUtf8 => "ctypes.c_char_p", + // Interfaces and iterators cross as opaque pointers; `Interface?` is + // the same slot with null meaning none. + Ty::Interface(_) | Ty::Iterator(_) => "ctypes.c_void_p", + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => "ctypes.c_void_p", + Ty::Bytes => "ctypes.c_uint8", Ty::Enum(_) => "ctypes.c_int32", Ty::Record(_) | Ty::RichEnum(_) | Ty::Optional(_) | Ty::List(_) | Ty::Map(_, _) => { unreachable!("buffered types have no scalar ctypes slot") } + Ty::CallbackInterface(_) => { + unreachable!("callback interfaces occupy a ctx + vtable slot pair") + } } } /// The Python typing hint for `ty` as it appears in signatures and stubs. pub(crate) fn py_type_hint(ty: &Ty) -> String { match ty { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::I64 - | Ty::U64 - | Ty::Handle => "int".into(), - // A typed handle is an opaque pointer-sized token; Python surfaces it - // as a plain integer address, exactly like an untyped handle. - Ty::TypedHandle(_) => "int".into(), + Ty::I8 | Ty::I16 | Ty::I32 | Ty::U8 | Ty::U16 | Ty::U32 | Ty::I64 | Ty::U64 => "int".into(), Ty::F32 | Ty::F64 => "float".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "str".into(), - Ty::Bytes | Ty::BorrowedBytes => "bytes".into(), - // Records, rich enums, plain enums, and interfaces surface as bare - // local class names in the generated module. A cross-module reference - // (e.g. `types.Contact`) must still annotate the *local* `Contact`, - // not the qualified IR name, which is not a symbol in this module. + Ty::StringUtf8 => "str".into(), + Ty::Bytes => "bytes".into(), + // Records, rich enums, plain enums, interfaces, and callback + // interfaces surface as bare local class names in the generated + // module. A cross-module reference (e.g. `types.Contact`) must still + // annotate the *local* `Contact`, not the qualified IR name, which is + // not a symbol in this module. Ty::Enum(name) => format!("\"{}\"", local_type_name(name)), Ty::Record(name) | Ty::RichEnum(name) | Ty::Interface(name) => { format!("\"{}\"", local_type_name(name)) } + Ty::CallbackInterface(name) => format!("\"{}\"", local_type_name(name)), Ty::Optional(inner) => format!("Optional[{}]", py_type_hint(inner)), Ty::List(inner) => format!("List[{}]", py_type_hint(inner)), Ty::Map(k, v) => format!("Dict[{}, {}]", py_type_hint(k), py_type_hint(v)), @@ -77,8 +72,9 @@ pub(crate) fn py_type_hint(ty: &Ty) -> String { /// Maps a shared ABI [`CType`] onto its `ctypes` spelling. The structural /// lowering (which slots exist, in what order) comes from /// [`weaveffi_core::abi`]; this is the Python-specific vocabulary applied to -/// each slot. Opaque handles and structs collapse to `c_void_p`; `char*` -/// becomes the `c_char_p` convenience type. +/// each slot. Opaque object and vtable pointers collapse to `c_void_p`; +/// `char*` becomes the `c_char_p` convenience type; the error struct is the +/// emitted `_WeaveFFIErrorStruct` so `error*` slots are typed pointers. pub(crate) fn py_ctype(ty: &CType) -> String { match ty { CType::Int8 => "ctypes.c_int8".into(), @@ -92,24 +88,41 @@ pub(crate) fn py_ctype(ty: &CType) -> String { CType::Double => "ctypes.c_double".into(), CType::Bool => "ctypes.c_int32".into(), CType::Size => "ctypes.c_size_t".into(), - CType::Handle => "ctypes.c_uint64".into(), CType::Char => "ctypes.c_char".into(), CType::Uint8 => "ctypes.c_uint8".into(), CType::Void => "None".into(), CType::Enum { .. } => "ctypes.c_int32".into(), - CType::CancelToken | CType::Error | CType::StructTag { .. } | CType::Named(_) => { - "ctypes.c_void_p".into() - } + CType::Error => "_WeaveFFIErrorStruct".into(), + CType::CancelToken + | CType::StructTag { .. } + | CType::VtableTag { .. } + | CType::Named(_) => "ctypes.c_void_p".into(), CType::Ptr { pointee, .. } => match pointee.as_ref() { CType::Char => "ctypes.c_char_p".into(), - CType::StructTag { .. } | CType::CancelToken | CType::Void | CType::Named(_) => { - "ctypes.c_void_p".into() - } + CType::StructTag { .. } + | CType::VtableTag { .. } + | CType::CancelToken + | CType::Void + | CType::Named(_) => "ctypes.c_void_p".into(), other => format!("ctypes.POINTER({})", py_ctype(other)), }, } } +/// The module-level `ctypes.Structure` subclass describing one callback +/// interface's vtable. `name` may be a qualified IR reference; the emitted +/// class uses the bare local name. +pub(crate) fn py_vtable_class_name(name: &str) -> String { + format!("_{}Vtable", local_type_name(name)) +} + +/// The module-level static vtable instance for one callback interface: the +/// single process-wide value whose address every call passing that +/// interface hands to the producer. +pub(crate) fn py_vtable_instance_name(name: &str) -> String { + format!("_{}_vtable", local_type_name(name)) +} + /// The `ctypes` argtypes one parameter contributes, in slot order, driven by /// its [`ArgPass`] contract. pub(crate) fn py_param_argtypes(p: &ParamBinding) -> Vec { @@ -122,6 +135,18 @@ pub(crate) fn py_param_argtypes(p: &ParamBinding) -> Vec { ArgPass::Direct { slot } | ArgPass::String { slot } | ArgPass::Object { slot, .. } => { vec![py_ctype(&slot.ty)] } + // The context slot carries the handle-table key; the vtable slot is + // typed against the interface's own Structure so `byref` of the + // static instance is accepted. + ArgPass::Callback { ctx, .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback family names a callback interface"); + vec![ + py_ctype(&ctx.ty), + format!("ctypes.POINTER({})", py_vtable_class_name(cb)), + ] + } } } @@ -183,9 +208,9 @@ pub(crate) fn py_member_name(name: &str) -> String { lang::escape_ident(&name.to_snake_case(), lang::PYTHON_KEYWORDS) } -/// The Python spelling of a module-level wrapper (free function or listener -/// register/unregister pair): the module-path prefix applied per config, then -/// snake_case, then keyword escaping. +/// The Python spelling of a module-level wrapper (free function): the +/// module-path prefix applied per config, then snake_case, then keyword +/// escaping. pub(crate) fn py_wrapper_fn_name( module_path: &str, name: &str, @@ -197,12 +222,6 @@ pub(crate) fn py_wrapper_fn_name( ) } -/// `Callable[[], None]` for a callback's idiomatic signature. -pub(crate) fn py_callable_hint(params: &[ParamBinding]) -> String { - let hints: Vec = params.iter().map(|p| py_type_hint(&p.ty)).collect(); - format!("Callable[[{}], None]", hints.join(", ")) -} - /// Escape a string for embedding in a double-quoted Python literal. pub(crate) fn py_str_literal(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") diff --git a/crates/weaveffi-gen-ruby/Cargo.toml b/crates/weaveffi-gen-ruby/Cargo.toml index 2c742f8e..3a0f3dae 100644 --- a/crates/weaveffi-gen-ruby/Cargo.toml +++ b/crates/weaveffi-gen-ruby/Cargo.toml @@ -15,5 +15,8 @@ camino = { workspace = true } heck = { workspace = true } serde = { workspace = true } +[dev-dependencies] +weaveffi-ir = { path = "../weaveffi-ir", version = "0.22.0" } + [lints] workspace = true diff --git a/crates/weaveffi-gen-ruby/src/callbacks.rs b/crates/weaveffi-gen-ruby/src/callbacks.rs new file mode 100644 index 00000000..bb5417a0 --- /dev/null +++ b/crates/weaveffi-gen-ruby/src/callbacks.rs @@ -0,0 +1,316 @@ +//! Callback-interface rendering: the consumer-facing Ruby module (duck-typed +//! method set with `NotImplementedError` defaults), the `FFI::Struct` vtable +//! layout, one trampoline `FFI::Function` per method plus the trailing +//! `free`, and the single process-wide vtable instance every registration +//! passes to the producer. +//! +//! The trampolines follow [`CallbackProtocol`](weaveffi_core::plan::CallbackProtocol): +//! `ctx` is resolved through the module's implementation registry, borrowed +//! string, bytes, and buffer arguments are copied or decoded before the +//! implementation runs, object arguments are adopted into wrappers, and any +//! exception is reported through `{prefix}_error_set(out_err, -4, message)` +//! instead of unwinding through the C frame. + +use heck::ToShoutySnakeCase; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::model::{CallbackInterfaceBinding, CallbackMethodBinding, ModuleBinding, Ty}; +use weaveffi_core::plan::{ArgPass, RetPass}; +use weaveffi_core::utils::local_type_name; + +use crate::docs::{emit_doc, emit_param_docs}; +use crate::types::{rb_abi_types, rb_direct_default, rb_direct_from_c, rb_ffi_type, rb_param_name}; + +/// The `FFI::Struct` subclass laying out one callback interface's vtable: +/// `Listener` (or `events.Listener`) becomes `WvListenerVtable`. +fn rb_vtable_class(name: &str) -> String { + format!("Wv{}Vtable", local_type_name(name)) +} + +/// The constant holding the one process-wide vtable instance for a callback +/// interface: `Listener` becomes `WV_LISTENER_VTABLE`. +pub(crate) fn rb_vtable_const(name: &str) -> String { + format!("WV_{}_VTABLE", local_type_name(name).to_shouty_snake_case()) +} + +/// The constant pinning one method's trampoline `FFI::Function`: +/// `Listener.on_message` becomes `WV_LISTENER_ON_MESSAGE`. +fn rb_trampoline_const(cb: &str, method: &str) -> String { + format!( + "WV_{}_{}", + local_type_name(cb).to_shouty_snake_case(), + method.to_shouty_snake_case() + ) +} + +/// The constant pinning the trailing `free` trampoline. +fn rb_free_const(cb: &str) -> String { + format!("WV_{}_FREE", local_type_name(cb).to_shouty_snake_case()) +} + +/// The trampoline's Ruby formal parameter names for one method: `ctx`, one +/// formal per parameter slot (a `_ptr`/`_len` pair for bytes and buffered +/// values), then `out_err`. The Ruby spellings mirror the C slot names so the +/// list lines up with [`CallbackMethodBinding::abi_params`] position for +/// position. +fn rb_tramp_formals(m: &CallbackMethodBinding) -> Vec { + let mut formals = vec!["ctx".to_string()]; + for p in &m.params { + let n = rb_param_name(&p.name); + match p.arg_pass() { + ArgPass::Bytes { .. } | ArgPass::Buffer { .. } => { + formals.push(format!("{n}_ptr")); + formals.push(format!("{n}_len")); + } + _ => formals.push(n), + } + } + formals.push("out_err".to_string()); + formals +} + +/// Render one callback interface: the consumer-facing module, the vtable +/// layout, the trampolines, and the static vtable instance. `prefix` is the +/// C symbol prefix the model was built with (needed to resolve each +/// parameter's receiving plan). +pub(crate) fn render_callback_interface( + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + prefix: &str, +) { + let protocol = cb.protocol(&module.path, prefix); + let local = local_type_name(&cb.name); + let vtable_class = rb_vtable_class(&cb.name); + let vtable_const = rb_vtable_const(&cb.name); + + let mut w = CodeWriter::two_space().with_depth(1); + + // The consumer-facing module: documentation of the required methods, and + // NotImplementedError defaults for consumers who include it. + w.blank(); + let mut d = String::new(); + emit_doc(&mut d, &cb.doc, " "); + w.raw(d); + if let Some(msg) = &cb.deprecated { + w.line(format!("# @deprecated {msg}")); + } + w.line("# Consumer-implemented callback interface. Any object responding to the"); + w.line(format!( + "# methods below is accepted wherever a {local} parameter is expected;" + )); + w.line("# include this module to inherit NotImplementedError defaults. The"); + w.line("# producer may call the methods from any thread until it releases the"); + w.line("# implementation."); + w.block(format!("module {local}"), "end", |w| { + for (idx, m) in cb.methods.iter().enumerate() { + if idx > 0 { + w.blank(); + } + let mut md = String::new(); + emit_doc(&mut md, &m.doc, " "); + w.raw(md); + if let Some(msg) = &m.deprecated { + w.line(format!("# @deprecated {msg}")); + } + emit_param_docs(w, &m.params); + if let Some(ret) = &m.ret { + w.line(format!("# @return [Object] a {ret}")); + } + let formals: Vec = m.params.iter().map(|p| rb_param_name(&p.name)).collect(); + let open = if formals.is_empty() { + format!("def {}", m.name) + } else { + format!("def {}({})", m.name, formals.join(", ")) + }; + w.block(open, "end", |w| { + w.line(format!( + "raise NotImplementedError, \"#{{self.class}}#{} is not implemented\"", + m.name + )); + }); + } + }); + + // The vtable layout: one pointer per method in declaration order, then + // the trailing free. + w.blank(); + w.line("# @api private"); + w.line(format!( + "# The C vtable layout the producer calls a {local} through." + )); + w.block(format!("class {vtable_class} < FFI::Struct"), "end", |w| { + let mut fields: Vec = cb + .methods + .iter() + .map(|m| format!(":{}, :pointer", m.name)) + .collect(); + fields.push(":free, :pointer".to_string()); + w.line(format!("layout {}", fields.join(",\n "))); + }); + + // One trampoline per method. + for (m, args) in cb.methods.iter().zip(&protocol.method_args) { + render_trampoline(&mut w, cb, m, args); + } + + // The trailing free: drop the registry entry; the producer never touches + // ctx again after this fires. + w.blank(); + w.line("# @api private"); + w.line(format!( + "# Releases a {local} implementation when the producer drops its last" + )); + w.line("# reference."); + w.block( + format!( + "{} = FFI::Function.new(:void, [:pointer]) do |ctx|", + rb_free_const(&cb.name) + ), + "end", + |w| { + w.line("_wv_cb_free(ctx)"); + }, + ); + + // The single static vtable instance, filled with the pinned trampolines. + w.blank(); + w.line(format!( + "# The one process-wide {local} vtable every registration hands the" + )); + w.line("# producer; its entries live for the process lifetime."); + w.line(format!("{vtable_const} = {vtable_class}.new")); + for m in &cb.methods { + w.line(format!( + "{vtable_const}[:{}] = {}", + m.name, + rb_trampoline_const(&cb.name, &m.name) + )); + } + w.line(format!( + "{vtable_const}[:free] = {}", + rb_free_const(&cb.name) + )); + + out.push_str(&w.finish()); +} + +/// Render one method's trampoline: an `FFI::Function` matching the vtable +/// entry's C signature that resolves `ctx`, receives each argument per its +/// [`RetPass`] plan, invokes the implementation, and converts the direct +/// return. Any exception is reported through `_wv_cb_fail` (which calls +/// `{prefix}_error_set` with `-4`) and the method's default value is +/// returned instead. +fn render_trampoline( + w: &mut CodeWriter, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + args: &[RetPass], +) { + let local = local_type_name(&cb.name); + let types = rb_abi_types(&m.abi_params, false); + let formals = rb_tramp_formals(m); + let default = rb_direct_default(m.ret.as_ref()); + + w.blank(); + w.line("# @api private"); + w.line(format!("# Trampoline for {local}#{}.", m.name)); + w.block( + format!( + "{} = FFI::Function.new({}, [{}]) do |{}|", + rb_trampoline_const(&cb.name, &m.name), + rb_ffi_type(&m.abi_ret, true), + types.join(", "), + formals.join(", ") + ), + "end", + |w| { + w.line("begin"); + w.scope(|w| { + w.line("impl = _wv_cb_lookup(ctx)"); + for (p, pass) in m.params.iter().zip(args) { + render_tramp_arg(w, &p.name, &p.ty, pass); + } + let call_args: Vec = m + .params + .iter() + .map(|p| format!("{}_v", rb_param_name(&p.name))) + .collect(); + let call = if call_args.is_empty() { + format!("impl.{}", m.name) + } else { + format!("impl.{}({})", m.name, call_args.join(", ")) + }; + match &m.ret { + None => { + w.line(call); + w.line("nil"); + } + // Coerce inside the rescue so a wrong-typed return + // surfaces as a foreign error rather than escaping + // through ffi's own conversion. + Some(Ty::Bool) => { + w.line(format!("{call} ? 1 : 0")); + } + Some(Ty::F32 | Ty::F64) => { + w.line(format!("Float({call})")); + } + Some(_) => { + w.line(format!("Integer({call})")); + } + } + }); + // Rescue Exception, not StandardError: NotImplementedError is a + // ScriptError, and nothing may unwind through the C frame. + w.line("rescue Exception => e"); + w.scope(|w| { + w.line("_wv_cb_fail(out_err, e)"); + w.line(default); + }); + w.line("end"); + }, + ); +} + +/// Emit the statements receiving one trampoline argument into the local +/// `{name}_v`, per its [`RetPass`] plan. Strings arrive as Ruby Strings +/// (ffi copies `:string` slots); bytes and buffers are borrowed `(ptr, len)` +/// pairs copied or decoded before the implementation runs; objects transfer +/// one strong reference that is adopted into a wrapper (`nil` for a null +/// nullable slot); direct values convert per [`rb_direct_from_c`]. +fn render_tramp_arg(w: &mut CodeWriter, name: &str, ty: &Ty, pass: &RetPass) { + let n = rb_param_name(name); + match pass { + RetPass::Void => unreachable!("callback parameters always have a type"), + RetPass::Direct => { + w.line(format!("{n}_v = {}", rb_direct_from_c(ty, &n))); + } + RetPass::String => { + // ffi copies a `:string` slot into a fresh BINARY String; the ABI + // guarantees UTF-8, so retag it. + w.line(format!( + "{n}_v = {n}.nil? ? '' : {n}.force_encoding(Encoding::UTF_8)" + )); + } + RetPass::Bytes => { + w.line(format!( + "{n}_v = {n}_ptr.null? ? ''.b : {n}_ptr.read_string({n}_len)" + )); + } + RetPass::Buffer => { + w.line(format!( + "{n}_r = WvBufferReader.new({n}_ptr.null? ? ''.b : {n}_ptr.read_string({n}_len))" + )); + crate::codec::render_wv_read(w, &format!("{n}_r"), &format!("{n}_v"), ty, 0, ""); + w.line(format!("{n}_r.expect_end!")); + } + RetPass::Object { nullable, .. } => { + let iface = ty.interface_name().expect("object plan names an interface"); + let class = local_type_name(iface); + if *nullable { + w.line(format!("{n}_v = {n}.null? ? nil : {class}._from_ptr({n})")); + } else { + w.line(format!("{n}_v = {class}._from_ptr({n})")); + } + } + } +} diff --git a/crates/weaveffi-gen-ruby/src/calls.rs b/crates/weaveffi-gen-ruby/src/calls.rs index 5e497867..0a826e36 100644 --- a/crates/weaveffi-gen-ruby/src/calls.rs +++ b/crates/weaveffi-gen-ruby/src/calls.rs @@ -1,26 +1,28 @@ //! Callable rendering: FFI attachments, the sync/async/iterator wrapper -//! bodies, listener registration, and the parameter and return marshalling -//! they share. +//! bodies, and the parameter and return marshalling they share. //! -//! Marshalling dispatch goes through the shared plan layer -//! ([`ArgPass`], [`RetPass`], [`ElemFree`]) rather than crate-local -//! `Ty` folds, so this backend cannot drift from the others on -//! call-boundary semantics. +//! Marshalling dispatch goes through the shared plan layer ([`ArgPass`], +//! [`RetPass`], [`Free`]) rather than crate-local `Ty` folds, so this +//! backend cannot drift from the others on call-boundary semantics: objects +//! are borrowed as parameters and adopted as returns, and a callback +//! interface crosses as a registry key plus the interface's static vtable. use heck::ToSnakeCase; use weaveffi_core::abi::CType; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - AsyncBinding, CallShape, CallbackBinding, FnBinding, IteratorBinding, ListenerBinding, - ModuleBinding, ParamBinding, + AsyncBinding, CallShape, FnBinding, IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{self, ArgPass, ElemFree, ErrorStrategy, RetPass}; +use weaveffi_core::plan::{self, ArgPass, ErrorStrategy, Free, RetPass}; use weaveffi_core::utils::{local_type_name, wrapper_name}; +use crate::callbacks::rb_vtable_const; use crate::docs::{emit_doc, emit_param_docs}; use crate::entities::{rb_checker_name, rb_error_factory_name}; -use crate::types::{rb_abi_types, rb_ffi_type, rb_mem_type, rb_param_name, rb_read_method}; +use crate::types::{ + rb_abi_types, rb_direct_from_c, rb_ffi_type, rb_mem_type, rb_param_name, rb_read_method, +}; /// How a rendered Ruby callable is scoped and spelled in the generated /// module: at module scope as a singleton method, or inside an interface @@ -33,8 +35,8 @@ pub(crate) enum RbScope<'a> { /// Whether the emitted name drops the module-path prefix. strip_module_prefix: bool, }, - /// An instance method on an interface class: `def name`, passing - /// `@handle` as the leading C argument. + /// An instance method on an interface class: `def name`, passing the + /// wrapper's borrowed `handle` as the leading C argument. Method { /// The top-level Ruby module name qualifying module singleton calls. module_name: &'a str, @@ -92,9 +94,11 @@ impl<'a> RbScope<'a> { } } - /// The `@handle` argument instance methods pass as the leading C slot. + /// The borrowed object pointer instance methods pass as the leading C + /// slot, through the `handle` accessor so a closed wrapper raises instead + /// of passing NULL. fn self_arg(&self) -> Option<&'static str> { - matches!(self, RbScope::Method { .. }).then_some("@handle") + matches!(self, RbScope::Method { .. }).then_some("handle") } /// The `def` opener for `f` with the given formal parameter names. @@ -199,198 +203,6 @@ pub(crate) fn render_attach_function(out: &mut String, f: &FnBinding) { out.push_str(&w.finish()); } -/// `callback :{c_fn_type}, [...], :void` declaration for a module callback. -/// Listener `attach_function`s reference the type by this symbol. Borrowed -/// string params use `:string` so the ffi gem hands the block a Ruby String. -pub(crate) fn render_callback_decl(out: &mut String, c: &CallbackBinding) { - let mut w = CodeWriter::two_space().with_depth(1); - let mut d = String::new(); - emit_doc(&mut d, &c.doc, " "); - w.raw(d); - w.line(format!( - "callback :{}, [{}], :void", - c.c_fn_type, - rb_abi_types(&c.abi_params, false).join(", ") - )); - out.push_str(&w.finish()); -} - -/// Attach one listener's register/unregister C symbol pair. -pub(crate) fn render_listener_ffi(out: &mut String, l: &ListenerBinding) { - let mut w = CodeWriter::two_space().with_depth(1); - w.line(format!( - "attach_function :{}, [:{}, :pointer], :uint64", - l.register_symbol, l.callback_c_fn_type - )); - w.line(format!( - "attach_function :{}, [:uint64], :void", - l.unregister_symbol - )); - out.push_str(&w.finish()); -} - -/// The trampoline formal parameter names one callback parameter contributes, -/// derived from its emitted Ruby spelling per its [`ArgPass`] shape (a -/// borrowed pointer/length pair for bytes and buffered values, one formal -/// otherwise). -fn rb_tramp_formals(p: &ParamBinding) -> Vec { - let n = rb_param_name(&p.name); - match p.arg_pass() { - ArgPass::Bytes { .. } | ArgPass::Buffer { .. } => { - vec![format!("{n}_ptr"), format!("{n}_len")] - } - _ => vec![n], - } -} - -/// Idiomatic register/unregister pair for one listener. The user passes a -/// block; the trampoline converts each C argument and the `FFI::Function` is -/// pinned in `@listener_refs` until unregistered. -pub(crate) fn render_listener_wrapper( - out: &mut String, - module: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, -) { - let Some(cb) = module.callbacks.iter().find(|c| c.name == l.event_callback) else { - unreachable!("listener '{}' references unknown callback", l.name); - }; - let register_name = wrapper_name( - &module.path, - &format!("register_{}", l.name), - strip_module_prefix, - ) - .to_snake_case(); - let unregister_name = wrapper_name( - &module.path, - &format!("unregister_{}", l.name), - strip_module_prefix, - ) - .to_snake_case(); - - let mut w = CodeWriter::two_space().with_depth(1); - w.blank(); - let mut d = String::new(); - emit_doc(&mut d, &l.doc, " "); - w.raw(d); - w.line(format!( - "# Registers a {} listener block. Returns a subscription id for", - cb.name - )); - w.line(format!("# {unregister_name}.")); - - // Trampoline formals: one per ABI slot, plus the ignored context. - let tramp_formals: Vec = cb - .params - .iter() - .flat_map(rb_tramp_formals) - .chain(std::iter::once("_context".to_string())) - .collect(); - let tramp_types = rb_abi_types(&cb.abi_params, false); - let call_args: Vec = cb.params.iter().map(rb_cb_arg_expr).collect(); - let buffered_params: Vec<_> = cb - .params - .iter() - .filter(|p| matches!(p.arg_pass(), ArgPass::Buffer { .. })) - .collect(); - w.block(format!("def self.{register_name}(&block)"), "end", |w| { - w.block( - format!( - "trampoline = FFI::Function.new(:void, [{}]) do |{}|", - tramp_types.join(", "), - tramp_formals.join(", ") - ), - "end", - |w| { - // Borrowed buffered arguments are only valid during the - // dispatch: decode them before invoking the user's block. A - // malformed buffer can't raise across the C boundary, so the - // event is dropped with a warning instead. - if !buffered_params.is_empty() { - w.line("begin"); - w.scope(|w| { - for p in &buffered_params { - let n = rb_param_name(&p.name); - w.line(format!( - "{n}_r = WvBufferReader.new({n}_ptr.null? ? ''.b : \ - {n}_ptr.read_string({n}_len))" - )); - crate::codec::render_wv_read( - w, - &format!("{n}_r"), - &format!("{n}_v"), - &p.ty, - 0, - "", - ); - w.line(format!("{n}_r.expect_end!")); - } - }); - w.line("rescue Error => e"); - w.scope(|w| { - w.line(format!( - "warn \"weaveffi: dropped {} event: #{{e.message}}\"", - cb.name - )); - w.line("next"); - }); - w.line("end"); - } - w.line(format!("block.call({})", call_args.join(", "))); - }, - ); - w.line(format!( - "listener_id = {}(trampoline, FFI::Pointer::NULL)", - l.register_symbol - )); - w.line("@listener_refs[listener_id] = trampoline"); - w.line("listener_id"); - }); - - w.blank(); - w.line(format!( - "# Unregisters a listener previously registered with {register_name}." - )); - w.block( - format!("def self.{unregister_name}(listener_id)"), - "end", - |w| { - w.line(format!("{}(listener_id)", l.unregister_symbol)); - w.line("@listener_refs.delete(listener_id)"); - w.line("nil"); - }, - ); - out.push_str(&w.finish()); -} - -/// The Ruby expression converting one callback parameter's trampoline -/// arguments into the idiomatic value passed to the user block, per the -/// parameter's [`ArgPass`] contract. Buffered parameters are decoded into a -/// `{n}_v` local before the dispatch, so their expression is just that local. -fn rb_cb_arg_expr(p: &ParamBinding) -> String { - let n = rb_param_name(&p.name); - match p.arg_pass() { - ArgPass::Buffer { .. } => format!("{n}_v"), - ArgPass::Bytes { .. } => { - format!("({n}_ptr.null? ? ''.b : {n}_ptr.read_string({n}_len))") - } - // `:string` slots arrive as Ruby Strings (copied by ffi) or nil. - ArgPass::String { .. } => n, - // Borrowed by contract: the producer owns callback arguments for the - // duration of the call, so opaque pointers pass through raw; a - // nullable object slot surfaces nil for null. - ArgPass::Object { - nullable: false, .. - } => n, - ArgPass::Object { nullable: true, .. } => format!("({n}.null? ? nil : {n})"), - ArgPass::Direct { .. } => match &p.ty { - Ty::Bool => format!("({n} != 0)"), - // Scalars, enums (their integer constants), and handles pass raw. - _ => n, - }, - } -} - /// Render the sync wrapper for one callable: convert the parameters, make /// the C call with a stack `ErrorStruct`, route the out-err slot through the /// function's checker, then convert the result per the scope. @@ -413,7 +225,7 @@ fn render_sync_function_wrapper( let mut d = String::new(); emit_doc(&mut d, &f.doc, &doc_ind); w.raw(d); - emit_param_docs(&mut w, f); + emit_param_docs(&mut w, &f.params); w.block(scope.def_open(f, ¶ms), "end", |w| { if let Some(msg) = &f.deprecated { let escaped = msg.replace('"', "\\\""); @@ -561,7 +373,10 @@ fn render_async_function_wrapper( w.line("if err && err[:code] != 0"); w.scope(|w| { w.line("code = err[:code]"); - w.line("msg = err[:message].null? ? '' : err[:message].read_string"); + w.line( + "msg = err[:message].null? ? '' : \ + err[:message].read_string.force_encoding(Encoding::UTF_8)", + ); if typed_error { // Copy the payload before releasing the boxed error. w.line( @@ -658,8 +473,11 @@ fn render_async_result_push( w.line("end"); } RetPass::String => { - // Owned by the consumer: copy, then free. - w.line("_wv_s = result.null? ? '' : result.read_string"); + // Owned by the consumer: copy (tagged UTF-8, since ffi's + // read_string yields BINARY), then free. + w.line( + "_wv_s = result.null? ? '' : result.read_string.force_encoding(Encoding::UTF_8)", + ); w.line(format!( "{m}weaveffi_free_string(result) unless result.null?" )); @@ -702,27 +520,10 @@ fn render_async_result_push( w.line("end"); } } - RetPass::Direct => match ret.as_ref() { - Some(Ty::Bool) => { - w.line("queue << (result != 0)"); - } - Some(Ty::TypedHandle(name)) => { - let local = local_type_name(name); - w.line("if result.null?"); - w.scope(|w| { - w.line("queue << Error.new(-1, 'null pointer')"); - }); - w.line("else"); - w.scope(|w| { - w.line(format!("queue << {local}.new(result)")); - }); - w.line("end"); - } - // Scalars, enums, and untyped handles pass through by value. - _ => { - w.line("queue << result"); - } - }, + RetPass::Direct => { + let ty = ret.as_ref().expect("direct return has a type"); + w.line(format!("queue << {}", rb_direct_from_c(ty, "result"))); + } } out.push_str(&w.finish()); } @@ -734,12 +535,12 @@ fn render_async_result_push( /// pull, so a handle cannot leak when the returned enumerator is never /// started (launch errors therefore raise on the first pull rather than at /// call time). Each consumer step issues exactly one C `next` call, each -/// yielded element is released per its element plan (strings and bytes freed -/// after copying; record and rich-enum pointers adopted by their -/// finalizer-bearing wrappers), and `destroy` runs exactly once from an -/// `ensure` block, so an early `break` or an error raised mid-iteration still -/// releases the handle. Launch and per-`next` errors follow the function's -/// [`ErrorStrategy`]. +/// yielded element is received per its element plan (strings and bytes +/// copied then freed, value buffers decoded then freed, object references +/// adopted into finalizer-bearing wrappers), and `destroy` runs exactly once +/// from an `ensure` block, so an early `break` or an error raised +/// mid-iteration still releases the handle. Launch and per-`next` errors +/// follow the function's [`ErrorStrategy`]. fn render_iterator_function_wrapper( out: &mut String, module: &ModuleBinding, @@ -753,6 +554,7 @@ fn render_iterator_function_wrapper( let q = scope.qualifier(); let checker = rb_checker_name(f, module.error.as_ref()); let params: Vec = f.params.iter().map(|p| rb_param_name(&p.name)).collect(); + let protocol = it.protocol(f, &module.path, ""); let mut w = CodeWriter::two_space().with_depth(depth); w.blank(); @@ -802,7 +604,7 @@ fn render_iterator_function_wrapper( let elem = &it.elem; // A pointer/length element pair (bytes, or any // buffered value) carries an extra out-length slot. - let needs_len = matches!(plan::elem_free(elem), ElemFree::Bytes); + let needs_len = matches!(protocol.elem_free, Free::Bytes); let item_mem = rb_mem_type(elem); w.line(format!("out_item = FFI::MemoryPointer.new({item_mem})")); if needs_len { @@ -821,6 +623,7 @@ fn render_iterator_function_wrapper( render_iterator_item_yield( &mut tmp, elem, + &protocol.elem, &" ".repeat(depth + 5), scope.module_name(), ); @@ -843,32 +646,37 @@ fn render_iterator_function_wrapper( } /// Convert the value written into `out_item` and yield it to the enumerator's -/// yielder `y`, releasing the element per its [`weaveffi_core::plan::ElemFree`] -/// plan first (copy, free, then yield, so an early `break` during the yield -/// cannot leak the element). `qualifier` is the top-level Ruby module name -/// when rendering inside a class body, where `weaveffi_free_*` calls need an -/// explicit receiver. -fn render_iterator_item_yield(out: &mut String, elem: &Ty, ind: &str, qualifier: Option<&str>) { +/// yielder `y`, receiving the element per its [`RetPass`] plan first (copy or +/// decode, free, then yield, so an early `break` during the yield cannot +/// leak the element; an object element is adopted). `qualifier` is the +/// top-level Ruby module name when rendering inside a class body, where +/// `weaveffi_free_*` calls need an explicit receiver. +fn render_iterator_item_yield( + out: &mut String, + elem: &Ty, + pass: &RetPass, + ind: &str, + qualifier: Option<&str>, +) { let m = qualifier.map(|q| format!("{q}.")).unwrap_or_default(); let mut w = CodeWriter::two_space().with_depth(ind.len() / 2); - if elem.is_buffered() { - // A buffered element is a producer-allocated value buffer: copy the - // bytes, release them, then decode and yield the value. - w.line("item_ptr = out_item.read_pointer"); - w.line("item_len = out_item_len.read(:size_t)"); - w.line("_wv_data = item_ptr.null? ? ''.b : item_ptr.read_string(item_len)"); - w.line(format!( - "{m}weaveffi_free_bytes(item_ptr, item_len) unless item_ptr.null?" - )); - w.line("_wv_r = WvBufferReader.new(_wv_data)"); - crate::codec::render_wv_read(&mut w, "_wv_r", "_wv_item", elem, 0, &m); - w.line("_wv_r.expect_end!"); - w.line("y << _wv_item"); - out.push_str(&w.finish()); - return; - } - match elem { - Ty::StringUtf8 | Ty::BorrowedStr => { + match pass { + RetPass::Void => unreachable!("iterator elements always have a type"), + RetPass::Buffer => { + // A buffered element is a producer-allocated value buffer: copy + // the bytes, release them, then decode and yield the value. + w.line("item_ptr = out_item.read_pointer"); + w.line("item_len = out_item_len.read(:size_t)"); + w.line("_wv_data = item_ptr.null? ? ''.b : item_ptr.read_string(item_len)"); + w.line(format!( + "{m}weaveffi_free_bytes(item_ptr, item_len) unless item_ptr.null?" + )); + w.line("_wv_r = WvBufferReader.new(_wv_data)"); + crate::codec::render_wv_read(&mut w, "_wv_r", "_wv_item", elem, 0, &m); + w.line("_wv_r.expect_end!"); + w.line("y << _wv_item"); + } + RetPass::String => { w.line("item_ptr = out_item.read_pointer"); w.line("if item_ptr.null?"); w.scope(|w| { @@ -876,13 +684,13 @@ fn render_iterator_item_yield(out: &mut String, elem: &Ty, ind: &str, qualifier: }); w.line("else"); w.scope(|w| { - w.line("item = item_ptr.read_string"); + w.line("item = item_ptr.read_string.force_encoding(Encoding::UTF_8)"); w.line(format!("{m}weaveffi_free_string(item_ptr)")); w.line("y << item"); }); w.line("end"); } - Ty::Bytes | Ty::BorrowedBytes => { + RetPass::Bytes => { w.line("item_ptr = out_item.read_pointer"); w.line("item_len = out_item_len.read(:size_t)"); w.line("if item_ptr.null?"); @@ -897,28 +705,27 @@ fn render_iterator_item_yield(out: &mut String, elem: &Ty, ind: &str, qualifier: }); w.line("end"); } - // A yielded typed handle is a new owned reference; the wrapper adopts - // the pointer. - Ty::TypedHandle(name) => { - let local = local_type_name(name); - w.line("item_ptr = out_item.read_pointer"); - w.line(format!("y << {local}.new(item_ptr) unless item_ptr.null?")); - } - // A yielded interface is a new owned reference; wrap it without - // re-running initialize. - Ty::Interface(name) => { - let local = local_type_name(name); + // A yielded object is one strong reference the wrapper adopts, + // without re-running initialize. A nullable element yields nil for a + // null pointer. + RetPass::Object { nullable, .. } => { + let local = local_type_name( + elem.interface_name() + .expect("object element names an interface"), + ); w.line("item_ptr = out_item.read_pointer"); - w.line(format!( - "y << {local}._from_ptr(item_ptr) unless item_ptr.null?" - )); - } - Ty::Bool => { - w.line("y << (out_item.read_int32 != 0)"); + if *nullable { + w.line(format!( + "y << (item_ptr.null? ? nil : {local}._from_ptr(item_ptr))" + )); + } else { + w.line("raise Error.new(-1, 'null pointer') if item_ptr.null?"); + w.line(format!("y << {local}._from_ptr(item_ptr)")); + } } - _ => { - let read = rb_read_method(elem); - w.line(format!("y << out_item.{read}")); + RetPass::Direct => { + let read = format!("out_item.{}", rb_read_method(elem)); + w.line(format!("y << {}", rb_direct_from_c(elem, &read))); } } out.push_str(&w.finish()); @@ -927,23 +734,34 @@ fn render_iterator_item_yield(out: &mut String, elem: &Ty, ind: &str, qualifier: /// The Ruby argument expressions one wrapper parameter contributes to the C /// call, driven by its [`ArgPass`] contract. A buffered parameter contributes /// its packed `(ptr, len)` pair, bytes contribute a copied native buffer plus -/// its length, and everything else is a single expression. +/// its length, an object contributes its borrowed pointer, a callback +/// interface contributes its registry key plus the interface's static +/// vtable, and everything else is a single expression. fn rb_call_args(p: &ParamBinding) -> Vec { let name = rb_param_name(&p.name); match p.arg_pass() { ArgPass::Buffer { .. } => vec![format!("{name}_buf"), format!("{name}_data.bytesize")], ArgPass::Bytes { .. } => vec![format!("{name}_buf"), format!("{name}.bytesize")], ArgPass::String { .. } => vec![name], + // Borrowed for the call: the wrapper keeps its own reference. ArgPass::Object { nullable: false, .. } => vec![format!("{name}.handle")], // A nullable borrowed object pointer: nil passes as NULL. ArgPass::Object { nullable: true, .. } => vec![format!("{name}&.handle")], + // The registry key (see `render_param_conversion`) and the one + // process-wide vtable for the interface. + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback plan names a callback interface"); + vec![ + format!("{name}_ctx"), + format!("{}.to_ptr", rb_vtable_const(cb)), + ] + } ArgPass::Direct { .. } => match &p.ty { Ty::Bool => vec![format!("{name}_c")], - // Typed handles occupy an opaque pointer slot borrowed from the - // wrapper object. - Ty::TypedHandle(_) => vec![format!("{name}.handle")], _ => vec![name], }, } @@ -953,8 +771,10 @@ fn rb_call_args(p: &ParamBinding) -> Vec { /// C call slots reference (see [`rb_call_args`]), per its [`ArgPass`] /// contract. A buffered parameter is packed into its value-buffer encoding /// and copied into a `MemoryPointer` the C call borrows for its duration; the -/// caller keeps ownership and the callee never frees it. `qualifier` names -/// the top-level Ruby module when rendering inside a class body. +/// caller keeps ownership and the callee never frees it. A callback interface +/// implementation is stored in the module's registry, whose key becomes the +/// `ctx` slot. `qualifier` names the top-level Ruby module when rendering +/// inside a class body. fn render_param_conversion(out: &mut String, p: &ParamBinding, ind: &str, qualifier: Option<&str>) { let q = qualifier.map(|q| format!("{q}.")).unwrap_or_default(); let name = rb_param_name(&p.name); @@ -975,6 +795,9 @@ fn render_param_conversion(out: &mut String, p: &ParamBinding, ind: &str, qualif )); w.line(format!("{name}_buf.put_bytes(0, {name})")); } + ArgPass::Callback { .. } => { + w.line(format!("{name}_ctx = {q}_wv_cb_register({name})")); + } ArgPass::Direct { .. } if matches!(p.ty, Ty::Bool) => { w.line(format!("{name}_c = {name} ? 1 : 0")); } @@ -1005,8 +828,10 @@ fn render_return_code(out: &mut String, ty: &Ty, ind: &str, qualifier: Option<&s w.line("_wv_value"); } RetPass::String => { + // ffi's read_string yields a BINARY string; the ABI guarantees + // UTF-8, so retag it before handing it to the caller. w.line("return '' if result.null?"); - w.line("str = result.read_string"); + w.line("str = result.read_string.force_encoding(Encoding::UTF_8)"); w.line(format!("{m}weaveffi_free_string(result)")); w.line("str"); } @@ -1038,19 +863,9 @@ fn render_return_code(out: &mut String, ty: &Ty, ind: &str, qualifier: Option<&s w.line(format!("{local}._from_ptr(result)")); } } - RetPass::Direct => match ty { - Ty::Bool => { - w.line("result != 0"); - } - Ty::TypedHandle(name) => { - w.line("raise Error.new(-1, 'null pointer') if result.null?"); - w.line(format!("{name}.new(result)")); - } - // Scalars, enums, and untyped handles pass through by value. - _ => { - w.line("result"); - } - }, + RetPass::Direct => { + w.line(rb_direct_from_c(ty, "result")); + } } out.push_str(&w.finish()); } diff --git a/crates/weaveffi-gen-ruby/src/codec.rs b/crates/weaveffi-gen-ruby/src/codec.rs index 34fcbae3..5579a76e 100644 --- a/crates/weaveffi-gen-ruby/src/codec.rs +++ b/crates/weaveffi-gen-ruby/src/codec.rs @@ -2,9 +2,14 @@ //! renderers for any wire shape, plus the per-record and per-rich-enum //! codec pairs (`_wv_write_{stem}`, `_wv_read_{stem}`). //! -//! Every dispatch here goes through [`wire::classify`], so this module never -//! re-derives the wire folds (handles as `u64` tokens, borrowed views as -//! their owned forms, records and rich enums as one user-codec shape). +//! Every dispatch here goes through [`Ty::wire`], so this module never +//! re-derives the wire folds (records and rich enums as one user-codec +//! shape, C-style enums as `i32`, interfaces as `u64` object tokens). +//! +//! An object token carries one strong reference. Writing one calls the +//! wrapper's `_wv_clone_ptr` (the interface's `_clone` symbol) so the +//! wrapper keeps its own reference; reading one adopts the pointer into a +//! fresh wrapper through `_from_ptr`, whose finalizer owes the `_destroy`. use heck::ToSnakeCase; use weaveffi_core::codegen::CodeWriter; @@ -24,7 +29,7 @@ pub(crate) fn wv_stem(name: &str) -> String { /// The `WvBufferWriter` method encoding one scalar wire shape, or `None` for /// the composite shapes that need statement-level rendering. C-style enums -/// encode as their `i32` discriminant; handles as raw `u64` tokens. +/// encode as their `i32` discriminant. fn wv_scalar_writer(shape: &WireType) -> Option<&'static str> { Some(match shape { WireType::Prim(Prim::Bool) => "write_bool", @@ -35,7 +40,7 @@ fn wv_scalar_writer(shape: &WireType) -> Option<&'static str> { WireType::Prim(Prim::I32) | WireType::Enum(_) => "write_i32", WireType::Prim(Prim::U32) => "write_u32", WireType::Prim(Prim::I64) => "write_i64", - WireType::Prim(Prim::U64) | WireType::Handle(_) => "write_u64", + WireType::Prim(Prim::U64) => "write_u64", WireType::Prim(Prim::F32) => "write_f32", WireType::Prim(Prim::F64) => "write_f64", WireType::Prim(Prim::String) => "write_string", @@ -56,7 +61,7 @@ fn wv_scalar_reader(shape: &WireType) -> Option<&'static str> { WireType::Prim(Prim::I32) | WireType::Enum(_) => "read_i32", WireType::Prim(Prim::U32) => "read_u32", WireType::Prim(Prim::I64) => "read_i64", - WireType::Prim(Prim::U64) | WireType::Handle(_) => "read_u64", + WireType::Prim(Prim::U64) => "read_u64", WireType::Prim(Prim::F32) => "read_f32", WireType::Prim(Prim::F64) => "read_f64", WireType::Prim(Prim::String) => "read_string", @@ -114,6 +119,11 @@ pub(crate) fn render_wv_write( WireType::User(n) => { w.line(format!("{q}_wv_write_{}({wvar}, {expr})", wv_stem(n))); } + // An object token carries one strong reference, so the wrapper's + // `_clone` symbol mints a fresh one; the wrapper keeps its own. + WireType::Object(_) => { + w.line(format!("{wvar}.write_u64({expr}._wv_clone_ptr.address)")); + } _ => unreachable!("scalar handled above"), } } @@ -170,6 +180,14 @@ pub(crate) fn render_wv_read( WireType::User(n) => { w.line(format!("{var} = {q}_wv_read_{}({rvar})", wv_stem(n))); } + // The reader adopts the token's strong reference into a new wrapper + // whose finalizer owes the `_destroy`; a zero token is malformed. + WireType::Object(n) => { + w.line(format!( + "{var} = {}._from_ptr({rvar}.read_object_token)", + local_type_name(n) + )); + } _ => unreachable!("scalar handled above"), } } diff --git a/crates/weaveffi-gen-ruby/src/docs.rs b/crates/weaveffi-gen-ruby/src/docs.rs index a0a6fee1..c5b712f9 100644 --- a/crates/weaveffi-gen-ruby/src/docs.rs +++ b/crates/weaveffi-gen-ruby/src/docs.rs @@ -3,7 +3,7 @@ use weaveffi_core::codegen::common::{emit_doc as common_emit_doc, DocCommentStyle}; use weaveffi_core::codegen::CodeWriter; -use weaveffi_core::model::FnBinding; +use weaveffi_core::model::ParamBinding; /// Emits a Ruby `# ...` doc comment at `indent`. Each input line is prefixed /// with `# `; blank lines become `#`. @@ -11,11 +11,11 @@ pub(crate) fn emit_doc(out: &mut String, doc: &Option, indent: &str) { common_emit_doc(out, doc, indent, DocCommentStyle::Hash); } -/// Emit one `# @param` tag per documented parameter of `f` into the writer, -/// naming each parameter by its emitted Ruby spelling (`rb_param_name`). +/// Emit one `# @param` tag per documented parameter into the writer, naming +/// each parameter by its emitted Ruby spelling (`rb_param_name`). /// Continuation lines indent under the tag; blank doc lines become `#`. -pub(crate) fn emit_param_docs(w: &mut CodeWriter, f: &FnBinding) { - for p in &f.params { +pub(crate) fn emit_param_docs(w: &mut CodeWriter, params: &[ParamBinding]) { + for p in params { if let Some(pdoc) = &p.doc { let trimmed = pdoc.trim(); if trimmed.is_empty() { diff --git a/crates/weaveffi-gen-ruby/src/entities.rs b/crates/weaveffi-gen-ruby/src/entities.rs index 772e1604..32ab3ff3 100644 --- a/crates/weaveffi-gen-ruby/src/entities.rs +++ b/crates/weaveffi-gen-ruby/src/entities.rs @@ -51,9 +51,10 @@ pub(crate) fn rb_checker_name(f: &FnBinding, error: Option<&ErrorBinding>) -> St /// /// Domain codes are validated positive-only; the negative range is reserved /// for the runtime (`-1` generic, `-2` producer panic, `-3` marshalling -/// failure). The factory therefore maps only declared codes onto typed -/// classes and lets everything else, negative runtime codes included, fall -/// through to the generic branded `Error`. +/// failure, `-4` a callback-interface implementation that raised). The +/// factory therefore maps only declared codes onto typed classes and lets +/// everything else, negative runtime codes included, fall through to the +/// generic branded `Error`. pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorBinding) { let domain = &eb.type_name; let factory = rb_error_factory_name(eb); @@ -134,7 +135,7 @@ pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorB "# Builds the {domain} subclass matching `code`, decoding any payload" )); w.line("# fields the code declares, or a generic Error for codes outside"); - w.line("# the domain (panics, marshalling)."); + w.line("# the domain (panics, marshalling, callback failures)."); w.block( format!("def self.{factory}(code, message, payload = nil)"), "end", @@ -182,7 +183,7 @@ pub(crate) fn render_error(out: &mut String, module: &ModuleBinding, eb: &ErrorB w.line("return if err[:code].zero?"); w.line("code = err[:code]"); w.line("msg_ptr = err[:message]"); - w.line("msg = msg_ptr.null? ? '' : msg_ptr.read_string"); + w.line("msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8)"); w.line("payload_ptr = err[:payload_ptr]"); w.line("payload = payload_ptr.null? ? nil : payload_ptr.read_string(err[:payload_len])"); w.line("weaveffi_error_clear(err.to_ptr)"); @@ -333,13 +334,18 @@ pub(crate) fn render_rich_enum_class(out: &mut String, e: &EnumBinding) { out.push_str(&w.finish()); } -/// Declare the FFI bindings for one interface: the destroy symbol plus every -/// constructor, method, and static through the shared attach path. Methods -/// carry their implicit leading `self` pointer slot in the precomputed ABI -/// signatures, so no special casing is needed here. +/// Declare the FFI bindings for one interface: the clone and destroy +/// lifecycle symbols plus every constructor, method, and static through the +/// shared attach path. Methods carry their implicit leading `self` pointer +/// slot in the precomputed ABI signatures, so no special casing is needed +/// here. pub(crate) fn render_interface_ffi(out: &mut String, i: &InterfaceBinding) { let mut w = CodeWriter::two_space().with_depth(1); w.blank(); + w.line(format!( + "attach_function :{}, [:pointer], :pointer", + i.clone_symbol + )); w.line(format!( "attach_function :{}, [:pointer], :void", i.destroy_symbol @@ -355,15 +361,20 @@ pub(crate) fn render_interface_ffi(out: &mut String, i: &InterfaceBinding) { } } -/// Render one interface as an opaque-object wrapper class, following the -/// struct wrapper's ownership pattern: a `{Name}Ptr < FFI::AutoPointer` -/// subclass releases the handle through the interface's C destroy symbol on -/// GC, and the wrapper class exposes `attr_reader :handle` plus an explicit -/// `destroy`. A constructor named `new` becomes `initialize`; every other -/// constructor becomes a class-method factory; methods pass `@handle` as the -/// leading C argument; statics are class methods. `_from_ptr` wraps an owned -/// pointer the producer already handed over (a C return value) without -/// re-running `initialize`. +/// Render one interface as a reference-counted object wrapper class. A +/// `{Name}Ptr < FFI::AutoPointer` subclass owns exactly one strong reference +/// and releases it through the interface's `_destroy` symbol either from +/// `close` or, as a backstop, from the GC finalizer (`AutoPointer#free` +/// disables the finalizer, so the two paths never both fire). The wrapper +/// exposes `handle` (the borrowed pointer passed to producer calls), `close`, +/// `closed?`, `_wv_clone_ptr` (a fresh strong reference for the value-buffer +/// codec), and an `initialize_copy` so `dup`/`clone` yield an independent +/// wrapper holding its own reference. A constructor named `new` becomes +/// `initialize`; every other constructor becomes a class-method factory; +/// methods pass `handle` as the leading C argument; statics are class +/// methods. `_from_ptr` adopts an owned pointer the producer already handed +/// over (a return, an async result, an iterator element, a buffer token) +/// without re-running `initialize`. pub(crate) fn render_interface_class( out: &mut String, module: &ModuleBinding, @@ -373,6 +384,11 @@ pub(crate) fn render_interface_class( let ptr_class = format!("{}Ptr", i.name); let mut w = CodeWriter::two_space().with_depth(1); w.blank(); + w.line("# @api private"); + w.line(format!( + "# Owns one strong reference to a {}; releases it exactly once.", + i.name + )); w.block( format!("class {ptr_class} < FFI::AutoPointer"), "end", @@ -387,11 +403,13 @@ pub(crate) fn render_interface_class( let mut d = String::new(); emit_doc(&mut d, &i.doc, " "); w.raw(d); + if let Some(msg) = &i.deprecated { + w.line(format!("# @deprecated {msg}")); + } w.line(format!("class {}", i.name)); w.scope(|w| { - w.line("attr_reader :handle"); - w.blank(); - w.line("# Wraps an owned pointer the producer handed over, without"); + w.line("# @api private"); + w.line("# Adopts one strong reference the producer handed over, without"); w.line("# re-running initialize."); w.block("def self._from_ptr(ptr)", "end", |w| { w.line("obj = allocate"); @@ -401,11 +419,44 @@ pub(crate) fn render_interface_class( w.line("obj"); }); w.blank(); - w.block("def destroy", "end", |w| { + w.line("# The borrowed object pointer passed to producer calls."); + w.block("def handle", "end", |w| { + w.line(format!( + "raise Error.new(-1, '{} used after close') if @handle.nil?", + i.name + )); + w.line("@handle"); + }); + w.blank(); + w.line("# Whether close has released this wrapper's reference."); + w.block("def closed?", "end", |w| { + w.line("@handle.nil?"); + }); + w.blank(); + w.line("# Releases this wrapper's reference now rather than at GC time."); + w.line("# Idempotent; the object itself is dropped when the last reference"); + w.line("# anywhere (another wrapper, a record field, the producer) goes."); + w.block("def close", "end", |w| { w.line("return if @handle.nil?"); w.line("@handle.free"); w.line("@handle = nil"); }); + w.blank(); + w.line("# @api private"); + w.line("# Mints a new strong reference the caller owns (used when this"); + w.line("# object is written into a value buffer)."); + w.block("def _wv_clone_ptr", "end", |w| { + w.line(format!("{rb_module_name}.{}(handle)", i.clone_symbol)); + }); + w.blank(); + w.line("# dup and clone produce an independent wrapper with its own reference."); + w.block("def initialize_copy(other)", "end", |w| { + w.line("super"); + w.line(format!( + "@handle = {ptr_class}.new({rb_module_name}.{}(other.handle))", + i.clone_symbol + )); + }); }); out.push_str(&w.finish()); diff --git a/crates/weaveffi-gen-ruby/src/lib.rs b/crates/weaveffi-gen-ruby/src/lib.rs index 61646625..4f4abb5c 100644 --- a/crates/weaveffi-gen-ruby/src/lib.rs +++ b/crates/weaveffi-gen-ruby/src/lib.rs @@ -1,7 +1,13 @@ //! Ruby (FFI gem) binding generator for WeaveFFI. //! //! Emits a Ruby gem (`.gemspec` + library) using the `ffi` gem to call -//! into the C ABI exposed by the underlying cdylib. Implements +//! into the C ABI (revision 2) exposed by the underlying cdylib. Interfaces +//! become reference-counted wrapper classes (`close` plus a GC finalizer +//! backstop, `dup`/`clone` for a second reference), records and rich enums +//! become value classes packed into value buffers (with interfaces inside +//! them carried as object tokens), and callback interfaces become +//! duck-typed Ruby modules backed by one static vtable of pinned +//! `FFI::Function` trampolines per interface. Implements //! [`LanguageBackend`]; the shared driver bridges it into the generator //! pipeline. #![deny(missing_docs)] @@ -9,6 +15,7 @@ #![warn(clippy::missing_panics_doc)] #![warn(clippy::doc_markdown)] +mod callbacks; mod calls; mod codec; mod docs; @@ -21,16 +28,13 @@ use camino::Utf8Path; use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; -use weaveffi_core::model::BindingModel; +use weaveffi_core::model::{BindingModel, CallbackInterfaceBinding, ModuleBinding}; use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; use weaveffi_core::resolved::ResolvedApi; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; -use crate::calls::{ - render_attach_function, render_callable, render_callback_decl, render_listener_ffi, - render_listener_wrapper, RbScope, -}; +use crate::calls::{render_attach_function, render_callable, RbScope}; use crate::entities::{ render_enum, render_error, render_interface_class, render_interface_ffi, render_rich_enum_class, render_struct_class, @@ -42,7 +46,7 @@ use crate::runtime::{render_preamble, ruby_loader_packaged, RUBY_LOADER_ORIGINAL /// Per-target configuration for [`RubyGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct RubyConfig { /// Top-level Ruby module name (default `"WeaveFFI"`). pub module_name: Option, @@ -109,7 +113,7 @@ impl LanguageBackend for RubyGenerator { "ruby" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -117,6 +121,16 @@ impl LanguageBackend for RubyGenerator { config.prefix() } + fn render_callback_interface( + &self, + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + config: &Self::Config, + ) { + callbacks::render_callback_interface(out, module, cb, config.prefix()); + } + fn files( &self, api: &ResolvedApi, @@ -191,6 +205,11 @@ impl LanguageBackend for RubyGenerator { let mut files = Vec::new(); for nb in &ctx.binaries.binaries { let platform = nb.platform; + // RubyGems has no platform string for Android or wasm32 builds, + // so those binaries have no gem to land in. + let Some(ruby_platform) = platform.ruby_platform() else { + continue; + }; let gem_dir = ruby_dir.join(platform.id()); let lib_dir = gem_dir.join("lib"); files.push(PackagedFile::text( @@ -205,7 +224,7 @@ impl LanguageBackend for RubyGenerator { )); files.push(PackagedFile::text( gem_dir.join(&gem_file), - render_packaged_gemspec(&package, &gem_file, platform, input_basename), + render_packaged_gemspec(&package, &gem_file, ruby_platform, input_basename), )); files.push(PackagedFile::text( gem_dir.join("README.md"), @@ -217,8 +236,8 @@ impl LanguageBackend for RubyGenerator { } /// Render the primary Ruby library source: the fixed preamble, then each -/// module's typed error surface, entities, codecs, FFI attachments, and -/// wrappers, in dependency order. +/// module's typed error surface, entities, codecs, FFI attachments, callback +/// interfaces, and wrappers, in dependency order. fn render_ruby_module( model: &BindingModel, module_name: &str, @@ -227,8 +246,7 @@ fn render_ruby_module( input_basename: &str, ) -> String { let mut out = render_prelude(CommentStyle::Hash, input_basename); - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - render_preamble(&mut out, module_name, has_listeners); + render_preamble(&mut out, module_name, model.has_callback_interfaces()); for m in &model.modules { out.push_str(&format!("\n # === Module: {} ===\n", m.path)); // The typed error surface comes first so the domain class exists @@ -261,21 +279,17 @@ fn render_ruby_module( for i in &m.interfaces { render_interface_ffi(&mut out, i); } - for c in &m.callbacks { - render_callback_decl(&mut out, c); - } - for l in &m.listeners { - render_listener_ffi(&mut out, l); - } for f in &m.functions { render_attach_function(&mut out, f); } + // Callback interfaces precede interfaces and functions because their + // static vtables are what interface members and free functions pass. + for cb in &m.callback_interfaces { + callbacks::render_callback_interface(&mut out, m, cb, &model.prefix); + } for i in &m.interfaces { render_interface_class(&mut out, m, i, module_name); } - for l in &m.listeners { - render_listener_wrapper(&mut out, m, l, strip_module_prefix); - } for f in &m.functions { let scope = RbScope::Free { module_path: &m.path, @@ -288,3 +302,416 @@ fn render_ruby_module( out.push_str(&render_trailer(CommentStyle::Hash, lib_filename)); out } + +#[cfg(test)] +mod tests { + use super::*; + use weaveffi_core::platform::{BinarySet, Platform}; + use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, Function, InterfaceDef, Module, Param, StructDef, StructField, + TypeRef, + }; + + fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } + } + + fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } + } + + fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } + } + + fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) + } + + /// One module exercising every revision-2 object shape: an interface + /// with a constructor and a method, `Store?` in and out, a record holding + /// a `Store` and a `[Store]`, an iterator of `Store`, a callback + /// interface with string, i32, record, and object parameters (one method + /// returning `bool`, one void), and a function taking that callback. + fn fixture() -> BindingModel { + let kv = Module { + name: "kv".into(), + doc: None, + functions: vec![ + func( + "maybe_store", + vec![param("store", TypeRef::Optional(Box::new(named("Store"))))], + Some(TypeRef::Optional(Box::new(named("Store")))), + ), + func( + "scan", + vec![], + Some(TypeRef::Iterator(Box::new(named("Store")))), + ), + func( + "subscribe", + vec![param("listener", named("Listener"))], + None, + ), + func( + "describe", + vec![param("bundle", named("Bundle"))], + Some(named("Bundle")), + ), + ], + interfaces: vec![InterfaceDef { + name: "Store".into(), + doc: Some("A key-value store.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("path", TypeRef::StringUtf8)], None)], + methods: vec![func( + "get", + vec![param("key", TypeRef::StringUtf8)], + Some(TypeRef::StringUtf8), + )], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Listener".into(), + doc: Some("Receives store events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + ], + Some(TypeRef::Bool), + ), + func( + "on_bundle", + vec![ + param("bundle", named("Bundle")), + param("store", named("Store")), + param("alt", TypeRef::Optional(Box::new(named("Store")))), + ], + None, + ), + ], + }], + structs: vec![StructDef { + name: "Bundle".into(), + doc: None, + deprecated: None, + fields: vec![ + field("primary", named("Store")), + field("extras", TypeRef::List(Box::new(named("Store")))), + ], + }], + enums: vec![], + errors: None, + modules: vec![], + }; + let api = ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![kv], + }); + BindingModel::build(&api, "weaveffi") + } + + fn render(model: &BindingModel) -> String { + render_ruby_module(model, "WeaveFFI", true, "weaveffi.rb", "weaveffi.yml") + } + + fn assert_has(src: &str, needle: &str) { + assert!(src.contains(needle), "missing `{needle}` in:\n{src}"); + } + + #[test] + fn runtime_binds_error_set_and_drops_legacy_symbols() { + let src = render(&fixture()); + assert_has( + &src, + "attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void", + ); + assert_has(&src, "FOREIGN_ERROR_CODE = -4"); + assert_has( + &src, + &format!("ABI_VERSION = {}", weaveffi_core::cabi::ABI_VERSION), + ); + for legacy in [ + "arena", + "listener_refs", + "handle_t", + "register_", + "unregister_", + ] { + assert!( + !src.contains(legacy), + "legacy `{legacy}` survived in:\n{src}" + ); + } + } + + #[test] + fn interface_wrapper_owns_one_reference_and_can_clone_it() { + let src = render(&fixture()); + assert_has( + &src, + "attach_function :weaveffi_kv_Store_clone, [:pointer], :pointer", + ); + assert_has( + &src, + "attach_function :weaveffi_kv_Store_destroy, [:pointer], :void", + ); + assert_has(&src, "class StorePtr < FFI::AutoPointer"); + assert_has(&src, "WeaveFFI.weaveffi_kv_Store_destroy(ptr)"); + assert_has(&src, "def close\n"); + assert_has(&src, "def closed?\n"); + assert_has(&src, "def _wv_clone_ptr\n"); + assert_has(&src, "WeaveFFI.weaveffi_kv_Store_clone(handle)"); + assert_has(&src, "def initialize_copy(other)"); + assert_has( + &src, + "StorePtr.new(WeaveFFI.weaveffi_kv_Store_clone(other.handle))", + ); + // The constructor adopts the returned reference; the method borrows. + assert_has(&src, "def initialize(path)"); + assert_has(&src, "@handle = StorePtr.new(result)"); + assert_has( + &src, + "result = WeaveFFI.weaveffi_kv_Store_get(handle, key, err)", + ); + } + + #[test] + fn nullable_objects_map_to_nil_in_both_directions() { + let src = render(&fixture()); + assert_has(&src, "def self.maybe_store(store)"); + assert_has(&src, "result = weaveffi_kv_maybe_store(store&.handle, err)"); + assert_has( + &src, + "return nil if result.null?\n Store._from_ptr(result)", + ); + } + + #[test] + fn records_carry_objects_as_cloned_tokens() { + let src = render(&fixture()); + assert_has(&src, "def self._wv_write_bundle(w, v)"); + assert_has(&src, "w.write_u64(v.primary._wv_clone_ptr.address)"); + assert_has(&src, "w.write_u64(_wv_e0._wv_clone_ptr.address)"); + assert_has(&src, "def self._wv_read_bundle(r)"); + assert_has(&src, "_wv_primary = Store._from_ptr(r.read_object_token)"); + assert_has(&src, "_wv_e0 = Store._from_ptr(r.read_object_token)"); + assert_has(&src, "def read_object_token"); + } + + #[test] + fn iterator_elements_adopt_objects() { + let src = render(&fixture()); + assert_has(&src, "def self.scan()"); + assert_has( + &src, + "has_item = weaveffi_kv_ScanIterator_next(iter, out_item, item_err)", + ); + assert_has(&src, "y << Store._from_ptr(item_ptr)"); + assert_has( + &src, + "weaveffi_kv_ScanIterator_destroy(iter) unless iter.null?", + ); + } + + #[test] + fn callback_interface_renders_module_vtable_and_trampolines() { + let src = render(&fixture()); + // Consumer-facing module with NotImplementedError defaults. + assert_has(&src, "module Listener\n"); + assert_has(&src, "def on_message(text, weight)"); + assert_has(&src, "def on_bundle(bundle, store, alt)"); + assert_has(&src, "raise NotImplementedError"); + // Vtable layout: methods in order, then free. + assert_has(&src, "class WvListenerVtable < FFI::Struct"); + assert_has( + &src, + "layout :on_message, :pointer,\n :on_bundle, :pointer,\n :free, :pointer", + ); + // Trampolines match the C vtable entry signatures. + assert_has( + &src, + "WV_LISTENER_ON_MESSAGE = FFI::Function.new(:int32, [:pointer, :string, :int32, :pointer]) do |ctx, text, weight, out_err|", + ); + assert_has( + &src, + "WV_LISTENER_ON_BUNDLE = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :pointer, :pointer, :pointer]) do |ctx, bundle_ptr, bundle_len, store, alt, out_err|", + ); + assert_has(&src, "impl = _wv_cb_lookup(ctx)"); + assert_has(&src, "impl.on_message(text_v, weight_v) ? 1 : 0"); + // Borrowed buffer decoded before the call; objects adopted. + assert_has(&src, "bundle_v = _wv_read_bundle(bundle_r)"); + assert_has(&src, "bundle_r.expect_end!"); + assert_has(&src, "store_v = Store._from_ptr(store)"); + assert_has(&src, "alt_v = alt.null? ? nil : Store._from_ptr(alt)"); + // Failure path: error_set with -4 and a default return. + assert_has( + &src, + "rescue Exception => e\n _wv_cb_fail(out_err, e)\n 0\n", + ); + assert_has( + &src, + "rescue Exception => e\n _wv_cb_fail(out_err, e)\n nil\n", + ); + assert_has( + &src, + "weaveffi_error_set(out_err, FOREIGN_ERROR_CODE, message)", + ); + // Free drops the registry entry. + assert_has( + &src, + "WV_LISTENER_FREE = FFI::Function.new(:void, [:pointer]) do |ctx|", + ); + assert_has(&src, "_wv_cb_free(ctx)"); + // One static vtable, filled with the pinned trampolines. + assert_has(&src, "WV_LISTENER_VTABLE = WvListenerVtable.new"); + assert_has( + &src, + "WV_LISTENER_VTABLE[:on_message] = WV_LISTENER_ON_MESSAGE", + ); + assert_has( + &src, + "WV_LISTENER_VTABLE[:on_bundle] = WV_LISTENER_ON_BUNDLE", + ); + assert_has(&src, "WV_LISTENER_VTABLE[:free] = WV_LISTENER_FREE"); + assert_eq!(src.matches("= WvListenerVtable.new").count(), 1); + } + + #[test] + fn passing_a_callback_registers_ctx_and_static_vtable() { + let src = render(&fixture()); + assert_has(&src, "def self.subscribe(listener)"); + assert_has(&src, "listener_ctx = _wv_cb_register(listener)"); + assert_has( + &src, + "weaveffi_kv_subscribe(listener_ctx, WV_LISTENER_VTABLE.to_ptr, err)", + ); + assert_has(&src, "def self._wv_cb_register(impl)"); + assert_has(&src, "def self._wv_cb_lookup(ctx)"); + assert_has(&src, "def self._wv_cb_free(ctx)"); + } + + #[test] + fn registry_is_omitted_without_callback_interfaces() { + let api = ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "math".into(), + doc: None, + functions: vec![func( + "add", + vec![param("a", TypeRef::I32), param("b", TypeRef::I32)], + Some(TypeRef::I32), + )], + interfaces: vec![], + callback_interfaces: vec![], + structs: vec![], + enums: vec![], + errors: None, + modules: vec![], + }], + }); + let src = render(&BindingModel::build(&api, "weaveffi")); + assert!(!src.contains("_wv_cb_register"), "{src}"); + assert_has(&src, "attach_function :weaveffi_error_set"); + } + + /// ffi's `Pointer#read_string` and `:string` callback slots produce + /// BINARY-encoded Strings; every C string the ABI guarantees is UTF-8 must + /// be retagged, or non-ASCII text fails to compare equal to a literal. + #[test] + fn c_strings_are_retagged_as_utf8() { + let src = render(&fixture()); + // Direct string return. + assert_has( + &src, + "str = result.read_string.force_encoding(Encoding::UTF_8)", + ); + // Callback-interface string parameter. + assert_has( + &src, + "text_v = text.nil? ? '' : text.force_encoding(Encoding::UTF_8)", + ); + // Error messages (generic checker). + assert_has( + &src, + "msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8)", + ); + // Every remaining bare read_string is a length-delimited byte copy. + for line in src.lines().filter(|l| l.contains(".read_string")) { + assert!( + line.contains(".read_string(") || line.contains("force_encoding(Encoding::UTF_8)"), + "untagged C string read in: {line}" + ); + } + } + + #[test] + fn rendering_is_deterministic() { + let model = fixture(); + assert_eq!(render(&model), render(&model)); + } + + #[test] + fn package_skips_platforms_without_a_gem_string() { + let model = fixture(); + let api = ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![], + }); + let mut binaries = BinarySet::new("weaveffi"); + binaries.insert(Platform::MacosArm64, "/tmp/darwin-arm64/libweaveffi.dylib"); + binaries.insert(Platform::AndroidArm64, "/tmp/android-arm64/libweaveffi.so"); + binaries.insert(Platform::Wasm32, "/tmp/wasm32/weaveffi.wasm"); + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("weaveffi.yml"), + }; + let files = RubyGenerator + .package( + &api, + &model, + &ctx, + Utf8Path::new("out"), + &RubyConfig::default(), + ) + .expect("ruby supports packaging"); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert!( + paths + .iter() + .all(|p| p.starts_with("out/ruby/darwin-arm64/")), + "{paths:?}" + ); + assert!(paths.iter().any(|p| p.ends_with("weaveffi.gemspec"))); + let gemspec = files + .iter() + .find(|f| f.path.as_str().ends_with("weaveffi.gemspec")) + .unwrap(); + let weaveffi_core::package::FileContent::Text(text) = &gemspec.content else { + panic!("gemspec is text"); + }; + assert_has(text, "s.platform = 'arm64-darwin'"); + } +} diff --git a/crates/weaveffi-gen-ruby/src/package.rs b/crates/weaveffi-gen-ruby/src/package.rs index 91bf16f2..76ba0a67 100644 --- a/crates/weaveffi-gen-ruby/src/package.rs +++ b/crates/weaveffi-gen-ruby/src/package.rs @@ -7,7 +7,6 @@ //! cannot corrupt the emitted spec. use weaveffi_core::pkg::ResolvedPackage; -use weaveffi_core::platform::Platform; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use crate::types::rb_str_literal; @@ -67,12 +66,13 @@ end ) } -/// Render a platform gemspec: it stamps `s.platform` and ships the bundled -/// native library alongside the Ruby sources. +/// Render a platform gemspec: it stamps `s.platform` with the RubyGems +/// platform string (`Platform::ruby_platform`) and ships the bundled native +/// library alongside the Ruby sources. pub(crate) fn render_packaged_gemspec( package: &ResolvedPackage, gem_file: &str, - platform: Platform, + ruby_platform: &str, input_basename: &str, ) -> String { let prelude = render_prelude(CommentStyle::Hash, input_basename); @@ -80,7 +80,6 @@ pub(crate) fn render_packaged_gemspec( let name = &package.name; let version = &package.version; let summary = rb_str_literal(&package.description_or_default()); - let ruby_platform = platform.ruby_platform(); let extra = gemspec_extra(package); format!( "{prelude}Gem::Specification.new do |s| diff --git a/crates/weaveffi-gen-ruby/src/runtime.rs b/crates/weaveffi-gen-ruby/src/runtime.rs index 859314f8..2ea5f569 100644 --- a/crates/weaveffi-gen-ruby/src/runtime.rs +++ b/crates/weaveffi-gen-ruby/src/runtime.rs @@ -1,7 +1,9 @@ //! The fixed Ruby runtime the generated module carries: the library loader, -//! the error surface, the value-buffer reader/writer pair, and the runtime -//! ABI attachments (`error_clear`, `error_free`, `free_string`, -//! `free_bytes`), guarded by the load-time ABI-revision check. +//! the error surface, the value-buffer reader/writer pair, the runtime ABI +//! attachments (`error_set`, `error_clear`, `error_free`, `free_string`, +//! `free_bytes`), guarded by the load-time ABI-revision check, and (when +//! the API declares callback interfaces) the implementation registry the +//! vtable trampolines resolve their `ctx` keys against. use weaveffi_core::cabi::ABI_VERSION; @@ -49,9 +51,9 @@ pub(crate) fn ruby_loader_packaged(lib: &str) -> String { /// Emit the fixed module preamble: the loader, the error struct and generic /// `Error` class, the buffer runtime, the runtime ABI attachments, the -/// generic `check_error!` trap helper, and (when the API declares listeners) -/// the trampoline pin table. -pub(crate) fn render_preamble(out: &mut String, module_name: &str, has_listeners: bool) { +/// generic `check_error!` trap helper, and (when the API declares callback +/// interfaces) the implementation registry. +pub(crate) fn render_preamble(out: &mut String, module_name: &str, has_callback_interfaces: bool) { out.push_str(&format!( "# frozen_string_literal: true # {module_name} Ruby FFI bindings (auto-generated) @@ -86,6 +88,16 @@ module {module_name} :payload_len, :size_t end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + class Error < StandardError attr_reader :code @@ -99,6 +111,7 @@ module {module_name} out.push_str(RUBY_BUFFER_RUNTIME); out.push_str( " + attach_function :weaveffi_error_set, [:pointer, :int32, :string], :void attach_function :weaveffi_error_clear, [:pointer], :void attach_function :weaveffi_error_free, [:pointer], :void attach_function :weaveffi_free_string, [:pointer], :void @@ -108,28 +121,71 @@ module {module_name} return if err[:code].zero? code = err[:code] msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string + msg = msg_ptr.null? ? '' : msg_ptr.read_string.force_encoding(Encoding::UTF_8) weaveffi_error_clear(err.to_ptr) raise Error.new(code, msg) end ", ); - if has_listeners { - out.push_str( - " - # Registered listener trampolines, keyed by subscription id. Holding the - # FFI::Function objects here keeps them alive until unregistered; without - # this the GC could collect a trampoline the producer still calls. - @listener_refs = {} -", - ); + if has_callback_interfaces { + out.push_str(RUBY_CALLBACK_REGISTRY); } } +/// The handle table behind every callback-interface parameter: the Ruby +/// implementation object is stored under an incrementing Integer key, and +/// that key (widened to a pointer) is what the producer receives as `ctx`. +/// The producer never sees a Ruby object address, so the GC is free to move +/// or keep the implementation as it likes; the entry is deleted when the +/// producer calls the vtable's `free`. +const RUBY_CALLBACK_REGISTRY: &str = " + # Live callback-interface implementations keyed by the Integer the producer + # holds as `ctx`. Keys start at 1 so a context pointer is never NULL. + @wv_cb_registry = {} + @wv_cb_next_key = 0 + @wv_cb_mutex = Mutex.new + + # @api private + # Registers `impl` and returns the `ctx` pointer to hand the producer. + def self._wv_cb_register(impl) + @wv_cb_mutex.synchronize do + @wv_cb_next_key += 1 + @wv_cb_registry[@wv_cb_next_key] = impl + FFI::Pointer.new(@wv_cb_next_key) + end + end + + # @api private + # Resolves a trampoline's `ctx` back to the registered implementation. + def self._wv_cb_lookup(ctx) + impl = @wv_cb_mutex.synchronize { @wv_cb_registry[ctx.address] } + raise Error.new(FOREIGN_ERROR_CODE, 'callback context is not registered') if impl.nil? + impl + end + + # @api private + # Drops the registry entry when the producer releases its last reference. + def self._wv_cb_free(ctx) + @wv_cb_mutex.synchronize { @wv_cb_registry.delete(ctx.address) } + nil + end + + # @api private + # Reports a callback implementation's exception through `out_err`. The + # producer copies the message, so the Ruby String need not outlive the call. + def self._wv_cb_fail(out_err, exception) + message = exception.message.to_s + message = exception.class.name if message.empty? + weaveffi_error_set(out_err, FOREIGN_ERROR_CODE, message) + nil + end +"; + /// The private Ruby runtime implementing the value-buffer wire format /// (little-endian, packed, no alignment): a writer building a binary String /// and a reader that raises `Error` on any malformed buffer (truncation, bad -/// flag bytes, invalid UTF-8, length prefixes past the end, trailing bytes). +/// flag bytes, invalid UTF-8, length prefixes past the end, zero object +/// tokens, trailing bytes). const RUBY_BUFFER_RUNTIME: &str = r#" # @api private # Appends values in the WeaveFFI value-buffer wire format: little-endian, @@ -295,6 +351,14 @@ const RUBY_BUFFER_RUNTIME: &str = r#" take(read_len, 'byte buffer') end + # An object token: a non-zero u64 carrying one strong reference the + # caller adopts into a wrapper. + def read_object_token + addr = read_u64 + raise Error.new(-1, 'malformed value buffer: null object token') if addr.zero? + FFI::Pointer.new(addr) + end + def expect_end! return if @pos == @data.bytesize raise Error.new(-1, 'malformed value buffer: trailing bytes after value') diff --git a/crates/weaveffi-gen-ruby/src/types.rs b/crates/weaveffi-gen-ruby/src/types.rs index ff3c3380..6f4924b4 100644 --- a/crates/weaveffi-gen-ruby/src/types.rs +++ b/crates/weaveffi-gen-ruby/src/types.rs @@ -39,7 +39,6 @@ pub(crate) fn rb_ffi_type(ty: &CType, string_as_pointer: bool) -> &'static str { CType::Uint64 => ":uint64", CType::Float => ":float", CType::Double => ":double", - CType::Handle => ":uint64", CType::Size => ":size_t", CType::Void => ":void", CType::Ptr { pointee, .. } if matches!(**pointee, CType::Char) && !string_as_pointer => { @@ -60,9 +59,9 @@ pub(crate) fn rb_abi_types(params: &[AbiParam], string_as_pointer: bool) -> Vec< } /// The `FFI::MemoryPointer` read method for one iterator element out-slot. -/// This is ABI-slot vocabulary, not wire vocabulary: a typed handle crosses -/// a `next` slot as an opaque pointer, so anything non-scalar reads a -/// pointer. +/// This is ABI-slot vocabulary, not wire vocabulary: strings, bytes, +/// buffers, and objects all cross a `next` slot as a pointer, so anything +/// non-scalar reads a pointer. pub(crate) fn rb_read_method(ty: &Ty) -> &'static str { match ty { Ty::I8 => "read_int8", @@ -75,7 +74,6 @@ pub(crate) fn rb_read_method(ty: &Ty) -> &'static str { Ty::U64 => "read_uint64", Ty::F32 => "read_float", Ty::F64 => "read_double", - Ty::Handle => "read_uint64", _ => "read_pointer", } } @@ -94,11 +92,33 @@ pub(crate) fn rb_mem_type(ty: &Ty) -> &'static str { Ty::U64 => ":uint64", Ty::F32 => ":float", Ty::F64 => ":double", - Ty::Handle => ":uint64", _ => ":pointer", } } +/// The Ruby expression converting one direct-family (`Direct`) C value +/// received from the producer (a return, an out-slot read, or a trampoline +/// argument) into its idiomatic Ruby value: a `bool` crosses as an `int32` +/// and becomes `true`/`false`; scalars and C-style enum discriminants pass +/// through. +pub(crate) fn rb_direct_from_c(ty: &Ty, expr: &str) -> String { + match ty { + Ty::Bool => format!("({expr} != 0)"), + _ => expr.to_string(), + } +} + +/// The Ruby literal a callback-interface trampoline returns to the producer +/// after its implementation raised: the zero value of the method's direct +/// return type, or `nil` for a void method. +pub(crate) fn rb_direct_default(ty: Option<&Ty>) -> &'static str { + match ty { + None => "nil", + Some(Ty::F32 | Ty::F64) => "0.0", + Some(_) => "0", + } +} + /// Escape a string for embedding in a single-quoted Ruby literal (the two /// characters with meaning there: backslash and the quote itself). pub(crate) fn rb_str_literal(s: &str) -> String { diff --git a/crates/weaveffi-gen-swift/src/callbacks.rs b/crates/weaveffi-gen-swift/src/callbacks.rs new file mode 100644 index 00000000..3d6b0def --- /dev/null +++ b/crates/weaveffi-gen-swift/src/callbacks.rs @@ -0,0 +1,319 @@ +//! Callback-interface rendering: the Swift `protocol` a consumer implements, +//! the box class that retains one implementation across the C boundary, and +//! the process-wide vtable of `@convention(c)` trampolines the producer calls +//! through. +//! +//! Every clause of [`CallbackProtocol`] is rendered here: one static vtable +//! per interface, a pointer-keyed context (the retained box), arguments +//! received per [`RetPass`] (borrowed strings, bytes, and buffers are copied +//! or decoded; object arguments are adopted), and a `do`/`catch` around every +//! implementation call so a thrown Swift error is reported through +//! `weaveffi_error_set` with the foreign error code instead of unwinding +//! through the C frame. + +use weaveffi_core::cabi::c_param_name; +use weaveffi_core::codegen::common::DocCommentStyle; +use weaveffi_core::codegen::CodeWriter; +use weaveffi_core::lang; +use weaveffi_core::model::{ + CallbackInterfaceBinding, CallbackMethodBinding, ModuleBinding, ParamBinding, Ty, +}; +use weaveffi_core::plan::{CallbackProtocol, RetPass}; +use weaveffi_core::utils::local_type_name; + +use crate::codec::{fresh, read_value_stmts}; +use crate::docs::emit_fn_doc; +use crate::types::{ + c_enum_type, callback_box_name, callback_vtable_name, swift_ident, swift_type_ctx, SwiftCtx, +}; + +/// The Swift spelling of one trampoline closure formal: the ABI slot name, +/// keyword-escaped (a param named `in` yields a slot named `in`, which can't +/// bind as a closure formal unescaped). +fn slot_ident(name: &str) -> String { + lang::escape_ident(name, lang::SWIFT_KEYWORDS) +} + +/// Clone a callback method with its parameter names camel-cased and +/// keyword-escaped, so the protocol requirement's argument labels and the +/// trampoline's call site agree. The ABI slot names are left untouched. +fn camel_method(m: &CallbackMethodBinding) -> CallbackMethodBinding { + let mut m = m.clone(); + for p in &mut m.params { + p.name = swift_ident(&p.name); + } + m +} + +/// The Swift return clause of a protocol requirement: empty for `void`. +fn ret_clause(m: &CallbackMethodBinding, ctx: SwiftCtx) -> String { + m.ret + .as_ref() + .map(|t| format!(" -> {}", swift_type_ctx(t, ctx))) + .unwrap_or_default() +} + +/// The Swift literal a trampoline returns after reporting a failure, in the +/// method's C return type: `false`, `0`, or a zero C enum. +fn c_default(m: &CallbackMethodBinding, c_prefix: &str, module: &str) -> Option { + m.ret.as_ref().map(|t| match t { + Ty::Bool => "false".to_string(), + Ty::Enum(name) => format!("{}(0)", c_enum_type(name, c_prefix, module)), + _ => "0".to_string(), + }) +} + +/// Emit the statements receiving one trampoline argument per its [`RetPass`] +/// and return the expression handed to the implementation. Borrowed strings, +/// bytes, and buffers are copied or decoded before the implementation runs +/// (the producer owns them only for the dispatch); object arguments carry +/// one strong reference the new wrapper adopts. +fn receive_arg( + w: &mut CodeWriter, + p: &ParamBinding, + rp: &RetPass, + ctx: SwiftCtx, + counter: &mut usize, +) -> String { + let n0 = slot_ident(&p.abi[0].name); + match rp { + RetPass::Direct => match &p.ty { + Ty::Enum(_) => format!("{}(rawValue: {n0}.rawValue)!", swift_type_ctx(&p.ty, ctx)), + _ => n0, + }, + RetPass::String => format!("String(cString: {n0}!)"), + RetPass::Bytes => { + let n1 = slot_ident(&p.abi[1].name); + format!("{n0}.map {{ Data(bytes: $0, count: {n1}) }} ?? Data()") + } + RetPass::Buffer => { + let n1 = slot_ident(&p.abi[1].name); + let buf = fresh(counter, "b"); + let reader = fresh(counter, "r"); + w.line(format!( + "let {buf} = [UInt8](UnsafeBufferPointer(start: {n0}, count: {n1}))" + )); + w.line(format!("var {reader} = WvReader(bytes: {buf})")); + let v = fresh(counter, "v"); + read_value_stmts(w, &p.ty, &v, &reader, ctx, counter); + w.line(format!("{reader}.finish()")); + v + } + RetPass::Object { nullable, .. } => { + let wrapper = ctx.ty_name(local_type_name( + p.ty.interface_name() + .expect("object argument names an interface"), + )); + if *nullable { + format!("{n0}.map {{ {wrapper}(ptr: $0) }}") + } else { + format!("{wrapper}(ptr: {n0}!)") + } + } + RetPass::Void => unreachable!("a parameter is never void"), + } +} + +/// Render the `public protocol` the consumer conforms to: one `throws` +/// requirement per method with idiomatic labels and types. A conforming +/// method may omit `throws`; one that throws is reported to the producer as a +/// foreign failure. +fn render_protocol(w: &mut CodeWriter, cb: &CallbackInterfaceBinding, ctx: SwiftCtx) { + let name = ctx.ty_name(local_type_name(&cb.name)); + if cb.doc.is_some() { + w.doc(&cb.doc, DocCommentStyle::TripleSlash); + w.line("///"); + } + w.line("/// Implement this protocol and pass the value where the API expects it; the"); + w.line("/// producer keeps it alive for as long as it holds the callback and may call"); + w.line("/// any method from any thread. A thrown error aborts the producer's call and"); + w.line("/// surfaces to the original caller as a foreign error (code -4)."); + if let Some(msg) = &cb.deprecated { + w.line(format!( + "@available(*, deprecated, message: \"{}\")", + msg.replace('"', "\\\"") + )); + } + w.line(format!("public protocol {name} {{")); + w.indent(); + for m in &cb.methods { + let m = camel_method(m); + { + let mut tmp = String::new(); + emit_fn_doc(&mut tmp, &m.doc, &m.params, &w.indent_str()); + w.raw(tmp); + } + if let Some(msg) = &m.deprecated { + w.line(format!( + "@available(*, deprecated, message: \"{}\")", + msg.replace('"', "\\\"") + )); + } + let params = m + .params + .iter() + .map(|p| format!("{}: {}", p.name, swift_type_ctx(&p.ty, ctx))) + .collect::>() + .join(", "); + w.line(format!( + "func {}({params}) throws{}", + swift_ident(&m.name), + ret_clause(&m, ctx) + )); + } + w.dedent(); + w.line("}"); + w.blank(); +} + +/// Render the internal box that pins one implementation behind the `void* +/// ctx` the producer holds: `Unmanaged.passRetained` when passing, one +/// `release` from the vtable's `free`. +fn render_box(w: &mut CodeWriter, cb: &CallbackInterfaceBinding, ctx: SwiftCtx) { + let proto = ctx.ty_name(local_type_name(&cb.name)); + let box_name = callback_box_name(&cb.name); + w.line(format!( + "/// Retains one `{proto}` implementation across the C boundary; the producer's" + )); + w.line("/// `free` entry releases it."); + w.line(format!("final class {box_name} {{")); + w.scope(|w| { + w.line(format!("let impl: any {proto}")); + w.line(format!("init(_ impl: any {proto}) {{ self.impl = impl }}")); + }); + w.line("}"); + w.blank(); +} + +/// Render one trampoline closure literal (the vtable entry for `m`) as a +/// labeled argument of the vtable struct initializer. +fn render_trampoline( + w: &mut CodeWriter, + m: &CallbackMethodBinding, + args: &[RetPass], + box_name: &str, + module: &str, + ctx: SwiftCtx, +) { + let camel = camel_method(m); + let formals = m + .abi_params + .iter() + .map(|s| slot_ident(&s.name)) + .collect::>() + .join(", "); + // The vtable field is spelled exactly as the C header declares it, so a + // method whose name is a C keyword carries the header's escape. + w.line(format!("{}: {{ {formals} in", c_param_name(&m.name))); + w.indent(); + w.line(format!( + "let wvBox = Unmanaged<{box_name}>.fromOpaque(ctx!).takeUnretainedValue()" + )); + let mut counter = 0usize; + let mut call_args = Vec::new(); + for (p, rp) in camel.params.iter().zip(args) { + let expr = receive_arg(w, p, rp, ctx, &mut counter); + call_args.push(format!("{}: {expr}", p.name)); + } + let call = format!( + "try wvBox.impl.{}({})", + swift_ident(&m.name), + call_args.join(", ") + ); + let default = c_default(m, ctx.c_prefix, module); + w.line("do {"); + w.indent(); + match (&m.ret, &default) { + (Some(Ty::Enum(name)), Some(_)) => { + w.line(format!( + "return {}({call}.rawValue)", + c_enum_type(name, ctx.c_prefix, module) + )); + } + (Some(_), Some(_)) => { + w.line(format!("return {call}")); + } + _ => { + w.line(call); + } + } + w.dedent(); + w.line("} catch {"); + w.indent(); + w.line("wvForeignError(out_err, error)"); + if let Some(default) = default { + w.line(format!("return {default}")); + } + w.dedent(); + w.line("}"); + w.dedent(); + w.line("},"); +} + +/// Render the process-wide vtable namespace: `value` is the C struct filled +/// with capture-free trampolines (implicitly `@convention(c)`), and +/// `pointer` is a heap cell initialized once with that value so the +/// producer can hold its address for the process lifetime. +fn render_vtable( + w: &mut CodeWriter, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + protocol: &CallbackProtocol<'_>, + ctx: SwiftCtx, +) { + let proto = ctx.ty_name(local_type_name(&cb.name)); + let box_name = callback_box_name(&cb.name); + let vtable_name = callback_vtable_name(&cb.name); + let vtable_tag = &cb.vtable_tag; + w.line(format!( + "/// The process-wide `{vtable_tag}` the producer calls `{proto}`" + )); + w.line("/// implementations through. Every entry recovers the box from `ctx`, copies"); + w.line("/// or adopts its arguments, and reports a thrown error through `out_err`"); + w.line("/// instead of unwinding."); + w.line(format!("enum {vtable_name} {{")); + w.indent(); + w.line(format!("static let value = {vtable_tag}(")); + w.indent(); + for (m, args) in cb.methods.iter().zip(&protocol.method_args) { + render_trampoline(w, m, args, &box_name, &module.path, ctx); + } + w.line("free: { ctx in"); + w.scope(|w| { + w.line(format!("Unmanaged<{box_name}>.fromOpaque(ctx!).release()")); + }); + w.line("}"); + w.dedent(); + w.line(")"); + w.line(format!( + "static let pointer: UnsafePointer<{vtable_tag}> = {{" + )); + w.scope(|w| { + w.line(format!( + "let cell = UnsafeMutablePointer<{vtable_tag}>.allocate(capacity: 1)" + )); + w.line("cell.initialize(to: value)"); + w.line("return UnsafePointer(cell)"); + }); + w.line("}()"); + w.dedent(); + w.line("}"); + w.blank(); +} + +/// Render everything one callback interface contributes at file scope: the +/// public protocol, the retaining box, and the vtable namespace whose +/// `pointer` every call site passing this interface hands to the producer. +pub(crate) fn render_swift_callback_interface( + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + ctx: SwiftCtx, +) { + let protocol = cb.protocol(&module.path, ctx.c_prefix); + let mut w = CodeWriter::four_space(); + render_protocol(&mut w, cb, ctx); + render_box(&mut w, cb, ctx); + render_vtable(&mut w, module, cb, &protocol, ctx); + out.push_str(&w.finish()); +} diff --git a/crates/weaveffi-gen-swift/src/calls.rs b/crates/weaveffi-gen-swift/src/calls.rs index 38d7c357..b130646f 100644 --- a/crates/weaveffi-gen-swift/src/calls.rs +++ b/crates/weaveffi-gen-swift/src/calls.rs @@ -1,6 +1,5 @@ -//! Callable rendering: sync and async wrapper functions, listener -//! register/unregister pairs, and the lazy sequence classes backing -//! `iter` returns. +//! Callable rendering: sync and async wrapper functions and the lazy +//! sequence classes backing `iter` returns. //! //! Parameter marshalling dispatches on the shared [`ArgPass`] plan and //! return handling on [`RetPass`], so how a value crosses the ABI is decided @@ -8,23 +7,19 @@ use std::fmt::Write; -use heck::ToLowerCamelCase; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; -use weaveffi_core::lang; use weaveffi_core::model::Ty; -use weaveffi_core::model::{ - CallShape, FnBinding, IteratorBinding, ListenerBinding, ModuleBinding, ParamBinding, -}; -use weaveffi_core::plan::{ret_pass, ArgPass, ElemFree, ErrorStrategy, RetPass}; -use weaveffi_core::utils::{local_type_name, wrapper_name}; +use weaveffi_core::model::{CallShape, FnBinding, IteratorBinding, ModuleBinding, ParamBinding}; +use weaveffi_core::plan::{ret_pass, ArgPass, ErrorStrategy, Free, RetPass}; +use weaveffi_core::utils::local_type_name; use crate::codec::{fresh, read_value_stmts, write_value_stmts}; use crate::docs::emit_fn_doc; use crate::entities::domain_stem; use crate::types::{ - c_enum_type, iterator_class_name, swift_ident, swift_scalar_default, swift_type_ctx, - swift_type_for, SwiftCtx, + c_enum_type, callback_box_name, callback_vtable_name, iterator_class_name, swift_ident, + swift_scalar_default, swift_type_ctx, swift_type_for, SwiftCtx, }; /// How a wrapper body reports a non-zero `weaveffi_error` slot. @@ -290,17 +285,21 @@ fn build_c_call_args(params: &[ParamBinding], c_prefix: &str, module_name: &str) args.push(format!("{}.ptr", p.name)); } } + // A callback interface passes the retained box (staged as + // `{name}_ctx`) plus the interface's process-wide vtable. + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback family names a callback interface"); + args.push(format!("{}_ctx", p.name)); + args.push(format!("{}.pointer", callback_vtable_name(cb))); + } ArgPass::Direct { .. } => match &p.ty { Ty::Enum(enum_name) => args.push(format!( "{}({}.rawValue)", c_enum_type(enum_name, c_prefix, module_name), p.name )), - // A typed handle is a `UInt64` token in Swift; the C slot is - // an opaque typed pointer, so reinterpret the bits. - Ty::TypedHandle(_) => { - args.push(format!("OpaquePointer(bitPattern: UInt({}))", p.name)); - } _ => args.push(p.name.clone()), }, } @@ -308,6 +307,73 @@ fn build_c_call_args(params: &[ParamBinding], c_prefix: &str, module_name: &str) args.join(", ") } +/// Emit the pre-call staging statements for `params`: byte params are copied +/// into `[UInt8]`, buffered params are packed into a writer (both handed to +/// the C call via `withUnsafeBufferPointer` later), and callback interfaces +/// are boxed with a +1 retain the producer's `free` entry releases. +fn render_arg_staging( + w: &mut CodeWriter, + params: &[ParamBinding], + ctx: SwiftCtx, + counter: &mut usize, +) { + for p in params { + match p.arg_pass() { + ArgPass::Bytes { .. } => { + w.line(format!("let {n}Bytes = Array({n})", n = p.name)); + } + ArgPass::Buffer { .. } => { + w.line(format!("var {n}Writer = WvWriter()", n = p.name)); + let writer = format!("{}Writer", p.name); + write_value_stmts(w, &p.ty, &p.name, &writer, ctx, counter); + } + ArgPass::Callback { .. } => { + let cb = + p.ty.callback_interface_name() + .expect("callback family names a callback interface"); + w.line(format!( + "let {n}_ctx = Unmanaged.passRetained({}({n})).toOpaque()", + callback_box_name(cb), + n = p.name + )); + } + ArgPass::Direct { .. } | ArgPass::String { .. } | ArgPass::Object { .. } => {} + } + } +} + +/// Open one pointer-staging closure for `p` (a string, bytes, or buffered +/// parameter), binding its `_ptr`/`_len` locals; `bind` is the prefix of the +/// opening line (`let rv: T = `, `return `, or nothing). +fn open_staging_closure(w: &mut CodeWriter, p: &ParamBinding, bind: &str) { + let n = &p.name; + match p.arg_pass() { + ArgPass::String { .. } => { + w.line(format!("{bind}{n}.withCString {{ {n}_ptr in")); + w.indent(); + } + ArgPass::Bytes { .. } => { + w.line(format!( + "{bind}{n}Bytes.withUnsafeBufferPointer {{ {n}_buf in" + )); + w.indent(); + w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); + w.line(format!("let {n}_len = {n}_buf.count")); + } + ArgPass::Buffer { .. } => { + w.line(format!( + "{bind}{n}Writer.bytes.withUnsafeBufferPointer {{ {n}_buf in" + )); + w.indent(); + w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); + w.line(format!("let {n}_len = {n}_buf.count")); + } + ArgPass::Direct { .. } | ArgPass::Object { .. } | ArgPass::Callback { .. } => { + unreachable!("only string, bytes, and buffered parameters are staged") + } + } +} + /// The Swift spelling of the raw C return value of `f`, used to annotate the /// binding when the call sits inside multi-statement staging closures (whose /// return type Swift cannot infer). @@ -327,7 +393,6 @@ fn raw_return_swift( RetPass::String => "UnsafePointer?".to_string(), RetPass::Object { .. } => "OpaquePointer?".to_string(), RetPass::Direct => match f.ret.as_ref() { - Some(Ty::TypedHandle(_)) => "OpaquePointer?".to_string(), Some(Ty::Enum(name)) => c_enum_type(name, c_prefix, module_name), Some(other) => swift_type_for(other), None => unreachable!("a direct return carries a type"), @@ -366,23 +431,7 @@ fn render_call_body( ) { let mut counter = 0usize; w.line("var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0)"); - - // Staging: byte params are copied into `[UInt8]`, buffered params are - // packed into a writer; both are handed to the C call via - // `withUnsafeBufferPointer` below. - for p in &f.params { - match p.arg_pass() { - ArgPass::Bytes { .. } => { - w.line(format!("let {n}Bytes = Array({n})", n = p.name)); - } - ArgPass::Buffer { .. } => { - w.line(format!("var {n}Writer = WvWriter()", n = p.name)); - let writer = format!("{}Writer", p.name); - write_value_stmts(w, &p.ty, &p.name, &writer, ctx, &mut counter); - } - _ => {} - } - } + render_arg_staging(w, &f.params, ctx, &mut counter); // An iterator launch returns the handle, not a value; its return plan is // the iterator protocol rather than a `RetPass`. @@ -436,30 +485,7 @@ fn render_call_body( } else { "return ".to_string() }; - let n = &p.name; - match p.arg_pass() { - ArgPass::String { .. } => { - w.line(format!("{bind}{n}.withCString {{ {n}_ptr in")); - w.indent(); - } - ArgPass::Bytes { .. } => { - w.line(format!( - "{bind}{n}Bytes.withUnsafeBufferPointer {{ {n}_buf in" - )); - w.indent(); - w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); - w.line(format!("let {n}_len = {n}_buf.count")); - } - ArgPass::Buffer { .. } => { - w.line(format!( - "{bind}{n}Writer.bytes.withUnsafeBufferPointer {{ {n}_buf in" - )); - w.indent(); - w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); - w.line(format!("let {n}_len = {n}_buf.count")); - } - ArgPass::Direct { .. } | ArgPass::Object { .. } => unreachable!(), - } + open_staging_closure(w, p, &bind); } if has_ret { w.line(format!("return {call}")); @@ -547,9 +573,6 @@ fn render_return_tail( let ty_name = ctx.ty_name(local_type_name(name)); w.line(format!("return {ty_name}(rawValue: rv.rawValue)!")); } - Some(Ty::TypedHandle(_)) => { - w.line("return UInt64(UInt(bitPattern: rv))"); - } _ => { w.line("return rv"); } @@ -557,170 +580,6 @@ fn render_return_tail( } } -/// The Swift type one callback parameter surfaces as in the user closure. -/// Interface parameters stay raw (`OpaquePointer?`): wrapping them in the -/// owning Swift class would `*_destroy` a borrowed handle on ARC release. -/// Buffered parameters are decoded to their idiomatic value types before the -/// closure is invoked. -fn swift_cb_param_type(ty: &Ty, ctx: SwiftCtx) -> String { - match ty { - Ty::Interface(_) => "OpaquePointer?".into(), - Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { - "OpaquePointer?".into() - } - other => swift_type_ctx(other, ctx), - } -} - -/// The Swift spelling of one trampoline closure formal: the ABI slot name, -/// keyword-escaped (a param named `in` yields a slot named `in`, which can't -/// bind as a closure formal unescaped). -fn cb_slot_ident(name: &str) -> String { - lang::escape_ident(name, lang::SWIFT_KEYWORDS) -} - -/// The expression converting one *direct* callback parameter's C slots into -/// the value handed to the user closure. Slot names follow the parameter's -/// precomputed ABI slots. Buffered parameters are decoded via statements -/// instead (see [`render_swift_listener`]). -fn swift_cb_direct_arg(p: &ParamBinding, ctx: SwiftCtx) -> String { - let n0 = cb_slot_ident(&p.abi[0].name); - match &p.ty { - Ty::Enum(_) => { - let local = swift_type_ctx(&p.ty, ctx); - format!("{local}(rawValue: {n0}.rawValue)!") - } - Ty::StringUtf8 | Ty::BorrowedStr => format!("String(cString: {n0}!)"), - Ty::Bytes | Ty::BorrowedBytes => { - let n1 = cb_slot_ident(&p.abi[1].name); - format!("{n0}.map {{ Data(bytes: $0, count: {n1}) }} ?? Data()") - } - Ty::TypedHandle(_) => format!("UInt64(UInt(bitPattern: {n0}))"), - // Interfaces (and nullable interfaces) stay raw borrowed pointers. - _ => n0, - } -} - -/// The register/unregister pair for one listener. The user closure is boxed -/// (`WvCallbackBox`) and retained through the C `context` pointer; the -/// capture-free trampoline closure decodes any buffered arguments, unboxes -/// the user closure, and invokes it. -pub(crate) fn render_swift_listener( - out: &mut String, - module_path: &str, - mb: &ModuleBinding, - l: &ListenerBinding, - strip_module_prefix: bool, - ctx: SwiftCtx, -) { - let Some(cb) = mb.callback(&l.event_callback) else { - unreachable!("validation guarantees the listener's callback exists"); - }; - let register_fn = swift_ident(&wrapper_name( - module_path, - &format!("register_{}", l.name), - strip_module_prefix, - )); - let unregister_fn = swift_ident(&wrapper_name( - module_path, - &format!("unregister_{}", l.name), - strip_module_prefix, - )); - - let closure_type = format!( - "({}) -> Void", - cb.params - .iter() - .map(|p| swift_cb_param_type(&p.ty, ctx)) - .collect::>() - .join(", ") - ); - - // Trampoline closure formals: every ABI slot, context last. - let slot_names: Vec = cb - .abi_params - .iter() - .map(|s| cb_slot_ident(&s.name)) - .collect(); - - let mut w = CodeWriter::four_space().with_depth(1); - { - let mut tmp = String::new(); - emit_fn_doc(&mut tmp, &l.doc, &[], " "); - w.raw(tmp); - } - w.line(format!( - "/// - Returns: A subscription id for ``{unregister_fn}(_:)``." - )); - w.line(format!( - "public static func {register_fn}(_ callback: @escaping {closure_type}) -> UInt64 {{" - )); - w.indent(); - w.line("let box = WvCallbackBox(callback)"); - w.line("let ctx = Unmanaged.passRetained(box).toOpaque()"); - w.line(format!( - "let id = {}({{ {} in", - l.register_symbol, - slot_names.join(", ") - )); - w.indent(); - w.line(format!( - "let cb = Unmanaged>.fromOpaque(context!).takeUnretainedValue().value" - )); - // Buffered arguments are borrowed (ptr, len) pairs, valid only for the - // dispatch: decode them before invoking the user closure. - let mut counter = 0usize; - let mut args: Vec = Vec::new(); - for p in &cb.params { - if let ArgPass::Buffer { ptr, len } = p.arg_pass() { - let base = p.name.to_lower_camel_case(); - w.line(format!( - "let {base}Buf = [UInt8](UnsafeBufferPointer(start: {}, count: {}))", - ptr.name, len.name - )); - w.line(format!("var {base}Reader = WvReader(bytes: {base}Buf)")); - let v = fresh(&mut counter, "v"); - let reader = format!("{base}Reader"); - read_value_stmts(&mut w, &p.ty, &v, &reader, ctx, &mut counter); - w.line(format!("{base}Reader.finish()")); - args.push(v); - } else { - args.push(swift_cb_direct_arg(p, ctx)); - } - } - w.line(format!("cb({})", args.join(", "))); - w.dedent(); - w.line("}, ctx)"); - w.line("wvListenerLock.lock()"); - w.line("wvListenerContexts[id] = ctx"); - w.line("wvListenerLock.unlock()"); - w.line("return id"); - w.dedent(); - w.line("}"); - - w.line(format!( - "/// Unregisters a listener previously registered with ``{register_fn}(_:)``." - )); - w.line(format!( - "public static func {unregister_fn}(_ id: UInt64) {{" - )); - w.indent(); - w.line(format!("{}(id)", l.unregister_symbol)); - w.line("wvListenerLock.lock()"); - w.line("let ctx = wvListenerContexts.removeValue(forKey: id)"); - w.line("wvListenerLock.unlock()"); - w.line("if let ctx = ctx {"); - w.scope(|w| { - w.line(format!( - "Unmanaged>.fromOpaque(ctx).release()" - )); - }); - w.line("}"); - w.dedent(); - w.line("}"); - out.push_str(&w.finish()); -} - /// Render one async callable as a continuation-backed `async` wrapper. A /// throwing callable is `async throws` over a throwing continuation resuming /// the module's typed error; a plain one is `async` over a never-throwing @@ -779,22 +638,11 @@ pub(crate) fn render_swift_async_function( w.line("let ctx = Unmanaged.passRetained(ContinuationRef(continuation)).toOpaque()"); // Staging: identical to the sync path. The producer copies every input - // synchronously during the launch, so pointer validity for the launch - // call's duration is sufficient. + // synchronously during the launch (and retains the callback pair for as + // long as it needs it), so pointer validity for the launch call's + // duration is sufficient. let mut counter = 0usize; - for p in &f.params { - match p.arg_pass() { - ArgPass::Bytes { .. } => { - w.line(format!("let {n}Bytes = Array({n})", n = p.name)); - } - ArgPass::Buffer { .. } => { - w.line(format!("var {n}Writer = WvWriter()", n = p.name)); - let writer = format!("{}Writer", p.name); - write_value_stmts(&mut w, &p.ty, &p.name, &writer, ctx, &mut counter); - } - _ => {} - } - } + render_arg_staging(&mut w, &f.params, ctx, &mut counter); // The launch returns void, so the staging closures carry no binding. let closure_params: Vec<&ParamBinding> = f @@ -803,28 +651,7 @@ pub(crate) fn render_swift_async_function( .filter(|p| needs_staging_closure(p)) .collect(); for p in &closure_params { - let n = &p.name; - match p.arg_pass() { - ArgPass::String { .. } => { - w.line(format!("{n}.withCString {{ {n}_ptr in")); - w.indent(); - } - ArgPass::Bytes { .. } => { - w.line(format!("{n}Bytes.withUnsafeBufferPointer {{ {n}_buf in")); - w.indent(); - w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); - w.line(format!("let {n}_len = {n}_buf.count")); - } - ArgPass::Buffer { .. } => { - w.line(format!( - "{n}Writer.bytes.withUnsafeBufferPointer {{ {n}_buf in" - )); - w.indent(); - w.line(format!("let {n}_ptr = {n}_buf.baseAddress")); - w.line(format!("let {n}_len = {n}_buf.count")); - } - ArgPass::Direct { .. } | ArgPass::Object { .. } => unreachable!(), - } + open_staging_closure(&mut w, p, ""); } let c_sym = format!("{}_async", f.c_base); @@ -989,9 +816,6 @@ fn render_async_resume_result( "contRef.value.resume(returning: {ty_name}(rawValue: result.rawValue)!)" )); } - Some(Ty::TypedHandle(_)) => { - w.line("contRef.value.resume(returning: UInt64(UInt(bitPattern: result)))"); - } _ => { w.line("contRef.value.resume(returning: result)"); } @@ -1023,7 +847,7 @@ pub(crate) fn render_swift_iterator_class( it: &IteratorBinding, ctx: SwiftCtx, ) { - let protocol = it.protocol(f); + let protocol = it.protocol(f, &mb.path, ctx.c_prefix); let class_name = iterator_class_name(it, ctx.c_prefix); let next_fn = &it.next.symbol; let destroy_fn = &it.destroy_symbol; @@ -1031,11 +855,7 @@ pub(crate) fn render_swift_iterator_class( let elem_swift = swift_type_ctx(inner, ctx); let stem = domain_stem(mb); let throws = protocol.error == ErrorStrategy::Throws; - let has_len_slot = protocol.elem_free == ElemFree::Bytes; - let is_bytes_elem = matches!(inner, Ty::Bytes | Ty::BorrowedBytes); - // A `(ptr, len)` element that isn't raw bytes is a buffered value the - // wrapper decodes. - let is_buffered_elem = has_len_slot && !is_bytes_elem; + let has_len_slot = protocol.elem_free == Free::Bytes; // `out_item` is the slot after the iterator handle; render its pointee as // the element C type so enum slots get the imported C enum @@ -1053,16 +873,17 @@ pub(crate) fn render_swift_iterator_class( .unwrap_or_default(); // The `out_item` slot declaration. - let (c_var, default): (String, String) = match inner { - _ if has_len_slot => ("UnsafePointer?".to_string(), "nil".to_string()), - Ty::StringUtf8 | Ty::BorrowedStr => { - ("UnsafePointer?".to_string(), "nil".to_string()) - } - Ty::Interface(_) | Ty::TypedHandle(_) | Ty::Optional(_) => { - ("OpaquePointer?".to_string(), "nil".to_string()) + let (c_var, default): (String, String) = match &protocol.elem { + RetPass::Bytes | RetPass::Buffer => { + ("UnsafePointer?".to_string(), "nil".to_string()) } - Ty::Enum(_) => (elem_c_type.clone(), format!("{elem_c_type}(0)")), - _ => (swift_type_for(inner), swift_scalar_default(inner)), + RetPass::String => ("UnsafePointer?".to_string(), "nil".to_string()), + RetPass::Object { .. } => ("OpaquePointer?".to_string(), "nil".to_string()), + RetPass::Direct => match inner { + Ty::Enum(_) => (elem_c_type.clone(), format!("{elem_c_type}(0)")), + _ => (swift_type_for(inner), swift_scalar_default(inner)), + }, + RetPass::Void => unreachable!("an iterator element is never void"), }; let mut w = CodeWriter::four_space(); @@ -1158,40 +979,54 @@ pub(crate) fn render_swift_iterator_class( w.dedent(); w.line("}"); - if is_buffered_elem { - // Decode the element buffer, then release it (ElemFree::Bytes). - w.line("let itemBytes = [UInt8](UnsafeBufferPointer(start: item, count: itemLen))"); - w.line("weaveffi_free_bytes(UnsafeMutablePointer(mutating: item), itemLen)"); - w.line("var itemReader = WvReader(bytes: itemBytes)"); - let mut counter = 0usize; - let v = fresh(&mut counter, "v"); - read_value_stmts(&mut w, inner, &v, "itemReader", ctx, &mut counter); - w.line("itemReader.finish()"); - w.line(format!("return {v}")); - } else if is_bytes_elem { - w.line("let element = Data(bytes: item!, count: itemLen)"); - w.line("weaveffi_free_bytes(UnsafeMutablePointer(mutating: item), itemLen)"); - w.line("return element"); - } else { - let convert = match inner { - Ty::StringUtf8 | Ty::BorrowedStr => "String(cString: item!)".to_string(), - // An owned interface element is adopted by the wrapper class, - // whose deinit owes the `_destroy`. - Ty::Interface(name) => { - format!("{}(ptr: item!)", ctx.ty_name(local_type_name(name))) - } - Ty::TypedHandle(_) => "UInt64(UInt(bitPattern: item))".to_string(), - Ty::Enum(name) => format!( - "{}(rawValue: item.rawValue)!", - ctx.ty_name(local_type_name(name)) - ), - _ => "item".to_string(), - }; - w.line(format!("let element = {convert}")); - if protocol.elem_free == ElemFree::String { + match &protocol.elem { + RetPass::Buffer => { + // Decode the element buffer, then release it (`Free::Bytes`). + w.line("let itemBytes = [UInt8](UnsafeBufferPointer(start: item, count: itemLen))"); + w.line("weaveffi_free_bytes(UnsafeMutablePointer(mutating: item), itemLen)"); + w.line("var itemReader = WvReader(bytes: itemBytes)"); + let mut counter = 0usize; + let v = fresh(&mut counter, "v"); + read_value_stmts(&mut w, inner, &v, "itemReader", ctx, &mut counter); + w.line("itemReader.finish()"); + w.line(format!("return {v}")); + } + RetPass::Bytes => { + w.line("let element = Data(bytes: item!, count: itemLen)"); + w.line("weaveffi_free_bytes(UnsafeMutablePointer(mutating: item), itemLen)"); + w.line("return element"); + } + RetPass::String => { + w.line("let element = String(cString: item!)"); w.line("weaveffi_free_string(item)"); + w.line("return element"); + } + // An owned interface element is adopted by the wrapper class, whose + // deinit owes the `_destroy`; a nullable one adopts when non-null. + RetPass::Object { nullable, .. } => { + let wrapper = ctx.ty_name(local_type_name( + inner + .interface_name() + .expect("object element names an interface"), + )); + if *nullable { + w.line(format!("return item.map {{ {wrapper}(ptr: $0) }}")); + } else { + w.line(format!("return {wrapper}(ptr: item!)")); + } } - w.line("return element"); + RetPass::Direct => match inner { + Ty::Enum(name) => { + w.line(format!( + "return {}(rawValue: item.rawValue)!", + ctx.ty_name(local_type_name(name)) + )); + } + _ => { + w.line("return item"); + } + }, + RetPass::Void => unreachable!("an iterator element is never void"), } w.dedent(); w.line("}"); diff --git a/crates/weaveffi-gen-swift/src/codec.rs b/crates/weaveffi-gen-swift/src/codec.rs index 7c80329a..c14a4402 100644 --- a/crates/weaveffi-gen-swift/src/codec.rs +++ b/crates/weaveffi-gen-swift/src/codec.rs @@ -1,10 +1,10 @@ //! Value-buffer codec emitters: the Swift statements serializing and //! decoding one value in the wire format. //! -//! Both emitters dispatch on the shared [`wire::classify`] classification, -//! so the non-obvious folds (handles as `u64` tokens, borrowed views like -//! their owned forms, records and rich enums through one user codec) are -//! decided centrally rather than re-derived from `Ty` here. +//! Both emitters dispatch on the shared [`Ty::wire`] classification, so the +//! non-obvious folds (interfaces as `u64` object tokens carrying one strong +//! reference, records and rich enums through one user codec) are decided +//! centrally rather than re-derived from `Ty` here. use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; @@ -72,10 +72,10 @@ pub(crate) fn write_value_stmts( WireType::Prim(Prim::Bytes) => { w.line(format!("{writer}.writeBytes({expr})")); } - // Both handle flavors surface as `UInt64` in Swift, so one write - // covers them. - WireType::Handle(_) => { - w.line(format!("{writer}.writeUInt64({expr})")); + // An object token carries one strong reference, so the wrapper + // writes a freshly cloned pointer and keeps its own. + WireType::Object(_) => { + w.line(format!("{writer}.writeObject({expr}.clonePtr())")); } WireType::Enum(name) => { // C-style enums cross as `i32` on the wire; a `UInt32`-raw Swift @@ -180,8 +180,13 @@ pub(crate) fn read_value_stmts( WireType::Prim(Prim::Bytes) => { w.line(format!("let {out} = {reader}.readBytes()")); } - WireType::Handle(_) => { - w.line(format!("let {out} = {reader}.readUInt64()")); + // The token's reference is adopted by a new wrapper, whose deinit + // owes the `_destroy`. + WireType::Object(name) => { + w.line(format!( + "let {out} = {}(ptr: {reader}.readObject())", + ctx.ty_name(local_type_name(name)) + )); } WireType::Enum(name) => { let local = local_type_name(name); diff --git a/crates/weaveffi-gen-swift/src/entities.rs b/crates/weaveffi-gen-swift/src/entities.rs index 53d93e00..8c8a1404 100644 --- a/crates/weaveffi-gen-swift/src/entities.rs +++ b/crates/weaveffi-gen-swift/src/entities.rs @@ -1,5 +1,6 @@ //! Entity rendering: enums, rich enums, records, interfaces, typed error -//! domains, and the recursive module walk emitting them. +//! domains, and the recursive module walk emitting them (callback interfaces +//! render through [`crate::callbacks`]). use std::collections::HashMap; @@ -14,9 +15,10 @@ use weaveffi_core::model::{ use weaveffi_core::utils::{local_type_name, wrapper_name}; use weaveffi_ir::ir::Module; +use crate::callbacks::render_swift_callback_interface; use crate::calls::{ camel_params, render_swift_async_function, render_swift_ctor_init, render_swift_function, - render_swift_iterator_class, render_swift_listener, ErrCtx, + render_swift_iterator_class, ErrCtx, }; use crate::codec::{fresh, read_value_stmts, write_value_stmts}; use crate::types::{enum_raw_type, swift_ident, swift_str, swift_type_ctx, SwiftCtx}; @@ -384,9 +386,12 @@ fn render_swift_error(out: &mut String, module: &ModuleBinding, eb: &ErrorBindin out.push_str(&w.finish()); } -/// Render one interface as a `public final class` owning its C handle: a -/// stored `ptr`, an internal ownership-adopting `init(ptr:)`, and a `deinit` -/// that calls the destroy symbol. +/// Render one interface as a `public final class` owning one strong +/// reference to its object: a stored `ptr`, an internal reference-adopting +/// `init(ptr:)`, a `deinit` that releases the reference exactly once through +/// the destroy symbol, and an internal `clonePtr()` that mints a second +/// reference through the clone symbol for positions that take ownership (an +/// object token inside a value buffer). /// /// The constructor named `new` surfaces as `public init` (throwing when the /// IDL marks it `throws`); every other constructor becomes a `public static @@ -420,6 +425,14 @@ fn render_swift_interface( w.line(format!("{}(ptr)", iface.destroy_symbol)); }); w.line("}"); + w.blank(); + w.line("/// Returns a new strong reference to the same object, for a position that"); + w.line("/// takes ownership of it (an object token inside a value buffer)."); + w.line("func clonePtr() -> OpaquePointer {"); + w.scope(|w| { + w.line(format!("{}(ptr)!", iface.clone_symbol)); + }); + w.line("}"); w.dedent(); let mut members = String::new(); @@ -507,8 +520,8 @@ fn render_swift_interface( /// Emit every file-scope type a module (and its submodules, recursively) /// contributes: the typed error surface, enums and their codecs, records and -/// their codecs, interface classes, and the sequence classes backing -/// `iter` callables. +/// their codecs, callback-interface protocols with their vtables, interface +/// classes, and the sequence classes backing `iter` callables. pub(crate) fn render_swift_module_types( out: &mut String, c_prefix: &str, @@ -537,6 +550,9 @@ pub(crate) fn render_swift_module_types( render_swift_struct(out, s, ctx); render_struct_codec(out, s, ctx); } + for cb in &mb.callback_interfaces { + render_swift_callback_interface(out, mb, cb, ctx); + } for i in &mb.interfaces { render_swift_interface(out, c_prefix, mb, i, ctx); } @@ -554,9 +570,9 @@ pub(crate) fn render_swift_module_types( } } -/// Emit the body of one module's namespace `enum`: listener pairs and -/// function wrappers at this depth, then one nested namespace `enum` per -/// submodule, re-indented to its depth. +/// Emit the body of one module's namespace `enum`: function wrappers at this +/// depth, then one nested namespace `enum` per submodule, re-indented to its +/// depth. #[allow(clippy::too_many_arguments)] pub(crate) fn render_swift_module_body( out: &mut String, @@ -572,11 +588,6 @@ pub(crate) fn render_swift_module_body( let mb = by_path[module_path]; let stem = domain_stem(mb); let mut bodies: Vec = Vec::new(); - for l in &mb.listeners { - let mut buf = String::new(); - render_swift_listener(&mut buf, module_path, mb, l, strip_module_prefix, ctx); - bodies.push(buf); - } for f in &mb.functions { let mut buf = String::new(); let f = camel_params(f); diff --git a/crates/weaveffi-gen-swift/src/lib.rs b/crates/weaveffi-gen-swift/src/lib.rs index 8d6d972e..17e401b4 100644 --- a/crates/weaveffi-gen-swift/src/lib.rs +++ b/crates/weaveffi-gen-swift/src/lib.rs @@ -10,12 +10,16 @@ //! The generated wrapper ships a small private writer/reader pair //! (`WvWriter`/`WvReader`) plus one pack and one unpack routine per record and //! rich enum, so records surface as plain Swift structs and rich enums as -//! native Swift enums with associated values. +//! native Swift enums with associated values. Objects surface as `final +//! class` wrappers owning one strong reference each, and callback interfaces +//! as Swift protocols whose implementations cross the boundary through a +//! process-wide vtable of `@convention(c)` trampolines. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] #![warn(clippy::doc_markdown)] +mod callbacks; mod calls; mod codec; mod docs; @@ -44,13 +48,13 @@ use crate::package::{ resolve_module_name, }; use crate::runtime::{ - render_buffer_runtime, render_continuation_ref, render_error_infra, render_listener_registry, + render_buffer_runtime, render_callback_support, render_continuation_ref, render_error_infra, }; use crate::types::{enum_raw_type, SwiftCtx}; /// Per-target configuration for [`SwiftGenerator`]. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct SwiftConfig { /// SwiftPM module name (default `"WeaveFFI"`). pub module_name: Option, @@ -143,7 +147,7 @@ impl LanguageBackend for SwiftGenerator { "swift" } - fn capabilities(&self) -> TargetCapabilities { + fn capabilities(&self, _config: &Self::Config) -> TargetCapabilities { TargetCapabilities::full() } @@ -231,8 +235,15 @@ impl LanguageBackend for SwiftGenerator { render_packaged_readme(module_name, &c_module, prefix, ctx, input_basename), ), ]; - // Bundle the prebuilt libraries as xcframework-ready slices. - for nb in &ctx.binaries.binaries { + // Bundle the prebuilt libraries as xcframework-ready slices. Only + // desktop slices are meaningful to a SwiftPM consumer; mobile and + // WebAssembly builds belong to other package formats. + for nb in ctx + .binaries + .binaries + .iter() + .filter(|nb| nb.platform.is_desktop()) + { let dest = dir .join("lib") .join(nb.platform.id()) @@ -306,9 +317,12 @@ fn render_swift_wrapper( render_continuation_ref(&mut out); } - let has_listeners = model.modules.iter().any(|m| !m.listeners.is_empty()); - if has_listeners { - render_listener_registry(&mut out); + let has_callbacks = model + .modules + .iter() + .any(|m| !m.callback_interfaces.is_empty()); + if has_callbacks { + render_callback_support(&mut out); } for m in &api.modules { @@ -330,3 +344,276 @@ fn render_swift_wrapper( out.push_str(&render_trailer(CommentStyle::DoubleSlash, filename)); out } + +#[cfg(test)] +mod tests { + use super::*; + use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, Function, InterfaceDef, Param, StructDef, StructField, TypeRef, + }; + + fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } + } + + fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } + } + + fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) + } + + fn opt(ty: TypeRef) -> TypeRef { + TypeRef::Optional(Box::new(ty)) + } + + /// One module exercising every object and callback shape the ABI + /// contract distinguishes: an interface with a constructor and a method, + /// nullable objects in and out, objects inside a record (directly and in + /// a list), an iterator of objects, and a callback interface taking a + /// string, an `i32`, a record, and an object, with `bool` and void + /// returns. + fn fixture() -> ResolvedApi { + ResolvedApi::assume_valid(Api { + version: weaveffi_ir::ir::CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "shop".into(), + doc: None, + functions: vec![ + func( + "find_cart", + vec![param("current", opt(named("Cart")))], + Some(opt(named("Cart"))), + ), + func( + "all_carts", + vec![], + Some(TypeRef::Iterator(Box::new(named("Cart")))), + ), + func("watch", vec![param("watcher", named("Watcher"))], None), + ], + interfaces: vec![InterfaceDef { + name: "Cart".into(), + doc: None, + deprecated: None, + constructors: vec![func( + "new", + vec![param("owner", TypeRef::StringUtf8)], + None, + )], + methods: vec![func( + "add", + vec![param("sku", TypeRef::StringUtf8)], + Some(TypeRef::Bool), + )], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Watcher".into(), + doc: None, + deprecated: None, + methods: vec![ + func( + "on_event", + vec![ + param("name", TypeRef::StringUtf8), + param("count", TypeRef::I32), + param("order", named("Order")), + param("cart", named("Cart")), + ], + Some(TypeRef::Bool), + ), + func("on_done", vec![], None), + ], + }], + structs: vec![StructDef { + name: "Order".into(), + doc: None, + deprecated: None, + fields: vec![ + StructField { + name: "cart".into(), + ty: named("Cart"), + doc: None, + }, + StructField { + name: "history".into(), + ty: TypeRef::List(Box::new(named("Cart"))), + doc: None, + }, + ], + }], + enums: vec![], + errors: None, + modules: vec![], + }], + }) + } + + fn render() -> String { + let api = fixture(); + let model = BindingModel::build(&api, "weaveffi"); + render_swift_wrapper( + &api, + &model, + "weaveffi", + true, + "weaveffi.yml", + "WeaveFFI.swift", + ) + } + + #[track_caller] + fn assert_has(out: &str, needle: &str) { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + + #[test] + fn interface_class_owns_one_reference() { + let out = render(); + assert_has(&out, "public final class Cart {"); + assert_has(&out, "let ptr: OpaquePointer"); + assert_has(&out, "weaveffi_shop_Cart_destroy(ptr)"); + assert_has(&out, "func clonePtr() -> OpaquePointer {"); + assert_has(&out, "weaveffi_shop_Cart_clone(ptr)!"); + // Exactly one destroy site: the deinit. + assert_eq!(out.matches("weaveffi_shop_Cart_destroy(").count(), 1); + // Constructor adopts the returned reference; the method borrows `ptr`. + assert_has(&out, "public init(owner: String) {"); + assert_has(&out, "weaveffi_shop_Cart_new("); + assert_has(&out, "public func add(sku: String) -> Bool {"); + assert_has(&out, "weaveffi_shop_Cart_add(ptr, "); + } + + #[test] + fn nullable_objects_map_to_optional_wrappers() { + let out = render(); + assert_has( + &out, + "public static func findCart(current: Cart?) -> Cart? {", + ); + assert_has(&out, "weaveffi_shop_find_cart(current?.ptr, &err)"); + assert_has(&out, "return rv.map { Cart(ptr: $0) }"); + } + + #[test] + fn records_carry_object_tokens() { + let out = render(); + assert_has(&out, "public struct Order {"); + assert_has(&out, "public var cart: Cart"); + assert_has(&out, "public var history: [Cart]"); + assert_has(&out, "w.writeObject(value.cart.clonePtr())"); + assert_has(&out, "w.writeObject(v0.clonePtr())"); + assert_has(&out, "Cart(ptr: r.readObject())"); + assert_has(&out, "mutating func writeObject(_ p: OpaquePointer)"); + assert_has(&out, "mutating func readObject() -> OpaquePointer {"); + } + + #[test] + fn object_iterator_adopts_each_element() { + let out = render(); + assert_has( + &out, + "public final class ShopAllCartsIterator: Sequence, IteratorProtocol {", + ); + assert_has(&out, "public func next() -> Cart? {"); + assert_has(&out, "var item: OpaquePointer? = nil"); + assert_has( + &out, + "weaveffi_shop_AllCartsIterator_next(handle, &item, &err)", + ); + assert_has(&out, "weaveffi_shop_AllCartsIterator_destroy(handle)"); + assert_has(&out, "return Cart(ptr: item!)"); + } + + #[test] + fn callback_interface_renders_protocol_box_and_vtable() { + let out = render(); + assert_has(&out, "public protocol Watcher {"); + assert_has( + &out, + "func onEvent(name: String, count: Int32, order: Order, cart: Cart) throws -> Bool", + ); + assert_has(&out, "func onDone() throws\n"); + assert_has(&out, "final class WvWatcherBox {"); + assert_has(&out, "let impl: any Watcher"); + assert_has(&out, "enum WvWatcherVtable {"); + assert_has(&out, "static let value = weaveffi_shop_Watcher_vtable("); + assert_has( + &out, + "static let pointer: UnsafePointer = {", + ); + assert_has(&out, "cell.initialize(to: value)"); + assert_has( + &out, + "on_event: { ctx, name, count, order_ptr, order_len, cart, out_err in", + ); + assert_has(&out, "on_done: { ctx, out_err in"); + assert_has(&out, "free: { ctx in"); + assert_has(&out, "Unmanaged.fromOpaque(ctx!).release()"); + assert_has( + &out, + "Unmanaged.fromOpaque(ctx!).takeUnretainedValue()", + ); + // No stray empty doc line when the interface is undocumented. + assert!(!out.contains("\n///\n/// Implement this protocol"), "{out}"); + } + + #[test] + fn callback_trampolines_decode_adopt_and_report_errors() { + let out = render(); + // The record decodes through its codec before the call; the borrowed + // string is copied and the object argument adopted at the call site. + assert_has( + &out, + "[UInt8](UnsafeBufferPointer(start: order_ptr, count: order_len))", + ); + assert_has(&out, "let v2 = wvReadOrder(&r1)"); + assert_has( + &out, + "return try wvBox.impl.onEvent(name: String(cString: name!), count: count, order: v2, cart: Cart(ptr: cart!))", + ); + assert_has(&out, "try wvBox.impl.onDone()"); + // A thrown Swift error is reported as code -4 and a default returned. + assert_has(&out, "weaveffi_error_set(outErr, -4, $0)"); + assert_has(&out, "wvForeignError(out_err, error)"); + assert_has(&out, "return false"); + } + + #[test] + fn passing_a_callback_boxes_it_with_the_static_vtable() { + let out = render(); + assert_has(&out, "public static func watch(watcher: Watcher) -> Void {"); + assert_has( + &out, + "let watcher_ctx = Unmanaged.passRetained(WvWatcherBox(watcher)).toOpaque()", + ); + assert_has( + &out, + "weaveffi_shop_watch(watcher_ctx, WvWatcherVtable.pointer, &err)", + ); + } + + #[test] + fn no_arena_or_listener_surface_remains() { + let out = render(); + assert!(!out.contains("arena"), "{out}"); + assert!(!out.contains("Listener"), "{out}"); + assert!(!out.contains("TypedHandle"), "{out}"); + assert!(!out.contains("weaveffi_arena"), "{out}"); + } +} diff --git a/crates/weaveffi-gen-swift/src/package.rs b/crates/weaveffi-gen-swift/src/package.rs index 6a3350e0..19a70957 100644 --- a/crates/weaveffi-gen-swift/src/package.rs +++ b/crates/weaveffi-gen-swift/src/package.rs @@ -118,9 +118,11 @@ pub(crate) fn render_packaged_readme( ) -> String { let prelude = render_prelude(CommentStyle::Xml, input_basename); let trailer = render_trailer(CommentStyle::Xml, "README.md"); + // Match the slices `package()` actually bundles: desktop only. let platforms: Vec = ctx .binaries .platforms() + .filter(|p| p.is_desktop()) .map(|p| format!("- `lib/{}/`", p.id())) .collect(); let platform_list = platforms.join("\n"); diff --git a/crates/weaveffi-gen-swift/src/runtime.rs b/crates/weaveffi-gen-swift/src/runtime.rs index 51a90b4c..20465c5e 100644 --- a/crates/weaveffi-gen-swift/src/runtime.rs +++ b/crates/weaveffi-gen-swift/src/runtime.rs @@ -1,13 +1,23 @@ //! The private Swift runtime prelude: the value-buffer writer/reader pair, -//! the generic brand error with its `check`/`trap` helpers, and the support -//! classes async wrappers and listeners box their state in. +//! the generic brand error with its `check`/`trap` helpers, the boxed +//! continuation async wrappers thread through the C context slot, and the +//! foreign-error helper callback-interface trampolines report through. use weaveffi_core::errors::ERROR_BRAND; +/// The `weaveffi_error.code` a callback-interface trampoline reports when the +/// consumer's implementation throws. Mirrors `weaveffi_abi::FOREIGN_ERROR_CODE`. +const FOREIGN_ERROR_CODE: i32 = -4; + /// The private Swift buffer runtime implementing the WeaveFFI value-buffer /// wire format: little-endian, packed, no alignment. `WvWriter` serializes, /// `WvReader` deserializes and traps (via `wvDecodeFailure`) on malformed /// input, which the spec routes through the same channel as a producer panic. +/// +/// Object tokens (`writeObject`/`readObject`) are the `u64` widening of an +/// interface pointer; each carries one strong reference, so the writer is +/// handed a freshly cloned pointer and the reader hands its pointer to a +/// wrapper that adopts it. const BUFFER_RUNTIME: &str = r#"/// Serializes values into the WeaveFFI value-buffer wire format /// (little-endian, packed, no alignment). struct WvWriter { @@ -49,6 +59,9 @@ struct WvWriter { bytes.append(contentsOf: v) } mutating func writeOptionFlag(_ present: Bool) { bytes.append(present ? 1 : 0) } + /// Writes an object token. `p` must be a strong reference the buffer now + /// owns (a freshly cloned pointer), never one a wrapper still holds. + mutating func writeObject(_ p: OpaquePointer) { writeUInt64(UInt64(UInt(bitPattern: p))) } } /// Traps on a malformed value buffer. Per the wire-format spec, consumers @@ -126,6 +139,14 @@ struct WvReader { default: wvDecodeFailure("option flag byte out of range") } } + /// Reads an object token: one strong reference the caller must adopt + /// into a wrapper whose deinit releases it. + mutating func readObject() -> OpaquePointer { + guard let p = OpaquePointer(bitPattern: UInt(readUInt64())) else { + wvDecodeFailure("null object token") + } + return p + } func finish() { if remaining != 0 { wvDecodeFailure("trailing bytes after value") } } @@ -142,12 +163,13 @@ pub(crate) fn render_buffer_runtime(out: &mut String) { /// Append the generic brand error enum plus the `check`/`trap` helpers every /// wrapper body reports its error slot through. /// -/// The brand error covers unknown codes, marshalling failures, and panics; -/// typed domain errors get one enum per declaring module, emitted alongside -/// that module's types. Domain error codes are validated positive, so the -/// reserved negative runtime codes (generic `-1`, panic `-2`, marshalling -/// `-3`) always reach the brand error on throwing paths and the `fatalError` -/// trap on non-throwing ones. +/// The brand error covers unknown codes, marshalling failures, panics, and +/// foreign callback failures; typed domain errors get one enum per declaring +/// module, emitted alongside that module's types. Domain error codes are +/// validated positive, so the reserved negative runtime codes (generic `-1`, +/// panic `-2`, marshalling `-3`, foreign callback failure `-4`) always reach +/// the brand error on throwing paths and the `fatalError` trap on +/// non-throwing ones. pub(crate) fn render_error_infra(out: &mut String) { out.push_str(&format!( "public enum {ERROR_BRAND}: Error, LocalizedError {{\n" @@ -177,7 +199,8 @@ pub(crate) fn render_error_infra(out: &mut String) { out.push_str("}\n\n"); // The trapping flavor for non-throwing wrappers: a non-zero code here can - // only be a producer panic or an argument-marshalling failure. + // only be a producer panic, an argument-marshalling failure, or a + // consumer callback implementation that threw. out.push_str("@inline(__always)\nfunc trap(_ err: inout weaveffi_error) {\n"); out.push_str(" if err.code != 0 {\n"); out.push_str(" let code = err.code\n"); @@ -199,16 +222,20 @@ pub(crate) fn render_continuation_ref(out: &mut String) { out.push_str("}\n\n"); } -/// Append the listener support: the closure box retained through the C -/// `context` pointer and the registry keeping it alive until unregistration. -pub(crate) fn render_listener_registry(out: &mut String) { - // A C function pointer cannot capture state, so each registered Swift - // closure is boxed and threaded through the `void* context` slot. The - // registry keeps the +1 retain alive until unregistration releases it. - out.push_str("final class WvCallbackBox {\n"); - out.push_str(" let value: T\n"); - out.push_str(" init(_ value: T) { self.value = value }\n"); +/// Append the callback-interface support: the helper every trampoline calls +/// when the consumer's implementation throws. It reports the failure through +/// the producer-owned `out_err` slot with `weaveffi_error_set`, which copies +/// the borrowed message, so no Swift allocation crosses the boundary and no +/// error ever unwinds through the C frame. +pub(crate) fn render_callback_support(out: &mut String) { + out.push_str( + "/// Reports a thrown Swift error to the producer as a foreign callback failure\n\ + /// (code -4); the producer aborts its current call with that code and message.\n\ + func wvForeignError(_ outErr: UnsafeMutablePointer?, _ error: Error) {\n\ + \x20 let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)\n", + ); + out.push_str(&format!( + " message.withCString {{ weaveffi_error_set(outErr, {FOREIGN_ERROR_CODE}, $0) }}\n" + )); out.push_str("}\n\n"); - out.push_str("var wvListenerContexts: [UInt64: UnsafeMutableRawPointer] = [:]\n"); - out.push_str("let wvListenerLock = NSLock()\n\n"); } diff --git a/crates/weaveffi-gen-swift/src/types.rs b/crates/weaveffi-gen-swift/src/types.rs index ccb51dda..a9d6b934 100644 --- a/crates/weaveffi-gen-swift/src/types.rs +++ b/crates/weaveffi-gen-swift/src/types.rs @@ -42,15 +42,16 @@ pub(crate) fn swift_type_for(t: &Ty) -> String { Ty::F32 => "Float".to_string(), Ty::F64 => "Double".to_string(), Ty::Bool => "Bool".to_string(), - Ty::StringUtf8 | Ty::BorrowedStr => "String".to_string(), - Ty::Bytes | Ty::BorrowedBytes => "Data".to_string(), - // Handles, plain and typed alike, are opaque `u64` resource tokens in - // the wire format; Swift surfaces both as `UInt64` and converts to - // the typed C pointer at the direct ABI boundary. - Ty::Handle | Ty::TypedHandle(_) => "UInt64".to_string(), - Ty::Enum(name) | Ty::Record(name) | Ty::RichEnum(name) | Ty::Interface(name) => { - local_type_name(name).to_string() - } + Ty::StringUtf8 => "String".to_string(), + Ty::Bytes => "Data".to_string(), + // An interface surfaces as its wrapper class and a callback interface + // as the protocol the consumer implements; both are file-scope types + // named after the IDL type. + Ty::Enum(name) + | Ty::Record(name) + | Ty::RichEnum(name) + | Ty::Interface(name) + | Ty::CallbackInterface(name) => local_type_name(name).to_string(), Ty::Optional(inner) => format!("{}?", swift_type_for(inner)), Ty::List(inner) => format!("[{}]", swift_type_for(inner)), Ty::Map(k, v) => format!("[{}: {}]", swift_type_for(k), swift_type_for(v)), @@ -102,9 +103,11 @@ impl SwiftCtx<'_> { /// with a module namespace (see [`SwiftCtx::ty_name`]). pub(crate) fn swift_type_ctx(t: &Ty, ctx: SwiftCtx) -> String { match t { - Ty::Record(name) | Ty::RichEnum(name) | Ty::Enum(name) | Ty::Interface(name) => { - ctx.ty_name(local_type_name(name)) - } + Ty::Record(name) + | Ty::RichEnum(name) + | Ty::Enum(name) + | Ty::Interface(name) + | Ty::CallbackInterface(name) => ctx.ty_name(local_type_name(name)), Ty::Optional(inner) => format!("{}?", swift_type_ctx(inner, ctx)), Ty::List(inner) => format!("[{}]", swift_type_ctx(inner, ctx)), Ty::Map(k, v) => format!("[{}: {}]", swift_type_ctx(k, ctx), swift_type_ctx(v, ctx)), @@ -141,6 +144,19 @@ pub(crate) fn iterator_class_name(it: &IteratorBinding, c_prefix: &str) -> Strin .to_upper_camel_case() } +/// The internal box class retaining one implementation of the callback +/// interface named `name` (possibly dot-qualified) across the C boundary: +/// `Wv{Name}Box`. +pub(crate) fn callback_box_name(name: &str) -> String { + format!("Wv{}Box", local_type_name(name)) +} + +/// The internal namespace holding the process-wide vtable for the callback +/// interface named `name` (possibly dot-qualified): `Wv{Name}Vtable`. +pub(crate) fn callback_vtable_name(name: &str) -> String { + format!("Wv{}Vtable", local_type_name(name)) +} + /// Swift literal initializing the by-value `out_item` slot used while pulling /// from an iterator whose element lowers to a C value type. pub(crate) fn swift_scalar_default(ty: &Ty) -> String { diff --git a/crates/weaveffi-gen-wasm/src/calls.rs b/crates/weaveffi-gen-wasm/src/calls.rs index fb8435b7..bd2f5e5a 100644 --- a/crates/weaveffi-gen-wasm/src/calls.rs +++ b/crates/weaveffi-gen-wasm/src/calls.rs @@ -1,38 +1,41 @@ -//! Callable wrappers: argument staging, return decoding, and the sync, -//! iterator, async, and listener surfaces. +//! Callable wrappers: argument staging, return decoding, the sync, iterator, +//! and async surfaces, and the callback-interface vtables. //! //! Each parameter's passing contract comes from [`ParamBinding::arg_pass`], -//! each result's receiving contract from [`plan::ret_pass`], and each -//! iterator element's release plan from [`plan::elem_free`], so this module -//! only spells those shared contracts in JavaScript; it never re-derives -//! them from `Ty` shapes. +//! each result's receiving contract from [`plan::ret_pass`], each iterator's +//! pull contract from [`IteratorBinding::protocol`], each async completion +//! from [`AsyncBinding::protocol`], and each callback interface's vtable +//! shape from [`CallbackInterfaceBinding::protocol`], so this module only +//! spells those shared contracts in JavaScript; it never re-derives them from +//! `Ty` shapes. -use heck::ToLowerCamelCase; use weaveffi_core::abi::CType; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; use weaveffi_core::model::{ - BindingModel, CallShape, CallbackBinding, FnBinding, IteratorBinding, ListenerBinding, - ModuleBinding, ParamBinding, + AsyncBinding, CallShape, CallbackInterfaceBinding, CallbackMethodBinding, FnBinding, + IteratorBinding, ModuleBinding, ParamBinding, }; -use weaveffi_core::plan::{self, ArgPass, ElemFree, RetPass}; +use weaveffi_core::plan::{self, ArgPass, Free, RetPass}; use weaveffi_core::utils::local_type_name; use crate::codec::{buf_read_expr, emit_buf_write_stmts}; -use crate::docs::emit_doc; -use crate::types::{js_checker_name, js_err_factory, js_fn_name, js_param_name, JsDecl}; +use crate::types::{ + emscripten_stub, js_cb_method_name, js_checker_name, js_err_factory, js_fn_name, js_param_name, + js_vtable_name, JsDecl, +}; /// The byte size of the linear-memory slot an iterator `next` writes one /// element of `ty` into: 8 for a `ptr` + `len` pair (bytes and buffered /// values), pointer or scalar width otherwise. fn iter_slot_size(ty: &Ty) -> u32 { match plan::elem_free(ty) { - ElemFree::Bytes => 8, - ElemFree::String => 4, - ElemFree::None => match ty { + Free::Bytes => 8, + Free::String => 4, + Free::None => match ty { Ty::Bool | Ty::I8 | Ty::U8 => 1, Ty::I16 | Ty::U16 => 2, - Ty::I64 | Ty::U64 | Ty::F64 | Ty::Handle => 8, + Ty::I64 | Ty::U64 | Ty::F64 => 8, _ => 4, }, } @@ -50,24 +53,50 @@ fn read_scalar_at(ty: &Ty, dv: &str, at: &str) -> String { Ty::U32 => format!("{dv}.getUint32({at}, true)"), Ty::I32 | Ty::Enum(_) => format!("{dv}.getInt32({at}, true)"), Ty::I64 => format!("{dv}.getBigInt64({at}, true)"), - Ty::U64 | Ty::Handle => format!("{dv}.getBigUint64({at}, true)"), + Ty::U64 => format!("{dv}.getBigUint64({at}, true)"), Ty::F32 => format!("{dv}.getFloat32({at}, true)"), Ty::F64 => format!("{dv}.getFloat64({at}, true)"), - // Opaque pointers (typed handles, interfaces) are i32 slots. + // Opaque pointers (interfaces) are i32 slots. _ => format!("{dv}.getUint32({at}, true)"), } } -/// A direct JS call argument for a scalar/handle value (coercing bool to 0/1 -/// and 64-bit values to `BigInt` as the wasm calling convention requires). +/// The idiomatic JS value of a by-value scalar `r` that arrived through a +/// wasm `i32` or `i64` slot (a return, an async completion, or a callback +/// parameter). Wasm integers are signed on the JS side, so unsigned widths +/// are reinterpreted (`u32` via `>>> 0`, `u64` via `BigInt.asUintN`) and +/// bool is compared against zero; every other scalar is already idiomatic. +fn js_direct_value(ty: &Ty, r: &str) -> String { + match ty { + Ty::Bool => format!("{r} !== 0"), + Ty::U32 => format!("{r} >>> 0"), + Ty::U64 => format!("BigInt.asUintN(64, {r})"), + _ => r.to_string(), + } +} + +/// A direct JS call argument for a scalar value (coercing bool to 0/1 and +/// 64-bit values to `BigInt` as the wasm calling convention requires). fn js_arg_scalar(ty: &Ty, val: &str) -> String { match ty { Ty::Bool => format!("{val} ? 1 : 0"), - Ty::I64 | Ty::U64 | Ty::Handle => format!("BigInt({val})"), + Ty::I64 | Ty::U64 => format!("BigInt({val})"), _ => val.to_string(), } } +/// The interface name inside an object-family type (`Interface` or +/// `Interface?`). +/// +/// # Panics +/// +/// Panics when `ty` is not an interface or optional interface, which cannot +/// happen for a slot classified as [`ArgPass::Object`] or [`RetPass::Object`]. +fn object_name(ty: &Ty) -> &str { + ty.interface_name() + .expect("only interface types classify as objects") +} + /// Stage one idiomatic input parameter into the Wasm ABI, dispatching on the /// shared [`ArgPass`] contract. /// @@ -76,8 +105,10 @@ fn js_arg_scalar(ty: &Ty, val: &str) -> String { /// `tmp` is a collision-free local-name base; `module` resolves record and /// rich-enum codec references. Buffered values (records, rich enums, /// optionals, lists, maps) are encoded into a value buffer and staged like -/// bytes: allocate, copy, pass `(ptr, len)`, dealloc after the call. Assumes -/// `wasm` is in scope. +/// bytes: allocate, copy, pass `(ptr, len)`, dealloc after the call. Object +/// arguments lend the wrapper's pointer for the call; callback interfaces +/// register the implementation in the loader's handle map and pass its key +/// plus the interface's static vtable. Assumes `wasm` is in scope. pub(crate) fn emit_stage_input( out: &mut String, indent: &str, @@ -112,16 +143,30 @@ pub(crate) fn emit_stage_input( args.push(format!("{tmp}_l")); cleanup.push(format!("wasm.weaveffi_dealloc({tmp}_p, {tmp}_l);")); } - // A borrowed object pointer; null means none for the nullable - // `Interface?` spelling. + // A borrowed object pointer: the wrapper keeps its own reference and + // the producer clones if it retains the object. Null means none for + // the nullable `Interface?` spelling. ArgPass::Object { nullable: true, .. } => { - args.push(format!("({value} ? {value}._handle : 0)")); + args.push(format!( + "({value} === null || {value} === undefined ? 0 : _borrow({value}))" + )); } ArgPass::Object { .. } => { - args.push(format!("{value}._handle")); + args.push(format!("_borrow({value})")); + } + // The implementation object goes into the handle map under a fresh + // integer key, which crosses as `ctx`; the vtable's `free` entry + // removes it when the producer drops its last reference. + ArgPass::Callback { .. } => { + w.line(format!("const {tmp}_ctx = _nextCbId++;")); + w.line(format!("_callbacks.set({tmp}_ctx, {value});")); + args.push(format!("{tmp}_ctx")); + args.push(js_vtable_name( + p.ty.callback_interface_name() + .expect("callback family names a callback interface"), + )); } - // By-value slot: scalars coerce per the wasm calling convention; - // typed handles pass through unwrapped. + // By-value slot: scalars coerce per the wasm calling convention. ArgPass::Direct { .. } => { args.push(js_arg_scalar(&p.ty, &value)); } @@ -129,6 +174,59 @@ pub(crate) fn emit_stage_input( out.push_str(&w.finish()); } +/// Emit the guarded producer call every synchronous entry point shares: +/// allocate the error slot (and, when `needs_len`, the `_lp` out-length slot +/// a bytes or buffered return writes through), invoke `symbol` with +/// `in_args` plus those slots, and translate anything thrown while the +/// producer was on the stack through `_trap`, which also releases the slot +/// the failed call never filled. `cleanup` (the staged-input releases) runs +/// in a `finally` so a trap leaks nothing in linear memory. The raw result +/// is bound to `bind` (declared with `let` above the `try`) when given. +/// Leaves the error slot live for the caller's checker. +fn emit_guarded_call( + w: &mut CodeWriter, + symbol: &str, + in_args: &[String], + cleanup: &[String], + needs_len: bool, + bind: Option<&str>, +) { + let mut call_args = in_args.to_vec(); + if needs_len { + w.line("const _lp = wasm.weaveffi_alloc(4);"); + call_args.push("_lp".to_string()); + } + w.line("const _err = _allocErr(wasm);"); + call_args.push("_err".to_string()); + if let Some(b) = bind { + w.line(format!("let {b};")); + } + let call = format!("wasm.{symbol}({})", call_args.join(", ")); + w.block("try {", "} catch (e) {", |w| { + match bind { + Some(b) => w.line(format!("{b} = {call};")), + None => w.line(format!("{call};")), + }; + }); + w.scope(|w| { + if needs_len { + w.line("wasm.weaveffi_dealloc(_lp, 4);"); + } + w.line("throw _trap(wasm, _err, e);"); + }); + if cleanup.is_empty() { + w.line("}"); + } else { + w.line("} finally {"); + w.scope(|w| { + for stmt in cleanup { + w.line(stmt); + } + }); + w.line("}"); + } +} + /// Emit the body that invokes `symbol` with the already-staged `in_args`, /// runs `cleanup`, routes the error slot through the `checker` helper, and /// decodes/returns the idiomatic value for `ret`. A buffered or bytes return @@ -152,24 +250,14 @@ pub(crate) fn emit_return_decode( ); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); - let mut call_args = in_args.to_vec(); - if needs_len { - w.line("const _lp = wasm.weaveffi_alloc(4);"); - call_args.push("_lp".to_string()); - } - w.line("const _err = _allocErr(wasm);"); - call_args.push("_err".to_string()); - - let call = format!("wasm.{symbol}({})", call_args.join(", ")); - if ret.is_some() { - w.line(format!("const _r = {call};")); - } else { - w.line(format!("{call};")); - } - - for stmt in cleanup { - w.line(stmt); - } + emit_guarded_call( + &mut w, + symbol, + in_args, + cleanup, + needs_len, + ret.map(|_| "_r"), + ); w.line(format!("{checker}(wasm, _err);")); w.line("_freeErr(wasm, _err);"); out.push_str(&w.finish()); @@ -177,21 +265,24 @@ pub(crate) fn emit_return_decode( emit_decode_value(out, indent, ret, "_r", module, prefix); } -/// The interface name inside a return type whose [`RetPass`] is `Object`. -/// -/// # Panics -/// -/// Panics when `ret` is not an interface or optional interface, which cannot -/// happen for a return classified as [`RetPass::Object`]. -fn object_ret_name(ret: &Ty) -> &str { - match ret { - Ty::Interface(name) => name, - Ty::Optional(inner) => match inner.as_ref() { - Ty::Interface(name) => name, - _ => unreachable!("non-interface optionals are buffered"), - }, - _ => unreachable!("only interface returns classify as RetPass::Object"), - } +/// Emit the guarded call of an interface's canonical constructor at +/// `indent`: the staged inputs, the producer call with trap translation, the +/// `checker`, and the adoption of the returned reference into `this`. +pub(crate) fn emit_constructor_call( + out: &mut String, + indent: &str, + symbol: &str, + in_args: &[String], + cleanup: &[String], + checker: &str, + destroy: &str, +) { + let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); + emit_guarded_call(&mut w, symbol, in_args, cleanup, false, Some("_r")); + w.line(format!("{checker}(wasm, _err);")); + w.line("_freeErr(wasm, _err);"); + w.line(format!("_adopt(this, _r, {destroy});")); + out.push_str(&w.finish()); } /// Emit the `return ...;` (if any) that converts the raw result `r` (plus the @@ -199,7 +290,8 @@ fn object_ret_name(ret: &Ty) -> &str { /// idiomatic value, dispatching on the shared [`RetPass`] contract. A /// buffered return is copied out of linear memory, released with /// `weaveffi_free_bytes`, and decoded through the buffer reader, which -/// rejects malformed encodings. +/// rejects malformed encodings. An object return transfers one strong +/// reference, adopted by the wrapper class's `_wrap`. fn emit_decode_value( out: &mut String, indent: &str, @@ -227,11 +319,8 @@ fn emit_decode_value( w.line("_rd.end();"); w.line("return _out;"); } - RetPass::Direct if matches!(ret, Ty::Bool) => { - w.line(format!("return {r} !== 0;")); - } RetPass::Direct => { - w.line(format!("return {r};")); + w.line(format!("return {};", js_direct_value(ret, r))); } RetPass::String => { w.line(format!("return _takeCStr(wasm, {r});")); @@ -242,7 +331,7 @@ fn emit_decode_value( w.line(format!("return _takeBytes(wasm, {r}, _len);")); } RetPass::Object { nullable, .. } => { - let cls = local_type_name(object_ret_name(ret)); + let cls = local_type_name(object_name(ret)); if nullable { w.line(format!("return {r} === 0 ? null : {cls}._wrap({r});")); } else { @@ -255,10 +344,11 @@ fn emit_decode_value( /// Emit one callable in the shape its [`CallShape`] and the mode call for: /// iterator members return a lazy JS iterator, async members return a -/// `Promise` (or an explicit throwing stub in Emscripten mode), and -/// everything else is a plain synchronous wrapper. `self_arg` threads the -/// instance handle for interface methods; `mb` supplies the module's error -/// domain for the throws split and the module path for codec references. +/// `Promise`, and everything else is a plain synchronous wrapper. In +/// Emscripten mode an async callable or one taking a callback interface is an +/// explicit throwing stub instead. `self_arg` threads the instance handle +/// for interface methods; `mb` supplies the module's error domain for the +/// throws split and the module path for codec references. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_js_callable( out: &mut String, @@ -271,126 +361,89 @@ pub(crate) fn emit_js_callable( emscripten: bool, ) { match &f.shape { + _ if emscripten && emscripten_stub(f) => emit_js_emscripten_stub(out, f, decl, indent), CallShape::Iterator(ib) => { - emit_js_iterator_function_wrapper(out, mb, f, ib, decl, self_arg, indent); + emit_js_iterator_function_wrapper(out, mb, f, ib, decl, self_arg, indent, prefix); } - _ if f.is_async && emscripten => emit_js_async_stub(out, f, decl, indent), - _ if f.is_async => { - emit_js_async_function_wrapper(out, mb, f, decl, self_arg, indent, prefix); + CallShape::Async(ab) => { + emit_js_async_function_wrapper(out, mb, f, ab, decl, self_arg, indent, prefix); } - _ => emit_js_function_wrapper(out, mb, f, decl, self_arg, indent, prefix), + CallShape::Sync(_) => emit_js_function_wrapper(out, mb, f, decl, self_arg, indent, prefix), } } -/// Async functions are unsupported in Emscripten mode: the trampoline -/// registration relies on `WebAssembly.Function` and a growable -/// `__indirect_function_table`, neither of which an Emscripten module exposes -/// portably. Each async entry point becomes an explicit stub that throws at -/// call time, so the gap is impossible to miss from JS even though the -/// `.d.ts` deliberately omits it (a compile-time error for TS users). -fn emit_js_async_stub(out: &mut String, f: &FnBinding, decl: JsDecl, indent: &str) { +/// Async functions and callback interfaces are unsupported in Emscripten +/// mode: both rely on `WebAssembly.Function` and a growable +/// `__indirect_function_table` to install trampolines, neither of which an +/// Emscripten module exposes portably. Each affected entry point becomes an +/// explicit stub that throws at call time, so the gap is impossible to miss +/// from JS even though the `.d.ts` deliberately omits it (a compile-time +/// error for TS users). +fn emit_js_emscripten_stub(out: &mut String, f: &FnBinding, decl: JsDecl, indent: &str) { let js_params: Vec = f.params.iter().map(js_param_name).collect(); let name = js_fn_name(f); + let what = if f.is_async { + format!("async function '{name}'") + } else { + format!("function '{name}' (it takes a callback interface)") + }; let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); w.block( format!("{}{name}({}) {{", decl.prefix(), js_params.join(", ")), decl.close(), |w| { w.line(format!( - "throw new Error(\"weaveffi: async function '{name}' is not supported in \ - Emscripten mode; use the wasm32-unknown-unknown loader or a native \ - target\");" + "throw new Error(\"weaveffi: {what} is not supported in Emscripten mode; \ + use the wasm32-unknown-unknown loader or a native target\");" )); }, ); out.push_str(&w.finish()); } -/// Listeners are unsupported in Emscripten mode: their trampolines rely on -/// `WebAssembly.Function` and a growable `__indirect_function_table`, exactly -/// like the async machinery. Each register/unregister entry point becomes an -/// explicit stub that throws at call time, so the gap is impossible to miss -/// from JS even though the `.d.ts` deliberately omits the pair (a -/// compile-time error for TS users). -pub(crate) fn emit_js_listener_stub(out: &mut String, l: &ListenerBinding, indent: &str) { - let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); - for op in ["register", "unregister"] { - let name = format!("{op}_{}", l.name).to_lower_camel_case(); - w.block(format!("{name}() {{"), "},", |w| { - w.line(format!( - "throw new Error(\"weaveffi: listener '{}' is not supported in \ - Emscripten mode; use the wasm32-unknown-unknown loader or a native \ - target\");", - l.name - )); - }); - } - out.push_str(&w.finish()); -} - -/// Every callback typedef referenced by at least one listener, paired with -/// its declaring module's path and deduplicated by `c_fn_type` in declaration -/// order. Each gets one long-lived trampoline in the wasm function table, -/// shared by all of its subscriptions (the per-subscription context id -/// disambiguates), so register/unregister churn never grows the table. -pub(crate) fn collect_listener_callbacks(model: &BindingModel) -> Vec<(&str, &CallbackBinding)> { - let mut cbs: Vec<(&str, &CallbackBinding)> = Vec::new(); - for m in &model.modules { - for l in &m.listeners { - let Some(cb) = m.callback(&l.event_callback) else { - // Validation guarantees the referenced callback exists - // in-module. - unreachable!("listener '{}' references unknown callback", l.name); - }; - if !cbs.iter().any(|(_, c)| c.c_fn_type == cb.c_fn_type) { - cbs.push((m.path.as_str(), cb)); - } - } - } - cbs -} - /// The wasm value type of one C ABI slot: pointers and 32-bit-or-smaller -/// scalars are `i32` on wasm32, 64-bit integers and handles widen to `i64`, -/// and floats keep their width. -fn cb_slot_wasm_type(ty: &CType) -> &'static str { +/// scalars are `i32` on wasm32, 64-bit integers widen to `i64`, and floats +/// keep their width. +fn slot_wasm_type(ty: &CType) -> &'static str { match ty { - CType::Int64 | CType::Uint64 | CType::Handle => "i64", + CType::Int64 | CType::Uint64 => "i64", CType::Float => "f32", CType::Double => "f64", _ => "i32", } } -/// The JS-side name of the long-lived trampoline registered for one callback -/// typedef. `c_fn_type` is a C identifier, so it is a valid JS identifier -/// suffix. -pub(crate) fn js_listener_tramp_name(c_fn_type: &str) -> String { - format!("_lsnPtr_{c_fn_type}") +/// The quoted wasm value-type list for a slot sequence, as the +/// `WebAssembly.Function` signature spells it. +fn wasm_type_list<'a>(slots: impl Iterator) -> String { + slots + .map(|t| format!("'{}'", slot_wasm_type(t))) + .collect::>() + .join(", ") } -/// Emit the statements decoding one callback argument from its raw wasm slot -/// values into the idiomatic JS value (bound to `target`) the subscriber -/// sees, dispatching on the shared [`ArgPass`] contract. +/// Emit the statements decoding one callback-method argument from its raw +/// wasm slot values into the idiomatic JS value (bound to `target`) the +/// implementation sees, dispatching on the shared [`RetPass`] contract the +/// callback protocol assigns to each parameter. /// -/// The producer owns every argument for the duration of the dispatch (the -/// `emit_*` helper frees lowered payloads after the last subscriber returns), -/// so this is the borrowing side of the marshalling table: strings, byte -/// buffers, and buffered values are copied or decoded out of linear memory -/// and never freed here, and interface pointers are wrapped without taking -/// ownership. Assumes `wasm` in scope. +/// Strings, bytes, and buffered values are borrowed for the duration of the +/// dispatch: they're copied or decoded out of linear memory and never freed +/// here. Object arguments transfer one strong reference, adopted by the +/// wrapper class's `_wrap` (null means none for `Interface?`). Assumes `wasm` +/// in scope. fn emit_cb_param_decode( - out: &mut String, - indent: &str, + w: &mut CodeWriter, p: &ParamBinding, + pass: &RetPass, slots: &[String], target: &str, module: &str, ) { - let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); let a = &slots[0]; - match p.arg_pass() { - ArgPass::Buffer { .. } => { + match pass { + RetPass::Void => unreachable!("a parameter always has a type"), + RetPass::Buffer => { let b = &slots[1]; w.line(format!( "const {target}_b = ({a} === 0 || {b} === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, {a}, {b}).slice();" @@ -402,26 +455,21 @@ fn emit_cb_param_decode( )); w.line(format!("{target}_r.end();")); } - ArgPass::Direct { .. } if matches!(p.ty, Ty::Bool) => { - w.line(format!("const {target} = {a} !== 0;")); - } - ArgPass::Direct { .. } => { - w.line(format!("const {target} = {a};")); + RetPass::Direct => { + w.line(format!("const {target} = {};", js_direct_value(&p.ty, a))); } - ArgPass::String { .. } => { + RetPass::String => { w.line(format!("const {target} = _readCStr(wasm, {a});")); } - ArgPass::Bytes { .. } => { + RetPass::Bytes => { let b = &slots[1]; w.line(format!( "const {target} = ({a} === 0 || {b} === 0) ? new Uint8Array(0) : new Uint8Array(wasm.memory.buffer, {a}, {b}).slice();" )); } - // Only `Interface` and `Interface?` classify as objects: a borrowed - // (possibly nullable) pointer wrapped without taking ownership. - ArgPass::Object { nullable, .. } => { - let cls = local_type_name(object_ret_name(&p.ty)); - if nullable { + RetPass::Object { nullable, .. } => { + let cls = local_type_name(object_name(&p.ty)); + if *nullable { w.line(format!( "const {target} = {a} === 0 ? null : {cls}._wrap({a});" )); @@ -430,94 +478,161 @@ fn emit_cb_param_decode( } } } - out.push_str(&w.finish()); } -/// Emit the long-lived trampoline for one callback typedef at `indent` -/// (loader scope). The trampoline's wasm signature mirrors the callback's ABI -/// slots (the trailing `void* context` slot carries the subscription's -/// context id); it looks up the subscription, decodes each argument per the -/// borrowing contract, and invokes the JS callback synchronously. `module` is -/// the callback's declaring module path, used to resolve codec references. -pub(crate) fn emit_js_listener_trampoline( - out: &mut String, +/// The `return` statement handing a callback method's JS result back through +/// the C return slot, and the default returned after a foreign failure. +/// Returns are restricted to the direct family (or void) by validation, so +/// only bool (0/1) and 64-bit integers (`BigInt`) need a coercion. +fn cb_return(ret: Option<&Ty>, call: &str) -> (String, Option<&'static str>) { + match ret { + None => (format!("{call};"), None), + Some(Ty::Bool) => (format!("return {call} ? 1 : 0;"), Some("return 0;")), + Some(Ty::I64 | Ty::U64) => (format!("return BigInt({call});"), Some("return 0n;")), + Some(_) => (format!("return {call};"), Some("return 0;")), + } +} + +/// The JS-side name of the trampoline registered for one vtable entry. +fn js_cb_tramp_name(cb: &CallbackInterfaceBinding, method: &str) -> String { + format!("_cb_{}_{method}", cb.name) +} + +/// Emit one callback method's trampoline at `indent` (loader scope): a +/// `WebAssembly.Function` whose signature mirrors the vtable entry's C slots +/// ([`CallbackMethodBinding::abi_params`] and `abi_ret`). It looks up the +/// implementation by `ctx`, decodes each argument per the borrowing contract, +/// calls the JS method synchronously, and coerces the result into the C +/// return. Any exception is caught, reported through `out_err` as +/// `FOREIGN_ERROR_CODE`, and replaced by a default value, so nothing unwinds +/// through the wasm frame. +fn emit_js_callback_trampoline( + w: &mut CodeWriter, + cb: &CallbackInterfaceBinding, + m: &CallbackMethodBinding, + args: &[RetPass], module: &str, - cb: &CallbackBinding, - indent: &str, ) { - let tramp = js_listener_tramp_name(&cb.c_fn_type); - let param_types: Vec = cb - .abi_params - .iter() - .map(|p| format!("'{}'", cb_slot_wasm_type(&p.ty))) - .collect(); - // Positional slot names: one per ABI slot, with the trailing context slot - // named _ctx. - let mut slot_names: Vec = (0..cb.abi_params.len() - 1) - .map(|i| format!("a{i}")) - .collect(); - slot_names.push("_ctx".to_string()); + let tramp = js_cb_tramp_name(cb, &m.name); + let param_types = wasm_type_list(m.abi_params.iter().map(|p| &p.ty)); + let result_types = match &m.abi_ret { + CType::Void => String::new(), + ret => format!("'{}'", slot_wasm_type(ret)), + }; + // Positional slot names: `_ctx`, one per parameter slot, then `_err`. + let mut slot_names = vec!["_ctx".to_string()]; + slot_names.extend((0..m.abi_params.len() - 2).map(|i| format!("a{i}"))); + slot_names.push("_err".to_string()); - let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); w.block( format!( - "const {tramp} = _registerTrampoline(_table, [{}], ({}) => {{", - param_types.join(", "), + "const {tramp} = _registerTrampoline(_table, [{param_types}], [{result_types}], ({}) => {{", slot_names.join(", ") ), "});", |w| { - w.line("const _l = _listeners.get(_ctx);"); - w.line("if (_l === undefined) return;"); - let inner = w.indent_str(); - let mut slot_idx = 0usize; - let mut call_args: Vec = Vec::new(); - for (i, p) in cb.params.iter().enumerate() { - let n = p.abi.len(); - let slots = &slot_names[slot_idx..slot_idx + n]; - slot_idx += n; - let target = format!("_p{i}"); - let mut tmp = String::new(); - emit_cb_param_decode(&mut tmp, &inner, p, slots, &target, module); - w.raw(tmp); - call_args.push(target); - } - w.line(format!("_l.callback({});", call_args.join(", "))); + w.block("try {", "} catch (e) {", |w| { + w.line("const _impl = _callbacks.get(_ctx);"); + let mut slot_idx = 1usize; + let mut call_args: Vec = Vec::new(); + for (i, (p, pass)) in m.params.iter().zip(args).enumerate() { + let n = p.abi.len(); + let slots = &slot_names[slot_idx..slot_idx + n]; + slot_idx += n; + let target = format!("_p{i}"); + emit_cb_param_decode(w, p, pass, slots, &target, module); + call_args.push(target); + } + // A callback that already failed during this producer call + // is not consulted again: the producer can't unwind on + // wasm32, so it keeps running until its thunk reports the + // failure, and the implementation must not observe those + // extra invocations. Arguments were still decoded above so + // that any object reference the producer transferred is + // adopted by a wrapper (and reclaimed by the finalizer + // backstop) rather than leaked. + w.block("if (_pendingForeign !== null) {", "}", |w| { + w.line("_reportForeign(wasm, _err, _pendingForeign);"); + if let Some(default) = cb_return(m.ret.as_ref(), "").1 { + w.line(default); + } else { + w.line("return;"); + } + }); + let call = format!( + "_impl.{}({})", + js_cb_method_name(&m.name), + call_args.join(", ") + ); + w.line(cb_return(m.ret.as_ref(), &call).0); + }); + w.scope(|w| { + w.line("_setForeignError(wasm, _err, e);"); + if let Some(default) = cb_return(m.ret.as_ref(), "").1 { + w.line(default); + } + }); + w.line("}"); }, ); - out.push_str(&w.finish()); } -/// Emit one listener's register/unregister pair as module-object members. -/// -/// `register` allocates a context id, hands the shared trampoline and that id -/// to the producer's `register_*` symbol, and returns the context id as the -/// consumer-facing subscription id (a plain number; the producer's `uint64_t` -/// id stays internal so the public surface avoids `BigInt`). `unregister` -/// releases both sides and is a no-op for an unknown id. -pub(crate) fn emit_js_listener_api(out: &mut String, l: &ListenerBinding, indent: &str) { - let tramp = js_listener_tramp_name(&l.callback_c_fn_type); - let register_name = format!("register_{}", l.name).to_lower_camel_case(); - let unregister_name = format!("unregister_{}", l.name).to_lower_camel_case(); +/// Emit the static vtable for one callback interface at `indent` (loader +/// scope): one trampoline per method in declaration order plus the trailing +/// `free`, each installed once in the wasm function table for the life of +/// the instance, then the vtable struct itself allocated in linear memory +/// with the module's allocator and filled with the table indices (wasm32 +/// function pointers are 4-byte table indices, so the struct is packed +/// `4 * (methods + 1)` bytes). The struct's address is what +/// [`emit_stage_input`] passes as `{name}_vtable`. +pub(crate) fn emit_js_callback_vtable( + out: &mut String, + module: &ModuleBinding, + cb: &CallbackInterfaceBinding, + prefix: &str, + indent: &str, +) { + let proto = cb.protocol(&module.path, prefix); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); - let mut doc = String::new(); - emit_doc(&mut doc, &l.doc, indent); - w.raw(doc); - w.block(format!("{register_name}(callback) {{"), "},", |w| { - w.line("const _id = _nextLsnId++;"); + w.line(format!( + "// Trampolines for the `{}` callback interface: one WebAssembly.Function", + cb.name + )); + w.line("// per vtable entry, mirroring the C signature of each method. Borrowed"); + w.line("// arguments are decoded before the JS method runs; object arguments"); + w.line("// are adopted; a thrown exception becomes a -4 error on out_err."); + for (m, args) in cb.methods.iter().zip(&proto.method_args) { + emit_js_callback_trampoline(&mut w, cb, m, args, &module.path); + } + let free = js_cb_tramp_name(cb, "free"); + w.line("// `free(ctx)`: the producer dropped its last reference; forget the"); + w.line("// implementation so it can be collected."); + w.line(format!( + "const {free} = _registerTrampoline(_table, ['i32'], [], (_ctx) => {{ _callbacks.delete(_ctx); }});" + )); + let vtable = js_vtable_name(&cb.name); + let size = 4 * (cb.methods.len() + 1); + w.line(format!( + "// The one static `{}` for this instance, filled with the table indices", + cb.vtable_tag + )); + w.line("// above in declaration order, then `free`."); + w.line(format!("const {vtable} = wasm.weaveffi_alloc({size});")); + w.block("{", "}", |w| { + w.line("const _dv = new DataView(wasm.memory.buffer);"); + for (i, m) in cb.methods.iter().enumerate() { + w.line(format!( + "_dv.setUint32({vtable} + {}, {}, true);", + 4 * i, + js_cb_tramp_name(cb, &m.name) + )); + } w.line(format!( - "const _rid = wasm.{}({tramp}, _id);", - l.register_symbol + "_dv.setUint32({vtable} + {}, {free}, true);", + 4 * cb.methods.len() )); - w.line("_listeners.set(_id, { callback, rid: _rid });"); - w.line("return _id;"); - }); - w.block(format!("{unregister_name}(id) {{"), "},", |w| { - w.line("const _l = _listeners.get(id);"); - w.line("if (_l === undefined) return;"); - w.line("_listeners.delete(id);"); - w.line(format!("wasm.{}(_l.rid);", l.unregister_symbol)); }); + w.blank(); out.push_str(&w.finish()); } @@ -580,47 +695,52 @@ fn emit_js_function_wrapper( } /// The `(w, p) => ...` closure converting one element out of an iterator's -/// `next` slot at pointer `p`, applying the per-element release plan from -/// [`plan::elem_free`]: a string is copied out of wasm memory and freed with -/// `free_string`, a bytes or buffered element is copied out of its -/// `ptr` + `len` pair and freed with `free_bytes` (buffered elements are then -/// decoded through the buffer reader), an interface pointer is adopted by -/// `_wrap`, and a by-value element is read directly. -fn js_iter_decode_closure(elem: &Ty, module: &str) -> String { - match plan::elem_free(elem) { - ElemFree::Bytes if elem.is_buffered() => { +/// `next` slot at pointer `p`, applying the per-element receiving plan +/// ([`IteratorProtocol::elem`](weaveffi_core::plan::IteratorProtocol::elem)): +/// a string is copied out of wasm memory and freed with `free_string`, a +/// bytes or buffered element is copied out of its `ptr` + `len` pair and +/// freed with `free_bytes` (buffered elements are then decoded through the +/// buffer reader), an object pointer is adopted by `_wrap`, and a by-value +/// element is read directly. +fn js_iter_decode_closure(elem: &Ty, pass: &RetPass, module: &str) -> String { + match pass { + RetPass::Void => unreachable!("iterator elements always have a type"), + RetPass::Buffer => { let read = buf_read_expr(elem, module, "_rd"); format!( "(w, p) => {{ const dv = new DataView(w.memory.buffer); const _rd = new _BufReader(_takeBytes(w, dv.getUint32(p, true), dv.getUint32(p + 4, true))); const _v = {read}; _rd.end(); return _v; }}" ) } - ElemFree::Bytes => { + RetPass::Bytes => { "(w, p) => { const dv = new DataView(w.memory.buffer); return _takeBytes(w, dv.getUint32(p, true), dv.getUint32(p + 4, true)); }".into() } - ElemFree::String => { + RetPass::String => { "(w, p) => _takeCStr(w, new DataView(w.memory.buffer).getUint32(p, true))".into() } - ElemFree::None => match elem { - Ty::Interface(name) => { - let cls = local_type_name(name); + RetPass::Object { nullable, .. } => { + let cls = local_type_name(object_name(elem)); + if *nullable { + format!("(w, p) => {{ const h = new DataView(w.memory.buffer).getUint32(p, true); return h === 0 ? null : {cls}._wrap(h); }}") + } else { format!("(w, p) => {cls}._wrap(new DataView(w.memory.buffer).getUint32(p, true))") } - scalar => { - let read = read_scalar_at(scalar, "new DataView(w.memory.buffer)", "p"); - format!("(w, p) => {read}") - } - }, + } + RetPass::Direct => { + let read = read_scalar_at(elem, "new DataView(w.memory.buffer)", "p"); + format!("(w, p) => {read}") + } } } /// Emit an iterator-returning function as a method returning a lazy JS /// iterator over the producer's iterator handle (the TypeScript type is /// `IterableIterator`). The wrapper issues one producer `next` call per -/// consumer step, converts and frees each element per its plan, and destroys -/// the handle exactly once: on exhaustion, on a `next` error, or from -/// `return()` when the consumer stops early. Both the launch call and every -/// `next` route their out-err slot through the throws-aware checker, so a -/// throwing function's domain errors keep their typed class. +/// consumer step, converts and frees (or adopts) each element per its plan, +/// and destroys the handle exactly once: on exhaustion, on a `next` error, or +/// from `return()` when the consumer stops early. Both the launch call and +/// every `next` route their out-err slot through the throws-aware checker, so +/// a throwing function's domain errors keep their typed class. +#[allow(clippy::too_many_arguments)] fn emit_js_iterator_function_wrapper( out: &mut String, mb: &ModuleBinding, @@ -629,8 +749,10 @@ fn emit_js_iterator_function_wrapper( decl: JsDecl, self_arg: Option<&str>, indent: &str, + prefix: &str, ) { let body = format!("{indent} "); + let proto = ib.protocol(f, &mb.path, prefix); let js_params: Vec = f.params.iter().map(js_param_name).collect(); let checker = js_checker_name(f, mb.error.as_ref()); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); @@ -662,11 +784,10 @@ fn emit_js_iterator_function_wrapper( if f.cancellable { args.push("0".to_string()); } - args.push("_err".to_string()); let slot_size = iter_slot_size(&ib.elem); // A `ptr` + `len` element (bytes or buffered) writes through two out // slots; the second lives 4 bytes past the first. - let two_slot = matches!(plan::elem_free(&ib.elem), ElemFree::Bytes); + let two_slot = matches!(proto.elem_free, Free::Bytes); let next_call = if two_slot { format!( "(it, slot, ep) => wasm.{}(it, slot, slot + 4, ep),", @@ -675,18 +796,10 @@ fn emit_js_iterator_function_wrapper( } else { format!("(it, slot, ep) => wasm.{}(it, slot, ep),", ib.next.symbol) }; - let decode = js_iter_decode_closure(&ib.elem, &mb.path); + let decode = js_iter_decode_closure(&ib.elem, &proto.elem, &mb.path); w.scope(|w| { w.raw(&staged); - w.line("const _err = _allocErr(wasm);"); - w.line(format!( - "const _it = wasm.{}({});", - f.c_base, - args.join(", ") - )); - for stmt in &cleanup { - w.line(stmt); - } + emit_guarded_call(w, &f.c_base, &args, &cleanup, false, Some("_it")); w.line(format!("{checker}(wasm, _err);")); w.line("_freeErr(wasm, _err);"); w.line(format!( @@ -700,56 +813,16 @@ fn emit_js_iterator_function_wrapper( out.push_str(&w.finish()); } -/// The wasm callback param-type list for an async function with the given -/// return: always `(ctx i32, err i32, ...result)`. Pointers are i32 on -/// wasm32; only `i64`/`u64` widen to i64; a buffered result arrives as a -/// borrowed `ptr` + `len` pair (two i32 slots). -pub(crate) fn async_cb_wasm_params(returns: Option<&Ty>) -> Vec<&'static str> { - let mut params = vec!["i32", "i32"]; - let Some(ty) = returns else { - return params; - }; - if ty.is_buffered() { - params.push("i32"); - params.push("i32"); - return params; - } - match ty { - Ty::I8 - | Ty::I16 - | Ty::I32 - | Ty::U8 - | Ty::U16 - | Ty::U32 - | Ty::Bool - | Ty::Enum(_) - | Ty::StringUtf8 - | Ty::BorrowedStr - | Ty::Interface(_) - | Ty::TypedHandle(_) - | Ty::Iterator(_) - // Only `Interface?` reaches here: a nullable object pointer. - | Ty::Optional(_) => { - params.push("i32"); - } - Ty::I64 | Ty::U64 | Ty::Handle => { - params.push("i64"); - } - Ty::F32 => { - params.push("f32"); - } - Ty::F64 => { - params.push("f64"); - } - Ty::Bytes | Ty::BorrowedBytes => { - params.push("i32"); - params.push("i32"); - } - Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { - unreachable!("buffered type handled above") - } - } - params +/// The wasm value types of an async completion callback's slots, straight +/// from the lowered [`AsyncBinding::callback_params`]: `(context, err, +/// )`, where pointers are `i32` on wasm32, 64-bit integers +/// widen to `i64`, and a bytes or buffered result arrives as an owned +/// `ptr` + `len` pair (two `i32` slots). +pub(crate) fn async_cb_wasm_params(a: &AsyncBinding) -> Vec<&'static str> { + a.callback_params + .iter() + .map(|p| slot_wasm_type(&p.ty)) + .collect() } /// Emit the `unwrap` clause for an async result, or none for a void/raw-scalar @@ -762,15 +835,15 @@ pub(crate) fn async_cb_wasm_params(returns: Option<&Ty>) -> Vec<&'static str> { /// The unwrap runs inside the completion callback, so it follows the async /// ownership contract: string, byte, and value buffers are consumer-owned, /// so they are deep-copied or decoded out of wasm memory and then released -/// through the runtime free symbols. Owned interface results are adopted by -/// their wrapper class instead. +/// through the runtime free symbols. Object results transfer one strong +/// reference, adopted by the wrapper class instead. fn emit_async_unwrap( out: &mut String, indent: &str, ret: Option<&Ty>, + pass: &RetPass, mk_err: Option<&str>, module: &str, - prefix: &str, ) { let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); let base = match mk_err { @@ -784,7 +857,7 @@ fn emit_async_unwrap( return; }; let open = format!("_asyncContexts.set(ctxId, {{ {base}, unwrap: "); - match plan::ret_pass(Some(ret), module, prefix) { + match pass { RetPass::Void => unreachable!("a present return type is never void"), // Owned: copy the encoding out of wasm memory, free the producer // allocation, then decode from the copy. @@ -800,8 +873,11 @@ fn emit_async_unwrap( w.line("return _v;"); }); } - RetPass::Direct if matches!(ret, Ty::Bool) => { - w.line(format!("{open}(w, r) => r !== 0 }});")); + RetPass::Direct if matches!(ret, Ty::Bool | Ty::U32 | Ty::U64) => { + w.line(format!( + "{open}(w, r) => {} }});", + js_direct_value(ret, "r") + )); } RetPass::Direct => { w.line(plain); @@ -815,8 +891,8 @@ fn emit_async_unwrap( }); } RetPass::Object { nullable, .. } => { - let cls = local_type_name(object_ret_name(ret)); - if nullable { + let cls = local_type_name(object_name(ret)); + if *nullable { w.line(format!( "{open}(w, h) => h === 0 ? null : {cls}._wrap(h) }});" )); @@ -841,17 +917,21 @@ fn emit_async_unwrap( /// Emit an async function as a method returning a `Promise` at `indent`. /// Throwing callables store the domain's error factory in the async context, /// so the completion callback rejects with the typed error; non-throwing ones -/// reject with the generic brand error only for panics. +/// reject with the generic brand error only for panics and foreign callback +/// failures. +#[allow(clippy::too_many_arguments)] fn emit_js_async_function_wrapper( out: &mut String, mb: &ModuleBinding, f: &FnBinding, + ab: &AsyncBinding, decl: JsDecl, self_arg: Option<&str>, indent: &str, prefix: &str, ) { let body2 = format!("{indent} "); + let proto = ab.protocol(f, &mb.path, prefix); let js_params: Vec = f.params.iter().map(js_param_name).collect(); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); @@ -866,9 +946,9 @@ fn emit_js_async_function_wrapper( &mut unwrap, &body2, f.ret.as_ref(), + &proto.result, js_err_factory(f, mb.error.as_ref()).as_deref(), &mb.path, - prefix, ); let mut staged = String::new(); let mut args: Vec = self_arg.iter().map(ToString::to_string).collect(); @@ -884,9 +964,8 @@ fn emit_js_async_function_wrapper( &mut cleanup, ); } - let cb_params = async_cb_wasm_params(f.ret.as_ref()); - let sig_key = cb_params.join("_"); - if f.cancellable { + let sig_key = async_cb_wasm_params(ab).join("_"); + if proto.cancellable { args.push("0".to_string()); } args.push(format!("_cbPtr_{sig_key}")); @@ -905,9 +984,27 @@ fn emit_js_async_function_wrapper( w.line("const ctxId = _nextCtxId++;"); w.raw(&unwrap); w.raw(&staged); - w.line(format!("wasm.{}_async({});", f.c_base, args.join(", "))); - for stmt in &cleanup { - w.line(stmt); + // The launcher may complete the call inline (the default + // wasm32 spawner drives the future before returning), so a + // producer trap surfaces here rather than in the callback: + // forget the context and reject with the translated error. + w.block("try {", "} catch (e) {", |w| { + w.line(format!("wasm.{}({});", ab.launch.symbol, args.join(", "))); + }); + w.scope(|w| { + w.line("_asyncContexts.delete(ctxId);"); + w.line("reject(_trapError(e));"); + }); + if cleanup.is_empty() { + w.line("}"); + } else { + w.line("} finally {"); + w.scope(|w| { + for stmt in &cleanup { + w.line(stmt); + } + }); + w.line("}"); } }); }, diff --git a/crates/weaveffi-gen-wasm/src/codec.rs b/crates/weaveffi-gen-wasm/src/codec.rs index e3660bdf..e69d6a9d 100644 --- a/crates/weaveffi-gen-wasm/src/codec.rs +++ b/crates/weaveffi-gen-wasm/src/codec.rs @@ -4,17 +4,24 @@ //! Buffered values (records, rich enums, optionals, lists, maps, and error //! payloads) cross the boundary serialized in the WeaveFFI value-buffer wire //! format. This module emits the JS statements and expressions that encode -//! and decode that format. Every dispatch goes through [`wire::classify`], so -//! the wire-shape folds (handles as `u64` tokens, borrowed views as their -//! owned forms, records and rich enums as one user-codec shape) live in -//! `weaveffi-core`, not here. +//! and decode that format. Every dispatch goes through [`Ty::wire`], so the +//! wire-shape classification (objects as `u64` tokens, records and rich enums +//! as one user-codec shape) lives in `weaveffi-core`, not here. +//! +//! An interface inside a buffer is an object token carrying one strong +//! reference: the writer calls the wrapper's `_clone()` (the interface's +//! `_clone` symbol) and writes the fresh pointer, and the reader adopts the +//! decoded pointer into a new wrapper via the class's `_wrap`. Because the +//! wrapper classes close over the loaded `wasm` instance, the per-type codecs +//! are emitted inside the loader rather than at module scope. -use weaveffi_core::abi::lower::split_qualified; +use weaveffi_core::abi::split_qualified; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; use weaveffi_core::model::BindingModel; use weaveffi_core::model::Ty; use weaveffi_core::model::{Prim, WireType}; +use weaveffi_core::utils::local_type_name; /// The `_write_*`/`_read_*` codec function names for a (possibly /// `module.Name`-qualified) record or rich enum referenced from @@ -44,7 +51,7 @@ fn scalar_method(wt: WireType<'_>) -> &'static str { WireType::Prim(Prim::I32) => "i32", WireType::Prim(Prim::U32) => "u32", WireType::Prim(Prim::I64) => "i64", - WireType::Prim(Prim::U64) | WireType::Handle(_) => "u64", + WireType::Prim(Prim::U64) => "u64", WireType::Prim(Prim::F32) => "f32", WireType::Prim(Prim::F64) => "f64", WireType::Prim(Prim::String) => "str", @@ -68,6 +75,12 @@ pub(crate) fn emit_buf_write_stmts( WireType::Enum(_) => { w.line(format!("{wtr}.i32({val});")); } + // An object token must carry a reference of its own: `_clone()` calls + // the interface's clone symbol, so the wrapper keeps the pointer it + // already holds. + WireType::Object(_) => { + w.line(format!("{wtr}.obj({val}._clone());")); + } WireType::User(name) => { let (write_fn, _) = buf_codec_names(name, module); w.line(format!("{write_fn}({wtr}, {val});")); @@ -126,12 +139,10 @@ pub(crate) fn emit_buf_write_stmts( /// the whole decode stays a single expression. pub(crate) fn buf_read_expr(ty: &Ty, module: &str, rdr: &str) -> String { match ty.wire() { - // A typed handle is an i32 pointer at the ABI but a u64 on the wire; - // narrowing back to a JS number keeps the two spellings interchangeable. - WireType::Handle(_) if matches!(ty, Ty::TypedHandle(_)) => { - format!("Number({rdr}.u64())") - } WireType::Enum(_) => format!("{rdr}.i32()"), + // The token carries one strong reference, which the new wrapper + // adopts; the reader rejects a zero token. + WireType::Object(name) => format!("{}._wrap({rdr}.obj())", local_type_name(name)), WireType::User(name) => { let (_, read_fn) = buf_codec_names(name, module); format!("{read_fn}({rdr})") @@ -196,6 +207,8 @@ pub(crate) fn emit_js_buffer_runtime(out: &mut String) { w.line("f64(v) { this._need(8); this._dv.setFloat64(this._len, v, true); this._len += 8; }"); w.line("len(n) { this.u32(n); }"); w.line("flag(present) { this.u8(present ? 1 : 0); }"); + w.line("// An object token: the pointer (a wasm32 i32, read unsigned) widened to u64."); + w.line("obj(ptr) { this.u64(BigInt(ptr >>> 0)); }"); w.block("str(v) {", "}", |w| { w.line("const b = _enc.encode(v);"); w.line("this.len(b.length);"); @@ -262,6 +275,13 @@ pub(crate) fn emit_js_buffer_runtime(out: &mut String) { w.line("if (b > 1) this._bad('option flag byte out of range');"); w.line("return b === 1;"); }); + w.line("// An object token narrowed back to a wasm32 pointer; zero is never a"); + w.line("// valid token (an absent `Interface?` uses the option flag instead)."); + w.block("obj() {", "}", |w| { + w.line("const t = this.u64();"); + w.line("if (t === 0n || t > 0xffffffffn) this._bad('object token out of range');"); + w.line("return Number(t);"); + }); w.block("str() {", "}", |w| { w.line("const n = this.len();"); w.line("const at = this._take(n, 'string bytes');"); @@ -286,12 +306,13 @@ pub(crate) fn emit_js_buffer_runtime(out: &mut String) { out.push_str(&w.finish()); } -/// Emit the module-scope `_write_*`/`_read_*` codec pair for every record and -/// rich enum in the model, in model (declaration) order. Field order is fixed +/// Emit the `_write_*`/`_read_*` codec pair for every record and rich enum in +/// the model, in model (declaration) order, at `indent` (loader scope, so the +/// object-token arms can reach the interface classes). Field order is fixed /// at generation time, so the codecs are direct straight-line code with no /// runtime dispatch. -pub(crate) fn emit_js_buffer_codecs(out: &mut String, model: &BindingModel) { - let mut w = CodeWriter::two_space(); +pub(crate) fn emit_js_buffer_codecs(out: &mut String, model: &BindingModel, indent: &str) { + let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); for m in &model.modules { for s in &m.structs { let (write_fn, read_fn) = buf_codec_names(&s.name, &m.path); diff --git a/crates/weaveffi-gen-wasm/src/dts.rs b/crates/weaveffi-gen-wasm/src/dts.rs index e8ffd55a..4bcf2697 100644 --- a/crates/weaveffi-gen-wasm/src/dts.rs +++ b/crates/weaveffi-gen-wasm/src/dts.rs @@ -1,25 +1,28 @@ //! TypeScript declaration (`.d.ts`) rendering: the error class surface, -//! record and enum type shapes, ambient interface classes, and the nested -//! module interface the loader's promise resolves to. +//! record and enum type shapes, callback interfaces, ambient interface +//! classes, and the nested module interface the loader's promise resolves to. -use heck::{ToLowerCamelCase, ToUpperCamelCase}; +use heck::ToUpperCamelCase; use weaveffi_core::codegen::common::DocCommentStyle; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::errors::ERROR_BRAND; use weaveffi_core::model::{ - BindingModel, CallShape, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, - ListenerBinding, ModuleBinding, + BindingModel, CallShape, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, + InterfaceBinding, ModuleBinding, }; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use crate::docs::{emit_doc, emit_fn_doc}; -use crate::types::{js_code_class_name, js_fn_name, js_param_name, ts_type_for}; +use crate::types::{ + emscripten_stub, js_cb_method_name, js_code_class_name, js_fn_name, js_param_name, ts_type_for, +}; /// Render the `.d.ts` companion: error classes, record -/// interfaces, enum constants, rich-enum unions, ambient interface classes, -/// the `Module` API shape, and the `load` signature. Async -/// members and listeners are omitted in Emscripten mode, turning their -/// runtime stubs into compile-time errors for TS consumers. +/// interfaces, enum constants, rich-enum unions, callback interfaces, ambient +/// interface classes, the `Module` API shape, and the `load` +/// signature. Async members, callback interfaces, and the functions taking +/// them are omitted in Emscripten mode, turning their runtime stubs into +/// compile-time errors for TS consumers. pub(crate) fn render_wasm_dts( model: &BindingModel, module_name: &str, @@ -68,6 +71,15 @@ pub(crate) fn render_wasm_dts( )); } + // Callback interfaces are unsupported (and never referenced) in + // Emscripten mode, so their shapes are omitted with the functions + // that take them. + if !emscripten { + for cb in &mb.callback_interfaces { + emit_dts_callback_interface(&mut out, cb); + } + } + for i in &mb.interfaces { emit_dts_interface_class(&mut out, mb, i, emscripten); } @@ -98,7 +110,7 @@ pub(crate) fn render_wasm_dts( )); } else { out.push_str(&format!( - "export function {load_fn}(url: string): Promise<{interface_name}>;\n\n" + "export function {load_fn}(source: string | URL | BufferSource | WebAssembly.Module): Promise<{interface_name}>;\n\n" )); } out.push_str(&render_trailer(CommentStyle::DoubleSlash, filename)); @@ -130,6 +142,70 @@ fn emit_dts_rich_enum_type(out: &mut String, e: &EnumBinding) { out.push_str(&w.finish()); } +/// Emit the TypeScript `interface` a consumer implements for one callback +/// interface: one method per declared method, camelCase, with the same +/// parameter and return typing as any other callable. Object parameters +/// arrive as wrappers the implementation owns (and should `close()`). +fn emit_dts_callback_interface(out: &mut String, cb: &CallbackInterfaceBinding) { + let mut w = CodeWriter::two_space(); + let mut tags = Vec::new(); + if let Some(msg) = &cb.deprecated { + tags.push(format!("@deprecated {msg}")); + } + let doc = cb + .doc + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + .map_or_else( + || { + "Implemented by the consumer and passed to the producer; the producer \ + calls these methods synchronously. An exception thrown from a method \ + is reported to the producer as an error (code -4) and the method's \ + default value is returned in its place." + .to_string() + }, + |d| { + format!( + "{d}\n\nImplemented by the consumer; the producer calls these methods \ + synchronously. An exception thrown from a method is reported to the \ + producer as an error (code -4)." + ) + }, + ); + let mut rendered = String::new(); + emit_fn_doc(&mut rendered, &Some(doc), &[], "", &tags); + w.raw(rendered); + w.block(format!("export interface {} {{", cb.name), "}", |w| { + let inner = w.indent_str(); + for m in &cb.methods { + let mut tags = Vec::new(); + if let Some(msg) = &m.deprecated { + tags.push(format!("@deprecated {msg}")); + } + let mut doc = String::new(); + emit_fn_doc(&mut doc, &m.doc, &m.params, &inner, &tags); + w.raw(doc); + let params: Vec = m + .params + .iter() + .map(|p| format!("{}: {}", js_param_name(p), ts_type_for(&p.ty))) + .collect(); + let ret = m + .ret + .as_ref() + .map_or_else(|| "void".to_string(), ts_type_for); + w.line(format!( + "{}({}): {ret};", + js_cb_method_name(&m.name), + params.join(", ") + )); + } + }); + w.blank(); + out.push_str(&w.finish()); +} + /// The TypeScript parameter list for one callable: camelCase names typed by /// [`ts_type_for`]. fn dts_params(f: &FnBinding) -> String { @@ -156,21 +232,28 @@ fn dts_ret(f: &FnBinding) -> String { } /// The JSDoc tag list for one callable: `@deprecated` first when present, a -/// streaming note for iterator-returning callables, then the `@throws` tag -/// matching the throws split (the typed domain error for throwing callables, -/// the generic brand error otherwise). +/// streaming note for iterator-returning callables, an ownership note for +/// object-returning callables, then the `@throws` tag matching the throws +/// split (the typed domain error for throwing callables, the generic brand +/// error otherwise). fn dts_fn_tags(f: &FnBinding, error: Option<&ErrorBinding>) -> Vec { let mut tags = Vec::new(); if let Some(msg) = &f.deprecated { tags.push(format!("@deprecated {msg}")); } - if matches!(f.shape, CallShape::Iterator(_)) { - tags.push( + match (&f.shape, f.ret.as_ref()) { + (CallShape::Iterator(_), _) => tags.push( "@returns A lazy iterator: one producer step per `next()` call. Exhaust it or \ call `return()` to release the producer handle (a `for...of` loop does both \ automatically); an abandoned iterator leaks the handle." .to_string(), - ); + ), + (_, Some(ret)) if ret.interface_name().is_some() => tags.push( + "@returns A wrapper owning one reference; `close()` it (or bind it with \ + `using`) when done." + .to_string(), + ), + _ => {} } match error { Some(eb) if f.throws => tags.push(format!( @@ -184,11 +267,17 @@ fn dts_fn_tags(f: &FnBinding, error: Option<&ErrorBinding>) -> Vec { tags } +/// Whether a callable is declared at all: in Emscripten mode async functions +/// and functions taking callback interfaces are runtime stubs, so leaving +/// them out makes the gap a compile-time error for TS consumers. +fn declared(f: &FnBinding, emscripten: bool) -> bool { + !(emscripten && emscripten_stub(f)) +} + /// Emit one module's member block inside the `Module` interface: -/// function signatures (async ones omitted in Emscripten mode), listener -/// pairs (omitted in Emscripten mode), `typeof` bindings for the interface -/// classes, and nested submodule blocks, skipping subtrees with no declared -/// content. +/// function signatures (stubbed ones omitted in Emscripten mode), `typeof` +/// bindings for the interface classes, and nested submodule blocks, skipping +/// subtrees with no declared content. fn render_dts_module_interface( out: &mut String, model: &BindingModel, @@ -196,27 +285,19 @@ fn render_dts_module_interface( indent: &str, emscripten: bool, ) { - fn tree_has_content(model: &BindingModel, mb: &ModuleBinding, include_listeners: bool) -> bool { + fn tree_has_content(model: &BindingModel, mb: &ModuleBinding) -> bool { !mb.functions.is_empty() || !mb.interfaces.is_empty() - || (include_listeners && !mb.listeners.is_empty()) - || model - .children(mb) - .any(|sub| tree_has_content(model, sub, include_listeners)) + || model.children(mb).any(|sub| tree_has_content(model, sub)) } - if !tree_has_content(model, mb, !emscripten) { + if !tree_has_content(model, mb) { return; } let error = mb.error.as_ref(); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); w.block(format!("{}: {{", mb.name), "};", |w| { let inner = w.indent_str(); - for f in &mb.functions { - // Async functions are throwing stubs in Emscripten mode; omitting - // them here makes the gap a compile-time error for TS consumers. - if emscripten && f.is_async { - continue; - } + for f in mb.functions.iter().filter(|f| declared(f, emscripten)) { let mut doc = String::new(); emit_fn_doc(&mut doc, &f.doc, &f.params, &inner, &dts_fn_tags(f, error)); w.raw(doc); @@ -227,15 +308,6 @@ fn render_dts_module_interface( dts_ret(f) )); } - // Listeners are throwing stubs in Emscripten mode; omitting them here - // makes the gap a compile-time error for TS consumers. - if !emscripten { - for l in &mb.listeners { - let mut tmp = String::new(); - render_dts_listener(&mut tmp, mb, l, &inner); - w.raw(tmp); - } - } // The module object carries the interface class itself, so statics, // factories, and `new` are reachable as `api.kv.Store...`. for i in &mb.interfaces { @@ -250,55 +322,6 @@ fn render_dts_module_interface( out.push_str(&w.finish()); } -/// Emit the TypeScript declarations for one listener's register/unregister -/// pair. The callback parameter types come from the referenced callback -/// typedef; the subscription id is a plain `number` (the loader keys -/// subscriptions by its own context id, so the producer's `uint64_t` id never -/// reaches the public surface). -fn render_dts_listener(out: &mut String, mb: &ModuleBinding, l: &ListenerBinding, indent: &str) { - let Some(cb) = mb.callback(&l.event_callback) else { - // Validation guarantees the referenced callback exists in-module. - unreachable!("listener '{}' references unknown callback", l.name); - }; - let register_name = format!("register_{}", l.name).to_lower_camel_case(); - let unregister_name = format!("unregister_{}", l.name).to_lower_camel_case(); - let cb_params: Vec = cb - .params - .iter() - .map(|p| format!("{}: {}", js_param_name(p), ts_type_for(&p.ty))) - .collect(); - let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); - let register_doc = match &l.doc { - Some(d) => format!( - "{}\n\n@returns A subscription id for `{unregister_name}()`.", - d.trim() - ), - None => format!( - "Register a listener for the `{}` callback.\n\n@returns A \ - subscription id for `{unregister_name}()`.", - cb.name - ), - }; - let mut doc = String::new(); - emit_doc(&mut doc, &Some(register_doc), indent); - w.raw(doc); - w.line(format!( - "{register_name}(callback: ({}) => void): number;", - cb_params.join(", ") - )); - let mut doc = String::new(); - emit_doc( - &mut doc, - &Some(format!( - "Unregister a listener previously registered with `{register_name}()`." - )), - indent, - ); - w.raw(doc); - w.line(format!("{unregister_name}(id: number): void;")); - out.push_str(&w.finish()); -} - /// Emit the TypeScript declarations for the error surface: the generic brand /// error, then one domain class per declaring module with its per-code /// subclasses (each carrying a literal-typed `CODE` and any declared payload @@ -306,8 +329,9 @@ fn render_dts_listener(out: &mut String, mb: &ModuleBinding, l: &ListenerBinding fn emit_dts_error_classes(out: &mut String, model: &BindingModel) { let mut w = CodeWriter::two_space(); w.line("/** Base error for WeaveFFI failures: domain errors extend it, and it is"); - w.line(" * thrown directly for unknown codes, marshalling failures, and producer"); - w.line(" * panics. Carries the stable ABI `code`. */"); + w.line(" * thrown directly for unknown codes, marshalling failures, producer"); + w.line(" * panics, and callback-interface implementations that raised (code -4)."); + w.line(" * Carries the stable ABI `code`. */"); w.block( format!("export declare class {ERROR_BRAND} extends Error {{"), "}", @@ -366,8 +390,10 @@ fn emit_dts_error_classes(out: &mut String, model: &BindingModel) { /// Emit the TypeScript declaration for an interface: an ambient class whose /// runtime binding is reached through the module object (`api.kv.Store`). The /// canonical `new` constructor declares `constructor`; other constructors and -/// statics are static members; async members are omitted in Emscripten mode -/// (they are throwing stubs at runtime). +/// statics are static members; stubbed members are omitted in Emscripten mode. +/// Every class declares `close()`; the `Symbol.dispose` method is present at +/// runtime but left undeclared so the file type-checks without the +/// `esnext.disposable` lib. fn emit_dts_interface_class( out: &mut String, mb: &ModuleBinding, @@ -376,7 +402,20 @@ fn emit_dts_interface_class( ) { let error = mb.error.as_ref(); let mut w = CodeWriter::two_space(); - w.doc(&i.doc, DocCommentStyle::Javadoc); + let doc = i + .doc + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + .map_or_else( + || "Wraps one reference to a producer object; release it with `close()`.".to_string(), + |d| { + format!( + "{d}\n\nWraps one reference to a producer object; release it with `close()`." + ) + }, + ); + w.doc(&Some(doc), DocCommentStyle::Javadoc); w.block(format!("export declare class {} {{", i.name), "}", |w| { let inner = w.indent_str(); if let Some(c) = i.constructors.iter().find(|c| c.name == "new") { @@ -396,10 +435,7 @@ fn emit_dts_interface_class( dts_ret(c) )); } - for f in &i.methods { - if emscripten && f.is_async { - continue; - } + for f in i.methods.iter().filter(|f| declared(f, emscripten)) { let mut doc = String::new(); emit_fn_doc(&mut doc, &f.doc, &f.params, &inner, &dts_fn_tags(f, error)); w.raw(doc); @@ -410,10 +446,7 @@ fn emit_dts_interface_class( dts_ret(f) )); } - for f in &i.statics { - if emscripten && f.is_async { - continue; - } + for f in i.statics.iter().filter(|f| declared(f, emscripten)) { let mut doc = String::new(); emit_fn_doc(&mut doc, &f.doc, &f.params, &inner, &dts_fn_tags(f, error)); w.raw(doc); @@ -424,8 +457,11 @@ fn emit_dts_interface_class( dts_ret(f) )); } - w.line("/** Releases the producer-owned handle exactly once. */"); - w.line("free(): void;"); + w.line("/** Releases this wrapper's reference exactly once; later calls are"); + w.line(" * no-ops. Also reachable as `[Symbol.dispose]()` for `using`"); + w.line(" * declarations. A wrapper collected unclosed is released by a"); + w.line(" * finalizer where the runtime provides one. */"); + w.line("close(): void;"); }); w.blank(); out.push_str(&w.finish()); diff --git a/crates/weaveffi-gen-wasm/src/entities.rs b/crates/weaveffi-gen-wasm/src/entities.rs index b92e3655..98dbab84 100644 --- a/crates/weaveffi-gen-wasm/src/entities.rs +++ b/crates/weaveffi-gen-wasm/src/entities.rs @@ -1,32 +1,32 @@ //! The JS loader assembler: the `weaveffi_wasm.js` stub with its feature-gated -//! runtime, module-scope enum and error surfaces, loader-scoped interface -//! classes, and the nested API object the loader returns. +//! runtime, module-scope enum and error surfaces, loader-scoped codecs, +//! callback vtables, and interface classes, and the nested API object the +//! loader returns. use heck::ToUpperCamelCase; use weaveffi_core::codegen::CodeWriter; use weaveffi_core::model::Ty; -use weaveffi_core::model::{ - BindingModel, CallShape, CallbackBinding, InterfaceBinding, ModuleBinding, -}; -use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; +use weaveffi_core::model::{BindingModel, CallShape, InterfaceBinding, ModuleBinding}; +use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle, ABI_RUNTIME_SYMBOLS}; use crate::calls::{ - async_cb_wasm_params, collect_listener_callbacks, emit_js_callable, emit_js_listener_api, - emit_js_listener_stub, emit_js_listener_trampoline, emit_stage_input, + async_cb_wasm_params, emit_constructor_call, emit_js_callable, emit_js_callback_vtable, + emit_stage_input, }; use crate::codec::{emit_js_buffer_codecs, emit_js_buffer_runtime}; use crate::runtime::{ emit_abi_version_check, emit_bytes_helpers, emit_check_err_ref, emit_error_slot_helpers, - emit_iterator_class, emit_js_error_checkers, emit_js_error_classes, emit_string_helpers, - emit_trampoline_helper, + emit_foreign_error_helper, emit_iterator_class, emit_js_error_checkers, emit_js_error_classes, + emit_js_error_factories, emit_object_helpers, emit_string_helpers, emit_trampoline_helper, }; -use crate::types::{is_string_type, js_checker_name, js_param_name, JsDecl}; - -/// Every producer entry-point symbol the generated glue calls by name: -/// sync and iterator launchers, iterator `next`/destroy pairs, and interface -/// destroy symbols, deduplicated in first-use order. Async launchers are -/// excluded; Emscripten mode stubs them out, and this list exists to build -/// the Emscripten export-binding table. +use crate::types::{emscripten_stub, is_string_type, js_checker_name, js_param_name, JsDecl}; + +/// Every producer entry-point symbol the Emscripten glue calls by name: sync +/// and iterator launchers, iterator `next`/destroy pairs, and interface +/// clone/destroy symbols, deduplicated in first-use order. Async launchers +/// and functions taking callback interfaces are excluded; Emscripten mode +/// stubs them out, and this list exists only to build the Emscripten +/// export-binding table. pub(crate) fn collect_called_symbols(model: &BindingModel) -> Vec { fn push_unique(syms: &mut Vec, s: &str) { if !syms.iter().any(|x| x == s) { @@ -36,6 +36,9 @@ pub(crate) fn collect_called_symbols(model: &BindingModel) -> Vec { let mut syms = Vec::new(); for m in &model.modules { for f in m.callables() { + if emscripten_stub(f) { + continue; + } match &f.shape { CallShape::Iterator(it) => { push_unique(&mut syms, &f.c_base); @@ -47,6 +50,7 @@ pub(crate) fn collect_called_symbols(model: &BindingModel) -> Vec { } } for i in &m.interfaces { + push_unique(&mut syms, &i.clone_symbol); push_unique(&mut syms, &i.destroy_symbol); } } @@ -72,51 +76,30 @@ pub(crate) fn render_wasm_js_stub( // Interface members marshal like free functions, so every callable counts. let has_functions = model.modules.iter().any(|m| m.callables().next().is_some()); - // In Emscripten mode async functions are throwing stubs, so none of the - // trampoline machinery (or its helpers) is emitted. - let has_async = !emscripten - && model - .modules - .iter() - .flat_map(ModuleBinding::callables) - .any(|f| f.is_async); - // Listeners get real dispatch only in the standard loader; Emscripten - // mode emits throwing stubs, so no trampolines or registry there either. - let listener_cbs: Vec<(&str, &CallbackBinding)> = if emscripten { - Vec::new() - } else { - collect_listener_callbacks(model) - }; - let has_listeners = !listener_cbs.is_empty(); + // In Emscripten mode async functions and callback interfaces are throwing + // stubs, so none of the trampoline machinery (or its helpers) is emitted. + let has_async = !emscripten && model.has_async(); + let has_callbacks = !emscripten && model.has_callback_interfaces(); + let has_interfaces = model.has_interfaces(); // Records and rich enums are value types packed and unpacked by the - // module-scope codecs; any of them (or any buffered type, or an error + // loader-scope codecs; any of them (or any buffered type, or an error // payload) pulls in the buffer writer/reader runtime. let has_codecs = model .modules .iter() .any(|m| !m.structs.is_empty() || m.enums.iter().any(|e| e.is_rich())); - let has_error_payloads = model.modules.iter().any(|m| { - m.error - .as_ref() - .is_some_and(|e| e.declared_here && e.codes.iter().any(|c| !c.fields.is_empty())) - }); - let needs_buf = has_codecs || has_error_payloads || model.any_type(&|t| t.is_buffered()); + let needs_buf = model.has_buffers(); // The buffer reader (and the codecs) reject malformed input by throwing // the brand error, so the error surface is needed whenever buffers are. let needs_err = has_functions || needs_buf; + let has_domains = model.modules.iter().any(ModuleBinding::declares_error); // Error messages always cross as C strings, so anything needing the error // helpers also needs the string-read helpers regardless of declared types. let needs_strings = needs_err || model.any_type(&is_string_type); // Buffered values are staged and released exactly like bytes, so the byte // helpers cover both. - let needs_bytes = needs_buf || model.any_type(&|t| matches!(t, Ty::Bytes | Ty::BorrowedBytes)); - // Any iterator-returning callable pulls in the shared lazy-iterator - // wrapper class. - let has_iterators = model - .modules - .iter() - .flat_map(ModuleBinding::callables) - .any(|f| matches!(f.shape, CallShape::Iterator(_))); + let needs_bytes = needs_buf || model.any_type(&|t| matches!(t, Ty::Bytes)); + let has_iterators = model.has_iterators(); out.push_str("// WeaveFFI Wasm bindings (auto-generated)\n"); out.push_str("//\n"); @@ -126,13 +109,15 @@ pub(crate) fn render_wasm_js_stub( out.push_str("// Boundary conventions for a wasm32-unknown-unknown build:\n"); } out.push_str("//\n"); - out.push_str("// Objects -> i32 pointer into linear memory (0 = null/absent)\n"); + out.push_str("// Objects -> i32 pointer into linear memory (0 = null/absent); one\n"); + out.push_str("// strong reference per wrapper, released by close()\n"); out.push_str("// Enums -> i32 discriminant value\n"); out.push_str("// i64/u64 -> JavaScript BigInt\n"); out.push_str("// Strings -> NUL-terminated UTF-8 (const char*); a single i32 pointer\n"); out.push_str("// Bytes -> i32 data pointer + i32 length (out_len for returns)\n"); out.push_str("// Buffered -> records, rich enums, optionals, lists, and maps cross\n"); out.push_str("// as one value buffer: i32 pointer + i32 length\n"); + out.push_str("// Callbacks -> i32 context key + i32 pointer to a static vtable\n"); out.push('\n'); if !model.modules.is_empty() { @@ -157,21 +142,24 @@ pub(crate) fn render_wasm_js_stub( if needs_err { emit_error_slot_helpers(&mut out); - emit_js_error_checkers(&mut out, model); if has_async { emit_check_err_ref(&mut out); } } - if has_codecs { - emit_js_buffer_codecs(&mut out, model); + if has_interfaces { + emit_object_helpers(&mut out); + } + + if has_callbacks { + emit_foreign_error_helper(&mut out); } if has_iterators { emit_iterator_class(&mut out); } - if has_async || has_listeners { + if has_async || has_callbacks { emit_trampoline_helper(&mut out); } @@ -203,9 +191,11 @@ pub(crate) fn render_wasm_js_stub( out.push_str(" * @returns {Promise} The API bindings.\n"); } } else { - out.push_str(" * Load a WeaveFFI Wasm module from the given URL.\n"); + out.push_str(" * Load a WeaveFFI Wasm module.\n"); out.push_str(" *\n"); - out.push_str(" * @param {string} url - URL to the `.wasm` file.\n"); + out.push_str(" * @param {string|URL|BufferSource|WebAssembly.Module} source - A URL to\n"); + out.push_str(" * fetch the `.wasm` from, its bytes, or an already compiled module\n"); + out.push_str(" * (use the bytes form where `fetch` cannot read files, e.g. Node).\n"); if model.modules.is_empty() { out.push_str( " * @returns {Promise} The exported Wasm functions.\n", @@ -234,6 +224,12 @@ pub(crate) fn render_wasm_js_stub( out.push_str(" * // Record: plain objects in and out (serialized automatically).\n"); out.push_str(" * const person = api.contacts.create({ name: 'Ada', age: 36 });\n"); out.push_str(" *\n"); + out.push_str(" * // Object: a class wrapping one producer reference; close() releases it\n"); + out.push_str(" * // (or let `using` do it). Passing it lends the reference for the call.\n"); + out.push_str(" * const store = new api.kv.Store();\n"); + out.push_str(" * api.kv.inspect(store);\n"); + out.push_str(" * store.close();\n"); + out.push_str(" *\n"); out.push_str(" * // Enum: pass the integer discriminant.\n"); out.push_str(" * api.ui.set_color(0); // 0 = first variant\n"); out.push_str(" *\n"); @@ -248,10 +244,19 @@ pub(crate) fn render_wasm_js_stub( out.push_str(&format!("export async function {load_fn}(module) {{\n")); out.push_str(" const m = await Promise.resolve(module);\n"); } else { - out.push_str(&format!("export async function {load_fn}(url) {{\n")); - out.push_str(" const response = await fetch(url);\n"); - out.push_str(" const bytes = await response.arrayBuffer();\n"); - out.push_str(" const { instance } = await WebAssembly.instantiate(bytes, {});\n"); + out.push_str(&format!("export async function {load_fn}(source) {{\n")); + out.push_str(" let module;\n"); + out.push_str(" if (source instanceof WebAssembly.Module) {\n"); + out.push_str(" module = source;\n"); + out.push_str( + " } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) {\n", + ); + out.push_str(" module = await WebAssembly.compile(source);\n"); + out.push_str(" } else {\n"); + out.push_str(" const response = await fetch(source);\n"); + out.push_str(" module = await WebAssembly.compile(await response.arrayBuffer());\n"); + out.push_str(" }\n"); + out.push_str(" const instance = await WebAssembly.instantiate(module, {});\n"); } if model.modules.is_empty() { @@ -262,113 +267,52 @@ pub(crate) fn render_wasm_js_stub( } } else { if emscripten { - // Bind the Emscripten exports once, up front, to the exact symbol - // names the glue above calls. Module access stays in quoted - // bracket notation so Closure Compiler's advanced property - // renaming cannot break it, while the rest of the glue keeps - // consistent dot access on this locally-constructed object. - let mut bindings: Vec<(String, String)> = vec![ - ( - "weaveffi_abi_version".to_string(), - format!("{prefix}_abi_version"), - ), - ("weaveffi_alloc".to_string(), format!("{prefix}_alloc")), - ("weaveffi_dealloc".to_string(), format!("{prefix}_dealloc")), - ]; - if needs_strings { - bindings.push(( - "weaveffi_free_string".to_string(), - format!("{prefix}_free_string"), - )); - } - if needs_bytes { - bindings.push(( - "weaveffi_free_bytes".to_string(), - format!("{prefix}_free_bytes"), - )); - } - if needs_err { - bindings.push(( - "weaveffi_error_clear".to_string(), - format!("{prefix}_error_clear"), - )); - } - bindings.extend(collect_called_symbols(model).into_iter().map(|s| { - let export = s.clone(); - (s, export) - })); - out.push_str(" // Bind the underscore-prefixed Emscripten exports to the symbol\n"); - out.push_str(" // names the glue above calls. Quoted bracket access keeps the\n"); - out.push_str(" // bindings safe under Closure Compiler's property renaming.\n"); - out.push_str(" const wasm = {\n"); - out.push_str(" // Emscripten replaces HEAPU8 when linear memory grows, so the\n"); - out.push_str(" // buffer is re-read on every access instead of captured once.\n"); - out.push_str(" get memory() { return { buffer: m['HEAPU8'].buffer }; },\n"); - for (name, export) in &bindings { - out.push_str(&format!(" {name}: m['_{export}'],\n")); - } - out.push_str(" };\n\n"); + emit_emscripten_bindings( + &mut out, + model, + prefix, + needs_strings, + needs_bytes, + needs_err, + ); } else { out.push_str(" const wasm = instance.exports;\n\n"); } out.push_str(" _checkAbiVersion(wasm);\n\n"); - if has_async || has_listeners { + // The per-domain factories and checkers, and the value codecs, live in + // the loader because object tokens resolve to the loader-scoped + // interface classes below. + if needs_err && has_domains { + emit_js_error_factories(&mut out, model, " "); + emit_js_error_checkers(&mut out, model, " "); + } + if has_codecs { + emit_js_buffer_codecs(&mut out, model, " "); + } + + if has_async || has_callbacks { out.push_str(" const _table = wasm.__indirect_function_table;\n\n"); } if has_async { - out.push_str(" let _nextCtxId = 1;\n"); - out.push_str(" const _asyncContexts = new Map();\n\n"); - out.push_str(" function _asyncHandler(ctxId, errPtr, ...results) {\n"); - out.push_str(" const ctx = _asyncContexts.get(ctxId);\n"); - out.push_str(" if (!ctx) return;\n"); - out.push_str(" _asyncContexts.delete(ctxId);\n"); - out.push_str(" try {\n"); - out.push_str(" if (errPtr !== 0) _checkErrRef(wasm, errPtr, ctx.mkErr);\n"); - out.push_str( - " ctx.resolve(ctx.unwrap ? ctx.unwrap(wasm, ...results) : results[0]);\n", - ); - out.push_str(" } catch (e) {\n"); - out.push_str(" ctx.reject(e);\n"); - out.push_str(" }\n"); - out.push_str(" }\n\n"); - - let mut trampolines: Vec<(String, Vec<&'static str>)> = Vec::new(); - for f in model.modules.iter().flat_map(ModuleBinding::callables) { - if f.is_async { - let params = async_cb_wasm_params(f.ret.as_ref()); - let key = params.join("_"); - if !trampolines.iter().any(|(k, _)| k == &key) { - trampolines.push((key, params)); - } - } - } - for (sig_key, params) in &trampolines { - let params_js: Vec = params.iter().map(|p| format!("'{p}'")).collect(); - out.push_str(&format!( - " const _cbPtr_{sig_key} = _registerTrampoline(_table, [{}], _asyncHandler);\n", - params_js.join(", ") - )); - } - out.push('\n'); + emit_async_machinery(&mut out, model); } - if has_listeners { - out.push_str(" // Listener subscriptions, keyed by the context id the loader\n"); - out.push_str(" // threads through the C ABI's void* context slot. Each entry\n"); - out.push_str(" // holds the JS callback and the producer's subscription id.\n"); - out.push_str(" let _nextLsnId = 1;\n"); - out.push_str(" const _listeners = new Map();\n\n"); - for (path, cb) in &listener_cbs { - emit_js_listener_trampoline(&mut out, path, cb, " "); + if has_callbacks { + out.push_str(" // Callback-interface implementations, keyed by the integer context\n"); + out.push_str(" // the loader passes as `ctx`; an entry lives until the producer\n"); + out.push_str(" // calls the vtable's `free(ctx)`.\n"); + out.push_str(" let _nextCbId = 1;\n"); + out.push_str(" const _callbacks = new Map();\n\n"); + for (module, cb) in model.callback_interfaces() { + emit_js_callback_vtable(&mut out, module, cb, prefix, " "); } - out.push('\n'); } // Interface classes close over the loaded `wasm` instance (and the // async machinery above), so they live inside the loader rather than - // at module scope like the value-type codecs. + // at module scope like the error classes. for module in &model.modules { for i in &module.interfaces { emit_interface_class(&mut out, module, i, " ", prefix, emscripten); @@ -388,24 +332,117 @@ pub(crate) fn render_wasm_js_stub( out } -/// Whether a module subtree exposes anything at runtime (functions, interface -/// classes, or listeners), so empty namespace objects are not emitted. -/// Records and rich enums contribute nothing here: they are plain value -/// shapes with no runtime members. +/// Emit the Emscripten export-binding table: the underscore-prefixed exports +/// bound once, up front, to the exact symbol names the glue calls. Module +/// access stays in quoted bracket notation so Closure Compiler's advanced +/// property renaming cannot break it, while the rest of the glue keeps +/// consistent dot access on this locally constructed object. +/// +/// Runtime symbols listed in [`ABI_RUNTIME_SYMBOLS`] keep their `weaveffi_` +/// spelling in the producer (the C header aliases a custom prefix onto them), +/// while `alloc`/`dealloc` are declared under the configured prefix. +fn emit_emscripten_bindings( + out: &mut String, + model: &BindingModel, + prefix: &str, + needs_strings: bool, + needs_bytes: bool, + needs_err: bool, +) { + let runtime = |sym: &str| -> (String, String) { + let export = if ABI_RUNTIME_SYMBOLS.contains(&sym) { + format!("weaveffi_{sym}") + } else { + format!("{prefix}_{sym}") + }; + (format!("weaveffi_{sym}"), export) + }; + let mut bindings: Vec<(String, String)> = + vec![runtime("abi_version"), runtime("alloc"), runtime("dealloc")]; + if needs_strings { + bindings.push(runtime("free_string")); + } + if needs_bytes { + bindings.push(runtime("free_bytes")); + } + if needs_err { + bindings.push(runtime("error_clear")); + } + bindings.extend( + collect_called_symbols(model) + .into_iter() + .map(|s| (s.clone(), s)), + ); + out.push_str(" // Bind the underscore-prefixed Emscripten exports to the symbol\n"); + out.push_str(" // names the glue above calls. Quoted bracket access keeps the\n"); + out.push_str(" // bindings safe under Closure Compiler's property renaming.\n"); + out.push_str(" const wasm = {\n"); + out.push_str(" // Emscripten replaces HEAPU8 when linear memory grows, so the\n"); + out.push_str(" // buffer is re-read on every access instead of captured once.\n"); + out.push_str(" get memory() { return { buffer: m['HEAPU8'].buffer }; },\n"); + for (name, export) in &bindings { + out.push_str(&format!(" {name}: m['_{export}'],\n")); + } + out.push_str(" };\n\n"); +} + +/// Emit the loader-scoped async machinery: the context table, the shared +/// completion handler, and one trampoline per distinct completion-callback +/// signature (deduplicated by wasm slot types, so every async function with +/// the same result shape shares a table entry). +fn emit_async_machinery(out: &mut String, model: &BindingModel) { + out.push_str(" let _nextCtxId = 1;\n"); + out.push_str(" const _asyncContexts = new Map();\n\n"); + out.push_str(" function _asyncHandler(ctxId, errPtr, ...results) {\n"); + out.push_str(" const ctx = _asyncContexts.get(ctxId);\n"); + out.push_str(" if (!ctx) return;\n"); + out.push_str(" _asyncContexts.delete(ctxId);\n"); + out.push_str(" try {\n"); + out.push_str(" if (errPtr !== 0) _checkErrRef(wasm, errPtr, ctx.mkErr);\n"); + out.push_str(" ctx.resolve(ctx.unwrap ? ctx.unwrap(wasm, ...results) : results[0]);\n"); + out.push_str(" } catch (e) {\n"); + out.push_str(" ctx.reject(e);\n"); + out.push_str(" }\n"); + out.push_str(" }\n\n"); + + let mut trampolines: Vec<(String, Vec<&'static str>)> = Vec::new(); + for (_, f) in model.callables() { + if let CallShape::Async(ab) = &f.shape { + let params = async_cb_wasm_params(ab); + let key = params.join("_"); + if !trampolines.iter().any(|(k, _)| k == &key) { + trampolines.push((key, params)); + } + } + } + for (sig_key, params) in &trampolines { + let params_js: Vec = params.iter().map(|p| format!("'{p}'")).collect(); + out.push_str(&format!( + " const _cbPtr_{sig_key} = _registerTrampoline(_table, [{}], [], _asyncHandler);\n", + params_js.join(", ") + )); + } + out.push('\n'); +} + +/// Whether a module subtree exposes anything at runtime (functions or +/// interface classes), so empty namespace objects are not emitted. Records, +/// rich enums, and callback interfaces contribute nothing here: they are +/// plain value shapes (or consumer-implemented shapes) with no runtime +/// members. fn module_tree_has_content(model: &BindingModel, mb: &ModuleBinding) -> bool { !mb.functions.is_empty() || !mb.interfaces.is_empty() - || !mb.listeners.is_empty() || model .children(mb) .any(|sub| module_tree_has_content(model, sub)) } /// Emit one module's namespace object (`math: { ... },`) inside the returned -/// API literal: free-function wrappers, listener register/unregister pairs -/// (or their Emscripten stubs), the interface classes themselves (so -/// factories, statics, and `instanceof` checks reach them), and nested -/// submodule objects, skipping subtrees with no runtime content. +/// API literal: free-function wrappers (or their Emscripten stubs), the +/// interface classes themselves (so factories, statics, and `instanceof` +/// checks reach them), and nested submodule objects, skipping subtrees with +/// no runtime content. fn render_js_module_object( out: &mut String, model: &BindingModel, @@ -434,15 +471,6 @@ fn render_js_module_object( ); w.raw(tmp); } - for l in &mb.listeners { - let mut tmp = String::new(); - if emscripten { - emit_js_listener_stub(&mut tmp, l, &inner); - } else { - emit_js_listener_api(&mut tmp, l, &inner); - } - w.raw(tmp); - } // The interface class itself is exposed on the module object, so // factories, statics, and `instanceof` checks all reach it. for i in &mb.interfaces { @@ -457,13 +485,17 @@ fn render_js_module_object( out.push_str(&w.finish()); } -/// Emit the loader-scoped `class` for an interface: an opaque-handle wrapper -/// closing over the loaded `wasm` instance. The canonical `new` constructor -/// maps to `constructor`; other constructors and statics are static methods; -/// methods pass `this._handle` as the implicit leading `self` argument. The -/// internal `_wrap(handle)` adopts an owned handle (returns, iterator -/// elements) without invoking the constructor, and `free()` releases the -/// handle exactly once via the destroy symbol. +/// Emit the loader-scoped `class` for an interface: a wrapper holding one +/// strong reference (the producer-side reference count) and closing over the +/// loaded `wasm` instance. The canonical `new` constructor maps to +/// `constructor`; other constructors and statics are static methods; methods +/// lend `this._handle` as the implicit leading `self` argument. The internal +/// `_wrap(handle)` adopts an owned reference (returns, async results, +/// iterator elements, decoded object tokens, callback arguments) without +/// invoking the constructor; `_clone()` produces a second reference through +/// the clone symbol for object tokens; `close()` and `Symbol.dispose` release +/// the reference exactly once, with a `FinalizationRegistry` backstop for +/// wrappers that are collected unclosed. fn emit_interface_class( out: &mut String, module: &ModuleBinding, @@ -474,6 +506,7 @@ fn emit_interface_class( ) { let cls = &i.name; let error = module.error.as_ref(); + let destroy = format!("wasm.{}", i.destroy_symbol); let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); if let Some(doc) = i.doc.as_deref().map(str::trim).filter(|d| !d.is_empty()) { for line in doc.lines() { @@ -484,7 +517,8 @@ fn emit_interface_class( let inner = w.indent_str(); // Canonical constructor: `new(...)` becomes `constructor(...)`, - // assigning the owned handle rather than returning a wrapped value. + // adopting the returned reference rather than returning a wrapped + // value. if let Some(c) = i.constructors.iter().find(|c| c.name == "new") { let body = format!("{inner} "); let js_params: Vec = c.params.iter().map(js_param_name).collect(); @@ -507,38 +541,37 @@ fn emit_interface_class( &mut cleanup, ); } - args.push("_err".to_string()); w.raw(staged); - w.line("const _err = _allocErr(wasm);"); - w.line(format!( - "const _r = wasm.{}({});", - c.c_base, - args.join(", ") - )); - for stmt in &cleanup { - w.line(stmt); - } - w.line(format!("{checker}(wasm, _err);")); - w.line("_freeErr(wasm, _err);"); - w.line("this._handle = _r;"); + let mut call = String::new(); + emit_constructor_call( + &mut call, &body, &c.c_base, &args, &cleanup, &checker, &destroy, + ); + w.raw(call); }, ); } - // Internal: adopt an owned handle (returns, iterator elements) + // Internal: adopt one strong reference (a return, an async result, + // an iterator element, a decoded object token, a callback argument) // without running the constructor. w.block("static _wrap(handle) {", "}", |w| { - w.line(format!("const _o = Object.create({cls}.prototype);")); - w.line("_o._handle = handle;"); - w.line("return _o;"); + w.line(format!( + "return _adopt(Object.create({cls}.prototype), handle, {destroy});" + )); }); - // Explicit cleanup: release the producer-owned handle exactly once. - w.block("free() {", "}", |w| { - w.block("if (this._handle !== 0) {", "}", |w| { - w.line(format!("wasm.{}(this._handle);", i.destroy_symbol)); - w.line("this._handle = 0;"); - }); + // Internal: a second strong reference to the same object, consumed by + // the value-buffer writer when this object is encoded as a token. + w.block("_clone() {", "}", |w| { + w.line(format!("return wasm.{}(_borrow(this));", i.clone_symbol)); + }); + + // Explicit cleanup: release this wrapper's reference exactly once. + w.block("close() {", "}", |w| { + w.line("_release(this);"); + }); + w.block("[_dispose]() {", "}", |w| { + w.line("_release(this);"); }); for c in i.constructors.iter().filter(|c| c.name != "new") { @@ -562,7 +595,7 @@ fn emit_interface_class( module, m, JsDecl::Method, - Some("this._handle"), + Some("_borrow(this)"), &inner, prefix, emscripten, diff --git a/crates/weaveffi-gen-wasm/src/lib.rs b/crates/weaveffi-gen-wasm/src/lib.rs index 9fd4b7a7..8257f6c6 100644 --- a/crates/weaveffi-gen-wasm/src/lib.rs +++ b/crates/weaveffi-gen-wasm/src/lib.rs @@ -1,9 +1,15 @@ //! WebAssembly binding generator for WeaveFFI. //! //! Emits a JavaScript loader stub and TypeScript declarations targeting a -//! `wasm32-unknown-unknown` cdylib build of the same Rust source. -//! Implements [`LanguageBackend`]; the shared driver bridges it into the -//! generator pipeline. +//! `wasm32-unknown-unknown` cdylib build of the same Rust source (or, in +//! Emscripten mode, a pre-initialized Emscripten module), speaking ABI +//! revision 2: reference-counted object wrappers with `close()`, +//! `Symbol.dispose`, and a `FinalizationRegistry` backstop; nullable objects +//! as `Wrapper | null`; object tokens inside value buffers; lazy iterators; +//! `Promise`-returning async functions; and callback interfaces implemented +//! as static vtables of function-table trampolines. Implements +//! [`LanguageBackend`]; the shared driver bridges it into the generator +//! pipeline, and [`LanguageBackend::package`] assembles the npm layout. #![deny(missing_docs)] #![warn(clippy::missing_errors_doc)] #![warn(clippy::missing_panics_doc)] @@ -23,12 +29,16 @@ use serde::{Deserialize, Serialize}; use weaveffi_core::backend::{LanguageBackend, OutputFile}; use weaveffi_core::capabilities::TargetCapabilities; use weaveffi_core::model::BindingModel; +use weaveffi_core::package::{PackageContext, PackagedFile}; use weaveffi_core::pkg; +use weaveffi_core::platform::Platform; use weaveffi_core::resolved::ResolvedApi; use crate::dts::render_wasm_dts; use crate::entities::render_wasm_js_stub; -use crate::package::{render_wasm_package_json, render_wasm_readme}; +use crate::package::{ + render_packaged_readme, render_wasm_package_json, render_wasm_readme, PackagedBinary, +}; /// WebAssembly backend: emits a JavaScript loader stub and TypeScript /// declarations targeting a `wasm32-unknown-unknown` cdylib build of the same @@ -39,7 +49,7 @@ const DEFAULT_MODULE_NAME: &str = "weaveffi_wasm"; /// Per-target configuration for [`WasmGenerator`]. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct WasmConfig { /// Module name used for the emitted `.js` loader and /// `.d.ts` (default `"weaveffi_wasm"`). @@ -51,12 +61,19 @@ pub struct WasmConfig { /// Target an Emscripten build instead of a bare `wasm32-unknown-unknown` /// one. The loader then accepts a pre-initialized Emscripten `Module` /// object (or the promise returned by its `MODULARIZE` factory) instead - /// of a `.wasm` URL, and binds the module's underscore-prefixed exports - /// to the symbol names the glue calls. Async functions, callbacks, and - /// listeners are not supported in this mode; each one becomes an explicit - /// stub that throws at call time and is omitted from the TypeScript - /// declarations. + /// of a `.wasm` source, and binds the module's underscore-prefixed + /// exports to the symbol names the glue calls. Async functions and + /// callback interfaces are not supported in this mode (see + /// [`allow_unsupported`](Self::allow_unsupported)); each affected entry + /// point becomes an explicit stub that throws at call time and is omitted + /// from the TypeScript declarations. pub emscripten: bool, + /// Generate in Emscripten mode even when the API uses async functions or + /// callback interfaces, which that mode cannot support. The orchestrator + /// downgrades the capability failure to a warning and the unsupported + /// entry points are emitted as explicit throwing stubs. Has no effect + /// outside Emscripten mode, where every feature is supported. + pub allow_unsupported: bool, /// Basename of the IDL the CLI was invoked with. #[serde(skip)] pub input_basename: Option, @@ -89,18 +106,32 @@ impl LanguageBackend for WasmGenerator { "wasm" } - /// Every gated feature is supported. Callbacks and listeners share the - /// async machinery: the loader installs one long-lived trampoline per - /// callback typedef in the wasm function table and hands its index to the - /// producer's `register_*` symbol, so `emit_*` dispatches straight back - /// into JavaScript. Because `wasm32-unknown-unknown` is single-threaded, - /// delivery is always synchronous: events fire only while a call into the - /// module is on the stack (a producer that emits from a spawned thread - /// cannot run on this target at all). Emscripten mode emits explicit - /// throwing stubs for callbacks, listeners, and async functions instead; - /// see [`WasmConfig::emscripten`]. - fn capabilities(&self) -> TargetCapabilities { - TargetCapabilities::full() + /// Every gated feature is supported on `wasm32-unknown-unknown`. Callback + /// interfaces and async completions share one mechanism: the loader + /// installs long-lived trampolines in the wasm function table (one per + /// callback method, built into a static vtable in linear memory; one per + /// async result shape) so the producer dispatches straight back into + /// JavaScript. Because the target is single-threaded, delivery is always + /// synchronous: a callback runs only while a call into the module is on + /// the stack (a producer that calls back from a spawned thread cannot run + /// on this target at all). Emscripten mode exposes neither + /// `WebAssembly.Function` nor a growable function table portably, so + /// there async functions and callback interfaces are unsupported and + /// emitted as explicit throwing stubs; see [`WasmConfig::emscripten`]. + fn capabilities(&self, config: &Self::Config) -> TargetCapabilities { + if config.emscripten { + TargetCapabilities { + async_functions: false, + callback_interfaces: false, + iterators: true, + } + } else { + TargetCapabilities::full() + } + } + + fn allows_unsupported(&self, config: &Self::Config) -> bool { + config.allow_unsupported } fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str { @@ -128,7 +159,13 @@ impl LanguageBackend for WasmGenerator { ), OutputFile::new( wasm_dir.join("package.json"), - render_wasm_package_json(&package, &js_filename, &dts_filename, input_basename), + render_wasm_package_json( + &package, + &js_filename, + &dts_filename, + None, + input_basename, + ), ), OutputFile::new( wasm_dir.join(&js_filename), @@ -153,4 +190,93 @@ impl LanguageBackend for WasmGenerator { ), ] } + + /// The npm package layout under `wasm/`: `package.json` (with the `.wasm` + /// listed in `files` when bundled), the loader, its declarations, a + /// README, and the prebuilt `wasm32-unknown-unknown` binary copied in as + /// `.wasm`. The package is a single pure-JS-plus-wasm artifact, + /// so there is no per-platform split. When no wasm32 binary was supplied + /// (or in Emscripten mode, where the consumer builds their own Emscripten + /// module) the package ships without a binary and the README says so. + fn package( + &self, + api: &ResolvedApi, + model: &BindingModel, + ctx: &PackageContext, + out_dir: &Utf8Path, + config: &Self::Config, + ) -> Option> { + let wasm_dir = out_dir.join("wasm"); + let module_name = config.module_name(); + let prefix = config.prefix(); + let input_basename = config.input_basename(); + let js_filename = format!("{module_name}.js"); + let dts_filename = format!("{module_name}.d.ts"); + let package = pkg::resolve(api, None, config.input_basename.as_deref()); + + // Emscripten mode ships glue only (the consumer links the module into + // its own Emscripten build). Otherwise the package exists to carry the + // `.wasm`, so with no wasm32 binary there is nothing to package. + let binary = if config.emscripten { + None + } else { + let b = ctx.binaries.get(Platform::Wasm32)?; + Some(PackagedBinary { + filename: ctx.binaries.bundled_filename(Platform::Wasm32), + source: b.source.clone(), + }) + }; + + let mut files = vec![ + PackagedFile::text( + wasm_dir.join("package.json"), + render_wasm_package_json( + &package, + &js_filename, + &dts_filename, + binary.as_ref().map(|b| b.filename.as_str()), + input_basename, + ), + ), + PackagedFile::text( + wasm_dir.join(&js_filename), + render_wasm_js_stub( + model, + module_name, + prefix, + input_basename, + &js_filename, + config.emscripten, + ), + ), + PackagedFile::text( + wasm_dir.join(&dts_filename), + render_wasm_dts( + model, + module_name, + input_basename, + &dts_filename, + config.emscripten, + ), + ), + PackagedFile::text( + wasm_dir.join("README.md"), + render_packaged_readme( + &package, + module_name, + &js_filename, + binary.as_ref(), + input_basename, + config.emscripten, + ), + ), + ]; + if let Some(b) = &binary { + files.push(PackagedFile::copy( + wasm_dir.join(&b.filename), + b.source.clone(), + )); + } + Some(files) + } } diff --git a/crates/weaveffi-gen-wasm/src/package.rs b/crates/weaveffi-gen-wasm/src/package.rs index dc3c41fa..b0f92f50 100644 --- a/crates/weaveffi-gen-wasm/src/package.rs +++ b/crates/weaveffi-gen-wasm/src/package.rs @@ -1,5 +1,6 @@ -//! Packaging output: the `package.json` manifest and the README with its -//! boundary-convention notes and generated API reference. +//! Packaging output: the `package.json` manifest, the generated-source README +//! with its boundary-convention notes and API reference, and the packaged +//! README describing the bundled artifact. //! //! The manifest is built with [`manifest::JsonObject`] //! (`weaveffi_core::manifest`) so quoting and escaping are centralized; user @@ -8,19 +9,30 @@ use std::collections::HashMap; +use camino::Utf8PathBuf; use weaveffi_core::cache::CLI_VERSION; -use weaveffi_core::codegen::common::walk_modules; use weaveffi_core::errors::ERROR_BRAND; -use weaveffi_core::manifest::JsonObject; +use weaveffi_core::manifest::{JsonObject, JsonValue}; use weaveffi_core::model::{ - BindingModel, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, ModuleBinding, - StructBinding, + BindingModel, CallbackInterfaceBinding, EnumBinding, ErrorBinding, FnBinding, InterfaceBinding, + ModuleBinding, StructBinding, }; use weaveffi_core::pkg::ResolvedPackage; use weaveffi_core::utils::{render_prelude, render_trailer, CommentStyle}; use weaveffi_ir::ir::Api; -use crate::types::{js_code_class_name, type_display, wasm_type, wasm_type_note}; +use crate::types::{ + js_cb_method_name, js_code_class_name, ts_type_for, type_display, wasm_type, wasm_type_note, +}; + +/// The prebuilt `wasm32-unknown-unknown` binary a package bundles: the +/// filename it's shipped under (next to the loader) and where to copy it from. +pub(crate) struct PackagedBinary { + /// The bundled filename, `.wasm`. + pub(crate) filename: String, + /// The on-disk source the binary is copied from. + pub(crate) source: Utf8PathBuf, +} /// Prepend the generated-file prelude entries (`//`, `//warning`, /// `//regenerate`) npm tolerates at the top of `package.json`. Mirrors @@ -42,12 +54,15 @@ fn json_prelude_entries(obj: JsonObject, input_basename: &str) -> JsonObject { } /// Render the `package.json` manifest: prelude comment entries, npm metadata -/// from the resolved package (optional fields only when present), and the -/// fixed ES-module entry points for the loader and its declarations. +/// from the resolved package (optional fields only when present), the fixed +/// ES-module entry points for the loader and its declarations, and, when a +/// binary is bundled, an explicit `files` list so npm publishes exactly the +/// loader, declarations, and `.wasm` (the README is always included by npm). pub(crate) fn render_wasm_package_json( package: &ResolvedPackage, js_filename: &str, dts_filename: &str, + wasm_filename: Option<&str>, input_basename: &str, ) -> String { let mut obj = json_prelude_entries(JsonObject::new(), input_basename) @@ -58,11 +73,110 @@ pub(crate) fn render_wasm_package_json( if let Some(author) = package.authors.first() { obj = obj.str_entry("author", author); } - obj.opt_str_entry("homepage", package.homepage.as_deref()) + obj = obj + .opt_str_entry("homepage", package.homepage.as_deref()) .str_entry("type", "module") .str_entry("main", js_filename) - .str_entry("types", dts_filename) - .render() + .str_entry("types", dts_filename); + if let Some(wasm) = wasm_filename { + obj = obj.entry( + "files", + JsonValue::Array( + [js_filename, dts_filename, wasm] + .into_iter() + .map(JsonValue::str) + .collect(), + ), + ); + } + obj.render() +} + +/// README for a packaged Wasm artifact: what the package contains, how to +/// load it from the bundled `.wasm` (or, without one, where the binary has to +/// come from), and the lifetime rules a consumer must follow. +pub(crate) fn render_packaged_readme( + package: &ResolvedPackage, + module_name: &str, + js_filename: &str, + binary: Option<&PackagedBinary>, + input_basename: &str, + emscripten: bool, +) -> String { + use heck::ToUpperCamelCase; + let load_fn = format!("load{}", module_name.to_upper_camel_case()); + let name = &package.name; + let mut out = render_prelude(CommentStyle::Xml, input_basename); + out.push_str(&format!("# {name} (WebAssembly)\n\n")); + if emscripten { + out.push_str( + "Auto-generated JavaScript glue and TypeScript declarations for an Emscripten \ + build of this library. The package carries no binary: build the Emscripten \ + module yourself (so you control options like `locateFile`) and hand it to \ + the loader.\n\n", + ); + out.push_str("```js\n"); + out.push_str("import Module from './your_library.js';\n"); + out.push_str(&format!("import {{ {load_fn} }} from '{name}';\n\n")); + out.push_str(&format!("const api = await {load_fn}(Module());\n")); + out.push_str("```\n\n"); + out.push_str( + "Async functions and callback interfaces are not supported in Emscripten \ + mode: each affected entry point throws at call time and is omitted from the \ + TypeScript declarations.\n\n", + ); + } else if let Some(b) = binary { + out.push_str(&format!( + "Auto-generated JavaScript loader and TypeScript declarations with the \ + prebuilt `wasm32-unknown-unknown` module bundled as `{}`. No toolchain is \ + needed: fetch the bundled file (or read it in Node) and hand it to the \ + loader.\n\n", + b.filename + )); + out.push_str("```js\n"); + out.push_str(&format!("import {{ {load_fn} }} from '{name}';\n\n")); + out.push_str("// Browsers and bundlers: resolve the bundled file's URL.\n"); + out.push_str(&format!( + "const api = await {load_fn}(new URL('./{}', import.meta.url));\n\n", + b.filename + )); + out.push_str("// Node: pass the bytes instead of fetching.\n"); + out.push_str("import { readFile } from 'node:fs/promises';\n"); + out.push_str(&format!( + "const api2 = await {load_fn}(await readFile(new URL('./{}', import.meta.url)));\n", + b.filename + )); + out.push_str("```\n\n"); + } else { + out.push_str( + "Auto-generated JavaScript loader and TypeScript declarations for a \ + `wasm32-unknown-unknown` build of this library. No `wasm32` binary was \ + supplied when this package was assembled, so it ships without one: build \ + the library with `cargo build --target wasm32-unknown-unknown --release` \ + and pass the resulting `.wasm` (a URL, its bytes, or a compiled \ + `WebAssembly.Module`) to the loader.\n\n", + ); + out.push_str("```js\n"); + out.push_str(&format!("import {{ {load_fn} }} from '{name}';\n\n")); + out.push_str(&format!( + "const api = await {load_fn}('your_library.wasm');\n" + )); + out.push_str("```\n\n"); + } + out.push_str(&format!( + "`{js_filename}` is an ES module; the package is `\"type\": \"module\"`.\n\n" + )); + out.push_str("## Object lifetimes\n\n"); + out.push_str( + "Interface values are wrapper classes holding one reference to a producer \ + object. Call `close()` (or bind the wrapper with `using`, which calls \ + `Symbol.dispose`) when you're done; a wrapper that is garbage collected \ + unclosed is released by a `FinalizationRegistry` backstop where the runtime \ + provides one. Passing a wrapper to a function lends its reference for the \ + duration of the call; a wrapper returned to you is yours to close.\n\n", + ); + out.push_str(&render_trailer(CommentStyle::Xml, "README.md")); + out } /// Render the `README.md`: build and load instructions for the configured @@ -98,7 +212,7 @@ pub(crate) fn render_wasm_readme( out.push_str("import { loadWeaveffiWasm } from './weaveffi_wasm.js';\n\n"); out.push_str("const api = await loadWeaveffiWasm(Module());\n"); out.push_str("```\n\n"); - if walk_modules(&api.modules).any(|m| m.functions.iter().any(|f| f.r#async)) { + if model.has_async() { out.push_str("## Async Functions\n\n"); out.push_str( "Async functions are not supported in Emscripten mode. Each one is \ @@ -108,16 +222,17 @@ pub(crate) fn render_wasm_readme( them.\n\n", ); } - if walk_modules(&api.modules).any(|m| !m.listeners.is_empty()) { - out.push_str("## Callbacks and Listeners\n\n"); + if model.has_callback_interfaces() { + out.push_str("## Callback Interfaces\n\n"); out.push_str( - "Callbacks and listeners are not supported in Emscripten mode: their \ + "Callback interfaces are not supported in Emscripten mode: their \ trampolines rely on `WebAssembly.Function` and a growable \ `__indirect_function_table`, neither of which an Emscripten module \ - exposes portably. Each register/unregister entry point is generated \ - as an explicit stub that throws at call time and is omitted from the \ - TypeScript declarations. Use the standard `wasm32-unknown-unknown` \ - loader or a native target when you need them.\n\n", + exposes portably. Each function taking a callback interface is \ + generated as an explicit stub that throws at call time and is omitted \ + from the TypeScript declarations, along with the interface shapes \ + themselves. Use the standard `wasm32-unknown-unknown` loader or a \ + native target when you need them.\n\n", ); } } else { @@ -126,7 +241,11 @@ pub(crate) fn render_wasm_readme( out.push_str("```bash\n"); out.push_str("cargo build --target wasm32-unknown-unknown --release\n"); out.push_str("```\n\n"); - out.push_str("Then serve the `.wasm` and use `weaveffi_wasm.js` to load it.\n\n"); + out.push_str( + "Then serve the `.wasm` and use `weaveffi_wasm.js` to load it: the loader \ + accepts a URL to fetch, the module's bytes (for Node, where `fetch` cannot \ + read files), or an already compiled `WebAssembly.Module`.\n\n", + ); } out.push_str("## Complex Type Handling\n\n"); out.push_str("Wasm only supports numeric types natively (`i32`, `i64`, `f32`, `f64`). "); @@ -141,11 +260,32 @@ pub(crate) fn render_wasm_readme( out.push_str("Rich (algebraic) enums are plain objects tagged by variant name "); out.push_str("(`{ tag: \"Circle\", radius: 2 }`) and cross the boundary serialized in a "); out.push_str("value buffer, exactly like records.\n\n"); + out.push_str("### Objects\n\n"); + out.push_str("Interfaces are **wrapper classes** holding one reference to a producer "); + out.push_str("object (an `i32` pointer into linear memory). A wrapper you receive is "); + out.push_str("yours: release it with `close()` (or bind it with `using`, which calls "); + out.push_str("`Symbol.dispose`); a wrapper collected unclosed is released by a "); + out.push_str("`FinalizationRegistry` backstop where the runtime provides one, and "); + out.push_str("destroy runs exactly once either way. Passing a wrapper to a function lends "); + out.push_str("its reference for the duration of the call. Inside a value buffer an object "); + out.push_str("crosses as a `u64` token carrying one reference: the writer clones the "); + out.push_str("wrapper's reference and the reader adopts the token into a new wrapper.\n\n"); + out.push_str("### 64-bit Integers\n\n"); + out.push_str("`i64` and `u64` are JavaScript **`bigint`** values everywhere: as arguments, "); + out.push_str("returns, record fields, and callback parameters. Map keys of these types "); + out.push_str("become string property names on the decoded object. A `number` is accepted "); + out.push_str("where a `bigint` is expected (it's widened with `BigInt(v)`, so a fractional "); + out.push_str("value throws a `RangeError`). Values crossing by value follow the wasm "); + out.push_str("calling convention: an out-of-range `bigint` wraps modulo 2^64 rather than "); + out.push_str("throwing, and unsigned results are read back as unsigned (`u64` via "); + out.push_str("`BigInt.asUintN`, `u32` via `>>> 0`).\n\n"); out.push_str("### Optionals\n\n"); out.push_str("Optional values map to **`null`** for the absent case. An optional crosses "); out.push_str("the boundary inside a value buffer as a one-byte presence flag followed by "); out.push_str("the value when present. The one exception is an optional interface, which "); - out.push_str("stays a nullable object pointer (`0` = absent).\n\n"); + out.push_str( + "stays a nullable object pointer (`0` = absent) and maps to `Wrapper | null`.\n\n", + ); out.push_str("### Lists and Maps\n\n"); out.push_str("Lists are JS arrays; maps are plain objects (a `Map` instance is also "); out.push_str("accepted on input). Both cross the boundary serialized in a value buffer "); @@ -157,24 +297,44 @@ pub(crate) fn render_wasm_readme( out.push_str("exactly once, on exhaustion or via `return()` when iteration stops early "); out.push_str("(a `for...of` loop calls `return()` automatically on `break` or `throw`). "); out.push_str("Abandoning an iterator without exhausting or closing it leaks the handle.\n"); - if !emscripten && walk_modules(&api.modules).any(|m| !m.listeners.is_empty()) { - out.push_str("\n### Callbacks and Listeners\n\n"); + if !emscripten && model.has_async() { + out.push_str("\n### Async Functions\n\n"); + out.push_str( + "`async` functions return a **`Promise`**. `wasm32-unknown-unknown` has no \ + threads, so the producer's default spawner drives each future to completion \ + inline, inside the launch call: the completion callback fires (through a \ + function-table trampoline) before the wrapper returns, and the Promise is \ + already settled when you `await` it. There is nothing to pump or poll from \ + JS. A future that returns `Pending` can't be woken on this target with the \ + default spawner; a producer whose futures wait on external events needs its \ + own spawner (`weaveffi::set_spawner`), and driving that executor is then the \ + producer's responsibility, not the glue's. A typed error rejects the Promise \ + with the same class the synchronous wrapper would throw.\n", + ); + } + if !emscripten && model.has_callback_interfaces() { + out.push_str("\n### Callback Interfaces\n\n"); out.push_str( - "Each listener surfaces as a `register.../unregister...` pair. `register` \ - takes a plain JS function and returns a numeric subscription id; \ - `unregister` takes that id and stops delivery. Delivery is **synchronous \ - and same-thread**: `wasm32-unknown-unknown` is single-threaded, so events \ - fire only while a call into the module is on the stack (for example, a \ - producer function that emits during its own execution). A producer that \ - emits from a spawned thread cannot run on this target at all.\n\n", + "A callback interface is a TypeScript `interface`; pass any object with the \ + matching methods where a parameter of that type is expected. The loader \ + keeps one **static vtable per interface** in linear memory, filled with \ + function-table indices of long-lived trampolines, and passes a small \ + integer context key with it; the producer calls `free(ctx)` when it drops \ + the callback, which releases your object. Delivery is **synchronous and \ + same-thread**: `wasm32-unknown-unknown` is single-threaded, so a method \ + runs only while a call into the module is on the stack. A producer that \ + calls back from a spawned thread cannot run on this target at all.\n\n", ); out.push_str( - "Callback arguments are **borrowed for the duration of the callback**: \ - strings, byte buffers, and buffered values (records, rich enums, \ - optionals, lists, maps) are copied or decoded into JS values before your \ - function runs, and interface arguments wrap producer-owned objects. Read \ - what you need inside the callback and do not retain an interface wrapper \ - or call `free()` on it.\n", + "Method arguments that are strings, bytes, or buffered values are \ + **borrowed for the duration of the call**: they're copied or decoded into \ + JS values before your method runs. Object arguments transfer one reference \ + to you: `close()` the wrapper when you're done with it. An exception thrown \ + from a method is reported to the producer as error code `-4` (with the \ + exception's message) and the method's default value is returned in its \ + place; the producer then aborts the call it was making, and the original \ + caller sees a `WeaveFFIError` with code `-4` carrying your message (see \ + *Traps* below for how that abort reaches the caller on this target).\n", ); } out.push_str("\n### Error Handling\n\n"); @@ -187,14 +347,46 @@ pub(crate) fn render_wasm_readme( out.push_str("- `weaveffi_alloc(size: i32) -> i32`: allocate `size` bytes in linear memory\n"); out.push_str("- `weaveffi_dealloc(ptr: i32, size: i32)`: release a `weaveffi_alloc` block\n"); out.push_str("- `weaveffi_error_clear(err_ptr: i32)`: clear and free error resources\n"); + out.push_str("- `weaveffi_error_set(err_ptr: i32, code: i32, msg: i32)`: fill an error slot (used to report callback exceptions)\n"); out.push_str("- `weaveffi_free_string(ptr: i32)`: free a producer-returned C string\n"); out.push_str("- `weaveffi_free_bytes(ptr: i32, len: i32)`: free a producer-returned buffer\n"); out.push_str("\nWrappers of functions declared `throws` raise the declaring module's typed\n"); out.push_str("error class (a `WeaveFFIError` subclass with a per-code subclass, such as\n"); out.push_str("`KeyNotFound`); every other wrapper raises the generic `WeaveFFIError` only\n"); - out.push_str("for producer panics and marshalling failures. When the matched error code\n"); - out.push_str("declares payload fields, the wrapper decodes them from the error's value\n"); - out.push_str("buffer and attaches them as properties on the thrown error.\n"); + out.push_str("for producer panics, marshalling failures, and callback exceptions\n"); + out.push_str("(code `-4`). When the matched error code declares payload fields, the\n"); + out.push_str("wrapper decodes them from the error's value buffer and attaches them as\n"); + out.push_str("properties on the thrown error.\n"); + if !emscripten { + out.push_str("\n#### Callback failures without unwinding\n\n"); + out.push_str( + "`wasm32-unknown-unknown` builds with `panic = \"abort\"`, so when a callback \ + interface method throws, the producer can't unwind out of the call the way \ + native targets do. Instead the runtime records the failure, the producer's \ + code runs to completion on the method's default return value (`0`, `0n`, \ + or `false`), and the thunk then reports code `-4` with the exception's \ + message in place of the result, so the wrapper throws (or the Promise \ + rejects with) the same `WeaveFFIError` a native build would produce. While \ + that producer call is still on the stack, the glue refuses every further \ + callback invocation with the same error rather than calling your \ + implementation again, so your code observes the failure exactly as it would \ + under unwinding. Producers must tolerate a callback returning its default \ + once (a well-behaved producer already does, because a consumer could return \ + that value on purpose).\n", + ); + out.push_str("\n#### Traps\n\n"); + out.push_str( + "A producer panic can't be caught on the Rust side either: it executes \ + `unreachable`, and the engine throws a `WebAssembly.RuntimeError` out of \ + the call. The glue translates that trap for you: the wrapper throws (or the \ + Promise rejects with) a `WeaveFFIError` with code `-2` (`producer \ + panicked`), or code `-4` if a callback method had already failed during \ + the call. The error slot and every staged argument are released either \ + way. The producer frames between the trap and the call are *not* unwound, \ + so their cleanup does not run: a lock held across the panic stays locked, \ + and allocations those frames owned leak.\n", + ); + } if !api.modules.is_empty() { render_api_reference(&mut out, api, model); @@ -234,6 +426,13 @@ fn render_api_reference(out: &mut String, api: &Api, model: &BindingModel) { } } + if !mb.callback_interfaces.is_empty() { + out.push_str("\n#### Callback Interfaces\n"); + for cb in &mb.callback_interfaces { + render_callback_interface_ref(out, cb); + } + } + if !mb.structs.is_empty() { out.push_str("\n#### Structs\n"); for s in &mb.structs { @@ -284,7 +483,7 @@ fn render_error_ref(out: &mut String, eb: &ErrorBinding) { } } -/// Document one interface: an opaque handle wrapped by a JS class, with the +/// Document one interface: an object pointer wrapped by a JS class, with the /// member entry points listed at the ABI level like free functions. fn render_interface_ref(out: &mut String, i: &InterfaceBinding) { out.push_str(&format!("\n##### `{}`\n\n", i.name)); @@ -293,9 +492,10 @@ fn render_interface_ref(out: &mut String, i: &InterfaceBinding) { out.push_str("\n\n"); } out.push_str( - "Passed as an **opaque object pointer** (`i32`), wrapped by a JS class. \ - Constructors return an owned handle; methods pass the handle as the implicit \ - leading `self` argument; `free()` releases the handle via the destroy symbol.\n", + "Passed as an **object pointer** (`i32`), wrapped by a JS class holding one \ + reference. Constructors return an owned reference; methods lend the pointer \ + as the implicit leading `self` argument; `close()` (or `Symbol.dispose`) \ + releases the reference via the destroy symbol exactly once.\n", ); for f in i .constructors @@ -306,11 +506,57 @@ fn render_interface_ref(out: &mut String, i: &InterfaceBinding) { render_function_ref(out, f); } out.push_str(&format!( - "\n##### `{}`\n\nReleases the object reference. Called by the wrapper's `free()`.\n", + "\n##### `{}`\n\nReturns a second reference to the same object. Used when the \ + wrapper is encoded as an object token inside a value buffer.\n", + i.clone_symbol + )); + out.push_str(&format!( + "\n##### `{}`\n\nReleases one object reference. Called by the wrapper's `close()` \ + (and by the finalizer backstop).\n", i.destroy_symbol )); } +/// Document one callback interface: the TS shape a consumer implements, its +/// vtable layout, and each method's parameters at the API level. +fn render_callback_interface_ref(out: &mut String, cb: &CallbackInterfaceBinding) { + out.push_str(&format!("\n##### `{}`\n\n", cb.name)); + if let Some(doc) = &cb.doc { + out.push_str(doc); + out.push_str("\n\n"); + } + if let Some(msg) = &cb.deprecated { + out.push_str(&format!("**Deprecated:** {msg}\n\n")); + } + out.push_str(&format!( + "Implemented in JS as an object with the methods below. Passed as an `i32` \ + context key plus an `i32` pointer to the static `{}` vtable; the vtable's \ + trailing `free(ctx)` entry releases the JS object.\n\n", + cb.vtable_tag + )); + out.push_str("| Method | Parameters | Returns |\n"); + out.push_str("|--------|------------|---------|\n"); + for m in &cb.methods { + let params = if m.params.is_empty() { + "(none)".to_string() + } else { + m.params + .iter() + .map(|p| format!("`{}: {}`", p.name, ts_type_for(&p.ty))) + .collect::>() + .join(", ") + }; + let ret = m + .ret + .as_ref() + .map_or_else(|| "`void`".to_string(), |t| format!("`{}`", ts_type_for(t))); + out.push_str(&format!( + "| `{}` | {params} | {ret} |\n", + js_cb_method_name(&m.name) + )); + } +} + /// Document one callable at the ABI level: its C signature in wasm value /// types and a per-parameter table of API type, wasm slot type, and boundary /// note. diff --git a/crates/weaveffi-gen-wasm/src/runtime.rs b/crates/weaveffi-gen-wasm/src/runtime.rs index 6df8c365..cd429e4e 100644 --- a/crates/weaveffi-gen-wasm/src/runtime.rs +++ b/crates/weaveffi-gen-wasm/src/runtime.rs @@ -1,9 +1,15 @@ //! The fixed JS runtime the loader embeds: string, byte, and error-slot //! helpers, the error class hierarchy with its per-domain factories and -//! checkers, the lazy iterator wrapper, and the trampoline registrar. +//! checkers, the object-wrapper lifecycle helpers, the lazy iterator wrapper, +//! the trampoline registrar, and the foreign-error reporter callback +//! trampolines use. //! //! Everything here is emitted conditionally by the loader assembler in -//! [`crate::entities`], keyed off which features the API actually uses. +//! [`crate::entities`], keyed off which features the API actually uses. The +//! error classes are exported at module scope; the per-domain factories and +//! checkers decode error payloads through the value-buffer codecs, which live +//! inside the loader (they reference the loader-scoped interface classes for +//! object tokens), so they're emitted there too. use heck::ToShoutySnakeCase; use weaveffi_core::cabi::ABI_VERSION; @@ -17,23 +23,22 @@ use crate::types::{ }; /// Emit the module-scope error classes: the generic `WeaveFFIError` base -/// (unknown codes, marshalling failures, panics), then one domain class per -/// declaring module (`class KvError extends WeaveFFIError`) with one subclass -/// per code carrying its stable `CODE` and default message. Each code class -/// is also aliased onto its domain class (`KvError.KeyNotFound`), and each -/// domain gets a factory that builds the matching subclass and decodes any -/// declared payload fields from the error's value buffer into properties on -/// the thrown error. +/// (unknown codes, marshalling failures, panics, foreign callback failures), +/// then one domain class per declaring module (`class KvError extends +/// WeaveFFIError`) with one subclass per code carrying its stable `CODE` and +/// default message. Each code class is also aliased onto its domain class +/// (`KvError.KeyNotFound`). /// -/// Domain codes are validated positive-only, so the frozen code table never -/// contains a negative key: the reserved runtime codes (`-1` generic, `-2` -/// panic, `-3` marshalling) always miss the table and fall through to the -/// generic brand error. +/// Domain codes are validated positive-only, so the reserved runtime codes +/// (`-1` generic, `-2` panic, `-3` marshalling, `-4` a callback-interface +/// implementation raised) always fall through to the generic brand error in +/// the factories emitted by [`emit_js_error_factories`]. pub(crate) fn emit_js_error_classes(out: &mut String, model: &BindingModel) { let mut w = CodeWriter::two_space(); w.line("/** Base error for WeaveFFI failures: domain errors extend it, and it is"); - w.line(" * thrown directly for unknown codes, marshalling failures, and producer"); - w.line(" * panics. Carries the stable ABI `code`. */"); + w.line(" * thrown directly for unknown codes, marshalling failures, producer"); + w.line(" * panics, and callback-interface implementations that raised (code -4)."); + w.line(" * Carries the stable ABI `code`. */"); w.block(format!("export class {ERROR_BRAND} extends Error {{"), "}", |w| { w.block("constructor(code, message) {", "}", |w| { w.line("super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`);"); @@ -85,20 +90,43 @@ pub(crate) fn emit_js_error_classes(out: &mut String, model: &BindingModel) { } let table = js_error_code_table_name(eb); - let factory = js_error_factory_name(eb); - let has_payload = eb.codes.iter().any(|c| !c.fields.is_empty()); w.block(format!("const {table} = Object.freeze({{"), "});", |w| { for c in &eb.codes { w.line(format!("{}: {},", c.value, js_code_class_name(&c.name))); } }); w.blank(); + } + out.push_str(&w.finish()); +} + +/// `_{TYPE_NAME}_CODES`: the frozen code-to-class table for one domain. +fn js_error_code_table_name(eb: &ErrorBinding) -> String { + format!("_{}_CODES", eb.type_name.to_shouty_snake_case()) +} + +/// Emit one `_{domain}From(wasm, code, message, payloadPtr, payloadLen)` +/// factory per declaring module at `indent` (loader scope): it builds the +/// matching code subclass (or the generic brand error for codes outside the +/// domain) and decodes any declared payload fields from the error's value +/// buffer into properties on the thrown error. +pub(crate) fn emit_js_error_factories(out: &mut String, model: &BindingModel, indent: &str) { + let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); + for m in &model.modules { + let Some(eb) = m.error.as_ref().filter(|eb| eb.declared_here) else { + continue; + }; + let domain = &eb.type_name; + let table = js_error_code_table_name(eb); + let factory = js_error_factory_name(eb); + let has_payload = eb.codes.iter().any(|c| !c.fields.is_empty()); w.line(format!( "// Build the {domain} subclass matching `code`, or a generic" )); w.line(format!( - "// {ERROR_BRAND} for codes outside the domain (panics, marshalling)." + "// {ERROR_BRAND} for codes outside the domain (panics, marshalling," )); + w.line("// foreign callback failures)."); if has_payload { w.line("// Codes that declare payload fields decode them from the error's"); w.line("// borrowed value buffer into properties on the thrown error."); @@ -140,18 +168,14 @@ pub(crate) fn emit_js_error_classes(out: &mut String, model: &BindingModel) { out.push_str(&w.finish()); } -/// `_{TYPE_NAME}_CODES`: the frozen code-to-class table for one domain. -fn js_error_code_table_name(eb: &ErrorBinding) -> String { - format!("_{}_CODES", eb.type_name.to_shouty_snake_case()) -} - -/// Emit one `_check{Domain}(wasm, errPtr)` helper per declaring module: -/// identical to the generic `_checkErr` except the thrown error is built by -/// the domain's factory, so domain codes surface as their typed subclasses -/// with any declared payload fields decoded and attached. The payload is -/// decoded before `weaveffi_error_clear` releases it. -pub(crate) fn emit_js_error_checkers(out: &mut String, model: &BindingModel) { - let mut w = CodeWriter::two_space(); +/// Emit one `_check{Domain}(wasm, errPtr)` helper per declaring module at +/// `indent` (loader scope): identical to the generic `_checkErr` except the +/// thrown error is built by the domain's factory, so domain codes surface as +/// their typed subclasses with any declared payload fields decoded and +/// attached. The payload is decoded before `weaveffi_error_clear` releases +/// it. +pub(crate) fn emit_js_error_checkers(out: &mut String, model: &BindingModel, indent: &str) { + let mut w = CodeWriter::two_space().with_depth(indent.len() / 2); for m in &model.modules { let Some(eb) = m.error.as_ref().filter(|eb| eb.declared_here) else { continue; @@ -164,6 +188,7 @@ pub(crate) fn emit_js_error_checkers(out: &mut String, model: &BindingModel) { )); w.line("// carries a non-zero code."); w.block(format!("function {checker}(wasm, errPtr) {{"), "}", |w| { + w.line("_pendingForeign = null;"); w.line("const dv = new DataView(wasm.memory.buffer);"); w.line("const code = dv.getInt32(errPtr, true);"); w.block("if (code !== 0) {", "}", |w| { @@ -249,9 +274,11 @@ pub(crate) fn emit_error_slot_helpers(out: &mut String) { out.push_str("}\n\n"); out.push_str("// Throw (and free the slot) if the error slot carries a non-zero code.\n"); out.push_str("// Non-throwing wrappers route here: a non-zero code can only be a\n"); - out.push_str("// producer panic or a marshalling failure, surfaced as the generic\n"); + out.push_str("// producer panic (-2), a marshalling failure (-3), or a callback\n"); + out.push_str("// interface implementation that raised (-4), surfaced as the generic\n"); out.push_str(&format!("// {ERROR_BRAND}.\n")); out.push_str("function _checkErr(wasm, errPtr) {\n"); + out.push_str(" _pendingForeign = null;\n"); out.push_str(" const dv = new DataView(wasm.memory.buffer);\n"); out.push_str(" const code = dv.getInt32(errPtr, true);\n"); out.push_str(" if (code !== 0) {\n"); @@ -264,8 +291,60 @@ pub(crate) fn emit_error_slot_helpers(out: &mut String) { out.push_str("}\n\n"); out.push_str("// Release an error slot on the success path.\n"); out.push_str("function _freeErr(wasm, errPtr) {\n"); + out.push_str(" _pendingForeign = null;\n"); out.push_str(" wasm.weaveffi_dealloc(errPtr, 16);\n"); out.push_str("}\n\n"); + emit_trap_helpers(out); +} + +/// Emit `_pendingForeign`, `_trapError`, and `_trap`: the trap translation +/// every producer call routes its exceptions through. +/// +/// `wasm32-unknown-unknown` has no unwinding runtime, so a producer panic +/// can't be caught and reported through `out_err` the way native targets do: +/// it executes `unreachable` and the engine throws a `WebAssembly.RuntimeError` +/// out of the call. The glue restores the ABI's contract on the JS side: a +/// trap that follows a callback-interface failure recorded in +/// `_pendingForeign` becomes the `-4` brand error with the consumer's message +/// (the runtime normally reports that failure through `out_err` without +/// trapping; this is the fallback), and any other trap is reported as a +/// producer panic (`-2`). Exceptions that aren't traps (the `-3` from +/// `_borrow`, for example) pass through unchanged. +fn emit_trap_helpers(out: &mut String) { + let mut w = CodeWriter::two_space(); + w.line("// The message of a callback-interface failure a trampoline reported through"); + w.line("// out_err, held until the producer call on the stack returns (every error"); + w.line("// checker and _freeErr clear it) so later callbacks in that call are refused."); + w.line("let _pendingForeign = null;"); + w.blank(); + w.line("// Translate an exception raised while a producer call was on the stack."); + w.line("// wasm32-unknown-unknown has no unwinding runtime: a producer panic executes"); + w.line("// `unreachable`, so the engine throws a WebAssembly.RuntimeError instead of"); + w.line("// the producer writing out_err. A trap that follows a callback-interface"); + w.line("// failure is labelled with that failure (-4); any other trap is a producer"); + w.line("// panic (-2). Non-trap exceptions pass through unchanged. The producer"); + w.line("// frames between the trap and the call are not unwound, so a lock held"); + w.line("// across a callback stays locked; a producer that snapshots its state"); + w.line("// before calling out stays usable."); + w.block("function _trapError(e) {", "}", |w| { + w.line("const foreign = _pendingForeign;"); + w.line("_pendingForeign = null;"); + w.line("if (!(e instanceof WebAssembly.RuntimeError)) return e;"); + w.line(format!( + "if (foreign !== null) return new {ERROR_BRAND}(-4, foreign);" + )); + w.line(format!( + "return new {ERROR_BRAND}(-2, 'producer panicked: ' + e.message);" + )); + }); + w.blank(); + w.line("// Release the error slot a failed call never filled, then translate."); + w.block("function _trap(wasm, errPtr, e) {", "}", |w| { + w.line("_freeErr(wasm, errPtr);"); + w.line("return _trapError(e);"); + }); + w.blank(); + out.push_str(&w.finish()); } /// Emit `_checkAbiVersion`, which the loader calls on the bound export table @@ -300,6 +379,7 @@ pub(crate) fn emit_check_err_ref(out: &mut String) { "// throwing callables; without it the generic {ERROR_BRAND} is thrown.\n" )); out.push_str("function _checkErrRef(wasm, errPtr, mkErr) {\n"); + out.push_str(" _pendingForeign = null;\n"); out.push_str(" const dv = new DataView(wasm.memory.buffer);\n"); out.push_str(" const code = dv.getInt32(errPtr, true);\n"); out.push_str(" if (code === 0) { wasm.weaveffi_error_free(errPtr); return; }\n"); @@ -312,6 +392,107 @@ pub(crate) fn emit_check_err_ref(out: &mut String) { out.push_str("}\n\n"); } +/// Emit the object-wrapper lifecycle helpers shared by every interface class: +/// `_dispose` (the `Symbol.dispose` well-known symbol, or its registered +/// stand-in on runtimes that predate explicit resource management), the +/// `FinalizationRegistry` backstop, `_adopt` (bind one strong reference to a +/// wrapper and arm the backstop), `_release` (destroy exactly once, disarming +/// the backstop), and `_borrow` (the live pointer of a wrapper passed as an +/// argument, rejecting a closed one). +pub(crate) fn emit_object_helpers(out: &mut String) { + let mut w = CodeWriter::two_space(); + w.line("// `using` declarations (explicit resource management) call this symbol;"); + w.line("// runtimes that predate it get the registered stand-in so the method is"); + w.line("// still reachable by name."); + w.line("const _dispose = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose');"); + w.blank(); + w.line("// Finalization backstop: a wrapper that is garbage collected without"); + w.line("// close() still releases its reference. The held value is the"); + w.line("// [destroy, handle] pair (never the wrapper, which would keep it alive);"); + w.line("// the wrapper is the unregister token so close() disarms the backstop and"); + w.line("// destroy runs exactly once either way. Absent on runtimes without"); + w.line("// FinalizationRegistry, where close() is the only release path."); + w.line("const _finalizer = typeof FinalizationRegistry === 'function'"); + w.line(" ? new FinalizationRegistry(([destroy, handle]) => destroy(handle))"); + w.line(" : null;"); + w.blank(); + w.line("// Bind one strong reference to a wrapper and arm the backstop."); + w.block("function _adopt(obj, handle, destroy) {", "}", |w| { + w.line("obj._handle = handle;"); + w.line("obj._destroy = destroy;"); + w.line("if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj);"); + w.line("return obj;"); + }); + w.blank(); + w.line("// Release a wrapper's reference exactly once; later calls are no-ops."); + w.block("function _release(obj) {", "}", |w| { + w.line("if (!obj._handle) return;"); + w.line("if (_finalizer !== null) _finalizer.unregister(obj);"); + w.line("obj._destroy(obj._handle);"); + w.line("obj._handle = 0;"); + }); + w.blank(); + w.line("// The pointer of a live wrapper, lent to the producer for one call (the"); + w.line("// wrapper keeps its own reference). A closed or non-object argument is a"); + w.line("// consumer programming error, reported with the marshalling code."); + w.block("function _borrow(obj) {", "}", |w| { + w.block( + "if (obj === null || obj === undefined || !obj._handle) {", + "}", + |w| { + w.line(format!( + "throw new {ERROR_BRAND}(-3, 'expected a live object wrapper');" + )); + }, + ); + w.line("return obj._handle;"); + }); + w.blank(); + out.push_str(&w.finish()); +} + +/// Emit `_reportForeign` and `_setForeignError`, which callback-interface +/// trampolines call when the consumer's implementation throws: the exception +/// message is staged in linear memory only for the duration of the +/// `weaveffi_error_set` call (the producer copies it with its own allocator), +/// then released, and the producer sees `FOREIGN_ERROR_CODE` (`-4`). +/// +/// `wasm32-unknown-unknown` builds with `panic = "abort"`, so the producer +/// can't unwind out of the failed call the way native targets do; the +/// runtime records the failure instead and the producer's code runs to +/// completion on the trampoline's default return value before the thunk +/// reports `-4`. The message is parked in `_pendingForeign` for two reasons: +/// so that every further callback invocation during the same producer call +/// is refused with the same error rather than reaching the consumer's +/// implementation again (matching what unwinding would have done), and so +/// that `_trapError` can still label a trap, should one follow. +pub(crate) fn emit_foreign_error_helper(out: &mut String) { + let mut w = CodeWriter::two_space(); + w.line("// Report a callback-interface failure through the trampoline's out_err"); + w.line("// slot as FOREIGN_ERROR_CODE (-4). weaveffi_error_set copies the message"); + w.line("// with the producer's allocator, so the staged C string is released as"); + w.line("// soon as it returns. The message is parked in _pendingForeign until the"); + w.line("// producer call on the stack returns: the producer can't unwind on wasm32,"); + w.line("// so it keeps running on the trampoline's default return value, and any"); + w.line("// further callback it makes during that call is refused with this error"); + w.line("// instead of reaching the implementation again."); + w.block("function _reportForeign(wasm, errPtr, msg) {", "}", |w| { + w.line("if (_pendingForeign === null) _pendingForeign = msg;"); + w.line("const [p, s] = _cstr(wasm, msg);"); + w.line("wasm.weaveffi_error_set(errPtr, -4, p);"); + w.line("wasm.weaveffi_dealloc(p, s);"); + }); + w.blank(); + w.line("// Report an exception thrown by a callback-interface implementation."); + w.block("function _setForeignError(wasm, errPtr, e) {", "}", |w| { + w.line( + "_reportForeign(wasm, errPtr, e instanceof Error ? (e.message || e.name) : String(e));", + ); + }); + w.blank(); + out.push_str(&w.finish()); +} + /// Emit the shared `_WeaveFFIIterator` class: a lazy wrapper over a producer /// iterator handle implementing the JS iterator protocol, destroying the /// handle exactly once (on exhaustion, on a `next` error, or from @@ -352,6 +533,12 @@ pub(crate) fn emit_iterator_class(out: &mut String) { out.push_str(" let _has;\n"); out.push_str(" try {\n"); out.push_str(" _has = this._callNext(this._handle, this._slot, _err);\n"); + out.push_str(" } catch (e) {\n"); + out.push_str(" // A trap: the slot was never filled, so release it here.\n"); + out.push_str(" this._close();\n"); + out.push_str(" throw _trap(wasm, _err, e);\n"); + out.push_str(" }\n"); + out.push_str(" try {\n"); out.push_str(" // Throws (and releases the slot) on a non-zero code.\n"); out.push_str(" this._check(wasm, _err);\n"); out.push_str(" } catch (e) {\n"); @@ -377,13 +564,15 @@ pub(crate) fn emit_iterator_class(out: &mut String) { } /// Emit `_registerTrampoline`, which grows the wasm function table by one -/// slot and installs a `WebAssembly.Function` wrapping `handler`, returning -/// the new index. Shared by the async completion and listener trampolines. +/// slot and installs a `WebAssembly.Function` wrapping `handler` with the +/// given wasm parameter and result types, returning the new index. Shared by +/// the async completion trampolines and the callback-interface vtable +/// entries. pub(crate) fn emit_trampoline_helper(out: &mut String) { - out.push_str("function _registerTrampoline(table, paramTypes, handler) {\n"); + out.push_str("function _registerTrampoline(table, paramTypes, resultTypes, handler) {\n"); out.push_str(" const idx = table.grow(1);\n"); out.push_str(" table.set(idx, new WebAssembly.Function(\n"); - out.push_str(" { parameters: paramTypes, results: [] },\n"); + out.push_str(" { parameters: paramTypes, results: resultTypes },\n"); out.push_str(" handler\n"); out.push_str(" ));\n"); out.push_str(" return idx;\n"); diff --git a/crates/weaveffi-gen-wasm/src/types.rs b/crates/weaveffi-gen-wasm/src/types.rs index bd4839f9..feba7852 100644 --- a/crates/weaveffi-gen-wasm/src/types.rs +++ b/crates/weaveffi-gen-wasm/src/types.rs @@ -15,19 +15,20 @@ use weaveffi_core::utils::local_type_name; /// The wasm value-type spelling of one IDL type at the boundary, for the /// README's signature tables. Buffered types occupy two `i32` slots (pointer -/// plus length); everything else keeps its scalar or pointer slot. +/// plus length); a callback interface occupies two `i32` slots (context plus +/// vtable pointer); everything else keeps its scalar or pointer slot. pub(crate) fn wasm_type(ty: &Ty) -> &'static str { if ty.is_buffered() { return "i32, i32"; } match ty { Ty::I8 | Ty::I16 | Ty::I32 | Ty::U8 | Ty::U16 | Ty::U32 | Ty::Bool | Ty::Enum(_) => "i32", - Ty::I64 | Ty::U64 | Ty::Handle => "i64", + Ty::I64 | Ty::U64 => "i64", Ty::F32 => "f32", Ty::F64 => "f64", - Ty::StringUtf8 | Ty::BorrowedStr => "i32", - Ty::Bytes | Ty::BorrowedBytes => "i32, i32", - Ty::TypedHandle(_) | Ty::Interface(_) | Ty::Iterator(_) => "i32", + Ty::StringUtf8 => "i32", + Ty::Bytes | Ty::CallbackInterface(_) => "i32, i32", + Ty::Interface(_) | Ty::Iterator(_) => "i32", // Only `Interface?` reaches here: a nullable object pointer. Ty::Optional(_) => "i32", Ty::Record(_) | Ty::RichEnum(_) | Ty::List(_) | Ty::Map(_, _) => { @@ -54,11 +55,10 @@ pub(crate) fn wasm_type_note(ty: &Ty) -> &'static str { Ty::F32 => "native Wasm f32", Ty::F64 => "native Wasm f64", Ty::Bool => "0 = false, 1 = true", - Ty::StringUtf8 | Ty::BorrowedStr => "NUL-terminated C string pointer", - Ty::Bytes | Ty::BorrowedBytes => "ptr + len in linear memory", - Ty::TypedHandle(_) => "opaque pointer", - Ty::Handle => "opaque 64-bit handle", - Ty::Interface(_) => "opaque object pointer", + Ty::StringUtf8 => "NUL-terminated C string pointer", + Ty::Bytes => "ptr + len in linear memory", + Ty::Interface(_) => "borrowed object pointer", + Ty::CallbackInterface(_) => "context key + static vtable pointer", Ty::Enum(_) => "variant discriminant", Ty::Iterator(_) => "opaque iterator handle", Ty::Optional(_) => "nullable object pointer, 0 = absent", @@ -83,16 +83,16 @@ pub(crate) fn type_display(ty: &Ty) -> String { Ty::F32 => "f32".into(), Ty::F64 => "f64".into(), Ty::Bool => "bool".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "string".into(), - Ty::Bytes | Ty::BorrowedBytes => "bytes".into(), - Ty::TypedHandle(_) | Ty::Handle => "handle".into(), - Ty::Record(n) | Ty::RichEnum(n) => local_type_name(n).to_string(), + Ty::StringUtf8 => "string".into(), + Ty::Bytes => "bytes".into(), + Ty::Record(n) | Ty::RichEnum(n) | Ty::Interface(n) | Ty::CallbackInterface(n) => { + local_type_name(n).to_string() + } Ty::Enum(n) => n.clone(), Ty::Optional(inner) => format!("{}?", type_display(inner)), Ty::List(inner) => format!("[{}]", type_display(inner)), Ty::Iterator(inner) => format!("iter<{}>", type_display(inner)), Ty::Map(k, v) => format!("{{{}:{}}}", type_display(k), type_display(v)), - Ty::Interface(n) => local_type_name(n).to_string(), } } @@ -103,23 +103,22 @@ pub(crate) fn ts_type_for(ty: &Ty) -> String { "number".into() } Ty::Bool => "boolean".into(), - Ty::StringUtf8 | Ty::BorrowedStr => "string".into(), + Ty::StringUtf8 => "string".into(), // Bytes cross the boundary as plain `Uint8Array` copies; the Node-only // `Buffer` type does not exist in browsers and is never returned here. - Ty::Bytes | Ty::BorrowedBytes => "Uint8Array".into(), + Ty::Bytes => "Uint8Array".into(), // Every 64-bit integer crosses the JS boundary as a BigInt: wasm i64 // results arrive as BigInt and i64 arguments are BigInt-coerced. - Ty::I64 | Ty::U64 | Ty::Handle => "bigint".into(), - // A typed handle is an opaque i32 pointer at the ABI, surfaced as a - // plain number. - Ty::TypedHandle(_) => "number".into(), - // Records, rich enums, plain enums, and interfaces surface as bare - // local TS names; a cross-module reference (resolved to e.g. - // `kv.Store`) must name the local `Store`, not the qualified IR name - // which is undeclared here. - Ty::Enum(name) | Ty::Record(name) | Ty::RichEnum(name) | Ty::Interface(name) => { - local_type_name(name).to_string() - } + Ty::I64 | Ty::U64 => "bigint".into(), + // Records, rich enums, plain enums, interfaces, and callback + // interfaces surface as bare local TS names; a cross-module reference + // (resolved to e.g. `kv.Store`) must name the local `Store`, not the + // qualified IR name which is undeclared here. + Ty::Enum(name) + | Ty::Record(name) + | Ty::RichEnum(name) + | Ty::Interface(name) + | Ty::CallbackInterface(name) => local_type_name(name).to_string(), Ty::Optional(inner) => format!("{} | null", ts_type_for(inner)), Ty::List(inner) => { let inner_ts = ts_type_for(inner); @@ -140,7 +139,7 @@ pub(crate) fn ts_type_for(ty: &Ty) -> String { // constraint, so 64-bit keys are typed as the strings they become. Ty::Map(k, v) => { let key = match k.as_ref() { - Ty::I64 | Ty::U64 | Ty::Handle => "string".to_string(), + Ty::I64 | Ty::U64 => "string".to_string(), other => ts_type_for(other), }; format!("Record<{key}, {}>", ts_type_for(v)) @@ -148,9 +147,9 @@ pub(crate) fn ts_type_for(ty: &Ty) -> String { } } -/// True if `ty` is one of the UTF-8 string spellings. +/// True if `ty` is the UTF-8 string type. pub(crate) fn is_string_type(ty: &Ty) -> bool { - matches!(ty, Ty::StringUtf8 | Ty::BorrowedStr) + matches!(ty, Ty::StringUtf8) } // ── Naming and error-surface policy ── @@ -162,6 +161,13 @@ pub(crate) fn js_fn_name(f: &FnBinding) -> String { f.name.to_lower_camel_case() } +/// The lowerCamelCase JS name of a callback-interface method (`on_message` +/// becomes `onMessage`): the property the trampoline invokes on the consumer's +/// implementation object and the member the TS `interface` declares. +pub(crate) fn js_cb_method_name(name: &str) -> String { + name.to_lower_camel_case() +} + /// The camelCase JS spelling of one parameter (`ttl_seconds` becomes /// `ttlSeconds`), escaped when the camel-cased form collides with a JS/TS /// reserved word (a parameter named `new` becomes `new_`; property and @@ -193,7 +199,8 @@ pub(crate) fn js_error_checker_name(eb: &ErrorBinding) -> String { /// The error-check helper a callable's out-err slot routes through, per its /// [`ErrorStrategy`]: the module domain's typed checker for /// [`ErrorStrategy::Throws`], the generic `_checkErr` (plain `WeaveFFIError`; -/// panics and marshalling failures only) for [`ErrorStrategy::Trap`]. +/// panics, marshalling failures, and foreign callback failures only) for +/// [`ErrorStrategy::Trap`]. pub(crate) fn js_checker_name(f: &FnBinding, error: Option<&ErrorBinding>) -> String { match (f.error_strategy(), error) { (ErrorStrategy::Throws, Some(eb)) => js_error_checker_name(eb), @@ -212,6 +219,28 @@ pub(crate) fn js_err_factory(f: &FnBinding, error: Option<&ErrorBinding>) -> Opt } } +/// The loader-scoped JS constant holding the linear-memory address of the +/// static vtable for the (possibly dot-qualified) callback interface `name`. +/// Callback interface names are unique across the API, so the local name is +/// collision-free. +pub(crate) fn js_vtable_name(name: &str) -> String { + format!("_vtable_{}", local_type_name(name)) +} + +/// True when `f` takes at least one callback-interface parameter, which the +/// Emscripten loader cannot support (see [`crate::calls`]). +pub(crate) fn takes_callback(f: &FnBinding) -> bool { + f.params + .iter() + .any(|p| matches!(p.ty, Ty::CallbackInterface(_))) +} + +/// True when `f` is unsupported in Emscripten mode and rendered as a throwing +/// stub there: an async function or one taking a callback interface. +pub(crate) fn emscripten_stub(f: &FnBinding) -> bool { + f.is_async || takes_callback(f) +} + /// Escape a string for embedding in a double-quoted JS literal. pub(crate) fn js_str_literal(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") diff --git a/crates/weaveffi-gen-wasm/tests/abi2.rs b/crates/weaveffi-gen-wasm/tests/abi2.rs new file mode 100644 index 00000000..8a018cc0 --- /dev/null +++ b/crates/weaveffi-gen-wasm/tests/abi2.rs @@ -0,0 +1,695 @@ +//! ABI revision 2 rendering checks: reference-counted object wrappers, +//! nullable objects, object tokens inside value buffers, iterators of objects, +//! callback-interface vtables with their trampolines, the Emscripten +//! capability ceiling, and the npm package layout. + +use camino::Utf8Path; +use weaveffi_core::backend::LanguageBackend; +use weaveffi_core::capabilities::TargetCapabilities; +use weaveffi_core::model::BindingModel; +use weaveffi_core::package::{FileContent, PackageContext, PackagedFile}; +use weaveffi_core::platform::{BinarySet, Platform}; +use weaveffi_core::validate::validate_api; +use weaveffi_gen_wasm::{WasmConfig, WasmGenerator}; +use weaveffi_ir::ir::{ + Api, CallbackInterfaceDef, EnumDef, EnumVariant, Function, InterfaceDef, Module, Param, + StructDef, StructField, TypeRef, CURRENT_SCHEMA_VERSION, +}; + +fn param(name: &str, ty: TypeRef) -> Param { + Param { + name: name.into(), + ty, + doc: None, + } +} + +fn func(name: &str, params: Vec, returns: Option) -> Function { + Function { + name: name.into(), + params, + returns, + doc: None, + throws: false, + r#async: false, + cancellable: false, + deprecated: None, + } +} + +fn field(name: &str, ty: TypeRef) -> StructField { + StructField { + name: name.into(), + ty, + doc: None, + } +} + +fn named(name: &str) -> TypeRef { + TypeRef::Named(name.into()) +} + +/// One module exercising every revision-2 shape: an interface with a +/// constructor and a method, `Interface?` in and out, a record with an +/// `Interface` field and a `[Interface]` field, an iterator over interface +/// elements, a callback interface with string, `i32`, record, and object +/// parameters (one `bool` return, one `void`), and a function taking it. +fn fixture() -> Api { + Api { + version: CURRENT_SCHEMA_VERSION.into(), + modules: vec![Module { + name: "bus".into(), + doc: None, + functions: vec![ + func( + "lookup", + vec![param( + "fallback", + TypeRef::Optional(Box::new(named("Ticker"))), + )], + Some(TypeRef::Optional(Box::new(named("Ticker")))), + ), + func( + "tickers", + vec![], + Some(TypeRef::Iterator(Box::new(named("Ticker")))), + ), + func( + "subscribe", + vec![param("listener", named("Subscriber"))], + None, + ), + func( + "describe", + vec![param("env", named("Envelope"))], + Some(named("Envelope")), + ), + Function { + r#async: true, + ..func( + "fetch", + vec![param("id", TypeRef::I64)], + Some(TypeRef::Optional(Box::new(named("Ticker")))), + ) + }, + Function { + r#async: true, + ..func( + "checksum", + vec![param("id", TypeRef::I64)], + Some(TypeRef::U64), + ) + }, + ], + interfaces: vec![InterfaceDef { + name: "Ticker".into(), + doc: Some("A counter.".into()), + deprecated: None, + constructors: vec![func("new", vec![param("start", TypeRef::I64)], None)], + methods: vec![ + func("value", vec![], Some(TypeRef::I64)), + func("mask", vec![], Some(TypeRef::U64)), + func("bits", vec![], Some(TypeRef::U32)), + ], + statics: vec![], + }], + callback_interfaces: vec![CallbackInterfaceDef { + name: "Subscriber".into(), + doc: Some("Receives bus events.".into()), + deprecated: None, + methods: vec![ + func( + "on_message", + vec![ + param("text", TypeRef::StringUtf8), + param("weight", TypeRef::I32), + param("envelope", named("Envelope")), + ], + Some(TypeRef::Bool), + ), + func( + "on_ticker", + vec![ + param("ticker", named("Ticker")), + param("alt", TypeRef::Optional(Box::new(named("Ticker")))), + ], + None, + ), + func( + "classify", + vec![param("weight", TypeRef::I32)], + Some(named("Priority")), + ), + func( + "on_mask", + vec![param("mask", TypeRef::U64), param("bits", TypeRef::U32)], + None, + ), + ], + }], + structs: vec![StructDef { + name: "Envelope".into(), + doc: None, + deprecated: None, + fields: vec![ + field("topic", TypeRef::StringUtf8), + field("primary", named("Ticker")), + field("others", TypeRef::List(Box::new(named("Ticker")))), + ], + }], + enums: vec![EnumDef { + name: "Priority".into(), + doc: None, + deprecated: None, + variants: vec![ + EnumVariant { + name: "Low".into(), + value: 0, + doc: None, + fields: vec![], + }, + EnumVariant { + name: "High".into(), + value: 1, + doc: None, + fields: vec![], + }, + ], + }], + errors: None, + modules: vec![], + }], + } +} + +fn render_with(config: &WasmConfig) -> (String, String) { + let api = validate_api(fixture(), None).expect("fixture validates"); + let model = BindingModel::build(&api, WasmGenerator.prefix(config)); + let files = WasmGenerator.files(&api, &model, Utf8Path::new("out"), config); + let pick = |suffix: &str| { + files + .iter() + .find(|f| f.path.as_str().ends_with(suffix)) + .unwrap_or_else(|| panic!("no {suffix} output")) + .contents + .clone() + }; + (pick("weaveffi_wasm.js"), pick("weaveffi_wasm.d.ts")) +} + +fn render() -> (String, String) { + render_with(&WasmConfig::default()) +} + +fn assert_has(src: &str, needle: &str) { + assert!(src.contains(needle), "missing `{needle}` in:\n{src}"); +} + +fn emscripten_config() -> WasmConfig { + WasmConfig { + emscripten: true, + ..WasmConfig::default() + } +} + +#[test] +fn objects_are_reference_counted_wrappers_with_finalizers() { + let (js, dts) = render(); + // The class lives inside the loader and is exposed on the module object. + assert_has(&js, " class Ticker {\n constructor(start) {"); + assert_has(&js, " Ticker: Ticker,"); + // The constructor adopts the returned reference with the destroy symbol. + assert_has( + &js, + "_r = wasm.weaveffi_bus_Ticker_new(BigInt(start), _err);", + ); + assert_has(&js, "_adopt(this, _r, wasm.weaveffi_bus_Ticker_destroy);"); + // Internal adoption path used by returns, tokens, iterators, and callbacks. + assert_has( + &js, + "static _wrap(handle) {\n return _adopt(Object.create(Ticker.prototype), handle, wasm.weaveffi_bus_Ticker_destroy);", + ); + // Clone symbol produces the second reference for object tokens. + assert_has( + &js, + "_clone() {\n return wasm.weaveffi_bus_Ticker_clone(_borrow(this));", + ); + // close() and Symbol.dispose both funnel into the once-only release. + assert_has(&js, "close() {\n _release(this);"); + assert_has(&js, "[_dispose]() {\n _release(this);"); + assert_has( + &js, + "const _dispose = typeof Symbol.dispose === 'symbol' ? Symbol.dispose : Symbol.for('Symbol.dispose');", + ); + assert_has( + &js, + "function _release(obj) {\n if (!obj._handle) return;\n if (_finalizer !== null) _finalizer.unregister(obj);\n obj._destroy(obj._handle);\n obj._handle = 0;\n}", + ); + // The FinalizationRegistry backstop holds [destroy, handle], never the wrapper. + assert_has( + &js, + "new FinalizationRegistry(([destroy, handle]) => destroy(handle))", + ); + assert_has( + &js, + "if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj);", + ); + // Methods lend the pointer as the implicit self. + assert_has( + &js, + "_r = wasm.weaveffi_bus_Ticker_value(_borrow(this), _err);", + ); + assert_has( + &js, + "throw new WeaveFFIError(-3, 'expected a live object wrapper');", + ); + + assert_has(&dts, "export declare class Ticker {"); + assert_has(&dts, " constructor(start: bigint);"); + assert_has(&dts, " value(): bigint;"); + assert_has(&dts, " close(): void;"); + assert_has(&dts, "Also reachable as `[Symbol.dispose]()` for `using`"); + assert_has(&dts, " Ticker: typeof Ticker;"); +} + +#[test] +fn nullable_objects_are_nullable_pointers_both_ways() { + let (js, dts) = render(); + assert_has( + &js, + "_r = wasm.weaveffi_bus_lookup((fallback === null || fallback === undefined ? 0 : _borrow(fallback)), _err);", + ); + assert_has(&js, "return _r === 0 ? null : Ticker._wrap(_r);"); + // Async results adopt the same way. + assert_has(&js, "unwrap: (w, h) => h === 0 ? null : Ticker._wrap(h)"); + assert_has(&dts, "lookup(fallback: Ticker | null): Ticker | null;"); + assert_has(&dts, "fetch(id: bigint): Promise;"); +} + +#[test] +fn objects_inside_records_are_cloned_tokens() { + let (js, dts) = render(); + // Encoding writes a fresh clone as a u64 token, never the wrapper's own pointer. + assert_has(&js, "w.obj(v.primary._clone());"); + assert_has(&js, "for (const _e1 of _a1) {\n w.obj(_e1._clone());"); + assert_has(&js, "obj(ptr) { this.u64(BigInt(ptr >>> 0)); }"); + // Decoding adopts each token into a new wrapper and rejects zero tokens. + assert_has(&js, "v.primary = Ticker._wrap(r.obj());"); + assert_has(&js, "_arr.push(Ticker._wrap(r.obj()));"); + assert_has( + &js, + "if (t === 0n || t > 0xffffffffn) this._bad('object token out of range');", + ); + assert_has( + &dts, + "export interface Envelope {\n topic: string;\n primary: Ticker;\n others: Ticker[];\n}", + ); +} + +#[test] +fn iterators_adopt_object_elements() { + let (js, dts) = render(); + assert_has(&js, "_it = wasm.weaveffi_bus_tickers(_err);"); + assert_has(&js, "return new _WeaveFFIIterator(wasm, _it, 4,"); + assert_has( + &js, + "(it, slot, ep) => wasm.weaveffi_bus_TickersIterator_next(it, slot, ep),", + ); + assert_has( + &js, + "(it) => wasm.weaveffi_bus_TickersIterator_destroy(it),", + ); + assert_has( + &js, + "(w, p) => Ticker._wrap(new DataView(w.memory.buffer).getUint32(p, true))", + ); + assert_has(&dts, "tickers(): IterableIterator;"); +} + +#[test] +fn callback_interfaces_render_ts_interface_vtable_and_trampolines() { + let (js, dts) = render(); + // Consumer-facing TS interface. + assert_has( + &dts, + "export interface Subscriber {\n onMessage(text: string, weight: number, envelope: Envelope): boolean;\n onTicker(ticker: Ticker, alt: Ticker | null): void;\n classify(weight: number): Priority;\n onMask(mask: bigint, bits: number): void;\n}", + ); + assert_has(&dts, "subscribe(listener: Subscriber): void;"); + + // The handle map keyed by the integer ctx. + assert_has(&js, "let _nextCbId = 1;\n const _callbacks = new Map();"); + + // One trampoline per method, whose wasm signature mirrors abi_params + // (ctx, params..., out_err) and abi_ret. + assert_has( + &js, + "const _cb_Subscriber_on_message = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32', 'i32', 'i32'], ['i32'], (_ctx, a0, a1, a2, a3, _err) => {", + ); + assert_has( + &js, + "const _cb_Subscriber_on_ticker = _registerTrampoline(_table, ['i32', 'i32', 'i32', 'i32'], [], (_ctx, a0, a1, _err) => {", + ); + assert_has( + &js, + "const _cb_Subscriber_classify = _registerTrampoline(_table, ['i32', 'i32', 'i32'], ['i32'], (_ctx, a0, _err) => {", + ); + // Borrowed args are decoded (string, i32, record), object args adopted. + assert_has(&js, "const _impl = _callbacks.get(_ctx);"); + assert_has(&js, "const _p0 = _readCStr(wasm, a0);"); + assert_has(&js, "const _p1 = a1;"); + assert_has( + &js, + "const _p2 = _read_bus_Envelope(_p2_r);\n _p2_r.end();", + ); + assert_has(&js, "return _impl.onMessage(_p0, _p1, _p2) ? 1 : 0;"); + assert_has( + &js, + "const _p0 = Ticker._wrap(a0);\n const _p1 = a1 === 0 ? null : Ticker._wrap(a1);\n if (_pendingForeign !== null) {", + ); + assert_has(&js, "return _impl.classify(_p0);"); + // Failure path: error_set with -4 and a default return. + assert_has( + &js, + "} catch (e) {\n _setForeignError(wasm, _err, e);\n return 0;\n }", + ); + assert_has( + &js, + "} catch (e) {\n _setForeignError(wasm, _err, e);\n }", + ); + assert_has( + &js, + "function _reportForeign(wasm, errPtr, msg) {\n if (_pendingForeign === null) _pendingForeign = msg;\n const [p, s] = _cstr(wasm, msg);\n wasm.weaveffi_error_set(errPtr, -4, p);\n wasm.weaveffi_dealloc(p, s);\n}", + ); + assert_has( + &js, + "function _setForeignError(wasm, errPtr, e) {\n _reportForeign(wasm, errPtr, e instanceof Error ? (e.message || e.name) : String(e));\n}", + ); + // Without unwinding the producer keeps calling back after a failure; + // those invocations are refused after their arguments are adopted. + assert_has( + &js, + "if (_pendingForeign !== null) {\n _reportForeign(wasm, _err, _pendingForeign);\n return 0;\n }\n return _impl.classify(_p0);", + ); + assert_has( + &js, + "if (_pendingForeign !== null) {\n _reportForeign(wasm, _err, _pendingForeign);\n return;\n }\n _impl.onTicker(_p0, _p1);", + ); + // Every producer-call completion path clears the parked failure. + assert_has( + &js, + "function _freeErr(wasm, errPtr) {\n _pendingForeign = null;", + ); + assert_has( + &js, + "function _checkErr(wasm, errPtr) {\n _pendingForeign = null;", + ); + assert_has( + &js, + "function _checkErrRef(wasm, errPtr, mkErr) {\n _pendingForeign = null;", + ); + // free deletes the map entry. + assert_has( + &js, + "const _cb_Subscriber_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); });", + ); + + // Exactly one static vtable, allocated once with the module allocator and + // filled with table indices in declaration order, then free. + assert_eq!( + js.matches("const _vtable_Subscriber = wasm.weaveffi_alloc(20);") + .count(), + 1 + ); + assert_has( + &js, + "_dv.setUint32(_vtable_Subscriber + 0, _cb_Subscriber_on_message, true);\n _dv.setUint32(_vtable_Subscriber + 4, _cb_Subscriber_on_ticker, true);\n _dv.setUint32(_vtable_Subscriber + 8, _cb_Subscriber_classify, true);\n _dv.setUint32(_vtable_Subscriber + 12, _cb_Subscriber_on_mask, true);\n _dv.setUint32(_vtable_Subscriber + 16, _cb_Subscriber_free, true);", + ); + + // Passing an implementation: map key as ctx plus the vtable address. + assert_has( + &js, + "subscribe(listener) {\n const a0_ctx = _nextCbId++;\n _callbacks.set(a0_ctx, listener);", + ); + assert_has( + &js, + "wasm.weaveffi_bus_subscribe(a0_ctx, _vtable_Subscriber, _err);", + ); +} + +/// `wasm32-unknown-unknown` can't unwind, so a producer panic reaches JS as a +/// `WebAssembly.RuntimeError` trap. Every producer call is guarded: the trap +/// is translated into the brand error (`-4` if a callback failure was parked +/// during the call, `-2` otherwise), the error slot is released, staged inputs +/// are freed in a `finally`, and async launchers reject their Promise and +/// forget the context. +#[test] +fn traps_are_translated_into_brand_errors() { + let (js, _) = render(); + assert_has(&js, "let _pendingForeign = null;"); + assert_has( + &js, + "function _trapError(e) {\n const foreign = _pendingForeign;\n _pendingForeign = null;\n if (!(e instanceof WebAssembly.RuntimeError)) return e;\n if (foreign !== null) return new WeaveFFIError(-4, foreign);\n return new WeaveFFIError(-2, 'producer panicked: ' + e.message);\n}", + ); + assert_has( + &js, + "function _trap(wasm, errPtr, e) {\n _freeErr(wasm, errPtr);\n return _trapError(e);\n}", + ); + // Sync wrapper: guarded call, then the checker on the untouched slot. + assert_has( + &js, + "const _err = _allocErr(wasm);\n let _r;\n try {\n _r = wasm.weaveffi_bus_lookup((fallback === null || fallback === undefined ? 0 : _borrow(fallback)), _err);\n } catch (e) {\n throw _trap(wasm, _err, e);\n }\n _checkErr(wasm, _err);", + ); + // A buffered return releases its out-length slot on the trap path and + // frees the staged argument in `finally`. + assert_has( + &js, + "const _lp = wasm.weaveffi_alloc(4);\n const _err = _allocErr(wasm);\n let _r;\n try {\n _r = wasm.weaveffi_bus_describe(a0_p, a0_l, _lp, _err);\n } catch (e) {\n wasm.weaveffi_dealloc(_lp, 4);\n throw _trap(wasm, _err, e);\n } finally {\n wasm.weaveffi_dealloc(a0_p, a0_l);\n }", + ); + // Constructor and iterator launch are guarded the same way. + assert_has( + &js, + "let _r;\n try {\n _r = wasm.weaveffi_bus_Ticker_new(BigInt(start), _err);\n } catch (e) {\n throw _trap(wasm, _err, e);\n }", + ); + assert_has( + &js, + "let _it;\n try {\n _it = wasm.weaveffi_bus_tickers(_err);\n } catch (e) {\n throw _trap(wasm, _err, e);\n }", + ); + // Iterator steps release the slot on a trap before rethrowing. + assert_has( + &js, + "_has = this._callNext(this._handle, this._slot, _err);\n } catch (e) {\n // A trap: the slot was never filled, so release it here.\n this._close();\n throw _trap(wasm, _err, e);\n }", + ); + // Async launch: forget the context and reject with the translated error. + assert_has( + &js, + "try {\n wasm.weaveffi_bus_fetch_async(BigInt(id), _cbPtr_i32_i32_i32, ctxId);\n } catch (e) {\n _asyncContexts.delete(ctxId);\n reject(_trapError(e));\n }", + ); +} + +/// Wasm integers are signed on the JS side, so unsigned scalars arriving +/// through a direct slot (sync returns, async completions, callback +/// parameters) are reinterpreted: `u32` via `>>> 0`, `u64` via +/// `BigInt.asUintN(64, ...)`. Signed and 32-bit-or-narrower values need no +/// coercion, and inputs rely on the wasm calling convention's own wrapping. +#[test] +fn unsigned_scalars_are_reinterpreted_on_the_way_out() { + let (js, _) = render(); + assert_has( + &js, + "_r = wasm.weaveffi_bus_Ticker_mask(_borrow(this), _err);", + ); + assert_has(&js, "return BigInt.asUintN(64, _r);"); + assert_has( + &js, + "_r = wasm.weaveffi_bus_Ticker_bits(_borrow(this), _err);", + ); + assert_has(&js, "return _r >>> 0;"); + // Signed 64-bit returns stay as the BigInt wasm hands back. + assert_has( + &js, + "_r = wasm.weaveffi_bus_Ticker_value(_borrow(this), _err);\n } catch (e) {\n throw _trap(wasm, _err, e);\n }\n _checkErr(wasm, _err);\n _freeErr(wasm, _err);\n return _r;", + ); + // Async completion of a u64 result. + assert_has( + &js, + "_asyncContexts.set(ctxId, { resolve, reject, unwrap: (w, r) => BigInt.asUintN(64, r) });", + ); + assert_has( + &js, + "wasm.weaveffi_bus_checksum_async(BigInt(id), _cbPtr_i32_i32_i64, ctxId);", + ); + // Callback parameters. + assert_has( + &js, + "const _p0 = BigInt.asUintN(64, a0);\n const _p1 = a1 >>> 0;\n if (_pendingForeign !== null) {", + ); +} + +#[test] +fn runtime_checks_abi_revision_and_drops_legacy_surfaces() { + let (js, dts) = render(); + assert_has(&js, "const _ABI_VERSION = 2;"); + assert_has(&js, "_checkAbiVersion(wasm);"); + assert_has(&js, "wasm.weaveffi_error_set("); + for gone in [ + "arena", + "weaveffi_handle_t", + "TypedHandle", + "register_", + "_listeners", + "free()", + ".destroy()", + ] { + assert!(!js.contains(gone), "stale `{gone}` in loader"); + assert!(!dts.contains(gone), "stale `{gone}` in declarations"); + } +} + +#[test] +fn emscripten_mode_is_partial_and_stubs_unsupported_entry_points() { + let config = emscripten_config(); + assert_eq!( + WasmGenerator.capabilities(&config), + TargetCapabilities { + async_functions: false, + callback_interfaces: false, + iterators: true, + } + ); + assert_eq!( + WasmGenerator.capabilities(&WasmConfig::default()), + TargetCapabilities::full() + ); + assert!(!WasmGenerator.allows_unsupported(&config)); + assert!(WasmGenerator.allows_unsupported(&WasmConfig { + allow_unsupported: true, + ..emscripten_config() + })); + + let (js, dts) = render_with(&config); + assert_has( + &js, + "subscribe(listener) {\n throw new Error(\"weaveffi: function 'subscribe' (it takes a callback interface) is not supported in Emscripten mode;", + ); + assert_has( + &js, + "fetch(id) {\n throw new Error(\"weaveffi: async function 'fetch' is not supported in Emscripten mode;", + ); + // No trampoline machinery, but objects and iterators still work. + assert!( + !js.contains("_registerTrampoline"), + "trampolines in Emscripten glue" + ); + assert!(!js.contains("_vtable_"), "vtable in Emscripten glue"); + assert_has( + &js, + "get memory() { return { buffer: m['HEAPU8'].buffer }; },", + ); + assert_has( + &js, + "weaveffi_bus_Ticker_clone: m['_weaveffi_bus_Ticker_clone'],", + ); + assert_has( + &js, + "weaveffi_bus_Ticker_destroy: m['_weaveffi_bus_Ticker_destroy'],", + ); + assert_has( + &js, + "weaveffi_bus_TickersIterator_next: m['_weaveffi_bus_TickersIterator_next'],", + ); + assert!( + !js.contains("m['_weaveffi_bus_subscribe']"), + "stubbed symbol bound" + ); + // The declarations omit the stubs and the callback interface entirely. + assert!(!dts.contains("subscribe("), "stubbed function declared"); + assert!(!dts.contains("fetch("), "stubbed async function declared"); + assert!( + !dts.contains("interface Subscriber"), + "callback interface declared" + ); + assert_has(&dts, "lookup(fallback: Ticker | null): Ticker | null;"); + assert_has(&dts, "tickers(): IterableIterator;"); +} + +fn packaged_text<'a>(files: &'a [PackagedFile], suffix: &str) -> &'a str { + files + .iter() + .find(|f| f.path.as_str().ends_with(suffix)) + .and_then(|f| match &f.content { + FileContent::Text(s) => Some(s.as_str()), + FileContent::Copy(_) => None, + }) + .unwrap_or_else(|| panic!("no packaged {suffix}")) +} + +fn package_with(config: &WasmConfig) -> Vec { + let api = validate_api(fixture(), None).expect("fixture validates"); + let model = BindingModel::build(&api, WasmGenerator.prefix(config)); + let mut binaries = BinarySet::new("bus"); + for p in Platform::ALL { + binaries.insert(p, format!("/prebuilt/{}/lib", p.id())); + } + let ctx = PackageContext { + binaries: &binaries, + input_basename: Some("bus.yml"), + }; + WasmGenerator + .package(&api, &model, &ctx, Utf8Path::new("out"), config) + .expect("wasm packages") +} + +#[test] +fn package_bundles_only_the_wasm32_binary() { + let files = package_with(&WasmConfig::default()); + let bundled: Vec<&PackagedFile> = files.iter().filter(|f| f.is_binary()).collect(); + assert_eq!(bundled.len(), 1, "{bundled:?}"); + assert_eq!(bundled[0].path.as_str(), "out/wasm/bus.wasm"); + assert_eq!( + bundled[0].content, + FileContent::Copy("/prebuilt/wasm32/lib".into()) + ); + let mut paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + paths.sort_unstable(); + assert_eq!( + paths, + [ + "out/wasm/README.md", + "out/wasm/bus.wasm", + "out/wasm/package.json", + "out/wasm/weaveffi_wasm.d.ts", + "out/wasm/weaveffi_wasm.js", + ] + ); + let manifest = packaged_text(&files, "package.json"); + assert_has(manifest, "\"main\": \"weaveffi_wasm.js\""); + assert_has(manifest, "\"types\": \"weaveffi_wasm.d.ts\""); + assert_has( + manifest, + "\"weaveffi_wasm.js\",\n \"weaveffi_wasm.d.ts\",\n \"bus.wasm\"", + ); + let readme = packaged_text(&files, "README.md"); + assert_has(readme, "bundled as `bus.wasm`"); + assert_has(readme, "new URL('./bus.wasm', import.meta.url)"); + // The loader and declarations are the same as `generate` emits. + let (js, dts) = render(); + assert_eq!(packaged_text(&files, "weaveffi_wasm.js"), js); + assert_eq!(packaged_text(&files, "weaveffi_wasm.d.ts"), dts); +} + +#[test] +fn emscripten_package_ships_without_a_binary() { + let files = package_with(&emscripten_config()); + assert!( + files.iter().all(|f| !f.is_binary()), + "binary bundled in Emscripten package" + ); + let manifest = packaged_text(&files, "package.json"); + assert!( + !manifest.contains("\"files\""), + "files list without a binary" + ); + let readme = packaged_text(&files, "README.md"); + assert_has(readme, "The package carries no binary"); + assert_has(readme, "const api = await loadWeaveffiWasm(Module());"); +} diff --git a/crates/weaveffi-ir/src/ir.rs b/crates/weaveffi-ir/src/ir.rs index 2c55b29c..8bd4131c 100644 --- a/crates/weaveffi-ir/src/ir.rs +++ b/crates/weaveffi-ir/src/ir.rs @@ -2,18 +2,19 @@ //! document becomes. //! //! This is the *document* model: [`Api`] is the root and owns a forest of -//! [`Module`]s, each grouping [`Function`]s, [`InterfaceDef`]s, [`StructDef`]s, -//! [`EnumDef`]s, [`CallbackDef`]s, [`ListenerDef`]s, and an optional +//! [`Module`]s, each grouping [`Function`]s, [`InterfaceDef`]s, +//! [`CallbackInterfaceDef`]s, [`StructDef`]s, [`EnumDef`]s, and an optional //! [`ErrorDomain`]. Types are referenced throughout by [`TypeRef`], which //! (de)serializes as a compact string (`i32`, `[string]`, `{string:i32}`, //! `Contact?`, and so on) rather than as a tagged object. //! //! The document model is deliberately *unresolved*: every user-defined type //! reference is a [`TypeRef::Named`] carrying the name exactly as written. -//! Whether that name is a record, an enum, or an interface is decided by -//! `weaveffi-core`'s validator, which lowers the document into the resolved -//! binding model generators consume. Keeping the two representations distinct -//! means an IDL document always round-trips losslessly through this crate. +//! Whether that name is a record, an enum, an interface, or a callback +//! interface is decided by `weaveffi-core`'s validator, which lowers the +//! document into the resolved binding model generators consume. Keeping the +//! two representations distinct means an IDL document always round-trips +//! losslessly through this crate. //! //! Package identity and per-generator options are not part of the IDL: they //! describe how bindings are *shipped*, not what the API *is*, and live in the @@ -33,7 +34,7 @@ use serde::{Deserialize, Serialize}; /// /// See [`docs/src/stability.md`](https://github.com/weavefoundry/weaveffi/blob/main/docs/src/stability.md) /// for the full schema policy and the surfaces covered by SemVer. -pub const CURRENT_SCHEMA_VERSION: &str = "0.8.0"; +pub const CURRENT_SCHEMA_VERSION: &str = "0.9.0"; /// Every IR schema version the current tools accept. /// @@ -44,7 +45,7 @@ pub const SUPPORTED_VERSIONS: &[&str] = &[CURRENT_SCHEMA_VERSION]; /// `skip_serializing_if` predicate for `bool` fields that default to `false`. /// Keeps the canonical IDL emitted by `weaveffi extract` minimal by omitting -/// flags the user never set (e.g. `async: false`, `mutable: false`). +/// flags the user never set (e.g. `async: false`, `throws: false`). #[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool { !*b @@ -59,7 +60,7 @@ fn is_false(b: &bool) -> bool { #[serde(deny_unknown_fields)] #[schemars(description = "Top-level WeaveFFI API definition.")] pub struct Api { - /// IR schema version this document targets (for example `0.8.0`). + /// IR schema version this document targets (for example `0.9.0`). /// Validation rejects any value not listed in [`SUPPORTED_VERSIONS`]. pub version: String, /// Top-level modules that make up the API surface. Each is an independent @@ -67,16 +68,17 @@ pub struct Api { pub modules: Vec, } -/// A module: a named namespace grouping related functions, types, callbacks, -/// listeners, and an error domain. +/// A module: a named namespace grouping related functions, types, callback +/// interfaces, and an error domain. /// /// Modules are the IDL's unit of organization and map onto each target /// language's natural grouping construct (a namespace, a submodule, a symbol /// prefix, and so on). They may nest through [`modules`](Self::modules) to /// mirror a package hierarchy. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[schemars( - description = "A WeaveFFI module: a named group of functions, types, callbacks, listeners, and errors." + description = "A WeaveFFI module: a named group of functions, types, callback interfaces, and errors." )] pub struct Module { /// Module name, used as a namespace segment and a symbol-prefix component @@ -94,18 +96,16 @@ pub struct Module { /// with constructors, methods, and static functions. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub interfaces: Vec, + /// Callback interfaces declared in this module: method sets the consumer + /// implements and the producer calls. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub callback_interfaces: Vec, /// Record (struct) types declared in this module. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub structs: Vec, /// Enum types, C-style or algebraic, declared in this module. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enums: Vec, - /// Callback signatures this module's functions and listeners can invoke. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub callbacks: Vec, - /// Event listeners (subscribe and unsubscribe endpoints) this module exposes. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listeners: Vec, /// Optional error domain: the named codes this module's fallible functions /// report. `None` when the module declares no errors. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -122,8 +122,10 @@ pub struct Module { /// is lowered (a completion callback, an extra cancel-token parameter) without /// altering the parameter and return shape declared here. The same shape also /// describes an interface's constructors, methods, and statics (see -/// [`InterfaceDef`]). +/// [`InterfaceDef`]) and a callback interface's methods (see +/// [`CallbackInterfaceDef`]). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Function { /// Function name, lowered to a per-language symbol (for example /// `create_contact`). @@ -158,10 +160,6 @@ pub struct Function { /// carrying this message. `None` means the function is current. #[serde(default, skip_serializing_if = "Option::is_none")] pub deprecated: Option, - /// Version in which the function was introduced (for example `0.2.0`), - /// surfaced as a "since" annotation where the target language supports one. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub since: Option, } /// An interface: an opaque, stateful object type with constructors, instance @@ -173,11 +171,15 @@ pub struct Function { /// resources with identity and behavior (stores, sessions, connections), in /// contrast to a [`StructDef`], which models a plain data record with fields. /// -/// Every interface also receives an implicit destructor symbol -/// (`{tag}_destroy`); generated wrappers release the underlying object through -/// their language's natural disposal hook (`Drop`, `__del__`, `Disposable`, -/// finalizers, `close()`). +/// Objects are reference counted by the producer. Every interface receives +/// implicit `{tag}_clone` and `{tag}_destroy` symbols; generated wrappers hold +/// one strong reference each and release it through their language's natural +/// disposal hook (`Drop`, `__del__`, `Disposable`, finalizers, `close()`). +/// Because the count lives in the producer, an interface value may appear +/// anywhere a type can: as a parameter, a return, an iterator element, a record +/// field, a list element, or a map value. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[schemars( description = "An interface: an opaque object type with constructors, methods, and statics." )] @@ -205,78 +207,68 @@ pub struct InterfaceDef { pub statics: Vec, } -/// A single parameter of a [`Function`] or [`CallbackDef`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct Param { - /// Parameter name as it appears in generated signatures (for example `id`). - pub name: String, - /// Parameter type. Serialized under the IDL key `type`. - #[serde(rename = "type")] - pub ty: TypeRef, - /// Whether the callee may write back through this parameter (for example a - /// buffer filled in place). Defaults to `false`. - #[serde(default, skip_serializing_if = "is_false")] - pub mutable: bool, - /// Human-readable documentation for the parameter, propagated to the - /// generated bindings. `None` when undocumented. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub doc: Option, -} - -/// A callback signature: a function shape the host implements and native code -/// invokes. +/// A callback interface: a set of methods the **consumer** implements and the +/// **producer** invokes. +/// +/// A callback interface is the inverse of an [`InterfaceDef`]. A consumer +/// passes an implementation (a class instance, a closure record, a trait +/// object) wherever the type appears as a parameter; the producer keeps it +/// alive as long as it needs to and calls its methods, possibly from any +/// thread. At the C ABI it lowers to a context pointer plus a vtable of +/// function pointers; see the C ABI contract. /// -/// Callbacks are declared at module scope rather than as a [`TypeRef`] so the C -/// ABI can represent them uniformly as a function pointer plus a context -/// pointer. A [`ListenerDef`] references one by name to model an event stream. +/// Methods are synchronous, can't declare `throws`, `async`, or `cancellable`, +/// and return either nothing or a value in the direct family (integers, +/// floats, `bool`, or a C-style enum). Parameters may use any type other than +/// another callback interface or an iterator. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct CallbackDef { - /// Callback name, used to name the generated function-pointer type and - /// referenced by [`ListenerDef::event_callback`] (for example `on_message`). +#[serde(deny_unknown_fields)] +#[schemars( + description = "A callback interface: a set of methods the consumer implements and the producer calls." +)] +pub struct CallbackInterfaceDef { + /// Callback interface type name (for example `MessageListener`). pub name: String, - /// Parameters passed to the callback each time it fires. - pub params: Vec, /// Human-readable documentation, propagated to the generated bindings. /// `None` when undocumented. #[serde(default, skip_serializing_if = "Option::is_none")] pub doc: Option, + /// Deprecation notice for the whole type; when set, generators annotate + /// the emitted type. `None` means the callback interface is current. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + /// Methods the consumer implements, in vtable order. Must be non-empty. + pub methods: Vec, } -/// An event listener: a subscribe and unsubscribe endpoint that delivers events -/// through a [`CallbackDef`]. -/// -/// Generators expand a listener into register and unregister functions; the -/// register call takes the named callback and returns a subscription id the -/// caller later hands to unregister. +/// A single parameter of a [`Function`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct ListenerDef { - /// Listener name, lowered into the generated `register_*` and - /// `unregister_*` function names (for example `messages`). +#[serde(deny_unknown_fields)] +pub struct Param { + /// Parameter name as it appears in generated signatures (for example `id`). pub name: String, - /// Name of the [`CallbackDef`] invoked for each event. Must match a callback - /// declared on the same [`Module`]. - pub event_callback: String, - /// Human-readable documentation, propagated to the generated bindings. - /// `None` when undocumented. + /// Parameter type. Serialized under the IDL key `type`. + #[serde(rename = "type")] + pub ty: TypeRef, + /// Human-readable documentation for the parameter, propagated to the + /// generated bindings. `None` when undocumented. #[serde(default, skip_serializing_if = "Option::is_none")] pub doc: Option, } /// A reference to a type in the IDL, exactly as written. /// -/// Every user-defined type (record, enum, or interface) is a -/// [`Named`](Self::Named) reference at this level; the validator resolves the -/// name against the declarations in scope and lowers it into the resolved -/// kind generators consume. Keeping the document model unresolved is what -/// makes an IDL round-trip losslessly. +/// Every user-defined type (record, enum, interface, or callback interface) is +/// a [`Named`](Self::Named) reference at this level; the validator resolves the +/// name against the declarations in scope and lowers it into the resolved kind +/// generators consume. Keeping the document model unresolved is what makes an +/// IDL round-trip losslessly. /// -/// Callback-style behavior is **not** expressed as a `TypeRef` variant. -/// Instead, callbacks and listeners are declared at the module level via -/// `Module.callbacks` (see [`CallbackDef`]) and `Module.listeners` (see -/// [`ListenerDef`]), and asynchronous functions use `async: true`. These -/// primitives cover every pattern the FFI boundary needs to support, and -/// keep the type system free of function-typed values that the C ABI -/// cannot represent uniformly. +/// There is no function-typed `TypeRef`. Consumer-implemented behavior is +/// expressed as a [`CallbackInterfaceDef`] referenced by name, and +/// asynchronous producer work uses `async: true`. Together these cover every +/// pattern the FFI boundary needs and keep the type system free of values the +/// C ABI can't represent uniformly. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TypeRef { /// Signed 8-bit integer (`i8`). @@ -301,27 +293,15 @@ pub enum TypeRef { F64, /// Boolean (`bool`). Bool, - /// Owned UTF-8 string (`string`). + /// UTF-8 string (`string`). Borrowed when passed in, owned when returned. StringUtf8, - /// Owned byte buffer (`bytes`). + /// Byte buffer (`bytes`). Borrowed when passed in, owned when returned. Bytes, - /// Opaque, untyped resource handle (`handle`). See - /// [`TypedHandle`](Self::TypedHandle) for the form tagged with a referent - /// name. - Handle, - /// Opaque resource handle tagged with the name of what it refers to - /// (`handle`), giving generators a distinct type per resource kind. - TypedHandle(String), - /// A reference to a user-defined type (record, enum, or interface) by its - /// bare or dot-qualified name (`Contact`, `shared.Status`), exactly as - /// parsed. Resolution happens in the validator, not here. + /// A reference to a user-defined type (record, enum, interface, or callback + /// interface) by its bare or dot-qualified name (`Contact`, + /// `shared.Status`), exactly as parsed. Resolution happens in the + /// validator, not here. Named(String), - /// Borrowed string slice (`&str`): a non-owning view valid only for the - /// duration of a call, used to pass input without copying. - BorrowedStr, - /// Borrowed byte slice (`&[u8]`): a non-owning view valid only for the - /// duration of a call. - BorrowedBytes, /// Optional value (`T?`): either the inner type or nothing. Optional(Box), /// Homogeneous list (`[T]`) of the inner element type. @@ -335,17 +315,18 @@ pub enum TypeRef { /// Parse the IDL's compact type syntax into a [`TypeRef`]. /// -/// Handles primitive names (`i32`, `string`, `bytes`, `handle`, and so on), -/// borrowed forms (`&str`, `&[u8]`), typed handles (`handle`), iterators +/// Handles primitive names (`i32`, `string`, `bytes`, and so on), iterators /// (`iter`), lists (`[T]`), maps (`{K:V}`), and the optional suffix (`T?`). -/// Any other bare identifier is taken to be a user-defined record, enum, or -/// interface name and returned as [`TypeRef::Named`]. +/// Any other bare identifier is taken to be a user-defined record, enum, +/// interface, or callback interface name and returned as [`TypeRef::Named`]. /// /// # Errors /// -/// Returns an error message when `s` is empty or only whitespace, or when a map -/// type (`{K:V}`) is missing its `:` separator. The same errors propagate up -/// from a malformed inner type of a list, map, optional, or iterator. +/// Returns an error message when `s` is empty or only whitespace, when a map +/// type (`{K:V}`) is missing its `:` separator, or when `s` uses one of the +/// spellings removed in schema 0.9 (`handle`, `handle`, `&str`, `&[u8]`). +/// The same errors propagate up from a malformed inner type of a list, map, +/// optional, or iterator. pub fn parse_type_ref(s: &str) -> Result { let s = s.trim(); if s.is_empty() { @@ -374,18 +355,23 @@ pub fn parse_type_ref(s: &str) -> Result { if let Some(inner) = s.strip_suffix('?') { return parse_type_ref(inner).map(|t| TypeRef::Optional(Box::new(t))); } - if let Some(inner) = s - .strip_prefix("handle<") - .and_then(|rest| rest.strip_suffix('>')) - { - return Ok(TypeRef::TypedHandle(inner.into())); - } if let Some(inner) = s .strip_prefix("iter<") .and_then(|rest| rest.strip_suffix('>')) { return parse_type_ref(inner).map(|t| TypeRef::Iterator(Box::new(t))); } + if s == "handle" || s.starts_with("handle<") { + return Err( + "`handle` types were removed in schema 0.9; declare an interface and reference it by name" + .to_string(), + ); + } + if s == "&str" || s == "&[u8]" { + return Err(format!( + "`{s}` was removed in schema 0.9; `string` and `bytes` parameters are always borrowed at the ABI" + )); + } match s { "i8" => Ok(TypeRef::I8), "i16" => Ok(TypeRef::I16), @@ -400,9 +386,6 @@ pub fn parse_type_ref(s: &str) -> Result { "bool" => Ok(TypeRef::Bool), "string" => Ok(TypeRef::StringUtf8), "bytes" => Ok(TypeRef::Bytes), - "handle" => Ok(TypeRef::Handle), - "&str" => Ok(TypeRef::BorrowedStr), - "&[u8]" => Ok(TypeRef::BorrowedBytes), name => Ok(TypeRef::Named(name.to_string())), } } @@ -423,15 +406,30 @@ fn map_separator(inner: &str) -> Option { } impl TypeRef { - /// The referenced user-type name for a [`Named`](Self::Named) or - /// [`TypedHandle`](Self::TypedHandle) reference, or `None` for every - /// other type. + /// The referenced user-type name for a [`Named`](Self::Named) reference, + /// or `None` for every other type. pub fn user_name(&self) -> Option<&str> { match self { - TypeRef::Named(n) | TypeRef::TypedHandle(n) => Some(n), + TypeRef::Named(n) => Some(n), _ => None, } } + + /// Walk this type and every type nested inside it (optional payloads, list + /// elements, map keys and values, iterator items), outermost first. + pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a TypeRef)) { + f(self); + match self { + TypeRef::Optional(inner) | TypeRef::List(inner) | TypeRef::Iterator(inner) => { + inner.walk(f); + } + TypeRef::Map(k, v) => { + k.walk(f); + v.walk(f); + } + _ => {} + } + } } fn type_ref_to_string(ty: &TypeRef) -> String { @@ -449,10 +447,6 @@ fn type_ref_to_string(ty: &TypeRef) -> String { TypeRef::Bool => "bool".to_string(), TypeRef::StringUtf8 => "string".to_string(), TypeRef::Bytes => "bytes".to_string(), - TypeRef::BorrowedStr => "&str".to_string(), - TypeRef::BorrowedBytes => "&[u8]".to_string(), - TypeRef::Handle => "handle".to_string(), - TypeRef::TypedHandle(name) => format!("handle<{name}>"), TypeRef::Named(name) => name.clone(), TypeRef::Optional(inner) => format!("{}?", type_ref_to_string(inner)), TypeRef::List(inner) => format!("[{}]", type_ref_to_string(inner)), @@ -487,9 +481,8 @@ impl<'de> Deserialize<'de> for TypeRef { } /// Manual `JsonSchema` impl because `TypeRef` (de)serializes as a string with -/// custom syntax: primitive names (`i32`, `string`, ...), `&str`, `&[u8]`, -/// `handle<{name}>`, `iter<{T}>`, `[{T}]`, `{ {K}: {V} }`, `{name}?`, or any -/// user-defined struct/enum/interface name. +/// custom syntax: primitive names (`i32`, `string`, ...), `iter<{T}>`, +/// `[{T}]`, `{ {K}: {V} }`, `{name}?`, or any user-defined type name. impl JsonSchema for TypeRef { fn schema_name() -> String { "TypeRef".to_string() @@ -509,10 +502,9 @@ impl JsonSchema for TypeRef { meta.description = Some( "Reference to a type. Encoded as a string with custom syntax: \ primitives (`i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `u64`, \ - `f32`, `f64`, `bool`, `string`, `bytes`, `handle`), \ - borrowed types (`&str`, `&[u8]`), typed handles (`handle<{name}>`), \ - iterators (`iter<{T}>`), lists (`[{T}]`), maps (`{{K:V}}`), \ - optionals (`{T}?`), or any user-defined struct/enum/interface name." + `f32`, `f64`, `bool`, `string`, `bytes`), iterators (`iter<{T}>`), \ + lists (`[{T}]`), maps (`{{K:V}}`), optionals (`{T}?`), or any \ + user-defined struct, enum, interface, or callback interface name." .to_string(), ); schema.into() @@ -527,6 +519,7 @@ impl JsonSchema for TypeRef { /// algebraic enum is a value type that crosses the ABI serialized in a value /// buffer (an `i32` tag followed by the active variant's fields). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[schemars( description = "An enum type. C-style when every variant is a bare discriminant; an algebraic sum type when any variant declares fields." )] @@ -558,6 +551,7 @@ impl EnumDef { /// A single variant of an [`EnumDef`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EnumVariant { /// Variant name (for example `Red`). pub name: String, @@ -577,6 +571,7 @@ pub struct EnumVariant { /// A struct (record) type with named fields. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[schemars(description = "A struct (record) type with named fields.")] pub struct StructDef { /// Struct type name (for example `Contact`). @@ -597,6 +592,7 @@ pub struct StructDef { /// A named field of a [`StructDef`], or the payload of an algebraic /// [`EnumVariant`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct StructField { /// Field name (for example `email`). pub name: String, @@ -612,6 +608,7 @@ pub struct StructField { /// A module's error domain: the named set of error codes its fallible functions /// can report. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ErrorDomain { /// Error domain name, used to name the generated error type (for example /// `ContactErrors`). @@ -622,11 +619,13 @@ pub struct ErrorDomain { /// A single named error within an [`ErrorDomain`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ErrorCode { /// Error code name, lowered to a variant or constant on the generated error /// type (for example `not_found`). pub name: String, /// Stable numeric value carried across the C ABI to identify this error. + /// Must be positive; non-positive codes are reserved for the runtime. pub code: i32, /// Default human-readable message describing the error. pub message: String, @@ -667,9 +666,6 @@ mod tests { ("bool", TypeRef::Bool), ("string", TypeRef::StringUtf8), ("bytes", TypeRef::Bytes), - ("handle", TypeRef::Handle), - ("&str", TypeRef::BorrowedStr), - ("&[u8]", TypeRef::BorrowedBytes), ]; for (s, expected) in cases { assert_eq!(&rt(s), expected); @@ -680,10 +676,6 @@ mod tests { fn composites_round_trip() { assert_eq!(rt("Contact"), TypeRef::Named("Contact".into())); assert_eq!(rt("shared.Status"), TypeRef::Named("shared.Status".into())); - assert_eq!( - rt("handle"), - TypeRef::TypedHandle("Session".into()) - ); assert_eq!( rt("Contact?"), TypeRef::Optional(Box::new(TypeRef::Named("Contact".into()))) @@ -740,10 +732,35 @@ mod tests { assert!(parse_type_ref("?").is_err()); } + #[test] + fn removed_spellings_are_errors_with_guidance() { + for removed in ["handle", "handle", "&str", "&[u8]", "[handle]"] { + let err = parse_type_ref(removed).unwrap_err(); + assert!(err.contains("0.9"), "{removed}: {err}"); + } + } + + #[test] + fn walk_visits_nested_types_outermost_first() { + let ty = parse_type_ref("{string:[Contact?]}").unwrap(); + let mut seen = Vec::new(); + ty.walk(&mut |t| seen.push(t.to_string())); + assert_eq!( + seen, + [ + "{string:[Contact?]}", + "string", + "[Contact?]", + "Contact?", + "Contact" + ] + ); + } + #[test] fn document_round_trips_through_yaml_and_json() { let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: contacts doc: Address book @@ -753,6 +770,7 @@ modules: fields: - { name: name, type: string } - { name: tags, type: "[string]" } + - { name: book, type: "Book?" } enums: - name: Shape variants: @@ -761,13 +779,15 @@ modules: interfaces: - name: Book constructors: - - { name: open, params: [{ name: path, type: "&str" }], throws: true } + - { name: open, params: [{ name: path, type: string }], throws: true } methods: - { name: find, params: [{ name: q, type: string }], return: "Contact?" } - callbacks: - - { name: on_change, params: [{ name: id, type: i64 }] } - listeners: - - { name: changes, event_callback: on_change } + - { name: watch, params: [{ name: listener, type: ChangeListener }] } + callback_interfaces: + - name: ChangeListener + methods: + - { name: on_change, params: [{ name: id, type: i64 }] } + - { name: should_continue, params: [], return: bool } errors: name: BookError codes: @@ -779,7 +799,6 @@ modules: async: true cancellable: true deprecated: gone - since: "0.2.0" modules: - name: inner functions: @@ -797,6 +816,7 @@ modules: "Contact".into() )))) ); + assert_eq!(m.callback_interfaces[0].methods.len(), 2); assert_eq!(m.errors.as_ref().unwrap().codes[0].fields.len(), 1); assert!(m.functions[0].r#async && m.functions[0].cancellable); assert_eq!(m.modules[0].functions[0].name, "ping"); @@ -809,6 +829,19 @@ modules: assert_eq!(back2, api); } + #[test] + fn removed_document_keys_are_rejected() { + for yaml in [ + "version: \"0.9.0\"\nmodules:\n - name: m\n callbacks: []\n", + "version: \"0.9.0\"\nmodules:\n - name: m\n listeners: []\n", + "version: \"0.9.0\"\nmodules:\n - name: m\n functions:\n - { name: f, since: \"0.1.0\" }\n", + "version: \"0.9.0\"\nmodules:\n - name: m\n functions:\n - { name: f, params: [{ name: x, type: i32, mutable: true }] }\n", + ] { + let err = serde_yaml::from_str::(yaml).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{yaml}\n{err}"); + } + } + #[test] fn serialization_omits_defaulted_fields() { let api = Api { @@ -825,13 +858,11 @@ modules: r#async: false, cancellable: false, deprecated: None, - since: None, }], interfaces: vec![], + callback_interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], errors: None, modules: vec![], }], @@ -853,5 +884,6 @@ modules: assert_eq!(type_ref["type"], "string"); assert!(json["properties"].get("package").is_none()); assert!(json["properties"].get("generators").is_none()); + assert!(json["definitions"].get("CallbackInterfaceDef").is_some()); } } diff --git a/crates/weaveffi-ir/src/parse.rs b/crates/weaveffi-ir/src/parse.rs index 62c37ebc..eceaa20d 100644 --- a/crates/weaveffi-ir/src/parse.rs +++ b/crates/weaveffi-ir/src/parse.rs @@ -184,13 +184,11 @@ mod tests { Param { name: "a".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, Param { name: "b".to_string(), ty: TypeRef::I32, - mutable: false, doc: None, }, ], @@ -200,13 +198,11 @@ mod tests { r#async: false, cancellable: false, deprecated: None, - since: None, }], interfaces: vec![], + callback_interfaces: vec![], structs: vec![], enums: vec![], - callbacks: vec![], - listeners: vec![], errors: None, modules: vec![], }], @@ -216,7 +212,7 @@ mod tests { #[test] fn every_format_parses_the_same_document() { let yaml = r#" -version: "0.8.0" +version: "0.9.0" modules: - name: math functions: @@ -230,7 +226,7 @@ modules: doc: "Adds two numbers" "#; let json = r#"{ - "version": "0.8.0", + "version": "0.9.0", "modules": [{ "name": "math", "functions": [{ @@ -245,7 +241,7 @@ modules: }] }"#; let toml_str = r#" -version = "0.8.0" +version = "0.9.0" [[modules]] name = "math" @@ -289,7 +285,7 @@ type = "i32" #[test] fn parse_errors_carry_spans() { - let yaml = "version: \"0.8.0\"\nmodules:\n - name: [oops\n"; + let yaml = "version: \"0.9.0\"\nmodules:\n - name: [oops\n"; match parse_api_str(yaml, "yaml").unwrap_err() { ParseError::Yaml { line, span, .. } => { assert!(line > 0); @@ -309,7 +305,7 @@ type = "i32" #[test] fn unknown_type_syntax_is_a_parse_error() { - let yaml = "version: \"0.8.0\"\nmodules:\n - name: m\n functions:\n - name: f\n params: [{ name: x, type: \"{string}\" }]\n"; + let yaml = "version: \"0.9.0\"\nmodules:\n - name: m\n functions:\n - name: f\n params: [{ name: x, type: \"{string}\" }]\n"; let err = parse_api_str(yaml, "yaml").unwrap_err(); assert!(err.to_string().contains("map type missing"), "{err}"); } diff --git a/crates/weaveffi-macros/Cargo.toml b/crates/weaveffi-macros/Cargo.toml index 3bc80491..9c07185f 100644 --- a/crates/weaveffi-macros/Cargo.toml +++ b/crates/weaveffi-macros/Cargo.toml @@ -22,3 +22,7 @@ proc-macro2 = "1.0" [lints] workspace = true + +[dev-dependencies] +trybuild = "1.0.120" +weaveffi = { path = "../weaveffi" } diff --git a/crates/weaveffi-macros/src/codegen/async_fns.rs b/crates/weaveffi-macros/src/codegen/async_fns.rs index 727c816a..2581882f 100644 --- a/crates/weaveffi-macros/src/codegen/async_fns.rs +++ b/crates/weaveffi-macros/src/codegen/async_fns.rs @@ -6,50 +6,111 @@ //! from an arbitrary producer thread, and everything it delivers is *owned by //! the consumer*. A non-null `err` is heap-boxed and released with //! `weaveffi_error_free`; a string result is released with -//! `weaveffi_free_string`; a buffered result is released with -//! `weaveffi_free_bytes`; an owned-object result transfers ownership. This -//! ownership transfer (unlike the borrow-and-copy contract of synchronous +//! `weaveffi_free_string`; a buffered or bytes result is released with +//! `weaveffi_free_bytes`; an object result transfers one strong reference. +//! This ownership transfer (unlike the borrow-and-copy contract of synchronous //! `out_err` slots) is what lets consumers defer decoding past the callback's //! return, which runtimes such as Dart's `NativeCallable.listener` require. //! The callback's `err` slot follows the owning function's //! [`ErrorStrategy`](weaveffi_core::plan::ErrorStrategy). +//! +//! The future runs on the process-wide [`weaveffi_abi::spawn`] executor (the +//! default thread-per-future spawner, or whatever the producer installed with +//! [`weaveffi_abi::set_spawner`]), wrapped in [`weaveffi_abi::CatchUnwind`] +//! so a panic is delivered through the callback rather than into the executor. use proc_macro2::TokenStream; use quote::quote; -use weaveffi_core::model::Ty; -use weaveffi_core::model::{AsyncBinding, FnBinding, ParamBinding}; +use weaveffi_core::model::{AsyncBinding, FnBinding, ParamBinding, Ty}; use super::helpers::{ - ctype_to_rust, fn_slots, ident, is_copy, sentinel, user_param_type, CallTarget, + ctype_to_rust, fn_slots, ident, is_copy, rust_type_ident, sentinel, CallTarget, UserSig, }; use super::sync::throws; use super::unsupported; +/// The two "fire the callback with an error and stop" tails an async launcher +/// needs: one for the caller's thread (before the spawn, where `context` is +/// still a raw pointer and `return` leaves the launcher) and one for inside +/// the spawned future (where `__wv_ctx` is a `usize` and `return` leaves the +/// async block). Both replay the result slots as their zero/null sentinels. +struct Fail { + sentinels: Vec, +} + +impl Fail { + fn with(&self, ctx: TokenStream, build_err: TokenStream) -> TokenStream { + let sentinels = &self.sentinels; + quote! {{ + let mut __wv_e = ::weaveffi::abi::weaveffi_error::default(); + #build_err + callback( + #ctx, + ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) + #(, #sentinels)* + ); + return; + }} + } + + fn marshal(msg: &str) -> TokenStream { + quote! { + ::weaveffi::abi::error_set( + &mut __wv_e, + ::weaveffi::abi::MARSHAL_ERROR_CODE, + #msg, + ); + } + } + + /// Reject on the caller's thread with a marshalling error. + fn pre(&self, msg: &str) -> TokenStream { + self.with(quote!(context), Self::marshal(msg)) + } + + /// Reject inside the future with a marshalling error. + fn inner(&self, msg: &str) -> TokenStream { + self.with( + quote!(__wv_ctx as *mut ::std::ffi::c_void), + Self::marshal(msg), + ) + } +} + /// Lift one async-launcher input slot into an *owned* Rust value and give the -/// call argument the user's `async fn` receives. +/// call argument the user's `async fn` receives, as `(pre_spawn, in_future, +/// arg)`. /// -/// Async inputs must own their data before the worker thread runs: the foreign +/// Async inputs must own their data before the future is spawned: the foreign /// caller may free or reuse the argument buffers as soon as the launcher -/// returns. So borrowed slots (`&str`, `&[u8]`, `[&str]`) are copied here, just -/// like the hand-written async sample does. There is no `out_err` slot on a -/// launcher, so an invalid input is lifted leniently (an unreadable string -/// becomes empty) rather than reported - the completion callback is the only -/// error channel, and it fires with the result the future produces. +/// returns. So strings and bytes are copied and objects retained (a new strong +/// reference) on the caller's thread; a borrowed spelling (`&str`, `&T`) is +/// then satisfied by lending the owned value. There is no `out_err` slot on a +/// launcher, so an invalid input fires the completion callback with a +/// marshalling error, which keeps the "exactly once" promise. fn lift_async_input( pb: &ParamBinding, - sig: &syn::Signature, + user: &UserSig<'_>, + fail: &Fail, ) -> syn::Result<(TokenStream, TokenStream, TokenStream)> { let name = ident(&pb.name); let none = TokenStream::new(); + let is_ref = user.param_is_ref(&pb.name); + let arg = if is_ref { + quote!(&#name) + } else { + quote!(#name) + }; + let invalid = fail.pre(&format!("{} is null or invalid", pb.name)); + // A buffered parameter arrives as a borrowed value buffer the caller may - // free as soon as the launcher returns: copy the raw bytes on the - // caller's thread, decode on the worker thread (inside its catch_unwind, - // so a malformed buffer is delivered through the callback's `err` with - // the panic code rather than unwinding across the C boundary). + // free as soon as the launcher returns: copy the raw bytes on the caller's + // thread and decode inside the future (a malformed buffer is delivered + // through the callback's `err` with the marshalling code). if pb.ty.is_buffered() { let ptr = ident(&format!("{}_ptr", pb.name)); let len = ident(&format!("{}_len", pb.name)); - let panic_msg = format!("{}: malformed WeaveFFI value buffer", pb.name); + let decode_fail = fail.inner(&format!("{}: malformed value buffer", pb.name)); return Ok(( quote! { let #name: ::std::vec::Vec = if #ptr.is_null() { @@ -59,36 +120,40 @@ fn lift_async_input( }; }, quote! { - let #name = ::weaveffi::abi::decode_value(&#name).expect(#panic_msg); + let #name = match ::weaveffi::abi::decode_value(&#name) { + ::std::result::Result::Ok(__v) => __v, + ::std::result::Result::Err(_) => #decode_fail + }; }, - quote!(#name), + arg, )); } Ok(match &pb.ty { - ty if is_copy(ty) && !matches!(ty, Ty::Enum(_)) => (none.clone(), none, quote!(#name)), - // A typed handle is an opaque pointer, which is not `Send`. Carry it - // across the worker-thread boundary as a `usize` and rebuild it inside - // the closure with the producer's own pointer type (so a `super::T` - // path stays in scope), mirroring the hand-written async sample. - Ty::TypedHandle(_) => { - let addr = ident(&format!("__wv_addr_{}", pb.name)); - let uty = user_param_type(sig, &pb.name) - .ok_or_else(|| unsupported(&pb.name, "async typed-handle parameter"))?; + Ty::Enum(enum_name) => { + let et = user + .param_object(&pb.name) + .unwrap_or_else(|| quote::ToTokens::into_token_stream(rust_type_ident(enum_name))); ( - quote!(let #addr = #name as usize;), - quote!(let #name = #addr as #uty;), - quote!(#name), + quote! { + let #name = match <#et>::__weaveffi_from_i32(#name) { + ::std::option::Option::Some(__v) => __v, + ::std::option::Option::None => #invalid + }; + }, + none, + arg, ) } + ty if is_copy(ty) => (none.clone(), none, arg), Ty::StringUtf8 => ( - quote!(let #name = ::weaveffi::abi::c_ptr_to_string(#name).unwrap_or_default();), - none, - quote!(#name), - ), - Ty::BorrowedStr => ( - quote!(let #name = ::weaveffi::abi::c_ptr_to_string(#name).unwrap_or_default();), + quote! { + let #name = match ::weaveffi::abi::c_ptr_to_string(#name) { + ::std::option::Option::Some(__s) => __s, + ::std::option::Option::None => #invalid + }; + }, none, - quote!(&#name), + arg, ), Ty::Bytes => { let ptr = ident(&format!("{}_ptr", pb.name)); @@ -96,16 +161,57 @@ fn lift_async_input( ( quote!(let #name = unsafe { ::weaveffi::abi::lift_bytes(#ptr, #len) };), none, - quote!(#name), + arg, ) } - Ty::BorrowedBytes => { - let ptr = ident(&format!("{}_ptr", pb.name)); - let len = ident(&format!("{}_len", pb.name)); + // An object is always retained across the spawn (the consumer may + // release its own reference the moment the launcher returns). A `&T` + // spelling is satisfied by lending the `Arc`, which derefs to `&T`. + Ty::Interface(_) => { + let arg = if user.param_wants_arc(&pb.name) { + arg + } else { + quote!(&#name) + }; ( - quote!(let #name = unsafe { ::weaveffi::abi::lift_bytes(#ptr, #len) };), + quote! { + let #name = match unsafe { ::weaveffi::abi::object_arc(#name) } { + ::std::option::Option::Some(__wv_o) => __wv_o, + ::std::option::Option::None => #invalid + }; + }, + none, + arg, + ) + } + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { + let arg = if user.param_wants_arc(&pb.name) { + arg + } else { + quote!(#name.as_deref()) + }; + ( + quote!(let #name = unsafe { ::weaveffi::abi::object_arc(#name) };), + none, + arg, + ) + } + Ty::CallbackInterface(_) => { + let dyn_ty = user.param_callback(&pb.name)?; + let ctx = ident(&format!("{}_ctx", pb.name)); + let vtable = ident(&format!("{}_vtable", pb.name)); + let null_fail = fail.pre(&format!("{}: null callback vtable", pb.name)); + ( + quote! { + let #name = match unsafe { + ::weaveffi::abi::lift_callback::<#dyn_ty>(#ctx, #vtable) + } { + ::std::option::Option::Some(__wv_cb) => __wv_cb, + ::std::option::Option::None => #null_fail + }; + }, none, - quote!(&#name), + arg, ) } _ => return Err(unsupported(&pb.name, "async parameter type")), @@ -117,37 +223,49 @@ fn lift_async_input( /// /// Every result transfers ownership to the consumer, per the plan's async /// contract ([`weaveffi_core::plan::AsyncProtocol`]): a string is released -/// with `weaveffi_free_string`, a value buffer with `weaveffi_free_bytes`, -/// and an owned interface result is adopted (the consumer eventually calls -/// `_destroy`). Ownership transfer is what lets consumers defer decoding past -/// the callback's return. `bytes` results are not yet supported. -fn async_result_args(ty: &Ty, value: TokenStream) -> syn::Result<(TokenStream, Vec)> { +/// with `weaveffi_free_string`, a value or byte buffer with +/// `weaveffi_free_bytes`, and an object result is one strong reference the +/// consumer eventually releases with `_destroy`. +fn async_result_args( + ty: &Ty, + value: TokenStream, + object: Option<&TokenStream>, +) -> syn::Result<(TokenStream, Vec)> { let none = TokenStream::new(); - // A buffered result crosses as an owned `(ptr, len)` pair the consumer - // decodes at its leisure and releases with `weaveffi_free_bytes`. - if ty.is_buffered() { - return Ok(( + let owned_bytes = |bytes: TokenStream| { + ( quote! { - let __wv_res_buf = ::weaveffi::abi::encode_value(&(#value)).into_boxed_slice(); + let __wv_res_buf = (#bytes).into_boxed_slice(); let __wv_res_len = __wv_res_buf.len(); let __wv_res_ptr = ::std::boxed::Box::into_raw(__wv_res_buf) as *const u8; }, vec![quote!(__wv_res_ptr), quote!(__wv_res_len)], + ) + }; + if ty.is_buffered() { + return Ok(owned_bytes( + quote!(::weaveffi::abi::encode_value(&(#value))), )); } + let typed = |call: TokenStream| match object { + Some(obj) => quote!(let __wv_res: *mut #obj = #call;), + None => quote!(let __wv_res = #call;), + }; Ok(match ty { - t if is_copy(t) && !matches!(t, Ty::Enum(_)) => (none.clone(), vec![value]), - Ty::Enum(_) => (none.clone(), vec![quote!((#value) as i32)]), - // An owned string the consumer releases with `weaveffi_free_string`. - Ty::StringUtf8 | Ty::BorrowedStr => ( + Ty::Enum(_) => (none, vec![quote!((#value).__weaveffi_to_i32())]), + t if is_copy(t) => (none, vec![value]), + Ty::StringUtf8 => ( quote!(let __wv_res = ::weaveffi::abi::string_to_c_ptr(&(#value));), vec![quote!(__wv_res)], ), - // An owned-object result: the callback adopts the pointer (the plan's - // `result_adopt`) and the consumer eventually calls `_destroy`. + Ty::Bytes => owned_bytes(value), Ty::Interface(_) => ( - none.clone(), - vec![quote!(::std::boxed::Box::into_raw(::std::boxed::Box::new(#value)))], + typed(quote!(::weaveffi::abi::lower_object(#value))), + vec![quote!(__wv_res)], + ), + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => ( + typed(quote!(::weaveffi::abi::lower_object_opt(#value))), + vec![quote!(__wv_res)], ), _ => return Err(unsupported("async return", "result type")), }) @@ -156,24 +274,33 @@ fn async_result_args(ty: &Ty, value: TokenStream) -> syn::Result<(TokenStream, V /// Generate the completion-callback typedef and the `_async` launcher for an /// `async fn`. /// -/// The launcher lifts inputs into owned values, spawns a worker thread, drives -/// the future to completion with `weaveffi::abi::block_on`, then invokes the -/// host's callback with `(context, err, result…)`. A `Result` -/// return routes its `Err` through a transient `weaveffi_error`; success passes -/// a null `err`. The foreign caller's `context` is moved across the thread -/// boundary as a `usize` (so the closure is `Send`); the callback pointer is -/// `Send` on its own. +/// The launcher lifts inputs into owned values on the caller's thread, then +/// hands a `Send + 'static` future to [`weaveffi_abi::spawn`]. The future +/// awaits the producer's `async fn` under [`weaveffi_abi::CatchUnwind`] and +/// invokes the host's callback with `(context, err, result…)` exactly once: a +/// `Result` return routes its `Err` through a boxed `weaveffi_error`, a panic +/// (or a consumer callback's failure) is reported with the reserved code, and +/// success passes a null `err`. The foreign caller's `context` is carried as a +/// `usize` so the future is `Send`; the callback pointer is `Send` on its own. pub(crate) fn gen_async_function( f: &FnBinding, a: &AsyncBinding, - sig: &syn::Signature, + user: &UserSig<'_>, target: &CallTarget, ) -> syn::Result { let cb_ty = ident(&a.callback_type); let cb_slots: Vec = a .callback_params .iter() - .map(|p| ctype_to_rust(&p.ty)) + .map(|p| { + // The result slot of an object return is spelled with the + // producer's pointee so `super::T` stays in scope. + let is_object_ret = f.ret.as_ref().is_some_and(|t| t.interface_name().is_some()); + match (p.name.as_str(), user.ret_object()) { + ("result", Some(obj)) if is_object_ret => quote!(*mut #obj), + _ => ctype_to_rust(&p.ty), + } + }) .collect(); let callback_typedef = quote! { #[doc(hidden)] @@ -182,56 +309,43 @@ pub(crate) fn gen_async_function( }; let launch_sym = ident(&a.launch.symbol); - let launch_params: Vec = fn_slots(&a.launch.params, f, sig); + let launch_params = fn_slots(&a.launch.params, &f.params, user)?; - // The error path replays the result slots as their zero/null sentinels. - let sentinels: Vec = a - .callback_params - .iter() - .skip(2) - .map(|p| sentinel(&p.ty)) - .collect(); + let fail = Fail { + sentinels: a + .callback_params + .iter() + .skip(2) + .map(|p| sentinel(&p.ty)) + .collect(), + }; // Lift each logical input into three parts: a pre-spawn statement that runs - // on the caller's thread (owning borrowed data, bouncing a non-`Send` - // handle through a `usize`), an in-closure statement that reconstitutes the - // value on the worker thread, and the argument forwarded to the producer. + // on the caller's thread (owning borrowed data, retaining objects), an + // in-future statement that finishes the lift (decoding a value buffer), + // and the argument forwarded to the producer. let mut pre_spawn = TokenStream::new(); - let mut in_closure = TokenStream::new(); + let mut in_future = TokenStream::new(); let mut call_args: Vec = Vec::new(); - // An async method's receiver crosses the worker-thread boundary the same - // way a typed handle does: as a `usize`, rebuilt into `&T` inside the - // closure. The object outlives the call by the ABI contract (the consumer - // must not destroy it while a call is in flight). A null receiver still - // fires the callback (with an error), so the continuation never hangs. + // An async method retains its receiver for the life of the call: the + // consumer may release its own reference the moment the launcher returns. + // A null receiver still fires the callback (with an error), so the + // continuation never hangs. if let CallTarget::Method(ty) = target { + let null_self = fail.pre("self is null"); pre_spawn.extend(quote! { - if __wv_self.is_null() { - let mut __wv_e = ::weaveffi::abi::weaveffi_error::default(); - ::weaveffi::abi::error_set( - &mut __wv_e, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - "self is null", - ); - callback( - context, - ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) - #(, #sentinels)* - ); - return; - } - let __wv_self_addr = __wv_self as usize; - }); - in_closure.extend(quote! { - let __wv_obj: &#ty = unsafe { &*(__wv_self_addr as *const #ty) }; + let __wv_obj = match unsafe { ::weaveffi::abi::object_arc::<#ty>(__wv_self) } { + ::std::option::Option::Some(__wv_o) => __wv_o, + ::std::option::Option::None => #null_self + }; }); } for pb in &f.params { - let (pre, inc, arg) = lift_async_input(pb, sig)?; + let (pre, inc, arg) = lift_async_input(pb, user, &fail)?; pre_spawn.extend(pre); - in_closure.extend(inc); + in_future.extend(inc); call_args.push(arg); } @@ -247,27 +361,16 @@ pub(crate) fn gen_async_function( } let is_throws = throws(f); + let object = user.ret_object(); let (result_pre, success_args) = match &f.ret { - Some(ty) => async_result_args(ty, quote!(__wv_val))?, + Some(ty) => async_result_args(ty, quote!(__wv_val), object.as_ref())?, None => (TokenStream::new(), Vec::new()), }; - // Result buffers and strings transfer ownership to the consumer, which - // releases them with the runtime free symbols, per the async plan - // contract. let success_call = quote! { #result_pre callback(__wv_ctx as *mut ::std::ffi::c_void, ::std::ptr::null_mut() #(, #success_args)*); }; - - // A non-null `err` is heap-boxed: the consumer copies what it needs and - // releases it with `weaveffi_error_free`. - let fail_call = quote! { - callback( - __wv_ctx as *mut ::std::ffi::c_void, - ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) - #(, #sentinels)* - ); - }; + let sentinels = &fail.sentinels; let dispatch = if is_throws { let bind = if f.ret.is_some() { @@ -286,7 +389,11 @@ pub(crate) fn gen_async_function( &::weaveffi::abi::ErrorReport::message(&__wv_err), ::weaveffi::abi::ErrorReport::payload(&__wv_err), ); - #fail_call + callback( + __wv_ctx as *mut ::std::ffi::c_void, + ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) + #(, #sentinels)* + ); } } } @@ -301,7 +408,6 @@ pub(crate) fn gen_async_function( #success_call } }; - let call = target.call(&f.name, &call_args); Ok(quote! { @@ -312,33 +418,48 @@ pub(crate) fn gen_async_function( pub extern "C" fn #launch_sym(#(#launch_params),*) { #pre_spawn let __wv_ctx = context as usize; - // Drive the future to completion, then fire the host callback. On a - // threaded target this happens on a worker thread so the launcher - // returns immediately; `wasm32` has no threads, so run inline (the - // sample futures are ready without awaiting real I/O). A panic in - // the producer's future is caught and delivered through the - // callback's `err` argument with the reserved panic code, so the - // consumer's continuation always fires exactly once. - let __wv_body = move || { - let __wv_run = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - #in_closure - let __wv_out = ::weaveffi::abi::block_on(#call); - #dispatch - })); + ::weaveffi::abi::spawn(async move { + // The inner future finishes lifting, awaits the producer, and + // fires the callback. A panic anywhere inside (including a + // consumer callback-interface failure surfacing as a + // `ForeignError`) is caught and delivered through `err`, so + // the continuation fires exactly once. A failure the producer + // recorded instead of unwinding (`panic = "abort"` builds) is + // taken before the success callback can fire, for the same + // reason. + let __wv_run = ::weaveffi::abi::CatchUnwind::new(async move { + #in_future + let __wv_out = #call.await; + match ::weaveffi::abi::take_foreign_error() { + ::std::option::Option::Some(__wv_foreign) => { + let mut __wv_e = ::weaveffi::abi::weaveffi_error::default(); + ::weaveffi::abi::error_set( + &mut __wv_e, + __wv_foreign.code, + &__wv_foreign.message, + ); + callback( + __wv_ctx as *mut ::std::ffi::c_void, + ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) + #(, #sentinels)* + ); + } + ::std::option::Option::None => { + #dispatch + } + } + }) + .await; if let ::std::result::Result::Err(__wv_panic) = __wv_run { let mut __wv_e = ::weaveffi::abi::weaveffi_error::default(); ::weaveffi::abi::error_set_panic(&mut __wv_e, &*__wv_panic); - #fail_call + callback( + __wv_ctx as *mut ::std::ffi::c_void, + ::std::boxed::Box::into_raw(::std::boxed::Box::new(__wv_e)) + #(, #sentinels)* + ); } - }; - #[cfg(target_arch = "wasm32")] - { - __wv_body(); - } - #[cfg(not(target_arch = "wasm32"))] - { - ::std::thread::spawn(__wv_body); - } + }); } }) } diff --git a/crates/weaveffi-macros/src/codegen/callbacks.rs b/crates/weaveffi-macros/src/codegen/callbacks.rs index dbaffc6a..f8e87c33 100644 --- a/crates/weaveffi-macros/src/codegen/callbacks.rs +++ b/crates/weaveffi-macros/src/codegen/callbacks.rs @@ -1,177 +1,251 @@ -//! Thunk emission for callbacks and listeners: the `extern "C"` function -//! pointer typedefs, the register/unregister symbols, and the safe `emit_*` -//! helpers. +//! Emission for callback interfaces: the `#[repr(C)]` vtable struct, the +//! foreign wrapper that implements the producer's trait on top of a +//! `(ctx, vtable)` pair, and the `CallbackInterface` impl that lets thunks +//! lift `Arc` parameters. +//! +//! This is the producer half of the contract stated by +//! [`weaveffi_core::plan::CallbackProtocol`]: each method lowers its +//! arguments (strings and buffers are borrowed for the call and freed after +//! the vtable entry returns; objects transfer one strong reference the +//! consumer adopts), calls the entry with `(ctx, args…, &mut err)`, and then +//! checks `err`. A consumer failure is raised through +//! `weaveffi::abi::raise_foreign_error`: on unwinding builds it unwinds with a +//! `ForeignError` payload that the enclosing thunk reports as +//! `FOREIGN_ERROR_CODE`; on `panic = "abort"` builds it is recorded and the +//! thunk picks it up after the producer returns. use proc_macro2::{Span, TokenStream}; -use quote::quote; -use weaveffi_core::model::Ty; -use weaveffi_core::model::{CallbackBinding, ListenerBinding, ModuleBinding, ParamBinding}; +use quote::{quote, ToTokens}; +use weaveffi_core::model::{CallbackInterfaceBinding, CallbackMethodBinding, ParamBinding, Ty}; -use super::helpers::{ctype_to_rust, ident, is_copy, rust_type_ident, typeref_to_rust}; +use super::helpers::{ident, is_copy, ret_arrow, rust_type_ident, slot_type_for, UserSig}; use super::unsupported; -/// Emit the `extern "C"` function-pointer typedef for a module-level callback. -/// -/// The alias names every ABI slot (the callback's parameters plus the trailing -/// `void* context`) so a registered host function is called with exactly the -/// signature the generated header declares. It is `#[doc(hidden)]` because the -/// `register_*` symbol, not the bare typedef, is the producer-facing surface. -pub(crate) fn gen_callback_type(c: &CallbackBinding) -> syn::Result { - let ty = ident(&c.c_fn_type); - let slots: Vec = c.abi_params.iter().map(|p| ctype_to_rust(&p.ty)).collect(); - Ok(quote! { - #[doc(hidden)] - #[allow(non_camel_case_types)] - pub type #ty = extern "C" fn(#(#slots),*); - }) -} - -/// Build the four pieces the emit helper needs for one callback parameter: the -/// Rust parameter it accepts, the lowering preamble, the C call argument(s) it -/// forwards, and any post-call cleanup. Borrowed inputs (`&str`, `&[u8]`, and -/// buffered composites taken as `&T`) are lowered into temporaries that live -/// across every dispatch and are freed once afterward, mirroring the -/// hand-written events sample. -fn emit_callback_param( +/// Build the lowering for one callback-method argument as `(preamble, C +/// arguments, cleanup)`. The Rust parameter is whatever the producer's trait +/// declares; `is_ref` says whether it arrives borrowed. +fn lower_callback_arg( pb: &ParamBinding, -) -> syn::Result<(TokenStream, TokenStream, Vec, TokenStream)> { + user: &UserSig<'_>, +) -> syn::Result<(TokenStream, Vec, TokenStream)> { let n = ident(&pb.name); + let is_ref = user.param_is_ref(&pb.name); let none = TokenStream::new(); - // A buffered payload is borrowed for the dispatch: encode it once into a - // local value buffer, hand every subscriber the same `(ptr, len)` view, - // and let the encoding drop after the loop. + // Borrow the value for encoding whether it arrived owned or borrowed. + let borrowed = if is_ref { quote!(#n) } else { quote!(&#n) }; + + // A buffered payload is borrowed for the call: encode it into a local + // value buffer, hand the consumer its `(ptr, len)` view, and let the + // encoding drop afterward. if pb.ty.is_buffered() { - let rt = typeref_to_rust(&pb.ty)?; let tmp = ident(&format!("__wv_cb_{}", pb.name)); return Ok(( - quote!(#n: &#rt), - quote!(let #tmp = ::weaveffi::abi::encode_value(#n);), + quote!(let #tmp = ::weaveffi::abi::encode_value(#borrowed);), vec![quote!(#tmp.as_ptr()), quote!(#tmp.len())], none, )); } Ok(match &pb.ty { - Ty::Enum(name) => { - let et = rust_type_ident(name); - ( - quote!(#n: #et), - none.clone(), - vec![quote!((#n) as i32)], - none, - ) - } + Ty::Enum(_) => (none.clone(), vec![quote!(#n.__weaveffi_to_i32())], none), ty if is_copy(ty) => { - let rt = typeref_to_rust(ty)?; - (quote!(#n: #rt), none.clone(), vec![quote!(#n)], none) + let v = if is_ref { quote!(*#n) } else { quote!(#n) }; + (none.clone(), vec![v], none) } - Ty::StringUtf8 | Ty::BorrowedStr => { + Ty::StringUtf8 => { let tmp = ident(&format!("__wv_cb_{}", pb.name)); ( - quote!(#n: &str), - quote!(let #tmp = ::weaveffi::abi::string_to_c_ptr(#n);), + quote!(let #tmp = ::weaveffi::abi::string_to_c_ptr(&#n);), vec![quote!(#tmp)], quote!(::weaveffi::abi::free_string(#tmp);), ) } - Ty::Bytes | Ty::BorrowedBytes => ( - quote!(#n: &[u8]), + Ty::Bytes => ( none.clone(), vec![quote!(#n.as_ptr()), quote!(#n.len())], none, ), - _ => return Err(unsupported(&pb.name, "callback parameter type")), + // An object transfers one strong reference the consumer adopts (and + // eventually `_destroy`s). The producer must hold it as an `Arc`, + // since only an `Arc` allocation can carry a reference count. + Ty::Interface(_) => { + if !user.param_wants_arc(&pb.name) { + return Err(unsupported( + &pb.name, + "callback-interface object parameter that is not an `Arc` (the consumer \ + adopts a reference, so spell it `Arc`)", + )); + } + let obj = user.param_object(&pb.name); + ( + none.clone(), + vec![ + quote!(::weaveffi::abi::lower_object::<#obj>(::std::sync::Arc::clone(#borrowed))), + ], + none, + ) + } + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { + if !user.param_wants_arc(&pb.name) { + return Err(unsupported( + &pb.name, + "callback-interface object parameter that is not an `Option>`", + )); + } + let obj = user.param_object(&pb.name); + ( + none.clone(), + vec![quote!(::weaveffi::abi::lower_object_opt::<#obj>(#n.clone()))], + none, + ) + } + _ => return Err(unsupported(&pb.name, "callback-interface parameter type")), }) } -/// Emit a listener's registry, its `register_*`/`unregister_*` symbols, and a -/// safe `emit_` helper the producer calls to fire every subscriber. -/// -/// The registry is a module-level `Mutex>`; -/// `emit_*` snapshots the `(callback, context)` pairs under the lock, releases -/// it, lowers the event payload once, dispatches to each subscriber, and frees -/// the payload, so a re-entrant `register`/`unregister` from inside a callback -/// cannot deadlock. -pub(crate) fn gen_listener(mb: &ModuleBinding, l: &ListenerBinding) -> syn::Result { - let cb = mb.callback(&l.event_callback).ok_or_else(|| { - syn::Error::new( - Span::call_site(), - format!( - "weaveffi: listener `{}` references unknown callback `{}`", - l.name, l.event_callback - ), - ) - })?; - - let cb_ty = ident(&l.callback_c_fn_type); - let reg = ident(&format!("__WEAVEFFI_REG_{}", l.name)); - let next_id = ident(&format!("__WEAVEFFI_NEXTID_{}", l.name)); - let register_sym = ident(&l.register_symbol); - let unregister_sym = ident(&l.unregister_symbol); - let emit_fn = ident(&format!("emit_{}", l.name)); +/// The expression that lifts a vtable entry's return value `__wv_ret` into +/// the trait method's return type. Callback returns are direct-family only. +fn lift_callback_ret(ret: Option<&Ty>, user: &UserSig<'_>) -> syn::Result { + Ok(match ret { + None => TokenStream::new(), + Some(Ty::Enum(name)) => { + let et = user + .ret_object() + .unwrap_or_else(|| rust_type_ident(name).into_token_stream()); + quote! { + match <#et>::__weaveffi_from_i32(__wv_ret) { + ::std::option::Option::Some(__wv_e) => __wv_e, + ::std::option::Option::None => { + ::weaveffi::abi::raise_foreign_error(::weaveffi::abi::ForeignError { + code: ::weaveffi::abi::MARSHAL_ERROR_CODE, + message: ::std::string::String::from( + "callback interface returned an invalid enum discriminant", + ), + }); + <#et>::__weaveffi_placeholder() + } + } + } + } + Some(ty) if is_copy(ty) => quote!(__wv_ret), + Some(_) => return Err(unsupported("callback return", "non-direct return type")), + }) +} - let mut decls: Vec = Vec::new(); +/// Emit one method of the foreign wrapper's trait impl. +fn gen_foreign_method(m: &CallbackMethodBinding, sig: &syn::Signature) -> syn::Result { + let user = UserSig::new(sig, None); + let field = ident(&m.name); let mut pre = TokenStream::new(); let mut c_args: Vec = Vec::new(); let mut cleanup = TokenStream::new(); - for pb in &cb.params { - let (decl, p, args, free) = emit_callback_param(pb)?; - decls.push(decl); + for pb in &m.params { + let (p, args, free) = lower_callback_arg(pb, &user)?; pre.extend(p); c_args.extend(args); cleanup.extend(free); } + let lifted = lift_callback_ret(m.ret.as_ref(), &user)?; + Ok(quote! { + #[allow(unsafe_code, unused_variables, clippy::let_unit_value)] + #sig { + #pre + let mut __wv_err = ::weaveffi::abi::weaveffi_error::default(); + let __wv_ret = unsafe { + (self.0.vtable().#field)(self.0.ctx(), #(#c_args,)* &mut __wv_err) + }; + #cleanup + ::weaveffi::abi::check_foreign_error(__wv_err); + #lifted + } + }) +} - let emit_doc = format!( - "Fire the `{}` listener, invoking every registered `{}` callback.", - l.name, l.event_callback - ); +/// Emit everything a callback interface needs on the producer side. +/// +/// * `{vtable_tag}`: the `#[repr(C)]` vtable struct, one `unsafe extern "C"` +/// function pointer per method in declaration order plus the trailing +/// `free`, exactly as the generated header declares it. +/// * `__WeaveffiForeign_{Trait}`: a newtype over +/// [`ForeignCallback`](weaveffi_abi::ForeignCallback) implementing the +/// producer's trait by forwarding each call through the vtable. +/// * `impl CallbackInterface for dyn Trait`, which is how a thunk elsewhere +/// in the crate (possibly a nested module) names the vtable type and lifts +/// the `(ctx, vtable)` pair into an `Arc`. +pub(crate) fn gen_callback_interface( + cb: &CallbackInterfaceBinding, + item: &syn::ItemTrait, +) -> syn::Result { + let vt = ident(&cb.vtable_tag); + let trait_ident = ident(&cb.name); + let foreign = ident(&format!("__WeaveffiForeign_{}", cb.name)); + let mut fields: Vec = Vec::new(); + let mut methods: Vec = Vec::new(); + for m in &cb.methods { + let sig = item + .items + .iter() + .find_map(|ti| match ti { + syn::TraitItem::Fn(f) if f.sig.ident == m.name => Some(&f.sig), + _ => None, + }) + .ok_or_else(|| { + syn::Error::new( + Span::call_site(), + format!( + "internal error: no source for callback method `{}::{}`", + cb.name, m.name + ), + ) + })?; + let user = UserSig::new(sig, None); + let field = ident(&m.name); + let slots = m + .abi_params + .iter() + .map(|p| slot_type_for(p, &m.params, &user)) + .collect::>>()?; + let arrow = ret_arrow(&m.abi_ret); + fields.push(quote!(pub #field: unsafe extern "C" fn(#(#slots),*) #arrow,)); + methods.push(gen_foreign_method(m, sig)?); + } + + let vtable_doc = format!( + "The C vtable a consumer supplies to implement the `{}` callback interface.", + cb.name + ); Ok(quote! { - #[allow(non_upper_case_globals)] - static #reg: ::std::sync::Mutex<::std::vec::Vec<(#cb_ty, usize, u64)>> = - ::std::sync::Mutex::new(::std::vec::Vec::new()); - #[allow(non_upper_case_globals)] - static #next_id: ::std::sync::atomic::AtomicU64 = - ::std::sync::atomic::AtomicU64::new(1); + #[doc = #vtable_doc] + #[doc(hidden)] + #[repr(C)] + #[allow(non_camel_case_types, non_snake_case)] + pub struct #vt { + #(#fields)* + pub free: unsafe extern "C" fn(ctx: *mut ::std::ffi::c_void), + } - #[no_mangle] - #[allow(unsafe_code, clippy::not_unsafe_ptr_arg_deref, clippy::missing_safety_doc)] - pub extern "C" fn #register_sym( - callback: #cb_ty, - context: *mut ::std::ffi::c_void, - ) -> u64 { - let __id = #next_id.fetch_add(1, ::std::sync::atomic::Ordering::Relaxed); - #reg.lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) - .push((callback, context as usize, __id)); - __id + impl ::weaveffi::abi::Vtable for #vt { + fn free(&self) -> unsafe extern "C" fn(*mut ::std::ffi::c_void) { + self.free + } } - #[no_mangle] - #[allow(unsafe_code, clippy::not_unsafe_ptr_arg_deref)] - pub extern "C" fn #unregister_sym(id: u64) { - #reg.lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) - .retain(|&(_, _, __i)| __i != id); + #[doc(hidden)] + #[allow(non_camel_case_types)] + pub struct #foreign(::weaveffi::abi::ForeignCallback<#vt>); + + #[allow(deprecated)] + impl #trait_ident for #foreign { + #(#methods)* } - #[doc = #emit_doc] - #[allow(unsafe_code)] - pub fn #emit_fn(#(#decls),*) { - let __targets: ::std::vec::Vec<(#cb_ty, usize)> = #reg - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) - .iter() - .map(|&(__c, __ctx, _)| (__c, __ctx)) - .collect(); - if __targets.is_empty() { - return; + impl ::weaveffi::abi::CallbackInterface for dyn #trait_ident { + type Vtable = #vt; + fn from_foreign( + cb: ::weaveffi::abi::ForeignCallback<#vt>, + ) -> ::std::sync::Arc { + ::std::sync::Arc::new(#foreign(cb)) } - #pre - for (__cb, __ctx) in __targets { - __cb(#(#c_args,)* __ctx as *mut ::std::ffi::c_void); - } - #cleanup } }) } diff --git a/crates/weaveffi-macros/src/codegen/enums.rs b/crates/weaveffi-macros/src/codegen/enums.rs index fb8b45d5..5cd85f8e 100644 --- a/crates/weaveffi-macros/src/codegen/enums.rs +++ b/crates/weaveffi-macros/src/codegen/enums.rs @@ -14,9 +14,12 @@ use weaveffi_core::model::EnumBinding; use super::helpers::{ident, rust_type_ident}; use super::marshal::{field_read_expr, field_write_stmt_ref}; -/// Generate the surface for one enum: `__weaveffi_from_i32` plus a -/// `BufferValue` impl for a C-style enum, or the tag-and-fields -/// `BufferValue` impl for a rich (algebraic) enum. +/// Generate the surface for one enum: `__weaveffi_from_i32` / +/// `__weaveffi_to_i32` plus a `BufferValue` impl for a C-style enum, or the +/// tag-and-fields `BufferValue` impl for a rich (algebraic) enum. +/// +/// `__weaveffi_to_i32` takes `&self` so a thunk can lower an enum it holds by +/// value or by reference without requiring `Copy` or `Clone`. pub(crate) fn gen_enum(e: &EnumBinding, item: Option<&syn::ItemEnum>) -> syn::Result { if e.is_rich() { let item = item.ok_or_else(|| { @@ -38,6 +41,12 @@ pub(crate) fn gen_enum(e: &EnumBinding, item: Option<&syn::ItemEnum>) -> syn::Re let vident = ident(&v.name); quote!(Self::#vident => #value,) }); + let first = e.variants.first().map(|v| ident(&v.name)).ok_or_else(|| { + syn::Error::new( + Span::call_site(), + format!("internal error: enum `{}` has no variants", e.name), + ) + })?; Ok(quote! { #[allow(dead_code)] impl #ty { @@ -48,13 +57,27 @@ pub(crate) fn gen_enum(e: &EnumBinding, item: Option<&syn::ItemEnum>) -> syn::Re _ => ::std::option::Option::None, } } + + /// The value a callback-interface method yields after its + /// consumer implementation has already been reported as failed + /// (see `weaveffi::abi::raise_foreign_error`); never observed by + /// a caller because the thunk discards the producer's result. + #[doc(hidden)] + pub fn __weaveffi_placeholder() -> Self { + Self::#first + } + + #[doc(hidden)] + pub fn __weaveffi_to_i32(&self) -> i32 { + match self { + #(#write_arms)* + } + } } impl ::weaveffi::abi::BufferValue for #ty { fn write_value(&self, __wv_w: &mut ::weaveffi::abi::BufferWriter) { - __wv_w.write_i32(match self { - #(#write_arms)* - }); + __wv_w.write_i32(self.__weaveffi_to_i32()); } fn read_value( __wv_r: &mut ::weaveffi::abi::BufferReader<'_>, diff --git a/crates/weaveffi-macros/src/codegen/helpers.rs b/crates/weaveffi-macros/src/codegen/helpers.rs index 415efd93..3e002b51 100644 --- a/crates/weaveffi-macros/src/codegen/helpers.rs +++ b/crates/weaveffi-macros/src/codegen/helpers.rs @@ -1,14 +1,15 @@ //! Shared rendering helpers: identifiers, C-type spelling, ABI slot lists, -//! sentinels, call targets, and the panic-catching thunk wrapper. +//! sentinels, call targets, the producer-signature reader, and the +//! panic-catching thunk wrapper. use proc_macro2::{Span, TokenStream}; -use quote::quote; +use quote::{quote, ToTokens}; +use syn::spanned::Spanned as _; use syn::Ident; use weaveffi_core::abi::{AbiParam, CType, ConstPos}; -use weaveffi_core::model::FnBinding; -use weaveffi_core::model::Ty; +use weaveffi_core::model::{ParamBinding, Ty}; -use super::{unsupported, PREFIX}; +use super::PREFIX; /// Make a call-site identifier from a string. pub(crate) fn ident(name: &str) -> Ident { @@ -21,7 +22,9 @@ pub(crate) fn ident(name: &str) -> Ident { /// /// This mirrors [`CType::render_rust`] for every slot except opaque object /// pointers: a struct tag resolves to the producer's *real* Rust type (the -/// `Box`ed object), which is ABI-identical to the header's incomplete tag. +/// `Arc`-allocated object), which is ABI-identical to the header's incomplete +/// tag. Callers that know the producer's written type prefer +/// [`slot_type_for`], which keeps a `super::T` path in scope. pub(crate) fn ctype_to_rust(ct: &CType) -> TokenStream { match ct { CType::Int8 => quote!(i8), @@ -38,7 +41,6 @@ pub(crate) fn ctype_to_rust(ct: &CType) -> TokenStream { CType::Size => quote!(usize), CType::Char => quote!(::std::os::raw::c_char), CType::Void => quote!(::std::ffi::c_void), - CType::Handle => quote!(u64), CType::CancelToken => quote!(::weaveffi::abi::weaveffi_cancel_token), CType::Error => quote!(::weaveffi::abi::weaveffi_error), CType::Enum { .. } => quote!(i32), @@ -46,18 +48,18 @@ pub(crate) fn ctype_to_rust(ct: &CType) -> TokenStream { let ty = ident(name); quote!(#ty) } - // A generator-named typedef (e.g. an async completion callback). It - // renders `{prefix}_{core}`, matching `CType::render_rust` so the slot - // type lines up with the alias the macro emits for it. - CType::Named(core) => { - let ty = ident(&format!("{PREFIX}_{core}")); + // Generator-named typedefs (an async completion callback, a callback + // interface vtable) render `{prefix}_...`, matching `render_rust` so + // the slot type lines up with the alias or struct the macro emits. + CType::Named(_) | CType::VtableTag { .. } => { + let ty = ident(&ct.render_rust(PREFIX)); quote!(#ty) } CType::Ptr { konst, pointee } => { let inner = ctype_to_rust(pointee); match konst { ConstPos::None => quote!(*mut #inner), - ConstPos::West | ConstPos::East => quote!(*const #inner), + ConstPos::West => quote!(*const #inner), } } } @@ -95,8 +97,7 @@ pub(crate) fn sentinel(ret: &CType) -> TokenStream { } } -/// True when this enum/struct field/param type crosses the ABI without owning -/// heap data (so a getter can read it by copy rather than clone). +/// True when this type crosses the ABI by value without owning heap data. pub(crate) fn is_copy(ty: &Ty) -> bool { matches!( ty, @@ -111,7 +112,6 @@ pub(crate) fn is_copy(ty: &Ty) -> bool { | Ty::F32 | Ty::F64 | Ty::Bool - | Ty::Handle | Ty::Enum(_) ) } @@ -122,93 +122,327 @@ pub(crate) fn rust_type_ident(name: &str) -> Ident { ident(name.rsplit('.').next().unwrap_or(name)) } -/// The producer's source type for the parameter named `name`, if present. -pub(crate) fn user_param_type<'a>(sig: &'a syn::Signature, name: &str) -> Option<&'a syn::Type> { - sig.inputs.iter().find_map(|arg| { - let syn::FnArg::Typed(pt) = arg else { +// ── the producer's own signature ───────────────────────────────────────── + +/// A view of the producer's written signature, used wherever the thunk must +/// spell a type the way the producer did. +/// +/// The binding model knows every type *semantically* (`Ty::Interface("Store")`) +/// but not how the producer spelled it (`&Store`, `Arc`, +/// `Option>`). Thunks are emitted inside the producer's +/// module, so reusing the written path keeps parent-module types in scope, and +/// the wrapper (`&` vs `Arc`) decides whether the object is borrowed or +/// retained for the call. +/// +/// Inside an interface's `impl` block the producer may write `Self`; the thunk +/// is a free function, so every type this view hands out has `Self` replaced +/// by the interface's name (`self_ty`). +#[derive(Clone, Copy)] +pub(crate) struct UserSig<'a> { + sig: &'a syn::Signature, + self_ty: Option<&'a Ident>, +} + +impl<'a> UserSig<'a> { + pub(crate) fn new(sig: &'a syn::Signature, self_ty: Option<&'a Ident>) -> Self { + Self { sig, self_ty } + } + + /// Spell `ty` for use in a free-function thunk, substituting `Self`. + fn spell(&self, ty: &syn::Type) -> TokenStream { + let tokens = ty.to_token_stream(); + match self.self_ty { + Some(self_ty) => replace_self(tokens, self_ty), + None => tokens, + } + } + + /// The producer's source type for the parameter named `name`. + pub(crate) fn param_type(&self, name: &str) -> Option<&'a syn::Type> { + self.sig.inputs.iter().find_map(|arg| { + let syn::FnArg::Typed(pt) = arg else { + return None; + }; + let syn::Pat::Ident(id) = pt.pat.as_ref() else { + return None; + }; + (id.ident == name).then(|| pt.ty.as_ref()) + }) + } + + /// Whether the parameter is written as a shared reference (`&T`), so the + /// call lends the lifted value instead of moving it. + pub(crate) fn param_is_ref(&self, name: &str) -> bool { + matches!(self.param_type(name), Some(syn::Type::Reference(_))) + } + + /// Whether the parameter's type (under any `&` and `Option`) is an + /// `Arc<..>`, meaning the producer wants to retain the object. + pub(crate) fn param_wants_arc(&self, name: &str) -> bool { + self.param_type(name).is_some_and(mentions_arc) + } + + /// The producer's spelling of the object type behind an interface + /// parameter, with `&`, `Option`, and `Arc` peeled (e.g. `super::Store`). + pub(crate) fn param_object(&self, name: &str) -> Option { + self.param_type(name) + .map(peel_wrappers) + .filter(|t| matches!(t, syn::Type::Path(_))) + .map(|t| self.spell(t)) + } + + /// The `dyn Trait` behind a callback-interface parameter written as + /// `Arc`. + /// + /// # Errors + /// + /// Rejects a trait object with extra bounds (`dyn Trait + Send`): the + /// generated `CallbackInterface` impl is for the bare `dyn Trait`, so the + /// producer declares `Send + Sync` as supertraits instead. + pub(crate) fn param_callback(&self, name: &str) -> syn::Result { + let ty = self.param_type(name).ok_or_else(|| { + syn::Error::new( + self.sig.span(), + format!("weaveffi: no source type for parameter `{name}`"), + ) + })?; + callback_dyn(ty) + } + + /// The producer's return type with `Result` peeled. + fn ret_syn(&self) -> Option<&'a syn::Type> { + match &self.sig.output { + syn::ReturnType::Default => None, + syn::ReturnType::Type(_, ty) => Some(weaveffi_bridge::peel_result(ty)), + } + } + + /// The producer's return type with `Result` peeled, spelled for a thunk. + pub(crate) fn ret_type(&self) -> Option { + self.ret_syn().map(|t| self.spell(t)) + } + + /// The producer's spelling of the object type behind an interface return + /// (`Result`, `Option`, and `Arc` peeled). + pub(crate) fn ret_object(&self) -> Option { + self.ret_syn() + .map(peel_wrappers) + .filter(|t| matches!(t, syn::Type::Path(_))) + .map(|t| self.spell(t)) + } + + /// The object type behind the element of an `Iter` return, when `X` is + /// an object (`Arc` or `Option>`). + pub(crate) fn iter_elem_object(&self) -> Option { + let elem = peel_wrappers(self.iter_elem()?); + matches!(elem, syn::Type::Path(_)).then(|| self.spell(elem)) + } + + /// The element type `X` of an `Iter` return. + fn iter_elem(&self) -> Option<&'a syn::Type> { + let syn::Type::Path(p) = self.ret_syn()? else { return None; }; - let syn::Pat::Ident(id) = pt.pat.as_ref() else { + let seg = p.path.segments.last()?; + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return None; }; - (id.ident == name).then(|| pt.ty.as_ref()) - }) + match args.args.first()? { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + } + } + + /// Whether the method receiver is `self: Arc` rather than `&self`. + pub(crate) fn receiver_is_arc(&self) -> bool { + self.sig + .receiver() + .is_some_and(|r| r.reference.is_none() && r.colon_token.is_some()) + } } -/// Render one ABI slot, preferring the producer's own type for a typed handle. -/// -/// A `handle` (`*mut T`/`*const T`) crosses the thunk untouched, so the slot -/// is spelled exactly as the producer wrote it. That keeps the pointee path the -/// producer chose (importantly `super::T` for a handle owned by a parent -/// module) in scope where the thunk is emitted, which a synthesized bare `T` -/// would not be when the thunk lands inside a nested submodule. -/// -/// The implicit method receiver slot is named `self` at the C level, which is -/// not a legal Rust parameter name, so it renders as `__wv_self`. -pub(crate) fn slot_tokens_for(p: &AbiParam, f: &FnBinding, sig: &syn::Signature) -> TokenStream { - if p.name == "self" { - let t = ctype_to_rust(&p.ty); - return quote!(__wv_self: #t); +/// Replace every `Self` identifier token in `tokens` with `self_ty`. +fn replace_self(tokens: TokenStream, self_ty: &Ident) -> TokenStream { + tokens + .into_iter() + .map(|tt| match tt { + proc_macro2::TokenTree::Ident(id) if id == "Self" => { + proc_macro2::TokenTree::Ident(Ident::new(&self_ty.to_string(), id.span())) + } + proc_macro2::TokenTree::Group(g) => { + let inner = replace_self(g.stream(), self_ty); + let mut out = proc_macro2::Group::new(g.delimiter(), inner); + out.set_span(g.span()); + proc_macro2::TokenTree::Group(out) + } + other => other, + }) + .collect() +} + +/// Peel `&`, `Option<..>`, `Arc<..>`, and `Result<..>` down to the innermost +/// type. +fn peel_wrappers(mut ty: &syn::Type) -> &syn::Type { + loop { + match ty { + syn::Type::Reference(r) => ty = &r.elem, + syn::Type::Paren(p) => ty = &p.elem, + syn::Type::Path(p) => { + let Some(seg) = p.path.segments.last() else { + return ty; + }; + if !matches!(seg.ident.to_string().as_str(), "Option" | "Arc" | "Result") { + return ty; + } + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { + return ty; + }; + match args.args.first() { + Some(syn::GenericArgument::Type(inner)) => ty = inner, + _ => return ty, + } + } + _ => return ty, + } } - if let Some(ty) = typed_handle_user_type(p, f, sig) { - let n = ident(&p.name); - quote!(#n: #ty) - } else { - slot_tokens(p) +} + +/// Whether `ty` (under `&` and `Option`) is an `Arc<..>`. +fn mentions_arc(mut ty: &syn::Type) -> bool { + loop { + match ty { + syn::Type::Reference(r) => ty = &r.elem, + syn::Type::Paren(p) => ty = &p.elem, + syn::Type::Path(p) => { + let Some(seg) = p.path.segments.last() else { + return false; + }; + match seg.ident.to_string().as_str() { + "Arc" => return true, + "Option" => { + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { + return false; + }; + match args.args.first() { + Some(syn::GenericArgument::Type(inner)) => ty = inner, + _ => return false, + } + } + _ => return false, + } + } + _ => return false, + } } } -/// Render the slot list for a function signature, honoring producer-written -/// typed-handle types (see [`slot_tokens_for`]). -pub(crate) fn fn_slots( - params: &[AbiParam], - f: &FnBinding, - sig: &syn::Signature, -) -> Vec { - params.iter().map(|p| slot_tokens_for(p, f, sig)).collect() +/// Extract the bare `dyn Trait` from an `Arc` (or `&Arc`) +/// spelling. +fn callback_dyn(ty: &syn::Type) -> syn::Result { + let inner = peel_wrappers(ty); + let syn::Type::TraitObject(obj) = inner else { + return Err(syn::Error::new_spanned( + ty, + "weaveffi: a callback interface parameter must be spelled `Arc`", + )); + }; + let mut traits = obj + .bounds + .iter() + .filter(|b| matches!(b, syn::TypeParamBound::Trait(_))); + let (Some(first), None) = (traits.next(), traits.next()) else { + return Err(syn::Error::new_spanned( + ty, + "weaveffi: spell a callback interface as `Arc` with exactly one trait; \ + declare `Send + Sync` as supertraits of the trait rather than as bounds here", + )); + }; + if obj.bounds.len() != 1 { + return Err(syn::Error::new_spanned( + ty, + "weaveffi: spell a callback interface as `Arc` without extra bounds; \ + declare `Send + Sync` as supertraits of the trait instead", + )); + } + Ok(quote!(dyn #first)) } -/// The producer's source type for an ABI slot that lowers a `handle` -/// parameter, or `None` when the slot is not a typed handle. -fn typed_handle_user_type<'a>( +// ── slot spelling that honors the producer's types ─────────────────────── + +/// Render one ABI slot as `name: ty`, spelling object and callback-interface +/// pointers with the producer's own types. +/// +/// An object slot lowered from `params` renders as `*const ` +/// so a `super::T` stays in scope where the thunk lands; a callback vtable +/// slot renders as `*const ::Vtable`, which +/// resolves through the impl the trait's own module emitted. Every other slot +/// renders from its C type. The implicit method receiver slot is named `self` +/// at the C level, which is not a legal Rust parameter name, so it renders as +/// `__wv_self`. +pub(crate) fn slot_type_for( p: &AbiParam, - f: &FnBinding, - sig: &'a syn::Signature, -) -> Option<&'a syn::Type> { - let pb = f.params.iter().find(|pb| pb.name == p.name)?; - if !matches!(pb.ty, Ty::TypedHandle(_)) { - return None; + params: &[ParamBinding], + user: &UserSig<'_>, +) -> syn::Result { + let n = if p.name == "self" { + ident("__wv_self") + } else { + ident(&p.name) + }; + for pb in params { + if pb.ty.interface_name().is_some() && pb.name == p.name { + if let Some(obj) = user.param_object(&pb.name) { + // Borrowed top-level parameters are `const T*`; a callback + // method's object slot transfers ownership and is `T*`. + let owned = matches!( + &p.ty, + CType::Ptr { + konst: ConstPos::None, + .. + } + ); + return Ok(if owned { + quote!(#n: *mut #obj) + } else { + quote!(#n: *const #obj) + }); + } + } + if pb.ty.callback_interface_name().is_some() && p.name == format!("{}_vtable", pb.name) { + let dyn_ty = user.param_callback(&pb.name)?; + return Ok(quote!( + #n: *const <#dyn_ty as ::weaveffi::abi::CallbackInterface>::Vtable + )); + } } - user_param_type(sig, &p.name) + let t = ctype_to_rust(&p.ty); + Ok(quote!(#n: #t)) +} + +/// Render the slot list for a lowered signature (see [`slot_type_for`]). +pub(crate) fn fn_slots( + abi_params: &[AbiParam], + params: &[ParamBinding], + user: &UserSig<'_>, +) -> syn::Result> { + abi_params + .iter() + .map(|p| slot_type_for(p, params, user)) + .collect() } -/// Render the `-> T` return clause, preferring the producer's own type for a -/// typed-handle return (so a parent-module `super::T` stays in scope). -pub(crate) fn ret_arrow_for(ret: &CType, f: &FnBinding, sig: &syn::Signature) -> TokenStream { - if matches!(f.ret, Some(Ty::TypedHandle(_))) { - if let syn::ReturnType::Type(_, ty) = &sig.output { - let ty = weaveffi_bridge::peel_result(ty); - return quote!(-> #ty); +/// Render the `-> T` return clause, spelling an object return with the +/// producer's own type. +pub(crate) fn ret_arrow_for(ret: &CType, ret_ty: Option<&Ty>, user: &UserSig<'_>) -> TokenStream { + if ret_ty.is_some_and(|t| t.interface_name().is_some()) { + if let Some(obj) = user.ret_object() { + return quote!(-> *mut #obj); } } ret_arrow(ret) } -/// Whether a parameter's source type is a shared reference (`&T`), so the call -/// passes a borrow rather than a clone. `&str`/`&[u8]`/`&mut T` are handled by -/// the type-specific lift, not here. -pub(crate) fn param_is_ref(sig: &syn::Signature, name: &str) -> bool { - sig.inputs.iter().any(|arg| { - let syn::FnArg::Typed(pt) = arg else { - return false; - }; - let syn::Pat::Ident(id) = pt.pat.as_ref() else { - return false; - }; - id.ident == name - && matches!(pt.ty.as_ref(), syn::Type::Reference(r) if r.mutability.is_none()) - }) -} +// ── call targets ───────────────────────────────────────────────────────── /// How a generated thunk invokes the producer's code: a free function in the /// module, an associated function on a type (constructor or static), or an @@ -218,11 +452,20 @@ pub(crate) enum CallTarget { Free, /// `Type::name(args...)`. Static(Ident), - /// `__wv_obj.name(args...)` where `__wv_obj` is the dereferenced receiver. + /// `__wv_obj.name(args...)` where `__wv_obj` is the lifted receiver (a + /// `&T` or an `Arc`; method-call syntax derefs either). Method(Ident), } impl CallTarget { + /// The interface type `Self` refers to in the producer's signature, if any. + pub(crate) fn self_ty(&self) -> Option<&Ident> { + match self { + CallTarget::Free => None, + CallTarget::Static(ty) | CallTarget::Method(ty) => Some(ty), + } + } + /// Build the call expression for this target. pub(crate) fn call(&self, fn_name: &str, args: &[TokenStream]) -> TokenStream { let f = ident(fn_name); @@ -233,13 +476,23 @@ impl CallTarget { } } - /// The receiver-lift preamble for a method (null-check, report through - /// `out_err`, dereference into `__wv_obj`); empty for free functions and + /// The receiver-lift preamble for a synchronous method: null-check, report + /// through `out_err`, and bind `__wv_obj` as a borrow (`&self`) or a + /// retained reference (`self: Arc`). Empty for free functions and /// statics. - pub(crate) fn self_preamble(&self, sentinel: &TokenStream) -> TokenStream { - match self { - CallTarget::Method(ty) => quote! { - if __wv_self.is_null() { + pub(crate) fn self_preamble(&self, sentinel: &TokenStream, as_arc: bool) -> TokenStream { + let CallTarget::Method(ty) = self else { + return TokenStream::new(); + }; + let lift = if as_arc { + quote!(unsafe { ::weaveffi::abi::object_arc::<#ty>(__wv_self) }) + } else { + quote!(unsafe { ::weaveffi::abi::object_ref::<#ty>(__wv_self) }) + }; + quote! { + let __wv_obj = match #lift { + ::std::option::Option::Some(__wv_o) => __wv_o, + ::std::option::Option::None => { ::weaveffi::abi::error_set( out_err, ::weaveffi::abi::MARSHAL_ERROR_CODE, @@ -247,19 +500,23 @@ impl CallTarget { ); return #sentinel; } - let __wv_obj: &#ty = unsafe { &*__wv_self }; - }, - _ => TokenStream::new(), + }; } } } /// Wrap a thunk body in `catch_unwind` so a producer panic is reported through -/// `out_err` (with the reserved panic code) instead of unwinding across the C +/// `out_err` (with the reserved panic code, or the foreign code when the +/// payload is a consumer callback's failure) instead of unwinding across the C /// boundary and aborting the process. On a non-throwing function this is the /// only way `out_err` can report failure, which consumers interpret per /// [`weaveffi_core::plan::ErrorStrategy::Trap`]. `sentinel` is the value the /// thunk returns on the panic path; pass `None` for a void thunk. +/// +/// The success arm also drains `take_foreign_error`: on a `panic = "abort"` +/// build a consumer callback failure can't unwind, so `check_foreign_error` +/// records it and the producer runs to completion; the thunk then reports the +/// recorded failure in place of the result. pub(crate) fn wrap_unwind(body: TokenStream, sentinel: Option<&TokenStream>) -> TokenStream { let tail = match sentinel { Some(s) => quote!(#s), @@ -269,7 +526,13 @@ pub(crate) fn wrap_unwind(body: TokenStream, sentinel: Option<&TokenStream>) -> match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(move || { #body })) { - ::std::result::Result::Ok(__wv_v) => __wv_v, + ::std::result::Result::Ok(__wv_v) => match ::weaveffi::abi::take_foreign_error() { + ::std::option::Option::None => __wv_v, + ::std::option::Option::Some(__wv_foreign) => { + ::weaveffi::abi::error_set(out_err, __wv_foreign.code, &__wv_foreign.message); + #tail + } + }, ::std::result::Result::Err(__wv_panic) => { ::weaveffi::abi::error_set_panic(out_err, &*__wv_panic); #tail @@ -277,47 +540,3 @@ pub(crate) fn wrap_unwind(body: TokenStream, sentinel: Option<&TokenStream>) -> } } } - -/// Spell an IR type as the Rust type a producer uses, for the cases the macro -/// needs to name a generic argument (currently the element of `iter`). The -/// map flavor is not recorded by the IR, so it defaults to `HashMap`. -pub(crate) fn typeref_to_rust(ty: &Ty) -> syn::Result { - Ok(match ty { - Ty::I8 => quote!(i8), - Ty::I16 => quote!(i16), - Ty::I32 => quote!(i32), - Ty::I64 => quote!(i64), - Ty::U8 => quote!(u8), - Ty::U16 => quote!(u16), - Ty::U32 => quote!(u32), - Ty::U64 => quote!(u64), - Ty::F32 => quote!(f32), - Ty::F64 => quote!(f64), - Ty::Bool => quote!(bool), - Ty::Handle => quote!(u64), - Ty::StringUtf8 | Ty::BorrowedStr => quote!(::std::string::String), - Ty::Bytes | Ty::BorrowedBytes => quote!(::std::vec::Vec), - Ty::Record(s) | Ty::RichEnum(s) | Ty::Enum(s) | Ty::Interface(s) => { - let ty = rust_type_ident(s); - quote!(#ty) - } - Ty::TypedHandle(n) => { - let ty = rust_type_ident(n); - quote!(*mut #ty) - } - Ty::Optional(inner) => { - let inner = typeref_to_rust(inner)?; - quote!(::std::option::Option<#inner>) - } - Ty::List(inner) => { - let inner = typeref_to_rust(inner)?; - quote!(::std::vec::Vec<#inner>) - } - Ty::Map(k, v) => { - let k = typeref_to_rust(k)?; - let v = typeref_to_rust(v)?; - quote!(::std::collections::HashMap<#k, #v>) - } - Ty::Iterator(_) => return Err(unsupported("type", "nested iterator")), - }) -} diff --git a/crates/weaveffi-macros/src/codegen/interfaces.rs b/crates/weaveffi-macros/src/codegen/interfaces.rs index 1b0da790..63068547 100644 --- a/crates/weaveffi-macros/src/codegen/interfaces.rs +++ b/crates/weaveffi-macros/src/codegen/interfaces.rs @@ -1,7 +1,11 @@ -//! Thunk emission for interfaces: the `_destroy` symbol. +//! Thunk emission for interfaces: the `_clone` / `_destroy` reference-count +//! symbols and the thread-safety assertion. //! //! Interface members (constructors, methods, statics) share the callable -//! dispatch in [`super::sync`]; only the destructor is interface-specific. +//! dispatch in [`super::sync`]; only the object lifecycle is +//! interface-specific. An interface object is an `Arc` handed out as a raw +//! pointer (see `weaveffi_abi::object`), so `_clone` bumps the count and +//! `_destroy` releases one reference. use proc_macro2::TokenStream; use quote::quote; @@ -9,23 +13,43 @@ use weaveffi_core::model::InterfaceBinding; use super::helpers::ident; -/// Generate `{c_tag}_destroy`: release the boxed interface object. A panicking -/// user `Drop` is swallowed (there is no `out_err` slot to report through, and -/// a destructor must not take down the process). This is the release call an -/// interface return's `ReturnFree::OwnedObject` plan (see -/// `weaveffi_core::plan`) obliges the consumer to make exactly once. -pub(crate) fn gen_interface_destroy(i: &InterfaceBinding) -> TokenStream { - let sym = ident(&i.destroy_symbol); +/// Generate the lifecycle surface for one interface: +/// +/// * a compile-time `Send + Sync` assertion, because the consumer may call +/// methods and release references from any thread, and async methods hold +/// an `Arc` across a spawn; +/// * `{c_tag}_clone`, which returns a new strong reference to the same object +/// (the plan's `RetPass::Object::clone_symbol`); +/// * `{c_tag}_destroy`, which releases one strong reference; the object drops +/// with the last one. A panicking user `Drop` is swallowed (there is no +/// `out_err` slot to report through, and a destructor must not take down the +/// process). +pub(crate) fn gen_interface_lifecycle(i: &InterfaceBinding) -> TokenStream { + let clone_sym = ident(&i.clone_symbol); + let destroy_sym = ident(&i.destroy_symbol); let ty = ident(&i.name); + let assert_msg = format!( + "weaveffi: interface `{}` must be Send + Sync because consumers may use it from any \ + thread; wrap interior state in Mutex/RwLock/atomics", + i.name + ); quote! { + const _: () = { + #[doc = #assert_msg] + const fn __wv_assert_send_sync() {} + __wv_assert_send_sync::<#ty>(); + }; + + #[no_mangle] + #[allow(unsafe_code, clippy::not_unsafe_ptr_arg_deref)] + pub extern "C" fn #clone_sym(ptr: *const #ty) -> *mut #ty { + unsafe { ::weaveffi::abi::object_clone(ptr) } + } + #[no_mangle] #[allow(unsafe_code, clippy::not_unsafe_ptr_arg_deref)] - pub extern "C" fn #sym(ptr: *mut #ty) { - if !ptr.is_null() { - let _ = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - unsafe { drop(::std::boxed::Box::from_raw(ptr)) } - })); - } + pub extern "C" fn #destroy_sym(ptr: *mut #ty) { + unsafe { ::weaveffi::abi::object_destroy(ptr) } } } } diff --git a/crates/weaveffi-macros/src/codegen/iterators.rs b/crates/weaveffi-macros/src/codegen/iterators.rs index 4b557f41..c4de5e72 100644 --- a/crates/weaveffi-macros/src/codegen/iterators.rs +++ b/crates/weaveffi-macros/src/codegen/iterators.rs @@ -3,8 +3,8 @@ //! //! This is the producer half of the pull contract stated by //! [`weaveffi_core::plan::IteratorProtocol`]: each `_next` call yields exactly -//! one element the consumer then owns (and frees per the protocol's -//! `elem_free`), and `_destroy` releases the handle exactly once. Errors from +//! one element the consumer then owns (and releases per the protocol's +//! `elem` pass), and `_destroy` releases the handle exactly once. Errors from //! the launcher and from each `_next` follow the owning function's //! [`ErrorStrategy`](weaveffi_core::plan::ErrorStrategy). @@ -12,11 +12,10 @@ use proc_macro2::TokenStream; use quote::quote; use weaveffi_core::model::{FnBinding, IteratorBinding}; -use super::helpers::{ - fn_slots, ident, param_is_ref, slot_tokens, typeref_to_rust, wrap_unwind, CallTarget, -}; +use super::helpers::{fn_slots, ident, slot_tokens, wrap_unwind, CallTarget, UserSig}; use super::marshal::{lift_param, lower_value}; use super::sync::throws; +use super::unsupported; /// Generate the launcher / `_next` / `_destroy` trio for a function returning /// `iter`. The producer returns a `weaveffi::Iter` (optionally wrapped in @@ -26,23 +25,33 @@ use super::sync::throws; pub(crate) fn gen_iterator_function( f: &FnBinding, it: &IteratorBinding, - sig: &syn::Signature, + user: &UserSig<'_>, target: &CallTarget, ) -> syn::Result { - let elem_rust = typeref_to_rust(&it.elem)?; - let iter_rust = quote!(::weaveffi::Iter<#elem_rust>); + // The handle type is the producer's own `Iter` spelling (with `Result` + // peeled), so the element type, including its map flavor and any + // `super::` path, is exactly what the producer's iterator yields. + let iter_rust = user + .ret_type() + .ok_or_else(|| unsupported(&f.name, "iterator return without a source type"))?; + // The object pointee for an element that is an interface (`Arc` or + // `Option>`), spelled the producer's way. + let elem_object: Option = if it.elem.interface_name().is_some() { + user.iter_elem_object() + } else { + None + }; // ── launcher: lift inputs, run the user fn, box the iterator ── let launch_sym = ident(&it.launch.symbol); - let launch_params: Vec = fn_slots(&it.launch.params, f, sig); + let launch_params = fn_slots(&it.launch.params, &f.params, user)?; let launch_sentinel = quote!(::std::ptr::null_mut()); - let self_pre = target.self_preamble(&launch_sentinel); + let self_pre = target.self_preamble(&launch_sentinel, user.receiver_is_arc()); let mut preamble = TokenStream::new(); let mut call_args: Vec = Vec::new(); for pb in &f.params { - let is_ref = param_is_ref(sig, &pb.name); - let (pre, arg) = lift_param(pb, is_ref, &launch_sentinel)?; + let (pre, arg) = lift_param(pb, user, &launch_sentinel)?; preamble.extend(pre); call_args.push(arg); } @@ -52,10 +61,11 @@ pub(crate) fn gen_iterator_function( let __wv_iter = match #call { ::std::result::Result::Ok(__v) => __v, ::std::result::Result::Err(__wv_err) => { - ::weaveffi::abi::error_set( + ::weaveffi::abi::error_set_with_payload( out_err, ::weaveffi::abi::ErrorReport::code(&__wv_err), &::weaveffi::abi::ErrorReport::message(&__wv_err), + ::weaveffi::abi::ErrorReport::payload(&__wv_err), ); return ::std::ptr::null_mut(); } @@ -86,9 +96,16 @@ pub(crate) fn gen_iterator_function( let next_sym = ident(&it.next.symbol); // The first slot is the opaque `iter` handle (spelled with the real Rust // type); the rest (`out_item`, any item out-params, `out_err`) lower - // straight from the model. - let rest_params: Vec = it.next.params[1..].iter().map(slot_tokens).collect(); - let item_lowered = lower_value(&it.elem, quote!(__wv_item))?; + // straight from the model, except an object `out_item`, which uses the + // producer's pointee spelling. + let rest_params: Vec = it.next.params[1..] + .iter() + .map(|p| match (&elem_object, p.name.as_str()) { + (Some(obj), "out_item") => quote!(out_item: *mut *mut #obj), + _ => slot_tokens(p), + }) + .collect(); + let item_lowered = lower_value(&it.elem, quote!(__wv_item), elem_object.as_ref())?; let next_sentinel = quote!(0); let next_body = wrap_unwind( quote! { @@ -125,8 +142,10 @@ pub(crate) fn gen_iterator_function( }; // ── destroy: drop the box; a panicking user `Drop` is swallowed (there is - // no `out_err` slot to report through, and a destructor must not abort). - // The consumer calls this exactly once, per the iterator protocol's + // no `out_err` slot to report through, and a destructor must not abort), + // and so is a foreign failure a `Drop` deferred by calling back into the + // consumer, which must not leak into the next unrelated thunk. The + // consumer calls this exactly once, per the iterator protocol's // handle-lifecycle clause. ── let destroy_sym = ident(&it.destroy_symbol); let destroy = quote! { @@ -137,6 +156,7 @@ pub(crate) fn gen_iterator_function( let _ = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { unsafe { drop(::std::boxed::Box::from_raw(iter)) } })); + let _ = ::weaveffi::abi::take_foreign_error(); } } }; diff --git a/crates/weaveffi-macros/src/codegen/marshal.rs b/crates/weaveffi-macros/src/codegen/marshal.rs index 957a5689..e43edd15 100644 --- a/crates/weaveffi-macros/src/codegen/marshal.rs +++ b/crates/weaveffi-macros/src/codegen/marshal.rs @@ -6,20 +6,22 @@ //! borrowed `(ptr, len)` value-buffer pair and are decoded through //! [`weaveffi_abi::decode_value`]; buffered returns are encoded with //! [`weaveffi_abi::encode_value`] and handed to the consumer as a -//! producer-allocated buffer it frees with `{prefix}_free_bytes`. The -//! remaining ownership rules here are the producer half of the contract -//! stated by [`weaveffi_core::plan::return_free`]: every string lowered here -//! is freed by the consumer with `{prefix}_free_string`, every buffer with -//! `{prefix}_free_bytes`, and every owned interface pointer with the type's -//! `_destroy` symbol. +//! producer-allocated buffer it frees with `{prefix}_free_bytes`. Interface +//! objects are reference counted: a parameter is borrowed for the call +//! ([`weaveffi_abi::object_ref`]) or retained ([`weaveffi_abi::object_arc`]) +//! depending on whether the producer wrote `&T` or `Arc`, and a return +//! hands the consumer one strong reference ([`weaveffi_abi::lower_object`]). +//! A callback interface arrives as `(ctx, vtable)` and is lifted into the +//! `Arc` the producer takes. The remaining ownership rules here are +//! the producer half of the contract stated by +//! [`weaveffi_core::plan::return_free`]. use proc_macro2::TokenStream; -use quote::quote; +use quote::{quote, ToTokens}; use weaveffi_core::abi::CType; -use weaveffi_core::model::Ty; -use weaveffi_core::model::{FieldBinding, ParamBinding}; +use weaveffi_core::model::{FieldBinding, ParamBinding, Ty}; -use super::helpers::{ident, is_copy, rust_type_ident, sentinel}; +use super::helpers::{ident, is_copy, rust_type_ident, sentinel, UserSig}; use super::unsupported; /// The expression that reconstructs a borrowed value-buffer slice from a @@ -27,7 +29,7 @@ use super::unsupported; /// which decodes only for types whose encoding can be zero bytes (none today: /// even an empty list is four length bytes), so a null buffer is reported as /// a decode failure rather than dereferenced. -fn buffer_slice_expr(ptr: &syn::Ident, len: &syn::Ident) -> TokenStream { +pub(crate) fn buffer_slice_expr(ptr: &syn::Ident, len: &syn::Ident) -> TokenStream { quote! { if #ptr.is_null() { &[][..] @@ -37,16 +39,42 @@ fn buffer_slice_expr(ptr: &syn::Ident, len: &syn::Ident) -> TokenStream { } } -/// Generate the lift preamble and the call-argument expression for one param. +/// The `error_set(out_err, MARSHAL_ERROR_CODE, msg); return sentinel;` tail a +/// synchronous thunk uses to reject an invalid input. +fn reject(msg: &str, sentinel: &TokenStream) -> TokenStream { + quote! { + ::weaveffi::abi::error_set( + out_err, + ::weaveffi::abi::MARSHAL_ERROR_CODE, + #msg, + ); + return #sentinel; + } +} + +/// The call argument for a lifted value bound to `name`: lent when the +/// producer wrote `&T`, moved otherwise. Deref coercion turns `&String` into +/// `&str`, `&Vec` into `&[u8]`, and `&Arc` into `&T` at the call. +fn arg_for(name: &syn::Ident, is_ref: bool) -> TokenStream { + if is_ref { + quote!(&#name) + } else { + quote!(#name) + } +} + +/// Generate the lift preamble and the call-argument expression for one +/// parameter of a synchronous thunk. pub(crate) fn lift_param( pb: &ParamBinding, - is_ref: bool, + user: &UserSig<'_>, sentinel: &TokenStream, ) -> syn::Result<(TokenStream, TokenStream)> { let name = ident(&pb.name); - let owned = || quote!(#name); - let by_ref = || quote!(&#name); + let is_ref = user.param_is_ref(&pb.name); + let arg = arg_for(&name, is_ref); let msg = format!("{} is null or invalid", pb.name); + let fail = reject(&msg, sentinel); // A buffered parameter is one `(const uint8_t*, size_t)` pair holding the // value serialized in the WeaveFFI buffer format. Decode it into the @@ -59,139 +87,129 @@ pub(crate) fn lift_param( let ptr = ident(&format!("{}_ptr", pb.name)); let len = ident(&format!("{}_len", pb.name)); let slice = buffer_slice_expr(&ptr, &len); - let decode_msg = format!("{}: malformed value buffer", pb.name); + let decode_fail = reject(&format!("{}: malformed value buffer", pb.name), sentinel); let pre = quote! { let #name = { let __wv_buf: &[u8] = #slice; match ::weaveffi::abi::decode_value(__wv_buf) { ::std::result::Result::Ok(__v) => __v, - ::std::result::Result::Err(_) => { - ::weaveffi::abi::error_set( - out_err, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - #decode_msg, - ); - return #sentinel; - } + ::std::result::Result::Err(_) => { #decode_fail } } }; }; - let arg = if is_ref { by_ref() } else { owned() }; return Ok((pre, arg)); } Ok(match &pb.ty { - ty if is_copy(ty) && !matches!(ty, Ty::Enum(_)) => (TokenStream::new(), owned()), Ty::Enum(enum_name) => { - let et = rust_type_ident(enum_name); + // Prefer the producer's path (it may be `super::Kind`). + let et = user + .param_object(&pb.name) + .unwrap_or_else(|| rust_type_ident(enum_name).into_token_stream()); let pre = quote! { - let #name = match #et::__weaveffi_from_i32(#name) { + let #name = match <#et>::__weaveffi_from_i32(#name) { ::std::option::Option::Some(__v) => __v, - ::std::option::Option::None => { - ::weaveffi::abi::error_set( - out_err, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - #msg, - ); - return #sentinel; - } + ::std::option::Option::None => { #fail } }; }; - (pre, owned()) + (pre, arg) } + ty if is_copy(ty) => (TokenStream::new(), arg), Ty::StringUtf8 => { let pre = quote! { let #name = match ::weaveffi::abi::c_ptr_to_string(#name) { ::std::option::Option::Some(__s) => __s, - ::std::option::Option::None => { - ::weaveffi::abi::error_set( - out_err, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - #msg, - ); - return #sentinel; - } + ::std::option::Option::None => { #fail } }; }; - (pre, owned()) - } - // A borrowed string is lifted zero-copy: the thunk borrows the - // caller's NUL-terminated buffer for the duration of the call, which - // is the whole point of a producer taking `&str` over `String`. - Ty::BorrowedStr => { - let pre = quote! { - // SAFETY: the C contract guarantees the argument is null or a - // NUL-terminated string valid for the duration of the call. - let #name = match unsafe { ::weaveffi::abi::c_ptr_to_str(#name) } { - ::std::option::Option::Some(__s) => __s, - ::std::option::Option::None => { - ::weaveffi::abi::error_set( - out_err, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - #msg, - ); - return #sentinel; - } - }; - }; - (pre, owned()) + (pre, arg) } Ty::Bytes => { let ptr = ident(&format!("{}_ptr", pb.name)); let len = ident(&format!("{}_len", pb.name)); ( quote!(let #name = unsafe { ::weaveffi::abi::lift_bytes(#ptr, #len) };), - owned(), + arg, ) } - Ty::BorrowedBytes => { - let ptr = ident(&format!("{}_ptr", pb.name)); - let len = ident(&format!("{}_len", pb.name)); - ( - quote!(let #name = unsafe { ::weaveffi::abi::lift_byte_slice(#ptr, #len) };), - owned(), - ) - } - // An interface parameter borrows the caller-owned object for the call; - // the slot is a `const {tag}*`, so the producer must accept `&T`. + // An object parameter is borrowed for the call. The producer's own + // spelling decides how it is lifted: `&T` borrows through the pointer + // without touching the count; `Arc` takes a new strong reference + // so the producer may keep the object. Ty::Interface(_) => { - if !is_ref { + let wants_arc = user.param_wants_arc(&pb.name); + if !wants_arc && !is_ref { return Err(unsupported( &pb.name, - "by-value interface parameter (accept `&T` instead: the caller keeps \ - ownership of the object)", + "by-value interface parameter (accept `&T` to borrow the object for the \ + call, or `Arc` to retain it)", )); } + let lift = if wants_arc { + quote!(::weaveffi::abi::object_arc(#name)) + } else { + quote!(::weaveffi::abi::object_ref(#name)) + }; let pre = quote! { - if #name.is_null() { - ::weaveffi::abi::error_set( - out_err, - ::weaveffi::abi::MARSHAL_ERROR_CODE, - #msg, - ); - return #sentinel; - } - let #name = unsafe { &*#name }; + let #name = match unsafe { #lift } { + ::std::option::Option::Some(__wv_o) => __wv_o, + ::std::option::Option::None => { #fail } + }; + }; + // A borrowed lift already *is* the `&T` the producer takes. + let arg = if wants_arc { arg } else { quote!(#name) }; + (pre, arg) + } + // `Interface?` is the one optional that is not buffered: a nullable + // pointer, lifted to `Option<&T>` or `Option>`. + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { + let wants_arc = user.param_wants_arc(&pb.name); + let lift = if wants_arc { + quote!(::weaveffi::abi::object_arc(#name)) + } else { + quote!(::weaveffi::abi::object_ref(#name)) + }; + (quote!(let #name = unsafe { #lift };), arg) + } + // A callback interface is `(ctx, vtable)`; a null vtable is a contract + // violation. The `dyn Trait` comes from the producer's `Arc` + // spelling and resolves the vtable type through `CallbackInterface`. + Ty::CallbackInterface(_) => { + let dyn_ty = user.param_callback(&pb.name)?; + let ctx = ident(&format!("{}_ctx", pb.name)); + let vtable = ident(&format!("{}_vtable", pb.name)); + let null_msg = format!("{}: null callback vtable", pb.name); + let fail = reject(&null_msg, sentinel); + let pre = quote! { + let #name = match unsafe { + ::weaveffi::abi::lift_callback::<#dyn_ty>(#ctx, #vtable) + } { + ::std::option::Option::Some(__wv_cb) => __wv_cb, + ::std::option::Option::None => { #fail } + }; }; - (pre, owned()) + (pre, arg) } - Ty::TypedHandle(_) => (TokenStream::new(), owned()), - // Only `Interface?` is optional and unbuffered; the macro does not - // marshal it yet. - Ty::Optional(_) => return Err(unsupported(&pb.name, "optional interface parameter")), Ty::Iterator(_) => return Err(unsupported(&pb.name, "iterator parameter")), _ => return Err(unsupported(&pb.name, "parameter type")), }) } /// Lower an owned Rust `value` of IR type `ty` into its C return expression. -/// `out_len` names the trailing length slot for the buffer-returning shapes. +/// `out_len` names the trailing length slot for the buffer-returning shapes; +/// `object` is the producer's spelling of the pointee for an object return +/// (see [`UserSig::ret_object`]), which pins the `Arc` the value converts +/// into. /// /// Every heap-owning lowering here creates the consumer obligation stated by /// [`weaveffi_core::plan::return_free`]: strings are released with /// `{prefix}_free_string`, byte and value buffers with `{prefix}_free_bytes`, -/// and owned interface pointers with the type's `_destroy` symbol. -pub(crate) fn lower_value(ty: &Ty, value: TokenStream) -> syn::Result { +/// and object references with the type's `_destroy` symbol. +pub(crate) fn lower_value( + ty: &Ty, + value: TokenStream, + object: Option<&TokenStream>, +) -> syn::Result { // A buffered return is encoded into a producer-allocated value buffer and // returned exactly like a bytes return: base pointer plus `*out_len`. if ty.is_buffered() { @@ -204,25 +222,24 @@ pub(crate) fn lower_value(ty: &Ty, value: TokenStream) -> syn::Result` + // return converts into the right `Arc` without a turbofish. + let typed = |call: TokenStream| match object { + Some(obj) => quote!({ let __wv_p: *mut #obj = #call; __wv_p }), + None => call, + }; Ok(match ty { - t if is_copy(t) && !matches!(t, Ty::Enum(_)) => value, - Ty::Enum(_) => quote!((#value) as i32), - Ty::StringUtf8 | Ty::BorrowedStr => { - quote!(::weaveffi::abi::string_to_c_ptr(&(#value))) + Ty::Enum(_) => quote!((#value).__weaveffi_to_i32()), + t if is_copy(t) => value, + Ty::StringUtf8 => quote!(::weaveffi::abi::string_to_c_ptr(&(#value))), + Ty::Bytes => quote!(unsafe { ::weaveffi::abi::lower_bytes(#value, out_len) }), + // A returned object hands the consumer one strong reference, which it + // releases with the type's `_destroy` symbol (`RetPass::Object` in the + // plan). The producer may return `Self`/`T` or `Arc`/`Arc`. + Ty::Interface(_) => typed(quote!(::weaveffi::abi::lower_object(#value))), + Ty::Optional(inner) if matches!(inner.as_ref(), Ty::Interface(_)) => { + typed(quote!(::weaveffi::abi::lower_object_opt(#value))) } - Ty::Bytes | Ty::BorrowedBytes => { - quote!(unsafe { ::weaveffi::abi::lower_bytes(#value, out_len) }) - } - // A returned interface moves to the heap; the caller owns the new - // reference and releases it with the type's `_destroy` symbol - // (`ReturnFree::OwnedObject` in the plan). - Ty::Interface(_) => { - quote!(::std::boxed::Box::into_raw(::std::boxed::Box::new(#value))) - } - Ty::TypedHandle(_) => value, - // Only `Interface?` is optional and unbuffered; the macro does not - // marshal it yet. - Ty::Optional(_) => return Err(unsupported("return", "optional interface return")), Ty::Iterator(_) => return Err(unsupported("return", "iterator return")), _ => return Err(unsupported("return", "return type")), }) @@ -237,14 +254,16 @@ pub(crate) fn lower_value(ty: &Ty, value: TokenStream) -> syn::Result, + ret_ty: Option<&Ty>, ret_ctype: &CType, is_throws: bool, call: TokenStream, + user: &UserSig<'_>, ) -> syn::Result { let sentinel = sentinel(ret_ctype); + let object = user.ret_object(); let lowered = match ret_ty { - Some(ty) => lower_value(ty, quote!(__wv_ret))?, + Some(ty) => lower_value(ty, quote!(__wv_ret), object.as_ref())?, None => quote!(()), }; let ok_arm = if ret_ty.is_some() { @@ -296,13 +315,10 @@ pub(crate) fn build_call_body( /// Every buffer-legal type routes through the [`weaveffi_abi::BufferValue`] /// trait, which the expansion implements for records, rich enums, and C-style /// enums, and the runtime blanket-implements for primitives, `String`, -/// collections, and `Option`, so arbitrary nesting composes. The one -/// exception is a typed handle, whose pointer identity crosses as a `u64`. -pub(crate) fn field_write_stmt(field: &FieldBinding, access: TokenStream) -> TokenStream { - match &field.ty { - Ty::TypedHandle(_) => quote!(__wv_w.write_u64(#access as u64);), - _ => quote!(::weaveffi::abi::BufferValue::write_value(&#access, __wv_w);), - } +/// collections, `Option`, and `Arc` (an object token), so arbitrary +/// nesting composes. +pub(crate) fn field_write_stmt(_field: &FieldBinding, access: TokenStream) -> TokenStream { + quote!(::weaveffi::abi::BufferValue::write_value(&#access, __wv_w);) } /// The expression that decodes one field's value from the reader `__wv_r`, @@ -310,19 +326,13 @@ pub(crate) fn field_write_stmt(field: &FieldBinding, access: TokenStream) -> Tok /// The concrete field type is inferred from the surrounding struct or enum /// constructor, so the producer's own type (including the map flavor) is /// what decoding targets. -pub(crate) fn field_read_expr(field: &FieldBinding) -> TokenStream { - match &field.ty { - Ty::TypedHandle(_) => quote!(__wv_r.read_u64().map(|__v| __v as _)), - _ => quote!(::weaveffi::abi::BufferValue::read_value(__wv_r)), - } +pub(crate) fn field_read_expr(_field: &FieldBinding) -> TokenStream { + quote!(::weaveffi::abi::BufferValue::read_value(__wv_r)) } /// The statement that writes one *borrowed* field binding (`&T`, as produced /// by a match on `&self`) to the writer `__wv_w`. Mirrors /// [`field_write_stmt`] with reference access. -pub(crate) fn field_write_stmt_ref(field: &FieldBinding, binding: &syn::Ident) -> TokenStream { - match &field.ty { - Ty::TypedHandle(_) => quote!(__wv_w.write_u64(*#binding as u64);), - _ => quote!(::weaveffi::abi::BufferValue::write_value(#binding, __wv_w);), - } +pub(crate) fn field_write_stmt_ref(_field: &FieldBinding, binding: &syn::Ident) -> TokenStream { + quote!(::weaveffi::abi::BufferValue::write_value(#binding, __wv_w);) } diff --git a/crates/weaveffi-macros/src/codegen/mod.rs b/crates/weaveffi-macros/src/codegen/mod.rs index e4a69a6a..434e0636 100644 --- a/crates/weaveffi-macros/src/codegen/mod.rs +++ b/crates/weaveffi-macros/src/codegen/mod.rs @@ -11,9 +11,10 @@ //! The emission is split by surface: [`sync`] for synchronous callables, //! [`async_fns`] for `async fn` launchers, [`iterators`] for `iter` trios, //! [`records`] and [`enums`] for the generated `BufferValue` serialization -//! impls of value types, [`interfaces`] for opaque-object destructors, and -//! [`callbacks`] for callback typedefs and listener registries. [`helpers`] -//! and [`marshal`] hold the shared slot rendering and lift/lower machinery. +//! impls of value types, [`interfaces`] for the object reference-count +//! symbols, and [`callbacks`] for callback-interface vtables and foreign +//! wrappers. [`helpers`] and [`marshal`] hold the shared slot rendering and +//! lift/lower machinery. mod async_fns; mod callbacks; @@ -166,6 +167,7 @@ fn render_symbols( ) -> syn::Result { let mut fns: HashMap = HashMap::new(); let mut enums: HashMap = HashMap::new(); + let mut traits: HashMap = HashMap::new(); // Interface member signatures, keyed by `(type name, fn name)` across all // inherent `impl` blocks of the type. let mut member_sigs: HashMap<(String, String), &syn::Signature> = HashMap::new(); @@ -177,6 +179,9 @@ fn render_symbols( syn::Item::Enum(e) => { enums.insert(e.ident.to_string(), e); } + syn::Item::Trait(t) => { + traits.insert(t.ident.to_string(), t); + } syn::Item::Impl(i) if i.trait_.is_none() => { let Some(ty_name) = impl_type_name(i) else { continue; @@ -228,11 +233,11 @@ fn render_symbols( for s in &mb.structs { generated.extend(records::gen_record(s)?); } - for c in &mb.callbacks { - generated.extend(callbacks::gen_callback_type(c)?); - } - for l in &mb.listeners { - generated.extend(callbacks::gen_listener(mb, l)?); + for c in &mb.callback_interfaces { + let item = traits + .get(&c.name) + .ok_or_else(|| missing("callback interface", &c.name))?; + generated.extend(callbacks::gen_callback_interface(c, item)?); } for i in &mb.interfaces { let ty = ident(&i.name); @@ -248,7 +253,7 @@ fn render_symbols( generated.extend(gen_function(m, sig, &target)?); } } - generated.extend(interfaces::gen_interface_destroy(i)); + generated.extend(interfaces::gen_interface_lifecycle(i)); } for f in &mb.functions { let sfn = fns diff --git a/crates/weaveffi-macros/src/codegen/sync.rs b/crates/weaveffi-macros/src/codegen/sync.rs index 0777acf7..4dc0dc6f 100644 --- a/crates/weaveffi-macros/src/codegen/sync.rs +++ b/crates/weaveffi-macros/src/codegen/sync.rs @@ -8,7 +8,7 @@ use weaveffi_core::model::{AbiFn, CallShape, FnBinding}; use weaveffi_core::plan::ErrorStrategy; use super::async_fns::gen_async_function; -use super::helpers::{fn_slots, ident, ret_arrow_for, sentinel, wrap_unwind, CallTarget}; +use super::helpers::{fn_slots, ident, ret_arrow_for, sentinel, wrap_unwind, CallTarget, UserSig}; use super::iterators::gen_iterator_function; use super::marshal::{build_call_body, lift_param}; @@ -27,10 +27,11 @@ pub(crate) fn gen_function( sig: &syn::Signature, target: &CallTarget, ) -> syn::Result { + let user = UserSig::new(sig, target.self_ty()); match &f.shape { - CallShape::Sync(abi) => gen_sync_function(f, abi, sig, target), - CallShape::Iterator(it) => gen_iterator_function(f, it, sig, target), - CallShape::Async(a) => gen_async_function(f, a, sig, target), + CallShape::Sync(abi) => gen_sync_function(f, abi, &user, target), + CallShape::Iterator(it) => gen_iterator_function(f, it, &user, target), + CallShape::Async(a) => gen_async_function(f, a, &user, target), } } @@ -38,27 +39,26 @@ pub(crate) fn gen_function( fn gen_sync_function( f: &FnBinding, abi: &AbiFn, - sig: &syn::Signature, + user: &UserSig<'_>, target: &CallTarget, ) -> syn::Result { let sym = ident(&abi.symbol); - let params: Vec = fn_slots(&abi.params, f, sig); - let arrow = ret_arrow_for(&abi.ret, f, sig); + let params = fn_slots(&abi.params, &f.params, user)?; + let arrow = ret_arrow_for(&abi.ret, f.ret.as_ref(), user); let sentinel = sentinel(&abi.ret); // Lift each parameter, collecting the preambles and the call arguments. - let self_pre = target.self_preamble(&sentinel); + let self_pre = target.self_preamble(&sentinel, user.receiver_is_arc()); let mut preamble = TokenStream::new(); let mut call_args: Vec = Vec::new(); for pb in &f.params { - let is_ref = super::helpers::param_is_ref(sig, &pb.name); - let (pre, arg) = lift_param(pb, is_ref, &sentinel)?; + let (pre, arg) = lift_param(pb, user, &sentinel)?; preamble.extend(pre); call_args.push(arg); } let call = target.call(&f.name, &call_args); - let body = build_call_body(&f.ret, &abi.ret, throws(f), call)?; + let body = build_call_body(f.ret.as_ref(), &abi.ret, throws(f), call, user)?; let is_void = matches!(abi.ret, CType::Void); let wrapped = wrap_unwind( quote! { #self_pre #preamble #body }, diff --git a/crates/weaveffi-macros/src/lib.rs b/crates/weaveffi-macros/src/lib.rs index 3ecd0390..d05896a0 100644 --- a/crates/weaveffi-macros/src/lib.rs +++ b/crates/weaveffi-macros/src/lib.rs @@ -29,13 +29,13 @@ //! * [`macro@module`] marks an exported namespace (the driver attribute). //! * [`macro@export`] exports a function; [`macro@record`] a by-value struct; //! [`macro@enumeration`] a `#[repr(i32)]` C-style enum. -//! * [`macro@interface`] declares an opaque object type whose `impl` block's -//! `pub fn`s become constructors, methods, and statics. +//! * [`macro@interface`] declares an opaque, reference-counted object type +//! whose `impl` block's `pub fn`s become constructors, methods, and statics. //! * [`macro@error`] declares the module's error domain from an enum with //! explicit discriminants; a variant's named fields become the code's //! structured payload. -//! * [`macro@callback`] / [`macro@listener`] declare a callback and an event -//! listener; [`macro@cancellable`] marks an async function as cancellable. +//! * [`macro@callback_interface`] declares a trait the consumer implements; +//! [`macro@cancellable`] marks an async function as cancellable. //! //! The item-level attributes are inert markers that [`macro@module`] reads; on //! their own they expand to the item unchanged. @@ -82,8 +82,9 @@ marker_attr! { record } marker_attr! { - /// Declare an interface: an opaque object type with constructors, methods, - /// and statics read from its `impl` block. Methods must take `&self`. + /// Declare an interface: an opaque, reference-counted object type with + /// constructors, methods, and statics read from its `impl` block. Methods + /// take `&self` or `self: Arc`; the type must be `Send + Sync`. interface } marker_attr! { @@ -97,12 +98,10 @@ marker_attr! { enumeration } marker_attr! { - /// Declare a callback function signature the host implements. - callback -} -marker_attr! { - /// Declare an event listener; takes `event = "CallbackName"`. - listener + /// Declare a callback interface: a trait whose `&self` methods the consumer + /// implements. Producers accept one as `Arc`; declare + /// `Send + Sync` as supertraits when it is used from an `async fn`. + callback_interface } marker_attr! { /// Mark an async function as accepting a cancellation token. diff --git a/crates/weaveffi-macros/tests/ui.rs b/crates/weaveffi-macros/tests/ui.rs new file mode 100644 index 00000000..5cfa1509 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui.rs @@ -0,0 +1,12 @@ +//! Compile-time contract of the `#[weaveffi::module]` family of macros. +//! +//! `ui/pass_*.rs` must expand and compile; `ui/fail_*.rs` must be rejected +//! with the diagnostic pinned in the matching `.stderr` file. Regenerate the +//! expectations with `TRYBUILD=overwrite cargo test -p weaveffi-macros --test ui`. + +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/pass_*.rs"); + t.compile_fail("tests/ui/fail_*.rs"); +} diff --git a/crates/weaveffi-macros/tests/ui/fail_box_param.rs b/crates/weaveffi-macros/tests/ui/fail_box_param.rs new file mode 100644 index 00000000..2c72a59b --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_box_param.rs @@ -0,0 +1,12 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::interface] + pub struct Widget; + + #[weaveffi::export] + pub fn take(w: Box) { + let _ = w; + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_box_param.stderr b/crates/weaveffi-macros/tests/ui/fail_box_param.stderr new file mode 100644 index 00000000..7f79f300 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_box_param.stderr @@ -0,0 +1,5 @@ +error: weaveffi: `Box` cannot cross the FFI boundary; objects and callback interfaces are shared, so spell them as `Arc` / `Arc` + --> tests/ui/fail_box_param.rs:7:20 + | +7 | pub fn take(w: Box) { + | ^^^ diff --git a/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.rs b/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.rs new file mode 100644 index 00000000..3c45b46f --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::callback_interface] + pub trait Listener: Send + Sync { + fn on_event(&mut self, n: i32); + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.stderr b/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.stderr new file mode 100644 index 00000000..27e90fbc --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_callback_mut_self.stderr @@ -0,0 +1,5 @@ +error: weaveffi: callback interface methods must take `&self` + --> tests/ui/fail_callback_mut_self.rs:5:21 + | +5 | fn on_event(&mut self, n: i32); + | ^ diff --git a/crates/weaveffi-macros/tests/ui/fail_callback_string_return.rs b/crates/weaveffi-macros/tests/ui/fail_callback_string_return.rs new file mode 100644 index 00000000..e745ffef --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_callback_string_return.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::callback_interface] + pub trait Namer: Send + Sync { + fn name(&self) -> String; + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_callback_string_return.stderr b/crates/weaveffi-macros/tests/ui/fail_callback_string_return.stderr new file mode 100644 index 00000000..4bf0fedb --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_callback_string_return.stderr @@ -0,0 +1,7 @@ +error: weaveffi: unsupported non-direct return type for `callback return` (not yet implemented by #[weaveffi::module]) + --> tests/ui/fail_callback_string_return.rs:1:1 + | +1 | #[weaveffi::module] + | ^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `weaveffi::module` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.rs b/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.rs new file mode 100644 index 00000000..96ef65d7 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::enumeration] + pub enum Mode { + Fast = 0, + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.stderr b/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.stderr new file mode 100644 index 00000000..99955f36 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_enum_without_repr.stderr @@ -0,0 +1,5 @@ +error: enum `Mode` must have #[repr(i32)] to be a #[weaveffi::enumeration] + --> tests/ui/fail_enum_without_repr.rs:3:5 + | +3 | #[weaveffi::enumeration] + | ^ diff --git a/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.rs b/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.rs new file mode 100644 index 00000000..44e74cae --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.rs @@ -0,0 +1,17 @@ +#[weaveffi::module] +mod bad { + use std::cell::Cell; + + #[weaveffi::interface] + pub struct Counter { + n: Cell, + } + + impl Counter { + pub fn new() -> Self { + Self { n: Cell::new(0) } + } + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.stderr b/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.stderr new file mode 100644 index 00000000..38aaad43 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_interface_not_sync.stderr @@ -0,0 +1,19 @@ +error[E0277]: `Cell` cannot be shared between threads safely + --> tests/ui/fail_interface_not_sync.rs:1:1 + | +1 | #[weaveffi::module] + | ^^^^^^^^^^^^^^^^^^^ `Cell` cannot be shared between threads safely + | + = help: within `Counter`, the trait `Sync` is not implemented for `Cell` + = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead +note: required because it appears within the type `Counter` + --> tests/ui/fail_interface_not_sync.rs:6:16 + | +6 | pub struct Counter { + | ^^^^^^^ +note: required by a bound in `__wv_assert_send_sync` + --> tests/ui/fail_interface_not_sync.rs:1:1 + | +1 | #[weaveffi::module] + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `__wv_assert_send_sync` + = note: this error originates in the attribute macro `weaveffi::module` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.rs b/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.rs new file mode 100644 index 00000000..adf88eda --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.rs @@ -0,0 +1,11 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::interface] + pub struct Widget; + + impl Widget { + pub fn consume(self) {} + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.stderr b/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.stderr new file mode 100644 index 00000000..047afc66 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_interface_self_by_value.stderr @@ -0,0 +1,5 @@ +error: weaveffi: interface methods must take `&self` or `self: Arc`; use interior mutability (Mutex, RwLock, atomics) for mutable state, because the object is shared across the FFI boundary + --> tests/ui/fail_interface_self_by_value.rs:7:24 + | +7 | pub fn consume(self) {} + | ^^^^ diff --git a/crates/weaveffi-macros/tests/ui/fail_iter_param.rs b/crates/weaveffi-macros/tests/ui/fail_iter_param.rs new file mode 100644 index 00000000..325bc28b --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_iter_param.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::export] + pub fn drain(items: weaveffi::Iter) -> i64 { + items.map(i64::from).sum() + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_iter_param.stderr b/crates/weaveffi-macros/tests/ui/fail_iter_param.stderr new file mode 100644 index 00000000..b8336163 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_iter_param.stderr @@ -0,0 +1,5 @@ +error: weaveffi: `Iter` is a pull handle the consumer drives and is only valid as a function's outermost return type, not as a parameter + --> tests/ui/fail_iter_param.rs:4:25 + | +4 | pub fn drain(items: weaveffi::Iter) -> i64 { + | ^^^^^^^^ diff --git a/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.rs b/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.rs new file mode 100644 index 00000000..daad6db0 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::export] + pub fn fill(text: &mut String) { + text.push('x'); + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.stderr b/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.stderr new file mode 100644 index 00000000..d0bcff80 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_mut_ref_param.stderr @@ -0,0 +1,5 @@ +error: weaveffi: `&mut` parameters cannot cross the FFI boundary; take the value by `&T` or by value and return the updated result + --> tests/ui/fail_mut_ref_param.rs:4:23 + | +4 | pub fn fill(text: &mut String) { + | ^ diff --git a/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.rs b/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.rs new file mode 100644 index 00000000..ed320abe --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.rs @@ -0,0 +1,9 @@ +#[weaveffi::module] +mod bad { + #[weaveffi::export] + pub fn maybe_stream(on: bool) -> Option> { + on.then(|| weaveffi::Iter::new(std::iter::empty())) + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.stderr b/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.stderr new file mode 100644 index 00000000..88faf542 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_nested_iter_return.stderr @@ -0,0 +1,5 @@ +error: weaveffi: `Iter` is only valid as the outermost return type; it can't be nested in an `Option`, `Vec`, map, or another `Iter` + --> tests/ui/fail_nested_iter_return.rs:4:38 + | +4 | pub fn maybe_stream(on: bool) -> Option> { + | ^^^^^^ diff --git a/crates/weaveffi-macros/tests/ui/fail_raw_pointer.rs b/crates/weaveffi-macros/tests/ui/fail_raw_pointer.rs new file mode 100644 index 00000000..866e4ebe --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_raw_pointer.rs @@ -0,0 +1,11 @@ +#[weaveffi::module] +mod bad { + pub struct Token; + + #[weaveffi::export] + pub fn open() -> *mut Token { + std::ptr::null_mut() + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_raw_pointer.stderr b/crates/weaveffi-macros/tests/ui/fail_raw_pointer.stderr new file mode 100644 index 00000000..3a5754c9 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_raw_pointer.stderr @@ -0,0 +1,5 @@ +error: weaveffi: raw pointers cannot cross the FFI boundary; declare the pointee as a #[weaveffi::interface] and pass it as `&T` or `Arc` + --> tests/ui/fail_raw_pointer.rs:6:22 + | +6 | pub fn open() -> *mut Token { + | ^ diff --git a/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.rs b/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.rs new file mode 100644 index 00000000..1c434d73 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.rs @@ -0,0 +1,13 @@ +#[weaveffi::module] +mod bad { + pub enum Oops { + Nope, + } + + #[weaveffi::export] + pub fn risky() -> Result { + Err(Oops::Nope) + } +} + +fn main() {} diff --git a/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.stderr b/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.stderr new file mode 100644 index 00000000..13fb6db0 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/fail_result_without_error_domain.stderr @@ -0,0 +1,5 @@ +error: weaveffi: `risky` returns a Result but no error domain is in scope; declare a #[weaveffi::error] enum in this module (or a parent module) + --> tests/ui/fail_result_without_error_domain.rs:2:5 + | +2 | mod bad { + | ^^^ diff --git a/crates/weaveffi-macros/tests/ui/pass_full_surface.rs b/crates/weaveffi-macros/tests/ui/pass_full_surface.rs new file mode 100644 index 00000000..41d2e872 --- /dev/null +++ b/crates/weaveffi-macros/tests/ui/pass_full_surface.rs @@ -0,0 +1,130 @@ +//! A module that touches every construct the macro supports must expand +//! and compile cleanly. +#![deny(unsafe_code)] + +use std::sync::Arc; + +#[weaveffi::module] +mod surface { + use std::sync::Arc; + + #[weaveffi::error] + #[repr(i32)] + pub enum Failure { + /// Not found + NotFound = 1, + /// Payload variant + Detailed { code: i32, note: String } = 2, + } + + #[weaveffi::enumeration] + #[repr(i32)] + pub enum Mode { + Fast = 0, + Safe = 1, + } + + #[weaveffi::record] + pub struct Item { + pub id: i64, + pub name: String, + pub tags: Vec, + pub mode: Mode, + pub owner: Option>, + } + + #[weaveffi::callback_interface] + pub trait Observer: Send + Sync { + fn on_item(&self, item: &Item, widget: Arc); + fn should_continue(&self, n: i32) -> bool; + fn pick(&self) -> Mode; + } + + #[weaveffi::interface] + pub struct Widget { + id: i64, + } + + impl Widget { + pub fn new(id: i64) -> Self { + Self { id } + } + pub fn id(&self) -> i64 { + self.id + } + pub fn me(self: Arc) -> Arc { + self + } + pub fn twin(&self) -> Option> { + None + } + pub fn poke(&self, n: i32) -> Result { + if n < 0 { + Err(Failure::NotFound) + } else { + Ok(n) + } + } + pub async fn later(self: Arc) -> i64 { + self.id + } + pub fn version() -> String { + "1".into() + } + } + + #[weaveffi::export] + pub fn watch(observer: Arc, widget: Arc) -> bool { + observer.on_item( + &Item { + id: 1, + name: "x".into(), + tags: vec![], + mode: observer.pick(), + owner: Some(widget.clone()), + }, + widget, + ); + observer.should_continue(1) + } + + #[weaveffi::export] + pub fn widgets(n: i32) -> Vec> { + (0..n).map(|i| Arc::new(Widget::new(i64::from(i)))).collect() + } + + #[weaveffi::export] + pub fn stream(items: Vec) -> weaveffi::Iter { + weaveffi::Iter::new(items) + } + + #[weaveffi::export] + pub fn stream_widgets() -> weaveffi::Iter> { + weaveffi::Iter::new(vec![Arc::new(Widget::new(7))]) + } + + #[weaveffi::export] + pub async fn fetch(id: i64) -> Result { + Err(Failure::Detailed { + code: 3, + note: format!("{id}"), + }) + } + + #[weaveffi::export] + #[weaveffi::cancellable] + pub async fn slow(bytes: Vec, cancel: weaveffi::CancelToken) -> Vec { + let _ = cancel; + bytes + } + + #[weaveffi::export] + pub fn maybe(text: Option, w: Option>) -> Option> { + let _ = text; + w + } +} + +fn main() { + let _ = Arc::new(surface::Widget::new(1)); +} diff --git a/crates/weaveffi/src/lib.rs b/crates/weaveffi/src/lib.rs index e3b0bac6..a1ec3eff 100644 --- a/crates/weaveffi/src/lib.rs +++ b/crates/weaveffi/src/lib.rs @@ -40,9 +40,16 @@ //! * [`macro@export`] - export a function (`async fn` is asynchronous; a //! `Result`-returning fn is fallible). //! * [`macro@record`] - a by-value struct serialized across the ABI. -//! * [`macro@enumeration`] - a `#[repr(i32)]` C-style enum. -//! * [`macro@callback`] / [`macro@listener`] - a callback and an event listener. +//! * [`macro@enumeration`] - a `#[repr(i32)]` C-style enum, or a rich enum +//! with data-carrying variants. +//! * [`macro@interface`] - an opaque, reference-counted object type; pass one +//! as `&T` or `Arc`, return one as `Self`, `T`, or `Arc`. +//! * [`macro@error`] - the module's error domain. +//! * [`macro@callback_interface`] - a trait the consumer implements; accept +//! one as `Arc`. //! * [`macro@cancellable`] - mark an `async fn` as accepting a cancel token. +//! * [`set_spawner`] - install the executor async exports run on (Tokio, for +//! example); the default drives each future on its own thread. //! * [`abi`] - the C ABI runtime: the error struct, memory helpers, the //! marshalling converters the expansion calls, and [`export_runtime!`]. @@ -78,12 +85,19 @@ pub use weaveffi_abi::CancelToken; /// domain. pub use weaveffi_abi::ErrorReport; -/// The opaque `handle` type: a `u64` token whose value round-trips through -/// consumers unchanged. Spell producer parameters and returns as -/// `weaveffi::Handle` to extract them as the IDL `handle` type; a bare `u64` -/// extracts as the `u64` scalar. -pub use weaveffi_abi::Handle; +/// Install the process-wide executor that exported `async fn`s run on. Call it +/// once at startup (before the first async export is launched) to hand futures +/// to a runtime such as Tokio; until then, and if never called, each future is +/// driven to completion on its own thread. +pub use weaveffi_abi::set_spawner; + +/// The executor hook [`set_spawner`] accepts: anything callable as +/// `Fn(BoxFuture)` that is `Send + Sync + 'static`. +pub use weaveffi_abi::Spawner; + +/// The type-erased `Send + 'static` future a [`Spawner`] receives. +pub use weaveffi_abi::BoxFuture; pub use weaveffi_macros::{ - callback, cancellable, enumeration, error, export, interface, listener, module, record, + callback_interface, cancellable, enumeration, error, export, interface, module, record, }; diff --git a/crates/weaveffi/tests/runtime.rs b/crates/weaveffi/tests/runtime.rs index 6d8622e9..f4beaf4e 100644 --- a/crates/weaveffi/tests/runtime.rs +++ b/crates/weaveffi/tests/runtime.rs @@ -10,6 +10,7 @@ #![allow(clippy::not_unsafe_ptr_arg_deref)] use std::os::raw::c_char; +use std::sync::Arc; use weaveffi::abi::{self, c_ptr_to_string, free_string, string_to_c_ptr, weaveffi_error}; #[weaveffi::module] @@ -158,6 +159,38 @@ pub mod dispatch { } } +/// Stands in for a `panic = "abort"` build, where a consumer callback failure +/// can't unwind: the producer records it with `defer_foreign_error` and keeps +/// running, and the thunk must report the recorded failure in place of the +/// producer's result. +#[weaveffi::module] +pub mod deferred { + /// Record a foreign failure, then return normally with a value the + /// caller must never see. + #[weaveffi::export] + pub fn sync_then_fail(fail: bool) -> i32 { + if fail { + weaveffi::abi::defer_foreign_error(weaveffi::abi::ForeignError { + code: weaveffi::abi::FOREIGN_ERROR_CODE, + message: "consumer said no".to_string(), + }); + } + 77 + } + + /// The async twin of `sync_then_fail`. + #[weaveffi::export] + pub async fn later_then_fail(fail: bool) -> i32 { + if fail { + weaveffi::abi::defer_foreign_error(weaveffi::abi::ForeignError { + code: weaveffi::abi::FOREIGN_ERROR_CODE, + message: "consumer said no, later".to_string(), + }); + } + 88 + } +} + #[weaveffi::module] pub mod maps { use std::collections::BTreeMap; @@ -243,20 +276,125 @@ pub mod stream { #[weaveffi::module] pub mod bus { - /// Invoked for every published message with its text and weight. - #[weaveffi::callback] - #[allow(dead_code, unused_variables)] - fn on_message(text: String, weight: i32) {} + use std::sync::{Arc, Mutex, PoisonError}; + + /// A message priority (a C-style enum crossing a callback boundary). + #[weaveffi::enumeration] + #[repr(i32)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Priority { + /// Routine. + Low = 0, + /// Urgent. + High = 1, + } + + /// A message payload (a record crossing a callback boundary). + #[weaveffi::record] + #[derive(Clone, Debug, PartialEq)] + pub struct Envelope { + /// Sequence number. + pub seq: i64, + /// Optional topic. + pub topic: Option, + } + + /// The consumer-implemented subscriber. + #[weaveffi::callback_interface] + pub trait Subscriber: Send + Sync { + /// Receive a message; returns the subscriber's running total. + fn on_message(&self, text: String, weight: i32, envelope: &Envelope) -> i64; + /// Ask the subscriber how urgent it considers `weight`. + fn classify(&self, weight: i32) -> Priority; + /// Inspect a shared object without retaining it. + fn on_ticker(&self, ticker: Arc, alt: Option>) -> bool; + } + + /// A shared object handed to subscribers. + #[weaveffi::interface] + pub struct Ticker { + value: i64, + } - /// The set of subscribers to published messages. - #[weaveffi::listener(event = "on_message")] - #[allow(dead_code)] - fn subscribers() {} + impl Ticker { + /// Create a ticker. + pub fn new(value: i64) -> Self { + Self { value } + } + /// Read the value. + pub fn value(&self) -> i64 { + self.value + } + } - /// Publish a message, fanning it out to every registered subscriber. + /// A bus that retains its subscribers. + #[weaveffi::interface] + pub struct Bus { + subs: Mutex>>, + } + + impl Bus { + /// Create an empty bus. + pub fn new() -> Arc { + Arc::new(Self { + subs: Mutex::new(Vec::new()), + }) + } + + /// Retain a subscriber. + pub fn subscribe(&self, subscriber: Arc) { + self.subs + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(subscriber); + } + + /// Publish to every subscriber, returning the sum of their totals. + /// + /// A subscriber failure unwinds through this frame like a panic, so + /// the subscriber list is snapshotted and the lock released before + /// any callback runs (the same discipline a panic-safe producer uses). + pub fn publish(&self, text: String, weight: i32) -> i64 { + let env = Envelope { + seq: 1, + topic: Some("t".to_string()), + }; + let subs: Vec> = self + .subs + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + subs.iter() + .map(|s| s.on_message(text.clone(), weight, &env)) + .sum() + } + + /// Publish asynchronously. + pub async fn publish_later(&self, text: String, weight: i32) -> i64 { + self.publish(text, weight) + } + + /// Drop every subscriber (the consumer's `free` must run). + pub fn clear(&self) { + self.subs + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } + } + + /// Call the subscriber once directly without retaining it. + #[weaveffi::export] + pub fn classify_once(subscriber: Arc, weight: i32) -> Priority { + subscriber.classify(weight) + } + + /// Hand the subscriber a ticker object. #[weaveffi::export] - pub fn publish(text: String, weight: i32) { - emit_subscribers(&text, weight); + pub fn tick(subscriber: &Arc, value: i64) -> bool { + let ticker = Arc::new(Ticker::new(value)); + subscriber.on_ticker(ticker.clone(), None) + && subscriber.on_ticker(ticker.clone(), Some(ticker)) } } @@ -613,34 +751,230 @@ fn iterator_scalar_elements() { assert_eq!(got, vec![0, 1, 4, 9]); } -#[test] -fn listener_register_emit_unregister() { +/// A consumer-side `Subscriber` implementation: the context is a heap-allocated +/// `SubState`, the vtable is a process-wide static, exactly as a generated +/// binding would do it. +mod consumer_subscriber { + use super::*; use std::os::raw::c_void; - use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; + + pub struct SubState { + pub total: AtomicI64, + pub fail_at: i32, + pub last_topic: std::sync::Mutex>, + pub freed: Arc, + } + + unsafe extern "C" fn on_message( + ctx: *mut c_void, + text: *const c_char, + weight: i32, + envelope_ptr: *const u8, + envelope_len: usize, + out_err: *mut weaveffi_error, + ) -> i64 { + let state = &*(ctx as *const SubState); + let text = c_ptr_to_string(text).unwrap(); + let env: bus::Envelope = + abi::decode_value(std::slice::from_raw_parts(envelope_ptr, envelope_len)).unwrap(); + *state.last_topic.lock().unwrap() = env.topic.clone(); + if weight == state.fail_at { + abi::error_set( + out_err, + abi::FOREIGN_ERROR_CODE, + &format!("subscriber rejected {text}"), + ); + return 0; + } + abi::error_set_ok(out_err); + state.total.fetch_add(weight as i64, Ordering::Relaxed) + weight as i64 + } + + unsafe extern "C" fn classify( + _ctx: *mut c_void, + weight: i32, + out_err: *mut weaveffi_error, + ) -> i32 { + abi::error_set_ok(out_err); + if weight > 5 { + 1 + } else { + 0 + } + } + + unsafe extern "C" fn on_ticker( + _ctx: *mut c_void, + ticker: *mut bus::Ticker, + alt: *mut bus::Ticker, + out_err: *mut weaveffi_error, + ) -> bool { + abi::error_set_ok(out_err); + // Object arguments transfer one strong reference: the consumer adopts + // each non-null pointer and owes exactly one `_destroy`. + let mut err = weaveffi_error::default(); + let v = bus::weaveffi_bus_Ticker_value(ticker, &mut err); + bus::weaveffi_bus_Ticker_destroy(ticker); + let alt_ok = if alt.is_null() { + true + } else { + let same = bus::weaveffi_bus_Ticker_value(alt, &mut err) == v; + bus::weaveffi_bus_Ticker_destroy(alt); + same + }; + v == 42 && alt_ok + } - static SUM: AtomicI64 = AtomicI64::new(0); - extern "C" fn on_msg(text: *const c_char, weight: i32, ctx: *mut c_void) { - assert!(!text.is_null()); - let counter = unsafe { &*(ctx as *const AtomicI64) }; - counter.fetch_add(weight as i64, Ordering::Relaxed); + unsafe extern "C" fn free(ctx: *mut c_void) { + let state = Box::from_raw(ctx as *mut SubState); + state.freed.fetch_add(1, Ordering::SeqCst); } - SUM.store(0, Ordering::Relaxed); - let id = - bus::weaveffi_bus_register_subscribers(on_msg, &SUM as *const AtomicI64 as *mut c_void); - assert!(id > 0); + pub static VTABLE: bus::weaveffi_bus_Subscriber_vtable = bus::weaveffi_bus_Subscriber_vtable { + on_message, + classify, + on_ticker, + free, + }; + + pub fn new_ctx(fail_at: i32, freed: &Arc) -> *mut c_void { + Box::into_raw(Box::new(SubState { + total: AtomicI64::new(0), + fail_at, + last_topic: std::sync::Mutex::new(None), + freed: Arc::clone(freed), + })) as *mut c_void + } +} + +#[test] +fn callback_interface_sync_paths() { + use consumer_subscriber::{new_ctx, VTABLE}; + use std::sync::atomic::AtomicUsize; + let freed = Arc::new(AtomicUsize::new(0)); + use std::sync::atomic::Ordering; + + let mut err = ok_err(); + + // Direct-family return through a non-retained callback: `free` runs as soon + // as the thunk drops its `Arc`. + let ctx = new_ctx(-1, &freed); + assert_eq!( + bus::weaveffi_bus_classify_once(ctx, &VTABLE, 9, &mut err), + 1 + ); + assert_eq!(err.code, 0); + assert_eq!(freed.load(Ordering::SeqCst), 1); + + // Objects flow producer -> consumer as borrowed pointers the consumer may + // clone; a `&Arc` spelling lends the lifted callback. + let ctx = new_ctx(-1, &freed); + assert!(bus::weaveffi_bus_tick(ctx, &VTABLE, 42, &mut err)); + assert_eq!(err.code, 0); + assert_eq!(freed.load(Ordering::SeqCst), 2); + + // A null vtable is a marshalling error, not a crash. + let r = bus::weaveffi_bus_classify_once(std::ptr::null_mut(), std::ptr::null(), 1, &mut err); + assert_eq!(r, 0); + assert_eq!(err.code, abi::MARSHAL_ERROR_CODE); + abi::error_clear(&mut err); +} + +#[test] +fn callback_interface_retained_and_foreign_error() { + use consumer_subscriber::{new_ctx, VTABLE}; + use std::sync::atomic::AtomicUsize; + let freed = Arc::new(AtomicUsize::new(0)); + use std::sync::atomic::Ordering; let mut err = ok_err(); + + let b = bus::weaveffi_bus_Bus_new(&mut err); + assert!(!b.is_null()); + bus::weaveffi_bus_Bus_subscribe(b, new_ctx(7, &freed), &VTABLE, &mut err); + bus::weaveffi_bus_Bus_subscribe(b, new_ctx(-1, &freed), &VTABLE, &mut err); + assert_eq!(err.code, 0); + assert_eq!(freed.load(Ordering::SeqCst), 0, "retained by the bus"); + let text = string_to_c_ptr("hi"); - bus::weaveffi_bus_publish(text, 3, &mut err); - bus::weaveffi_bus_publish(text, 5, &mut err); + assert_eq!(bus::weaveffi_bus_Bus_publish(b, text, 3, &mut err), 6); + assert_eq!(err.code, 0); + assert_eq!(bus::weaveffi_bus_Bus_publish(b, text, 5, &mut err), 16); + + // The first subscriber fails on weight 7: the producer call is aborted and + // the consumer's own message comes back with FOREIGN_ERROR_CODE. + let r = bus::weaveffi_bus_Bus_publish(b, text, 7, &mut err); + assert_eq!(r, 0); + assert_eq!(err.code, abi::FOREIGN_ERROR_CODE); + assert_eq!( + c_ptr_to_string(err.message).unwrap(), + "subscriber rejected hi" + ); + abi::error_clear(&mut err); + + // The bus is still usable afterwards. + assert_eq!(bus::weaveffi_bus_Bus_publish(b, text, 1, &mut err), 18); assert_eq!(err.code, 0); - assert_eq!(SUM.load(Ordering::Relaxed), 8); - bus::weaveffi_bus_unregister_subscribers(id); - bus::weaveffi_bus_publish(text, 100, &mut err); - assert_eq!(SUM.load(Ordering::Relaxed), 8); + bus::weaveffi_bus_Bus_clear(b, &mut err); + assert_eq!(freed.load(Ordering::SeqCst), 2, "free runs once each"); free_string(text); + bus::weaveffi_bus_Bus_destroy(b); +} + +#[test] +fn callback_interface_from_async_method() { + use consumer_subscriber::{new_ctx, VTABLE}; + use std::os::raw::c_void; + use std::sync::mpsc; + use std::time::Duration; + + type Msg = (i32, String, i64); + extern "C" fn cb(ctx: *mut c_void, err: *mut weaveffi_error, result: i64) { + let tx = unsafe { &*(ctx as *const mpsc::Sender) }; + let (code, msg) = if err.is_null() { + (0, String::new()) + } else { + let e = unsafe { &*err }; + let out = (e.code, c_ptr_to_string(e.message).unwrap_or_default()); + abi::error_free(err); + out + }; + tx.send((code, msg, result)).unwrap(); + } + + let mut err = ok_err(); + let b = bus::weaveffi_bus_Bus_new(&mut err); + let freed = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + bus::weaveffi_bus_Bus_subscribe(b, new_ctx(4, &freed), &VTABLE, &mut err); + + let (tx, rx) = mpsc::channel::(); + let tx_ptr = Box::into_raw(Box::new(tx)); + let text = string_to_c_ptr("async"); + + bus::weaveffi_bus_Bus_publish_later_async(b, text, 2, cb, tx_ptr as *mut c_void); + let (code, _, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(code, 0); + assert_eq!(result, 2); + + // A foreign failure inside the future is delivered through the callback. + bus::weaveffi_bus_Bus_publish_later_async(b, text, 4, cb, tx_ptr as *mut c_void); + let (code, msg, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(code, abi::FOREIGN_ERROR_CODE); + assert_eq!(msg, "subscriber rejected async"); + assert_eq!(result, 0); + + // The receiver was retained across the spawn: releasing the consumer's + // reference while a call is in flight is safe. + bus::weaveffi_bus_Bus_publish_later_async(b, text, 1, cb, tx_ptr as *mut c_void); + bus::weaveffi_bus_Bus_destroy(b); + let (code, _, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(code, 0); + assert_eq!(result, 3); + + free_string(text); + unsafe { drop(Box::from_raw(tx_ptr)) }; } #[test] @@ -720,39 +1054,136 @@ fn async_result_ok_and_err_paths() { unsafe { drop(Box::from_raw(tx_ptr)) }; } +#[test] +fn deferred_foreign_error_replaces_a_sync_result() { + let mut err = ok_err(); + assert_eq!( + deferred::weaveffi_deferred_sync_then_fail(false, &mut err), + 77 + ); + assert_eq!(err.code, 0); + + let r = deferred::weaveffi_deferred_sync_then_fail(true, &mut err); + assert_eq!(r, 0, "the producer's value is discarded for the sentinel"); + assert_eq!(err.code, abi::FOREIGN_ERROR_CODE); + assert_eq!(c_ptr_to_string(err.message).unwrap(), "consumer said no"); + abi::error_clear(&mut err); + + assert!( + abi::take_foreign_error().is_none(), + "the thunk drained the recorded failure" + ); + assert_eq!( + deferred::weaveffi_deferred_sync_then_fail(false, &mut err), + 77 + ); + assert_eq!(err.code, 0, "a later call on the same thread is unaffected"); +} + +#[test] +fn deferred_foreign_error_fires_the_async_callback_once() { + use std::os::raw::c_void; + use std::sync::mpsc; + use std::time::Duration; + + type Msg = (i32, String, i32); + extern "C" fn cb(ctx: *mut c_void, err: *mut weaveffi_error, result: i32) { + let tx = unsafe { &*(ctx as *const mpsc::Sender) }; + let (code, message) = if err.is_null() { + (0, String::new()) + } else { + let e = unsafe { &*err }; + let out = (e.code, c_ptr_to_string(e.message).unwrap_or_default()); + abi::error_free(err); + out + }; + tx.send((code, message, result)).unwrap(); + } + + let (tx, rx) = mpsc::channel::(); + let tx_ptr = Box::into_raw(Box::new(tx)); + + deferred::weaveffi_deferred_later_then_fail_async(false, cb, tx_ptr as *mut c_void); + let (code, _, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!((code, result), (0, 88)); + + deferred::weaveffi_deferred_later_then_fail_async(true, cb, tx_ptr as *mut c_void); + let (code, message, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(code, abi::FOREIGN_ERROR_CODE); + assert_eq!(message, "consumer said no, later"); + assert_eq!(result, 0); + assert!( + rx.recv_timeout(Duration::from_millis(200)).is_err(), + "the completion callback fires exactly once" + ); + + unsafe { drop(Box::from_raw(tx_ptr)) }; +} + /// Exercises nested-module codegen: the inner module's symbols must carry the -/// joined `outer_inner` path, and a nested function may reference a handle type +/// joined `outer_inner` path, and a nested function may reference an interface /// declared in its parent module via `super::` (the `kvstore` `stats` pattern). #[weaveffi::module] pub mod outer { - /// A handle target type declared in the parent module. + use std::sync::Arc; + + /// An interface declared in the parent module. + #[weaveffi::interface] pub struct Session { /// The session id. pub id: i64, } - /// Open a session, returning an opaque handle to it. + impl Session { + /// Open a session. + pub fn open(id: i64) -> Self { + Self { id } + } + } + + /// Return the same session (an `Arc`-typed parameter and return). #[weaveffi::export] - pub fn open_session(id: i64) -> *mut Session { - Box::into_raw(Box::new(Session { id })) + pub fn share(session: Arc) -> Arc { + session } /// The nested sub-module: its symbols use the `outer_inner` prefix. #[weaveffi::module] pub mod inner { + use std::sync::Arc; + /// A by-value record produced by the nested module. #[weaveffi::record] #[derive(Clone)] pub struct Report { /// Ten times the session id. pub score: i64, + /// The session the report is about (an object token in the buffer). + pub session: Option>, + } + + /// Summarize a parent-module `Session` into a nested `Report`. + #[weaveffi::export] + pub fn summarize(session: &super::Session, keep: Option<&super::Session>) -> Report { + Report { + score: session.id * 10 + keep.map_or(0, |k| k.id), + session: None, + } + } + + /// Attach a retained session to a report. + #[weaveffi::export] + pub fn attach(session: Arc) -> Report { + Report { + score: session.id, + session: Some(session), + } } - /// Summarize a parent-module `Session` handle into a nested `Report`. + /// Read back the session inside a report. #[weaveffi::export] - pub fn summarize(session: *const super::Session) -> Report { - let id = unsafe { (*session).id }; - Report { score: id * 10 } + pub fn session_of(report: Report) -> Option> { + report.session } } } @@ -760,19 +1191,93 @@ pub mod outer { #[test] fn nested_module_symbols_and_parent_type_reference() { let mut err = ok_err(); - let session = outer::weaveffi_outer_open_session(7, &mut err); + let session = outer::weaveffi_outer_Session_open(7, &mut err); assert_eq!(err.code, 0); assert!(!session.is_null()); // The nested function is reachable at `outer::inner::*` and its symbol // carries the joined module path; its record return is a value buffer. let mut out_len: usize = 0; - let ptr = outer::inner::weaveffi_outer_inner_summarize(session, &mut out_len, &mut err); + let ptr = outer::inner::weaveffi_outer_inner_summarize( + session, + std::ptr::null(), + &mut out_len, + &mut err, + ); assert_eq!(err.code, 0); let report: outer::inner::Report = decode_ret(ptr, out_len); assert_eq!(report.score, 70); + assert!(report.session.is_none()); + + let ptr = + outer::inner::weaveffi_outer_inner_summarize(session, session, &mut out_len, &mut err); + let report: outer::inner::Report = decode_ret(ptr, out_len); + assert_eq!(report.score, 77); + + outer::weaveffi_outer_Session_destroy(session); +} + +#[test] +fn object_reference_counting() { + let mut err = ok_err(); + let s = outer::weaveffi_outer_Session_open(3, &mut err); + + // `share` retains through `Arc` in and hands back a new strong + // reference out; the pointer identity is the same allocation. + let again = outer::weaveffi_outer_share(s, &mut err); + assert_eq!(err.code, 0); + assert_eq!(again, s, "the same object, one more reference"); + let third = outer::weaveffi_outer_Session_clone(s); + assert_eq!(third, s); + + outer::weaveffi_outer_Session_destroy(s); + outer::weaveffi_outer_Session_destroy(again); + // Still alive through `third`. + let mut out_len: usize = 0; + let ptr = outer::inner::weaveffi_outer_inner_summarize( + third, + std::ptr::null(), + &mut out_len, + &mut err, + ); + assert_eq!(err.code, 0); + let report: outer::inner::Report = decode_ret(ptr, out_len); + assert_eq!(report.score, 30); + outer::weaveffi_outer_Session_destroy(third); + outer::weaveffi_outer_Session_destroy(std::ptr::null_mut()); + assert!(outer::weaveffi_outer_Session_clone(std::ptr::null()).is_null()); +} + +#[test] +fn objects_inside_value_buffers_carry_a_reference() { + let mut err = ok_err(); + let s = outer::weaveffi_outer_Session_open(5, &mut err); - unsafe { drop(Box::from_raw(session)) }; + // `attach` retains the session inside the returned record: the buffer's + // object token is one strong reference the consumer adopts on decode. + let mut out_len: usize = 0; + let ptr = outer::inner::weaveffi_outer_inner_attach(s, &mut out_len, &mut err); + assert_eq!(err.code, 0); + let bytes = unsafe { std::slice::from_raw_parts(ptr, out_len) }.to_vec(); + abi::free_bytes(ptr as *mut u8, out_len); + // The consumer owns `s` and the token in `bytes`: two references. + outer::weaveffi_outer_Session_destroy(s); + + // Sending the buffer back transfers the token's reference to the producer, + // which returns it as the optional object result. + let back = outer::inner::weaveffi_outer_inner_session_of(bytes.as_ptr(), bytes.len(), &mut err); + assert_eq!(err.code, 0); + assert_eq!(back, s, "same allocation, still alive"); + outer::weaveffi_outer_Session_destroy(back); + + // A record with no object decodes to a null optional object return. + let none = abi::encode_value(&outer::inner::Report { + score: 0, + session: None, + }); + let back = outer::inner::weaveffi_outer_inner_session_of(none.as_ptr(), none.len(), &mut err); + assert!(back.is_null()); + assert_eq!(err.code, 0); } /// A producer module whose fallible function surfaces an IDL error domain's @@ -877,6 +1382,7 @@ pub mod legacy { #[weaveffi::module] pub mod counters { use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::Arc; /// The counters error domain. #[weaveffi::error] @@ -936,6 +1442,43 @@ pub mod counters { } } + /// Return a new reference to this same counter (`Arc` receiver + /// and return). + pub fn share(self: Arc) -> Arc { + self + } + + /// Return the counter with the larger value, or none if both are + /// below `floor` (optional object parameter and return). + pub fn larger(&self, other: Option<&Counter>, floor: i64) -> Option> { + let mine = self.value(); + let theirs = other.map(Counter::value); + match theirs { + Some(t) if t >= mine && t >= floor => Some(Arc::new(Counter { + value: AtomicI64::new(t), + step: 1, + })), + _ if mine >= floor => Some(Arc::new(Counter::new(mine))), + _ => None, + } + } + + /// Yield `n` fresh counters lazily (interface elements in an iterator). + pub fn fan_out(&self, n: i32) -> weaveffi::Iter> { + let base = self.value(); + weaveffi::Iter::new((0..n as i64).map(move |i| Arc::new(Counter::new(base + i)))) + } + + /// Read the value asynchronously (an async method retains `self`). + pub async fn value_later(&self) -> i64 { + self.value() + } + + /// Return a fresh counter asynchronously (async object result). + pub async fn snapshot_later(self: Arc) -> Arc { + self + } + /// Panic on purpose, proving panics surface as errors, not aborts. pub fn explode(&self) { panic!("counter exploded"); @@ -1041,6 +1584,117 @@ fn interface_as_free_function_parameter() { counters::weaveffi_counters_Counter_destroy(c); } +#[test] +fn arc_self_receiver_and_optional_objects() { + let mut err = ok_err(); + let c = counters::weaveffi_counters_Counter_new(10, &mut err); + let shared = counters::weaveffi_counters_Counter_share(c, &mut err); + assert_eq!(err.code, 0); + assert_eq!(shared, c, "`self: Arc` returns the same object"); + counters::weaveffi_counters_Counter_destroy(shared); + + let other = counters::weaveffi_counters_Counter_new(20, &mut err); + let bigger = counters::weaveffi_counters_Counter_larger(c, other, 0, &mut err); + assert_eq!(err.code, 0); + assert_eq!( + counters::weaveffi_counters_Counter_value(bigger, &mut err), + 20 + ); + counters::weaveffi_counters_Counter_destroy(bigger); + + let mine = counters::weaveffi_counters_Counter_larger(c, std::ptr::null(), 0, &mut err); + assert_eq!( + counters::weaveffi_counters_Counter_value(mine, &mut err), + 10 + ); + counters::weaveffi_counters_Counter_destroy(mine); + + let none = counters::weaveffi_counters_Counter_larger(c, other, 100, &mut err); + assert!(none.is_null()); + assert_eq!(err.code, 0, "a null optional object return is not an error"); + + counters::weaveffi_counters_Counter_destroy(other); + counters::weaveffi_counters_Counter_destroy(c); +} + +#[test] +fn iterator_of_objects() { + let mut err = ok_err(); + let c = counters::weaveffi_counters_Counter_new(5, &mut err); + let iter = counters::weaveffi_counters_Counter_fan_out(c, 3, &mut err); + assert_eq!(err.code, 0); + let mut values = Vec::new(); + loop { + let mut item: *mut counters::Counter = std::ptr::null_mut(); + if counters::weaveffi_counters_Counter_FanOutIterator_next(iter, &mut item, &mut err) == 0 { + break; + } + values.push(counters::weaveffi_counters_Counter_value(item, &mut err)); + counters::weaveffi_counters_Counter_destroy(item); + } + counters::weaveffi_counters_Counter_FanOutIterator_destroy(iter); + counters::weaveffi_counters_Counter_destroy(c); + assert_eq!(values, vec![5, 6, 7]); +} + +#[test] +fn async_methods_retain_the_receiver() { + use std::os::raw::c_void; + use std::sync::mpsc; + use std::time::Duration; + + extern "C" fn on_value(ctx: *mut c_void, err: *mut weaveffi_error, result: i64) { + let tx = unsafe { &*(ctx as *const mpsc::Sender) }; + assert!(err.is_null()); + tx.send(result).unwrap(); + } + extern "C" fn on_obj( + ctx: *mut c_void, + err: *mut weaveffi_error, + result: *mut counters::Counter, + ) { + let tx = unsafe { &*(ctx as *const mpsc::Sender) }; + assert!(err.is_null()); + let mut e = weaveffi_error::default(); + let v = counters::weaveffi_counters_Counter_value(result, &mut e); + counters::weaveffi_counters_Counter_destroy(result); + tx.send(v).unwrap(); + } + + let mut err = ok_err(); + let (tx, rx) = mpsc::channel::(); + let tx_ptr = Box::into_raw(Box::new(tx)); + + let c = counters::weaveffi_counters_Counter_new(8, &mut err); + counters::weaveffi_counters_Counter_value_later_async(c, on_value, tx_ptr as *mut c_void); + counters::weaveffi_counters_Counter_snapshot_later_async(c, on_obj, tx_ptr as *mut c_void); + // Releasing the consumer's reference while calls are in flight is safe: + // each launcher retained its own. + counters::weaveffi_counters_Counter_destroy(c); + let mut got = vec![ + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + ]; + got.sort_unstable(); + assert_eq!(got, vec![8, 8]); + + // A null receiver still completes (with a marshalling error). + extern "C" fn on_null(ctx: *mut c_void, err: *mut weaveffi_error, result: i64) { + let tx = unsafe { &*(ctx as *const mpsc::Sender) }; + assert!(!err.is_null()); + assert_eq!(unsafe { (*err).code }, abi::MARSHAL_ERROR_CODE); + abi::error_free(err); + tx.send(result).unwrap(); + } + counters::weaveffi_counters_Counter_value_later_async( + std::ptr::null(), + on_null, + tx_ptr as *mut c_void, + ); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), 0); + unsafe { drop(Box::from_raw(tx_ptr)) }; +} + #[test] fn interface_null_self_reports_error() { let mut err = ok_err(); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f2964a3e..e56db413 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -11,11 +11,12 @@ - [Samples](samples.md) - [Reference](reference/README.md) - [IDL Schema](reference/idl.md) + - [C ABI Contract](reference/abi.md) - [Value Buffer Protocol](reference/value-buffers.md) - [Memory & Error Model](reference/memory-error.md) - [Naming and Package Conventions](reference/naming.md) - [Generators](generators/README.md) - - [Android](generators/android.md) + - [Kotlin](generators/kotlin.md) - [C](generators/c.md) - [Node](generators/node.md) - [Swift](generators/swift.md) @@ -40,6 +41,6 @@ - [Tutorials](tutorials/README.md) - [Calculator](tutorials/calculator.md) - [Swift iOS](tutorials/swift.md) - - [Android](tutorials/android.md) + - [Kotlin](tutorials/kotlin.md) - [Python](tutorials/python.md) - [Node.js](tutorials/node.md) diff --git a/docs/src/api/README.md b/docs/src/api/README.md index b0eb7757..dc21bea4 100644 --- a/docs/src/api/README.md +++ b/docs/src/api/README.md @@ -2,6 +2,16 @@ Reference documentation for the WeaveFFI Rust crates. +- [Rust API](rust.md): a map of the public surface of the `weaveffi` facade + (the attributes, `export_runtime!`, `Iter`, `CancelToken`, `ErrorReport`, + and the spawner) and of the `weaveffi-abi` runtime it re-exports (the + error struct and reserved codes, reference-counted objects, callback + vtables, the value-buffer codec, cancel tokens, and the async spawner), + with a note on which items a producer calls and which exist for the macro + expansion. +- [Doc Comment Style](doc-style.md): the conventions and lints behind the + doc comments themselves. + API docs are generated from source via `cargo doc`: ```bash @@ -9,8 +19,7 @@ cargo doc --workspace --all-features --no-deps --open ``` When the documentation site is deployed, API docs are available under the -[API section](https://weavefoundry.github.io/weaveffi/api/rust/weaveffi_core/). +[API section](https://weavefoundry.github.io/weaveffi/api/rust/weaveffi/). Every public item in the library crates is documented; this is enforced in -CI. See [Doc Comment Style](doc-style.md) for the conventions and the lints -that back them. +CI by `#![deny(missing_docs)]` and the Clippy doc lints. diff --git a/docs/src/api/rust.md b/docs/src/api/rust.md index 5e5e3fab..0b25332a 100644 --- a/docs/src/api/rust.md +++ b/docs/src/api/rust.md @@ -1,17 +1,14 @@ # Rust API (cargo doc) -## The `weaveffi` producer crate - -A Rust producer depends on a single crate, [`weaveffi`][weaveffi-crate]. It -re-exports the `#[weaveffi::module]` family of attributes and the -`export_runtime!` macro, plus the C ABI runtime as `weaveffi::abi`. Annotate a -normal module, tag the items to export, and the macro emits the -`#[no_mangle] extern "C"` thunks for you. See -[The Rust Producer Macro](../guides/producer-macro.md) for the full guide. +This page is a map of the public Rust API a producer sees: the `weaveffi` +facade crate you depend on, and the `weaveffi-abi` runtime it re-exports as +`weaveffi::abi`. Every item below has a doc comment; `cargo doc` renders the +full signatures and the safety contracts, and this page tells you which items +you'll actually reach for and which exist for the macro expansion. ```toml [dependencies] -weaveffi = "0.12" +weaveffi = "0.22" ``` The supporting crates are published separately and are useful when you need @@ -19,21 +16,204 @@ the lower layers directly: | Crate | What it is | |-------|------------| -| [`weaveffi`][weaveffi-crate] | The producer facade: the attribute macros, `export_runtime!`, and `abi`. Depend on this. | -| [`weaveffi-abi`][abi-crate] | The stable C ABI runtime: `weaveffi_error`, memory helpers, cancel tokens, the arena, and the `lift_*`/`lower_*` marshalling converters the macro calls. The macro generates code against it; a producer reaches the same helpers through `weaveffi::abi` when it needs one directly (for example to dereference a raw handle). | +| [`weaveffi`][weaveffi-crate] | The producer facade: the attribute macros, `export_runtime!`, the handful of runtime types a producer names, and `abi`. Depend on this. | +| [`weaveffi-abi`][abi-crate] | The C ABI runtime: `weaveffi_error`, the reserved error codes, memory helpers, reference-counted objects, callback-interface vtables, the value-buffer codec, cancel tokens, and the async spawner. The macro generates code against it; you reach it as `weaveffi::abi`. | | [`weaveffi-macros`][macros-crate] | The proc-macro implementation behind `weaveffi`'s attributes. You rarely depend on it directly. | -| [`weaveffi-ir`][ir-crate] | The IR types (`Api`, `Module`, `TypeRef`, ...) and the IDL parser. | +| [`weaveffi-ir`][ir-crate] | The IR types (`Api`, `Module`, `TypeRef`, ...) and the IDL parser, for tools that consume the IDL. | + +## The `weaveffi` facade + +The facade is deliberately small. Its whole public surface, in the order +`cargo doc -p weaveffi --no-deps` lists it: + +### Attribute macros + +| Attribute | Applies to | What it does | +|-----------|------------|--------------| +| `#[weaveffi::module]` | `pub mod` | The driver. Reads every tagged item in the module and emits the `#[no_mangle] extern "C"` thunks, the vtable structs, and the `ErrorReport` implementations. Exactly one per module you export. | +| `#[weaveffi::export]` | `pub fn` / `pub async fn` | Exports a free function. `async fn` becomes an asynchronous export; a `Result`-returning fn becomes a `throws` function. | +| `#[weaveffi::record]` | `pub struct` | A by-value record, serialized across the ABI in the value-buffer format. | +| `#[weaveffi::enumeration]` | `pub enum` | A `#[repr(i32)]` C-style enum, or a rich enum with data-carrying variants. | +| `#[weaveffi::interface]` | `pub struct` (+ its `impl`) | An opaque, reference-counted object type. Must be `Send + Sync`. Constructors return `Self`, `Arc`, or the type name; methods take `&self` or `self: Arc`. | +| `#[weaveffi::callback_interface]` | `pub trait` | A consumer-implemented interface. Producer functions accept it as `Arc`. | +| `#[weaveffi::error]` | `pub enum` | The module's error domain: positive discriminants are the codes, doc comments are the default messages, named fields are the payload. Implements `ErrorReport`. | +| `#[weaveffi::cancellable]` | `pub async fn` | Marks an async export as accepting a `CancelToken` as its final parameter. | + +The [Producer Macro](../guides/producer-macro.md) guide covers each one with +compiling examples and the diagnostics the macro emits for what it rejects. + +### Macros + +| Item | Purpose | +|------|---------| +| `weaveffi::export_runtime!()` | Emits the fixed runtime symbols every producer cdylib must export: `weaveffi_abi_version`, `weaveffi_free_string`, `weaveffi_free_bytes`, `weaveffi_error_set`, `weaveffi_error_clear`, `weaveffi_error_free`, and the four `weaveffi_cancel_token_*` functions, plus `weaveffi_alloc` / `weaveffi_dealloc` on `wasm32`. Call it exactly once, at crate root, after the module. | + +### Types and functions + +| Item | Re-exported from | Purpose | +|------|------------------|---------| +| `weaveffi::Iter` | `weaveffi_abi::Iter` | The return type of a function whose IDL return is `iter`. Build one with `Iter::new(any_into_iterator)`; the macro boxes it behind an opaque handle that the consumer pulls one element at a time. Requires the iterator to be `Send + 'static`. | +| `weaveffi::CancelToken` | `weaveffi_abi::CancelToken` | The final parameter of a `#[weaveffi::cancellable]` async fn. Poll `is_cancelled()` at safe points and return early. `Send + Sync`; a null token reads as never cancelled. | +| `weaveffi::ErrorReport` | `weaveffi_abi::ErrorReport` | The trait a `Result`'s error type must implement to cross the boundary: `code()` (defaults to `-1`), `message()`, and `payload()` (defaults to empty). Implemented for `String`, `&str`, and `Box`; the `#[weaveffi::error]` expansion implements it for your domain enum. Implement it by hand for an error type the macro doesn't own (a `thiserror` enum, for example). | +| `weaveffi::set_spawner` | `weaveffi_abi::set_spawner` | Installs the process-wide executor async exports run on. Call once before the first async launch; returns `Err(SpawnerAlreadySet)` on a second call. | +| `weaveffi::Spawner` | `weaveffi_abi::Spawner` | The hook `set_spawner` takes: `fn spawn(&self, fut: BoxFuture)`. Blanket-implemented for any `Fn(BoxFuture) + Send + Sync + 'static`, so a closure forwarding to `tokio::runtime::Handle::spawn` is enough. | +| `weaveffi::BoxFuture` | `weaveffi_abi::BoxFuture` | `Pin + Send + 'static>>`, the type-erased future a spawner receives. Already wrapped so a panic inside it is caught and reported through the completion callback. | +| `weaveffi::abi` | `weaveffi_abi` | The whole runtime crate, for the rare case a producer needs a helper directly (tests that call the generated thunks, for instance). | + +A typical producer uses three of these by name (`Iter`, `CancelToken`, and +`set_spawner`) and never spells `abi::` outside its tests. The +[Async](../guides/async.md) guide has a compiling Tokio spawner example. + +## The `weaveffi-abi` runtime + +The runtime implements the contract described in the +[ABI reference](../reference/abi.md). Its root module holds the C-facing +types and the helpers every thunk needs; five submodules hold the object, +callback, buffer, conversion, and spawner machinery. Everything in the +submodules is also re-exported at the root, so `weaveffi::abi::lower_object` +and `weaveffi::abi::object::lower_object` are the same function. + +Items marked **(macro)** are called by generated code; you can read them to +understand the expansion but shouldn't need to call them. Items marked +**(tests)** are what a producer's unit tests use to exercise the generated +thunks directly, as every `samples/*/src/lib.rs` does. + +### Version + +| Item | Notes | +|------|-------| +| `ABI_VERSION: u32` | `2`. Bumps only on an incompatible change to the runtime surface; independent of the crate version and the IDL schema. | +| `abi_version() -> u32` | The body of the exported `weaveffi_abi_version` thunk. Generated consumers compare it against the revision they were generated for at load time. | + +### The error struct and reserved codes + +| Item | Notes | +|------|-------| +| `weaveffi_error` | `#[repr(C)] { code: i32, message: *const c_char, payload_ptr: *const u8, payload_len: usize }`. `Default` is the OK state. Never copy one that owns a message. | +| `GENERIC_ERROR_CODE` | `-1`: an untyped producer error (`Result` and any `ErrorReport` that keeps the default `code`). | +| `PANIC_ERROR_CODE` | `-2`: the producer panicked inside a thunk's `catch_unwind`. | +| `MARSHAL_ERROR_CODE` | `-3`: an argument couldn't be lifted (null pointer, invalid UTF-8, bad discriminant, malformed buffer). | +| `FOREIGN_ERROR_CODE` | `-4`: a consumer callback-interface implementation failed. | +| `error_set_ok(out_err)` **(macro)** | Sets `code = 0` and frees any prior message and payload. | +| `error_set(out_err, code, &str)` **(macro)** | Copies the message into a fresh allocation. | +| `error_set_c(out_err, code, *const c_char)` | The body of the exported `weaveffi_error_set`, which consumer trampolines call to report a callback failure. Copies the borrowed C string. | +| `error_set_with_payload(out_err, code, &str, Vec)` **(macro)** | `error_set` plus an owned payload buffer. | +| `error_set_panic(out_err, &dyn Any)` **(macro)** | The `catch_unwind` arm: a `ForeignError` payload is reported with its own code and message, anything else as `-2` with `producer panicked: ...`. | +| `error_clear(err)` **(tests)** | The body of `weaveffi_error_clear`: frees the message and payload, zeroes the fields. | +| `error_free(err)` | The body of `weaveffi_error_free`: `error_clear` plus freeing the heap box an async completion callback delivered. | +| `panic_message(&dyn Any) -> String` | Best-effort extraction of a panic payload's text. | +| `result_to_out_err(Result, out_err) -> Option` **(macro)** | Writes `Err` through `ErrorReport` and returns `None`; `Ok` sets the slot to OK. | +| `ErrorReport` | See the facade table above. | + +### Strings and bytes + +| Item | Notes | +|------|-------| +| `string_to_c_ptr(impl AsRef) -> *const c_char` **(macro)** | Allocates an owned C string; interior NULs are stripped. Freed by `free_string`. | +| `c_ptr_to_string(*const c_char) -> Option` **(tests)** | Copies a borrowed C string; `None` for null or invalid UTF-8. | +| `unsafe c_ptr_to_str<'a>(*const c_char) -> Option<&'a str>` **(macro)** | The zero-copy lift for a `&str` parameter. | +| `free_string(*const c_char)` | The body of `weaveffi_free_string`. Null is a no-op. | +| `free_bytes(*mut u8, len)` | The body of `weaveffi_free_bytes`. Null is a no-op. | +| `convert::lift_bytes`, `convert::lift_byte_slice`, `convert::lower_bytes` **(macro)** | Lift a `(ptr, len)` pair into an owned `Vec` or a borrowed `&[u8]`; lower a `Vec` into a `(ptr, len)` pair the consumer frees with `weaveffi_free_bytes`. | +| `wasm_alloc(size)`, `wasm_dealloc(ptr, size)` | `wasm32` only: the linear-memory allocator behind `weaveffi_alloc` / `weaveffi_dealloc`, which the generated JS glue uses to stage inputs and return slots. | + +### Reference-counted objects (`abi::object`) + +An interface value crosses the ABI as an `Arc` turned into a raw pointer. +Every helper here is **(macro)**; they exist so each `unsafe` +pointer-to-`Arc` conversion has one audited home. The +[Memory](../guides/memory.md) guide explains the ownership rules they +implement. + +| Item | Notes | +|------|-------| +| `lower_object(impl Into>) -> *mut T` | Hands one strong reference to the consumer; accepts an owned `T` or an existing `Arc`. | +| `lower_object_opt(Option>>) -> *mut T` | Same, with `None` as null. | +| `unsafe object_ref<'a>(*const T) -> Option<&'a T>` | Borrows an object parameter for the call without touching the count. | +| `unsafe object_arc(*const T) -> Option>` | Takes a new strong reference so the producer can keep the object past the call. | +| `unsafe object_clone(*const T) -> *mut T` | The body of every `{tag}_clone` symbol. | +| `unsafe object_destroy(*mut T)` | The body of every `{tag}_destroy` symbol: releases one reference, swallowing a panicking `Drop` and discarding any deferred foreign error. | +| `object_to_token(&Arc) -> u64` | The `u64` an interface value takes inside a value buffer; carries one strong reference. | +| `unsafe object_from_token(u64) -> Option>` | Adopts that reference. Adopting the same token twice double-frees. | + +### Callback interfaces (`abi::callback`) + +A callback-interface parameter arrives as a `(ctx, vtable)` pair. The +expansion wraps it in a `ForeignCallback`, implements your trait on top, and +hands you an `Arc`. + +| Item | Notes | +|------|-------| +| `Vtable` (trait) **(macro)** | Implemented by every generated `#[repr(C)]` vtable struct; exposes the trailing `free(ctx)` entry. | +| `CallbackInterface` (trait) **(macro)** | Implemented for `dyn Trait` of every callback interface; ties the trait object to its vtable type and builds the `Arc` from a `ForeignCallback`. | +| `unsafe lift_callback(ctx, vtable) -> Option>` **(macro)** | Lifts the parameter slots. A null vtable is a marshalling failure. | +| `ForeignCallback` **(macro)** | Owns the consumer's `ctx` and vtable pointer; `Send + Sync` because the ABI obliges consumers to make every entry callable from any thread. Calls `free(ctx)` exactly once when the last `Arc` drops. | +| `ForeignError { code, message }` | The payload a failed consumer call travels as. Implements `Display` and `Error`. | +| `check_foreign_error(weaveffi_error)` **(macro)** | Called after every vtable call. A zero code is a no-op; otherwise it copies the message, clears the error, and calls `raise_foreign_error`. A positive code is replaced by `-4` so a consumer bug can't impersonate a domain code. | +| `raise_foreign_error(ForeignError)` **(macro)** | On a `panic = "unwind"` build, `resume_unwind`s with the payload (the panic hook doesn't fire). On a `panic = "abort"` build, defers it. | +| `defer_foreign_error(ForeignError)` | Records the failure in a thread-local slot, keeping the first if one is pending. Public so tests can exercise the abort-build route. | +| `take_foreign_error() -> Option` **(macro)** | Every thunk calls this after the producer returns and reports a recorded failure instead of the result. | + +The [Errors](../guides/errors.md) guide walks through what a consumer sees at +the other end. + +### Value buffers (`abi::buffer`) + +Records, rich enums, optionals, lists, maps, and error payloads cross the ABI +serialized in the format specified in the +[value-buffer reference](../reference/value-buffers.md). The codec is public so +a producer can round-trip a value in a test, or implement `BufferValue` for a +type the macro doesn't generate. + +| Item | Notes | +|------|-------| +| `BufferValue` (trait) | `write_value(&self, &mut BufferWriter)` and `read_value(&mut BufferReader) -> Result`. Implemented for every scalar, `String`, `Arc` (as an object token), `Option`, `Vec` (so `Vec` encodes as `bytes` does), `BTreeMap`, and `HashMap`; the `#[weaveffi::record]` and rich-enum expansions implement it for your types. | +| `BufferWriter` | Growable little-endian writer: `new()`, `write_bool` through `write_f64`, `write_len`, `write_string`, `write_bytes`, `write_option_flag`, `finish() -> Vec`. | +| `BufferReader<'a>` | Validating reader over a borrowed slice: the matching `read_*` methods, `remaining()`, and `expect_end()`, which rejects trailing bytes. Every read returns `Result<_, BufferDecodeError>`. | +| `BufferDecodeError` | What a truncated buffer, hostile length prefix, invalid `bool` byte, or non-UTF-8 string produces; the thunk reports it as `-3`. | +| `encode_value(&T) -> Vec` | Serialize a whole value. | +| `decode_value(&[u8]) -> Result` | Deserialize a whole value, requiring the buffer to be consumed exactly. | + +### Cancel tokens + +| Item | Notes | +|------|-------| +| `weaveffi_cancel_token` | `#[repr(C)]` opaque struct holding one atomic flag. Consumers create, cancel, and destroy it through the exported `weaveffi_cancel_token_*` symbols. | +| `cancel_token_create()`, `cancel_token_cancel(ptr)`, `cancel_token_is_cancelled(ptr) -> bool`, `cancel_token_destroy(ptr)` | The bodies of those four symbols. Null is a safe no-op everywhere (`is_cancelled` reads `false`). | +| `CancelToken` | See the facade table above. `CancelToken::from_raw` is `#[doc(hidden)]`: the macro builds the token, you only read it. | + +### Iterators + +| Item | Notes | +|------|-------| +| `Iter` | See the facade table above. Implements `Iterator`; the macro's `_next` thunk calls `next()` and `_destroy` drops it. | + +### The async spawner (`abi::spawn`) + +| Item | Notes | +|------|-------| +| `Spawner`, `BoxFuture`, `set_spawner` | See the facade table above. | +| `SpawnerAlreadySet` | The error `set_spawner` returns on a second call; the first spawner wins. | +| `spawn(impl Future + Send + 'static)` **(macro)** | What every generated launcher calls: routes to the installed spawner, or to the default. | +| `block_on(F) -> F::Output` | The dependency-free executor behind the default spawner: polls on the current thread, parking between wakeups. No reactor, so futures that need Tokio's I/O driver need a Tokio spawner instead. | +| `CatchUnwind` **(macro)** | The future adapter every launcher awaits. Resolves to `Err(payload)` instead of unwinding when the inner future panics, and to `Err(ForeignError)` when a deferred foreign failure is pending, so the completion callback always fires exactly once. | + +The default spawner drives each future on a fresh thread with `block_on`; on +`wasm32`, which has no threads, the future is driven inline before the +launcher returns. The [Async](../guides/async.md) guide covers the +consequences for producers. ## Browsing the docs Generate and view the Rust API docs locally: ```bash -cargo doc --workspace --all-features --no-deps --open +cargo doc -p weaveffi -p weaveffi-abi --no-deps --open ``` -When the documentation site is deployed, API docs are available at -[weavefoundry.github.io/weaveffi/api/rust/weaveffi_core/](https://weavefoundry.github.io/weaveffi/api/rust/weaveffi_core/). +Add `--workspace --all-features` to include the generators, the IR, and the +CLI crates. When the documentation site is deployed, API docs are available at +[weavefoundry.github.io/weaveffi/api/rust/weaveffi/](https://weavefoundry.github.io/weaveffi/api/rust/weaveffi/). [weaveffi-crate]: https://docs.rs/weaveffi [abi-crate]: https://docs.rs/weaveffi-abi diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 4b49f1f4..156cd19b 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -7,35 +7,48 @@ or library-author-facing. ## High-level pipeline -Every `weaveffi generate` invocation flows through the same five -stages, in this order: +Every `weaveffi generate` invocation flows through the same stages, in +this order: ```text Input: annotated Rust (.rs) or an IDL (YAML/JSON/TOML) │ ▼ Parse ── weaveffi-ir::parse (IDL) | weaveffi-bridge (.rs): builds an `Api` IR - │ + │ (`Module`, `Function`, `StructDef`, `EnumDef`, `InterfaceDef`, + │ `CallbackInterfaceDef`, `ErrorDomain`, `TypeRef`) ▼ Validate ── weaveffi-core::validate: rejects errors, collects warnings, │ indexes every user-type declaration, and wraps the untouched - │ document in a `ResolvedApi` proof type - │ + │ document in a `ResolvedApi` proof type; `ResolvedApi::resolve` + │ turns each written `TypeRef` into a `Ty` with `Family` / `WireType` ▼ Configure ── weaveffi-cli `ProjectConfig`: loads the nearest weaveffi.toml │ ([package], [global], [generators.]) and attaches the │ package identity to the `ResolvedApi` ▼ -Generate ── weaveffi-core::codegen::Orchestrator: dispatches every - │ selected target in parallel via rayon; each target builds a - │ `BindingModel` (every type a resolved `Ty`) and renders it +Model ── weaveffi-core::model::BindingModel::build(api, prefix): every + │ callable gets its C symbol and `AbiSig` (ordered ABI slots from + │ `abi::lower_param` / `lower_return`), every interface its + │ `_clone`/`_destroy` symbols, every callback interface its vtable + ▼ +Plan ── weaveffi-core::plan: `ArgPass`, `RetPass`, `ErrorStrategy`, + │ `IteratorProtocol`, `AsyncProtocol`, `CallbackProtocol`: the + │ language-neutral calling contracts derived from the model + ▼ +Generate ── weaveffi-core::codegen::Orchestrator: checks each target's + │ `capabilities(config)` against the features the API uses, then + │ dispatches every selected target in parallel via rayon; each + │ `LanguageBackend` renders the model and plan in its own syntax ▼ Output ── Each target writes its files under {out_dir}/{target}/ and updates {out_dir}/.weaveffi-cache/{target}.hash ``` `validate` stops after the second stage; `generate`, `diff`, and `package` -run all of them. +run all of them. The `#[weaveffi::module]` proc-macro runs the same +pipeline up to the plan stage inside the producer's build and renders the +`extern "C"` thunks instead of a consumer wrapper. A `.rs` input is lowered to the IR by `weaveffi-bridge`, the same extractor the `#[weaveffi::module]` proc-macro uses to build a producer's C ABI glue. @@ -49,49 +62,54 @@ The workspace is structured as a small set of stable, focused crates. The dependency graph is acyclic and shallow: ```text -weaveffi-cli ──► weaveffi-core ──► weaveffi-ir - │ │ - │ ├──► weaveffi-gen-c - │ ├──► weaveffi-gen-cpp - │ ├──► weaveffi-gen-swift - │ ├──► weaveffi-gen-android - │ ├──► weaveffi-gen-node - │ ├──► weaveffi-gen-wasm - │ ├──► weaveffi-gen-python - │ ├──► weaveffi-gen-dotnet - │ ├──► weaveffi-gen-dart - │ ├──► weaveffi-gen-go - │ └──► weaveffi-gen-ruby - └──► weaveffi-bridge ──► weaveffi-ir (lowers annotated .rs to IR) - -Producer side (a Rust cdylib depends on these, not on the CLI): - -weaveffi ──► weaveffi-macros ──► weaveffi-bridge, weaveffi-core, weaveffi-ir - │ - └──► weaveffi-abi (the C ABI runtime, re-exported as `weaveffi::abi`) - -weaveffi-abi ──► (stand-alone, linked at run time by every cdylib that - exposes the WeaveFFI C ABI) - -weaveffi-fuzz ──► weaveffi-ir, weaveffi-core (workspace-private; unpublished) +Consumer side (the `weaveffi` binary): + +weaveffi-cli ──┬──► weaveffi-gen-c ──┐ + ├──► weaveffi-gen-cpp ──┤ + ├──► weaveffi-gen-swift ──┤ + ├──► weaveffi-gen-kotlin ──┤ + ├──► weaveffi-gen-node ──┤ + ├──► weaveffi-gen-wasm ──┼──► weaveffi-core ──► weaveffi-ir + ├──► weaveffi-gen-python ──┤ + ├──► weaveffi-gen-dotnet ──┤ + ├──► weaveffi-gen-dart ──┤ + ├──► weaveffi-gen-go ──┤ + ├──► weaveffi-gen-ruby ──┘ + ├──► weaveffi-core + └──► weaveffi-bridge ──► weaveffi-ir (lowers annotated .rs to IR) + +Producer side (a Rust cdylib depends on these, never on the CLI): + +weaveffi ──┬──► weaveffi-macros ──► weaveffi-bridge, weaveffi-core, weaveffi-ir + │ (build-time only: the proc-macro) + └──► weaveffi-abi (the C ABI runtime, re-exported as `weaveffi::abi`; + linked into every cdylib that exposes the ABI) + +weaveffi-fuzz ──► weaveffi-ir, weaveffi-core (workspace-private; unpublished) ``` +`weaveffi-abi` depends on no other workspace crate, and `weaveffi-core` +depends only on `weaveffi-ir`, so a producer's runtime never pulls the +generators in and a generator never pulls the runtime in. The two meet only +through the ABI revision number (`weaveffi_abi::ABI_VERSION` and +`weaveffi_core::cabi::ABI_VERSION`, kept equal by a test). + | Crate | What it owns | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `weaveffi-ir` | The IR types (`Api`, `Module`, `Function`, `TypeRef`, …), the `parse_api_str` parser, the `parse_type_ref` mini-grammar, and `CURRENT_SCHEMA_VERSION`. The document model only: no package or generator configuration. | -| `weaveffi-abi` | Stable C ABI runtime symbols: `weaveffi_abi_version`, `weaveffi_error`, `weaveffi_error_clear`, `weaveffi_free_string`, `weaveffi_free_bytes`, the arena, cancel tokens, the `lift_*`/`lower_*` marshalling converters the macro calls, and the `export_runtime!` macro. | +| `weaveffi-abi` | The C ABI runtime (revision `ABI_VERSION = 2`): the `weaveffi_error` struct and the `-1`..`-4` runtime codes, `weaveffi_error_set`/`_clear`/`_free`, `weaveffi_free_string`/`_bytes`, cancel tokens, and the `export_runtime!` macro that emits them as `#[no_mangle]` symbols. Plus the modules the macro-generated thunks call: `buffer` (the value-buffer reader and writer), `convert` (the `lift_*`/`lower_*` converters), `object` (reference-counted interface objects: `lower_object`, `object_clone`, `object_destroy`, `object_arc`, `object_from_token`), `callback` (consumer vtables: the `Vtable` trait, `ForeignCallback`, `check_foreign_error`), and `spawn` (the pluggable `Spawner` behind `set_spawner`, with `CatchUnwind` turning a panicking future into a `-2` completion). | | `weaveffi-bridge` | The single Rust-to-IR extractor: maps `#[weaveffi::module]`-annotated source (`syn` AST) to an `Api`. Shared by the proc-macro and the CLI's `extract`/`generate `. | -| `weaveffi-macros` | The `#[weaveffi::module]` proc-macro family. Lowers an annotated module through `weaveffi-bridge`, builds the `BindingModel`, and emits the `#[no_mangle] extern "C"` thunks (marshalling via `weaveffi-abi`). Emission is decomposed into nine focused submodules under `src/codegen/` (sync calls, async launchers, iterators, records, enums, interfaces, callbacks, marshalling, and shared helpers), each stating which clause of the `weaveffi_core::plan` contracts it implements. | +| `weaveffi-macros` | The `#[weaveffi::module]` proc-macro and its marker attributes (`export`, `record`, `enumeration`, `interface`, `callback_interface`, `error`, `cancellable`). Lowers an annotated module through `weaveffi-bridge`, builds the `BindingModel`, and emits the `#[no_mangle] extern "C"` thunks (marshalling via `weaveffi-abi`). Emission is decomposed into focused submodules under `src/codegen/` (`sync`, `async_fns`, `iterators`, `records`, `enums`, `interfaces`, `callbacks`, `marshal`, `helpers`), each stating which clause of the `weaveffi_core::plan` contracts it implements. | | `weaveffi` | The producer facade a Rust cdylib depends on: re-exports the `weaveffi-macros` attributes, `export_runtime!`, and `weaveffi-abi` as `weaveffi::abi`. | -| `weaveffi-core` | Validation and the `ResolvedApi` proof type, the resolved `Ty` type model with its `Family` and `WireType` classifications, the `BindingModel`, the `LanguageBackend` trait and its `Target`/`ConfiguredBackend` object-safe view, the `Orchestrator`, the `abi` C-ABI lowering model, the `cabi` shared C declaration renderer, the `plan` marshalling-plan module (the language-neutral calling contracts; see [The marshalling plan](#the-marshalling-plan)), the `pkg` package-identity policy, the `lang` per-language keyword tables, the `manifest` escaping helpers, and the per-target hash cache. | +| `weaveffi-core` | Validation and the `ResolvedApi` proof type, the resolved `Ty` type model with its `Family` and `WireType` classifications, the `BindingModel`, the `LanguageBackend` trait and its `Target`/`ConfiguredBackend` object-safe view, the `capabilities` feature gate, the `Orchestrator`, the `abi` C-ABI lowering model, the `cabi` shared C declaration renderer, the `plan` marshalling-plan module (the language-neutral calling contracts; see [The marshalling plan](#the-marshalling-plan)), the `errors` naming policy for generated error types, the `pkg` package-identity policy, the `lang` per-language keyword tables, the `manifest` escaping helpers, and the per-target hash cache. | | `weaveffi-gen-*` | Eleven generator crates. Each implements `LanguageBackend` and produces target-specific output (header, wrapper, package metadata). Each follows a shared internal layout; see [Generator crate layout](#generator-crate-layout). | | `weaveffi-cli` | The `weaveffi` binary. Loads the definition and the project's `weaveffi.toml`, applies validation, instantiates every target (via the `cli_targets!` registry in `config.rs`), and dispatches the `Orchestrator`. Subcommands live under `commands/` (`generate`, `validate`, `diff`, `package`); `extract.rs` sits beside `main.rs`; `config.rs` holds the target registry and `ProjectConfig`; `report.rs` formats CLI output. | | `weaveffi-fuzz` | `cargo-fuzz` harnesses for the parsers, the validator, and `parse_type_ref`. Workspace-private (not published to crates.io). | Crates that contain `unsafe` code opt in explicitly: `weaveffi-abi`, -`weaveffi-fuzz`, and any `samples/*` producer that dereferences a raw handle pointer in its own -helpers (such as `kvstore`) add `#![allow(unsafe_code)]` at the top of -their main source file. The thunks the `#[weaveffi::module]` macro emits +`weaveffi-fuzz`, and any `samples/*` producer that dereferences a raw +pointer in its own helpers (such as `kvstore`) add `#![allow(unsafe_code)]` +at the top of their main source file. The thunks the `#[weaveffi::module]` macro emits instead carry a scoped `#[allow(unsafe_code)]` on each generated function, so a macro-based producer needs no crate-level opt-in. The workspace-wide `unsafe_code = deny` lint forbids it everywhere else. @@ -152,19 +170,25 @@ that matter most: - `Api { version, modules }`: root node. Unknown top-level keys are rejected (`deny_unknown_fields`), so a stale `package:` or `generators:` block fails to parse instead of being silently dropped. -- `Module { name, doc, functions, interfaces, structs, enums, callbacks, - listeners, errors, modules }`: modules can nest. +- `Module { name, doc, functions, interfaces, callback_interfaces, + structs, enums, errors, modules }`: modules can nest. - `Function { name, params, returns, doc, throws, async, cancellable, - deprecated, since }`. The same shape describes an interface's - constructors, methods, and statics. -- `InterfaceDef { name, doc, constructors, methods, statics }`: an opaque - object type; every interface also receives an implicit destroy symbol. -- `TypeRef` enumerates every type reference a document can *write*: - primitives (`I32`, `U32`, `I64`, `F64`, `Bool`, `StringUtf8`, `Bytes`, - `Handle`, `BorrowedStr`, `BorrowedBytes`), the two user-type spellings - (`Named(String)` for any record, enum, or interface by bare or - dot-qualified name, and `TypedHandle(String)`), and the four composite - shapes (`Optional`, `List`, `Map`, `Iterator`). + deprecated }`. The same shape describes an interface's constructors, + methods, and statics and a callback interface's methods. +- `InterfaceDef { name, doc, deprecated, constructors, methods, statics }`: + a reference-counted object type; every interface also receives implicit + `_clone` and `_destroy` symbols. +- `CallbackInterfaceDef { name, doc, deprecated, methods }`: a method set + the consumer implements and the producer calls through a vtable. +- `TypeRef` enumerates every type reference a document can *write*: the + thirteen primitives (`I8` through `U64`, `F32`, `F64`, `Bool`, + `StringUtf8`, `Bytes`), one user-type spelling (`Named(String)` for any + record, enum, interface, or callback interface by bare or dot-qualified + name), and the four composite shapes (`Optional`, `List`, `Map`, + `Iterator`). Schema 0.9.0 removed `Handle`, `TypedHandle`, + `BorrowedStr`, and `BorrowedBytes` along with `Param.mutable` and + `Function.since`; the parser rejects each with a message naming its + replacement. The document never learns what a `Named` reference *is*. That knowledge lives one layer up, in `weaveffi_core`: @@ -179,31 +203,35 @@ lives one layer up, in `weaveffi_core`: - `weaveffi_core::model::Ty` is the *resolved* type every generator consumes: the same primitives and composites as `TypeRef`, but every user type carries its kind and dot-qualified name (`Record`, `RichEnum`, - `Enum`, `Interface`, `TypedHandle`). There is no unresolved variant, so - a generator can't forget to handle one. + `Enum`, `Interface`, `CallbackInterface`). There is no unresolved + variant, so a generator can't forget to handle one. - Two total classifications hang off `Ty`. `Ty::family()` says how a - value crosses a *call boundary* (`Direct`, `String`, `Bytes`, `Buffer`, - `Object { nullable }`, `Iterator`); the ABI lowering, the marshalling - plan, and every backend's argument handling dispatch on it. - `Ty::wire()` says how a value is encoded *inside a value buffer* - (`WireType::Prim`, `Handle`, `Enum`, `User`, `Optional`, `List`, - `Map`); every backend's codec emitter dispatches on it. Records, rich - enums, optionals, lists, and maps are the `Buffer` family: they cross - the ABI by value as one serialized `(const uint8_t*, size_t)` pair (see - the [Value Buffer Protocol](reference/value-buffers.md)), while `Enum` - lowers by value as an integer. + value crosses a *call boundary*: `Direct` (scalars, `bool`, C-style + enums, one slot by value), `String`, `Bytes`, `Buffer` (records, rich + enums, optionals, lists, maps, as one serialized `ptr`/`len` pair), + `Object { nullable }` (an interface or `Interface?`, one pointer slot), + `Callback` (a callback interface, a `ctx`/`vtable` pair), and + `Iterator`. The ABI lowering, the marshalling plan, and every backend's + argument handling dispatch on it. `Ty::wire()` says how a value is + encoded *inside a value buffer* (`WireType::Prim`, `Object`, `Enum`, + `User`, `Optional`, `List`, `Map`); every backend's codec emitter + dispatches on it. `Object` is the wire shape of an interface stored in + a record, list, map, or optional: a `u64` token carrying one strong + reference (see the [Value Buffer Protocol](reference/value-buffers.md)). + Callback interfaces and iterators have no wire shape because validation + never admits them inside a buffer. Every IR type derives `Debug`, `Clone`, `PartialEq`, `Eq`, `Serialize`, and `Deserialize`. -`TypeRef` (de)serializes as a string with custom syntax (`i32`, -`handle`, `[T]`, `{K:V}`, `T?`, `&str`, `&[u8]`). The parser is +`TypeRef` (de)serializes as a string with custom syntax (`i32`, `Name`, +`[T]`, `{K:V}`, `T?`, `iter`). The parser is `weaveffi_ir::ir::parse_type_ref`; both human-written IDL and the JSON Schema export rely on it. ### Schema versioning -`CURRENT_SCHEMA_VERSION` (currently `"0.8.0"`) lives in +`CURRENT_SCHEMA_VERSION` (currently `"0.9.0"`) lives in [`crates/weaveffi-ir/src/ir.rs`][ir-source]. Pre-1.0, `SUPPORTED_VERSIONS` contains exactly the current version; older schema revisions are rejected by validation with an actionable error. When you change the schema: @@ -235,33 +263,39 @@ Errors enforced today: - Reserved keyword rejection (`if`, `else`, `for`, `while`, `loop`, `match`, `type`, `return`, `async`, `await`, `break`, `continue`, `fn`, `struct`, `enum`, `mod`, `use`). -- Uniqueness of module/function/parameter/struct/enum/field/variant - names within their respective scopes, plus API-wide uniqueness of bare - type names (structs, enums, interfaces, and error domains share one - global namespace). +- Uniqueness of module/function/parameter/struct/enum/field/variant/ + interface/callback-interface names within their respective scopes, plus + API-wide uniqueness of bare type names (structs, enums, interfaces, + callback interfaces, and error domains share one global namespace). - Structs must have at least one field; enums at least one variant; - interfaces at least one member. + interfaces at least one member; callback interfaces at least one method. - Enum discriminant uniqueness within an enum. - Type references resolve by bare name across the whole API; the `ResolvedApi` index qualifies each to its owning module and kind (see [The IR](#the-ir) and [Cross-module references](reference/idl.md#cross-module-type-references)). - Interface members (constructors, methods, statics) share one namespace - per interface; constructors declare no return and cannot be async; - interface types are valid only as parameters, returns, and optionals of - those. Free functions and interface members share the module's C symbol - namespace. + per interface; constructors declare no return and cannot be async. + Interface types are valid in every position except as a map key. Free + functions and interface members (including the implicit `_clone` and + `_destroy`) share the module's C symbol namespace. +- Callback-interface methods are synchronous, never `throws`, and return + nothing or a `Direct` value; a callback interface type is valid only as + a bare top-level parameter of a function, constructor, static, or + method. - `throws: true` requires an error domain in scope (the module or an ancestor). -- Iterator return types are valid in return position only. -- Map keys must be a scalar, string, or enum type. -- `event_callback` on a listener must reference a callback in the same - module. +- Iterator types are valid only as the outermost return of a synchronous + callable. +- Map keys must be a scalar, `bool`, `string`, or C-style enum type. - Error domain name must not collide with a function name in the same module; codes must be positive (`0` means success and negative codes are reserved for the runtime) and unique within the domain; code names must be unique across every domain in the API. +The [IDL Reference](reference/idl.md#error-catalog) lists every +`ValidationError` variant with the message and suggestion the CLI prints. + Warnings emitted today: - `LargeEnumVariantCount` (>100 variants). @@ -270,27 +304,30 @@ Warnings emitted today: - `AsyncVoidFunction` (async without a return type). - `DeprecatedFunction` (informational). -Interfaces, typed error domains, async functions, cancellable functions, -listeners, callbacks, iterators (`iter`), typed handles (`handle`), -borrowed types (`&str`, `&[u8]`), nested modules, and cross-module type -references are all **first-class**. They pass validation and every -generator handles them. Do not re-add validator rejections for these -features. - -Per-target capability gating still exists as a mechanism: each -generator declares a `TargetCapabilities` (async, callbacks, listeners, -iterators), and the orchestrator fails generation (listing the -offending IDL definitions) when a selected target cannot deliver a -used feature. Today every shipped target declares full support, so the -gate only fires if a future target (or a new gated feature) introduces -a gap; a partial target's `allow_unsupported = true` config would opt -into generating the rest of the surface with explicit throwing stubs in -place of the unsupported entry points. The Wasm generator's Emscripten -mode is the one place stubs still appear: async functions, callbacks, -and listeners become explicit throwing stubs there (and are omitted -from the TypeScript declarations) because Emscripten modules do not -portably expose the trampoline machinery. Capability failures and mode -gaps must stay loud: never skip a definition silently. +Interfaces (in every position, including inside records, collections, +iterator elements, and async returns), callback interfaces, typed error +domains with structured payloads, async and cancellable functions, +iterators (`iter`), nested modules, and cross-module type references +are all **first-class**. They pass validation and every generator handles +them. Do not re-add validator rejections for these features. + +Per-target capability gating still exists as a mechanism. +`weaveffi_core::capabilities::Feature` names the gated features +(`AsyncFunctions`, `CallbackInterfaces`, `Iterators`), each generator +answers `LanguageBackend::capabilities(&self, config)` with a +`TargetCapabilities`, and the orchestrator runs `capabilities::check` +against `used_features(api)` before dispatch, failing generation (and +listing the offending IDL definitions) when a selected target cannot +deliver a used feature. The config is passed because a backend can have +modes with different ceilings: the Wasm generator's Emscripten mode +reports no async or callback support, and its `allow_unsupported = true` +flag (read through `LanguageBackend::allows_unsupported`) downgrades the +failure to a loud warning and makes the backend emit explicit throwing +stubs in place of the unsupported entry points (omitted from the +TypeScript declarations) because Emscripten modules do not portably expose +the trampoline machinery. Every other shipped target declares full +support. Capability failures and mode gaps must stay loud: never skip a +definition silently. ## Project configuration @@ -352,7 +389,8 @@ the object-safe `weaveffi_core::codegen::Target` view instead: ```rust,ignore pub trait Target: Send + Sync { fn name(&self) -> &'static str; // the `--target` token - fn capabilities(&self) -> TargetCapabilities; + fn capabilities(&self) -> TargetCapabilities; // the backend's answer for its bound config + fn allows_unsupported(&self) -> bool; fn generate(&self, api: &ResolvedApi, out_dir: &Utf8Path) -> Result<()>; fn output_files(&self, api: &ResolvedApi, out_dir: &Utf8Path) -> Vec; fn package(&self, api: &ResolvedApi, ctx: &PackageContext, out_dir: &Utf8Path) @@ -372,23 +410,29 @@ pub trait LanguageBackend: Send + Sync { type Config: Serialize + Default + Clone + Send + Sync; fn name(&self) -> &'static str; + /// Required: the gated features (async, callback interfaces, iterators) + /// this backend implements under `config`. Modes with different + /// ceilings (Wasm's Emscripten mode) answer differently per config. + fn capabilities(&self, config: &Self::Config) -> TargetCapabilities; + /// Whether `config` opts into generating despite unsupported features + /// (the backend must then emit explicit throwing stubs). Default `false`. + fn allows_unsupported(&self, config: &Self::Config) -> bool { false } + /// C ABI symbol prefix; the driver builds the `BindingModel` with it. fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str { "weaveffi" } - /// The single required hook: assemble every output file. Rendering is + /// The other required hook: assemble every output file. Rendering is /// pure; the driver performs the actual writes. fn files(&self, api: &ResolvedApi, model: &BindingModel, out_dir: &Utf8Path, config: &Self::Config) -> Vec; - /// Canonical per-module walk (error → enums → structs → interfaces → - /// callbacks → listeners → functions) with call-shape dispatch. - /// Single-pass backends override the `render_enum`/`render_struct`/ - /// `render_function` hooks and call this; multi-pass backends build - /// their layout in `files` directly. + /// Canonical per-module walk (error → enums → structs → callback + /// interfaces → interfaces → functions) with call-shape dispatch. + /// Single-pass backends override the `render_*` hooks and call this; + /// multi-pass backends build their layout in `files` directly. fn emit_members(&self, out: &mut String, module: &ModuleBinding, config: &Self::Config) { /* … */ } - // render_error / render_enum / render_struct / render_interface / - // render_callback / render_listener / render_function: all default - // to no-op. + // render_error / render_enum / render_struct / render_callback_interface / + // render_interface / render_function: all default to no-op. } ``` @@ -402,9 +446,9 @@ Python, Ruby, Go, Dart, and .NET are single-pass backends: they override the per-entity hooks and compose `emit_members` inside their module scoping. The rest build their layout directly in `files`: C and C++ order declarations by dependency, Swift splits types from the -namespaced module body, and Android, Node, and Wasm each render -parallel files (Kotlin + JNI C, addon C + JS, JS + `.d.ts`) in their -own passes. +namespaced module body, and Kotlin, Node, and Wasm each render +parallel files (Kotlin + JNI C, addon C + JS + `.d.ts`, JS + `.d.ts`) in +their own passes. Generators emit code into a `String`; there is no template-engine layer (an early Tera prototype intended for user template overrides was removed @@ -428,9 +472,15 @@ managed by the `CodeWriter` toolkit (see below) rather than by hand-rolled every backend renders (see [The marshalling plan](#the-marshalling-plan)). - `model::ty`: `Ty::wire()` folds every type into its wire shape inside a value buffer (`WireType`), so backend codec emitters dispatch on one - closed alphabet instead of each re-deriving the folds (handles encode - as `u64` tokens, borrowed views encode like their owned forms, records - and rich enums share one user-codec shape). + closed alphabet instead of each re-deriving the folds (objects encode + as `u64` tokens carrying one strong reference, records and rich enums + share one user-codec shape). +- `capabilities`: the `Feature` alphabet, `TargetCapabilities`, and the + `used_features`/`check` pair the orchestrator runs before dispatch. +- `errors`: the naming policy for generated error types (`WeaveFFIError` + versus `WeaveFFIException` brands, `type_name`, `exception_type_name`, + `pascal`), so no two targets disagree on how `KEY_NOT_FOUND` is spelled; + see [Naming and Package Conventions](reference/naming.md#error-domains-and-codes). - `cabi`: the shared renderer for C declarations (runtime prototypes, including `{prefix}_abi_version` and the `{PREFIX}_ABI_VERSION` macro, enums, interface typedefs, function prototypes) that both the C and C++ @@ -479,7 +529,7 @@ Design points that keep output stable and migrations safe: byte-for-byte. The Python backend (`weaveffi-gen-python`) is the reference adopter: its -return marshalling, getters, enums, callbacks, listeners, and the central +return marshalling, getters, enums, callback interfaces, and the central function renderer are built with `CodeWriter`. Remaining generators are being migrated onto it incrementally, each move guarded by the snapshot corpus. @@ -490,8 +540,10 @@ The signatures above use `Result` from `anyhow` and IR types from Implementation notes: - Implement `name()` (the `--target` flag value, e.g. `"swift"`), the - associated `Config` type, and `files()`; override `prefix()` when the - config carries a configurable `c_prefix`. + associated `Config` type, `capabilities()`, and `files()`; override + `prefix()` when the config carries a configurable `c_prefix` and + `allows_unsupported()` when a mode of the target can't deliver every + feature. - Return every emitted file from `files()`; `--dry-run` and `weaveffi diff` read the derived `output_files`, so there is no separate list to keep in sync. @@ -501,24 +553,32 @@ Implementation notes: ## C ABI naming convention -Every emitted C symbol follows -`{c_prefix}_{module}_{function}` (default `c_prefix = "weaveffi"`). -The `c_prefix` configuration is honored end-to-end: when set, the -generated C output uses it consistently, including references to -`weaveffi-abi` runtime symbols (`{c_prefix}_error`, -`{c_prefix}_error_clear`, `{c_prefix}_free_string`, -`{c_prefix}_free_bytes`, `{c_prefix}_abi_version`). Because the Rust +Every emitted C symbol follows `{c_prefix}_{module_path}_{function}` +(default `c_prefix = "weaveffi"`), interface members follow +`{c_prefix}_{module_path}_{Interface}_{member}` with the implicit +`_clone` and `_destroy` beside them, and callback interfaces contribute a +`{c_prefix}_{module_path}_{Name}_vtable` type and no symbols. The +`c_prefix` configuration is honored end-to-end: when set, the generated C +output uses it consistently, including references to `weaveffi-abi` +runtime symbols (`{c_prefix}_error`, `{c_prefix}_error_set`, +`{c_prefix}_error_clear`, `{c_prefix}_error_free`, +`{c_prefix}_free_string`, `{c_prefix}_free_bytes`, +`{c_prefix}_abi_version`, the cancel-token functions). Because the Rust runtime always exports the canonical `weaveffi_*` names, the header `#define`s each prefixed runtime name to its canonical symbol (the `utils::ABI_RUNTIME_SYMBOLS` table, kept in lockstep with -`export_runtime!`). +`export_runtime!`). The full naming policy, including the per-language +re-casing, is in [Naming and Package Conventions](reference/naming.md#generated-identifiers). ### ABI revision -`weaveffi_abi::ABI_VERSION` (mirrored as `weaveffi_core::cabi::ABI_VERSION` -and kept equal by a test in the `weaveffi` facade crate) numbers the -runtime surface: the `weaveffi_error` layout, the value-buffer encoding, -and the set and signatures of the `weaveffi_*` runtime symbols. +`weaveffi_abi::ABI_VERSION` (currently `2`; mirrored as +`weaveffi_core::cabi::ABI_VERSION` and kept equal by a test in the +`weaveffi` facade crate) numbers the runtime surface: the `weaveffi_error` +layout and runtime codes, the value-buffer encoding including object +tokens, the object `_clone`/`_destroy` and callback vtable conventions, +and the set and signatures of the `weaveffi_*` runtime symbols. The +normative text is the [C ABI Contract](reference/abi.md). `export_runtime!` exports it as `weaveffi_abi_version()`; the C header declares it and defines `{PREFIX}_ABI_VERSION`. Generated consumers embed the revision they were built for and, where a check costs one call at @@ -541,9 +601,12 @@ A `string` becomes one `const char*`; `bytes` becomes (records, rich enums, optionals, lists, maps; the `Buffer` [`Family`](#the-ir)) becomes the same borrowed `{name}_ptr` / `{name}_len` pair holding its serialized -[value buffer](reference/value-buffers.md); bytes and buffered returns -append a `size_t* out_len` pointer; and every fallible call ends with a -trailing `{prefix}_error*`. +[value buffer](reference/value-buffers.md); an interface becomes one +borrowed `const {tag}*` parameter or one owned `{tag}*` return; a callback +interface becomes `void* {name}_ctx, const {tag}_vtable* {name}_vtable`; +bytes and buffered returns append a `size_t* out_len` pointer; and every +synchronous symbol other than `_clone` and `_destroy` ends with a trailing +`{prefix}_error* out_err`. That calling convention is defined **once**, in [`weaveffi_core::abi`][abi-source], rather than re-derived inside each @@ -552,13 +615,16 @@ generator: - `CType`: a prefix-agnostic algebra of C types (`Int32`, `Size`, `Ptr { pointee, const_pos }`, `StructTag { module, name }`, …) with a single `render_c(prefix)` method that produces canonical C spelling. -- `element_ctype(ty, module)`: the C type of a single element. -- `lower_param(name, ty, module, mutable)`: expands one IDL parameter - into its ordered `AbiParam` slots. +- `lower_param(name, ty, module)`: expands one IDL parameter into its + ordered `AbiParam` slots. - `lower_return(ty, module)`: the return `CType` plus any trailing `out_*` `AbiParam`s. -- `callback_result_params(ty, module)`: the trailing slots an async - callback receives after `(context, err)`. +- `sync_signature`, `async_input_params`, `async_callback_params`, and + `callback_method_signature`: assemble a whole `AbiSig` for a synchronous + symbol, an `_async` launcher, its completion callback, and a + callback-interface vtable entry, adding the fixed slots + (`error_out_param`, `context_param`, `ctx_param`, `cancel_token_param`) + in their canonical positions. The C and C++ generators render these slots straight to C declarations, so their headers *are* the model by construction. The @@ -583,7 +649,7 @@ their generator rather than leaking into the shared model: Imperative generators (Go cgo, Node, Dart, Swift) build their FFI signatures inline with marshalling code and dispatch on `Ty::family()` -for the shape of each slot. The Android (JNI) and Wasm backends target +for the shape of each slot. The Kotlin (JNI) and Wasm backends target different ABIs entirely and do not consume the C lowering. When you add a parameter shape or change how a type crosses the @@ -599,26 +665,33 @@ language-neutral statement of the calling contracts every backend renders, the questions the eleven generators used to answer independently (and inconsistently): -- **Passing.** `ArgPass` (`ParamBinding::arg_pass()`) and `RetPass` - (`plan::ret_pass`): how one parameter or return crosses the boundary - (a direct scalar slot, a string pointer, a bytes or buffer - `ptr`/`len` pair, or an object handle), so marshalling code dispatches - on the plan instead of re-deriving the shape with local matches on - `Ty`. +- **Passing in.** `ArgPass` (`ParamBinding::arg_pass()`): how one + parameter crosses the boundary (`Direct`, `String`, `Bytes`, `Buffer`, + `Object` borrowed for the call, or `Callback` as a `ctx`/`vtable` pair), + so marshalling code dispatches on the plan instead of re-deriving the + shape with local matches on `Ty`. +- **Passing out.** `RetPass` (`plan::ret_pass`): the receiving contract + for a sync return, an async result, an iterator element, or a + callback-method argument (`Void`, `Direct`, `String`, `Bytes`, `Buffer`, + or `Object { destroy_symbol, clone_symbol, nullable }`). `Object` names + both lifecycle symbols because a wrapper that adopts one strong + reference eventually owes `destroy_symbol` and calls `clone_symbol` + whenever it needs a second reference, for example to write the object + into a value buffer. - **Errors.** `ErrorStrategy` (`Throws` | `Trap`): when a call reports through `out_err`, is the non-zero code a typed domain error the caller can catch (`throws: true`), or a producer bug the wrapper must trap on? `FnBinding::error_strategy()` answers it once. See the [Error Handling guide](guides/errors.md#throws-versus-trap). -- **Ownership.** `ReturnFree` and `ElemFree` (via the `return_free` and - `elem_free` functions): after copying a returned value, or one - array/map/iterator element, into a native one, exactly which runtime - release call does the wrapper owe (`{prefix}_free_string`, - `{prefix}_free_bytes`, a type's `_destroy` symbol), if any? +- **Ownership.** `Free` (`None` | `String` | `Bytes`, via + `RetPass::free()` and `plan::elem_free`): after copying a returned + value, or one list/map/iterator element, into a native one, exactly + which runtime release call does the wrapper owe? Adopted objects report + `Free::None` here and owe their `_destroy` instead. - **Iterators.** `IteratorProtocol` (`IteratorBinding::protocol`): the `iter` pull contract, including the requirement that wrappers stay lazy (one producer `next` call per consumer step, never a hidden - drain into a list), the per-element release plan, and the + drain into a list), the per-element `RetPass`, and the destroy-exactly-once handle lifecycle. - **Async.** `AsyncProtocol` (`AsyncBinding::protocol`): the completion-callback contract: the callback fires exactly once from an @@ -626,16 +699,25 @@ independently (and inconsistently): consumer: result buffers are released through the runtime free symbols, a heap-boxed error through `{prefix}_error_free`, and owned-object results are adopted. +- **Callback interfaces.** `CallbackProtocol` + (`CallbackInterfaceBinding::protocol`): the vtable layout (one entry per + method in declaration order plus the trailing `free`), the `RetPass` for + each method argument as received inside the consumer's trampoline + (strings and buffers borrowed for the dispatch, objects adopted), the + direct-only return, and the foreign-error rule (an exception becomes + `weaveffi_error_set(out_err, -4, …)`, never an unwind through C). Generators are thin syntax backends over this shared plan: a backend that renders these plans in its own syntax cannot drift from the others on semantics; only the spelling differs. The producer side consumes the same contracts: each `weaveffi-macros` codegen submodule states which plan clause it implements (the generated `_async` launchers hand the -callback owned results and a heap-boxed error, iterator thunks -yield one element per `_next`, and error dispatch follows -`ErrorStrategy`), so the emitted glue and every consumer wrapper agree -by construction. When a generator needs a free/destroy or throws/trap +callback owned results and a heap-boxed error, iterator thunks yield one +element per `_next`, `interfaces` emits `_clone`/`_destroy` over +`weaveffi_abi::object`, `callbacks` wraps a vtable in a +`weaveffi_abi::callback::ForeignCallback` and checks `out_err` after every +dispatch, and error dispatch follows `ErrorStrategy`), so the emitted glue +and every consumer wrapper agree by construction. When a generator needs a free/destroy or throws/trap decision, it should consume the plan rather than re-derive the fact with a local match. @@ -654,8 +736,8 @@ doesn't need): | `docs.rs` | Doc-comment emission in the target's comment syntax | | `codec.rs` | Value-buffer encode/decode emitters, dispatched on `Ty::wire()` | | `runtime.rs` | The fixed runtime prelude the generated code relies on | -| `calls.rs` | Sync/async/iterator wrappers, callbacks, listeners, marshalling | -| `entities.rs` | Enums, records, interfaces, typed error domains | +| `calls.rs` | Sync/async/iterator wrappers, callback trampolines, marshalling | +| `entities.rs` | Enums, records, interfaces, callback interfaces, typed error domains | | `package.rs` | Ecosystem manifests and READMEs | | `tests.rs` | The crate's test module, via `#[cfg(test)] mod tests;` | @@ -676,6 +758,38 @@ collect to a `Vec` and `sort_by_key`. Never rely on `HashMap` iteration order for output; CI snapshot tests will fail non-deterministically on different platforms or insta orderings. +## The test pyramid + +Each layer catches a different class of regression, and a change to the +ABI or the IR usually has to pass through all of them: + +| Layer | Where | What it pins | +|-------|-------|--------------| +| Unit tests | `#[cfg(test)]` modules in every crate; `tests.rs` in each generator | Parsing, validation rules, `Ty` classification, ABI lowering, per-generator naming and codec helpers | +| Property tests | `crates/weaveffi-abi/tests/buffer_proptest.rs` (`proptest`) | The value-buffer codec laws: `decode(encode(v)) == v` for every `BufferValue`, and encodings are self-delimiting (concatenations split back exactly; trailing bytes are an error) | +| Compile-fail tests | `crates/weaveffi-macros/tests/ui.rs` (`trybuild`) | The proc-macro's contract: `ui/pass_*.rs` must expand and compile, `ui/fail_*.rs` must be rejected with the pinned `.stderr` (a `&mut` parameter, a callback method returning `String`, an interface that isn't `Sync`, a `Result` without an error domain, and so on) | +| Runtime tests | `crates/weaveffi-abi/tests/export_runtime.rs`, `crates/weaveffi/tests/abi_version.rs` | `export_runtime!` exports every symbol in `ABI_RUNTIME_SYMBOLS`, and the two `ABI_VERSION` constants agree | +| Snapshots | `crates/weaveffi-cli/tests/snapshots.rs` (`insta`) | Byte-exact output of all eleven generators over the five-fixture corpus (below) | +| Determinism | `crates/weaveffi-cli/tests/determinism.rs` | Two runs over the kitchen-sink fixture are byte-identical | +| CLI and output audits | `crates/weaveffi-cli/tests/cli_*.rs`, `extract_roundtrip.rs`, `no_silent_stubs.rs`, `safety_audit.rs`, `docs_examples.rs` | Exit codes and diagnostics of every subcommand, including `diff --check`; `extract` round-trips an IDL unchanged; no `unimplemented!`/`todo!` or stub markers in generator sources or their output; each target's disposal idiom really calls `_destroy` and frees strings; the README and Getting Started IDL snippets validate and generate, and every `SUMMARY.md` link resolves | +| Conformance | `conformance/run.sh` | Real consumers in all eleven languages against the eight `samples/*` producers (`contacts`, `events`, `kvstore`, `shapes`, `async-demo`, `codec`, `inventory`, and the header-only `calculator`), each compiled and run to exit 0 | + +`weaveffi diff --check` is also how downstream projects gate their own +CI: it regenerates into a temporary directory, compares with the committed +output, and exits non-zero (with a distinct exit code for "would change") +without writing anything. + +The conformance matrix is the only layer that executes generated code +against a live producer, so it is where ownership bugs surface: every +language runs the `events` lane (a callback interface, an object handed +back through a callback, iterators), the `kvstore` lane (objects inside +records, lists, and optionals; async; an eviction callback interface), the +`async-demo` lane, the `codec` lane (every wire shape round-tripped, +including object tokens inside buffers), and `shapes` (rich enums); +`contacts` covers the original struct/enum/optional surface, and the +`inventory` lanes cover nested modules with cross-module references and +per-module error domains. + ## Snapshot tests `crates/weaveffi-cli/tests/snapshots.rs` runs every generator across a @@ -683,8 +797,10 @@ five-fixture corpus under `tests/fixtures/` (`kitchen_sink`, which exercises every IDL feature; `shapes`, rich enums and records; `nested_modules`, cross-module references; `docs_everywhere`, doc-comment emission; and `edge_cases`, reserved-word identifiers, deeply nested -composites, mutable and optional parameters, and the other shapes a -mainstream API never reaches). The eleven targets are driven by one data-driven +composites, optional parameters, interfaces in every legal position +including inside records, lists, maps, and iterators, callback interfaces +carrying composite payloads and objects, and the other shapes a mainstream +API never reaches). The eleven targets are driven by one data-driven `snapshot_tests!` macro, so adding a fixture or a target is a one-line change. Output is diffed via [`cargo-insta`][insta]. When a snapshot diff is intentional: @@ -713,8 +829,10 @@ A condensed checklist (the long version lives in `weaveffi-gen-c`. Add it to `members` in the root `Cargo.toml` and depend on `weaveffi-core` and `weaveffi-ir`. 2. Implement `weaveffi_core::backend::LanguageBackend`: define the - associated `Config` type, then `name`, `prefix` (if the config carries - a `c_prefix`), and `files` (returning every `OutputFile`). For a + associated `Config` type, then `name`, `capabilities` (declare every + feature you implement; the orchestrator fails loudly for the rest), + `prefix` (if the config carries a `c_prefix`), and `files` (returning + every `OutputFile`). For a single-pass layout, override the `render_enum`/`render_struct`/ `render_function` hooks and compose `emit_members`; otherwise build the layout directly in `files`. Reuse `BindingModel`, `Ty::family()`, and @@ -738,8 +856,11 @@ A condensed checklist (the long version lives in ## Where to read next -- [IDL Schema](reference/idl.md): the type system and validation +- [IDL Reference](reference/idl.md): the type system and validation rules from a user's perspective. +- [C ABI Contract](reference/abi.md) and + [Memory and Error Model](reference/memory-error.md): the normative ABI + and the ownership summary every generator must honor. - [Project Configuration](guides/config.md): every option a consumer can set in `weaveffi.toml`. - [Stability and Versioning](stability.md): what counts as a diff --git a/docs/src/comparison.md b/docs/src/comparison.md index 9d2a4956..8c31a9a3 100644 --- a/docs/src/comparison.md +++ b/docs/src/comparison.md @@ -2,141 +2,176 @@ WeaveFFI sits in a crowded ecosystem of FFI tooling. This page is an honest, side-by-side look at how it compares to the projects you are most likely to -evaluate against it: **UniFFI**, **cbindgen**, **diplomat**, **SWIG**, and -**autocxx**. +evaluate against it: **UniFFI** (Mozilla's multi-language generator), +**Diplomat** (the Rust-to-many-languages tool behind ICU4X), **cbindgen** plus +hand-written glue, the single-language bridges **swift-bridge**, **napi-rs**, +and **wasm-bindgen**, and the C/C++-first generators **SWIG** and **autocxx**. > All comparisons reflect the public state of each project at the time of -> writing. If something here is out of date, please open a PR. +> writing (ABI revision 2, schema `0.9.0`). If something here is out of date, +> please open a PR. + +## The short version + +WeaveFFI's distinguishing bet is *one language-neutral IDL, one C ABI, eleven +generators*. The producer can be Rust (with `#[weaveffi::module]` writing the +ABI for you) or anything else that can export C symbols, and every generated +package is standalone: consumers never install WeaveFFI. Since ABI revision 2 +the object model is on par with the Rust-first tools: interface objects are +reference counted (`Arc`), can be shared between wrappers and nested inside +records, lists, maps, optionals, iterators, and async results, and callback +interfaces let the producer call consumer code through a vtable. + +The honest flip side is that WeaveFFI is pre-1.0 and its type system is +deliberately smaller than UniFFI's: no user-defined generics or trait objects +with generics, callback methods return only scalars (no strings, buffers, or +objects yet), no async callback methods, and no multi-file IDL imports. The +[roadmap](roadmap.md) lists what's planned. ## At a glance -| | **WeaveFFI** | **UniFFI** | **cbindgen** | **diplomat** | **SWIG** | **autocxx** | -|------------------------------------|:------------:|:----------:|:------------:|:------------:|:--------:|:-----------:| -| Source language | Rust / C / C++ / Zig (anything with a C ABI) | Rust | Rust | Rust | C / C++ | C++ | -| Input format | YAML / JSON / TOML IDL or annotated Rust | UDL or proc-macro on Rust | Rust source (annotated) | Rust source (annotated) | C/C++ headers + `.i` interface | C++ headers | -| **Languages** | | | | | | | -| C | ✓ | ✗ | ✓ | ✓ | ✓ | ✗ | -| C++ | ✓ (RAII, `std::optional/vector/unordered_map`) | ✗ | ✓ (header) | ✓ | ✓ | ✓ (its purpose) | -| Swift | ✓ (SwiftPM, `async/await`, `throws`) | ✓ | ✗ | ✓ | ✗ | ✗ | -| Kotlin / Android (JNI) | ✓ (Kotlin + JNI shim + Gradle) | ✓ | ✗ | ✗ | ✓ (Java via JNI) | ✗ | -| Node.js | ✓ (N-API + `.d.ts`) | community add-on | ✗ | ✗ | ✓ (JavaScriptCore/V8) | ✗ | -| WebAssembly | ✓ (loader + `.d.ts`) | ✗ | ✗ | ✓ (JS via Wasm) | ✗ | ✗ | -| Python | ✓ (`ctypes` + `.pyi`) | ✓ | ✗ | ✗ | ✓ | ✗ | -| .NET / C# | ✓ (P/Invoke + `.csproj`) | ✓ (community) | ✗ | ✗ | ✓ | ✗ | -| Dart / Flutter | ✓ (`dart:ffi`) | community | ✗ | ✓ | ✗ | ✗ | -| Go | ✓ (CGo) | community | ✗ | ✗ | ✓ | ✗ | -| Ruby | ✓ (FFI gem) | ✗ | ✗ | ✗ | ✓ | ✗ | -| **Type system** | | | | | | | -| Primitives + `string` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `bytes` / byte slices | ✓ | ✓ | ✓ (raw) | ✓ | partial | ✓ | -| Structs | ✓ (idiomatic value types via value buffers) | ✓ (records & objects) | ✓ (`#[repr(C)]`) | ✓ (opaque) | ✓ | ✓ | -| Interfaces (objects w/ methods) | ✓ (constructors, methods, statics, implicit destroy) | ✓ (objects) | ✗ | ✓ (opaque types w/ methods) | ✓ (classes) | ✓ (C++ classes) | -| Typed error domains | ✓ (per-module codes, opt-in `throws`, native error types) | ✓ (error enums) | ✗ | partial (`Result`) | ✗ | ✗ | -| Enums w/ explicit discriminants | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Optionals | ✓ (`T?`) | ✓ | partial | ✓ | partial | ✓ | -| Lists | ✓ (`[T]`) | ✓ | partial | ✓ | ✓ | ✓ | -| Maps | ✓ (`{K:V}`) | ✓ | ✗ | ✓ | partial | partial | -| Typed handles (`handle`) | ✓ | ✓ (objects) | ✗ | ✓ (opaque) | partial | ✗ | -| Borrowed types (`&str`, `&[u8]`) | ✓ | partial | ✓ | ✓ | ✗ | ✓ | -| Iterators (`iter`) | ✓ | ✓ (callbacks) | ✗ | partial | partial | ✗ | -| Async functions | ✓ (callback ABI + `async/await`/`Promise`/`suspend`/`Task`) | ✓ | ✗ | partial | ✗ | ✗ | -| Cancellable futures | ✓ (`weaveffi_cancel_token`) | partial | ✗ | ✗ | ✗ | ✗ | -| Callbacks / event listeners | ✓ (module-level) | ✓ | ✗ (raw fn ptrs) | partial | partial | partial | -| Cross-module type references | ✓ | ✓ | n/a | ✓ | ✓ | ✓ | -| Nested modules | ✓ | partial | n/a | ✓ | ✓ | ✓ | -| **Workflow** | | | | | | | -| Single-binary CLI install | ✓ (`cargo install weaveffi-cli`) | ✓ | ✓ | ✓ | system package | ✓ | -| Standalone publishable packages | ✓ (npm, SwiftPM, pub.dev, NuGet, gem, etc.) | partial | n/a | partial | partial | n/a | -| JSON Schema for IDL editor support | ✓ | ✗ | n/a | n/a | ✗ | n/a | -| `extract` from annotated source | ✓ (Rust) | ✓ (proc-macro) | ✓ (Rust) | ✓ (Rust) | n/a | ✓ (C++) | -| `watch` mode | ✓ | ✗ | ✓ (`--watch`) | ✗ | ✗ | partial | -| `format` IDL canonicalizer | ✓ | ✗ | n/a | n/a | ✗ | n/a | -| Custom template overrides | ✗ | partial (Mako) | ✗ | partial | ✓ (`%typemap`) | partial | -| Snapshot-tested generator output | ✓ | ✓ | ✓ | ✓ | partial | ✓ | -| Maturity | pre-1.0 | 1.0+ in Mozilla shipping products | 1.0+ widely deployed | pre-1.0 | 30+ years, ubiquitous | pre-1.0 | -| License | MIT OR Apache-2.0 | MPL-2.0 | MPL-2.0 | BSD-3-Clause | GPL with FOSS exception | MIT OR Apache-2.0 | - -Legend: ✓ = first-class support; *partial* = supported with caveats or via -extensions; ✗ = not supported; *n/a* = not applicable to that tool's scope. +| | **WeaveFFI** | **UniFFI** | **Diplomat** | **cbindgen + glue** | **swift-bridge** | **napi-rs** | **wasm-bindgen** | **SWIG** | **autocxx** | +|------------------------------------|:------------:|:----------:|:------------:|:-------------------:|:----------------:|:-----------:|:----------------:|:--------:|:-----------:| +| Producer language | Rust, C, C++, Zig (anything with a C ABI) | Rust | Rust | Rust | Rust | Rust | Rust | C / C++ | C++ (consumed from Rust) | +| Input | YAML / JSON / TOML IDL or annotated Rust | UDL or proc-macros | annotated Rust bridge crate | Rust source | annotated Rust bridge module | annotated Rust | annotated Rust | C/C++ headers + `.i` file | C++ headers | +| Consumer languages | C, C++, Swift, Kotlin, Node.js, Wasm/JS, Python, .NET, Dart, Go, Ruby | Kotlin, Swift, Python, Ruby first-party; Go, C#, Dart, Kotlin Multiplatform, React Native as external bindgens | C, C++, JS/TS (Wasm), Dart, Kotlin (JNA), Python (nanobind) | C (you write the rest) | Swift | Node.js | JS/TS (Wasm) | many (Python, Java, C#, Ruby, Lua, Perl, PHP, R, ...) | Rust | +| Standalone C header | ✓ (the contract every target shares) | ✗ (private scaffolding ABI) | ✓ (C backend) | ✓ (its purpose) | ✓ (generated) | ✗ | ✗ | ✗ | n/a | +| **Type system** | | | | | | | | | | +| Records / enums / optionals / lists / maps | ✓ (value buffers) | ✓ | ✓ (structs, enums, `Option`, slices; no maps) | manual | ✓ (transparent structs and enums, `Option`, `Vec`) | ✓ (serde objects) | ✓ (`serde-wasm-bindgen` or classes) | ✓ | ✓ | +| Objects with methods | ✓ reference counted, shareable, nestable | ✓ (`Arc`) | ✓ (opaques, borrowed or owned) | manual | ✓ (opaque types) | ✓ (`#[napi]` classes) | ✓ (classes) | ✓ (classes) | ✓ (C++ classes) | +| Callback interfaces | ✓ (vtable; sync, scalar returns) | ✓ (foreign traits; any return, `throws`, async) | partial (Kotlin, C, C++; input-only) | manual fn pointers | partial (closures Rust to Swift) | ✓ (`ThreadsafeFunction`) | ✓ (closures) | partial (directors) | partial | +| Typed error domains | ✓ (per-module codes, opt-in `throws`, payload fields) | ✓ (error enums) | ✓ (`Result`) | manual | ✓ (`Result`) | ✓ (JS `Error`) | ✓ (`Result`) | ✗ | ✗ | +| Async functions | ✓ (callback ABI, pluggable spawner, cancel tokens) | ✓ (poll-based, foreign executors) | ✗ | manual | ✓ (both directions) | ✓ (Tokio) | ✓ (`Promise`) | ✗ | ✗ | +| Iterators | ✓ (`iter`, lazy on every target) | ✗ (materialize or use a trait) | partial (`DiplomatWrite`) | manual | ✗ | ✗ | ✓ (JS iterators) | partial | ✗ | +| Generics / trait objects | ✗ (fixed set of built-in shapes) | partial (traits, no generics) | partial (traits on some backends) | ✗ | partial | ✗ | ✗ | ✓ (templates via `%template`) | ✓ | +| Multi-file / multi-crate definitions | ✗ (one document per API) | ✓ (external types across crates) | ✓ (one bridge crate, many modules) | n/a | ✗ | n/a | n/a | ✓ (`%include`) | ✓ | +| **Workflow** | | | | | | | | | | +| Standalone CLI | ✓ (`cargo install weaveffi-cli`) | `uniffi-bindgen` (build.rs or CLI) | `diplomat-tool` | ✓ | `swift-bridge-cli` | `napi` CLI (npm) | `wasm-bindgen-cli` | system package | cargo build | +| Publishable per-ecosystem packages | ✓ (`weaveffi package` for every target) | partial | partial | n/a | ✓ (SwiftPM) | ✓ (npm) | ✓ (npm via wasm-pack) | ✗ | n/a | +| Schema-checked IDL with JSON Schema | ✓ | ✗ | n/a | n/a | n/a | n/a | n/a | ✗ | n/a | +| Load-time ABI version check | ✓ (`weaveffi_abi_version`) | ✓ (checksums) | ✗ | ✗ | ✗ | ✓ (N-API version) | ✗ | ✗ | n/a | +| Generated-output drift check in CI | ✓ (`weaveffi diff --check`) | build-time | build-time | ✓ | build-time | build-time | build-time | ✗ | build-time | +| Maturity | pre-1.0 | shipping in Firefox and Mozilla products since 2020 | shipping in ICU4X | 1.0+, widely deployed | 0.1.x, active | 2.x+, widely deployed | 0.2.x, ubiquitous | 30+ years | pre-1.0 | +| License | MIT OR Apache-2.0 | MPL-2.0 | MIT OR Apache-2.0 | MPL-2.0 | MIT OR Apache-2.0 | MIT | MIT OR Apache-2.0 | GPL-3.0 (generated code exempt) | MIT OR Apache-2.0 | + +Legend: ✓ = first-class support; *partial* = supported with caveats, on a +subset of backends, or via extensions; ✗ = not supported; *manual* = you write +it by hand; *n/a* = not applicable to that tool's scope. ## Where competitors are stronger -We try hard to be honest about the trade-offs. Pick the right tool for the job: +Pick the right tool for the job. These are the places where another project +is ahead of WeaveFFI today. -- **UniFFI is more mature.** It ships in production at Mozilla (Firefox Sync, - Glean, Nimbus) and has years of battle-testing across iOS, Android, and - desktop. If you only need Swift, Kotlin, and Python today and you are - comfortable with a UDL-or-proc-macro workflow, UniFFI is the safer choice. +- **UniFFI has the richer type system and more production mileage.** It + ships in Firefox, Firefox Sync, Glean, and Nimbus and has years of + battle-testing across iOS, Android, and desktop. Its foreign traits can + return any type (strings, records, objects), can `throw`, and can be + `async`; WeaveFFI's callback-interface methods are synchronous and return + only scalars, `bool`, or a C-style enum. UniFFI also supports external types + across crates, whereas a WeaveFFI API is one IDL document (or one annotated + Rust source tree). If your matrix is Kotlin, Swift, and Python and you want + maximum maturity, UniFFI is the safer pick. +- **Diplomat has the more mature Kotlin and JS story for a Rust library.** + It powers ICU4X, its bridge-crate model keeps everything in Rust, and its + C++ backend is polished for slotting into existing C++ builds. Its opaques + support borrowed as well as owned references, which WeaveFFI does not + model (every WeaveFFI object crossing is a strong reference). Diplomat has + no async support and no maps, and callbacks exist only on some backends. - **cbindgen is simpler if all you want is a C header.** WeaveFFI generates - a C header *and* ten other targets. If you only consume the C surface - from C/C++ code, cbindgen has less ceremony, no IDL file, and a smaller - dependency footprint. -- **diplomat has a more polished C++ story.** Its C++ output uses richer - templates and integrates more cleanly with existing C++ codebases. WeaveFFI's - C++ output is RAII-based and includes a `CMakeLists.txt`, but it's - optimized for greenfield projects, not for slotting into a 20-year-old - C++ build system. -- **SWIG covers languages WeaveFFI doesn't.** Lua, Tcl, R, Octave, Perl, PHP: - if your target is exotic, SWIG probably has a generator. SWIG also - natively understands C and C++ headers, so you don't need to author an - IDL at all. -- **autocxx is unmatched for "wrap an existing C++ library."** It reads - your C++ headers directly and uses bindgen + cxx under the hood. WeaveFFI - does not parse C++; you describe the surface area you want to expose, and - WeaveFFI generates the contract. -- **No IDE plugin yet.** The other tools listed have community VSCode/JetBrains - extensions of varying quality. WeaveFFI ships a JSON Schema for editor - autocompletion and a `format` command, but no first-party IDE plugin. -- **No formal stability guarantee yet.** WeaveFFI is pre-1.0; the IDL, - generated output, and runtime symbol names can shift in minor releases. - UniFFI, cbindgen, and SWIG offer stronger compatibility commitments - today. + a C header *and* ten other targets. If you only consume the C surface from + C or C++ code, cbindgen has less ceremony, no IDL, and a smaller footprint. + You write the object lifecycle, error channel, and any callback plumbing + yourself. +- **swift-bridge, napi-rs, and wasm-bindgen are deeper in their one + language.** Each exposes idioms WeaveFFI's common denominator can't: + swift-bridge bridges async functions in *both* directions and transparent + Swift structs; napi-rs gives you the full N-API surface (`ThreadsafeFunction`, + typed arrays, `AsyncTask`, class inheritance) and Tokio integration; + wasm-bindgen talks to the whole Web platform through `web-sys` and + `js-sys` and runs futures on the JS event loop. If you ship to exactly one + of those ecosystems, the dedicated bridge is the better fit. +- **SWIG covers languages WeaveFFI doesn't.** Lua, Tcl, R, Octave, Perl, PHP, + Java: if your target is exotic, SWIG probably has a generator, and it reads + C and C++ headers directly so you author no IDL. It also handles C++ + templates. +- **autocxx is unmatched for "wrap an existing C++ library."** It reads your + C++ headers and uses bindgen plus cxx under the hood. WeaveFFI does not + parse C++; you describe the surface you want to expose and implement the + generated header. +- **WeaveFFI's Wasm target is single-threaded.** The default + `wasm32-unknown-unknown` build has no threads, so async functions resolve + inline and callback-interface methods fire only while a call into the + module is on the stack; a producer that calls back from a spawned thread + can't run there. wasm-bindgen's `wasm-bindgen-futures` integrates with the + JS event loop natively, and Emscripten-based toolchains can use + `pthread`s. In WeaveFFI's Emscripten compatibility mode, async functions + and callback interfaces are not available at all. +- **No formal stability guarantee yet.** WeaveFFI is pre-1.0; schema `0.9.0` + and ABI revision 2 removed constructs without compatibility shims (see the + [migration guide](stability.md#migrating-from-schema-080--abi-1-to-090--abi-2)). + UniFFI, cbindgen, napi-rs, wasm-bindgen, and SWIG offer stronger + compatibility commitments today. ## When to choose WeaveFFI WeaveFFI is the right pick when you want: -1. **One source of truth for many languages.** If your library has to land - in npm *and* SwiftPM *and* PyPI *and* NuGet *and* pub.dev *and* RubyGems - *and* a Go module *and* a Gradle artifact, that's the WeaveFFI sweet - spot. UniFFI covers a smaller subset out of the box; cbindgen and - autocxx don't try. -2. **Standalone, publishable consumer packages.** Generated packages are - self-contained: a Swift consumer adds your `.xcframework` + a SwiftPM - manifest and is done. No "install WeaveFFI" step on the consumer side. -3. **A native library that isn't (only) Rust.** WeaveFFI works against - anything that exposes a stable C ABI: Rust (with the - `#[weaveffi::module]` macro generating the ABI for you), C, C++, Zig, - etc. UniFFI and diplomat assume Rust; autocxx assumes C++. -4. **Idiomatic per-target output, not a lowest-common-denominator API.** - Async functions become `async/await` in Swift, `Promise`s in Node, - `suspend fun` in Kotlin, `async def` in Python, and `Task` in C#, - all from the same `async: true` flag in the IDL. -5. **A CLI workflow with `validate`, `lint`, `diff`, `watch`, and - `format`.** WeaveFFI is built for monorepos and CI: every sub-command - has a `--format json` output mode, and `diff --check` and - `format --check` are designed to drop into pre-commit and CI gates. -6. **Honest pre-1.0 churn, documented every release.** Every breaking IDL - change is called out in `CHANGELOG.md` with a migration note, and - `weaveffi validate` rejects out-of-date schema versions with an - actionable error instead of silently misreading them. +1. **One source of truth for many languages.** If your library has to land in + npm *and* SwiftPM *and* PyPI *and* NuGet *and* pub.dev *and* RubyGems + *and* a Go module *and* a Gradle artifact, that's the WeaveFFI sweet spot. + UniFFI and Diplomat cover a smaller set out of the box; the + single-language bridges don't try. +2. **A native library that isn't (only) Rust.** WeaveFFI works against + anything that exposes a C ABI: Rust (with the `#[weaveffi::module]` macro + generating the ABI for you), C, C++, Zig, and so on. UniFFI, Diplomat, + swift-bridge, napi-rs, and wasm-bindgen assume Rust; autocxx assumes C++. +3. **Objects that behave the same everywhere.** Reference-counted interfaces + with deterministic release (`close()`, `Dispose()`, `deinit`, RAII) and a + garbage-collector backstop on every managed target, and the ability to put + an object inside a record, a list, a map, an optional, an iterator, or an + async result on all eleven targets, from one declaration. +4. **Callback interfaces without hand-written trampolines.** Declare the + methods once and each target gets a protocol, interface, or abstract class + to implement; the producer receives an `Arc` it can retain and + call from any thread, and a consumer-side exception surfaces to the + original caller as a typed foreign error. +5. **Standalone, publishable consumer packages.** Generated packages are + self-contained; `weaveffi package` bundles the native library per platform + (including `jniLibs//` for Kotlin and the `.wasm` for npm). There is no + "install WeaveFFI" step on the consumer side. +6. **Idiomatic per-target output, not a lowest-common-denominator API.** + Async functions become `async/await` in Swift, `Promise`s in Node and + Wasm, `suspend fun` in Kotlin, `async def` in Python, `Task` in C#, and + `Future` in Dart, all from the same `async: true` flag; Rust producers + can plug their own executor with `weaveffi::set_spawner`. +7. **A CI-first CLI.** `validate`, `diff --check`, `extract`, `schema`, and + `package` are designed to drop into pipelines, every generator's output is + byte-for-byte deterministic, and generated consumers refuse to load a + producer built for a different ABI revision. ## When to choose something else -- **You only need Swift + Kotlin + Python and want maximum stability**: - use UniFFI. +- **You only need Kotlin, Swift, and Python and want maximum stability, or + you need callback methods that return strings, records, or objects, or + async callbacks**: use UniFFI. - **You only need a C header for a Rust crate**: use cbindgen. -- **You're wrapping a large existing C++ codebase**: use autocxx (or - cxx + bindgen directly). +- **You ship to exactly one of Swift, Node.js, or the browser**: use + swift-bridge, napi-rs, or wasm-bindgen respectively. +- **You're wrapping a large existing C++ codebase from Rust**: use autocxx (or + cxx plus bindgen directly). - **Your target language is Lua, Tcl, R, Octave, Perl, or PHP**: use SWIG. -- **You need a battle-tested C++ binding generator with rich template - support**: use diplomat or SWIG. +- **You want a Rust-only bridge crate with borrowed-reference semantics and a + polished C++ backend**: use Diplomat (`#[diplomat::bridge]`, from the + `rust-diplomat/diplomat` repository). -## Migrating to / from WeaveFFI +## Migrating to or from WeaveFFI -WeaveFFI's IDL is intentionally close to UniFFI's UDL surface area, which -makes hand-porting straightforward in either direction. There is no -automatic UDL → WeaveFFI converter today, but `weaveffi extract` can read -annotated Rust source and produce a starting IDL, which is often the -fastest path off any Rust-only generator. See the -[extract guide](guides/extract.md) for details. +WeaveFFI's IDL is intentionally close to UniFFI's UDL surface area (records, +enums, interfaces, callback interfaces, error enums, `async`), which makes +hand-porting straightforward in either direction. There is no automatic UDL +to WeaveFFI converter today, but `weaveffi extract` can read annotated Rust +source and produce a starting IDL, which is often the fastest path off any +Rust-only generator. See the [extract guide](guides/extract.md) for details, +and the [C ABI contract](reference/abi.md) if you're bringing a non-Rust +producer to the generated header. diff --git a/docs/src/faq.md b/docs/src/faq.md index 0b16c12a..fbd90a24 100644 --- a/docs/src/faq.md +++ b/docs/src/faq.md @@ -1,7 +1,8 @@ # FAQ -The top ten questions we hear about WeaveFFI. For broader context see the -[introduction](intro.md), the [comparison page](comparison.md), and the +The questions we hear most about WeaveFFI. For broader context see the +[introduction](intro.md), the [comparison page](comparison.md), the +[C ABI contract](reference/abi.md), and the [per-target generator docs](generators/README.md). ## 1. Why not UniFFI? @@ -11,23 +12,30 @@ production at Mozilla, and is the right choice if you only need Swift, Kotlin, and Python. We built WeaveFFI because we needed: - **More targets out of the box.** WeaveFFI ships first-class generators - for C, C++, Swift, Kotlin/Android, Node.js, Wasm, Python, .NET, Dart, - Go, and Ruby, eleven in total. UniFFI's first-party language list is - shorter and the rest live as community extensions of varying maturity. + for C, C++, Swift, Kotlin, Node.js, Wasm, Python, .NET, Dart, Go, and + Ruby, eleven in total, all consuming one C ABI. UniFFI's first-party + language list is shorter and the rest live as external bindgens of varying + maturity. - **A standalone CLI workflow.** WeaveFFI is a single binary - (`cargo install weaveffi-cli`) with `validate`, `lint`, `diff`, - `watch`, `format`, and `extract` subcommands designed to - drop into CI. UniFFI is a build-script integration first. + (`cargo install weaveffi-cli`) with `generate`, `validate`, `diff`, + `extract`, and `package` subcommands designed to drop into CI. UniFFI is a + build-script integration first. - **A non-Rust-only story.** WeaveFFI's IR is language-agnostic: any - backend that can expose a stable C ABI (Rust, C, C++, Zig, …) can be - driven from the same IDL. UniFFI is Rust-first. + backend that can expose a stable C ABI (Rust, C, C++, Zig, ...) can be + driven from the same IDL and implement the same generated header. UniFFI + is Rust-first and its scaffolding ABI is private. - **A YAML/JSON/TOML IDL with a JSON Schema.** WeaveFFI ships `weaveffi.schema.json` for editor autocompletion. UniFFI's UDL is - custom-syntax and proc-macro is Rust-only. + custom-syntax and its proc-macro path is Rust-only. -If your matrix is only Swift+Kotlin+Python and you want maximum -maturity today, UniFFI is the safer pick. See the -[comparison page](comparison.md) for the full table. +Since ABI revision 2 the object model is comparable: WeaveFFI interfaces are +reference-counted `Arc` objects that can be shared and nested inside +records and collections, and callback interfaces are traits the consumer +implements. UniFFI is still ahead on callback methods that return strings, +records, or objects, on async callback methods, and on cross-crate type +imports (see the [roadmap](roadmap.md)). If your matrix is only +Swift+Kotlin+Python and you want maximum maturity today, UniFFI is the safer +pick. See the [comparison page](comparison.md) for the full table. ## 2. Can I use it with C++ codebases? @@ -50,41 +58,50 @@ Two distinct cases: Yes, with a curated set of built-in generic shapes rather than open user-defined generics: -- `handle`: typed opaque pointers (compile-time-checked handle - types per resource). -- `iter`: lazy streaming sequences with `_next` / `_destroy` ABI. +- `iter`: lazy streaming sequences with a `_next` / `_destroy` ABI. - `[T]`: homogeneous lists. -- `{K:V}`: homogeneous maps (serialized as value buffers at the - C ABI). +- `{K:V}`: homogeneous maps. - `T?`: optionals. -- `&str`, `&[u8]`: borrowed views (no copy at the boundary). -We deliberately do **not** support arbitrary user-defined generics -(e.g. `Result` parameterized at the IDL level). -Cross-language generic monomorphization is a rabbit hole; the -built-in shapes cover ~95% of real-world FFI surface area without -requiring every target generator to implement type-erasure logic. +All four compose with each other and with every other type, including +interface objects: `[Store]`, `{string:Store}`, `Store?`, and `iter` +are all valid, and records may carry any of them as fields. Lists, maps, +optionals, records, and rich enums cross the C ABI serialized as +[value buffers](reference/value-buffers.md); objects inside them travel as +tokens that carry one strong reference. + +We deliberately do **not** support arbitrary user-defined generics (for +example `Result` parameterized at the IDL level) or traits +with generic methods. Cross-language generic monomorphization is a rabbit +hole; the built-in shapes cover the large majority of real-world FFI surface +area without requiring every target generator to implement type-erasure +logic. Trait-object interfaces (one declared method set, several producer +implementations) are on the [roadmap](roadmap.md). ## 4. What's the runtime overhead? WeaveFFI itself adds **no runtime** beyond the small `weaveffi-abi` -crate (a few hundred lines: error helpers, string/byte-slice -allocators, cancel tokens). Per-call overhead is the cost of: - -1. Marshalling arguments across the C ABI (string→`const char*`, - list→`*ptr + len`, etc.). Borrowed types (`&str`, `&[u8]`) avoid - copies. +crate (error helpers, string and byte-buffer allocators, the value-buffer +codec, the object reference-count shims, the callback vtable shims, cancel +tokens, and the async spawner hook). Per-call overhead is the cost of: + +1. Marshalling arguments across the C ABI (`string` to `const char*`, + `bytes` to `ptr + len`, and so on). String and byte parameters are + borrowed views for the duration of the call, so the producer copies only + what it keeps. 2. The single `extern "C"` function call. 3. Marshalling the return value back. For primitive arguments and return types, this is roughly the cost of -a normal function call plus an out-pointer write for the error. For -larger structs, lists, and maps, it's dominated by the underlying -allocation/copy cost, not by anything WeaveFFI inserts. +a normal function call plus an out-pointer write for the error. Passing an +object is one pointer; the producer clones the `Arc` only if it retains it. +For larger structs, lists, and maps, it's dominated by the encode/decode of +the value buffer, which is a single allocation per value on each side. Async functions add a callback indirection (the C ABI is callback-based) -plus whatever runtime your backend uses. There is no scheduler imposed -by WeaveFFI; the implementation chooses how to spawn work. +plus whatever executor drives the future. The default spawner runs each +future on a dedicated thread; producers that already have a runtime plug it +in with `weaveffi::set_spawner` (see question 13). ## 5. How are errors propagated? @@ -92,7 +109,9 @@ At the C ABI, generated functions take a trailing `weaveffi_error* out_err` parameter. On success the runtime sets `code = 0` and `message = NULL`. On failure it sets a non-zero code and a heap-allocated UTF-8 message that the caller frees via -`weaveffi_error_clear`. +`weaveffi_error_clear`. Negative codes are reserved for the runtime: +`-1` generic, `-2` producer panic, `-3` marshalling failure, and `-4` +a consumer callback-interface implementation raised. Above the ABI, error surfacing is opt-in per function: a module declares an error domain (`errors:` in the IDL) with named, stable @@ -132,9 +151,10 @@ Yes, via two escape hatches in increasing order of power: 1. **Project config** (`[generators.]` tables in the `weaveffi.toml` next to your definition). Controls Swift module - names, Android package, C prefix, C++ namespace, Dart/Go/Ruby package - names, module-prefix stripping (`strip_module_prefix`), and other - per-target knobs. See the [Project Configuration guide](guides/config.md). + names, the Kotlin package, C prefix, C++ namespace, Dart/Go/Ruby package + names, module-prefix stripping (`strip_module_prefix`), Wasm's + Emscripten mode, and other per-target knobs. See the + [Project Configuration guide](guides/config.md). 2. **Hook commands** (`pre_generate` / `post_generate` in the config). Run arbitrary shell commands before and after generation, useful for `prettier`, `swiftformat`, `gofmt`, etc. @@ -185,20 +205,26 @@ Windows-specific issue, please open an issue. ## 9. How do I distribute the cdylib? You build a platform-specific shared library per target triple and -ship it alongside the generated package. Three common patterns: +ship it alongside the generated package. `weaveffi package` does the +assembly: point it at prebuilt libraries with `--binaries ` (or let it +cross-compile a Rust producer with `--build `) and it lays each +target's package out the way its ecosystem expects, including +`jniLibs//` for Kotlin and the `.wasm` binary inside an npm package for +Wasm (which needs a `wasm32` build). Three common layouts: - **Per-platform npm/PyPI/gem packages.** Publish one package per - `(os, arch)` and use a small loader in the consumer that picks the - right binary at install or runtime. WeaveFFI generates the - TypeScript/Python/Ruby loader, you supply the binaries. + `(os, arch)` and let the generated loader pick the right binary at + runtime. WeaveFFI generates the TypeScript/Python/Ruby loader, you supply + the binaries. - **`xcframework` for Swift.** Bundle iOS device, iOS simulator, and macOS slices into a single `.xcframework` that SwiftPM can consume. The generated `Package.swift` references it as a `.binaryTarget`. -- **`.aar` for Android.** Package the JNI shim + per-ABI `.so` files - into an Android Archive that Gradle resolves like any other - dependency. The generated `build.gradle` skeleton is compatible - with this layout. +- **`.aar` for Android.** Package the JNI shim plus per-ABI `.so` files + (`android-arm64`, `android-x64`) into an Android Archive that Gradle + resolves like any other dependency. The generated `build.gradle.kts` is + compatible with this layout, and the same wrapper also runs on the desktop + JVM. The name, version, and metadata stamped into every generated manifest (`package.json`, `pyproject.toml`, `*.gemspec`, `*.csproj`, `pubspec.yaml`, @@ -206,10 +232,11 @@ The name, version, and metadata stamped into every generated manifest [`[package]` table](guides/config.md#package) of your `weaveffi.toml`, so you set your identity once and every ecosystem stays in sync. -There is no opinionated "weaveffi publish" command today; you use -each ecosystem's normal publish flow. The -[generator-specific docs](generators/README.md) cover the recommended -build matrix per language. +There is no "weaveffi publish" command; you use each ecosystem's normal +publish flow on the output of `weaveffi package`. See the +[Packaging and Distribution guide](guides/packaging.md) and the +[generator-specific docs](generators/README.md) for the recommended build +matrix per language. ## 10. What's the licensing? @@ -223,3 +250,97 @@ projects without restriction. Generated code carries no license header of its own; it's yours to license however you like. Contributions to the WeaveFFI repo are accepted under the same MIT-or-Apache-2.0 dual license; see [`CONTRIBUTING.md`](https://github.com/weavefoundry/weaveffi/blob/main/CONTRIBUTING.md#license). + +## 11. Who owns an object, and when is it freed? + +The producer does. Every interface object is reference counted on the native +side (an `Arc` in a Rust producer), and the C ABI exposes two symbols per +interface: `{tag}_clone`, which returns a new strong reference to the same +object, and `{tag}_destroy`, which releases one. The object is dropped when +the last reference goes away, wherever that reference lives. + +Each generated wrapper holds exactly one strong reference and releases it +through the language's natural disposal hook: `deinit` in Swift, the +destructor in C++ (copying a wrapper calls `_clone`), `close()` or a `using` +declaration in Node.js and Wasm, `close()` or a `with` block in Python, +`Dispose()` in .NET, `close()` (`AutoCloseable`) in Kotlin, `dispose()` in +Dart, `Close()` in Go, and `close` in Ruby. Every managed target also +registers a garbage-collector backstop (`Cleaner`, `FinalizationRegistry`, +`__del__`, a finalizer, `NativeFinalizer`, `runtime.SetFinalizer`, +`FFI::AutoPointer`) so a wrapper that's never closed still releases its +reference eventually. Closing a wrapper twice is a no-op; using one after +closing it throws. + +Because the count lives in the producer, ownership is uniform in every +position: an object returned from a call, produced by an iterator, delivered +by an async completion, or passed to a callback-interface method is one +reference the consumer adopts. An object passed *to* the producer as a +parameter is borrowed for the call, and the producer clones it if it keeps +it. An object inside a record, list, map, or optional travels as a token +carrying one reference, so two wrappers (or a wrapper and a producer-side +`Arc`) can point at the same object and neither can free it out from under +the other. See [Memory Ownership](guides/memory.md) and the +[C ABI contract](reference/abi.md#objects-interfaces). + +## 12. How do callback interfaces work? + +A callback interface is a set of methods the consumer implements and the +producer calls. In the IDL it's a `callback_interfaces:` entry with +`methods:`; in Rust it's + +```rust +#[weaveffi::callback_interface] +pub trait EvictionListener: Send + Sync { + fn on_evict(&self, entry: &Entry, reason: EvictionReason) -> bool; +} +``` + +accepted by any function, constructor, static, or method as +`Arc`. Each target exposes it as the natural thing to +implement: a Swift `protocol`, a Kotlin, Go, or C# interface, a Python `ABC`, +a Dart `abstract class`, a C++ class with virtual methods passed as a +`std::shared_ptr`, a TypeScript `interface` in Node.js and Wasm, and a Ruby +module. At the C ABI it's a `void* ctx` plus a static vtable of function +pointers with a trailing `free(ctx)`; the producer may call the methods any +number of times from any thread and calls `free` exactly once when it drops +its last reference. There is no unregister call to forget. + +Methods are synchronous, never `throws` or `async`, and return nothing, a +scalar, `bool`, or a C-style enum; parameters may be anything except another +callback interface or an iterator, and an object parameter hands the consumer +a reference it adopts. If the consumer's implementation raises, the wrapper +reports it through the method's `out_err` slot with `FOREIGN_ERROR_CODE` +(`-4`); the producer aborts the call it was making (in Rust, the call +unwinds) and the original caller sees that code and message instead of a +crash. Producers should therefore not hold a `Mutex` guard across a callback +call. Richer return types and async callback methods are on the +[roadmap](roadmap.md). The `events` and `kvstore` [samples](samples.md) show +both patterns. + +## 13. Which executor runs my async functions? + +Whichever you install. An `async` export lowers to a launcher that returns +immediately and a completion callback that fires exactly once, from a +producer thread. Something has to drive the future in between, and the +`weaveffi-abi` runtime has a pluggable `Spawner` hook for that. The default +spawner needs no runtime: it detaches a thread per future and blocks on it, +which is fine for CPU-bound work and for futures woken from other threads. + +A producer whose futures need a reactor (Tokio's I/O or timers, for example) +calls `weaveffi::set_spawner` once at startup: + +```rust +let handle = tokio::runtime::Handle::current(); +weaveffi::set_spawner(move |fut| { + handle.spawn(fut); +}) +.expect("spawner installed once"); +``` + +The first call wins; a second returns `SpawnerAlreadySet`. Every future handed +to a spawner is already wrapped so a panic inside it is caught and reported +through the completion callback as `PANIC_ERROR_CODE`, so a spawner never +sees an unwinding future. On `wasm32`, which has no threads, the default +spawner drives the future inline before the launcher returns. Consumers don't +see any of this: they get `async/await`, `Promise`, `suspend`, `Task`, or +`Future` as usual. See the [Async Functions guide](guides/async.md). diff --git a/docs/src/generators/README.md b/docs/src/generators/README.md index 4593ca22..e82832cc 100644 --- a/docs/src/generators/README.md +++ b/docs/src/generators/README.md @@ -5,48 +5,65 @@ artifacts they produce. Choose a target below to explore the details. ## Feature support matrix -Every generator implements the full IDL surface (structs, enums, -interfaces, optionals, lists, maps, typed handles, borrowed parameters, -typed error domains with opt-in `throws`, and nested modules) -plus the call shapes below. A -generator that cannot support a feature declares it in its -`TargetCapabilities`, and `weaveffi generate` fails loudly when an IDL -uses a feature the selected target cannot deliver (no silent skips). +Every generator implements the full IDL surface of schema 0.9.0 on ABI +revision 2: records, plain and rich enums, reference-counted interface +objects (including nullable objects and objects inside records, lists, +optionals, iterators, and async results), callback interfaces, +optionals, lists, maps, typed error domains with opt-in `throws`, and +nested modules, plus the call shapes below. A generator that cannot +support a feature declares it in its `TargetCapabilities`, and +`weaveffi generate` fails loudly when an IDL uses a feature the +selected target cannot deliver (no silent skips). -| Target | Async functions | Iterators (`iter`) | Callbacks | Listeners | -|--------|:---:|:---:|:---:|:---:| -| C | ✓ (raw callback ABI) | ✓ | ✓ | ✓ | -| C++ | ✓ (`std::future`) | ✓ | ✓ (`std::function`) | ✓ | -| Swift | ✓ (`async throws`) | ✓ | ✓ (closures) | ✓ | -| Android (Kotlin) | ✓ (`suspend fun`) | ✓ | ✓ (lambdas via JNI) | ✓ | -| Node.js | ✓ (`Promise`) | ✓ | ✓ (thread-safe functions) | ✓ | -| Python | ✓ (`async def`) | ✓ | ✓ (`CFUNCTYPE`) | ✓ | -| .NET | ✓ (`Task`) | ✓ | ✓ (delegates) | ✓ | -| Dart | ✓ (`Future`) | ✓ | ✓ (`NativeCallable`) | ✓ | -| Go | ✓ (blocking bridge) | ✓ | ✓ (exported trampolines) | ✓ | -| Ruby | ✓ (blocking bridge) | ✓ | ✓ (`FFI::Function`) | ✓ | -| Wasm | ✓ (`Promise`) | ✓ | ✓ (table trampolines) | ✓ | +| Target | Object disposal | Callback interface | Async functions | Iterators (`iter`) | `weaveffi package` bundles | +|--------|-----------------|--------------------|-----------------|-----------------------|----------------------------| +| [C](c.md) | raw `_clone`/`_destroy` symbols | `ctx` + vtable struct with `free` | raw completion-callback ABI | raw `_next`/`_destroy` | every supplied slice (CMake selects desktop) | +| [C++](cpp.md) | copyable RAII class (copy = `_clone`) | abstract class via `std::shared_ptr` | `std::future` | move-only input range | desktop slices | +| [Swift](swift.md) | `final class`, `deinit` releases | protocol | `async throws` | `Sequence` | desktop slices (XCFramework) | +| [Kotlin](kotlin.md) | `AutoCloseable` `close()` + `Cleaner` backstop | `interface` via JNI shim | `suspend fun` | `Iterator` | `jniLibs//` + desktop `resources/natives/` | +| [Node.js](node.md) | `close()`/`Symbol.dispose` + `FinalizationRegistry` | plain object (TS `interface`) | `Promise` | `IterableIterator` | desktop slices as `optionalDependencies` | +| [Wasm](wasm.md) | `close()`/`Symbol.dispose` + `FinalizationRegistry` | plain object via table trampolines | `Promise` (settles inline) | `IterableIterator` | the `wasm32` `.wasm` | +| [Python](python.md) | `close()` + `__del__`, context manager | abstract base class | `async def` | iterator | desktop slices (platform wheel) | +| [.NET](dotnet.md) | `IDisposable` + finalizer | C# `interface` | `Task` | `IEnumerable` (single pass) | desktop slices under `runtimes//native/` | +| [Dart](dart.md) | `dispose()` + `NativeFinalizer` | abstract class | `Future` | `Iterable` (single pass) | desktop slices | +| [Go](go.md) | `Close()` + `runtime.SetFinalizer` | Go `interface` | blocking bridge | `iter.Seq`/`iter.Seq2` | desktop slices (relocatable cgo preamble) | +| [Ruby](ruby.md) | `close` + `FFI::AutoPointer` | duck-typed object | blocking bridge | `Enumerator` | desktop slices (platform gems) | Notes: +- **Objects are reference counted.** Every wrapper owns one strong + reference to the producer's `Arc`; disposal drops it, and copying a + wrapper or placing it in a record, list, or optional mints a new + reference through the `_clone` symbol. Use after disposal is a + consumer error surfaced in the language's idiom (an exception with + `MARSHAL_ERROR_CODE` `-3`, `ObjectDisposedException`, `StateError`, a + Go panic, and so on); each page documents its own. +- **Callback implementations own the objects they receive.** An object + argument to a callback method is adopted by the implementation, which + disposes it. A callback implementation that throws surfaces to the + caller of the producer function as `FOREIGN_ERROR_CODE` `-4` through + the language's error type; a non-throwing callable follows the + language's trap idiom. - **Iterators are lazy.** Every target wraps the C ABI's - handle/`_next`/`_destroy` triple in its native lazy idiom (Go - `iter.Seq`, Swift `Sequence`, C++ input-iterator range, Kotlin - `Iterator`, JS iterables, Python iterators, .NET `IEnumerable`, - Dart `Iterable`, Ruby `Enumerator`), pulling one element per - consumer step and destroying the handle exactly once. C exposes the - raw symbols directly. + handle/`_next`/`_destroy` triple in its native lazy idiom, pulling one + element per consumer step and destroying the handle exactly once. C + exposes the raw symbols directly. - **Go and Ruby async** wrappers block the calling thread until the producer's completion callback fires (a channel receive in Go, a `Queue#pop` in Ruby). Run them from a goroutine or Ruby thread for concurrency; the native producer still runs off-thread. -- **Wasm callbacks/listeners deliver synchronously.** The loader - installs one long-lived JavaScript trampoline per callback typedef in - the module's function table, so the producer's `emit_*` dispatches - straight back into JS. Because `wasm32-unknown-unknown` is - single-threaded, events fire only while a call into the module is on - the stack; a producer that emits from a spawned thread cannot run on - this target at all ([details](wasm.md#callbacks-and-listeners)). In - [Emscripten mode](wasm.md#emscripten-mode) callbacks, listeners, and - async functions become explicit throwing stubs rather than silent - no-ops. +- **Thread affinity of callbacks differs.** Dart's `isolateLocal` + callables require the producer to call back synchronously on the + calling thread; Node blocks the producer thread while the JS thread + runs the method and can deadlock if a synchronous call from JS waits + on a worker's callback; Kotlin attaches producer threads to the JVM; + Ruby and Python rely on their FFI's GVL/GIL dispatch; Wasm is + single-threaded, so callbacks fire only while a call into the module + is on the stack ([details](wasm.md#callback-interfaces)). In + [Emscripten mode](wasm.md#emscripten-mode) callback interfaces and + async functions are unsupported and, when allowed, become explicit + throwing stubs rather than silent no-ops. +- **64-bit integers.** Node and Wasm use `bigint`; Dart and Kotlin carry + `u64` in a signed `int`/`Long` bit pattern; the other targets have + native 64-bit types. `NaN`, infinities, and `-0` cross every boundary + unchanged. diff --git a/docs/src/generators/android.md b/docs/src/generators/android.md deleted file mode 100644 index 970ad6ed..00000000 --- a/docs/src/generators/android.md +++ /dev/null @@ -1,691 +0,0 @@ -# Android - -## Overview - -The Android target produces a Gradle `android-library` template that -combines a Kotlin wrapper, JNI C shims, and a CMake build for the JNI -shared library. The wrapper exposes idiomatic Kotlin types while the JNI -layer bridges them to the C ABI. - -## What gets generated - -| File | Purpose | -|------|---------| -| `generated/android/settings.gradle` | Gradle settings for the library module | -| `generated/android/build.gradle` | `android-library` plugin, NDK config | -| `generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.kt` | Kotlin wrapper (enums, struct classes, namespaced functions) | -| `generated/android/src/main/cpp/weaveffi_jni.c` | JNI shims that call the C ABI and throw Java exceptions | -| `generated/android/src/main/cpp/CMakeLists.txt` | NDK CMake build for the JNI shared library | - -## Type mapping - -| IDL type | Kotlin type (external) | Kotlin type (wrapper) | JNI C type | -|----------------|------------------------|-----------------------|----------------| -| `i32` | `Int` | `Int` | `jint` | -| `u32` | `Long` | `Long` | `jlong` | -| `i64` | `Long` | `Long` | `jlong` | -| `f64` | `Double` | `Double` | `jdouble` | -| `i8` | `Byte` | `Byte` | `jbyte` | -| `i16` | `Short` | `Short` | `jshort` | -| `u8` | `Byte` | `Byte` | `jbyte` | -| `u16` | `Short` | `Short` | `jshort` | -| `u64` | `Long` | `Long` | `jlong` | -| `f32` | `Float` | `Float` | `jfloat` | -| `bool` | `Boolean` | `Boolean` | `jboolean` | -| `string` | `String` | `String` | `jstring` | -| `bytes` | `ByteArray` | `ByteArray` | `jbyteArray` | -| `handle` | `Long` | `Long` | `jlong` | -| `StructName` | `ByteArray` (value buffer) | `StructName` (data class) | `jbyteArray` | -| `EnumName` (plain) | `Int` | `EnumName` | `jint` | -| `EnumName` (rich) | `ByteArray` (value buffer) | `EnumName` (sealed class) | `jbyteArray` | -| `T?` | `ByteArray` (value buffer; `Interface?` stays `Long`) | `T?` | `jbyteArray` | -| `[i32]` | `IntArray` | `IntArray` | `jintArray` | -| `[i64]` | `LongArray` | `LongArray` | `jlongArray` | -| `[string]` | `Array` | `Array` | `jobjectArray` | -| `iter` | `Long` (iterator handle) | `Iterator` (lazy wrapper class) | `jlong` | - -## Example IDL → generated code - -```yaml -version: "0.8.0" -modules: - - name: contacts - enums: - - name: ContactType - variants: - - { name: Personal, value: 0 } - - { name: Work, value: 1 } - - { name: Other, value: 2 } - - structs: - - name: Contact - fields: - - { name: name, type: string } - - { name: age, type: i32 } - - functions: - - name: get_contact - params: - - { name: id, type: i32 } - return: Contact - - - name: find_by_type - params: - - { name: contact_type, type: ContactType } - return: "[Contact]" -``` - -The Kotlin wrapper declares `external fun` entries inside a companion -object and loads the JNI library on first use. Function names are -lowerCamelCase with the module prefix stripped by default -(`strip_module_prefix = false` in `[generators.android]` restores prefixed names). -Where a parameter or return value needs wrapping (enums, buffered -values), the external entry is a private `...Jni` function with lowered -types and a public wrapper converts at the boundary. A buffered value -(a struct, rich enum, optional, list, or map) crosses JNI as a -`ByteArray` copy of its value buffer, decoded or packed by the wrapper -layer: - -```kotlin -package com.weaveffi - -class WeaveFFI { - companion object { - init { System.loadLibrary("weaveffi") } - - @JvmStatic private external fun getContactJni(id: Int): ByteArray - @JvmStatic fun getContact(id: Int): Contact = - /* decode the value buffer into a Contact */ - @JvmStatic private external fun findByTypeJni(contactType: Int): ByteArray - @JvmStatic fun findByType(contactType: ContactType): List = - /* decode the value buffer into a List */ - } -} -``` - -Enums become Kotlin `enum class` with a `fromValue` factory: - -```kotlin -enum class ContactType(val value: Int) { - Personal(0), - Work(1), - Other(2); - - companion object { - fun fromValue(value: Int): ContactType = entries.first { it.value == value } - } -} -``` - -Structs are plain Kotlin data classes with one typed property per field. -They own no native resources: there's no handle, no `Closeable`, and no -per-struct JNI symbols. A `Contact` crosses the boundary serialized in -the [value-buffer format](../reference/value-buffers.md), packed and -decoded by the generated wrapper layer: - -```kotlin -/** A contact record */ -data class Contact( - val name: String, - val age: Int, -) -``` - -The JNI shims (`weaveffi_jni.c`) bridge each Kotlin `external fun` into -the C ABI and route errors through a shared `throw_weaveffi_error` -helper that throws the generic `WeaveFFIException`: - -```c -static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) { - const char* msg = err->message ? err->message : "WeaveFFI error"; - jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException"); - if (exClass != NULL) { - jmethodID ctor = (*env)->GetMethodID(env, exClass, "", "(ILjava/lang/String;)V"); - jstring jmsg = (*env)->NewStringUTF(env, msg); - jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg); - if (ex != NULL) { (*env)->Throw(env, ex); } - } - weaveffi_error_clear(err); -} - -JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_getContactJni(JNIEnv* env, jclass clazz, jint id) { - weaveffi_error err = {0}; - size_t out_len = 0; - const uint8_t* rv = weaveffi_contacts_get_contact((int32_t)id, &out_len, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - // Copy the value buffer into a Java byte[] and release the original. - jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); - (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); - weaveffi_free_bytes(rv, out_len); - return out; -} -``` - -The CMake file links the JNI shim against the generated C header: - -```cmake -cmake_minimum_required(VERSION 3.22) -project(weaveffi) -add_library(weaveffi SHARED weaveffi_jni.c) -target_include_directories(weaveffi PRIVATE ../../../../c) -``` - -## Typed errors - -Every generated file carries the generic -`open class WeaveFFIException(val code: Int, message: String)`. A module's -[error domain](../guides/errors.md) adds a sealed exception hierarchy -named after the domain with the trailing `Error` stem replaced by -`Exception` (`KvError` becomes `KvException`), one nested class per code, -and a `fromCode` mapper. From the `kvstore` sample: - -```kotlin -/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */ -open class WeaveFFIException(val code: Int, message: String) : Exception(message) - -/** Typed error domain `KvError` declared by module `kv`. */ -sealed class KvException(code: Int, message: String) : WeaveFFIException(code, message) { - class KeyNotFound(message: String = "key not found") : KvException(1001, message) - class Expired(message: String = "entry expired") : KvException(1002, message) - class StoreFull(message: String = "store has reached capacity") : KvException(1003, message) - class IoError(message: String = "I/O failure") : KvException(1004, message) - - companion object { - fun fromCode(code: Int, message: String): WeaveFFIException = when (code) { - 1001 -> KeyNotFound(message) - 1002 -> Expired(message) - 1003 -> StoreFull(message) - 1004 -> IoError(message) - else -> WeaveFFIException(code, message) - } - } -} -``` - -A callable with `throws: true` throws the matching subclass from its JNI -shim (a per-domain `throw_weaveffi_kv_KvError` helper resolves -`com/weaveffi/KvException$KeyNotFound` and friends by code); catch the -specific class, the sealed domain, or the generic base: - -```kotlin -try { - store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null) -} catch (e: KvException.StoreFull) { - // typed case -} catch (e: KvException) { - // any kv domain error -} -``` - -A callable without `throws` keeps a plain signature; its only possible -failures are producer bugs (a panic or a marshalling failure), which -arrive as the generic `WeaveFFIException`. Unknown codes on the typed -path fall back to `WeaveFFIException` too. - -An error code that declares payload `fields:` carries them serialized in -the error's payload buffer; the JNI shim passes the bytes up and the -typed exception exposes the decoded fields as properties before -`weaveffi_error_clear` releases the buffer. - -## Interfaces - -An `interfaces:` entry becomes a Kotlin class holding a `Long` handle and -implementing `java.io.Closeable`. Its members live on the class: constructors become companion factories (a -constructor named `new` becomes `operator fun invoke`, so `ContactBook()` -reads like a real constructor), methods are instance functions, statics -are companion functions, and `close()` calls the implicit destroy symbol. -From the `kvstore` sample's `Store` (trimmed): - -```kotlin -/** An embedded key-value store owning its entries */ -class Store internal constructor(internal var handle: Long) : java.io.Closeable { - companion object { - init { System.loadLibrary("weaveffi") } - - @JvmStatic private external fun nativeOpen(path: String): Long - @JvmStatic private external fun nativeDefaultCapacity(): Long - @JvmStatic private external fun nativeDelete(selfHandle: Long, key: String): Boolean - @JvmStatic private external fun nativeDestroy(handle: Long) - - /** Open (or create) a store backed by the given filesystem path */ - fun open(path: String): Store = Store(nativeOpen(path)) - - /** The largest number of live entries one store will hold */ - fun defaultCapacity(): Long = nativeDefaultCapacity() - } - - /** Remove the entry for the given key, returning true if it existed */ - fun delete(key: String): Boolean = nativeDelete(handle, key) - - /** Stream every key, optionally filtered by a prefix */ - fun listKeys(prefix: String?): Iterator = KvStoreListKeysIterator(nativeListKeys(handle, prefix)) - - /** Reclaim space asynchronously; returns the number of bytes reclaimed */ - suspend fun compact(): Long = suspendCancellableCoroutine { cont -> - nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) }) - } - - /** Legacy single-shot put kept for compatibility */ - @Deprecated("use put() with explicit kind") - fun legacyPut(key: String, value: ByteArray): Boolean = nativeLegacyPut(handle, key, value) - - override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } -} -``` - -```kotlin -Store.open("/tmp/cache.kv").use { store -> - store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null) - println(store.count()) -} -``` - -The JNI externals pass the wrapper's handle as the leading `selfHandle` -argument. An interface parameter elsewhere in the API takes the wrapper -class (`WeaveFFI.getStats(store: Store)` in the nested `stats` module); -an interface return wraps the new owned handle. - -## Rich (algebraic) enums - -A *rich* (algebraic) enum, a sum type whose variants carry associated -data, becomes an idiomatic Kotlin sealed class: one subclass (or object, -for a unit variant) per variant carrying that variant's fields. Rich -enums own no native resources and declare no JNI symbols. (A plain -C-style enum with no payloads stays a Kotlin `enum class` backed by an -`Int`; see above.) - -For the `shapes` module's `Shape` enum (`Empty`, `Circle { radius: f64 }`, -`Rectangle { width: f32, height: f32 }`, and -`Labeled { label: string, count: u8 }`), the surface follows this shape: - -```kotlin -/** An algebraic shape (sum type with associated data) */ -sealed class Shape { - /** The empty shape */ - object Empty : Shape() - /** A circle with a radius */ - data class Circle(val radius: Double) : Shape() - /** An axis-aligned rectangle */ - data class Rectangle(val width: Float, val height: Float) : Shape() - /** A labeled shape with a small count */ - data class Labeled(val label: String, val count: Byte) : Shape() -} -``` - -On the wire a `Shape` is a value buffer holding the `i32` variant tag -followed by the active variant's fields; the wrapper layer packs and -decodes the `ByteArray` at the JNI boundary. Construct variants directly -and branch with an exhaustive `when`: - -```kotlin -val c = Shape.Circle(2.0) -when (c) { - is Shape.Circle -> println(c.radius) // 2.0 - else -> {} -} -val bigger = WeaveFFI.scale(c, 3.0) // returns a new Shape value -println(WeaveFFI.describe(bigger)) -``` - -Values are plain Kotlin data: there's no handle and nothing to close. - -## Build instructions - -1. Install Android Studio (Giraffe or newer) plus the NDK. -2. Cross-compile the Rust cdylib for every Android ABI you support: - - ```bash - rustup target add aarch64-linux-android armv7-linux-androideabi \ - x86_64-linux-android i686-linux-android - export ANDROID_NDK_HOME=/path/to/ndk - cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ - build --release -p your_library - ``` - -3. Open `generated/android` in Android Studio, sync Gradle, and build - the AAR (`./gradlew :weaveffi:assemble`). -4. Add the resulting AAR as a dependency in your app module and ensure - your `jniLibs/` directory contains the Rust-built cdylib for each - supported ABI. - -## Memory and ownership - -- Interface wrappers implement `Closeable`; either call `.close()` - explicitly or use `use { ... }`. The `finalize()` safety net runs - during GC but is not a substitute for deterministic cleanup. Structs - and rich enums are plain Kotlin values with nothing to close. -- Strings returned from JNI are fresh Java strings; the JNI shim frees - the underlying Rust pointer with `weaveffi_free_string` before - returning. -- Byte arrays returned from JNI are copied with `SetByteArrayRegion`, - then the Rust buffer is freed with `weaveffi_free_bytes`. -- Buffered values (structs, rich enums, optionals, lists, maps) cross - JNI as `ByteArray` copies of their value buffers: the shim copies a - returned buffer with `SetByteArrayRegion` and releases the original - with `weaveffi_free_bytes`; a buffered parameter is packed by the - Kotlin layer and borrowed by the producer for the call. - -## Async support - -Async IDL functions (`async: true`) are exposed as Kotlin `suspend fun` -declarations built on `suspendCancellableCoroutine`. The public suspend -wrapper passes a `WeaveContinuation` (a small class with `onSuccess` / -`onError` methods) to a private external launcher; struct results -resume as raw handles and are re-wrapped after the await. From the -`async-demo` sample (`WeaveFFI.kt`): - -```kotlin -@JvmStatic private external fun runTaskAsync(name: String, callback: Any) -@JvmStatic suspend fun runTask(name: String): TaskResult { - val raw: ByteArray = suspendCancellableCoroutine { cont -> - runTaskAsync(name, WeaveContinuation(cont) { code, message -> TaskException.fromCode(code, message) }) - } - return /* decode the value buffer into a TaskResult */ -} - -internal class WeaveContinuation( - private val cont: kotlinx.coroutines.CancellableContinuation, - private val mapError: (Int, String) -> Throwable -) { - @Suppress("UNCHECKED_CAST") - fun onSuccess(result: Any?) { cont.resume(result as T) } - fun onError(code: Int, message: String) { cont.resumeWithException(mapError(code, message)) } -} -``` - -`run_task` declares `throws: true`, so a failed suspend call resumes with -the typed `TaskException`; an async callable without `throws` maps its -(producer-bug-only) failures to the generic `WeaveFFIException`. - -The JNI launcher allocates a per-call context holding the `JavaVM` and -a `NewGlobalRef` to the `WeaveContinuation`, then hands the C ABI a -completion callback. That callback attaches the producer's thread to -the JVM if it is not already attached, calls `onSuccess`/`onError`, -deletes the global ref, frees the context exactly once, and detaches -the thread if it attached it: - -```c -typedef struct { - JavaVM* jvm; - jobject callback; -} weaveffi_jni_async_ctx; - -JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_runTaskAsync(JNIEnv* env, jclass clazz, jstring name, jobject callback) { - weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - const char* name_chars = (*env)->GetStringUTFChars(env, name, NULL); - weaveffi_tasks_run_task_async(name_chars, weaveffi_tasks_run_task_jni_cb, ctx); - (*env)->ReleaseStringUTFChars(env, name, name_chars); -} - -static void weaveffi_tasks_run_task_jni_cb(void* context, weaveffi_error* err, - const uint8_t* result_ptr, size_t result_len) { - weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; - JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; } - attached = 1; - } - /* ... calls callback.onError(int, String) or callback.onSuccess(Object) ... */ - weaveffi_jni_handle_uncaught(env); - (*env)->DeleteGlobalRef(env, ctx->callback); - JavaVM* jvm = ctx->jvm; - free(ctx); - if (attached) (*jvm)->DetachCurrentThread(jvm); -} -``` - -The completion callback fires exactly once, on a producer thread. -Result buffers passed to it (strings, byte arrays, and buffered values, -which arrive as a `(result_ptr, result_len)` pair) are owned by the -consumer, so the shim copies them into Java objects (`NewStringUTF`, -`SetByteArrayRegion`) and then releases them with -`weaveffi_free_string` or `weaveffi_free_bytes`; the Kotlin layer then -decodes buffered results into their value types. A reported error is -heap-boxed: the shim copies its fields and releases it with -`weaveffi_error_free`. An owned interface result transfers ownership -too: the callback resumes the continuation with the raw handle, and -the suspend wrapper adopts it. An exception thrown by the -resumed coroutine goes through the same -`weaveffi_jni_handle_uncaught` path as listener exceptions (see -[Callbacks and listeners](#callbacks-and-listeners)). - -The generated `build.gradle` does not declare a coroutines dependency; -add `org.jetbrains.kotlinx:kotlinx-coroutines-android` (or `-core`) to -the consuming project. - -For callables marked `cancellable: true`, the C ABI takes an extra -`weaveffi_cancel_token*` parameter. The private external launcher -carries it as `cancelToken: Long` and the shim casts it to -`weaveffi_cancel_token*`, but the public suspend wrapper currently -passes `0L` (no token); coroutine cancellation isn't wired to the -native cancel token. From the `kvstore` sample's async method -`Store.compact`: - -```kotlin -@JvmStatic private external fun nativeCompactAsync(selfHandle: Long, cancelToken: Long, callback: Any) - -suspend fun compact(): Long = suspendCancellableCoroutine { cont -> - nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) }) -} -``` - -## Callbacks and listeners - -IDL `callbacks` paired with `listeners` produce a register/unregister -pair. From the `events` sample: - -```yaml -modules: - - name: events - callbacks: - - name: OnMessage - params: - - { name: message, type: string } - listeners: - - name: message_listener - event_callback: OnMessage -``` - -The Kotlin surface takes a lambda and returns a `Long` subscription id; -pass that id back to unregister: - -```kotlin -@JvmStatic external fun registerMessageListener(callback: (String) -> Unit): Long -@JvmStatic external fun unregisterMessageListener(id: Long) -``` - -The JNI shim keeps the lambda alive with a `NewGlobalRef` stored in a -mutex-guarded registry (a linked list of contexts holding the `JavaVM`, -the global ref, and the subscription id). When the producer fires, a C -trampoline attaches the producer's thread to the JVM if needed and -invokes the lambda through its `kotlin.jvm.functions.Function1` -`invoke(Object): Object` method; unregistering removes the registry -entry, deletes the global ref, and frees the context: - -```c -static void weaveffi_events_OnMessage_fn_jni_tramp(const char* message, void* context) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context; - JNIEnv* env = NULL; - int attached = 0; - if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { - if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return; - attached = 1; - } - if ((*env)->PushLocalFrame(env, 32) != 0) { - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); - return; - } - jobject _a0 = message ? (jobject)(*env)->NewStringUTF(env, message) : (jobject)(*env)->NewStringUTF(env, ""); - jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback); - jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;"); - (*env)->CallObjectMethod(env, ctx->callback, invoke, _a0); - weaveffi_jni_handle_uncaught(env); - (*env)->PopLocalFrame(env, NULL); - if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); -} - -JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerMessageListener(JNIEnv* env, jclass clazz, jobject callback) { - weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx)); - (*env)->GetJavaVM(env, &ctx->jvm); - ctx->callback = (*env)->NewGlobalRef(env, callback); - uint64_t id = weaveffi_events_register_message_listener(weaveffi_events_OnMessage_fn_jni_tramp, ctx); - /* ... stores ctx in the registry under id ... */ - return (jlong)id; -} -``` - -The callback runs on the producer's thread, whichever thread the -native side fires the event from. For UI work, hop to the main thread -yourself (e.g. `withContext(Dispatchers.Main)` or `Handler.post`). - -An exception thrown by the Kotlin callback has no caller to propagate -to, since the frame below it is native producer code. The glue routes -it through `weaveffi_jni_handle_uncaught`, which delivers it to the -handler installed on the module companion: - -```kotlin -/** - * Installs a handler for exceptions thrown by listener callbacks and - * async continuations on native producer threads. These exceptions have - * no Kotlin caller to propagate to; when no handler is installed, they - * are logged with their stack trace and dropped. Pass `null` to - * restore the default logging behavior. - */ -@JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) { - callbackExceptionHandler = handler -} -``` - -When a module declares listeners or async functions, the JNI glue also -defines `JNI_OnLoad`, which caches a global reference to the wrapper -class and the `dispatchCallbackException` method id so the producer -thread can deliver exceptions without an extra class lookup. - -## Iterators - -`iter` returns surface as `Iterator` in Kotlin, backed by a -generated per-function wrapper class that is fully lazy: the external -launcher returns the raw iterator handle as a `Long`, and each -`hasNext()` lookahead issues exactly one `nativeNext` call, which maps -to one producer `_next` call. Nothing is drained into a hidden list. -From the `events` sample (`get_messages` returns `iter`): - -```kotlin -@JvmStatic private external fun getMessagesJni(): Long -@JvmStatic fun getMessages(): Iterator = EventsGetMessagesIterator(getMessagesJni()) - -/** - * A lazy iterator over the `String` elements streamed by [getMessages]. Each step pulls - * exactly one element from the native producer. The native handle is - * released when the producer is exhausted, when [close] is called, or by - * the finalizer if the iterator is abandoned, whichever comes first. - */ -class EventsGetMessagesIterator internal constructor(private var handle: Long) : Iterator, java.io.Closeable { - private var nextSlot: Array? = null - - override fun hasNext(): Boolean { - if (nextSlot != null) return true - if (handle == 0L) return false - val slot = nativeNext(handle) - if (slot == null) { - close() - return false - } - nextSlot = slot - return true - } - - override fun next(): String { - if (!hasNext()) throw NoSuchElementException() - val raw = nextSlot!![0] - nextSlot = null - return raw as String - } - - override fun close() { - if (handle != 0L) { - nativeDestroy(handle) - handle = 0L - } - } - - protected fun finalize() { - close() - } - - companion object { - init { System.loadLibrary("weaveffi") } - - @JvmStatic private external fun nativeNext(handle: Long): Array? - @JvmStatic private external fun nativeDestroy(handle: Long) - } -} -``` - -The JNI `nativeNext` shim pulls one element and returns it in a -one-slot `Object[]`; `null` means the stream is exhausted. Each string -element is freed with `weaveffi_free_string` right after `NewStringUTF` -copies it; a buffered element (a record, rich enum, optional, list, or -map) arrives as a value buffer that the shim copies into a `ByteArray` -and releases with `weaveffi_free_bytes`, and the Kotlin `next()` decodes -it into the value type: - -```c -JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EventsGetMessagesIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) { - weaveffi_events_GetMessagesIterator* _iter = (weaveffi_events_GetMessagesIterator*)(intptr_t)handle; - const char* _item = (const char*)0; - weaveffi_error err = {0}; - int32_t _has = weaveffi_events_GetMessagesIterator_next(_iter, &_item, &err); - if (err.code != 0) { - throw_weaveffi_error(env, &err); - return NULL; - } - if (_has == 0) { return NULL; } - jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, ""); - weaveffi_free_string(_item); - jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object"); - jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL); - (*env)->SetObjectArrayElement(env, _slot, 0, _jitem); - return _slot; -} -``` - -The native handle is destroyed exactly once: `close()` is called -eagerly when `hasNext()` sees exhaustion, callers can call `close()` -themselves (the class implements `Closeable`) when abandoning -iteration early, and the `finalize()` safety net covers abandoned -iterators during GC. Nulling the handle makes a double destroy -impossible. - -Errors from the launcher and from each `next` follow the function's -error strategy: the throwing `kvstore` sample's `Store.listKeys` shim -throws the typed domain exception (`throw_weaveffi_kv_KvError`, so -`KvException.KeyNotFound` and friends) from the step that failed, -while the non-throwing `getMessages` throws the generic -`WeaveFFIException` only for producer bugs. - -## Troubleshooting - -- **`UnsatisfiedLinkError: Couldn't find libweaveffi.so`**: the - Rust-built cdylib was not packaged inside the AAR. Place it under - `src/main/jniLibs//` and rebuild. -- **`UnsatisfiedLinkError` for the JNI symbol itself**: Kotlin - external function names must match the JNI signature, including the - `_1` escape for underscores. Re-run `weaveffi generate` if you - hand-edited either side. -- **Crashes when releasing strings**: the JNI shim is responsible for - calling `ReleaseStringUTFChars` on every `GetStringUTFChars`. If you - edit the shim, keep the pairing intact. -- **R8/ProGuard removes `WeaveFFI` symbols**: keep the wrapper class - with `-keep class com.weaveffi.** { *; }` in your ProGuard rules. diff --git a/docs/src/generators/c.md b/docs/src/generators/c.md index 6774fac2..f137662c 100644 --- a/docs/src/generators/c.md +++ b/docs/src/generators/c.md @@ -5,13 +5,16 @@ The C target emits the canonical C header and a thin reference C file that every other WeaveFFI target ultimately speaks to. All cross-language bindings sit on top of these symbols, so the C output is also the easiest -way to inspect what the IDL compiles to. +way to inspect what the IDL compiles to. The header is a rendering of the +[C ABI contract](../reference/abi.md), revision 2: reference-counted +objects, consumer-implemented callback-interface vtables, value buffers, +async launchers, and iterators. ## What gets generated | File | Purpose | |------|---------| -| `generated/c/weaveffi.h` | Public header: enums, interface types, function prototypes, error/memory helpers, and the value-buffer convention comment | +| `generated/c/weaveffi.h` | Public header: enums, object types, vtables, function prototypes, error/memory helpers, and the value-buffer convention comment | | `generated/c/weaveffi.c` | Default `weaveffi_alloc`/`weaveffi_dealloc` implementations (used by the Wasm JS glue); producers that ship their own allocator can omit it | ## Type mapping @@ -29,57 +32,59 @@ way to inspect what the IDL compiles to. | `f32` | `float` | `float` | | `f64` | `double` | `double` | | `bool` | `bool` | `bool` | -| `string` | `const char*` (NUL-terminated UTF-8) | `const char*` | -| `bytes` | `const uint8_t* ptr, size_t len` | `const uint8_t*` + `size_t* out_len`| -| `handle` | `weaveffi_handle_t` | `weaveffi_handle_t` | +| `string` | `const char*` (NUL-terminated UTF-8, borrowed) | `const char*` (owned; `weaveffi_free_string`) | +| `bytes` | `const uint8_t* ptr, size_t len` (borrowed) | `const uint8_t*` + `size_t* out_len` (owned; `weaveffi_free_bytes`) | | `Struct` | `const uint8_t* {name}_ptr, size_t {name}_len` (value buffer, borrowed) | `const uint8_t*` + `size_t* out_len` (value buffer, owned) | -| `Interface` | `const weaveffi_m_I*` (borrowed) | `weaveffi_m_I*` (owned) | +| `Interface` | `const weaveffi_m_I*` (borrowed) | `weaveffi_m_I*` (one strong reference, adopted) | +| `Interface?` | `const weaveffi_m_I*` (null is "none") | `weaveffi_m_I*` (null is "none") | +| `CallbackInterface` | `void* {name}_ctx, const weaveffi_m_CI_vtable* {name}_vtable` | not allowed | | `Enum` (plain) | `weaveffi_m_E` | `weaveffi_m_E` | | `Enum` (rich) | value buffer, like `Struct` | value buffer, like `Struct` | -| `T?` | value buffer (`Interface?` stays a nullable pointer) | value buffer | +| `T?` (other) | value buffer | value buffer | | `[T]` | value buffer | value buffer | | `{K:V}` | value buffer | value buffer | -| `iter` | n/a | opaque iterator handle (see [Iterators](#iterators)) | +| `iter` | n/a | opaque iterator pointer (see [Iterators](#iterators)) | Every buffered type (structs, rich enums, optionals, lists, maps) is one serialized `(ptr, len)` pair in the [value-buffer format](../reference/value-buffers.md): borrowed when passed in, producer-allocated and freed with `weaveffi_free_bytes` when returned. +An object inside a buffer is a `u64` token that carries one strong +reference (see [Objects inside buffers](#objects-inside-buffers)). C ABI symbol naming follows a strict convention: | Kind | Pattern | Example | |-------------------|---------------------------------------------------|-----------------------------------------------| -| Function | `weaveffi_{module}_{function}` | `weaveffi_contacts_create_contact` | -| Enum type | `weaveffi_{module}_{Enum}` | `weaveffi_contacts_ContactType` | -| Enum variant | `weaveffi_{module}_{Enum}_{Variant}` | `weaveffi_contacts_ContactType_Personal` | +| Function | `weaveffi_{module}_{function}` | `weaveffi_events_route_once` | +| Enum type | `weaveffi_{module}_{Enum}` | `weaveffi_kv_EntryKind` | +| Enum variant | `weaveffi_{module}_{Enum}_{Variant}` | `weaveffi_kv_EntryKind_Volatile` | | Interface type | `weaveffi_{module}_{Interface}` | `weaveffi_kv_Store` | | Interface member | `weaveffi_{module}_{Interface}_{member}` | `weaveffi_kv_Store_open` | +| Interface clone | `weaveffi_{module}_{Interface}_clone` | `weaveffi_kv_Store_clone` | | Interface destroy | `weaveffi_{module}_{Interface}_destroy` | `weaveffi_kv_Store_destroy` | +| Callback vtable | `weaveffi_{module}_{CallbackInterface}_vtable` | `weaveffi_events_Subscriber_vtable` | | Error enum | `weaveffi_{module}_{Domain}` | `weaveffi_kv_KvError` | | Error constant | `weaveffi_{module}_{Domain}_{Code}` | `weaveffi_kv_KvError_KeyNotFound` | -| Callback typedef | `weaveffi_{module}_{Callback}_fn` | `weaveffi_events_OnMessage_fn` | -| Listener register | `weaveffi_{module}_register_{listener}` | `weaveffi_events_register_message_listener` | -| Listener unregister | `weaveffi_{module}_unregister_{listener}` | `weaveffi_events_unregister_message_listener` | -| Async callback | `weaveffi_{module}_{function}_callback` | `weaveffi_tasks_run_task_callback` | -| Async launcher | `weaveffi_{module}_{function}_async` | `weaveffi_tasks_run_task_async` | -| Iterator type | `weaveffi_{module}_{Function}Iterator` | `weaveffi_events_GetMessagesIterator` | -| Iterator next | `weaveffi_{module}_{Function}Iterator_next` | `weaveffi_events_GetMessagesIterator_next` | -| Iterator destroy | `weaveffi_{module}_{Function}Iterator_destroy` | `weaveffi_events_GetMessagesIterator_destroy` | - -`{Function}` is the function name converted to PascalCase -(`get_messages` → `GetMessages`). An iterator returned by an interface -method nests under the interface instead: -`weaveffi_kv_Store_ListKeysIterator`. Interface members and async -launchers compose the same way (`weaveffi_kv_Store_compact_async`). - -When the IDL sets `c_prefix`, every symbol, including the runtime -helpers, is rewritten with the new prefix. - -## Example IDL → generated code +| Async callback | `weaveffi_{module}_{function}_callback` | `weaveffi_kv_Store_compact_callback` | +| Async launcher | `weaveffi_{module}_{function}_async` | `weaveffi_kv_Store_compact_async` | +| Iterator type | `weaveffi_{module}_{Function}Iterator` | `weaveffi_kv_Store_ListKeysIterator` | +| Iterator next | `weaveffi_{module}_{Function}Iterator_next` | `weaveffi_kv_Store_ListKeysIterator_next` | +| Iterator destroy | `weaveffi_{module}_{Function}Iterator_destroy` | `weaveffi_kv_Store_ListKeysIterator_destroy` | + +`{Function}` is the function name converted to PascalCase (`list_keys` +becomes `ListKeys`). An iterator returned by an interface method nests +under the interface (`weaveffi_kv_Store_ListKeysIterator`), and so do +async launchers (`weaveffi_kv_Store_compact_async`). Nested modules join +their path with underscores (`weaveffi_kv_stats_get_stats`). + +When the IDL sets `c_prefix`, every symbol, including the runtime helpers, +is rewritten with the new prefix. + +## Example IDL and generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -117,23 +122,25 @@ modules: return: i32 ``` -The header opens with an include guard, standard headers, an -`extern "C"` block, and the shared error/memory helpers: +The header opens with an include guard, standard headers, an `extern "C"` +block, the ABI revision, and the shared error/memory helpers (taken from the +`kvstore` sample's output): ```c -#ifndef WEAVEFFI_H -#define WEAVEFFI_H - -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -typedef uint64_t weaveffi_handle_t; - +/* The WeaveFFI C ABI revision this header was generated against. The + producer exports weaveffi_abi_version() so a consumer can refuse to load + a library built for a different revision instead of misreading its + error struct or value buffers. */ +#define WEAVEFFI_ABI_VERSION 2u +WEAVEFFI_API uint32_t weaveffi_abi_version(void); + +/* Error slot written by every fallible call. `payload_ptr`/`payload_len` + hold the matched error code's fields serialized in the WeaveFFI value + buffer format (null when the code declares no fields); both the message + and the payload are released by weaveffi_error_clear. Positive codes are + the module's declared error codes; negative codes are runtime traps: + -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback + interface implementation raised. */ typedef struct weaveffi_error { int32_t code; const char* message; @@ -141,9 +148,18 @@ typedef struct weaveffi_error { size_t payload_len; } weaveffi_error; -void weaveffi_error_clear(weaveffi_error* err); -void weaveffi_free_string(const char* ptr); -void weaveffi_free_bytes(uint8_t* ptr, size_t len); +/* Fill `err` with `code` and a producer-owned copy of `message`. Callback + interface trampolines call this to report a failure in the consumer's + implementation (code -4) without allocating with a foreign allocator. */ +WEAVEFFI_API void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); +WEAVEFFI_API void weaveffi_error_clear(weaveffi_error* err); + +/* Async completion callbacks receive a heap-boxed error the consumer + owns; weaveffi_error_free releases the message, the payload, and the + box itself. Passing NULL is a safe no-op. */ +WEAVEFFI_API void weaveffi_error_free(weaveffi_error* err); +WEAVEFFI_API void weaveffi_free_string(const char* ptr); +WEAVEFFI_API void weaveffi_free_bytes(uint8_t* ptr, size_t len); ``` A comment block near the top of the header states the value-buffer @@ -153,10 +169,11 @@ producer-allocated buffer returned as `const uint8_t*` with a trailing `size_t* out_len`, decoded by the caller and released with `weaveffi_free_bytes`. -In the real output each prototype is prefixed with a `WEAVEFFI_API` visibility -macro (and deprecated functions with `WEAVEFFI_DEPRECATED`), omitted here for -brevity. See [Symbol visibility](#symbol-visibility) for what it does and when -you need it. +In the real output each prototype is prefixed with a `WEAVEFFI_API` +visibility macro (and deprecated functions with `WEAVEFFI_DEPRECATED`), +omitted from the shorter snippets on this page. See +[Symbol visibility](#symbol-visibility) for what it does and when you need +it. Structs generate no C symbols of their own: a struct crosses the ABI as a serialized value buffer, so a function that takes or returns one simply @@ -198,9 +215,9 @@ const uint8_t* weaveffi_contacts_list_contacts( weaveffi_error* out_err); ``` -Every function takes a trailing `weaveffi_error* out_err`. On failure -`out_err->code` is non-zero and `out_err->message` points at a -Rust-allocated string the consumer must clear: +Every synchronous function takes a trailing `weaveffi_error* out_err`. On +failure `out_err->code` is non-zero and `out_err->message` points at a +producer-allocated string the consumer must clear: ```c weaveffi_error err = {0}; @@ -212,69 +229,270 @@ if (err.code != 0) { } ``` -## Interfaces +## Objects (interfaces) -An `interfaces:` entry lowers to a forward-declared opaque struct plus one -prototype per member. Constructors return an owned pointer, methods take a -leading `const {tag}* self` argument before their declared parameters, -statics take no `self`, and every interface gets an implicit `_destroy`. -From the `kvstore` sample's `Store`: +An `interfaces:` entry lowers to a forward-declared opaque struct, one +prototype per member, and a `_clone`/`_destroy` pair. Constructors are +statics returning a pointer, methods take a leading `const {tag}* self` +argument before their declared parameters, and statics take no `self`. From +the `kvstore` sample's `Store`: ```c typedef struct weaveffi_kv_Store weaveffi_kv_Store; -/* Constructor: returns a new owned instance. */ -weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err); +/* Constructor: returns one strong reference the caller adopts. */ +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err); /* Static: no self slot. */ -int64_t weaveffi_kv_Store_default_capacity(weaveffi_error* out_err); +WEAVEFFI_API int64_t weaveffi_kv_Store_default_capacity(weaveffi_error* out_err); -/* Methods: an implicit leading self slot. */ -bool weaveffi_kv_Store_delete(const weaveffi_kv_Store* self, const char* key, - weaveffi_error* out_err); -int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err); +/* Methods: a leading self slot, borrowed for the call. */ +WEAVEFFI_API bool weaveffi_kv_Store_delete(const weaveffi_kv_Store* self, const char* key, weaveffi_error* out_err); +WEAVEFFI_API int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err); -/* Implicit destructor: releases the object. */ -void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); +/** Returns a new strong reference to the same object (the pointer value is unchanged). Null is a no-op returning null. */ +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self); +/** Releases one strong reference; the object is dropped when the last reference is released. Null is a no-op. */ +WEAVEFFI_API void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); ``` -Ownership follows the reference direction: an interface parameter (such as -`const weaveffi_kv_Store* store` on `weaveffi_kv_stats_get_stats`) is -borrowed for the duration of the call, while every pointer returned by a -constructor or function is owned by the consumer, who must eventually pass -it to `_destroy`. Iterator-returning and async methods follow the same -shapes as free functions with the `self` slot in front: `Store.list_keys` -yields a `weaveffi_kv_Store_ListKeysIterator` handle, and the async -`Store.compact` appears under [Async support](#async-support). +Objects are reference counted by the producer, so the C consumer deals in +strong references rather than owned allocations: + +- Every pointer a constructor, function, iterator step, or async callback + hands you is one strong reference. Release it with `_destroy` exactly + once; the object is dropped when the last reference anywhere goes away. +- `_clone` mints another strong reference to the same object and returns + the same pointer value. Use it whenever you want to store a pointer in + two places, or hand one to a value buffer, and pair each clone with its + own `_destroy`. +- An interface parameter (`const weaveffi_kv_Store* self`, or `store` on + `weaveffi_kv_stats_get_stats`) is borrowed for the duration of the call; + the producer clones it if it needs to retain it. +- Both `_clone` and `_destroy` accept null as a no-op, and a null `self` + on any method is rejected with `MARSHAL_ERROR_CODE` (`-3`) before the + producer runs. + +There is no wrapper type in C, so there's no use-after-close guard: a +pointer that has been passed to `_destroy` is dangling, and it's the +caller's job to null it. The `conformance/c/kvstore.c` consumer shows the +idiom: -## Typed errors +```c +weaveffi_kv_Store* cloned = weaveffi_kv_Store_clone(store); +assert(cloned == store); +weaveffi_kv_Store_destroy(store); +assert(count(cloned) == 2 && "clone outlives the destroyed original"); +store = cloned; +``` + +### Nullable objects -C is the raw ABI surface, so throwing and non-throwing callables look -identical: every prototype carries the trailing `weaveffi_error* out_err`, -and the consumer checks `err.code` after each call. What a module's error -domain adds is a typed C enum naming the codes its `throws: true` callables -can report, so consumers match on names instead of magic numbers. From the -`kvstore` sample's `KvError` domain: +`Interface?` doesn't use a value buffer: it stays a nullable pointer in +both directions, where null means "none". From `Store.larger(other: +Store?) -> Store?`: + +```c +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_larger(const weaveffi_kv_Store* self, const weaveffi_kv_Store* other, weaveffi_error* out_err); +``` + +A non-null return is one strong reference to release, even when the +producer returned one of its inputs back to you (`larger` returns either +`self` or `other`, with a fresh reference either way). + +### Objects inside buffers + +Inside a value buffer (a record field, a list element, an optional, a map +value, an async result) an object is a `u64` pointer token that carries one +strong reference; whoever decodes the buffer adopts it. That has two +practical consequences: + +- **Reading.** Each token you decode is yours to `_destroy`. Decode a + buffer exactly once; decoding it twice would adopt the same reference + twice. +- **Writing.** Never write a pointer you still hold. Write the result of + `_clone`, since the producer's reader takes that reference: + +```c +// Encode a StoreInfo. Object fields are written as fresh strong references +// (`_clone`), since the buffer's reader adopts them. +static void write_store_info(wv_writer* w, const char* label, + weaveffi_kv_Store* store, weaveffi_kv_Store* mirror, + int64_t n) { + wv_put_str(w, label); + wv_put_obj(w, weaveffi_kv_Store_clone(store)); + wv_put_bool(w, mirror != NULL); + if (mirror) wv_put_obj(w, weaveffi_kv_Store_clone(mirror)); + wv_put_i64(w, n); +} +``` + +The `kvstore` sample exercises every position: `describe` returns a +`StoreInfo` record whose `store` field is the receiver itself, `open_many` +returns `[Store]`, and `total_count` takes `[Store]` plus `StoreInfo?`. +Iterator elements and async results that are objects follow the same +rule as returns: one strong reference per element, adopted by the +consumer. + +## Callback interfaces + +A `callback_interfaces:` entry is a set of methods the consumer implements +and the producer calls. It lowers to a vtable `typedef struct` and no +exported symbols. From the `events` sample's `Subscriber`: + +```c +typedef struct weaveffi_events_Subscriber_vtable { + /** Decide how the bus should treat `topic` for this subscriber. */ + weaveffi_events_Delivery (*route)(void* ctx, const char* topic, weaveffi_error* out_err); + /** + * Receive an accepted message. Returns the subscriber's running count + * of received messages. + */ + int64_t (*on_message)(void* ctx, const uint8_t* message_ptr, size_t message_len, weaveffi_error* out_err); + /** + * Receive the bus itself (an object handed through a callback). The + * consumer adopts the reference and may keep or drop it. + */ + void (*on_attached)(void* ctx, weaveffi_events_EventBus* bus, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_events_Subscriber_vtable; + +WEAVEFFI_API int64_t weaveffi_events_EventBus_subscribe(const weaveffi_events_EventBus* self, void* subscriber_ctx, const weaveffi_events_Subscriber_vtable* subscriber_vtable, weaveffi_error* out_err); +WEAVEFFI_API weaveffi_events_Delivery weaveffi_events_route_once(void* subscriber_ctx, const weaveffi_events_Subscriber_vtable* subscriber_vtable, const char* topic, weaveffi_error* out_err); +``` + +A callback-interface parameter named `subscriber` lowers to two slots, +`void* subscriber_ctx` and `const {tag}_vtable* subscriber_vtable`. The +consumer supplies a heap context per implementation and one static vtable +per callback interface: + +```c +typedef struct { + const char* fail_topic; + int64_t received; + weaveffi_events_EventBus* bus; // kept reference (when keep_bus) + // ... +} sub_ctx; + +static weaveffi_events_Delivery sub_route(void* ctx, const char* topic, + weaveffi_error* out_err) { + sub_ctx* s = (sub_ctx*)ctx; + if (s->fail_topic && strcmp(topic, s->fail_topic) == 0) { + weaveffi_error_set(out_err, -4, "subscriber rejected topic"); + return weaveffi_events_Delivery_Skip; + } + return weaveffi_events_Delivery_Accept; +} + +// The bus arrives as one strong reference the consumer adopts: it is usable +// right here, and it is ours to keep or release. +static void sub_on_attached(void* ctx, weaveffi_events_EventBus* bus, + weaveffi_error* out_err) { + (void)out_err; + sub_ctx* s = (sub_ctx*)ctx; + if (s->keep_bus) { + s->bus = bus; + } else { + weaveffi_events_EventBus_destroy(bus); + } +} + +static void sub_free(void* ctx) { + sub_ctx* s = (sub_ctx*)ctx; + weaveffi_events_EventBus_destroy(s->bus); // null is a no-op + free(s); +} + +static const weaveffi_events_Subscriber_vtable SUB_VTABLE = { + sub_route, + sub_on_message, + sub_on_attached, + sub_free, +}; + +sub_ctx* a = calloc(1, sizeof *a); +weaveffi_events_EventBus_subscribe(bus, a, &SUB_VTABLE, &err); +``` + +The contract, in full: + +- **Lifetime.** The producer may call any method any number of times, from + any thread, until it calls `free(ctx)` exactly once; after that it never + touches `ctx` again. The producer holds the pair behind its own reference + count, so `free` runs when its last clone drops: for `route_once`, before + the call returns; for `subscribe`, when the bus is cleared or destroyed. +- **Argument ownership.** Strings, bytes, and value buffers passed to a + method (`topic`, `message_ptr`/`message_len`) are borrowed for the call; + copy what you need. An object passed to a method (`bus` above) is one + strong reference the implementation owns: keep it or `_destroy` it. The + vtable slot is `weaveffi_events_EventBus*`, not `const`, to make that + visible. +- **Returns.** Methods return `void` or a Direct-family value (a scalar, + `bool`, or a C-style enum), so nothing is allocated on the way back. +- **Failure.** To report that the implementation failed, call + `weaveffi_error_set(out_err, -4, "message")` and return any value. The + producer copies the message, aborts the current call, and the original + caller observes `FOREIGN_ERROR_CODE` (`-4`) with your text in + `out_err->message`, exactly as it would observe a producer panic. Never + write `out_err->message` yourself; the producer frees it with its own + allocator. +- **Reentrancy.** A method may call back into the producer (the + `on_attached` above calls `subscriber_count` on the bus it was handed). + The samples are written to never hold a lock across a callback; treat + your own state the same way, since the method runs on whatever thread + the producer is on (an `_async` method may call from a worker thread). + +## Error codes + +Every synchronous symbol writes its trailing `out_err`; async and +iterator paths deliver errors as described below. Positive codes are the +module's declared domain codes. The negative range is reserved: + +| Code | Meaning | Where it comes from | +|------|---------|---------------------| +| `-1` | `GENERIC_ERROR_CODE` | an untyped producer error (`Result`) | +| `-2` | `PANIC_ERROR_CODE` | the producer panicked, synchronously or inside a spawned async future; `message` carries the panic text | +| `-3` | `MARSHAL_ERROR_CODE` | an argument could not be lifted: a null `string` or `self` pointer, non-UTF-8 text, an out-of-range enum discriminant, a malformed value buffer | +| `-4` | `FOREIGN_ERROR_CODE` | one of your callback-interface methods called `weaveffi_error_set`; `message` carries your text | + +C is the raw ABI, so throwing and non-throwing callables look identical: +every prototype carries `out_err`, and the consumer checks `err.code` after +each call. What a module's error domain adds is a typed enum naming the +positive codes its `throws: true` callables can report, so consumers match +on names instead of magic numbers. From the `kvstore` sample: ```c /** Error codes reported by throwing functions in the `kv` module tree. */ typedef enum { + /** key not found */ weaveffi_kv_KvError_KeyNotFound = 1001, + /** entry expired */ weaveffi_kv_KvError_Expired = 1002, + /** store has reached capacity */ weaveffi_kv_KvError_StoreFull = 1003, + /** I/O failure */ weaveffi_kv_KvError_IoError = 1004 } weaveffi_kv_KvError; ``` A callable declared with `throws: true` can set any of these codes; a -callable without `throws` can only fail with the reserved codes (`-2` for a -producer panic, `1` for a marshalling failure). See the -[Error Handling guide](../guides/errors.md) for the full code table. +callable without `throws` can only fail with the reserved negative codes. +The higher-level targets trap on a negative code from a non-throwing +function; in C the check is yours. See the +[Error Handling guide](../guides/errors.md) for the full picture. + +## 64-bit integers and floats + +`i64`/`u64` cross as `int64_t`/`uint64_t` by value, so the full range is +available with no conversion; the `codec` sample's `roundtrip_u64` returns +`UINT64_MAX` unchanged. `f32`/`f64` cross as `float`/`double`, and NaN, +the infinities, and negative zero survive the trip bit for bit; inside a +value buffer they are written as their IEEE 754 little-endian bytes. ## Symbol visibility -Every function prototype is tagged with a `WEAVEFFI_API` macro that the header -defines near the top: +Every function prototype is tagged with a `WEAVEFFI_API` macro that the +header defines near the top: ```c #ifndef WEAVEFFI_API @@ -284,6 +502,8 @@ defines near the top: # else # define WEAVEFFI_API __declspec(dllimport) # endif +# elif defined(__EMSCRIPTEN__) +# define WEAVEFFI_API __attribute__((used, visibility("default"))) # elif defined(__GNUC__) && (__GNUC__ >= 4) # define WEAVEFFI_API __attribute__((visibility("default"))) # else @@ -294,15 +514,15 @@ defines near the top: This covers the two ways the header is used: -- **Consuming** a prebuilt library (the common case) needs nothing extra. On - Windows the prototypes resolve to `__declspec(dllimport)`; everywhere else the - macro is harmless. +- **Consuming** a prebuilt library (the common case) needs nothing extra. + On Windows the prototypes resolve to `__declspec(dllimport)`; everywhere + else the macro is harmless. - **Implementing** the header (a C, C++, or Zig backend that supplies the - symbols instead of calling them) relies on the macro to stay exportable. Under - hidden default visibility (`-fvisibility=hidden`, the release-build norm and - the MSVC default) an untagged definition is local and ships no usable symbol. - On GCC and Clang the macro applies `visibility("default")`, so your - definitions export with no extra flags. + symbols instead of calling them) relies on the macro to stay exportable. + Under hidden default visibility (`-fvisibility=hidden`, the release-build + norm and the MSVC default) an untagged definition is local and ships no + usable symbol. On GCC and Clang the macro applies `visibility("default")`, + so your definitions export with no extra flags. When you implement the header on Windows, compile your library with `WEAVEFFI_BUILD` defined so the macro switches to `__declspec(dllexport)`: @@ -311,8 +531,8 @@ When you implement the header on Windows, compile your library with cc -DWEAVEFFI_BUILD -shared mylib.c -o mylib.dll ``` -Deprecated functions carry a companion `WEAVEFFI_DEPRECATED("...")` macro that -expands to `__declspec(deprecated(...))` on MSVC and +Deprecated functions carry a companion `WEAVEFFI_DEPRECATED("...")` macro +that expands to `__declspec(deprecated(...))` on MSVC and `__attribute__((deprecated(...)))` on GCC and Clang. When the IDL sets `c_prefix`, both macros follow it: a `c_prefix` of `acme` @@ -324,21 +544,17 @@ colliding. An enum whose variants declare `fields` is a *rich* (algebraic) enum, a sum type with associated data. Unlike a plain C-style enum (a bare `int32_t` -discriminant), a rich enum crosses the ABI as a serialized **value buffer**: -an `i32` tag (the variant's declared discriminant) followed by the active +discriminant), a rich enum crosses the ABI as a serialized value buffer: an +`i32` tag (the variant's declared discriminant) followed by the active variant's fields in declaration order. No per-enum C symbols are generated; -the consumer packs and unpacks the bytes. From the `shapes` sample -(`Shape` = `Empty | Circle{radius} | Rectangle{width,height} | -Labeled{label,count}`), a function taking and returning a `Shape` looks -like any other buffered call: +the consumer packs and unpacks the bytes. From the `codec` sample (`Shape` += `Empty | Circle{radius} | Rect{width,height} | Labeled{label,count} | +Nested{inner,note}`), a function taking and returning a `Shape` looks like +any other buffered call: ```c -/* scale(shape: Shape, factor: f64) -> Shape */ -const uint8_t* weaveffi_shapes_scale( - const uint8_t* shape_ptr, size_t shape_len, - double factor, - size_t* out_len, - weaveffi_error* out_err); +/** Return the argument unchanged. */ +WEAVEFFI_API const uint8_t* weaveffi_codec_roundtrip_shape(const uint8_t* value_ptr, size_t value_len, size_t* out_len, weaveffi_error* out_err); ``` To build a `Circle{radius: 2.0}`, encode the tag and the payload @@ -357,9 +573,9 @@ memcpy(shape, &tag, 4); memcpy(shape + 4, &radius, 8); size_t out_len = 0; -const uint8_t* scaled = weaveffi_shapes_scale(shape, sizeof shape, 3.0, &out_len, &err); -/* decode the returned tag and fields from `scaled` ... */ -weaveffi_free_bytes((uint8_t*)scaled, out_len); +const uint8_t* back = weaveffi_codec_roundtrip_shape(shape, sizeof shape, &out_len, &err); +/* decode the returned tag and fields from `back` ... */ +weaveffi_free_bytes((uint8_t*)back, out_len); ``` The consumer owns every buffer a function returns; release each one with @@ -367,40 +583,53 @@ The consumer owns every buffer a function returns; release each one with ## Build instructions -The runnable consumer uses the `contacts` sample crate and its -conformance program. +The runnable consumers use the sample crates and their conformance +programs; `conformance/c/wvbuf.h` is a small header-only value-buffer +writer and reader you can copy into your own project. macOS: ```bash -cargo build -p contacts -weaveffi generate samples/contacts/src/lib.rs -o generated +cargo build -p events +weaveffi generate samples/events/src/lib.rs -o generated -cc -I generated/c conformance/c/contacts.c -L target/debug -lcontacts -o c_contacts -DYLD_LIBRARY_PATH=target/debug ./c_contacts +cc -I generated/c -I conformance/c conformance/c/events.c -L target/debug -levents -o c_events +DYLD_LIBRARY_PATH=target/debug ./c_events ``` Linux: ```bash -cargo build -p contacts -weaveffi generate samples/contacts/src/lib.rs -o generated +cargo build -p events +weaveffi generate samples/events/src/lib.rs -o generated -cc -I generated/c conformance/c/contacts.c -L target/debug -lcontacts -o c_contacts -LD_LIBRARY_PATH=target/debug ./c_contacts +cc -I generated/c -I conformance/c conformance/c/events.c -L target/debug -levents -o c_events +LD_LIBRARY_PATH=target/debug ./c_events ``` Windows: ```powershell -cargo build -p contacts -weaveffi generate samples\contacts\src\lib.rs -o generated -cl /I generated\c conformance\c\contacts.c /link contacts.lib -.\contacts.exe +cargo build -p events +weaveffi generate samples\events\src\lib.rs -o generated +cl /I generated\c /I conformance\c conformance\c\events.c /link events.lib +.\events.exe ``` See `conformance/c/` for end-to-end consumers of every sample. +## Packaging + +`weaveffi package --target c` emits the header under `c/include/`, one +prebuilt library per platform under `c/lib//`, and a +`CMakeLists.txt` that picks the library matching the host and exposes it as +an imported target; `add_subdirectory` and link. Every library you pass +with `--binary` is copied, but the generated `CMakeLists.txt` only knows how +to select the desktop platforms (`darwin-arm64`, `darwin-x64`, `linux-x64`, +`linux-arm64`, `windows-x64`); Android and `wasm32` slices, if bundled, need +your own build logic to pick them up. See +[Packaging and Distribution](../guides/packaging.md). + ## Memory and ownership Rust always owns memory it allocates. Strings and byte buffers returned @@ -408,183 +637,152 @@ across the boundary must be freed by the consumer with the matching helper: ```c -const char* name = weaveffi_contacts_greet("Alice", &err); -printf("%s\n", name); -weaveffi_free_string(name); +const char* text = weaveffi_codec_roundtrip_string("héllo", &err); +printf("%s\n", text); +weaveffi_free_string(text); size_t len; -const uint8_t* data = weaveffi_storage_get_data(&len, &err); +const uint8_t* data = weaveffi_codec_roundtrip_bytes(in, in_len, &len, &err); weaveffi_free_bytes((uint8_t*)data, len); ``` -Returned value buffers (structs, rich enums, optionals, lists, maps) -follow the bytes rule: decode, then free once with -`weaveffi_free_bytes(ptr, out_len)`. For interface objects, call the -matching `_destroy` symbol when the consumer is done. Borrowed parameters -(`const T*`, `string`/`bytes` inputs, buffered `(ptr, len)` pairs) remain -owned by the caller for the duration of the call only. +Returned value buffers (structs, rich enums, optionals, lists, maps) follow +the bytes rule: decode, then free once with `weaveffi_free_bytes(ptr, +out_len)`, and `_destroy` every object token you adopted from it. For +top-level objects, release each strong reference with `_destroy`. Borrowed +parameters (`const T*` objects, `string`/`bytes` inputs, buffered `(ptr, +len)` pairs) remain owned by the caller for the duration of the call only. -## Callbacks and listeners +## Async support -A `callbacks:` entry becomes a function-pointer typedef whose -parameters mirror the IDL signature plus a trailing opaque -`void* context`. A `listeners:` entry becomes a register/unregister -pair built on that typedef. From the `events` sample: +Async functions (`async: true`) get no synchronous prototype. Each one +emits a per-function callback typedef, `(void* context, weaveffi_error* +err, )`, and a launcher with the `_async` suffix. From the +`events` sample's `EventBus.publish_later`: ```c -typedef void (*weaveffi_events_OnMessage_fn)(const char* message, void* context); +typedef void (*weaveffi_events_EventBus_publish_later_callback)(void* context, weaveffi_error* err, int64_t result); -uint64_t weaveffi_events_register_message_listener( - weaveffi_events_OnMessage_fn callback, - void* context); -void weaveffi_events_unregister_message_listener(uint64_t id); +/** Publish from a producer thread, resolving with the accepted count. */ +WEAVEFFI_API void weaveffi_events_EventBus_publish_later_async(const weaveffi_events_EventBus* self, const char* topic, const char* text, weaveffi_events_EventBus_publish_later_callback callback, void* context); ``` -The contract: - -- `register_*` stores the `(callback, context)` pair and returns a - `uint64_t` subscription id. Pass that id to `unregister_*` to stop - delivery. -- `context` is opaque to the producer and is passed back verbatim as - the last argument of every invocation. It must stay valid until the - listener is unregistered. -- The producer invokes the callback on **its own thread**, whenever - the event fires. The callback must be thread-safe and must not - assume it runs on the registering thread. -- Pointer arguments (e.g. `const char* message`) are only valid for - the duration of the invocation; copy anything that must outlive it. +The launcher returns immediately; the producer runs the future on its +configured spawner (`weaveffi::set_spawner`; the default drives each future +on a dedicated thread) and invokes the callback exactly once, from an +arbitrary producer thread, with either a result or a populated error. The +launcher clones `self` for the duration of the call, so destroying your +own reference while the call is pending is safe. + +Ownership inside the callback follows the async contract: everything the +callback receives is owned by the consumer. Copy or decode result buffers +(strings, bytes, and the serialized value buffers of buffered results), +then release them with `weaveffi_free_string` or `weaveffi_free_bytes`. An +object result is one strong reference to `_destroy`. A non-null `err` is +heap-boxed: copy its code, message, and payload, then release it exactly +once with `weaveffi_error_free` (null is a safe no-op, so the +`conformance/c/events.c` consumer calls it unconditionally): ```c -static void on_message(const char* message, void* context) { - int* count = context; /* runs on the producer's thread */ - (*count)++; +static void on_publish_later(void* context, weaveffi_error* err, int64_t result) { + g_later_err = err ? err->code : 0; + weaveffi_error_free(err); + g_later_result = result; + atomic_store(&g_later_done, 1); } - -weaveffi_error err = {0}; -int count = 0; -uint64_t id = weaveffi_events_register_message_listener(on_message, &count); -weaveffi_events_send_message("hello", &err); /* fires the listener */ -weaveffi_events_unregister_message_listener(id); -``` - -## Async support - -Async functions (`async: true`) get no synchronous prototype. Each one -emits a per-function callback typedef, `(void* context, -weaveffi_error* err, )`, and a launcher with the -`_async` suffix. From the `async-demo` sample: - -```c -/* run_task returns a TaskResult record: a buffered async result. */ -typedef void (*weaveffi_tasks_run_task_callback)( - void* context, - weaveffi_error* err, - const uint8_t* result_ptr, - size_t result_len); - -void weaveffi_tasks_run_task_async( - const char* name, - weaveffi_tasks_run_task_callback callback, - void* context); ``` -The launcher returns immediately; WeaveFFI invokes the callback -exactly once, with either a result or a populated error, from the -producer's worker thread. - -Ownership inside the callback follows the async contract: everything -the callback receives is owned by the consumer. Copy or decode result -buffers (strings, bytes, and the serialized value buffers of buffered -results), then release them with `weaveffi_free_string` or -`weaveffi_free_bytes`. Owned interface results transfer ownership of -the object pointer, which you must eventually pass to the matching -`_destroy`. A non-null `err` is heap-boxed: copy its code, message, -and payload, then release it exactly once with `weaveffi_error_free`. +A panic inside the spawned future is caught (`weaveffi::abi::CatchUnwind`) +and reported through `err` with `PANIC_ERROR_CODE`, so the callback still +fires exactly once; a callback-interface failure inside the future arrives +as `FOREIGN_ERROR_CODE` the same way. For `cancellable: true` functions the launcher gains a `weaveffi_cancel_token*` slot before the callback, and the runtime -provides the token lifecycle. Async interface methods follow the same -shape with the leading `self` slot; from the `kvstore` sample's async -cancellable `Store.compact`: +provides the token lifecycle. From the `kvstore` sample's `Store.compact`: ```c -typedef void (*weaveffi_kv_Store_compact_callback)( - void* context, - weaveffi_error* err, - int64_t result); - -void weaveffi_kv_Store_compact_async( - const weaveffi_kv_Store* self, - weaveffi_cancel_token* cancel_token, - weaveffi_kv_Store_compact_callback callback, - void* context); - -weaveffi_cancel_token* weaveffi_cancel_token_create(void); -void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token); -bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token); -void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token); +typedef void (*weaveffi_kv_Store_compact_callback)(void* context, weaveffi_error* err, int64_t result); + +WEAVEFFI_API void weaveffi_kv_Store_compact_async(const weaveffi_kv_Store* self, weaveffi_cancel_token* cancel_token, weaveffi_kv_Store_compact_callback callback, void* context); + +WEAVEFFI_API weaveffi_cancel_token* weaveffi_cancel_token_create(void); +WEAVEFFI_API void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token); +WEAVEFFI_API bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token); +WEAVEFFI_API void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token); ``` See [Async functions](../guides/async.md) for the full pattern. ## Iterators -Functions returning `iter` produce an opaque iterator handle plus +Functions returning `iter` produce an opaque iterator pointer plus `_next`/`_destroy` functions instead of a materialized list. From the -`events` sample (`get_messages` returns `iter`): +`events` sample (`EventBus.messages` returns `iter`): ```c -typedef struct weaveffi_events_GetMessagesIterator weaveffi_events_GetMessagesIterator; +typedef struct weaveffi_events_EventBus_MessagesIterator weaveffi_events_EventBus_MessagesIterator; -weaveffi_events_GetMessagesIterator* weaveffi_events_get_messages( - weaveffi_error* out_err); -int32_t weaveffi_events_GetMessagesIterator_next( - weaveffi_events_GetMessagesIterator* iter, - const char** out_item, - weaveffi_error* out_err); -void weaveffi_events_GetMessagesIterator_destroy( - weaveffi_events_GetMessagesIterator* iter); +WEAVEFFI_API weaveffi_events_EventBus_MessagesIterator* weaveffi_events_EventBus_messages(const weaveffi_events_EventBus* self, weaveffi_error* out_err); +WEAVEFFI_API int32_t weaveffi_events_EventBus_MessagesIterator_next(weaveffi_events_EventBus_MessagesIterator* iter, const char** out_item, weaveffi_error* out_err); +WEAVEFFI_API void weaveffi_events_EventBus_MessagesIterator_destroy(weaveffi_events_EventBus_MessagesIterator* iter); ``` -`_next` writes the next element into the one-slot out-param and -returns `1`, or returns `0` when exhausted (leaving `*out_item` -untouched). Failures are reported through `out_err`, so check it after -the loop ends. Element ownership follows the usual return rules; each -`next` hands over an element the consumer now owns, so here each -`const char*` must be freed with `weaveffi_free_string`. Call -`_destroy` exactly once when done, even if iteration stopped early: +`_next` writes the next element into the out-param and returns `1`, or +returns `0` when exhausted (leaving `*out_item` untouched). Failures are +reported through `out_err`, so check it after the loop ends. Element +ownership follows the usual return rules; each `next` hands over an element +the consumer now owns, so here each `const char*` must be freed with +`weaveffi_free_string`. Call `_destroy` exactly once when done, even if +iteration stopped early: ```c weaveffi_error err = {0}; -weaveffi_events_GetMessagesIterator* iter = weaveffi_events_get_messages(&err); +weaveffi_events_EventBus_MessagesIterator* it = weaveffi_events_EventBus_messages(bus, &err); const char* item = NULL; -while (weaveffi_events_GetMessagesIterator_next(iter, &item, &err) == 1) { +while (weaveffi_events_EventBus_MessagesIterator_next(it, &item, &err) != 0) { printf("%s\n", item); weaveffi_free_string(item); } if (err.code != 0) { /* a failing step ended the loop */ } -weaveffi_events_GetMessagesIterator_destroy(iter); +weaveffi_events_EventBus_MessagesIterator_destroy(it); ``` -An iterator over a buffered element type (records, rich enums, -composites) writes each element as a producer-allocated value buffer -through `const uint8_t** out_item` plus a `size_t* out_len`; decode it, -then free it with `weaveffi_free_bytes` per element. +An iterator over a buffered element type writes each element as a +producer-allocated value buffer through `const uint8_t** out_item` plus a +`size_t* out_len`; decode it, then free it with `weaveffi_free_bytes` per +element. An iterator over objects writes a `{tag}**` slot holding one +strong reference per element, which you `_destroy` when done. + +The higher-level targets wrap exactly these three symbols in their native +lazy idioms; only the C surface exposes them raw. + +## Known limitations -The higher-level targets wrap exactly these three symbols in their -native lazy idioms; only the C surface exposes them raw. +- C has no wrapper type, so nothing stops you from using a pointer after + `_destroy` or decoding a buffer with object tokens twice. Both are + undefined behavior; the other targets guard against them. +- There is no generated value-buffer codec for C. You write the encoder and + decoder yourself (or start from `conformance/c/wvbuf.h`). +- Async completion runs on a producer thread with no marshalling back to + the caller; you synchronize (the conformance consumers use C11 atomics). +- Callback-interface methods run on the producer's thread and the vtable + entries must be thread-safe; nothing serializes them for you. ## Troubleshooting -- **`undefined reference to weaveffi_*`**: make sure the linker sees - the cdylib (`-L target/debug -l`). The header alone is - not enough. +- **`undefined reference to weaveffi_*`**: make sure the linker sees the + cdylib (`-L target/debug -l`). The header alone is not + enough. - **Crashes inside `weaveffi_free_string`**: the pointer wasn't - Rust-allocated. Only free pointers returned from a generated getter - or function. -- **`error: unknown type weaveffi_handle_t`**: the consumer included - the header without ``. Include order matters; the generated - header pulls in the standard integer typedefs explicitly. + producer-allocated. Only free pointers returned from a generated + function, iterator step, or async callback. +- **`err.code == -3` on a method call**: the `self` pointer was null, a + `string` parameter was null or not UTF-8, or a value buffer you encoded + is malformed. Check the buffer layout against the + [protocol](../reference/value-buffers.md). +- **`err.code == -4` from a function you didn't expect to fail**: one of + your vtable methods reported a foreign error. The message is yours. - **`weaveffi.c` looks nearly empty**: that file only carries the default `weaveffi_alloc`/`weaveffi_dealloc` implementations for Wasm producers. All declarations live in `weaveffi.h`. diff --git a/docs/src/generators/cpp.md b/docs/src/generators/cpp.md index 293bcd39..b5fb004f 100644 --- a/docs/src/generators/cpp.md +++ b/docs/src/generators/cpp.md @@ -4,17 +4,24 @@ The C++ target emits a header-only library `weaveffi.hpp` that wraps the C ABI in idiomatic C++17. Structs become plain value structs, rich enums -become `std::variant`-backed sum types, interfaces become RAII classes with -deleted copies and movable handles, error domains map to typed exception -hierarchies, async functions return `std::future`, and listeners accept -`std::function` callbacks. A `CMakeLists.txt` is included so the generated +become `std::variant`-backed sum types, interfaces become copyable RAII +classes holding one strong reference to a reference-counted producer +object, callback interfaces become abstract classes you subclass and pass +as `std::shared_ptr`, error domains map to typed exception hierarchies, +async functions return `std::future`, and `iter` returns a lazy range. +The header inlines the same `extern "C"` declarations the C target emits, +so it's self-contained. A `CMakeLists.txt` is included so the generated directory can be dropped into any CMake build. +The generated surface follows ABI revision 2 (`WEAVEFFI_ABI_VERSION`). +Call `weaveffi::check_abi_version()` once at startup; it throws +`WeaveFFIError` if the loaded library was built for a different revision. + ## What gets generated | File | Purpose | |------|---------| -| `generated/cpp/weaveffi.hpp` | Header-only bindings: extern "C" declarations, RAII wrappers, enum classes, inline function wrappers | +| `generated/cpp/weaveffi.hpp` | Header-only bindings: extern "C" declarations, RAII wrappers, abstract callback classes, enum classes, inline function wrappers | | `generated/cpp/CMakeLists.txt` | INTERFACE library target (`weaveffi_cpp`) | | `generated/cpp/README.md` | Build instructions | @@ -35,9 +42,10 @@ directory can be dropped into any CMake build. | `bool` | `bool` | `bool` | | `string` | `std::string` | `const std::string&` | | `bytes` | `std::vector` | `const std::vector&` | -| `handle` | `void*` | `void*` | | `StructName` | `StructName` (value struct) | `const StructName&` | -| `InterfaceName` | `InterfaceName` (RAII class) | `const InterfaceName&` | +| `InterfaceName` | `InterfaceName` (RAII class) | `const InterfaceName&` (borrowed) | +| `InterfaceName?` | `std::optional` | `const std::optional&` | +| `CallbackName` | abstract class `CallbackName` | `std::shared_ptr` | | `EnumName` (plain) | `EnumName` (`enum class`) | `EnumName` | | `EnumName` (rich) | `EnumName` (`std::variant`-backed sum type) | `const EnumName&` | | `T?` | `std::optional` | `const std::optional&` | @@ -45,10 +53,15 @@ directory can be dropped into any CMake build. | `{K: V}` | `std::unordered_map` | `const std::unordered_map&` | | `iter` | generated lazy range class (return only; see [Iterators](#iterators)) | n/a | +64-bit integers are native `int64_t`/`uint64_t`, and `float`/`double` +cross the ABI as IEEE values, so NaN, the infinities, and `-0.0` round-trip +bit-for-bit (the `codec` sample's `roundtrip_u64` and `roundtrip_f64` +exercise this). + ## Example IDL → generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -152,109 +165,257 @@ inline Contact create_contact( } // namespace weaveffi ``` -The module namespace replaces the old flat `contacts_create_contact` -spelling; call it as `weaveffi::contacts::create_contact(...)`. Nested IDL -modules nest namespaces the same way (`weaveffi::kv::stats::get_stats`). +Call it as `weaveffi::contacts::create_contact(...)`. Nested IDL modules +nest namespaces the same way (`weaveffi::kv::stats::get_stats`). + +## Objects (interfaces) + +An `interfaces:` entry becomes a copyable RAII class that holds exactly one +strong reference to a reference-counted object owned by the producer. +Constructors become static factories (or a real C++ constructor when the +IDL constructor is named `new`, as `EventBus()` in the `events` sample), +methods are instance members, and statics are static members. From the +`kvstore` sample's `Store`: + +```cpp +class Store { + weaveffi_kv_Store* handle_; + +public: + /** Adopts one strong reference to a producer object. */ + explicit Store(weaveffi_kv_Store* h) : handle_(h) {} + + /** Releases this wrapper's reference; the object is dropped with its last one. */ + ~Store() { + if (handle_) weaveffi_kv_Store_destroy(handle_); + } + + /** Copies share the object: the copy takes a new strong reference. */ + Store(const Store& other) : handle_(weaveffi_kv_Store_clone(other.handle_)) {} + + Store(Store&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + + /** The wrapped pointer, borrowed: this wrapper keeps its reference. */ + const weaveffi_kv_Store* handle() const { return handle_; } + + /** A new strong reference the caller owns (for example to write into a value buffer). */ + weaveffi_kv_Store* clone_handle() const { return weaveffi_kv_Store_clone(handle_); } + + static Store open(const std::string& path); + bool delete_(const std::string& key) const; + int64_t count() const; + StoreListKeysIterator list_keys(const std::optional& prefix) const; + std::future compact(weaveffi_cancel_token* cancel_token = nullptr) const; + static int64_t default_capacity(); +}; +``` + +- **Disposal is RAII.** The destructor calls `_destroy` once, releasing + this wrapper's reference; the producer drops the object when the last + reference (from any wrapper, any record field, or the producer's own + retention) goes away. There is no public `destroy()` or `close()`. +- **Copies mint a new strong reference.** The copy constructor and copy + assignment call `_clone`, so `Store copy = store;` yields a second wrapper + over the *same* object (`copy.handle() == store.handle()`); a mutation + through one is visible through the other, and each destructor releases + its own reference. Moves transfer the pointer and leave the source empty. +- **Use after move.** A moved-from wrapper has `handle() == nullptr`. Its + destructor is a no-op, but calling a method on it passes a null object to + the producer, which rejects the call with `MARSHAL_ERROR_CODE` (-3) as a + `WeaveFFIError`. Don't reuse a moved-from wrapper. +- `handle()` borrows the raw pointer for the duration of a call; + `clone_handle()` returns a fresh strong reference you must eventually + pass to `_destroy` (or adopt into another wrapper). + +Method names keep their snake_case IDL spelling; a name that collides with +a C++ keyword gains a trailing underscore (`delete` → `delete_`). +Deprecated members carry `[[deprecated("...")]]`. + +### Objects as parameters, returns, and inside values + +A top-level object parameter is passed as `const Store&` and borrowed for +the call. A returned object is adopted into a new wrapper. `Store?` maps to +`std::optional`: a disengaged optional passes a null pointer, and a +null return becomes `std::nullopt`: + +```cpp +inline std::optional Store::larger(const std::optional& other) const { + weaveffi_error err{}; + auto result = weaveffi_kv_Store_larger(handle_, other.has_value() ? other->handle() : nullptr, &err); + detail::check(err); + if (!result) return std::nullopt; + return Store(result); +} +``` + +Objects inside records, lists, map values, optionals, and rich-enum +payloads are held by value (`Store store; std::optional mirror;` in +`StoreInfo`). On the wire they're `u64` tokens: the generated pack routine +calls `clone_handle()` for each one, so the producer receives its own +reference and your wrapper stays valid, and the unpack routine adopts the +token into a wrapper: + +```cpp +inline void write_StoreInfo(BufferWriter& w, const StoreInfo& v) { + w.write_string(v.label); + w.write_u64(static_cast(reinterpret_cast(v.store.clone_handle()))); + w.write_option_flag(v.mirror.has_value()); + if (v.mirror.has_value()) { + w.write_u64(static_cast(reinterpret_cast((*v.mirror).clone_handle()))); + } + w.write_i64(v.count); +} + +inline StoreInfo read_StoreInfo(BufferReader& r) { + std::string f_label = r.read_string(); + Store f_store = Store(reinterpret_cast(static_cast(r.read_u64()))); + // ... +} +``` + +`Store::open_many` returns `std::vector` (one adopted wrapper per +element), and `Store::total_count(const std::vector&, const std::optional&)` +clones every object it encodes. Copying a record that contains an object +copies the wrapper, which clones the reference. ## Typed errors `WeaveFFIError` extends `std::runtime_error` and carries the raw `code()`. A module's error domain generates a typed hierarchy: one class named after the domain, plus one subclass per declared code, each named in PascalCase -with exactly one `Error` suffix. From the `contacts` sample's -`ContactsError` domain: +with exactly one `Error` suffix. From the `kvstore` sample's `KvError` +domain: ```cpp -namespace weaveffi { - -class ContactsError : public WeaveFFIError { +class KvError : public WeaveFFIError { public: - ContactsError(int32_t code, const std::string& msg) : WeaveFFIError(code, msg) {} + KvError(int32_t code, const std::string& msg) : WeaveFFIError(code, msg) {} }; -/** name must not be empty */ -class InvalidNameError : public ContactsError { /* ... */ }; - -/** contact not found */ -class NotFoundError : public ContactsError { /* ... */ }; - -} // namespace weaveffi +/** key not found */ +class KeyNotFoundError : public KvError { +public: + KeyNotFoundError(const std::string& msg) : KvError(1001, msg) {} +}; ``` A callable declared with `throws: true` routes its failure through a -per-domain checker (`detail::check_contacts`) that throws the most specific +per-domain checker (`detail::check_kv`) that throws the most specific subclass, so you can catch a single code, the domain, or the generic base: ```cpp try { - auto contact = book.get(42); -} catch (const weaveffi::NotFoundError& e) { + auto entry = store.get("missing"); +} catch (const weaveffi::KeyNotFoundError& e) { std::cerr << "Not found: " << e.what() << '\n'; -} catch (const weaveffi::ContactsError& e) { - std::cerr << "Contacts error " << e.code() << ": " << e.what() << '\n'; +} catch (const weaveffi::KvError& e) { + std::cerr << "kv error " << e.code() << ": " << e.what() << '\n'; } ``` -A callable without `throws` has the same C++ signature (C++ has no checked -exceptions), but its failures can only be producer bugs (a panic or a -marshalling failure), which arrive as the generic `weaveffi::WeaveFFIError` -rather than a domain type. An unknown code on the typed path falls back to -the domain class itself (`ContactsError`). - An error code that declares payload `fields:` exposes them as typed members on its subclass, decoded from the error's payload buffer before -`weaveffi_error_clear` releases it. +`weaveffi_error_clear` releases it. An unknown positive code on the typed +path falls back to the domain class itself (`KvError`). -## Interfaces +### Runtime error codes -An `interfaces:` entry becomes a move-only RAII class owning an opaque -handle. Constructors become static factories, -methods are instance members, statics are static members, and the -destructor calls the implicit C `_destroy` symbol. From the `kvstore` -sample's `Store` (trimmed): +Domain codes are validated positive-only, so a negative runtime code always +surfaces as the generic `WeaveFFIError`, never a typed domain exception. +That's also why no wrapper is `noexcept`: a callable without `throws` has +the same C++ signature and still checks `out_err` through `detail::check`. -```cpp -/** An embedded key-value store owning its entries */ -class Store { - void* handle_; +The header doesn't name these codes; the names below are the ABI's (see +[ABI](../reference/abi.md)). -public: - ~Store() { - if (handle_) weaveffi_kv_Store_destroy(static_cast(handle_)); - } - Store(const Store&) = delete; - Store(Store&& other) noexcept; +| Code | ABI name | When | +|------|----------|------| +| -1 | `GENERIC_ERROR_CODE` | The producer reported a failure with no domain code (also what `check_abi_version()` throws) | +| -2 | `PANIC_ERROR_CODE` | The Rust producer panicked inside an export or a spawned async future | +| -3 | `MARSHAL_ERROR_CODE` | A null object pointer or a malformed value buffer or string was rejected at the boundary | +| -4 | `FOREIGN_ERROR_CODE` | A callback-interface implementation threw | - /** Open (or create) a store backed by the given filesystem path */ - static Store open(const std::string& path) { - weaveffi_error err{}; - auto result = weaveffi_kv_Store_open(path.c_str(), &err); - detail::check_kv(err); // throws: true -> typed KvError path - return Store(result); - } +Sync callables throw from the call; async callables reject the future so +`.get()` rethrows; iterator steps throw from `next()` (after releasing the +producer iterator). - /** Remove the entry for the given key, returning true if it existed */ - bool delete_(const std::string& key) const; +## Callback interfaces + +A `callback_interfaces:` entry becomes an abstract class with one pure +virtual method per IDL method. Subclass it and pass a +`std::shared_ptr` to any function that accepts the interface. From +the `events` sample: - /** Return the number of live entries in the store */ - int64_t count() const; // no throws: generic check only +```cpp +class Subscriber { +public: + virtual ~Subscriber() = default; - /** Stream every key, optionally filtered by a prefix */ - ListKeysIterator list_keys(const std::optional& prefix) const; + virtual Delivery route(const std::string& topic) = 0; + virtual int64_t on_message(const Message& message) = 0; + virtual void on_attached(EventBus bus) = 0; +}; +``` - /** Reclaim space asynchronously; returns the number of bytes reclaimed */ - std::future compact(weaveffi_cancel_token* cancel_token = nullptr) const; +```cpp +class RecordingSubscriber : public Subscriber { + std::optional kept_bus_; - /** The largest number of live entries one store will hold */ - static int64_t default_capacity(); +public: + Delivery route(const std::string& topic) override { + return topic == "quiet" ? Delivery::Skip : Delivery::Accept; + } + int64_t on_message(const Message& message) override { + std::cout << message.topic << ": " << message.text << '\n'; + return 1; + } + void on_attached(EventBus bus) override { + kept_bus_ = std::move(bus); // or let it drop to release the reference + } }; + +weaveffi::EventBus bus; +bus.subscribe(std::make_shared()); ``` -Method names keep their snake_case IDL spelling; a name that collides with -a C++ keyword gains a trailing underscore (`delete` → `delete_`). -Deprecated members carry `[[deprecated("...")]]`. An interface parameter is -passed as `const Store&` (borrowed); an interface return wraps the owned -pointer in a new instance. +Under the hood the wrapper boxes your `shared_ptr` on the heap as the +vtable `ctx`, hands the producer the one process-wide static vtable for the +interface, and the vtable's `free` deletes the box when the producer drops +its last reference (which runs your destructor): + +```cpp +inline int64_t EventBus::subscribe(std::shared_ptr subscriber) const { + if (!subscriber) throw std::invalid_argument("subscriber: null callback interface"); + auto* subscriber_ctx = new std::shared_ptr(std::move(subscriber)); + // ... weaveffi_events_EventBus_subscribe(handle_, subscriber_ctx, &detail::Subscriber_vtable(), &err) +} +``` + +- **Argument ownership.** Strings and buffered values (`const Message&`) + are decoded into temporaries that live for the duration of the call; + copy them if you need them afterward. An object argument + (`EventBus bus`) is passed *by value* and is owned by your + implementation: keep it (move it into a member) or let it go out of + scope to release the reference. +- **Lifetime.** The producer may keep the implementation as long as it + likes (`subscribe` retains it until `clear_subscribers`); a function that + only uses it for the call (`route_once`) frees it before returning. + Passing a null `shared_ptr` throws `std::invalid_argument` before + anything crosses the ABI. +- **Exceptions.** Trampolines never let an exception unwind through the C + frame. Anything derived from `std::exception` is reported with + `weaveffi_error_set(out_err, -4, e.what())`; anything else gets a + generic message. The producer aborts the call that triggered the + callback, and the caller sees `WeaveFFIError` with `code() == -4` + carrying your `what()`, for a `throws` and a non-`throws` callable alike, + and through the future of an async callable. The implementation stays + attached. +- **Threads.** Methods run on whichever thread the producer calls from: the + calling thread for a synchronous call, a producer worker for an async + one (`publish_later` in the sample). Synchronize shared state yourself; + the wrapper doesn't marshal calls onto any particular thread. ## Rich (algebraic) enums @@ -294,7 +455,8 @@ weaveffi::Shape bigger = weaveffi::shapes::scale(shape, 3.0); On the wire a rich enum is a value buffer holding an `i32` tag followed by the active variant's fields; there are no per-variant C constructors, tag readers, or destroy symbols. The wrappers pack and unpack the buffer, so no -manual free is required. +manual free is required. A variant payload may hold an object; it follows +the token rules above. ## Build instructions @@ -319,136 +481,81 @@ target_link_libraries(myapp weaveffi_cpp) ``` Then `#include "weaveffi.hpp"` and link against the Rust shared library -(`libweaveffi.dylib`, `libweaveffi.so`, or `weaveffi.dll`). +(`libweaveffi.dylib`, `libweaveffi.so`, or `weaveffi.dll`). The header +needs `-std=c++17` (or `cxx_std_17`); `std::optional`, `std::variant`, and +`std::future` come from the standard library, so no other dependency is +required. + +## Packaging + +`weaveffi package --target cpp` emits the header under `cpp/include/`, +one prebuilt library per desktop platform under `cpp/lib//`, and +a `CMakeLists.txt` that selects the library matching the host and links it +into the `weaveffi_cpp` interface target. Only desktop slices are bundled +(`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`); +Android and `wasm32` binaries are skipped. See +[Packaging and Distribution](../guides/packaging.md). ## Memory and ownership -- Interface wrappers own a single `void*` handle. The destructor calls - the C `_destroy` function. Copies are deleted; moves transfer ownership - by nulling the source handle. +- Interface wrappers own one strong reference. The destructor releases it; + copies clone it; moves transfer it. Nothing else to do. - Structs, rich enums, optionals, lists, and maps are plain C++ values. They cross the ABI serialized in a single value buffer: parameters are packed by the wrapper and borrowed by the callee for the duration of the call, and returns are unpacked into the C++ value and the - producer's buffer is released with `weaveffi_free_bytes`. Nothing to - free on the consumer side afterward. + producer's buffer is released with `weaveffi_free_bytes`. Object fields + inside those values are cloned on the way in and adopted on the way out. - Returned strings are copied into `std::string` and the raw pointer is freed via `weaveffi_free_string` before returning. - -## Callbacks and listeners - -Listeners surface as free functions in the module's namespace taking -`std::function`. The register wrapper boxes the callable in a -`std::shared_ptr`, hands the C ABI a capture-less trampoline plus the raw -pointer as `context`, and pins the box in a global registry so it stays -alive until unregister. From the `events` sample (trimmed): - -```cpp -namespace detail { - -inline std::mutex& wv_listener_mutex() { - static std::mutex m; - return m; -} - -inline std::unordered_map>& wv_listener_registry() { - static std::unordered_map> registry; - return registry; -} - -} // namespace detail - -namespace events { - -inline uint64_t register_message_listener(std::function callback) { - auto fn = std::make_shared>(std::move(callback)); - uint64_t id = weaveffi_events_register_message_listener( - [](const char* message, void* context) { - auto& cb = *static_cast*>(context); - cb(std::string(message ? message : "")); - }, - fn.get()); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry()[id] = fn; - return id; -} - -inline void unregister_message_listener(uint64_t id) { - weaveffi_events_unregister_message_listener(id); - std::lock_guard lock(detail::wv_listener_mutex()); - detail::wv_listener_registry().erase(id); -} - -} // namespace events -``` - -- `register_*` returns the `uint64_t` subscription id from the C - layer. The registry (`detail::wv_listener_registry()`, a - `std::unordered_map>` guarded by - `detail::wv_listener_mutex()`) maps that id to the boxed - `std::function`, keeping it alive while events can still fire. -- `unregister_*` first unregisters at the C layer, then erases the - registry entry, releasing the callable. -- The static trampoline converts the C arguments to C++ types - (`const char*` → `std::string`) before invoking the stored function. -- The callback runs on the producer's thread, not the thread that - registered it; capture and synchronize accordingly. - -```cpp -uint64_t id = weaveffi::events::register_message_listener( - [](std::string message) { std::cout << message << '\n'; }); -weaveffi::events::send_message("hello"); -weaveffi::events::unregister_message_listener(id); -``` +- Callback-interface implementations are owned jointly by your + `shared_ptr` and the producer's box; the producer's `free` releases its + share. ## Async support Async IDL functions return `std::future`. The wrapper allocates a heap-owned `std::promise`, hands the C ABI a callback that resolves -(or rejects) the promise, and returns the corresponding future: +(or rejects) the promise, and returns the corresponding future. From the +`kvstore` sample's cancellable `Store::compact`: ```cpp -inline std::future fetch_contact(int32_t id) { - auto* promise_ptr = new std::promise(); +inline std::future Store::compact(weaveffi_cancel_token* cancel_token) const { + auto* promise_ptr = new std::promise(); auto future = promise_ptr->get_future(); - weaveffi_contacts_fetch_contact_async(id, - [](void* context, weaveffi_error* err, - const uint8_t* result_ptr, size_t result_len) { - auto* p = static_cast*>(context); + weaveffi_kv_Store_compact_async(handle_, cancel_token, [](void* context, weaveffi_error* err, int64_t result) { + auto* p = static_cast*>(context); + try { if (err && err->code != 0) { std::string msg(err->message ? err->message : "unknown error"); - p->set_exception(detail::make_error(err->code, msg)); + p->set_exception(detail::make_kv_error(err->code, msg, err->payload_ptr, err->payload_len)); } else { - p->set_value(/* unpack (result_ptr, result_len) */); + p->set_value(result); } - delete p; - }, static_cast(promise_ptr)); + } catch (...) { + p->set_exception(std::current_exception()); + } + weaveffi_error_free(err); + delete p; + }, static_cast(promise_ptr)); return future; } ``` Use it with `.get()` (blocking) or compose with your event loop. The completion lambda runs exactly once, on an arbitrary producer thread; it -completes (or rejects) the promise and then deletes it. Everything -passed to the callback is owned by the consumer: the lambda copies or -unpacks string, bytes, and buffered-value results into C++ values and -then releases them with `weaveffi_free_string` or -`weaveffi_free_bytes`, and it releases a heap-boxed error with -`weaveffi_error_free` after copying its fields. An owned interface -result transfers ownership too: the -callback adopts the pointer into a RAII wrapper. An async callable with `throws: true` rejects with the -module's typed domain exception (`detail::make_kv_error` and friends); -one without `throws` rejects with the generic `WeaveFFIError` only when -the producer has a bug. +settles the promise and then deletes it. Everything passed to the callback +is owned by the consumer: strings, bytes, and buffered results are copied +into C++ values and released with `weaveffi_free_string` or +`weaveffi_free_bytes`, a heap-boxed error is released with +`weaveffi_error_free`, and an object result is adopted into a wrapper +(`std::future`). An async callable with `throws: true` rejects with +the module's typed domain exception; one without `throws` rejects with the +generic `WeaveFFIError` for runtime codes (a panic in the future is -2, a +callback that threw is -4). When the IDL marks the callable `cancellable: true`, the wrapper gains -a trailing `weaveffi_cancel_token*` parameter defaulting to `nullptr`. -From the `kvstore` sample's async method `Store.compact`: - -```cpp -/** Reclaim space asynchronously; returns the number of bytes reclaimed */ -std::future compact(weaveffi_cancel_token* cancel_token = nullptr) const; -``` +a trailing `weaveffi_cancel_token*` parameter defaulting to `nullptr`: ```cpp weaveffi_cancel_token* token = weaveffi_cancel_token_create(); @@ -458,44 +565,41 @@ weaveffi_cancel_token_cancel(token); // from any thread weaveffi_cancel_token_destroy(token); ``` -C++ is one of only three targets (C, C++, Kotlin) that expose the -cancel token; see [Async functions](../guides/async.md). +C++ is one of the few targets (C, C++, Kotlin) that expose the cancel +token; see [Async functions](../guides/async.md). ## Iterators `iter` return values surface as a generated move-only RAII range class with `begin()`/`end()`, so results stream in constant memory: nothing is drained up front, and each iteration step pulls exactly one -element from the producer through `_next`. From the `events` sample -(`get_messages` returns `iter`, trimmed): +element from the producer through `_next`. From the `kvstore` sample +(`Store::list_keys` returns `iter`, trimmed): ```cpp -/** - * A lazy, move-only range over the `std::string` elements produced by `get_messages()`. - */ -class GetMessagesIterator { - weaveffi_events_GetMessagesIterator* handle_; +class StoreListKeysIterator { + weaveffi_kv_Store_ListKeysIterator* handle_; public: - ~GetMessagesIterator() { - if (handle_) weaveffi_events_GetMessagesIterator_destroy(handle_); + ~StoreListKeysIterator() { + if (handle_) weaveffi_kv_Store_ListKeysIterator_destroy(handle_); } - GetMessagesIterator(const GetMessagesIterator&) = delete; - GetMessagesIterator(GetMessagesIterator&&) noexcept; + StoreListKeysIterator(const StoreListKeysIterator&) = delete; + StoreListKeysIterator(StoreListKeysIterator&& other) noexcept; /** Pulls the next element, or `std::nullopt` once exhausted. */ std::optional next() { if (!handle_) return std::nullopt; weaveffi_error err{}; const char* item{}; - int32_t has_item = weaveffi_events_GetMessagesIterator_next(handle_, &item, &err); + int32_t has_item = weaveffi_kv_Store_ListKeysIterator_next(handle_, &item, &err); if (err.code != 0) { - weaveffi_events_GetMessagesIterator_destroy(handle_); + weaveffi_kv_Store_ListKeysIterator_destroy(handle_); handle_ = nullptr; - detail::check(err); + detail::check_kv(err); } if (has_item == 0) { - weaveffi_events_GetMessagesIterator_destroy(handle_); + weaveffi_kv_Store_ListKeysIterator_destroy(handle_); handle_ = nullptr; return std::nullopt; } @@ -505,54 +609,61 @@ public: } struct sentinel {}; - - /** Single-pass input iterator; each increment pulls one element. */ class iterator { /* input_iterator_tag; compares against sentinel */ }; - iterator begin() { return iterator(this); } sentinel end() const { return sentinel{}; } }; - -inline GetMessagesIterator get_messages() { - weaveffi_error err{}; - weaveffi_events_GetMessagesIterator* iter = weaveffi_events_get_messages(&err); - detail::check(err); - return GetMessagesIterator(iter); -} ``` The range is single-pass: `begin()` returns an input iterator that compares against a sentinel, so a plain range-`for` works: ```cpp -for (const std::string& message : weaveffi::events::get_messages()) { - std::cout << message << '\n'; +for (const std::string& key : store.list_keys(std::nullopt)) { + std::cout << key << '\n'; } ``` -Each pulled string is copied into `std::string` and its native -allocation freed with `weaveffi_free_string`; a buffered element (a -record, rich enum, optional, list, or map) arrives as a value buffer -that is unpacked into the C++ value and released with -`weaveffi_free_bytes`. The producer iterator is destroyed exactly -once: eagerly when `next()` reports exhaustion (or an error), or from -the range's destructor when iteration is abandoned early (the handle -is nulled, so a double destroy is impossible). - -Errors from the launcher and from each `next` follow the function's -error strategy. A throwing function like the `kvstore` sample's -`Store::list_keys` checks both through `detail::check_kv`, so the step -that failed throws the typed `KvError` subclass (after releasing the -iterator); a non-throwing function like `get_messages` throws the -generic `WeaveFFIError` only for producer bugs. +Each pulled string is copied into `std::string` and its native allocation +freed with `weaveffi_free_string`; a buffered element arrives as a value +buffer that is unpacked and released with `weaveffi_free_bytes`; an object +element is adopted into a wrapper. The producer iterator is destroyed +exactly once: eagerly when `next()` reports exhaustion (or an error), or +from the range's destructor when iteration is abandoned early. + +Errors from the launcher and from each `next` follow the function's error +strategy: `Store::list_keys` checks both through `detail::check_kv`, so a +failing step throws the typed `KvError` subclass after releasing the +iterator, while a non-throwing function throws the generic `WeaveFFIError` +only for runtime codes. + +## Known limitations + +- A callback interface must be passed as `std::shared_ptr`; there is no + overload for a raw reference or a lambda. Wrap a lambda in a small + subclass if you need one. +- Trampolines only capture `what()` from exceptions derived from + `std::exception`; other thrown types are reported with a generic + message. +- Exceptions thrown by callback methods that return `void` and belong to a + non-`throws` callable still abort the call with -4; C++ has no separate + trap path, so the caller simply sees `WeaveFFIError`. +- The lazy range is single-pass and move-only; copy the elements into a + `std::vector` if you need random access or a second pass. +- `std::future` has no cancellation or continuation support of its own; + cancellation goes through the raw `weaveffi_cancel_token*`. ## Troubleshooting - **`undefined reference to weaveffi_*`**: link against the Rust cdylib. The header alone is not enough. -- **Double-free crashes**: RAII wrappers delete copy operators on - purpose. If you see double-frees, somewhere you have a manual copy or - a raw `void*` shared between wrappers. +- **`WeaveFFIError` with code -3 on a method call**: the wrapper's handle is + null. You're calling through a moved-from wrapper or one constructed from + a null pointer. +- **Double-free crashes**: every wrapper releases exactly one reference. A + double free means a raw pointer from `handle()` was adopted by a second + wrapper without `clone_handle()`, or a `clone_handle()` result was + destroyed twice. - **Exceptions not caught across DLL boundaries on MSVC**: build the consumer and the dynamically loaded library with the same `_HAS_EXCEPTIONS` setting and CRT. diff --git a/docs/src/generators/dart.md b/docs/src/generators/dart.md index 60f1dad2..1fe8cdf3 100644 --- a/docs/src/generators/dart.md +++ b/docs/src/generators/dart.md @@ -3,52 +3,78 @@ ## Overview The Dart target produces a pure-Dart FFI package that wraps the C ABI -using [`dart:ffi`](https://dart.dev/interop/c-interop). It opens the -shared library with `DynamicLibrary.open` and resolves each symbol via -`lookupFunction`. There's no native compilation step or `ffigen` run -required; the generated `.dart` file is ready to import. +(revision 2) using [`dart:ffi`](https://dart.dev/interop/c-interop). It +opens the shared library with `DynamicLibrary.open` and resolves each +symbol via `lookupFunction`. There's no native compilation step or +`ffigen` run required; the generated `.dart` file is ready to import. + +Records and rich enums are plain Dart classes (a sealed hierarchy for a +rich enum) that cross the ABI as value buffers. Interfaces are +reference-counted wrapper classes with `dispose()` and a +`NativeFinalizer` backstop. Callback interfaces are abstract classes the +consumer implements, bound through `NativeCallable.isolateLocal` +trampolines. Async functions return `Future`, and `iter` returns +are lazy `Iterable`s backed by `sync*` generators. ## What gets generated | File | Purpose | |------|---------| -| `dart/lib/weaveffi.dart` | `dart:ffi` bindings: loader, typedefs, lookups, wrappers, struct/enum classes | +| `dart/lib/weaveffi.dart` | `dart:ffi` bindings: loader, ABI check, typedefs, lookups, wrappers, record/enum classes | | `dart/pubspec.yaml` | Package metadata and `package:ffi` dependency | | `dart/README.md` | Basic usage instructions | +The library verifies the producer's ABI revision when it's first +loaded, throwing a `StateError` on mismatch: + +```dart +const int _abiVersion = 2; +``` + ## Type mapping -| IDL type | Dart type | Native FFI type | Dart FFI type | -|--------------|---------------------|------------------------|----------------------| -| `i32` | `int` | `Int32` | `int` | -| `u32` | `int` | `Uint32` | `int` | -| `i64` | `int` | `Int64` | `int` | -| `f64` | `double` | `Double` | `double` | -| `i8` | `int` | `Int8` | `int` | -| `i16` | `int` | `Int16` | `int` | -| `u8` | `int` | `Uint8` | `int` | -| `u16` | `int` | `Uint16` | `int` | -| `u64` | `int` | `Uint64` | `int` | -| `f32` | `double` | `Float` | `double` | -| `bool` | `bool` | `Int32` | `int` | -| `string` | `String` | `Pointer` | `Pointer` | -| `bytes` | `List` | `Pointer` | `Pointer` | -| `handle` | `int` | `Int64` | `int` | -| `StructName` | `StructName` (plain class) | value buffer (`Pointer` + length) | same | -| `InterfaceName` | `InterfaceName` | `Pointer` | `Pointer` | -| `EnumName` (plain) | `EnumName` | `Int32` | `int` | -| `EnumName` (rich) | `EnumName` (sealed class hierarchy) | value buffer (`Pointer` + length) | same | -| `T?` | `T?` | value buffer; `Interface?` stays a nullable pointer | same | -| `[T]` | `List` | value buffer (`Pointer` + length) | same | -| `{K: V}` | `Map` | value buffer (`Pointer` + length) | same | -| `iter` | `Iterable` (lazy) | `Pointer` | `Pointer` | - -Booleans cross as `Int32` (`0`/`1`) and the wrapper converts both ways. - -## Example IDL → generated code +| IDL type | Dart type | Native FFI type | +|--------------|---------------------|------------------------| +| `i8`, `i16`, `i32`, `i64` | `int` | `Int8`, `Int16`, `Int32`, `Int64` | +| `u8`, `u16`, `u32`, `u64` | `int` | `Uint8`, `Uint16`, `Uint32`, `Uint64` | +| `f32`, `f64` | `double` | `Float`, `Double` | +| `bool` | `bool` | `Int32` | +| `string` | `String` | `Pointer` | +| `bytes` | `List` | `Pointer` + `Size` | +| `StructName` | `StructName` (plain class) | value buffer (`Pointer` + `Size`) | +| `EnumName` (plain) | `EnumName` (enhanced enum) | `Int32` | +| `EnumName` (rich) | `EnumName` (sealed class hierarchy) | value buffer | +| `InterfaceName` | `InterfaceName` (wrapper class) | `Pointer` | +| `InterfaceName?` | `InterfaceName?` | `Pointer` (`nullptr` for `null`) | +| `CallbackName` | `CallbackName` (abstract class to extend) | `Pointer` ctx + vtable pointer | +| `T?` | `T?` | value buffer | +| `[T]` | `List` | value buffer | +| `{K: V}` | `Map` | value buffer | +| `iter` | `Iterable` (lazy) | `Pointer` | + +Buffered types cross the boundary serialized in the +[value-buffer format](../reference/value-buffers.md); the library +carries a private `_BufferWriter`/`_BufferReader` pair plus one +`_pack*`/`_unpack*` routine per record and rich enum. Objects nested +inside a buffered value travel as object tokens (see +[Objects](#objects-interfaces)). Booleans cross as `Int32` (`0`/`1`) +and the wrapper converts both ways. + +### 64-bit integers and floats + +Dart's `int` is a signed 64-bit integer, so `i64` round-trips exactly. +`u64` is carried as its two's-complement bit pattern: `u64::MAX` arrives +as `-1` and `2^63` as `-9223372036854775808`, both across `Uint64` FFI +slots and inside value buffers (`writeUint64`/`readUint64`). Apply +`.toUnsigned(64)` or `BigInt.from(v).toUnsigned(64)` when you need the +arithmetic value. `f32`/`f64` map to `double`; the `codec` conformance +consumer verifies NaN, both infinities, and `-0.0` survive a round trip +bit-for-bit. + +## Example IDL and generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -73,11 +99,6 @@ modules: - { name: name, type: string } - { name: email, type: "string?" } - { name: contact_type, type: ContactType } - return: handle - - - name: get_contact - params: - - { name: id, type: handle } return: Contact - name: find_contact @@ -120,8 +141,8 @@ enum ContactType { ``` Structs are plain Dart value classes: one final typed field per IDL -field plus a constructor with named arguments. They declare no C -symbols (no destroy, no getters) and there's nothing to dispose: +field plus a constructor with named arguments. They declare no C symbols +(no destroy, no getters) and there's nothing to dispose: ```dart /// A contact record @@ -134,55 +155,73 @@ class Contact { } ``` -A `Contact` crosses the ABI serialized in the -[value-buffer format](../reference/value-buffers.md) as a single -pointer-plus-length pair; the library carries a private buffer writer -and reader plus generated `_packContact`/`_unpackContact` helpers. - Each function emits a native typedef, Dart typedef, lookup, and -top-level wrapper: +top-level wrapper. Buffered parameters are packed and staged in native +memory the producer borrows for the call; buffered returns are copied, +freed, and decoded. From the `kvstore` sample's cross-module `getStats`: ```dart -typedef _NativeWeaveffiContactsCreateContact = Int64 Function( - Pointer, Pointer, Size, Int32, Pointer<_WeaveFFIError>); -typedef _DartWeaveffiContactsCreateContact = int Function( - Pointer, Pointer, int, int, Pointer<_WeaveFFIError>); -final _weaveffiContactsCreateContact = _lib.lookupFunction< - _NativeWeaveffiContactsCreateContact, - _DartWeaveffiContactsCreateContact>('weaveffi_contacts_create_contact'); - -int createContact(String name, String? email, ContactType contactType) { +typedef _NativeWeaveffiKvStatsGetStats = Pointer Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +typedef _DartWeaveffiKvStatsGetStats = Pointer Function(Pointer, Pointer, Pointer<_WeaveFFIError>); +final _weaveffiKvStatsGetStats = _lib.lookupFunction< + _NativeWeaveffiKvStatsGetStats, _DartWeaveffiKvStatsGetStats>('weaveffi_kv_stats_get_stats'); + +/// Throws [KvException] on domain errors. +Stats getStats(Store store) { + final outLen = calloc(); final err = calloc<_WeaveFFIError>(); - final namePtr = name.toNativeUtf8(); - // Optionals are buffered: pack the argument into a value buffer that - // the producer borrows for the duration of the call. - final emailBuf = /* generated pack routine for String? */; try { - final result = _weaveffiContactsCreateContact( - namePtr, emailBuf.ptr, emailBuf.len, contactType.value, err); - _checkError(err); - return result; + final result = _weaveffiKvStatsGetStats(store._handle, outLen, err); + _checkKvException(err); + final n = outLen.value; + final data = _copyNativeBytes(result, n); + if (result != nullptr) _weaveffiFreeBytes(result, n); + final reader = _BufferReader(data); + final decoded = _unpackStats(reader); + reader.expectEnd(); + return decoded; } finally { - calloc.free(namePtr); + calloc.free(outLen); calloc.free(err); } } ``` -Wrapper names are lowerCamelCase with the IDL module prefix stripped -by default (a `kv.open_store` function would surface as `openStore`, -not `kvOpenStore`); the C symbols keep their full names. Set +Wrapper names are lowerCamelCase with the IDL module prefix stripped by +default (a `kv.open_store` function would surface as `openStore`, not +`kvOpenStore`); the C symbols keep their full names. Set `strip_module_prefix: false` in the Dart generator config (or under `[global]`) to keep module-prefixed wrapper names. ## Typed errors -The package defines `WeaveFFIException` with `code` and `message` -fields. A module's error domain adds an exception subclass named by -replacing the trailing `Error` stem with `Exception` (`KvError` -becomes `KvException`) plus one subclass per code, and a mapper that -falls back to `WeaveFFIException` for codes outside the domain. From -the `kvstore` sample: +The package defines `WeaveFFIException` with `code` and `message` fields +and the four runtime trap codes as constants: + +```dart +class WeaveFFIException implements Exception { + /// The producer reported an untyped error. + static const int genericCode = -1; + /// The producer panicked; [message] carries the panic text. + static const int panicCode = -2; + /// An argument could not be lifted by the producer. + static const int marshalCode = -3; + /// A Dart callback-interface implementation threw; [message] carries the + /// exception's text. + static const int foreignCode = -4; + final int code; + final String message; + WeaveFFIException(this.code, this.message); + @override + String toString() => '$runtimeType($code): $message'; +} +``` + +A module's error domain adds an exception subclass named by replacing +the trailing `Error` stem with `Exception` (`KvError` becomes +`KvException`) plus one subclass per code, and a mapper that falls back +to `WeaveFFIException` for codes outside the domain. From the `kvstore` +sample: ```dart /// Typed error domain `KvError` declared by module `kv`. @@ -196,16 +235,6 @@ class KeyNotFoundException extends KvException { } // ExpiredException, StoreFullException, IoException follow the same shape. - -WeaveFFIException _mapKvException(int code, String message) { - switch (code) { - case 1001: - return KeyNotFoundException(message); - // ... 1002, 1003, 1004 ... - default: - return WeaveFFIException(code, message); - } -} ``` Only callables marked `throws: true` in the IDL check their error slot @@ -213,35 +242,65 @@ with `_checkKvException` (their doc comments read `Throws [KvException] on domain errors.`); catching `KeyNotFoundException` or `KvException` works as usual. A callable without `throws` uses the generic `_checkError`, which throws -`WeaveFFIException` only if the producer misbehaves. +`WeaveFFIException` if the producer misbehaves. Both copy the message +out and release the slot with `weaveffi_error_clear` before throwing. + +An error code that declares payload `fields:` carries them serialized in +the error's payload buffer; the mapper decodes them into typed fields on +the exception before the buffer is released. + +### Runtime error codes -An error code that declares payload `fields:` carries them serialized -in the error's payload buffer; the mapper decodes them into typed -fields on the exception before `weaveffi_error_clear` releases the -buffer. +| Code | Constant | Meaning | Where it surfaces | +|------|----------|---------|-------------------| +| `-1` | `genericCode` | The producer reported an error without a declared code. | Thrown as `WeaveFFIException`. | +| `-2` | `panicCode` | The Rust implementation panicked; the export macros and the async spawner catch the unwind. | Thrown as `WeaveFFIException`, or fails the awaited `Future`. | +| `-3` | `marshalCode` | Malformed input at the boundary (invalid UTF-8, a truncated value buffer, a bad enum discriminant). | Thrown as `WeaveFFIException`. | +| `-4` | `foreignCode` | A callback-interface method implemented in Dart threw. | Thrown as `WeaveFFIException` from the producer call that invoked the callback (see [Callback interfaces](#callback-interfaces)). | -## Interfaces +There's no non-throwing call path in Dart: a non-throwing callable whose +error slot comes back non-zero still throws `WeaveFFIException`. Using a +disposed wrapper is reported separately as `StateError`. -An `interfaces:` entry becomes a class holding the opaque pointer. A -constructor named `new` renders as an unnamed `factory` (so -`ContactBook()` just works); other constructors become named factories -(`Store.open(path)`). Methods are lowerCamelCase instance methods, -statics are static methods, and `dispose()` releases the native -object. From the `kvstore` sample (trimmed): +## Objects (interfaces) + +An `interfaces:` entry becomes a class that adopts one strong reference +to a reference-counted producer object. From the `kvstore` sample +(trimmed): ```dart -/// An embedded key-value store owning its entries -class Store { - final Pointer _handle; - Store._(this._handle); +final _weaveffiKvStoreDestroyFinalizer = NativeFinalizer( + _lib.lookup)>>('weaveffi_kv_Store_destroy')); + +class Store implements Finalizable { + final Pointer _ptr; + bool _disposed = false; + + /// Adopts one strong reference to the native object. + Store._(this._ptr) { + _weaveffiKvStoreDestroyFinalizer.attach(this, _ptr, detach: this); + } + + /// The borrowed native pointer for the duration of a call. + Pointer get _handle { + if (_disposed) throw StateError('Store used after dispose()'); + return _ptr; + } - /// Releases the native object reference. + /// Mints a second strong reference (the interface's `_clone` symbol) for + /// an object token written into a value buffer. + Pointer _cloneRef() => _weaveffiKvStoreClone(_handle); + + /// Releases this instance's native reference. Safe to call more than + /// once; the native object is dropped when its last reference (this + /// one, any other wrapper's, or the producer's) is released. void dispose() { - _weaveffiKvStoreDestroy(_handle); + if (_disposed) return; + _disposed = true; + _weaveffiKvStoreDestroyFinalizer.detach(this); + _weaveffiKvStoreDestroy(_ptr); } - /// Open (or create) a store backed by the given filesystem path - /// /// Throws [KvException] on domain errors. factory Store.open(String path) { final pathPtr = path.toNativeUtf8(); @@ -255,27 +314,27 @@ class Store { calloc.free(err); } } - - bool put(String key, List value, EntryKind kind, int? ttlSeconds) { /* throws KvException */ } - Entry? get(String key) { /* throws KvException */ } - Iterable listKeys(String? prefix) sync* { /* see Iterators */ } - int count() { /* generic check only (no throws) */ } - - /// Throws [KvException] on domain errors. - Future compact() { /* see Async support */ } - - @Deprecated('use put() with explicit kind') - bool legacyPut(String key, List value) { /* ... */ } - - /// The largest number of live entries one store will hold - static int defaultCapacity() { /* ... */ } } ``` -Functions elsewhere in the IDL pass the wrapper's handle across the -boundary (`getStats(store)` returns a `Stats` record decoded from a -value buffer). There's no finalizer; call `dispose()` when done, ideally -in `try`/`finally`: +- **Construction.** A constructor named `new` renders as an unnamed + `factory` (the `events` sample's `EventBus()`); other constructors + become named factories (`Store.open(path)`). Methods are + lowerCamelCase instance methods, statics are static methods, and + deprecated members carry `@Deprecated`. +- **Disposal.** `dispose()` releases this wrapper's reference through + the `_destroy` symbol. It's idempotent, and a `NativeFinalizer` + attached at construction destroys the reference at GC time if the + wrapper was never disposed (disposal detaches it first, so the destroy + runs exactly once). The producer object itself is dropped only when + the last reference anywhere is released. +- **Use after dispose.** Every call reads the private `_handle` getter, + which throws `StateError('Store used after dispose()')` on a disposed + wrapper, whether it's the receiver, a parameter, or a field of a record + being packed. +- **Copies mint new references.** Methods returning the receiver or an + existing object (`share()`, `fork()`) return a fresh strong reference + adopted into a new wrapper; disposing one never affects another. ```dart final store = Store.open('/tmp/cache.kv'); @@ -288,6 +347,62 @@ try { } ``` +### Nullable objects, and objects inside values + +An `Interface?` parameter passes `nullptr` for `null`, and an +`Interface?` return maps `nullptr` to `null`: + +```dart +Store? larger(Store? other) { + final err = calloc<_WeaveFFIError>(); + try { + final result = _weaveffiKvStoreLarger(_handle, other?._handle ?? nullptr, err); + _checkError(err); + return result == nullptr ? null : Store._(result); + } finally { + calloc.free(err); + } +} +``` + +Objects inside records, lists, maps, and optionals travel as 8-byte +object tokens in the value buffer. Writing a token mints a new strong +reference with `_cloneRef()`; reading one adopts the reference into a +fresh wrapper. From the `StoreInfo` record (`store: Store`, +`mirror: Store?`): + +```dart +void _packStoreInfo(_BufferWriter w, StoreInfo v) { + w.writeString(v.label); + w.writeUint64(v.store._cloneRef().address); + final t0 = v.mirror; + if (t0 == null) { + w.writeOptionFlag(false); + } else { + w.writeOptionFlag(true); + w.writeUint64(t0._cloneRef().address); + } + w.writeInt64(v.count); +} + +StoreInfo _unpackStoreInfo(_BufferReader r) { + return StoreInfo( + label: r.readString(), + store: Store._(Pointer.fromAddress(r.readUint64())), + mirror: (r.readOptionFlag() ? Store._(Pointer.fromAddress(r.readUint64())) : null), + count: r.readInt64(), + ); +} +``` + +Lists of objects work the same way in both directions +(`Store.openMany(paths)` returns `List`, +`Store.totalCount(stores, extra)` takes one); each wrapper in a returned +list owns its own reference and must be disposed (or left to the +finalizer) individually. Iterators over objects adopt one reference per +step, and async functions returning an object adopt the pointer inside +the completion callback before completing the `Future`. + ## Rich (algebraic) enums A rich (algebraic) enum is a sum type whose variants carry associated @@ -348,7 +463,135 @@ final bigger = scale(circle, 3.0); // returns a new Shape value Values are plain Dart data: there's no native handle and nothing to dispose. The generated `_packShape`/`_unpackShape` helpers write and -read the tagged wire format. +read the tagged wire format; variant fields of interface type follow the +object token rules above. + +## Callback interfaces + +A `callback_interfaces:` entry becomes an abstract class the consumer +extends (or implements) and passes wherever the API takes that type. +From the `kvstore` sample: + +```dart +/// Object arguments are owned by the implementation: call `dispose()` on +/// them (or let the finalizer run) when done. A thrown exception is reported +/// to the producer, which aborts the call it was making; the original Dart +/// caller then observes a [WeaveFFIException] with [WeaveFFIException.foreignCode] +/// carrying the exception's text. +/// +/// Dart limitation: methods are bound with `NativeCallable.isolateLocal`, so +/// the producer may only invoke them on the thread of the isolate that +/// passed the callback. That holds when the producer calls them +/// synchronously during a call from Dart. A producer that invokes a method +/// from another thread (an async task, a background worker) is unsupported: +/// a `NativeCallable.listener` cannot return a value or read borrowed +/// arguments after the native frame returns. +abstract class EvictionListener { + /// An entry left the store. Returns whether the listener wants to keep + /// receiving notifications; `false` detaches it. + bool onEvict(Entry entry, EvictionReason reason); +} +``` + +```dart +class Auditor extends wv.EvictionListener { + @override + bool onEvict(wv.Entry entry, wv.EvictionReason reason) { + print('${entry.key}: $reason'); + return true; + } +} + +store.setEvictionListener(Auditor()); +``` + +Behind the class is one process-wide static vtable per callback +interface, allocated once and never freed, whose method slots are +`NativeCallable.isolateLocal` trampolines and whose `free` slot is a +`NativeCallable.listener`. Passing an implementation parks it in a +handle table under an integer key; the key (widened to a pointer) +crosses as `ctx`, so the producer never holds a Dart object: + +```dart +final Map _callbackTable = {}; +int _nextCallbackKey = 1; + +Pointer _registerCallback(Object impl) { + final key = _nextCallbackKey++; + _callbackTable[key] = impl; + return Pointer.fromAddress(key); +} + +// Vtable entry `weaveffi_kv_EvictionListener_vtable.on_evict`. +bool _evictionListenerVtOnEvict(Pointer ctx, Pointer entryPtr, int entryLen, int reason, Pointer<_WeaveFFIError> outErr) { + try { + final entryData = _copyNativeBytes(entryPtr, entryLen); + final entryReader = _BufferReader(entryData); + final entryValue = _unpackEntry(entryReader); + entryReader.expectEnd(); + final impl = _callbackFor(ctx) as EvictionListener; + return impl.onEvict(entryValue, EvictionReason.fromValue(reason)); + } catch (e) { + _foreignError(outErr, e); + return false; + } +} + +void _evictionListenerVtFree(Pointer ctx) { + _callbackTable.remove(ctx.address); +} + +final Pointer<_EvictionListenerVtableStruct> _EvictionListenerVtable = () { + final vt = calloc<_EvictionListenerVtableStruct>(); + vt.ref.onEvict = _pinCallable(NativeCallable<_NativeEvictionListenerVtOnEvict>.isolateLocal( + _evictionListenerVtOnEvict, exceptionalReturn: false)); + vt.ref.free = _pinCallable(NativeCallable<_NativeEvictionListenerVtFree>.listener( + _evictionListenerVtFree)); + return vt; +}(); +``` + +- **Lifetime.** The implementation stays in `_callbackTable` (and + therefore alive) exactly as long as the producer may call it; the + vtable's `free` removes it when the producer drops its last reference. + A producer that retains the implementation (a store's eviction + listener) keeps it alive across calls; one that doesn't (the `events` + sample's `routeOnce`) frees it before returning. `free` is a + `.listener` callable, so it may safely arrive from any thread; the + removal is posted to the isolate's event loop. +- **Argument ownership.** Borrowed strings and buffers are copied into + Dart values before the method runs, so the implementation may keep + them. An object passed to a callback method is owned by the + implementation: the trampoline adopts it into a new wrapper + (`impl.onAttached(EventBus._(bus))` in the `events` sample), and the + implementation should `dispose()` it when done (or let the finalizer + run). +- **Return values.** A method's return value is converted back to its C + representation (`bool` as `Bool`, a plain enum as its `value`, a + record as a value buffer the producer frees). +- **Exceptions.** An exception escaping a method never unwinds through + the C frame. `_foreignError` writes `foreignCode` (-4) with + `error.toString()` into the producer's error slot, and the trampoline + returns the `exceptionalReturn` default; the producer aborts the call + in progress, and the original Dart caller sees `WeaveFFIException` + with `code == -4`. For a callable marked `throws`, the domain mapper + falls through to `WeaveFFIException` (so `on KvException` doesn't + catch it but `on WeaveFFIException` does). The isolate is never taken + down. +- **Threads (the important caveat).** Method trampolines are + `NativeCallable.isolateLocal`, which can only run on the thread of the + isolate that created them. That's exactly right when the producer + calls back synchronously during a call from Dart (the `kvstore` + eviction listener fires inside `delete()`/`get()`). A producer that + invokes a callback method from another thread (an async task, a + background worker, a timer) is unsupported and will crash the + process; `NativeCallable.listener` can't be used instead because it + returns before the method runs, so it could neither return a value + nor read borrowed arguments. Keep such producers on the C, C++, + Swift, Kotlin, Node, .NET, Go, Python, or Ruby targets. +- **Isolate lifetime.** The vtable callables have `keepIsolateAlive` + cleared, so an idle isolate can still exit while a producer holds a + callback implementation. ## Build instructions @@ -366,7 +609,8 @@ Standalone Dart: cargo build --release -p your_library ``` -3. Make the cdylib findable at runtime: +3. Make the cdylib findable at runtime. `WEAVEFFI_LIBRARY` may point at + an exact file; otherwise: - macOS: `DYLD_LIBRARY_PATH=$PWD/../../target/release dart run example/main.dart` - Linux: `LD_LIBRARY_PATH=$PWD/../../target/release dart run example/main.dart` @@ -394,117 +638,65 @@ Flutter: - Linux / Windows: place next to the executable or on the library search path. +## Packaging + +`weaveffi package --target dart` emits the Dart package under `dart/` +with a loader that tries the bundled libraries first, and copies each +supplied desktop binary to `dart/native//` (`macos-arm64`, +`macos-x64`, `linux-x64`, `linux-arm64`, `windows-x64`). The packaged +loader still honours `WEAVEFFI_LIBRARY` first, then probes every +bundled slice for the running OS, then the bare system library name. Android and +`wasm32` binaries have no slot in the loader and are skipped; bundle +them through the Flutter mechanisms above. See +[Packaging](../guides/packaging.md) for the shared workflow. + ## Memory and ownership -- **Strings:** Dart `String` values are converted with - `toNativeUtf8()`. The wrapper frees the resulting pointer in a - `finally` block. Returned UTF-8 pointers are copied with - `toDartString()` and then released with `weaveffi_free_string`. -- **Bytes:** returned buffers are copied into Dart collections, then - the producer's allocation is released with `weaveffi_free_bytes`. -- **Buffered values (structs, rich enums, optionals, lists, maps):** +- **Strings:** Dart `String` values are converted with `toNativeUtf8()` + and freed in a `finally` block. Returned UTF-8 pointers are copied + with `toDartString()` and then released with `weaveffi_free_string`. +- **Bytes:** copied into native memory for the call and freed after; + returned buffers are copied into Dart lists, then the producer's + allocation is released with `weaveffi_free_bytes`. +- **Buffered values (records, rich enums, optionals, lists, maps):** parameters are packed into a value buffer that the producer borrows for the duration of the call; returns are copied into Dart memory, released with `weaveffi_free_bytes`, and decoded with the generated - `_unpack*` helper. Nothing to dispose afterward: - - ```dart - final contact = getContact(id); // a plain Contact value - print(contact.name); - ``` - -- **Interfaces:** wrappers hold a `Pointer`. The `dispose()` - method calls the corresponding `_destroy` C function; always wrap - usage in `try`/`finally`. An absent `Interface?` is a `nullptr` that - becomes `null`. -- **Iterators:** each yielded element is copied (or, for buffered - element types, decoded from a value buffer released with - `weaveffi_free_bytes`), and the iterator handle is destroyed exactly - once; see [Iterators](#iterators). - -## Callbacks and listeners - -A `callbacks:` entry in the IDL defines the native function-pointer -type; a `listeners:` entry generates a register/unregister pair around -it. Registration wraps the Dart closure in a `NativeCallable`, hands -its `nativeFunction` pointer to the C ABI, and returns the `int` -subscription id the native side minted: - -```dart -// Live listener trampolines by subscription id. Holding the -// NativeCallable here keeps its native thunk alive until unregistered. -final Map _listenerCallables = {}; - -/// Registers a OnMessage listener. Returns a subscription id for -/// unregisterMessageListener(). -int registerMessageListener(void Function(String message) callback) { - final callable = - NativeCallable<_NativeCb_weaveffi_events_OnMessage_fn>.isolateLocal( - (Pointer message, Pointer context) { - callback(message == nullptr ? '' : message.toDartString()); - }); - final id = _weaveffiEventsRegisterMessageListener(callable.nativeFunction, nullptr); - _listenerCallables[id] = callable; - return id; -} - -/// Unregisters a listener previously registered with registerMessageListener(). -void unregisterMessageListener(int id) { - _weaveffiEventsUnregisterMessageListener(id); - _listenerCallables.remove(id)?.close(); -} -``` - -- **Lifetime.** The live `NativeCallable` is stored in - `_listenerCallables` keyed by subscription id; that reference keeps - the native thunk and the captured closure alive. Unregistering - removes the entry and `close()`s the callable. The C `void* context` -slot is unused (`nullptr`); the closure travels inside the callable, -so no registry id needs to cross the boundary. -- **Threading.** Listener trampolines are - `NativeCallable.isolateLocal`, not `.listener`: WeaveFFI listeners - fire synchronously on the thread calling the producer API (here, - while `sendMessage` runs), and the argument pointers are only valid - for that borrow window, so they are converted to Dart values inside - the callback before the producer frees them. An `isolateLocal` - callable may only be invoked on the owning isolate's thread, so - events are delivered during the isolate's own calls into the - library rather than queued to the event loop. -- **Isolate lifetime.** The generated code never sets - `keepIsolateAlive = false`, so the `dart:ffi` default applies: a - registered listener keeps its isolate alive until it is - unregistered. + `_unpack*` helper. Object tokens written into a buffer are fresh + strong references the producer owns; tokens read out are adopted into + wrappers. +- **Interfaces:** one strong reference per wrapper, released by + `dispose()` with a `NativeFinalizer` backstop. +- **Callback implementations:** held in `_callbackTable` until the + producer calls the vtable's `free`. +- **Iterators:** each yielded element is copied (or decoded from a value + buffer that's then freed), and the iterator handle is destroyed + exactly once; see [Iterators](#iterators). ## Async support Functions marked `async: true` return a `Future` backed by the `_async`-suffixed C launcher. The completion callback is a -`NativeCallable.listener`, which may be invoked from any native -thread: the event is posted to the owning isolate's event loop, where -it completes the `Completer`: +`NativeCallable.listener`, which may be invoked from any native thread: +the event is posted to the owning isolate's event loop, where it +completes the `Completer`. From the `kvstore` sample's `Store.compact`: ```dart -/// Throws [TaskException] on domain errors. -Future runTask(String name) { - final completer = Completer(); - final namePtr = name.toNativeUtf8(); - late NativeCallable<_NativeAsyncCb_weaveffi_tasks_run_task> callable; - callable = NativeCallable<_NativeAsyncCb_weaveffi_tasks_run_task>.listener( - (Pointer context, Pointer<_WeaveFFIError> err, - Pointer resultPtr, int resultLen) { +/// Throws [KvException] on domain errors. +Future compact() { + final completer = Completer(); + late NativeCallable<_NativeAsyncCb_weaveffi_kv_Store_compact> callable; + callable = NativeCallable<_NativeAsyncCb_weaveffi_kv_Store_compact>.listener((Pointer context, Pointer<_WeaveFFIError> err, int result) { try { if (err.address != 0 && err.ref.code != 0) { final code = err.ref.code; final msg = err.ref.message.toDartString(); + final payload = _copyNativeBytes(err.ref.payloadPtr, err.ref.payloadLen); _weaveffiErrorFree(err); - completer.completeError(_mapTaskException(code, msg)); + completer.completeError(_mapKvException(code, msg, payload)); return; } - // TaskResult is a record: copy the owned value buffer, free it, - // then decode from the copy. - final resultData = _copyNativeBytes(resultPtr, resultLen); - _weaveffiFreeBytes(resultPtr, resultLen); - completer.complete(_unpackTaskResult(_BufferReader(resultData))); + completer.complete(result); } catch (e) { completer.completeError(e); } finally { @@ -512,91 +704,85 @@ Future runTask(String name) { } }); try { - _weaveffiTasksRunTaskAsync(namePtr, callable.nativeFunction, nullptr); + _weaveffiKvStoreCompactAsync(_handle, nullptr, callable.nativeFunction, nullptr); } catch (e) { callable.close(); - calloc.free(namePtr); rethrow; } - return completer.future.whenComplete(() { - calloc.free(namePtr); - }); + return completer.future; } ``` The callable is closed in the callback's `finally` (or immediately if the launch itself throws), so each native trampoline is freed exactly -once; input buffers are released in `whenComplete` once the future +once; input buffers (a `publishLater(topic, text)` call's string +arguments, say) are released in a `whenComplete` once the future settles. The `dart:async` import is only emitted when the IDL contains at least one async function. Result ownership follows the async contract: the callback owns the string, bytes, and buffered results it receives (records, rich enums, optionals, lists, and maps arrive as a `(resultPtr, resultLen)` pair), -so the callback body copies them into Dart values and then releases -them with `_weaveffiFreeString` or `_weaveffiFreeBytes`. Consumer -ownership is what makes `NativeCallable.listener` safe here: the -listener defers the callback body to the isolate's event loop, past -the native callback's return, so a producer-freed buffer would already -dangle by the time it ran. A reported error is heap-boxed and released -with `_weaveffiErrorFree` after its fields are copied. An owned -interface result transfers ownership too: the wrapper adopts the -pointer, and its `dispose()` owns the eventual destroy. +so the callback body copies them into Dart values and then releases them +with `_weaveffiFreeString` or `_weaveffiFreeBytes`. Consumer ownership +is what makes `NativeCallable.listener` safe here: the listener defers +the callback body to the isolate's event loop, past the native +callback's return, so a producer-freed buffer would already dangle by +the time it ran. A reported error is heap-boxed and released with +`_weaveffiErrorFree` after its fields are copied. An owned interface +result transfers ownership too: the wrapper adopts the pointer, and its +`dispose()` owns the eventual destroy. For a callable marked `throws: true`, the completion callback maps an -error through the domain mapper (`_mapTaskException` above, -`_mapKvException` on `Store.compact()`), so the future fails with the -typed exception; a non-throwing async callable can only fail with -`WeaveFFIException` on a producer bug. Async interface methods follow -the same pattern as instance methods returning `Future`. +error through the domain mapper (`_mapKvException` above), so the future +fails with the typed exception; a non-throwing async callable can only +fail with `WeaveFFIException` on a producer bug. A panic inside the +spawned future surfaces as `panicCode` (-2). Async interface methods +follow the same pattern as instance methods returning `Future`; the +receiver's `_handle` is read (throwing `StateError` if disposed) before +the launcher runs. For functions marked `cancellable: true` the C launcher gains a `weaveffi_cancel_token*` parameter. The Dart wrapper passes `nullptr` -for it and doesn't expose the token; only the C and C++ -targets surface cancellation tokens. +for it and doesn't expose the token; only the C and C++ targets surface +cancellation tokens. ## Iterators `iter` returns surface as `Iterable` backed by a `sync*` generator, so they are fully lazy: nothing runs until the consumer -starts iterating, and each element pulls exactly one native `next` -call. Iterating the returned `Iterable` again launches a fresh native -iterator. From the `events` sample: +starts iterating, and each element pulls exactly one native `next` call. +Iterating the returned `Iterable` again launches a fresh native +iterator. From the `kvstore` sample's `Store.listKeys` (argument +packing elided): ```dart -/// Return an iterator over all sent messages -/// -/// Returns a lazy [Iterable]: elements are pulled from the native -/// iterator one at a time (one native `next` call per element), and -/// iterating the result again launches a fresh native iterator. -/// -/// The native iterator handle is destroyed exactly once: eagerly when -/// the iteration completes or fails, or by a GC finalizer if the -/// iteration is abandoned before it is exhausted. -Iterable getMessages() sync* { +Iterable listKeys(String? prefix) sync* { + // ... pack the `String?` prefix into a staged value buffer ... final err = calloc<_WeaveFFIError>(); final outItem = calloc>(); Pointer iter = nullptr; final anchor = _IteratorLifetime(); try { - iter = _weaveffiEventsGetMessages(err); - _checkError(err); - _weaveffiEventsGetMessagesIteratorDestroyFinalizer.attach(anchor, iter, detach: anchor); - while (_weaveffiEventsGetMessagesIteratorNext(iter, outItem, err) != 0) { - _checkError(err); + iter = _weaveffiKvStoreListKeys(_handle, prefixPtr, prefixBuf.length, err); + _checkKvException(err); + _weaveffiKvStoreListKeysIteratorDestroyFinalizer.attach(anchor, iter, detach: anchor); + while (_weaveffiKvStoreListKeysIteratorNext(iter, outItem, err) != 0) { + _checkKvException(err); final itemPtr = outItem.value; final item = itemPtr.toDartString(); _weaveffiFreeString(itemPtr); yield item; } - _checkError(err); + _checkKvException(err); } finally { if (iter != nullptr) { - _weaveffiEventsGetMessagesIteratorDestroyFinalizer.detach(anchor); - _weaveffiEventsGetMessagesIteratorDestroy(iter); + _weaveffiKvStoreListKeysIteratorDestroyFinalizer.detach(anchor); + _weaveffiKvStoreListKeysIteratorDestroy(iter); iter = nullptr; } calloc.free(outItem); + calloc.free(prefixPtr); calloc.free(err); } } @@ -605,31 +791,60 @@ Iterable getMessages() sync* { Each yielded string is copied with `toDartString()` and its producer allocation released with `weaveffi_free_string`; a buffered element is decoded from its value buffer and released with `weaveffi_free_bytes` -instead. The handle lifecycle covers -early abandonment: the `finally` block runs when the loop exhausts, -when a step fails, or when the consumer stops iterating (Dart closes -the suspended `sync*` frame on `break`). If an iteration is abandoned -without ever resuming the frame, the `_IteratorLifetime` anchor's -`NativeFinalizer` destroys the handle at GC time; an eagerly destroyed -handle detaches first, so the destroy runs exactly once either way. +instead; an object element is adopted into a new wrapper. The handle +lifecycle covers early abandonment: the `finally` block runs when the +loop exhausts, when a step fails, or when the consumer stops iterating +(Dart closes the suspended `sync*` frame on `break`). If an iteration is +abandoned without ever resuming the frame, the `_IteratorLifetime` +anchor's `NativeFinalizer` destroys the handle at GC time; an eagerly +destroyed handle detaches first, so the destroy runs exactly once either +way. Errors from the launcher and from each `next` follow the function's -error strategy: the throwing `kvstore` sample's `Store.listKeys` -checks each step with `_checkKvException` and throws the typed -`KvException` subclasses from the step that failed; the non-throwing -`getMessages` throws `WeaveFFIException` only for producer bugs. +error strategy: the throwing `listKeys` checks each step with +`_checkKvException` and throws the typed `KvException` subclasses from +the step that failed; a non-throwing iterator throws `WeaveFFIException` +only for producer bugs. Because the generator is lazy, the launch error +is thrown on the first `moveNext()`, not when the method returns. + +## Known limitations + +- Callback-interface methods only work when the producer calls them + synchronously on the thread of the isolate that passed the + implementation; invocation from another producer thread is + unsupported. +- `u64` values above `2^63 - 1` arrive as negative `int`s (two's + complement); convert with `toUnsigned(64)` if needed. +- Async cancellation doesn't propagate: `cancellable: true` tokens are + not exposed, and an abandoned `Future` leaves the native operation + running. +- `Iterable` results relaunch the native iterator on each + re-iteration; the producer sees a fresh call. +- The plain `generate` output relies on the dynamic loader (or + `WEAVEFFI_LIBRARY`) to find the library; only `weaveffi package` + bundles desktop slices, and mobile slices go through Flutter. ## Troubleshooting -- **`Invalid argument(s): Failed to load dynamic library`**: the - cdylib is not on the search path. Set `DYLD_LIBRARY_PATH` / - `LD_LIBRARY_PATH` or copy the library next to your executable. +- **`Invalid argument(s): Failed to load dynamic library`**: the cdylib + is not on the search path. Set `WEAVEFFI_LIBRARY`, + `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH`, or copy the library next to + your executable. +- **`StateError: WeaveFFI ABI mismatch`**: the library was built by a + different `weaveffi` release than the bindings. Regenerate the bindings + and rebuild the library together. +- **`StateError: Store used after dispose()`**: a disposed wrapper was + used as a receiver, a parameter, or a record field. Keep the wrapper + alive for as long as the object is in use. +- **`WeaveFFIException(-4): ...`**: a callback-interface method you + implemented threw; the message is the exception's `toString()`. +- **Crash or "Cannot invoke native callback outside an isolate"**: the + producer called a callback-interface method from a thread other than + the isolate's. That producer can't be used from Dart; see the + threading caveat above. - **`UnsupportedError: Unsupported platform`**: the loader maps to `darwin`, `linux`, and `windows`. Other platforms (Android, iOS) use the Flutter integration where the framework opens the library. - **`MissingPluginException` in Flutter**: that error is unrelated to WeaveFFI; double-check that you depend on the generated package and haven't shadowed it with a different `weaveffi` dependency. -- **Strings appear truncated**: Rust strings aren't nul-terminated; - make sure `toDartString()` is reading the pointer returned from a - generated getter, not a raw pointer. diff --git a/docs/src/generators/dotnet.md b/docs/src/generators/dotnet.md index af1d771e..89328e35 100644 --- a/docs/src/generators/dotnet.md +++ b/docs/src/generators/dotnet.md @@ -2,12 +2,17 @@ ## Overview -The .NET target emits a C# class library that wraps the C ABI through +The .NET target emits a C# class library that wraps the C ABI (revision +2) through [P/Invoke](https://learn.microsoft.com/en-us/dotnet/standard/native-interop/pinvoke). -Structs and rich enums are plain C# value classes packed and unpacked -from value buffers, interfaces are exposed as `IDisposable` classes with -PascalCase members, error domains become managed exception types, and -the project targets `net8.0`. +Records and rich enums are plain C# value classes packed and unpacked +from value buffers; interfaces are reference-counted objects wrapped in +`IDisposable` classes with a finalizer backstop; callback interfaces are +C# `interface`s the consumer implements, adapted to the producer's +vtable by `[UnmanagedCallersOnly]` trampolines; async functions return +`Task`; `iter` returns are lazily streamed `IEnumerable`; error +domains become managed exception types; and the project targets +`net8.0`. ## What gets generated @@ -18,41 +23,87 @@ the project targets `net8.0`. | `generated/dotnet/WeaveFFI.nuspec` | NuGet package metadata | | `generated/dotnet/README.md` | Build and pack instructions | -File names and the C# namespace follow the IDL `package.name` (a -package named `kvstore` produces `Kvstore.cs` inside -`namespace Kvstore`); `WeaveFFI` is the default. +File names and the C# namespace follow the IDL `package.name` (a package +named `kvstore` produces `Kvstore.cs` inside `namespace Kvstore`); +`WeaveFFI` is the default and `namespace` under `[generators.dotnet]` +overrides it. + +The `NativeMethods` class checks the producer's ABI revision in its +static constructor, so a mismatched library fails before the first real +P/Invoke instead of misreading the error struct or a value buffer: + +```csharp +internal static class NativeMethods +{ + private const string LibName = "weaveffi"; + + // The ABI revision these bindings were generated against. + internal const uint AbiVersion = 2; + + static NativeMethods() + { + uint found; + try + { + found = weaveffi_abi_version(); + } + catch (EntryPointNotFoundException e) + { + throw new InvalidOperationException( + $"the loaded WeaveFFI library predates ABI versioning (these bindings expect ABI revision {AbiVersion})", e); + } + if (found != AbiVersion) + { + throw new InvalidOperationException( + $"WeaveFFI ABI mismatch: these bindings expect revision {AbiVersion} but the loaded library reports revision {found}"); + } + } + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern uint weaveffi_abi_version(); +} +``` ## Type mapping | IDL type | C# type | P/Invoke type | |--------------|----------------------------|---------------| -| `i32` | `int` | `int` | -| `u32` | `uint` | `uint` | -| `i64` | `long` | `long` | -| `f64` | `double` | `double` | -| `i8` | `sbyte` | `sbyte` | -| `i16` | `short` | `short` | -| `u8` | `byte` | `byte` | -| `u16` | `ushort` | `ushort` | -| `u64` | `ulong` | `ulong` | -| `f32` | `float` | `float` | +| `i8`, `i16`, `i32`, `i64` | `sbyte`, `short`, `int`, `long` | same | +| `u8`, `u16`, `u32`, `u64` | `byte`, `ushort`, `uint`, `ulong` | same | +| `f32`, `f64` | `float`, `double` | same | | `bool` | `bool` | `int` | | `string` | `string` | `IntPtr` | -| `handle` | `ulong` | `ulong` | -| `bytes` | `byte[]` | `IntPtr` | +| `bytes` | `byte[]` | `IntPtr` + `UIntPtr` | | `StructName` | `StructName` (sealed value class) | value buffer (`IntPtr` + length) | -| `InterfaceName` | `InterfaceName` | `IntPtr` | | `EnumName` (plain) | `EnumName` | `int` | | `EnumName` (rich) | `EnumName` (closed class hierarchy) | value buffer (`IntPtr` + length) | -| `T?` | `T?` (nullable) | value buffer; `Interface?` stays `IntPtr` | -| `[T]` | `T[]` | value buffer (`IntPtr` + length) | -| `{K: V}` | `Dictionary` | value buffer (`IntPtr` + length) | -| `iter` | `IEnumerable` (lazy) | `IntPtr` | - -## Example IDL → generated code +| `InterfaceName` | `InterfaceName` (`IDisposable` class) | `IntPtr` | +| `InterfaceName?` | `InterfaceName?` | `IntPtr` (`IntPtr.Zero` for `null`) | +| `CallbackName` | `ICallbackName` (C# interface) | `IntPtr` ctx + `IntPtr` vtable | +| `T?` | `T?` (nullable) | value buffer | +| `[T]` | `T[]` | value buffer | +| `{K: V}` | `Dictionary` | value buffer | +| `iter` | `IEnumerable` (lazy, single use) | `IntPtr` | + +Buffered types cross the boundary serialized in the +[value-buffer format](../reference/value-buffers.md). The generated +file carries an internal `WeaveFFIBufferWriter`/`WeaveFFIBufferReader` +pair plus one `WriteTo`/`ReadFrom` pair per record and rich enum. +Objects nested inside a buffered value travel as `u64` object tokens +(see [Objects](#objects-interfaces)). + +### 64-bit integers and floats + +`i64` and `u64` are native `long` and `ulong`, both across P/Invoke and +inside value buffers (`WriteI64`/`WriteU64`), so the full range +round-trips exactly. `f32`/`f64` are `float`/`double`; the `codec` +conformance consumer verifies NaN, both infinities, and `-0.0` survive +a round trip bit-for-bit. + +## Example IDL and generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -78,11 +129,6 @@ modules: - { name: name, type: string } - { name: email, type: "string?" } - { name: age, type: i32 } - return: handle - - - name: get_contact - params: - - { name: id, type: handle } return: Contact - name: list_contacts @@ -128,66 +174,67 @@ public sealed class Contact } ``` -A `Contact` crosses the ABI serialized in the -[value-buffer format](../reference/value-buffers.md) as a single -pointer-plus-length pair; the internal `WriteTo`/`ReadFrom` pair packs and -unpacks the wire bytes. - Functions live as static methods on a class named after the module. Method names are PascalCase with the module prefix stripped (`Contacts.CreateContact`, not `ContactsCreateContact`); set `strip_module_prefix: false` in the .NET generator config (or under `[global]`) to keep prefixed names. Nested IDL modules flatten into a single class with a concatenated name (a `stats` module nested under -`kv` becomes `KvStats` with `KvStats.GetStats`): +`kv` becomes `KvStats` with `KvStats.GetStats`). Buffered parameters are +packed into a pinned `byte[]` the producer borrows for the call, and +buffered returns are copied out, freed, and decoded. From the `kvstore` +sample: ```csharp -public static class Contacts +public static class KvStats { - public static ulong CreateContact(string name, string? email, int age) + public static Stats GetStats(Store store) { var err = new WeaveFFIError(); - var namePtr = Marshal.StringToCoTaskMemUTF8(name); - // Optionals are buffered: pack the argument into a value buffer, - // pinned while the producer borrows it for the call. - byte[] emailBuf = /* generated pack routine for string? */; - var emailPin = GCHandle.Alloc(emailBuf, GCHandleType.Pinned); - try - { - var result = NativeMethods.weaveffi_contacts_create_contact( - namePtr, emailPin.AddrOfPinnedObject(), (UIntPtr)emailBuf.Length, - age, ref err); - WeaveFFIError.Check(err); - return result; - } - finally - { - emailPin.Free(); - Marshal.FreeCoTaskMem(namePtr); - } + var result = NativeMethods.weaveffi_kv_stats_get_stats(store.Handle, out var outLen, ref err); + WeaveFFIError.CheckKv(err); + var resultBuf = new byte[(int)outLen]; + if (result != IntPtr.Zero && (int)outLen > 0) Marshal.Copy(result, resultBuf, 0, (int)outLen); + NativeMethods.weaveffi_free_bytes(result, outLen); + var decodedReader = new WeaveFFIBufferReader(resultBuf); + var decoded = Stats.ReadFrom(decodedReader); + decodedReader.ExpectEnd(); + return decoded; } } ``` -P/Invoke entries live in an internal `NativeMethods` class: +## Typed errors + +The library defines `WeaveFFIException` with a `Code` property and the +four runtime trap codes as constants: ```csharp -internal static class NativeMethods +/// Raised for any WeaveFFI failure that isn't a typed domain +/// error: producer bugs, panics, marshalling failures, and exceptions +/// thrown by a callback-interface implementation. +public class WeaveFFIException : Exception { - private const string LibName = "weaveffi"; - - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - internal static extern void weaveffi_free_string(IntPtr ptr); - - [DllImport(LibName, EntryPoint = "weaveffi_contacts_create_contact", CallingConvention = CallingConvention.Cdecl)] - internal static extern ulong weaveffi_contacts_create_contact(IntPtr name, IntPtr email, UIntPtr emailLen, int age, ref WeaveFFIError err); + /// An untyped producer error. + public const int GenericErrorCode = -1; + /// The producer panicked; the message carries the panic text. + public const int PanicErrorCode = -2; + /// An argument couldn't be lifted by the producer. + public const int MarshalErrorCode = -3; + /// A callback-interface implementation threw; the message + /// carries the original exception's message. + public const int ForeignErrorCode = -4; + + public int Code { get; } + + public WeaveFFIException(int code, string message) : base(message) + { + Code = code; + } } ``` -## Typed errors - -The library defines `WeaveFFIException` with a `Code` property. A -module's error domain adds a derived exception named by replacing the +A module's error domain adds a derived exception named by replacing the trailing `Error` stem with `Exception` (`KvError` becomes `KvException`), carrying one `const int` per code and a `FromCode` factory. From the `kvstore` sample: @@ -210,8 +257,9 @@ public class KvException : WeaveFFIException } /// Wraps a raw error slot in the typed exception, falling - /// back to for unknown codes. - internal static WeaveFFIException FromCode(int code, string message) + /// back to for unknown codes. Codes + /// declaring payload fields decode them into Data. + internal static WeaveFFIException FromCode(int code, string message, byte[]? payload) { switch (code) { @@ -227,12 +275,13 @@ public class KvException : WeaveFFIException Only callables marked `throws: true` in the IDL surface the typed exception: their wrappers check the error slot with -`WeaveFFIError.CheckKv`, which throws `KvException` for domain codes -and plain `WeaveFFIException` for anything else (producer panics, -marshalling failures), and their doc comments carry an -`` tag. A callable without `throws` uses -the generic `WeaveFFIError.Check`, which only throws -`WeaveFFIException` if the producer misbehaves. +`WeaveFFIError.CheckKv`, which throws `KvException` for domain codes and +plain `WeaveFFIException` for anything else (producer panics, +marshalling failures, a throwing callback), and their doc comments carry +an `` tag. A callable without `throws` +uses the generic `WeaveFFIError.Check`, which throws `WeaveFFIException` +if the producer misbehaves. Both copy the message (and payload) out of +the slot and release it with `weaveffi_error_clear` before throwing. An error code that declares payload `fields:` carries them serialized in the error's payload buffer; `FromCode` decodes them and exposes each @@ -249,26 +298,60 @@ catch (KvException e) when (e.Code == KvException.KeyNotFound) } ``` -## Interfaces +### Runtime error codes + +| Code | Constant | Meaning | Where it surfaces | +|------|----------|---------|-------------------| +| `-1` | `GenericErrorCode` | The producer reported an error without a declared code. | Thrown as `WeaveFFIException`. | +| `-2` | `PanicErrorCode` | The Rust implementation panicked; the export macros and the async spawner catch the unwind. | Thrown as `WeaveFFIException`, or faults the awaited `Task`. | +| `-3` | `MarshalErrorCode` | Malformed input at the boundary (invalid UTF-8, a truncated value buffer, a bad enum discriminant). | Thrown as `WeaveFFIException`. | +| `-4` | `ForeignErrorCode` | A callback-interface method implemented in C# threw. | Thrown as `WeaveFFIException` from the producer call that invoked the callback (see [Callback interfaces](#callback-interfaces)). | + +There's no non-throwing call path in C#: a non-throwing callable whose +error slot comes back non-zero still throws `WeaveFFIException`, so a +producer bug or a throwing callback never goes unnoticed. Misuse of a +disposed wrapper is reported separately as `ObjectDisposedException`. + +## Objects (interfaces) -An `interfaces:` entry becomes a class implementing `IDisposable`. -Constructors are static factories (a constructor named `new` becomes a -public C# constructor), methods are PascalCase instance methods, -statics are static methods, and `Dispose()` calls the C destructor with -a finalizer as a safety net. From the `kvstore` sample (trimmed): +An `interfaces:` entry becomes a class implementing `IDisposable` that +owns one strong reference to a reference-counted producer object. From +the `kvstore` sample (trimmed): ```csharp public class Store : IDisposable { private IntPtr _handle; - private bool _disposed; + private int _released; + + /// Adopts one strong reference to a native object. + internal Store(WeaveFFIHandle handle) + { + _handle = handle.Value; + } + + /// The borrowed native pointer for the duration of a call. + /// The wrapper was disposed. + internal IntPtr Handle + { + get + { + var h = _handle; + if (h == IntPtr.Zero) + { + throw new ObjectDisposedException(nameof(Store)); + } + return h; + } + } - internal Store(IntPtr handle) + /// Mints a second strong reference the caller owns (for + /// example to write this object into a value buffer). + internal IntPtr CloneHandle() { - _handle = handle; + return NativeMethods.weaveffi_kv_Store_clone(Handle); } - /// Open (or create) a store backed by the given filesystem path /// Thrown when the call reports a KvError code. public static Store Open(string path) { @@ -278,7 +361,7 @@ public class Store : IDisposable { var result = NativeMethods.weaveffi_kv_Store_open(pathPtr, ref err); WeaveFFIError.CheckKv(err); - return new Store(result); + return new Store(new WeaveFFIHandle(result)); } finally { @@ -286,64 +369,127 @@ public class Store : IDisposable } } - public bool Put(string key, byte[] value, EntryKind kind, long? ttlSeconds) { /* throws KvException */ } - public Entry? Get(string key) { /* throws KvException */ } - public IEnumerable ListKeys(string? prefix) { /* lazy; see Memory and ownership */ } - public long Count() { /* generic check only (no throws) */ } - - /// Thrown when the call reports a KvError code. - public async Task Compact() { /* see Async support */ } - - [Obsolete("use put() with explicit kind")] - public bool LegacyPut(string key, byte[] value) { /* ... */ } + /// Releases this wrapper's reference. The native object is + /// dropped when the producer releases its last reference; other + /// wrappers to the same object stay valid. + public void Dispose() + { + Release(); + GC.SuppressFinalize(this); + } - /// The largest number of live entries one store will hold - public static long DefaultCapacity() + ~Store() { - var err = new WeaveFFIError(); - var result = NativeMethods.weaveffi_kv_Store_default_capacity(ref err); - WeaveFFIError.Check(err); - return result; + Release(); } - public void Dispose() + private void Release() { - if (!_disposed) + if (System.Threading.Interlocked.Exchange(ref _released, 1) != 0) { - NativeMethods.weaveffi_kv_Store_destroy(_handle); - _disposed = true; + return; + } + var h = _handle; + _handle = IntPtr.Zero; + if (h != IntPtr.Zero) + { + NativeMethods.weaveffi_kv_Store_destroy(h); } - GC.SuppressFinalize(this); } +} +``` - ~Store() - { - Dispose(); - } +- **Construction.** A constructor named `new` becomes a public C# + constructor (the `events` sample's `new EventBus()`); any other + constructor is a static factory (`Store.Open(path)`). Methods are + PascalCase instance methods, statics are static methods, and + deprecated members carry `[Obsolete]`. The adopting constructor takes + a distinct `WeaveFFIHandle` struct rather than a bare `IntPtr`, so it + never competes with a public constructor taking an integer. +- **Disposal.** `Dispose()` releases this wrapper's reference through + the `_destroy` symbol. It's idempotent (an `Interlocked.Exchange` + guard), and the finalizer is a backstop for wrappers that were never + disposed. Use `using`. The producer object itself is dropped only when + the last reference anywhere is released. +- **Use after dispose.** Every call reads the internal `Handle` + property, which throws `ObjectDisposedException` once the wrapper has + been released, whether the wrapper is the receiver, a parameter, or a + field of a record being packed. +- **Copies mint new references.** Methods that return the receiver or an + existing object (`Share()`, `Fork()`) return a fresh strong reference + adopted into a new wrapper; disposing one wrapper never affects + another pointing at the same object. + +### Nullable objects, and objects inside values + +An `Interface?` parameter passes `IntPtr.Zero` for `null`, and an +`Interface?` return maps `IntPtr.Zero` to `null`: + +```csharp +public Store? Larger(Store? other) +{ + var err = new WeaveFFIError(); + var result = NativeMethods.weaveffi_kv_Store_larger(Handle, other?.Handle ?? IntPtr.Zero, ref err); + WeaveFFIError.Check(err); + return result == IntPtr.Zero ? null : new Store(new WeaveFFIHandle(result)); } ``` -Functions elsewhere in the IDL pass the wrapper's handle across the -boundary (`KvStats.GetStats(store)` reads `store.Handle`; the returned -`Stats` record is decoded from a value buffer). Deprecated members carry -`[Obsolete]`: +Objects inside records, lists, dictionaries, and optionals travel as +`u64` object tokens in the value buffer. Writing a token mints a new +strong reference with `CloneHandle()`; reading one adopts the reference +into a fresh wrapper. From the `StoreInfo` record (`store: Store`, +`mirror: Store?`): ```csharp -using var store = Store.Open("/tmp/cache.kv"); -store.Put("alpha", new byte[] { 1 }, EntryKind.Persistent, null); -Console.WriteLine($"{store.Count()} / {Store.DefaultCapacity()}"); -long reclaimed = await store.Compact(); +internal void WriteTo(WeaveFFIBufferWriter writer) +{ + writer.WriteString(Label); + writer.WriteObject(Store.CloneHandle()); + if (Mirror != null) + { + writer.WriteOptionFlag(true); + writer.WriteObject(Mirror!.CloneHandle()); + } + else + { + writer.WriteOptionFlag(false); + } + writer.WriteI64(Count); +} + +internal static StoreInfo ReadFrom(WeaveFFIBufferReader reader) +{ + var fLabel = reader.ReadString(); + var fStore = new Store(new WeaveFFIHandle(reader.ReadObject())); + Store? fMirror = null; + if (reader.ReadOptionFlag()) + { + var fMirrorValue = new Store(new WeaveFFIHandle(reader.ReadObject())); + fMirror = fMirrorValue; + } + var fCount = reader.ReadI64(); + return new StoreInfo(fLabel, fStore, fMirror, fCount); +} ``` +`ReadObject` rejects a zero token as a malformed buffer. Lists of +objects work the same way in both directions (`Store.OpenMany(paths)` +returns `Store[]`, `Store.TotalCount(stores, extra)` takes one); each +wrapper in a returned array owns its own reference and must be disposed +individually. Iterators over objects adopt one reference per step, and +async functions returning an object adopt the pointer inside the +completion callback before completing the `Task`. + ## Rich (algebraic) enums A *rich* (algebraic) enum, a sum type whose variants carry associated data, becomes a closed class hierarchy: an abstract base class with a -private constructor plus one nested sealed class per variant, each shaped -like a record (typed get-only properties and a positional constructor). -Rich enums own no native resources and declare no C symbols. (A plain -C-style enum with no payloads stays a normal C# `enum` backed by `int`; -see above.) +private constructor plus one nested sealed class per variant, each +shaped like a record (typed get-only properties and a positional +constructor). Rich enums own no native resources and declare no C +symbols. (A plain C-style enum with no payloads stays a normal C# `enum` +backed by `int`; see above.) For the `shapes` module's `Shape` enum (`Empty`, `Circle { radius: f64 }`, `Rectangle { width: f32, height: f32 }`, and @@ -407,7 +553,139 @@ Console.WriteLine(Shapes.Describe(bigger)); ``` Values are plain managed data: there's nothing to dispose and no native -handle to track. +handle to track. Variant fields of interface type follow the object +token rules above. + +## Callback interfaces + +A `callback_interfaces:` entry becomes a C# `interface` (prefixed with +`I`) the consumer implements and passes wherever the API takes that +type. From the `kvstore` sample: + +```csharp +/// Implement this interface and pass an instance wherever the +/// native library expects it. The library may call any method from any +/// thread until it releases its last reference to the instance. Object +/// arguments are owned by the implementation, which should dispose them +/// when done. An exception thrown by a method is reported to the native +/// caller as . +public interface IEvictionListener +{ + bool OnEvict(Entry entry, EvictionReason reason); +} +``` + +```csharp +sealed class Auditor : IEvictionListener +{ + public bool OnEvict(Entry entry, EvictionReason reason) + { + Console.WriteLine($"{entry.Key}: {reason}"); + return true; + } +} + +store.SetEvictionListener(new Auditor()); +``` + +Behind the interface is one process-wide static vtable per callback +interface, allocated once with `Marshal.AllocHGlobal` and never freed, +whose slots are unmanaged function pointers to `[UnmanagedCallersOnly]` +trampolines. Passing an implementation allocates a `GCHandle` for it and +hands the producer the handle's `IntPtr` as `ctx`: + +```csharp +internal static unsafe class WeaveFFIVtable_kv_EvictionListener +{ + internal static readonly IntPtr Pointer = Allocate(); + + private static IntPtr Allocate() + { + var layout = new Layout + { + on_evict = (IntPtr)(delegate* unmanaged[Cdecl])&OnEvictTrampoline, + free = (IntPtr)(delegate* unmanaged[Cdecl])&FreeTrampoline, + }; + var mem = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(layout, mem, false); + return mem; + } + + private static IEvictionListener Target(IntPtr ctx) + { + return (IEvictionListener)GCHandle.FromIntPtr(ctx).Target!; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static byte OnEvictTrampoline(IntPtr ctx, IntPtr entry_ptr, UIntPtr entry_len, int reason, IntPtr out_err) + { + try + { + var impl = Target(ctx); + var arg0Buf = new byte[(int)entry_len]; + if (entry_ptr != IntPtr.Zero && (int)entry_len > 0) Marshal.Copy(entry_ptr, arg0Buf, 0, (int)entry_len); + var arg0Reader = new WeaveFFIBufferReader(arg0Buf); + var arg0 = Entry.ReadFrom(arg0Reader); + arg0Reader.ExpectEnd(); + return (byte)(impl.OnEvict(arg0, (EvictionReason)reason) ? 1 : 0); + } + catch (Exception ex) + { + NativeMethods.weaveffi_error_set(out_err, WeaveFFIException.ForeignErrorCode, ex.Message); + return default; + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void FreeTrampoline(IntPtr ctx) + { + GCHandle.FromIntPtr(ctx).Free(); + } +} +``` + +```csharp +public void SetEvictionListener(IEvictionListener listener) +{ + var err = new WeaveFFIError(); + var listenerCtx = GCHandle.ToIntPtr(GCHandle.Alloc(listener)); + NativeMethods.weaveffi_kv_Store_set_eviction_listener(Handle, listenerCtx, WeaveFFIVtable_kv_EvictionListener.Pointer, ref err); + WeaveFFIError.Check(err); +} +``` + +- **Lifetime.** The `GCHandle` keeps the implementation alive exactly as + long as the producer may call it; the vtable's `free` trampoline frees + the handle when the producer drops its last reference. A producer that + retains the implementation (a store's eviction listener) keeps it + alive across calls; one that doesn't (the `events` sample's + `Events.RouteOnce`) frees it before returning. Passing the same + object twice allocates two handles. +- **Argument ownership.** Borrowed strings and buffers are copied into + managed memory before the method runs, so the implementation may keep + them. An object passed to a callback method is owned by the + implementation: the trampoline adopts it into a new wrapper + (`impl.OnAttached(new EventBus(new WeaveFFIHandle(bus)))` in the + `events` sample), and the implementation should dispose it when done. +- **Return values.** A method's return value is converted back to its C + representation (`bool` to `0`/`1`, a plain enum to its `int`, a record + to a value buffer the producer frees). +- **Exceptions.** An exception escaping a method never crosses the + unmanaged frame. The trampoline writes `ForeignErrorCode` (-4) with + `ex.Message` into the producer's error slot and returns a default + value; the producer aborts the call in progress, and the original + caller gets `WeaveFFIException` with `Code == -4`. For a callable + marked `throws`, `FromCode` falls through to `WeaveFFIException` + because -4 is outside the domain, so `catch (KvException)` doesn't + catch it but `catch (WeaveFFIException)` does. The runtime is never + taken down. +- **Threads.** The producer may call a method from any thread; + `[UnmanagedCallersOnly]` entry points are safe to enter from native + threads the runtime has never seen. The method runs on that thread, + not on any captured `SynchronizationContext`, so post to your UI + thread or dispatcher yourself. A callback that blocks waiting for the + thread that made the producer call will deadlock if the producer + invoked it synchronously from that call. ## Build instructions @@ -430,62 +708,97 @@ handle to track. dotnet pack -c Release ``` - The resulting `.nupkg` lives in `bin/Release/`. For production - packages, bundle the native cdylib inside the package under - `runtimes/{rid}/native/`. + The resulting `.nupkg` lives in `bin/Release/`. For a package that + carries the native library, use `weaveffi package` (below). + +4. Make the cdylib findable at runtime: place it next to the built DLL, + set `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`, or register a + `DllImportResolver` for the generated assembly that returns the + absolute path (the conformance consumers do this from a + `WEAVEFFI_LIBRARY` variable). + +5. Use the bindings: + + ```csharp + using var store = Store.Open("/tmp/cache.kv"); + store.Put("alpha", new byte[] { 1 }, EntryKind.Persistent, null); + Console.WriteLine($"{store.Count()} / {Store.DefaultCapacity()}"); + foreach (var key in store.ListKeys(null)) + { + Console.WriteLine(key); + } + long reclaimed = await store.Compact(); + ``` + +## Packaging + +`weaveffi package --target dotnet` emits a single NuGet project under +`dotnet/` whose `.csproj` packs `runtimes/**` as content, and copies +each supplied desktop binary to `runtimes//native/`, the layout +NuGet resolves automatically at restore time. The generated `LibName` +constant is rebound from `weaveffi` to the bundled library's base name. -4. Make the cdylib findable at runtime: place it next to the built - DLL, set `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`, or include it in - the NuGet package as above. +| Platform | RID | +|----------|-----| +| `macos-arm64` | `osx-arm64` | +| `macos-x64` | `osx-x64` | +| `linux-x64` | `linux-x64` | +| `linux-arm64` | `linux-arm64` | +| `windows-x64` | `win-x64` | + +Android and `wasm32` binaries have no RID slot and are skipped. See +[Packaging](../guides/packaging.md) for the shared workflow. ## Memory and ownership - Each interface class implements `IDisposable`; use `using` for - deterministic cleanup. The finalizer is a safety net only and runs - on a non-deterministic schedule. Structs and rich enums are plain - managed values with nothing to dispose. -- Returned strings are copied into managed memory and the raw pointer - is freed via `weaveffi_free_string` immediately. + deterministic cleanup. The finalizer is a safety net only and runs on + a non-deterministic schedule. Records and rich enums are plain managed + values with nothing to dispose. +- Returned strings are copied into managed memory and the raw pointer is + freed via `weaveffi_free_string` immediately. - Strings passed as parameters are marshalled with `Marshal.StringToCoTaskMemUTF8` and freed in a `finally` block. -- Buffered values (structs, rich enums, optionals, arrays, and - dictionaries) cross as one value buffer: parameters are packed into - a pinned `byte[]` that the producer borrows for the call; returns - are copied into managed memory, released with `weaveffi_free_bytes`, - and decoded into the C# value. -- An optional interface return surfaces as `IntPtr.Zero` from the C - ABI and becomes `null` in C#. -- `iter` functions return a lazy, single-use `IEnumerable` - (`WeaveFFIOnceEnumerable`) that pulls one item through the C - `_next` function per enumeration step; each string element is copied - and freed with `weaveffi_free_string`, and the native iterator - handle is destroyed in a `finally` block when enumeration completes, - a step fails, or the enumerator is disposed early (a `foreach` - disposes it automatically, including on early exit). A throwing - function checks the launch and each step with the domain checker - (`Store.ListKeys` throws `KvException` from the failing step). +- Buffered values (records, rich enums, optionals, arrays, and + dictionaries) cross as one value buffer: parameters are packed into a + pinned `byte[]` that the producer borrows for the call; returns are + copied into managed memory, released with `weaveffi_free_bytes`, and + decoded. Object tokens written into a buffer are fresh strong + references the producer owns; tokens read out are adopted into + wrappers. +- Callback implementations are pinned by a `GCHandle` until the producer + calls the vtable's `free`. ## Async support Async IDL functions are exposed as `async Task` methods (named like every other wrapper: no extra `Async` suffix is appended). The wrapper -wires the C ABI completion callback into a `TaskCompletionSource` -and keeps the callback delegate alive with a `GCHandle` while the call -is in flight: +wires the C ABI completion callback into a `TaskCompletionSource` and +keeps the callback delegate alive with a `GCHandle` while the call is in +flight. From the `kvstore` sample's `Store.Compact`: ```csharp -/// Thrown when the call reports a TaskError code. -public static async Task RunTask(string name) +/// Thrown when the call reports a KvError code. +public async Task Compact() { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - NativeMethods.AsyncCb_weaveffi_tasks_run_task callback = (context, err, result, resultLen) => + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + NativeMethods.AsyncCb_weaveffi_kv_Store_compact callback = (context, err, result) => { try { - // ... tcs.SetException(TaskException.FromCode(...)) on error ... - // TaskResult is a record: decode the borrowed (result, resultLen) - // value buffer into the managed value. - tcs.SetResult(TaskResult.ReadFrom(/* reader over the copied bytes */)); + if (err != IntPtr.Zero) + { + var wErr = Marshal.PtrToStructure(err); + if (wErr.Code != 0) + { + var msg = Marshal.PtrToStringUTF8(wErr.Message) ?? ""; + var payload = WeaveFFIError.CopyPayload(wErr); + NativeMethods.weaveffi_error_free(err); + tcs.SetException(KvException.FromCode(wErr.Code, msg, payload)); + return; + } + } + tcs.SetResult(result); } finally { @@ -497,104 +810,139 @@ public static async Task RunTask(string name) }; var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal); var ctx = GCHandle.ToIntPtr(gcHandle); - // ... marshal parameters, gcHandle.Free() in a catch if the native call throws ... - NativeMethods.weaveffi_tasks_run_task_async(namePtr, callback, ctx); + try + { + NativeMethods.weaveffi_kv_Store_compact_async(Handle, IntPtr.Zero, callback, ctx); + } + catch + { + if (gcHandle.IsAllocated) gcHandle.Free(); + throw; + } return await tcs.Task; } ``` - The `GCHandle` prevents the GC from collecting the delegate (and the - native thunk the producer will call) before completion. It is freed + native thunk the producer will call) before completion. It's freed exactly once: in the callback's `finally`, or on the `catch` path if the native call itself throws synchronously. - The completion callback runs on the producer's native thread; `RunContinuationsAsynchronously` keeps awaiting code from running inline on that thread. - For a callable marked `throws: true`, an error faults the task with - the domain exception via its `FromCode` factory - (`KvException.FromCode` on `Store.Compact()`); otherwise a failure + the domain exception via its `FromCode` factory; otherwise a failure can only be a producer bug and faults the task with - `WeaveFFIException`. + `WeaveFFIException`. A panic inside the spawned future surfaces as + `PanicErrorCode` (-2). - Result ownership follows the async contract: string, bytes, and - buffered results (records, rich enums, optionals, arrays, maps, - arriving as a `(result, resultLen)` pair) are owned by the consumer, - so the callback copies or decodes them into managed values and then - releases them with `NativeMethods.weaveffi_free_string` or - `NativeMethods.weaveffi_free_bytes`. A reported error is heap-boxed - and released with `NativeMethods.weaveffi_error_free` after its - fields are copied. An owned interface result transfers ownership - too: the wrapper adopts the pointer. + buffered results (arriving as a `(result, resultLen)` pair) are owned + by the consumer, so the callback copies or decodes them into managed + values and then releases them with `weaveffi_free_string` or + `weaveffi_free_bytes`. A reported error is heap-boxed and released + with `weaveffi_error_free` after its fields are copied. An owned + interface result transfers ownership too: the callback adopts the + pointer into a new wrapper. Async interface methods follow the same pattern as instance methods: -`await store.Compact()` returns `Task`. +`await store.Compact()` returns `Task`, and the receiver's +`Handle` is read (throwing `ObjectDisposedException` if needed) before +the launcher runs. For functions marked `cancellable: true` the wrapper passes -`IntPtr.Zero` for the C ABI's cancel-token slot; no -`CancellationToken` parameter is exposed. Only the C and C++ -targets expose cancellation tokens. +`IntPtr.Zero` for the C ABI's cancel-token slot; no `CancellationToken` +parameter is exposed. Only the C and C++ targets expose cancellation +tokens. -## Callbacks and listeners +## Iterators -An IDL `listener` becomes a register/unregister pair on the module -class. Registration takes an `Action<...>` and returns a `ulong` -subscription id; unregistration takes that id back: +Functions returning `iter` return a lazy, single-use `IEnumerable` +(`WeaveFFIOnceEnumerable`) backed by a C# iterator method that pulls +one item through the C `_next` function per step. From the `kvstore` +sample's `Store.ListKeys`: ```csharp -public static ulong RegisterMessageListener(Action callback) -public static void UnregisterMessageListener(ulong id) -``` - -The id is the `uint64` returned by the C ABI's -`weaveffi_events_register_message_listener(callback_fn, context)`. -Registration wraps the `Action` in a Cdecl delegate trampoline and -stores it in a registry keyed by the subscription id so the GC cannot -collect it while the native side may still call it: - -```csharp -private static readonly object _listenerLock = new object(); -private static readonly Dictionary _listenerRefs = new Dictionary(); +public IEnumerable ListKeys(string? prefix) +{ + // ... pack the `string?` prefix into a pinned value buffer ... + var iter = NativeMethods.weaveffi_kv_Store_list_keys(Handle, prefixPin.AddrOfPinnedObject(), (UIntPtr)prefixBuf.Length, ref err); + WeaveFFIError.CheckKv(err); + return new WeaveFFIOnceEnumerable(EnumerateListKeys(iter)); +} -public static ulong RegisterMessageListener(Action callback) +private static IEnumerator EnumerateListKeys(IntPtr iter) { - NativeMethods.Cb_weaveffi_events_OnMessage_fn trampoline = (message, context) => + try { - callback(Marshal.PtrToStringUTF8(message) ?? ""); - }; - ulong id; - lock (_listenerLock) + while (true) + { + var iterErr = new WeaveFFIError(); + if (NativeMethods.weaveffi_kv_Store_ListKeysIterator_next(iter, out var out_item, ref iterErr) == 0) + { + WeaveFFIError.CheckKv(iterErr); + yield break; + } + WeaveFFIError.CheckKv(iterErr); + var item = Marshal.PtrToStringUTF8(out_item) ?? ""; + NativeMethods.weaveffi_free_string(out_item); + yield return item; + } + } + finally { - id = NativeMethods.weaveffi_events_register_message_listener(trampoline, IntPtr.Zero); - _listenerRefs[id] = trampoline; + NativeMethods.weaveffi_kv_Store_ListKeysIterator_destroy(iter); } - return id; } ``` -The trampoline's delegate type is declared with -`[UnmanagedFunctionPointer(CallingConvention.Cdecl)]`. -`Events.UnregisterMessageListener(id)` calls the C ABI unregister first -and then drops the registry entry, releasing the delegate for -collection. - -Threading caveats: - -- The callback runs on the producer's native thread, not on any - captured `SynchronizationContext`. Post to your UI thread or - dispatcher yourself if needed. -- Keep callbacks fast and non-throwing; they execute while the native - producer is delivering the event. +- The native handle is destroyed in the `finally` block exactly once: + when enumeration completes, when a step throws, or when the enumerator + is disposed early (a `foreach` disposes it automatically, including on + `break`). +- The sequence can be enumerated only once; a second `GetEnumerator()` + throws `InvalidOperationException`. Materialise with `.ToList()` if + you need to iterate twice. +- Each yielded element is owned by the consumer: strings are copied and + freed, buffered elements are copied, freed, and decoded, and an object + element is adopted into a new wrapper (or `null` for an absent + `Interface?` element). +- A throwing function checks the launch and each step with the domain + checker (`ListKeys` throws `KvException` from the failing step); a + non-throwing one throws `WeaveFFIException` only for producer bugs. + +## Known limitations + +- Async cancellation doesn't propagate: no `CancellationToken` is + accepted, and an abandoned `Task` leaves the native operation running. +- Callback methods run on whatever thread the producer uses; nothing + marshals them to a `SynchronizationContext`. +- `iter` sequences are single-pass. +- The plain `generate` output relies on the default `DllImport` probing + for a library named `weaveffi`; only `weaveffi package` bundles the + native library under `runtimes//native/`. +- Function pointers and `[UnmanagedCallersOnly]` require .NET 5+ and + `AllowUnsafeBlocks`; the generated project targets `net8.0`. ## Troubleshooting -- **`DllNotFoundException: Unable to load DLL 'weaveffi'`**: the - runtime cannot find the shared library. Place it in the application - directory or set `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`. -- **`AccessViolationException` on dispose**: the interface object has - been disposed twice. Wrap usage in `using` and avoid passing handles - around once disposed. -- **Strings returned with garbage characters**: make sure your - binding is targeting `UTF8` (`Marshal.PtrToStringUTF8`, +- **`DllNotFoundException: Unable to load DLL 'weaveffi'`**: the runtime + cannot find the shared library. Place it in the application directory, + set `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`, register a + `DllImportResolver`, or ship it via `weaveffi package`. +- **`TypeInitializationException` wrapping "WeaveFFI ABI mismatch"**: + the library was built by a different `weaveffi` release than the + bindings. Regenerate the bindings and rebuild the library together. +- **`ObjectDisposedException`**: a disposed wrapper was used as a + receiver, a parameter, or a record field. Keep the wrapper alive for + as long as the object is in use; disposing one wrapper doesn't affect + others pointing at the same object. +- **`WeaveFFIException` with `Code == -4`**: a callback-interface method + you implemented threw; the message is the original exception's + message. Catch inside the method if the producer call should succeed + anyway. +- **`InvalidOperationException: this sequence can be enumerated only + once`**: an `iter` result was enumerated twice; call the function + again or materialise the first pass. +- **Strings returned with garbage characters**: make sure your binding + is targeting `UTF8` (`Marshal.PtrToStringUTF8`, `StringToCoTaskMemUTF8`); the generated helpers do this for you. -- **NuGet consumers cannot find the cdylib**: ship it inside the - package under `runtimes/{rid}/native/` so the .NET runtime resolves - it automatically. diff --git a/docs/src/generators/go.md b/docs/src/generators/go.md index 256982e2..1a8431ac 100644 --- a/docs/src/generators/go.md +++ b/docs/src/generators/go.md @@ -2,58 +2,84 @@ ## Overview -The Go target produces idiomatic Go bindings that use CGo to call the C -ABI. The generator emits one Go source file (`weaveffi.go`) plus a -`go.mod` so the result can be imported by any Go module. Functions -marked `throws: true` return `(value, error)` to match Go conventions; -all other wrappers return plain values. Structs and rich enums are plain -Go value types packed and unpacked from value buffers; interface -wrappers expose methods plus an explicit `Close()`. Functions returning -`iter` produce standard-library `iter.Seq`/`iter.Seq2` sequences, -so the generated module requires Go 1.23 or later (the emitted -`go.mod` declares `go 1.23`). +The Go target produces idiomatic Go bindings that use cgo to call the C +ABI (revision 2). The generator emits one Go source file (`weaveffi.go`) +plus a `go.mod` so the result can be imported by any Go module. +Functions marked `throws: true` return `(value, error)` to match Go +conventions; all other wrappers return plain values and panic on the +runtime trap codes. Records and rich enums are plain Go value types +packed and unpacked from value buffers. Interfaces are reference-counted +objects behind pointer wrappers with `Close()` and a finalizer backstop. +Callback interfaces are Go `interface` types the consumer implements, +crossing as a `cgo.Handle` plus one static vtable of exported +trampolines. Async functions block the calling goroutine on a channel, +and `iter` returns produce standard-library `iter.Seq`/`iter.Seq2` +sequences, so the generated module requires Go 1.23 or later (the +emitted `go.mod` declares `go 1.23`). ## What gets generated | File | Purpose | |------|---------| -| `go/weaveffi.go` | CGo bindings: preamble, type wrappers, function wrappers | +| `go/weaveffi.go` | cgo bindings: preamble, ABI check, codecs, type wrappers, function wrappers | | `go/go.mod` | Go module descriptor (configurable module path) | | `go/README.md` | Prerequisites and build instructions | +The package checks the producer's ABI revision in `init()` and panics +on mismatch, so a stale library fails at load time: + +```go +// The ABI revision these bindings were generated against. +const wvABIVersion uint32 = 2 + +func init() { + if found := uint32(C.weaveffi_abi_version()); found != wvABIVersion { + panic(fmt.Sprintf("WeaveFFI ABI mismatch: these bindings expect revision %d but the loaded library reports revision %d", wvABIVersion, found)) + } +} +``` + ## Type mapping -| IDL type | Go type | C type (CGo) | +| IDL type | Go type | C type (cgo) | |--------------|---------------|----------------------------| -| `i32` | `int32` | `C.int32_t` | -| `u32` | `uint32` | `C.uint32_t` | -| `i64` | `int64` | `C.int64_t` | -| `f64` | `float64` | `C.double` | -| `i8` | `int8` | `C.int8_t` | -| `i16` | `int16` | `C.int16_t` | -| `u8` | `uint8` | `C.uint8_t` | -| `u16` | `uint16` | `C.uint16_t` | -| `u64` | `uint64` | `C.uint64_t` | -| `f32` | `float32` | `C.float` | +| `i8`, `i16`, `i32`, `i64` | `int8`, `int16`, `int32`, `int64` | `C.int8_t` ... `C.int64_t` | +| `u8`, `u16`, `u32`, `u64` | `uint8`, `uint16`, `uint32`, `uint64` | `C.uint8_t` ... `C.uint64_t` | +| `f32`, `f64` | `float32`, `float64` | `C.float`, `C.double` | | `bool` | `bool` | `C._Bool` | | `string` | `string` | `*C.char` (via `C.CString`/`C.GoString`) | | `bytes` | `[]byte` | `*C.uint8_t` + `C.size_t` | -| `handle` | `int64` | `C.weaveffi_handle_t` | | `Struct` | `StructName` (plain struct) | value buffer (`*C.uint8_t` + `C.size_t`) | +| `Enum` (plain) | `EnumName` (`int32` alias) | `C.weaveffi_mod_Enum` | +| `Enum` (rich) | `EnumName` (sealed interface + variant structs) | value buffer | | `Interface` | `*InterfaceName` | `*C.weaveffi_mod_Interface` | -| `Enum` (plain) | `EnumName` | `C.weaveffi_mod_Enum` | -| `Enum` (rich) | `EnumName` (value type) | value buffer (`*C.uint8_t` + `C.size_t`) | -| `T?` | `*T` | value buffer; `Interface?` stays a nil-able pointer | -| `[T]` | `[]T` | value buffer (`*C.uint8_t` + `C.size_t`) | -| `{K: V}` | `map[K]V` | value buffer (`*C.uint8_t` + `C.size_t`) | +| `Interface?` | `*InterfaceName` (nil-able) | `*C.weaveffi_mod_Interface` (NULL for `nil`) | +| `CallbackInterface` | `CallbackName` (Go `interface`) | `void*` ctx + `const vtable*` | +| `T?` | `*T` | value buffer | +| `[T]` | `[]T` | value buffer | +| `{K: V}` | `map[K]V` | value buffer | | `iter` | `iter.Seq[T]`, or `iter.Seq2[T, error]` when the function throws | opaque iterator pointer + `_next`/`_destroy` | -Booleans map to `C._Bool`, matching CGo's representation of `_Bool`. +Buffered types cross the boundary serialized in the +[value-buffer format](../reference/value-buffers.md); the package +carries a private `wvWriter`/`wvReader` pair plus one `wvPack*` and one +`wvUnpack*` routine per record and rich enum. Objects nested inside a +buffered value travel as `u64` object tokens (see +[Objects](#objects-interfaces)). Booleans map to `C._Bool`, matching +cgo's representation of `_Bool`. + +### 64-bit integers and floats + +`i64` and `u64` are native `int64` and `uint64`, both across cgo and +inside value buffers (`writeI64`/`writeU64`), so the full range +round-trips exactly. `f32`/`f64` are `float32`/`float64`, packed with +`math.Float64bits`; the `codec` conformance consumer verifies NaN, both +infinities, and `-0.0` survive a round trip bit-for-bit. -## Example IDL → generated code +## Example IDL and generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -77,11 +103,6 @@ modules: - { name: first_name, type: string } - { name: email, type: "string?" } - { name: contact_type, type: ContactType } - return: handle - - - name: get_contact - params: - - { name: id, type: handle } return: Contact - name: list_contacts @@ -93,22 +114,29 @@ modules: return: i32 ``` -The generated `weaveffi.go` opens with the CGo preamble: +The generated `weaveffi.go` opens with the cgo preamble. The preamble +also declares the exported trampolines and static vtables for every +callback interface and async function in the IDL (from the `kvstore` +sample): ```go -package weaveffi +package kvstore /* -#cgo LDFLAGS: -lweaveffi +#cgo LDFLAGS: -lkvstore #include "weaveffi.h" #include +static void* wvHandlePtr(uintptr_t h) { return (void*)h; } +extern bool goWv_weaveffi_kv_EvictionListener_on_evict(void* ctx, uint8_t* entry_ptr, size_t entry_len, weaveffi_kv_EvictionReason reason, weaveffi_error* out_err); +extern void goWv_weaveffi_kv_EvictionListener_free(void* ctx); +static const weaveffi_kv_EvictionListener_vtable wvVtable_weaveffi_kv_EvictionListener = { + (bool (*)(void*, const uint8_t*, size_t, weaveffi_kv_EvictionReason, weaveffi_error*))goWv_weaveffi_kv_EvictionListener_on_evict, + goWv_weaveffi_kv_EvictionListener_free, +}; +static const weaveffi_kv_EvictionListener_vtable* wvVtablePtr_weaveffi_kv_EvictionListener(void) { return &wvVtable_weaveffi_kv_EvictionListener; } +extern void goWv_weaveffi_kv_Store_compact_callback(void* context, weaveffi_error* err, int64_t result); */ import "C" - -import ( - "fmt" - "unsafe" -) ``` Enums become typed integer aliases: @@ -135,50 +163,46 @@ type Contact struct { } ``` -There are no C symbols behind a struct. A `Contact` crosses the ABI -serialized in the [value-buffer format](../reference/value-buffers.md) as a -single `(*C.uint8_t, C.size_t)` pair; the package carries a private buffer -reader and writer plus one generated pack and unpack routine per type. - Function wrappers are PascalCase with the IDL module prefix stripped (`CreateContact`, not `ContactsCreateContact`); set `strip_module_prefix: false` in the Go generator config (or under -`[global]`) to keep prefixed names. A function without `throws` returns -a plain value; its error slot is checked by `wvTrap`, which panics, -because a non-zero code there can only be a producer panic or a -marshalling failure: +`[global]`) to keep prefixed names. A function marked `throws: true` +returns `(value, error)`; a buffered return is copied into Go memory, +released with `weaveffi_free_bytes`, and decoded. From the `kvstore` +sample's cross-module `GetStats`: ```go -func CreateContact(firstName string, email *string, contactType ContactType) int64 { - cFirstName := C.CString(firstName) - defer C.free(unsafe.Pointer(cFirstName)) - // Optionals are buffered: pack the argument into a value buffer, - // borrowed by the producer for the duration of the call. - emailBuf := /* generated pack routine for *string */ +func GetStats(store *Store) (Stats, error) { + defer runtime.KeepAlive(store) + var cOutLen C.size_t var cErr C.weaveffi_error - result := C.weaveffi_contacts_create_contact( - cFirstName, - (*C.uint8_t)(unsafe.Pointer(&emailBuf[0])), C.size_t(len(emailBuf)), - C.weaveffi_contacts_ContactType(contactType), &cErr) - wvTrap(&cErr) - return int64(result) + result := C.weaveffi_kv_stats_get_stats(store.ptr, &cOutLen, &cErr) + if cErr.code != 0 { + return Stats{}, wvMapKv(wvTakeError(&cErr)) + } + rRes := &wvReader{buf: wvCopyBuffer(result, cOutLen)} + var goResult Stats + goResult = wvUnpackStats(rRes) + rRes.expectEnd() + return goResult, nil } ``` -A function marked `throws: true` returns `(value, error)` instead; see -[Typed errors](#typed-errors). - -Lists, maps, and other buffered returns arrive as one value buffer: the -wrapper copies the bytes into Go memory, releases the producer's buffer -with `weaveffi_free_bytes`, then decodes the Go value: +A function without `throws` returns a plain value; its error slot is +checked by `wvTrap`, which panics, because a non-zero code there can +only be a runtime trap: ```go -var cOutLen C.size_t -raw := C.weaveffi_store_list_ids(&cOutLen, &cErr) -wvTrap(&cErr) -buf := C.GoBytes(unsafe.Pointer(raw), C.int(cOutLen)) -C.weaveffi_free_bytes(raw, cOutLen) -ids := /* generated unpack routine over buf */ +// wvTrap panics when the C error slot reports a failure. Non-throwing +// wrappers check their slot with it: a non-zero code there can only be +// a producer panic, a marshalling failure, or a callback-interface +// implementation that panicked. The panic value is a *WeaveFFIError, so a +// recover() site can still inspect the code. +func wvTrap(cErr *C.weaveffi_error) { + if cErr.code != 0 { + panic(wvBrandError(wvTakeError(cErr))) + } +} ``` The Go module path follows the `[package]` name in `weaveffi.toml` @@ -192,10 +216,25 @@ module_path = "github.com/myorg/mylib" ## Typed errors The package defines a generic `WeaveFFIError` struct with `Code` and -`Message` fields. A module's error domain adds a typed error struct -named after the domain, package-level code constants, and a mapper -that falls back to `*WeaveFFIError` for codes outside the domain. From -the `kvstore` sample: +`Message` fields: + +```go +// WeaveFFIError reports a failure crossing the C boundary that no typed +// error domain claims: an unknown code, a marshalling failure, a +// producer panic (code -2), or a Go callback-interface implementation +// that panicked (code -4). +type WeaveFFIError struct { + // Code is the numeric ABI error code. + Code int32 + // Message is the human-readable error message. + Message string +} +``` + +A module's error domain adds a typed error struct named after the +domain, package-level code constants, and a mapper that falls back to +`*WeaveFFIError` for codes outside the domain. From the `kvstore` +sample: ```go // KvError is a typed error reported by the `kv` module. @@ -224,8 +263,8 @@ const ( ``` A callable marked `throws: true` returns `(value, error)` and maps a -non-zero error slot through the domain mapper (`wvMapKv`); match it -with `errors.As` and compare the code constants: +non-zero error slot through the domain mapper (`wvMapKv`); match it with +`errors.As` and compare the code constants: ```go _, err := store.Delete("missing") @@ -236,29 +275,62 @@ if errors.As(err, &kvErr) && kvErr.Code == KvErrorKeyNotFound { ``` A callable without `throws` returns a plain value and checks its slot -with `wvTrap`, which panics on the codes that can only mean a producer -bug. +with `wvTrap`, which panics with a `*WeaveFFIError`. An error code that declares payload `fields:` carries them serialized in the error's payload buffer; the mapper decodes them into typed fields on the error value before `weaveffi_error_clear` releases the buffer. -## Interfaces +### Runtime error codes + +| Code | Meaning | Where it surfaces | +|------|---------|-------------------| +| `-1` | The producer reported an error without a declared code. | `*WeaveFFIError` returned from a throwing call, or the panic value of a non-throwing one. | +| `-2` | The Rust implementation panicked; the export macros and the async spawner catch the unwind. | Same as above (a blocking async call returns it as its `error`). | +| `-3` | Malformed input at the boundary (invalid UTF-8, a truncated value buffer, a bad enum discriminant). | Same as above. | +| `-4` | A callback-interface method implemented in Go panicked. | `*WeaveFFIError` with `Code == -4` returned from the throwing producer call that invoked the callback, or the panic value of a non-throwing one (see [Callback interfaces](#callback-interfaces)). | + +The panic value from `wvTrap` is a `*WeaveFFIError`, so a `recover()` +site can still inspect the code. Using a closed wrapper is a separate +panic with a plain string message. -An `interfaces:` entry becomes a struct holding the typed C pointer. -Constructors become package-level factory functions combining the -constructor and type names (`open` becomes `OpenStore`, `new` becomes -`NewContactBook`), methods hang off the wrapper, statics become -package-level functions prefixed by the type name -(`StoreDefaultCapacity`), and `Close()` frees the native object. From -the `kvstore` sample (trimmed): +## Objects (interfaces) + +An `interfaces:` entry becomes a struct holding the typed C pointer to a +reference-counted producer object. Constructors become package-level +factory functions combining the constructor and type names (`open` +becomes `OpenStore`, `new` becomes `NewEventBus`), methods hang off the +wrapper, statics become package-level functions prefixed by the type +name (`StoreDefaultCapacity`, `StoreOpenMany`), and `Close()` releases +the reference. From the `kvstore` sample (trimmed): ```go +// Each wrapper holds one strong reference; Close releases it, and a +// finalizer releases it if the wrapper is garbage collected first. type Store struct { ptr *C.weaveffi_kv_Store } -// OpenStore: Open (or create) a store backed by the given filesystem path +// wvAdoptStore adopts one owned strong reference into a new wrapper. A null +// pointer adopts to nil. +func wvAdoptStore(ptr *C.weaveffi_kv_Store) *Store { + if ptr == nil { + return nil + } + s := &Store{ptr: ptr} + runtime.SetFinalizer(s, (*Store).Close) + return s +} + +// wvTokenStore clones o's reference into a value-buffer object token. The +// wrapper keeps its own reference; the token carries the new one. +func wvTokenStore(o *Store) uint64 { + if o == nil || o.ptr == nil { + panic("weaveffi: nil or closed Store cannot be encoded in a non-optional position") + } + return uint64(uintptr(unsafe.Pointer(C.weaveffi_kv_Store_clone(o.ptr)))) +} + func OpenStore(path string) (*Store, error) { cPath := C.CString(path) defer C.free(unsafe.Pointer(cPath)) @@ -267,46 +339,53 @@ func OpenStore(path string) (*Store, error) { if cErr.code != 0 { return nil, wvMapKv(wvTakeError(&cErr)) } - return &Store{ptr: result}, nil + return wvAdoptStore(result), nil } -// Put: Insert or replace a value, returning true on success -func (s *Store) Put(key string, value []byte, kind EntryKind, ttlSeconds *int64) (bool, error) { /* ... */ } - -// Count: Return the number of live entries in the store -func (s *Store) Count() int64 { +// Share: A second reference to this same store (the returned pointer equals +// the receiver's; both must eventually be destroyed). +func (s *Store) Share() *Store { + if s.ptr == nil { + panic("weaveffi: Store used after Close") + } + defer runtime.KeepAlive(s) var cErr C.weaveffi_error - result := C.weaveffi_kv_Store_count(s.ptr, &cErr) + result := C.weaveffi_kv_Store_share(s.ptr, &cErr) wvTrap(&cErr) - return int64(result) + return wvAdoptStore(result) } -// Compact: Reclaim space asynchronously; returns the number of bytes reclaimed -// Blocks the calling goroutine until the async producer completes. -func (s *Store) Compact() (int64, error) { /* see Async support */ } - -// ListKeys: Stream every key, optionally filtered by a prefix -func (s *Store) ListKeys(prefix *string) iter.Seq2[string, error] { /* see Iterators */ } - -// LegacyPut: Legacy single-shot put kept for compatibility -// Deprecated: use put() with explicit kind -func (s *Store) LegacyPut(key string, value []byte) (bool, error) { /* ... */ } - -// StoreDefaultCapacity: The largest number of live entries one store will hold -func StoreDefaultCapacity() int64 { /* ... */ } - func (s *Store) Close() { if s.ptr != nil { C.weaveffi_kv_Store_destroy(s.ptr) s.ptr = nil + runtime.SetFinalizer(s, nil) } } ``` -Functions elsewhere in the IDL pass the wrapper's pointer across the -boundary. Deprecated members carry a standard `// Deprecated:` comment -that `go vet` and editors understand. Pair every interface wrapper with -`defer store.Close()`: +- **Construction.** Every constructor is a package-level function; a + throwing one returns `(*T, error)`, a non-throwing one returns `*T` + (`NewEventBus()` in the `events` sample). Deprecated members carry a + standard `// Deprecated:` comment that `go vet` and editors + understand. +- **Disposal.** `Close()` releases this wrapper's reference through the + `_destroy` symbol and clears the finalizer. It's idempotent. A + finalizer set at adoption releases the reference if the wrapper is + garbage collected first, but Go finalizers run on a non-deterministic + schedule, so pair every wrapper with `defer store.Close()`. The + producer object itself is dropped only when the last reference + anywhere is released. +- **Use after close.** Every method checks `s.ptr == nil` first and + panics with `"weaveffi: Store used after Close"`. Encoding a closed + (or nil) wrapper into a non-optional position of a value buffer panics + from `wvTokenStore`. +- **Copies mint new references.** Methods returning the receiver or an + existing object (`Share()`, `Fork()`) return a fresh strong reference + adopted into a new wrapper; closing one never affects another. + `runtime.KeepAlive` keeps the receiver (and object parameters) + reachable until the C call returns, so the finalizer can't fire + mid-call. ```go store, err := OpenStore("/tmp/cache.kv") @@ -318,36 +397,238 @@ ok, err := store.Put("alpha", []byte{1}, EntryKindPersistent, nil) fmt.Println(store.Count(), StoreDefaultCapacity()) ``` +### Nullable objects, and objects inside values + +An `Interface?` parameter is a nil-able pointer that crosses as NULL +when nil, and an `Interface?` return adopts a NULL pointer to nil +(`wvAdoptStore` handles both): + +```go +func (s *Store) Larger(other *Store) *Store { + if s.ptr == nil { + panic("weaveffi: Store used after Close") + } + defer runtime.KeepAlive(s) + defer runtime.KeepAlive(other) + var cOther *C.weaveffi_kv_Store + if other != nil { + cOther = other.ptr + } + var cErr C.weaveffi_error + result := C.weaveffi_kv_Store_larger(s.ptr, cOther, &cErr) + wvTrap(&cErr) + return wvAdoptStore(result) +} +``` + +Objects inside records, lists, maps, and optionals travel as `u64` +object tokens in the value buffer. Writing a token mints a new strong +reference with the `_clone` symbol (`wvTokenStore`); reading one adopts +the reference into a fresh wrapper (`wvUntokenStore`). From the +`StoreInfo` record (`Store *Store`, `Mirror *Store`): + +```go +func wvPackStoreInfo(w *wvWriter, v StoreInfo) { + w.writeString(v.Label) + w.writeU64(wvTokenStore(v.Store)) + if v.Mirror == nil { + w.writeOptionFlag(false) + } else { + w.writeOptionFlag(true) + w.writeU64(wvTokenStore(v.Mirror)) + } + w.writeI64(v.Count) +} + +func wvUnpackStoreInfo(r *wvReader) StoreInfo { + var v StoreInfo + v.Label = r.readString() + v.Store = wvUntokenStore(r.readU64()) + if r.readOptionFlag() { + var oMirror0 *Store + oMirror0 = wvUntokenStore(r.readU64()) + v.Mirror = oMirror0 + } + v.Count = r.readI64() + return v +} +``` + +Lists of objects work the same way in both directions +(`StoreOpenMany(paths)` returns `([]*Store, error)`, +`StoreTotalCount(stores, extra)` takes `[]*Store`); each wrapper in a +returned slice owns its own reference and should be closed individually. +Iterators over objects adopt one reference per step, and a blocking +async call returning an object adopts the pointer inside the completion +trampoline. + ## Rich (algebraic) enums A *rich* (algebraic) enum, a sum type whose variants carry associated data, crosses the C ABI as a serialized value buffer, exactly like a struct: an `i32` tag (the declared discriminant, or declaration order) -followed by the active variant's fields in order. There are no per-variant -C constructors, tag readers, getters, or destroy symbols, and the Go -surface is a plain value type with no `Close()`. (A plain C-style enum -with no payloads stays a typed `int32` alias with `const` values; see -above.) - -For the `shapes` module's `Shape` enum (`Empty`, `Circle { radius: f64 }`, -`Rectangle { width: f32, height: f32 }`, and -`Labeled { label: string, count: u8 }`), the bindings expose the variant -discriminants as package constants -(`ShapeEmpty`/`ShapeCircle`/`ShapeRectangle`/`ShapeLabeled`), a way to -construct each variant with its fields, and typed access to the active -variant's data. Values are plain Go data, so nothing needs to be freed: +followed by the active variant's fields in order. The Go surface is a +sealed interface plus one struct per variant, with no `Close()`. (A +plain C-style enum with no payloads stays a typed `int32` alias with +`const` values; see above.) From the `codec` sample: ```go -c := /* construct the Circle variant with radius 2.0 */ -fmt.Println(c.Tag() == ShapeCircle) // true +// Shape is a sealed sum type: exactly one of its variant structs is the +// value at a time. +type Shape interface { + isShape() +} + +// ShapeEmpty: No payload. +type ShapeEmpty struct{} + +func (ShapeEmpty) isShape() {} + +// ShapeCircle: One `f64`. +type ShapeCircle struct { + // Radius: Radius. + Radius float64 +} -bigger := Scale(c, 3.0) // returns a new Shape value -fmt.Println(Describe(bigger)) +func (ShapeCircle) isShape() {} + +// ShapeLabeled: A string and an `i32`. +type ShapeLabeled struct { + // Label: Label text. + Label string + // Count: Repeat count. + Count int32 +} + +func (ShapeLabeled) isShape() {} +``` + +Construct a variant as a struct literal and discriminate with a type +switch: + +```go +var c Shape = ShapeCircle{Radius: 2.0} +switch v := c.(type) { +case ShapeCircle: + fmt.Println(v.Radius) +case ShapeLabeled: + fmt.Println(v.Label, v.Count) +} ``` Free functions that take or return the enum pack it into a value buffer on the way in and unpack the returned buffer on the way out, releasing -the producer's bytes with `weaveffi_free_bytes`. +the producer's bytes with `weaveffi_free_bytes`. Variant fields of +interface type follow the object token rules above. + +## Callback interfaces + +A `callback_interfaces:` entry becomes a Go `interface` the consumer +implements and passes wherever the API takes that type. From the +`kvstore` sample: + +```go +// Implement this interface in Go and pass the value to native functions +// that accept it. +// +// The native library may call any method from any thread until it releases +// the implementation. A panic in a method is reported to the native caller +// as a foreign error (code -4) instead of crashing the process. +type EvictionListener interface { + // OnEvict: An entry left the store. Returns whether the listener wants to keep + // receiving notifications; `false` detaches it. + OnEvict(entry Entry, reason EvictionReason) bool +} +``` + +```go +type auditor struct{} + +func (auditor) OnEvict(entry kvstore.Entry, reason kvstore.EvictionReason) bool { + fmt.Println(entry.Key, reason) + return true +} + +store.SetEvictionListener(auditor{}) +``` + +cgo forbids passing Go pointers to C, so the implementation never +crosses the boundary directly. Passing one wraps it in a `cgo.Handle` +whose integer value crosses as `ctx` (widened to `void*` in C by the +preamble's `wvHandlePtr`, which keeps `go vet` quiet), together with the +address of one static vtable per interface whose slots are `//export`ed +Go trampolines: + +```go +//export goWv_weaveffi_kv_EvictionListener_on_evict +func goWv_weaveffi_kv_EvictionListener_on_evict(ctx unsafe.Pointer, entry_ptr *C.uint8_t, entry_len C.size_t, reason C.weaveffi_kv_EvictionReason, out_err *C.weaveffi_error) (ret C._Bool) { + defer func() { + if r := recover(); r != nil { + wvForeignError(out_err, r) + } + }() + impl := cgo.Handle(uintptr(ctx)).Value().(EvictionListener) + rArg0 := &wvReader{buf: wvBorrowBuffer(entry_ptr, entry_len)} + var arg0 Entry + arg0 = wvUnpackEntry(rArg0) + rArg0.expectEnd() + arg1 := EvictionReason(reason) + ret = boolToC(impl.OnEvict(arg0, arg1)) + return +} + +//export goWv_weaveffi_kv_EvictionListener_free +func goWv_weaveffi_kv_EvictionListener_free(ctx unsafe.Pointer) { + cgo.Handle(uintptr(ctx)).Delete() +} +``` + +```go +func (s *Store) SetEvictionListener(listener EvictionListener) { + if s.ptr == nil { + panic("weaveffi: Store used after Close") + } + defer runtime.KeepAlive(s) + hListener := cgo.NewHandle(listener) + var cErr C.weaveffi_error + C.weaveffi_kv_Store_set_eviction_listener(s.ptr, C.wvHandlePtr(C.uintptr_t(hListener)), C.wvVtablePtr_weaveffi_kv_EvictionListener(), &cErr) + wvTrap(&cErr) +} +``` + +- **Lifetime.** The `cgo.Handle` keeps the implementation alive exactly + as long as the producer may call it; the vtable's `free` trampoline + deletes the handle when the producer drops its last reference. A + producer that retains the implementation (a store's eviction listener) + keeps it alive across calls; one that doesn't (the `events` sample's + `RouteOnce`) frees it before returning. Passing the same value twice + creates two handles. +- **Argument ownership.** Borrowed strings and buffers are copied into + Go memory before the method runs (`wvBorrowBuffer`, `C.GoString`), so + the implementation may keep them. An object passed to a callback + method is owned by the implementation: the trampoline adopts it into a + new wrapper (`impl.OnAttached(wvAdoptEventBus(bus))` in the `events` + sample), and the implementation should `Close()` it when done (or let + the finalizer run). +- **Return values.** A method's return value is converted back to its C + representation (`bool` via `boolToC`, a plain enum to its C enum, a + record to a value buffer the producer frees). +- **Panics.** A panic escaping a method never unwinds through the C + frame. The deferred `recover()` hands the value to `wvForeignError`, + which writes code -4 with `fmt.Sprint(recovered)` into the producer's + error slot, and the trampoline returns its zero value; the producer + aborts the call in progress. The original caller then sees + `*WeaveFFIError` with `Code == -4` as the returned `error` (throwing + callable) or as the `wvTrap` panic value (non-throwing callable, the + trap idiom). The process is never taken down. +- **Threads.** The producer may call a method from any thread; cgo + callbacks run on whatever OS thread the producer fires them from, with + the Go runtime attaching a goroutine to it. The `kvstore` eviction + listener fires synchronously inside `Delete()`/`Get()`; the `events` + sample's `PublishLater` calls subscribers from the producer's worker + thread. Don't block in a callback waiting on the goroutine that made + the producer call if the producer invoked it synchronously from that + call. ## Build instructions @@ -363,9 +644,11 @@ the producer's bytes with `weaveffi_free_bytes`. cargo build --release -p your_library ``` -3. Point CGo at the header and library: +3. Point cgo at the header and library. The Go package `#include`s the + header emitted by the C target, so generate that too: ```bash + weaveffi generate api.yaml -o generated --target c export CGO_CFLAGS="-I$PWD/generated/c" export CGO_LDFLAGS="-L$PWD/target/release -lweaveffi" ``` @@ -377,8 +660,31 @@ the producer's bytes with `weaveffi_free_bytes`. go build ./... ``` -CGo requires a C compiler (`gcc` or `clang`) on the host; on Windows -use a MinGW-w64 toolchain or the MSVC build provided by `go env`. +cgo requires a C compiler (`gcc` or `clang`) on the host; on Windows use +a MinGW-w64 toolchain or the MSVC build provided by `go env`. Go 1.23 or +later is required for the `iter` package. + +## Packaging + +`weaveffi package --target go` emits the Go module under `go/` with a +self-contained, relocatable cgo preamble and copies each supplied +desktop binary to `go/lib//` (`macos-arm64`, `macos-x64`, +`linux-x64`, `linux-arm64`, `windows-x64`). The single +`#cgo LDFLAGS: -l` line is expanded into a `${SRCDIR}`-relative +include path for the packaged C header plus one `-L` (and, except on +Windows, `-Wl,-rpath`) directive per `GOOS,GOARCH` pair, so +`go build` selects the matching slice with no environment setup: + +```go +#cgo CFLAGS: -I${SRCDIR}/../c/include +#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/lib/macos-arm64 -Wl,-rpath,${SRCDIR}/lib/macos-arm64 +#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/lib/linux-x64 -Wl,-rpath,${SRCDIR}/lib/linux-x64 +#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/lib/windows-x64 +#cgo LDFLAGS: -lkvstore +``` + +Android and `wasm32` binaries are skipped. See +[Packaging](../guides/packaging.md) for the shared workflow. ## Memory and ownership @@ -387,118 +693,42 @@ use a MinGW-w64 toolchain or the MSVC build provided by `go env`. - **Strings out:** `C.GoString` copies the C string into Go-owned memory, then the wrapper calls `weaveffi_free_string` to release the Rust allocation. -- **Bytes:** input slices are passed by pointer for the duration of - the call (no copy); returned bytes are copied with `C.GoBytes` and - then `weaveffi_free_bytes` is called. +- **Bytes:** input slices are passed by pointer for the duration of the + call (no copy); returned bytes are copied with `C.GoBytes` and then + `weaveffi_free_bytes` is called. - **Buffered values (structs, rich enums, optionals, lists, maps):** - parameters are packed into a value buffer that the producer borrows - for the duration of the call; returns are copied into Go memory, - released with `weaveffi_free_bytes`, and decoded into the Go value. - Nothing to close afterward. -- **Interfaces:** wrappers hold a typed C pointer. Always pair with - `defer s.Close()` because Go has no deterministic destructors. - `Interface?` stays a nil-able pointer. - -## Callbacks and listeners - -A `callbacks:` entry in the IDL defines a C function-pointer type; a -`listeners:` entry generates a register/unregister pair around it: - -```yaml -modules: - - name: events - callbacks: - - name: OnMessage - params: - - { name: message, type: string } - listeners: - - name: message_listener - event_callback: OnMessage -``` - -The C ABI is `weaveffi_events_register_message_listener(callback, -void* context)`, which returns a `uint64_t` subscription id, plus -`weaveffi_events_unregister_message_listener(id)`. The Go surface -takes a closure and returns that id: - -```go -// Returns a subscription id for UnregisterMessageListener. -func RegisterMessageListener(callback func(message string)) uint64 { - ctxID := wvCallbackStore(callback) - id := uint64(C.weaveffi_events_register_message_listener( - C.weaveffi_events_OnMessage_fn(unsafe.Pointer(C.goWv_weaveffi_events_OnMessage_fn)), - unsafe.Pointer(uintptr(ctxID)))) - wvCallbackMu.Lock() - wvListenerCtx[id] = ctxID - wvCallbackMu.Unlock() - return id -} - -func UnregisterMessageListener(id uint64) { - C.weaveffi_events_unregister_message_listener(C.uint64_t(id)) - wvCallbackMu.Lock() - ctxID, ok := wvListenerCtx[id] - delete(wvListenerCtx, id) - wvCallbackMu.Unlock() - if ok { - wvCallbackDelete(ctxID) - } -} -``` - -CGo forbids passing Go pointers to C, so the closure itself never -crosses the boundary. The bindings keep a mutex-guarded registry -(`wvCallbacks`, written through `wvCallbackStore`) and hand C two -things: a `//export`ed trampoline (`goWv_weaveffi_events_OnMessage_fn`, -declared `extern` in the CGo preamble) as the function pointer, and -the registry key as the `void* context`, an integer id cast via -`unsafe.Pointer(uintptr(ctxID))` that the C side never dereferences. -When the event fires, the trampoline looks the closure up and calls it: - -```go -//export goWv_weaveffi_events_OnMessage_fn -func goWv_weaveffi_events_OnMessage_fn(message *C.char, context unsafe.Pointer) { - v := wvCallbackLoad(uint64(uintptr(context))) - if v == nil { - return - } - cb := v.(func(message string)) - arg0 := "" - if message != nil { - arg0 = C.GoString(message) - } - cb(arg0) -} -``` - -- **Subscription ids:** the native library mints the `uint64` id; pair - every register with exactly one unregister. Unregistering tears down - the native subscription, then uses `wvListenerCtx` (subscription id → - registry key) to delete the stored closure so it can be collected. A - leaked subscription pins the closure forever. -- **Threading:** the callback runs as a CGo callback on whatever thread -the producer fires it from (in the events sample, synchronously -inside `SendMessage`). Don't block in it; forward to a channel - or goroutine if handling is slow. + parameters are packed into a Go-owned `[]byte` that the producer + borrows for the duration of the call; returns are copied into Go + memory with `wvCopyBuffer`, released with `weaveffi_free_bytes`, and + decoded. Object tokens written into a buffer are fresh strong + references the producer owns; tokens read out are adopted into + wrappers. +- **Interfaces:** one strong reference per wrapper, released by + `Close()` with a `runtime.SetFinalizer` backstop. Always + `defer s.Close()`. +- **Callback implementations:** held by a `cgo.Handle` until the + producer calls the vtable's `free`. ## Async support Functions marked `async: true` are exposed through `_async`-suffixed C launchers that take a completion callback plus `void* context`. Go has no ambient async runtime, so the generated wrapper turns that into a -blocking call built on a channel: it makes a buffered channel, stores -it in the same callback registry the listener bindings use, launches -the C call with an exported trampoline and the integer context id, -then receives from the channel. The generated doc comment states that -the call blocks. From the `kvstore` sample: +blocking call built on a channel: it makes a buffered channel, wraps it +in a `cgo.Handle`, launches the C call with an exported trampoline and +the handle as context, then receives from the channel. The generated +doc comment states that the call blocks. From the `kvstore` sample: ```go -// Compact: Reclaim space asynchronously; returns the number of bytes reclaimed // Blocks the calling goroutine until the async producer completes. func (s *Store) Compact() (int64, error) { + if s.ptr == nil { + panic("weaveffi: Store used after Close") + } + defer runtime.KeepAlive(s) ch := make(chan wvOutcomeKvStoreCompact, 1) - ctxID := wvCallbackStore(ch) - C.weaveffi_kv_Store_compact_async(s.ptr, nil, C.weaveffi_kv_Store_compact_callback(unsafe.Pointer(C.goWv_weaveffi_kv_Store_compact_callback)), unsafe.Pointer(uintptr(ctxID))) + h := cgo.NewHandle(ch) + C.weaveffi_kv_Store_compact_async(s.ptr, nil, C.weaveffi_kv_Store_compact_callback(unsafe.Pointer(C.goWv_weaveffi_kv_Store_compact_callback)), C.wvHandlePtr(C.uintptr_t(h))) outcome := <-ch if outcome.err != nil { return 0, outcome.err @@ -508,23 +738,16 @@ func (s *Store) Compact() (int64, error) { ``` The completion callback fires exactly once, on a producer thread. The -trampoline removes the channel from the registry with `wvCallbackTake` -(one-shot), converts the C error or result inside the callback (result -buffers such as strings and buffered values are owned by the consumer, -so the trampoline copies or decodes them into Go memory and then -releases them with the runtime free symbols; a heap-boxed error is -read and released by `wvTakeBoxedError`; an owned interface result is -adopted into a wrapper instead), and sends a single `wvOutcome…` +trampoline takes the channel out of the handle (one-shot), converts the +C error or result inside the callback, and sends a single `wvOutcome…` value: ```go //export goWv_weaveffi_kv_Store_compact_callback func goWv_weaveffi_kv_Store_compact_callback(context unsafe.Pointer, err *C.weaveffi_error, result C.int64_t) { - v := wvCallbackTake(uint64(uintptr(context))) - if v == nil { - return - } - ch := v.(chan wvOutcomeKvStoreCompact) + h := cgo.Handle(uintptr(context)) + ch := h.Value().(chan wvOutcomeKvStoreCompact) + h.Delete() if err != nil && err.code != 0 { ch <- wvOutcomeKvStoreCompact{err: wvMapKv(wvTakeBoxedError(err))} return @@ -533,52 +756,66 @@ func goWv_weaveffi_kv_Store_compact_callback(context unsafe.Pointer, err *C.weav } ``` +Result buffers such as strings and buffered values are owned by the +consumer, so the trampoline copies or decodes them into Go memory and +then releases them with the runtime free symbols; a heap-boxed error is +read and released by `wvTakeBoxedError` (`weaveffi_error_free`); an +owned interface result is adopted into a wrapper instead. + For a callable marked `throws: true`, the trampoline maps the error through the domain mapper, so the returned `error` is the typed one -(`*KvError` from `store.Compact()`). The native producer already runs -on its own thread, so the wrapper simply blocks the calling goroutine; -callers that want concurrency run the call from a goroutine of their -own. +(`*KvError` from `store.Compact()`); a non-throwing async callable +returns a plain value and panics via `wvTrap` on a trap code. A panic +inside the spawned future surfaces as code -2. The native producer +already runs on its own thread, so the wrapper simply blocks the calling +goroutine; callers that want concurrency run the call from a goroutine +of their own. For functions marked `cancellable: true` the C launcher gains a `weaveffi_cancel_token*` parameter. The Go wrapper passes `nil` for it -and doesn't expose the token; only the C and C++ targets -surface cancellation tokens. +and doesn't expose the token; only the C and C++ targets surface +cancellation tokens. ## Iterators `iter` returns map to the standard library's range-over-function -sequences (Go 1.23+): a non-throwing function returns `iter.Seq[T]` -and a throwing one returns `iter.Seq2[T, error]`. Nothing is drained: -the producer iterator is launched when the consumer starts ranging, -and each consumer step issues exactly one producer `next` call. From -the `events` sample: +sequences (Go 1.23+): a non-throwing function returns `iter.Seq[T]` and +a throwing one returns `iter.Seq2[T, error]`. Nothing is drained: the +producer iterator is launched when the consumer starts ranging, and each +consumer step issues exactly one producer `next` call. From the +`kvstore` sample's throwing `ListKeys`: ```go -// GetMessages: Return an iterator over all sent messages -// Returns a lazy sequence: the producer iterator is launched on first -// iteration and one producer next call runs per element. The iterator is -// destroyed exactly once, whether the sequence is exhausted or abandoned -// early; each range over the sequence launches a fresh producer iterator. -// A reported error can only be a producer bug and panics with the -// weaveffi message. -func GetMessages() iter.Seq[string] { - return func(yield func(string) bool) { +// A launch or per-element error is yielded as the final (zero value, +// error) pair, and iteration stops. +func (s *Store) ListKeys(prefix *string) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + if s.ptr == nil { + panic("weaveffi: Store used after Close") + } + defer runtime.KeepAlive(s) + // ... pack the *string prefix into a value buffer ... var cErr C.weaveffi_error - it := C.weaveffi_events_get_messages(&cErr) - wvTrap(&cErr) - defer C.weaveffi_events_GetMessagesIterator_destroy(it) + it := C.weaveffi_kv_Store_list_keys(s.ptr, cPrefixPtr, C.size_t(len(wPrefix.buf)), &cErr) + if cErr.code != 0 { + yield("", wvMapKv(wvTakeError(&cErr))) + return + } + defer C.weaveffi_kv_Store_ListKeysIterator_destroy(it) for { var outItem *C.char var iterErr C.weaveffi_error - ok := C.weaveffi_events_GetMessagesIterator_next(it, &outItem, &iterErr) != 0 - wvTrap(&iterErr) + ok := C.weaveffi_kv_Store_ListKeysIterator_next(it, &outItem, &iterErr) != 0 + if iterErr.code != 0 { + yield("", wvMapKv(wvTakeError(&iterErr))) + return + } if !ok { return } item := C.GoString(outItem) C.weaveffi_free_string(outItem) - if !yield(item) { + if !yield(item, nil) { return } } @@ -586,53 +823,51 @@ func GetMessages() iter.Seq[string] { } ``` -Each yielded element is copied into Go memory and its Rust allocation -released per element (strings via `weaveffi_free_string`; buffered -elements arrive as value buffers that are decoded and released with -`weaveffi_free_bytes`). The deferred `_destroy` call -runs exactly once, whether the consumer exhausts the sequence or -breaks out of the `for range` loop early. Ranging over the same -returned sequence again launches a fresh producer iterator. - -A throwing function yields errors in-band as the second value of the -`iter.Seq2` pair: a launch or per-element failure is mapped through -the domain mapper, yielded as the final `(zero value, error)` pair, -and iteration stops. From the `kvstore` sample: - -```go -// ListKeys: Stream every key, optionally filtered by a prefix -// ... -// A launch or per-element error is yielded as the final (zero value, -// error) pair, and iteration stops. -func (s *Store) ListKeys(prefix *string) iter.Seq2[string, error] { - return func(yield func(string, error) bool) { - // ... launch weaveffi_kv_Store_list_keys ... - if cErr.code != 0 { - yield("", wvMapKv(wvTakeError(&cErr))) - return - } - defer C.weaveffi_kv_Store_ListKeysIterator_destroy(it) - for { - // ... one _next call per step; errors yield ("", err) and stop ... - } - } -} -``` - Consume it with `for key, err := range store.ListKeys(nil)`, checking -`err` on each step. In a non-throwing sequence such as `GetMessages`, -a reported error can only be a producer bug, so `wvTrap` panics -instead of yielding it. +`err` on each step. Each yielded element is copied into Go memory and +its Rust allocation released per element (strings via +`weaveffi_free_string`; buffered elements are decoded and released with +`weaveffi_free_bytes`; object elements are adopted into wrappers). The +deferred `_destroy` call runs exactly once, whether the consumer +exhausts the sequence or breaks out of the `for range` loop early. +Ranging over the same returned sequence again launches a fresh producer +iterator. + +In a non-throwing sequence (the `events` sample's `Messages()`), a +reported error can only be a producer bug or a panicking callback, so +`wvTrap` panics instead of yielding it. + +## Known limitations + +- Async functions block the calling goroutine; there's no channel- or + context-based variant, and `cancellable: true` tokens are not exposed. +- Callback methods run on the producer's thread as cgo callbacks; there + is no marshalling to a particular goroutine. +- Non-throwing wrappers panic on the runtime trap codes (including a + panicking callback, code -4) rather than returning an `error`. +- The generated module needs Go 1.23+ (`iter`, `range` over functions) + and a C toolchain for cgo. +- The plain `generate` output expects `weaveffi.h` from the C target and + the library on `CGO_CFLAGS`/`CGO_LDFLAGS`; only `weaveffi package` + produces a relocatable module. ## Troubleshooting -- **`undefined reference to weaveffi_*`**: `CGO_LDFLAGS` is missing - the `-l` flag or `-L` directory. Recheck the environment exports. -- **`could not determine kind of name` in CGo**: ensure - `CGO_CFLAGS` points at the directory containing `weaveffi.h`. -- **Crashes after an interface object goes out of scope**: Go doesn't - call `Close()` for you. Either `defer s.Close()` or wrap usage in a - helper that takes a closure. -- **`go: cannot find module providing package weaveffi`**: change - the generator config so `go.mod` declares the module path you - actually import, e.g. `github.com/myorg/mylib`. +- **`undefined reference to weaveffi_*`**: `CGO_LDFLAGS` is missing the + `-l` flag or `-L` directory. Recheck the environment exports. +- **`could not determine kind of name` in cgo**: ensure `CGO_CFLAGS` + points at the directory containing `weaveffi.h` (generate the C + target alongside Go). +- **`panic: WeaveFFI ABI mismatch`** at startup: the library was built + by a different `weaveffi` release than the bindings. Regenerate the + bindings and rebuild the library together. +- **`panic: weaveffi: Store used after Close`**: a closed wrapper was + used. Keep the wrapper alive for as long as the object is in use; + closing one wrapper doesn't affect others pointing at the same object. +- **`weaveffi: ... (code -4)`** returned or panicked: a + callback-interface method you implemented panicked; the message is + the panic value. Recover inside the method if the producer call should + succeed anyway. +- **`go: cannot find module providing package weaveffi`**: change the + generator config so `go.mod` declares the module path you actually + import, e.g. `github.com/myorg/mylib`. diff --git a/docs/src/generators/kotlin.md b/docs/src/generators/kotlin.md new file mode 100644 index 00000000..53d3277e --- /dev/null +++ b/docs/src/generators/kotlin.md @@ -0,0 +1,812 @@ +# Kotlin + +## Overview + +The Kotlin target produces a Gradle library module (`kotlin/`, Kotlin DSL) +that combines a Kotlin wrapper, a JNI C shim, and a CMake build for the +shim. Android is the primary runtime (the module is an Android library and +the shim builds through the NDK's CMake), and the same sources run on a +desktop JVM when the shim is built against a JDK. The wrapper exposes +idiomatic Kotlin: data classes and sealed classes for values, +`AutoCloseable` wrappers for reference-counted objects, Kotlin `interface`s +for callback interfaces, `suspend fun`s for async callables, and lazy +`Iterator`s. The surface follows ABI revision 2; `JNI_OnLoad` checks the +producer's `weaveffi_abi_version()` and refuses to load a mismatched +library with `UnsatisfiedLinkError`. + +## What gets generated + +| File | Purpose | +|------|---------| +| `generated/kotlin/settings.gradle.kts` | Gradle settings naming the module after the package | +| `generated/kotlin/build.gradle.kts` | `com.android.library` plugin, NDK/CMake config, `kotlinx-coroutines-core` dependency | +| `generated/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt` | Kotlin wrapper: enums, data classes, object wrappers, callback interfaces, iterators, buffer codec | +| `generated/kotlin/src/main/cpp/weaveffi_jni.c` | JNI shim that calls the C ABI, throws Kotlin exceptions, and hosts the callback vtables | +| `generated/kotlin/src/main/cpp/CMakeLists.txt` | CMake build for the JNI shared library | + +The generated `build.gradle.kts`: + +```kotlin +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.weaveffi" + compileSdk = 34 + defaultConfig { + // java.lang.ref.Cleaner, which backs every wrapper's disposal, is API 33. + minSdk = 33 + externalNativeBuild { + cmake { + cppFlags("") + } + } + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") +} +``` + +## Configuration + +| Key | Default | Meaning | +|-----|---------|---------| +| `[generators.kotlin] package` | `"com.weaveffi"` | JVM package for the wrapper and the `namespace` in `build.gradle.kts` | +| `[generators.kotlin] strip_module_prefix` | `true` | Strip the IR module prefix from function names (`createContact` rather than `contactsCreateContact`) | +| `[generators.kotlin] prefix` | `"weaveffi"` | C ABI symbol prefix; normally set once via `[global] c_prefix` | + +See [Configuration](../guides/config.md) for the full table. + +## Type mapping + +| IDL type | Kotlin type (wrapper) | JNI lowering | Notes | +|----------------|------------------------|-----------------------|-------| +| `i8` | `Byte` | `jbyte` | | +| `i16` | `Short` | `jshort` | | +| `i32` | `Int` | `jint` | | +| `i64` | `Long` | `jlong` | | +| `u8` | `Byte` | `jbyte` | Same bit pattern; values above 127 read negative | +| `u16` | `Short` | `jshort` | Same bit pattern; values above 32767 read negative | +| `u32` | `Long` | `jlong` | Widened; always non-negative | +| `u64` | `Long` | `jlong` | Same bit pattern; values above `2^63 - 1` read negative (`toULong()` recovers them) | +| `f32` | `Float` | `jfloat` | Raw bits; NaN, infinities, `-0.0` preserved | +| `f64` | `Double` | `jdouble` | Raw bits; NaN, infinities, `-0.0` preserved | +| `bool` | `Boolean` | `jboolean` | | +| `string` | `String` | `jstring` | Modified-UTF-8-safe conversion in the shim | +| `bytes` | `ByteArray` | `jbyteArray` | | +| `StructName` | `StructName` (data class) | `jbyteArray` (value buffer) | | +| `InterfaceName` | `InterfaceName` (`AutoCloseable` class) | `jlong` handle | See [Objects](#objects-interfaces) | +| `InterfaceName?` | `InterfaceName?` | `Long?` | Null pointer | +| `CallbackName` | `interface CallbackName` | `jobject` pinned by a global ref | See [Callback interfaces](#callback-interfaces) | +| `EnumName` (plain) | `EnumName` (`enum class`) | `jint` | | +| `EnumName` (rich) | `EnumName` (sealed class) | `jbyteArray` (value buffer) | | +| `T?` | `T?` | `jbyteArray` (value buffer) | | +| `[T]` | `List` | `jbyteArray` (value buffer) | | +| `{K: V}` | `Map` | `jbyteArray` (value buffer) | | +| `iter` | `Iterator` (lazy wrapper class) | `jlong` iterator handle | | + +The JVM has no unsigned primitives, so the wrapper carries `u8`, `u16`, +and `u64` as the same-width signed type with the identical bit pattern +(the `codec` sample's `roundtripU64(value: Long): Long` round-trips values +above `2^63` as negative `Long`s). Convert with `toUByte()`, `toUShort()`, +and `toULong()` when you need the unsigned view. Floats are written to and +read from value buffers with `toRawBits()`/`fromBits()`, so NaN payloads, +the infinities, and `-0.0` survive. + +## Example IDL → generated code + +```yaml +version: "0.9.0" +modules: + - name: contacts + enums: + - name: ContactType + variants: + - { name: Personal, value: 0 } + - { name: Work, value: 1 } + - { name: Other, value: 2 } + + structs: + - name: Contact + fields: + - { name: name, type: string } + - { name: age, type: i32 } + + functions: + - name: get_contact + params: + - { name: id, type: i32 } + return: Contact + + - name: find_by_type + params: + - { name: contact_type, type: ContactType } + return: "[Contact]" +``` + +Free functions live on the `WeaveFFI` companion object. Function names +are lowerCamelCase with the module prefix stripped by default. Where a +parameter or return value needs wrapping (enums, buffered values), the +external entry is a private `...Jni` function with lowered types and a +public wrapper converts at the boundary. A buffered value crosses JNI as a +`ByteArray` copy of its value buffer, encoded and decoded by the +`WeaveBufferWriter`/`WeaveBufferReader` codec in the same file: + +```kotlin +package com.weaveffi + +class WeaveFFI { + companion object { + init { WeaveNativeLibrary.ensureLoaded() } + + @JvmStatic private external fun getContactJni(id: Int): ByteArray + @JvmStatic fun getContact(id: Int): Contact = weaveDecode(getContactJni(id)) { r -> unpackContact(r) } + @JvmStatic private external fun findByTypeJni(contactType: Int): ByteArray + @JvmStatic fun findByType(contactType: ContactType): List = + weaveDecode(findByTypeJni(contactType.value)) { r -> r.readList { unpackContact(r) } } + } +} +``` + +Enums become Kotlin `enum class` with a `fromValue` factory: + +```kotlin +enum class ContactType(val value: Int) { + Personal(0), + Work(1), + Other(2); + + companion object { + fun fromValue(value: Int): ContactType = entries.first { it.value == value } + } +} +``` + +Structs are plain Kotlin data classes with one typed property per field +(field names keep their IDL spelling). They own no native resources: no +handle, no `AutoCloseable`, no per-struct JNI symbols. A `Contact` crosses +the boundary serialized in the +[value-buffer format](../reference/value-buffers.md): + +```kotlin +/** A contact record */ +data class Contact( + val name: String, + val age: Int, +) + +internal fun packContact(w: WeaveBufferWriter, v: Contact) { + w.writeString(v.name) + w.writeI32(v.age) +} + +internal fun unpackContact(r: WeaveBufferReader): Contact = Contact( + r.readString(), + r.readI32(), +) +``` + +The JNI shim (`weaveffi_jni.c`) bridges each `external fun` into the C +ABI. Strings are converted with a standard-UTF-8 helper pair +(`weaveffi_jni_string_to_utf8`/`weaveffi_jni_utf8_to_string`), so +supplementary characters survive; returned value buffers +are copied into a `byte[]` and released with `weaveffi_free_bytes`: + +```c +JNIEXPORT jbyteArray JNICALL Java_com_weaveffi_WeaveFFI_getContactJni(JNIEnv* env, jclass clazz, jint id) { + weaveffi_error err = {0, NULL, NULL, 0}; + size_t out_len = 0; + const uint8_t* rv = weaveffi_contacts_get_contact((int32_t)id, &out_len, &err); + if (err.code != 0) { + throw_weaveffi_error(env, &err); + return NULL; + } + jbyteArray out = (*env)->NewByteArray(env, (jsize)out_len); + (*env)->SetByteArrayRegion(env, out, 0, (jsize)out_len, (const jbyte*)rv); + weaveffi_free_bytes(rv, out_len); + return out; +} +``` + +The CMake file builds the shim against the generated C header: + +```cmake +cmake_minimum_required(VERSION 3.22) +project(weaveffi_jni C) +add_library(weaveffi SHARED weaveffi_jni.c) +target_include_directories(weaveffi PRIVATE ../../../../c) +``` + +## Typed errors + +Every generated file carries the generic +`open class WeaveFFIException(val code: Int, message: String)`. A module's +[error domain](../guides/errors.md) adds a sealed exception hierarchy +named after the domain with the trailing `Error` stem replaced by +`Exception` (`KvError` becomes `KvException`), one nested class per code, +and a `fromCode` mapper. From the `kvstore` sample: + +```kotlin +/** Generic WeaveFFI failure: panics, marshalling errors, callback implementation failures (code -4), and unknown codes. */ +open class WeaveFFIException(val code: Int, message: String) : Exception(message) + +/** Typed error domain `KvError` declared by module `kv`. */ +sealed class KvException(code: Int, message: String) : WeaveFFIException(code, message) { + class KeyNotFound(message: String = "key not found") : KvException(1001, message) + class Expired(message: String = "entry expired") : KvException(1002, message) + class StoreFull(message: String = "store has reached capacity") : KvException(1003, message) + class IoError(message: String = "I/O failure") : KvException(1004, message) + + companion object { + @JvmStatic fun fromCode(code: Int, message: String, payload: ByteArray?): WeaveFFIException = when (code) { + 1001 -> KeyNotFound(message) + 1002 -> Expired(message) + 1003 -> StoreFull(message) + 1004 -> IoError(message) + else -> WeaveFFIException(code, message) + } + } +} +``` + +A callable with `throws: true` throws the matching subclass from its JNI +shim (a per-domain thrower resolves `com/weaveffi/KvException$KeyNotFound` +and friends by code); catch the specific class, the sealed domain, or the +generic base: + +```kotlin +try { + store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null) +} catch (e: KvException.StoreFull) { + // typed case +} catch (e: KvException) { + // any kv domain error +} catch (e: WeaveFFIException) { + // runtime code (negative) or unknown code +} +``` + +A callable without `throws` keeps a plain signature; the shim still checks +the error slot and throws the generic `WeaveFFIException` for a runtime +code. An error code that declares payload `fields:` carries them in the +error's payload buffer; the shim passes the bytes up and `fromCode` decodes +them into properties on the typed exception. + +### Runtime error codes + +Negative codes are never domain codes and always arrive as the generic +`WeaveFFIException`: + +| Code | ABI name | When | +|------|----------|------| +| -1 | `GENERIC_ERROR_CODE` | The producer reported a failure with no domain code | +| -2 | `PANIC_ERROR_CODE` | The Rust producer panicked inside an export or a spawned async future; also used by the Kotlin buffer reader for a malformed value buffer | +| -3 | `MARSHAL_ERROR_CODE` | A null object or a malformed value buffer or string was rejected at the boundary | +| -4 | `FOREIGN_ERROR_CODE` | A callback-interface implementation threw | + +They surface as a thrown exception from a sync call, as the exception a +`suspend fun` resumes with, or from the `hasNext()`/`next()` step of an +iterator. Kotlin has no separate trap path: a non-throwing callable throws +`WeaveFFIException` just like a throwing one. + +## Objects (interfaces) + +An `interfaces:` entry becomes a class that holds one strong native +reference and implements `AutoCloseable`. Constructors become companion +factories (a constructor named `new` becomes `operator fun invoke`, so +`EventBus()` reads like a real constructor), methods are instance +functions, and statics are companion functions. From the `kvstore` +sample's `Store`: + +```kotlin +class Store private constructor(handle: Long) : AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + + /** The native pointer, borrowed for one call; throws after [close]. */ + internal val handle: Long get() = ref.get() + + /** A new strong reference, written into value buffers as an object token. */ + internal fun cloneHandle(): Long = nativeClone(ref.get()) + + companion object { + init { WeaveNativeLibrary.ensureLoaded() } + + /** Adopts one strong native reference (a constructor return, a `_clone`, or a buffer token) into a wrapper that owes its `_destroy`. */ + internal fun fromHandle(handle: Long): Store = Store(handle) + @JvmStatic private external fun nativeOpen(path: String): Long + @JvmStatic private external fun nativeDelete(selfHandle: Long, key: String): Boolean + @JvmStatic private external fun nativeClone(handle: Long): Long + @JvmStatic private external fun nativeDestroy(handle: Long) + + fun open(path: String): Store = Store.fromHandle(nativeOpen(path)) + fun defaultCapacity(): Long = nativeDefaultCapacity() + } + + fun delete(key: String): Boolean = nativeDelete(handle, key) + fun count(): Long = nativeCount(handle) + fun listKeys(prefix: String?): Iterator = KvStoreListKeysIterator(nativeListKeys(handle, /* ... */)) + suspend fun compact(): Long = suspendCancellableCoroutine { cont -> /* ... */ } + + @Deprecated("use put() with explicit kind") + fun legacyPut(key: String, value: ByteArray): Boolean = nativeLegacyPut(handle, key, value) + + /** Releases this wrapper's native reference; safe to call more than once. */ + override fun close() { + cleanable.clean() + } +} +``` + +```kotlin +Store.open("/tmp/cache.kv").use { store -> + store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null) + println(store.count()) +} +``` + +- **Disposal is `close()` plus a `Cleaner` backstop.** `close()` releases + the wrapper's reference with `_destroy` exactly once; calling it again + is a no-op. A wrapper that becomes unreachable without being closed is + released by the process-wide `java.lang.ref.Cleaner` (which is why + `minSdk` is 33). Prefer `use { }` or an explicit `close()`; the cleaner + is a safety net, not deterministic cleanup. +- **Use after close throws.** Every native call borrows the pointer + through `ref.get()`, which throws `IllegalStateException("WeaveFFI + object used after close()")` once the reference has been released. +- **Clones mint a new strong reference.** `cloneHandle()` calls `_clone` + whenever the wrapper must hand the producer a reference it will own; + each wrapper adopted from a return or a buffer token owes its own + `_destroy`. Two wrappers over the same producer object (the sample's + `share()`, or the bus handed to `onAttached`) are independent: closing + one leaves the other usable. + +### Objects as parameters, returns, and inside values + +A top-level object parameter is borrowed for the call (the JNI external +takes `handle`); a returned object is adopted with `fromHandle`. `Store?` +is a `Long?` at the JNI layer, null for absent: + +```kotlin +fun larger(other: Store?): Store? = nativeLarger(handle, other?.handle)?.let { Store.fromHandle(it) } +``` + +Objects inside records, lists, map values, optionals, and rich-enum +payloads are ordinary properties (`val store: Store, val mirror: Store?` +in `StoreInfo`). On the wire they're `u64` tokens: the pack routine writes +a fresh `cloneHandle()` for each one, and the unpack routine adopts the +token: + +```kotlin +internal fun packStoreInfo(w: WeaveBufferWriter, v: StoreInfo) { + w.writeString(v.label) + w.writeI64(v.store.cloneHandle()) + w.writeOptional(v.mirror) { v0 -> w.writeI64(v0.cloneHandle()) } + w.writeI64(v.count) +} + +internal fun unpackStoreInfo(r: WeaveBufferReader): StoreInfo = StoreInfo( + r.readString(), + Store.fromHandle(r.readObject()), + r.readOptional { Store.fromHandle(r.readObject()) }, + r.readI64(), +) +``` + +`Store.openMany(paths)` returns `List` (one adopted wrapper per +element, each of which you should close), and +`Store.totalCount(stores, extra)` clones each object it encodes. An async +callable returning an object resumes with the raw handle, which the +suspend wrapper adopts; an `iter` adopts one per step. + +## Callback interfaces + +A `callback_interfaces:` entry becomes a Kotlin `interface` with one +function per IDL method. Implement it in any class and pass the instance +wherever the API expects the interface. From the `events` sample: + +```kotlin +interface Subscriber { + /** Decide how the bus should treat `topic` for this subscriber. */ + fun route(topic: String): Delivery + /** Receive an accepted message. Returns the subscriber's running count of received messages. */ + fun onMessage(message: Message): Long + /** Receive the bus itself (an object handed through a callback). The consumer adopts the reference and may keep or drop it. */ + fun onAttached(bus: EventBus) +} + +/** JNI dispatch shims for [Subscriber]; the native vtable trampolines call these, nothing else does. */ +internal object SubscriberJni { + @JvmStatic fun route(impl: Subscriber, topic: String): Int = impl.route(topic).value + @JvmStatic fun onMessage(impl: Subscriber, message: ByteArray): Long = impl.onMessage(weaveDecode(message) { r -> unpackMessage(r) }) + @JvmStatic fun onAttached(impl: Subscriber, bus: Long) = impl.onAttached(EventBus.fromHandle(bus)) +} +``` + +```kotlin +class LoggingSubscriber : Subscriber { + var keptBus: EventBus? = null + + override fun route(topic: String) = if (topic == "quiet") Delivery.Skip else Delivery.Accept + override fun onMessage(message: Message): Long { + println("${message.topic}: ${message.text}") + return 1 + } + override fun onAttached(bus: EventBus) { + keptBus = bus // or bus.close() to release it now + } +} + +val bus = EventBus() +bus.subscribe(LoggingSubscriber()) +``` + +In the shim, the implementation is pinned with `NewGlobalRef` and that +reference is the vtable `ctx`. `JNI_OnLoad` caches the `SubscriberJni` +class and its static method ids, and one process-wide static vtable of C +trampolines dispatches every call: + +```c +static weaveffi_events_Delivery weaveffi_events_Subscriber_jni_route(void* ctx, const char* topic, weaveffi_error* out_err) { + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { + weaveffi_error_set(out_err, -4, "WeaveFFI: could not attach the calling thread to the JavaVM"); + return 0; + } + if ((*env)->PushLocalFrame(env, 16) != 0) { /* ... -4 ... */ } + jstring _a0 = weaveffi_jni_utf8_to_string(env, topic); + jint _rv = (*env)->CallStaticIntMethod(env, weaveffi_events_Subscriber_jni_cls, weaveffi_events_Subscriber_jni_mid_route, (jobject)ctx, _a0); + if ((*env)->ExceptionCheck(env)) { + weaveffi_jni_report_foreign(env, out_err); + _rv = (jint)0; + } + (*env)->PopLocalFrame(env, NULL); + weaveffi_jni_detach(attached); + return (weaveffi_events_Delivery)_rv; +} + +static void weaveffi_events_Subscriber_jni_free(void* ctx) { + if (ctx == NULL) { return; } + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env != NULL) { (*env)->DeleteGlobalRef(env, (jobject)ctx); } + weaveffi_jni_detach(attached); +} +``` + +- **Argument ownership.** Strings and buffered values are copied into + Kotlin objects before your method runs. An object argument + (`bus: EventBus`) arrives as an adopted wrapper your implementation + owns: keep it, or `close()` it to release the reference. If you neither + keep nor close it, the `Cleaner` releases it eventually. +- **Lifetime.** The global ref keeps your implementation reachable for as + long as the producer holds it (`subscribe` retains it until + `clearSubscribers()` or the bus is dropped); the vtable's `free` deletes + the ref, after which the object is collectable. A function that only + uses the interface for the call (`WeaveFFI.routeOnce`) frees it before + returning. +- **Exceptions.** A Kotlin exception thrown by a callback method never + unwinds through the native frame. The trampoline clears it, calls + `Throwable.toString()` for the message, and reports + `weaveffi_error_set(out_err, -4, ...)`. The producer aborts the call + that triggered the callback, and the original caller (sync, `suspend`, + or iterator step) sees `WeaveFFIException` with `code == -4` and a + message like `java.lang.IllegalStateException: ...`. The implementation + stays attached. `setCallbackExceptionHandler` doesn't apply here; it + only covers exceptions thrown while an async result is delivered. +- **Threads.** The producer may call from any thread. The trampoline + attaches a non-JVM thread to the `JavaVM` for the duration of the call + and detaches it afterward, so a callback invoked from a producer worker + (`publishLater` in the sample) runs on that worker with a fresh + `JNIEnv`. Nothing hops to the main thread; use + `withContext(Dispatchers.Main)` or a `Handler` yourself. + +## Rich (algebraic) enums + +A *rich* (algebraic) enum, a sum type whose variants carry associated +data, becomes an idiomatic Kotlin sealed class: one subclass (or object, +for a unit variant) per variant carrying that variant's fields. Rich +enums own no native resources and declare no JNI symbols. (A plain +C-style enum with no payloads stays a Kotlin `enum class` backed by an +`Int`; see above.) + +For the `shapes` module's `Shape` enum (`Empty`, `Circle { radius: f64 }`, +`Rectangle { width: f32, height: f32 }`, and +`Labeled { label: string, count: u8 }`), the surface follows this shape: + +```kotlin +/** An algebraic shape (sum type with associated data) */ +sealed class Shape { + /** The empty shape */ + object Empty : Shape() + /** A circle with a radius */ + data class Circle(val radius: Double) : Shape() + /** An axis-aligned rectangle */ + data class Rectangle(val width: Float, val height: Float) : Shape() + /** A labeled shape with a small count */ + data class Labeled(val label: String, val count: Byte) : Shape() +} +``` + +On the wire a `Shape` is a value buffer holding the `i32` variant tag +followed by the active variant's fields; the wrapper layer packs and +decodes the `ByteArray` at the JNI boundary. Construct variants directly +and branch with an exhaustive `when`: + +```kotlin +val c = Shape.Circle(2.0) +when (c) { + is Shape.Circle -> println(c.radius) // 2.0 + else -> {} +} +val bigger = WeaveFFI.scale(c, 3.0) // returns a new Shape value +println(WeaveFFI.describe(bigger)) +``` + +Values are plain Kotlin data: there's no handle and nothing to close. A +payload that holds an object follows the token rules above. + +## Build instructions + +### Android + +1. Install Android Studio plus the NDK. +2. Cross-compile the Rust cdylib for every Android ABI you support: + + ```bash + rustup target add aarch64-linux-android x86_64-linux-android + export ANDROID_NDK_HOME=/path/to/ndk + cargo ndk -t arm64-v8a -t x86_64 build --release -p your_library + ``` + +3. Place each `libyour_library.so` under `src/main/jniLibs//`, open + `generated/kotlin` in Android Studio, sync Gradle, and build the AAR + (`./gradlew :kvstore:assemble`). The NDK's CMake builds the JNI shim + (`libweaveffi.so`) against the generated C header in the sibling `c/` + output, so generate both targets. +4. Add the resulting AAR as a dependency in your app module. + +### Desktop JVM + +Compile the shim against a JDK and link it to the producer cdylib, then +compile `WeaveFFI.kt` with your sources against `kotlinx-coroutines-core` +and run with the shim on `java.library.path`. This is what +`conformance/run.sh` does: + +```bash +cc -shared -fPIC generated/kotlin/src/main/cpp/weaveffi_jni.c \ + -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" -Igenerated/c \ + -Ltarget/debug -lkvstore -Wl,-rpath,target/debug \ + -o build/libweaveffi.dylib # .so on Linux + +kotlinc generated/kotlin/src/main/kotlin/com/weaveffi/WeaveFFI.kt Main.kt \ + -cp kotlinx-coroutines-core-jvm.jar -include-runtime -d build/app.jar + +java -Djava.library.path=build -cp build/app.jar:kotlinx-coroutines-core-jvm.jar Main +``` + +`WeaveNativeLibrary` calls `System.loadLibrary("weaveffi")` once, from the +first companion `init` that runs. + +## Packaging + +`weaveffi package --target kotlin` assembles an AAR-style module that is +self-contained: the shim compiles against a bundled copy of the C header +(`src/main/cpp/include/.h`), and the prebuilt producer libraries +land in two places: + +- Android slices under `src/main/jniLibs//lib.so` + (`arm64-v8a`, `x86_64`), which the packaged `CMakeLists.txt` links as an + imported target per `ANDROID_ABI` and Gradle bundles through + `sourceSets { getByName("main") { jniLibs.srcDirs("src/main/jniLibs") } }`. +- Desktop slices under `src/main/resources/natives//` + (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, + `windows-x64`). Build the shim once per platform with + `cmake -S src/main/cpp -B build -DWEAVEFFI_PLATFORM_ID=darwin-arm64 && + cmake --build build`; the packaged loader picks `natives/-/` + from `os.name` and `os.arch`, extracts the producer library and the shim + from the classpath, and falls back to `System.loadLibrary` when the + resources are absent. + +`wasm32` binaries are skipped. Kotlin is the one target besides Wasm that +bundles a non-desktop platform. See +[Packaging and Distribution](../guides/packaging.md). + +## Memory and ownership + +- Object wrappers own one strong reference, released by `close()` or the + `Cleaner`. Structs and rich enums are plain Kotlin values. +- Strings returned from JNI are fresh Java strings; the shim frees the + Rust pointer with `weaveffi_free_string` before returning. String + parameters are converted to heap UTF-8 for the call and freed afterward. +- Byte arrays returned from JNI are copied with `SetByteArrayRegion`, then + the Rust buffer is freed with `weaveffi_free_bytes`. +- Buffered values cross JNI as `ByteArray` copies of their value buffers: + the shim copies a returned buffer and releases the original; a buffered + parameter is encoded by the Kotlin layer and borrowed by the producer + for the call (`GetByteArrayElements` / `ReleaseByteArrayElements`). + Object fields inside are cloned on the way in and adopted on the way out. +- Callback-interface implementations are pinned by a JNI global ref until + the producer's `free` runs. + +## Async support + +Async IDL functions (`async: true`) are exposed as `suspend fun`s built on +`suspendCancellableCoroutine`. The public wrapper passes a +`WeaveContinuation` (a small class with `onSuccess`/`onError` methods) to +a private external launcher. From the `kvstore` sample: + +```kotlin +@JvmStatic private external fun nativeCompactAsync(selfHandle: Long, cancelToken: Long, callback: Any) + +suspend fun compact(): Long = suspendCancellableCoroutine { cont -> + nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message, payload -> KvException.fromCode(code, message, payload) }) +} + +internal class WeaveContinuation( + private val cont: kotlinx.coroutines.CancellableContinuation, + private val mapError: (Int, String, ByteArray?) -> Throwable +) { + @Suppress("UNCHECKED_CAST") + fun onSuccess(result: Any?) { cont.resume(result as T) } + fun onError(code: Int, message: String, payload: ByteArray?) { cont.resumeWithException(mapError(code, message, payload)) } +} +``` + +The JNI launcher allocates a per-call context holding a `NewGlobalRef` to +the continuation and hands the C ABI a completion callback. That callback +attaches the producer's thread to the JVM if needed, calls +`onSuccess`/`onError`, deletes the global ref, frees the context exactly +once, and detaches the thread if it attached it: + +```c +static void weaveffi_events_EventBus_publish_later_jni_cb(void* context, weaveffi_error* err, int64_t result) { + weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context; + JNIEnv* env = NULL; + int attached = weaveffi_jni_attach(&env); + if (env == NULL) { free(ctx); return; } + if (err != NULL && err->code != 0) { + /* copy code, message, payload; weaveffi_error_free(err); call onError(int, String, byte[]) */ + } else { + /* box the result; call onSuccess(Object) */ + } + weaveffi_jni_handle_uncaught(env); + (*env)->DeleteGlobalRef(env, ctx->callback); + free(ctx); + weaveffi_jni_detach(attached); +} +``` + +The completion callback fires exactly once, on a producer thread. Result +buffers passed to it are owned by the consumer, so the shim copies them +into Java objects and releases them with `weaveffi_free_string` or +`weaveffi_free_bytes`; a reported error is heap-boxed and released with +`weaveffi_error_free`; an object result is resumed as a raw handle that +the suspend wrapper adopts. A `throws: true` callable resumes with the +typed domain exception (or `WeaveFFIException` for a runtime code); one +without `throws` resumes with `WeaveFFIException`. + +An exception thrown by the resumed coroutine itself (not by a callback +interface) has no Kotlin caller on that thread. The glue routes it through +`weaveffi_jni_handle_uncaught`, which delivers it to the handler installed +with `WeaveFFI.setCallbackExceptionHandler` or, with no handler, logs it +with `ExceptionDescribe` and drops it. + +For callables marked `cancellable: true`, the private launcher carries a +`cancelToken: Long` and the shim casts it to `weaveffi_cancel_token*`, +but the public suspend wrapper passes `0L` (no token); coroutine +cancellation isn't wired to the native token. + +## Iterators + +`iter` returns surface as `Iterator`, backed by a generated +per-function wrapper class that is fully lazy: the external launcher +returns the raw iterator handle as a `Long`, and each `hasNext()` +lookahead issues exactly one `nativeNext` call, which maps to one producer +`_next`. From the `kvstore` sample (`Store.listKeys` returns +`iter`): + +```kotlin +class KvStoreListKeysIterator internal constructor(handle: Long) : Iterator, AutoCloseable { + private val ref = WeaveNativeRef(handle) { h -> nativeDestroy(h) } + private val cleanable = weaveCleaner.register(this, ref) + private var nextSlot: Array? = null + + override fun hasNext(): Boolean { + if (nextSlot != null) return true + if (ref.peek() == 0L) return false + val slot = nativeNext(ref.get()) + if (slot == null) { + close() + return false + } + nextSlot = slot + return true + } + + override fun next(): String { + if (!hasNext()) throw NoSuchElementException() + val raw = nextSlot!![0] + nextSlot = null + return raw as String + } + + /** Releases the native iterator; safe to call more than once. */ + override fun close() { + cleanable.clean() + } + + companion object { + init { WeaveNativeLibrary.ensureLoaded() } + + @JvmStatic private external fun nativeNext(handle: Long): Array? + @JvmStatic private external fun nativeDestroy(handle: Long) + } +} +``` + +The `nativeNext` shim pulls one element and returns it in a one-slot +`Object[]`; `null` means the stream is exhausted. A string element is +freed with `weaveffi_free_string` right after it's copied; a buffered +element is copied into a `ByteArray`, released with `weaveffi_free_bytes`, +and decoded in `next()`; an object element is adopted into a wrapper. The +native handle is destroyed exactly once: eagerly when `hasNext()` sees +exhaustion, from `close()` when you abandon iteration early (the class is +`AutoCloseable`), or by the `Cleaner` otherwise. A closed iterator reports +`hasNext() == false`. + +Use it as a plain `Iterator` or lift it into a `Sequence`: + +```kotlin +for (key in store.listKeys(prefix = null)) println(key) +val texts = bus.messages().asSequence().toList() +``` + +Errors from the launcher and from each `next` follow the function's error +strategy: `listKeys` throws the typed `KvException` subclass from the step +that failed, while a non-throwing function throws the generic +`WeaveFFIException`. + +## Known limitations + +- `minSdk` is 33 because disposal relies on `java.lang.ref.Cleaner`. +- Unsigned integers are carried as signed bit patterns (`u8` → `Byte`, + `u16` → `Short`, `u64` → `Long`); only `u32` is widened. +- Coroutine cancellation isn't propagated to the native cancel token. +- The generated `build.gradle.kts` pins the Kotlin Android plugin and + `kotlinx-coroutines-core` versions; adjust them to your project. +- The Kotlin module compiles the generated `WeaveFFI.kt` as one file; the + conformance consumers compile it in the same module so `internal` + members are reachable, which isn't required for normal use. +- Callback methods run on whichever thread the producer uses; nothing is + marshalled to the main thread. + +## Troubleshooting + +- **`UnsatisfiedLinkError: Couldn't find libweaveffi.so`**: the JNI shim + wasn't built or isn't on `java.library.path` (desktop) or inside the AAR + (Android). On Android the producer cdylib must also sit under + `src/main/jniLibs//`. +- **`UnsatisfiedLinkError: ... different C ABI revision`**: `JNI_OnLoad` + found a producer library built for another ABI revision. Rebuild the + producer with the same WeaveFFI version. +- **`IllegalStateException: WeaveFFI object used after close()`**: a + wrapper was used after `close()` (or after `use { }` returned). Adopt a + second wrapper (`share()`, or the one a callback hands you) if you need + a longer-lived reference. +- **`WeaveFFIException` with code -4**: a callback interface method threw. + The message carries the throwable's `toString()`; fix the implementation + or catch the exception at the call site. +- **R8/ProGuard removes `WeaveFFI` symbols**: keep the wrapper package with + `-keep class com.weaveffi.** { *; }`; the shim resolves `SubscriberJni` + and the exception classes by name. diff --git a/docs/src/generators/node.md b/docs/src/generators/node.md index e5bad8b6..8ac34ef6 100644 --- a/docs/src/generators/node.md +++ b/docs/src/generators/node.md @@ -8,50 +8,58 @@ definitions, and the complete N-API addon C source (plus a `WEAVEFFI_ADDON` environment override, then prefers the node-gyp build output (`./build/Release/weaveffi.node`), and falls back to a prebuilt binary placed next to it as `index.node`. On top of the raw native -bindings it layers the idiomatic wrappers: error classes, interface -classes, plain-object records and tagged-union rich enums, and -camelCased function wrappers. +bindings it layers the idiomatic wrappers: error classes, object classes +with `close()` and `Symbol.dispose`, plain-object records and tagged-union +rich enums, duck-typed callback interfaces, `Promise`-returning async +functions, lazy iterators, and camelCased function wrappers. The surface +follows ABI revision 2, and 64-bit integers are `bigint` everywhere. ## What gets generated | File | Purpose | |------|---------| -| `generated/node/index.js` | CommonJS loader: tries `./build/Release/weaveffi.node`, falls back to `./index.node` | +| `generated/node/index.js` | CommonJS loader: tries `./build/Release/weaveffi.node`, falls back to `./index.node`; idiomatic wrappers | | `generated/node/types.d.ts` | TypeScript declarations for the public surface | -| `generated/node/weaveffi_addon.c` | N-API addon source: marshaling, promises, threadsafe functions | +| `generated/node/weaveffi_addon.c` | N-API addon source: marshaling, promises, callback vtables, threadsafe functions | | `generated/node/binding.gyp` | node-gyp build file (includes `../c`, links `-lweaveffi`) | | `generated/node/package.json` | npm package metadata (`main`, `types`, `gypfile`, install script) | ## Type mapping -| IDL type | TypeScript type | -|---------------|----------------------| -| `i32` | `number` | -| `u32` | `number` | -| `i8` | `number` | -| `i16` | `number` | -| `u8` | `number` | -| `u16` | `number` | -| `i64` | `number` | -| `u64` | `number` | -| `f64` | `number` | -| `f32` | `number` | -| `bool` | `boolean` | -| `string` | `string` | -| `bytes` | `Buffer` | -| `handle` | `bigint` | -| `StructName` | `StructName` (a plain object interface) | -| `EnumName` (plain, C-style) | `enum EnumName` | -| `EnumName` (rich / algebraic) | discriminated union type (e.g. `Shape`) | -| `T?` | `T \| null` | -| `[T]` | `T[]` | -| `{K: V}` | `Record` | -| `iter` | `IterableIterator` (lazy) | +| IDL type | TypeScript type | Notes | +|---------------|----------------------|-------| +| `i8`, `i16`, `i32` | `number` | | +| `u8`, `u16`, `u32` | `number` | | +| `i64` | `bigint` | Full 64-bit range; never rounded | +| `u64` | `bigint` | Full 64-bit range; never rounded | +| `f32`, `f64` | `number` | IEEE values; NaN, infinities, `-0` preserved | +| `bool` | `boolean` | | +| `string` | `string` | | +| `bytes` | `Buffer` | | +| `StructName` | `StructName` (a plain object interface) | Value buffer | +| `InterfaceName` | `class InterfaceName` | One strong reference per instance; see [Objects](#objects-interfaces) | +| `InterfaceName?` | `InterfaceName \| null` | | +| `CallbackName` | `interface CallbackName` | Any object with the right methods; see [Callback interfaces](#callback-interfaces) | +| `EnumName` (plain, C-style) | `enum EnumName` | A frozen object with forward and reverse mappings at runtime | +| `EnumName` (rich / algebraic) | discriminated union type (e.g. `Shape`) | Value buffer | +| `T?` | `T \| null` | Value buffer | +| `[T]` | `T[]` | Value buffer | +| `{K: V}` | `Record` | Value buffer | +| `iter` | `IterableIterator` (lazy) | | + +`i64` and `u64` are `bigint` in every position: parameters, returns, +record fields, and callback arguments and results. Passing a `number` +where a `bigint` is expected is a type error in the addon; the `codec` +sample declares `roundtripI64(value: bigint): bigint` and +`roundtripU64(value: bigint): bigint`, and the `kvstore` sample's +`Store.count()` returns `1n`, not `1`. Floats cross as raw IEEE values +(`writeDoubleLE`/`readDoubleLE` in the value-buffer codec), so NaN, the +infinities, and `-0` round-trip. ## Example IDL → generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -82,11 +90,6 @@ modules: params: - { name: contact_id, type: i32 } - { name: color, type: "Color?" } - - - name: get_tags - params: - - { name: contact_id, type: i32 } - return: "[string]" ``` Structs become TypeScript interfaces backed by plain JS objects (the @@ -109,15 +112,15 @@ export enum Color { ``` Functions are exported flat in lowerCamelCase with the module prefix -stripped by default (`strip_module_prefix = false` in `[generators.node]` restores -`_`-prefixed names); parameters are camelCased too. Optional +stripped by default (`strip_module_prefix = false` in `[generators.node]` +restores `_`-prefixed names); parameters are camelCased too, while +record field names keep their IDL spelling (`created_at`). Optional return and parameter types use `| null`, arrays use `T[]`: ```typescript export function getContact(id: number): Contact | null export function listContacts(): Contact[] export function setFavoriteColor(contactId: number, color: Color | null): void -export function getTags(contactId: number): string[] ``` ## Typed errors @@ -147,6 +150,12 @@ class KeyNotFoundError extends KvError { } KeyNotFoundError.CODE = 1001; // ExpiredError, StoreFullError, IoError follow the same shape. + +const __kvErrorCodes = Object.freeze({ 1001: KeyNotFoundError, 1002: ExpiredError, 1003: StoreFullError, 1004: IoError }); +function __kvErrorFrom(code, message, payload) { + const _cls = __kvErrorCodes[code]; + return _cls === undefined ? new WeaveFFIError(code, message) : new _cls(message); +} ``` A callable with `throws: true` rebrands any native failure through the @@ -160,56 +169,92 @@ try { // typed case; e.code === 1003 } else if (e instanceof KvError) { // any kv domain error + } else if (e instanceof WeaveFFIError) { + // runtime code (negative) } } ``` -A callable without `throws` has the same JS signature (JavaScript has no -checked exceptions), but its failures can only be producer bugs, which -surface as the generic `WeaveFFIError`. Unknown codes on the typed path -fall back to `WeaveFFIError` as well. +A callable without `throws` has the same JS signature and rebrands +through `__generic`, so every failure is a plain `WeaveFFIError`. Unknown +codes on the typed path fall back to `WeaveFFIError` as well. -## Interfaces +### Runtime error codes -An `interfaces:` entry becomes a JS class owning the native pointer, -registered with a `FinalizationRegistry` and freed deterministically via -`destroy()`. Constructors become static factories, methods are instance -methods, statics are static methods, all camelCased. From the `kvstore` -sample's `Store` (trimmed from `index.js`): +| Code | ABI name | When | +|------|----------|------| +| -1 | `GENERIC_ERROR_CODE` | The producer reported a failure with no domain code | +| -2 | `PANIC_ERROR_CODE` | The Rust producer panicked inside an export or a spawned async future; also used by the loader for a malformed value buffer | +| -3 | `MARSHAL_ERROR_CODE` | A null object or a malformed value at the boundary; also thrown by the loader for use after `close()`, a wrong wrapper class, or a null callback implementation | +| -4 | `FOREIGN_ERROR_CODE` | A callback-interface implementation threw, is missing a method, or returned a value of the wrong type | + +They surface as a thrown `WeaveFFIError` from a sync call, as a rejected +`Promise` from an async one, and from the `next()` step of an iterator. +JavaScript has no trap path: a non-throwing callable throws the generic +class the same way. + +## Objects (interfaces) + +An `interfaces:` entry becomes a JS class holding one strong reference to +a reference-counted native object. A constructor named `new` becomes the +class constructor (`new EventBus()` in the `events` sample); other +constructors become static factories; methods are instance methods and +statics are static methods, all camelCased. From the `kvstore` sample's +`Store` (trimmed from `index.js`): ```js +// Borrow a live object wrapper's native handle for one call. The wrapper +// keeps its own reference; the producer clones if it retains the object. +function __borrow(o, cls) { + if (!(o instanceof cls)) { + throw new WeaveFFIError(-3, 'expected an instance of ' + cls.name); + } + if (!o._handle) { + throw new WeaveFFIError(-3, cls.name + ' used after close()'); + } + return o._handle; +} + class Store { static open(path) { const _r = __invoke(addon.Store_open, [path], __kvErrorFrom); - return Store._fromHandle(_r); + return Store._adopt(_r); } put(key, value, kind, ttlSeconds) { - return __invoke(addon.Store_put, [this._handle, key, value, kind, ttlSeconds], __kvErrorFrom); - } - listKeys(prefix) { - const _it = __invoke(addon.Store_list_keys, [this._handle, prefix], __kvErrorFrom); - return new WeaveFFIIterator(_it, addon.Store_list_keys_iterNext, addon.Store_list_keys_iterDestroy, __kvErrorFrom, null); + return __invoke(addon.Store_put, [__borrow(this, Store), key, value, kind, __encode((w, v) => __wOpt(w, v, (w, v) => w.i64(v)), ttlSeconds)], __kvErrorFrom); } count() { - return __invoke(addon.Store_count, [this._handle], __generic); + return __invoke(addon.Store_count, [__borrow(this, Store)], __generic); } compact() { - return __invokeAsync(addon.Store_compact, [this._handle], __kvErrorFrom); + return __invokeAsync(addon.Store_compact, [__borrow(this, Store)], __kvErrorFrom); } static defaultCapacity() { return __invoke(addon.Store_default_capacity, [], __generic); } - destroy() { + close() { if (this._handle) { Store._cleanup.unregister(this); - addon.Store_destroy(this._handle); - this._handle = 0; + addon.Store__destroy(this._handle); + this._handle = 0n; } } + _cloneHandle() { + return addon.Store__clone(__borrow(this, Store)); + } } +Store._adopt = function (handle) { + const _o = Object.create(Store.prototype); + _o._handle = handle; + Store._cleanup.register(_o, handle, _o); + return _o; +}; Store._cleanup = new FinalizationRegistry((handle) => { - if (handle) { addon.Store_destroy(handle); } + if (handle) { addon.Store__destroy(handle); } }); +if (typeof Symbol.dispose === 'symbol') { + Store.prototype[Symbol.dispose] = Store.prototype.close; +} ``` The typed declarations mirror the class, with `@throws` and @@ -220,23 +265,173 @@ export class Store { /** @throws {KvError} */ static open(path: string): Store; /** @throws {KvError} */ - put(key: string, value: Buffer, kind: EntryKind, ttlSeconds: number | null): boolean; - /** @throws {KvError} */ - listKeys(prefix: string | null): IterableIterator; - count(): number; + put(key: string, value: Buffer, kind: EntryKind, ttlSeconds: bigint | null): boolean; + count(): bigint; /** @throws {KvError} */ - compact(): Promise; - static defaultCapacity(): number; - /** Free the underlying native object. */ - destroy(): void; + compact(): Promise; + static defaultCapacity(): bigint; + /** + * Release this wrapper's reference to the native object. Safe to call + * more than once; a wrapper that is never closed is released when it is + * garbage collected. Using the wrapper after `close()` throws. + */ + close(): void; + /** Alias of `close()` for `using` declarations. */ + [Symbol.dispose](): void; +} +``` + +- **Disposal is `close()` plus a `FinalizationRegistry` backstop.** + `close()` releases the wrapper's reference exactly once and is + idempotent; `Symbol.dispose` aliases it so `using store = Store.open(...)` + works where the runtime supports explicit resource management. A + wrapper collected unclosed is released by the registry, so `_destroy` + runs exactly once either way. +- **Use after close throws** `WeaveFFIError` with code -3 (`Store used + after close()`), from `__borrow`, before anything reaches the addon. +- **Clones mint a new strong reference.** `_cloneHandle()` calls + `_clone` whenever the wrapper must hand the producer a reference it + will own; every adopted wrapper (a return, an async result, an iterated + element, a buffer token) owes its own `close()`. Two wrappers over the + same object (`share()`, or the bus handed to `onAttached`) are + independent: closing one leaves the other usable. + +### Objects as parameters, returns, and inside values + +A top-level object parameter is borrowed (`__borrow`); a returned object +is adopted. `Store?` is `Store | null` both ways: + +```js +larger(other) { + const _r = __invoke(addon.Store_larger, [__borrow(this, Store), (other == null ? null : __borrow(other, Store))], __generic); + return (_r == null ? null : Store._adopt(_r)); +} +``` + +Objects inside records, lists, map values, optionals, and rich-enum +payloads are ordinary properties (`store: Store; mirror: Store | null` in +`StoreInfo`). On the wire they're `u64` tokens: the pack function writes a +fresh `_cloneHandle()` per object and the unpack function adopts: + +```js +function __packStoreInfo(w, v) { + w.str(v.label); + w.u64(v.store._cloneHandle()); + __wOpt(w, v.mirror, (w, v) => w.u64(v._cloneHandle())); + w.i64(v.count); +} +function __unpackStoreInfo(r) { + return { + label: r.str(), + store: Store._adopt(r.u64()), + mirror: __rOpt(r, (r) => Store._adopt(r.u64())), + count: r.i64(), + }; +} +``` + +`Store.openMany(paths)` returns `Store[]` (one adopted wrapper each); +`Store.totalCount(stores, extra)` clones every object it encodes. An +async callable returning an object resolves with an adopted wrapper, and +an `iter` adopts one per step. + +## Callback interfaces + +A `callback_interfaces:` entry becomes a TypeScript `interface`. Any +object with the right methods satisfies it: a class instance, an object +literal, or a module. From the `events` sample: + +```typescript +export interface Subscriber { + route(topic: string): Delivery; + onMessage(message: Message): bigint; + onAttached(bus: EventBus): void; +} +``` + +```js +const subscriber = { + route(topic) { return topic === 'quiet' ? Delivery.Skip : Delivery.Accept; }, + onMessage(message) { console.log(message.topic, message.text); return 1n; }, + onAttached(bus) { this.bus = bus; }, // or bus.close() to release it now +}; +const bus = new wv.EventBus(); +bus.subscribe(subscriber); +``` + +The loader wraps your object in a small adapter that converts arguments +(decoding buffered values, adopting objects) and maps camelCase method +names to the ABI's snake_case: + +```js +function __adaptSubscriber(impl) { + if (impl === null || impl === undefined) { + throw new WeaveFFIError(-3, 'Subscriber implementation must be an object'); + } + return { + route(topic) { + return impl.route(topic); + }, + on_message(message) { + return impl.onMessage(__decode(__unpackMessage, message)); + }, + on_attached(bus) { + return impl.onAttached(EventBus._adopt(bus)); + }, + }; } ``` -A function elsewhere in the API that takes the interface accepts the -wrapper instance and unwraps its handle (`getStats(store)` in the nested -`stats` module); a function returning an interface wraps the new owned -handle in a fresh instance. Call `destroy()` when you're done; the -`FinalizationRegistry` is only a GC-timed safety net. +The addon keeps the adapter behind a `napi_ref`, installs one static +vtable per interface, and gives each implementation its own +`napi_threadsafe_function`. A trampoline calls the implementation +directly when the producer is on the JS thread, and otherwise hops the +call to the JS thread and blocks until it has run: + +```c +static weaveffi_events_Delivery weaveffi_events_Subscriber_route_tramp(void* ctx, const char* topic, weaveffi_error* out_err) { + weaveffi_events_Subscriber_route_frame f; + memset(&f, 0, sizeof f); + f.hdr.out_err = out_err; + f.topic = topic; + weaveffi_napi_cb_ctx* c = (weaveffi_napi_cb_ctx*)ctx; + if (weaveffi_napi_on_js_thread()) { + weaveffi_events_Subscriber_route_invoke(c->env, c, &f); + } else { + weaveffi_napi_cb_req req; + req.ctx = c; + req.method = 0; + req.frame = &f; + weaveffi_napi_cb_hop(&req); // napi_call_threadsafe_function + wait on a condvar + } + return f.result; +} +``` + +- **Argument ownership.** Strings and buffered values are decoded into + fresh JS values. An object argument (`bus`) is adopted into a wrapper + your implementation owns: keep it, or `close()` it; the finalizer + backstops it otherwise. +- **Lifetime.** The `napi_ref` keeps the implementation alive for as long + as the producer holds it; the vtable's `free` releases the reference and + the threadsafe function (hopping to the JS thread if needed). The + threadsafe function is unref'd, so a live implementation doesn't by + itself keep the event loop alive. +- **Errors.** A thrown exception, a missing method, or a return value of + the wrong type (for example a `number` where a `bigint` is required) + never unwinds through the C frame. The addon clears the pending + exception, takes its `message`, and reports + `weaveffi_error_set(out_err, -4, ...)`. The producer aborts the call + that triggered the callback and the caller sees `WeaveFFIError` with + `code === -4`, thrown from a sync call or rejecting the `Promise` of an + async one. The implementation stays attached. +- **Threads.** Callback methods always run on the JS thread. When the + producer calls from one of its own threads (the `events` sample's + `publishLater`), that thread blocks until the JS thread has run the + method, which requires the event loop to be free. A synchronous call + from JS that waits on a producer worker which in turn needs a callback + will deadlock, since the JS thread is busy in the synchronous call. Use + async callables for any path that invokes callbacks from a worker. ## Rich (algebraic) enums @@ -270,11 +465,8 @@ ordinary JS values: const { describe, scale } = require('./index.js'); const circle = { tag: 'Circle', radius: 2.0 }; -const labeled = { tag: 'Labeled', label: 'unit', count: 3 }; - -console.log(describe(circle)); // native-rendered description -const bigger = scale(circle, 3.0); // a fresh Shape value - +console.log(describe(circle)); +const bigger = scale(circle, 3.0); if (bigger.tag === 'Circle') { console.log(bigger.radius); // 6 } @@ -282,7 +474,7 @@ if (bigger.tag === 'Circle') { There is nothing to release: values are copied across the boundary, and the loader frees each returned native buffer immediately after decoding -it. +it. A variant payload that holds an object follows the token rules above. ## Build instructions @@ -293,7 +485,7 @@ producer cdylib on the linker path: ```bash cargo build -p kvstore -weaveffi generate samples/kvstore/src/lib.rs -o generated +weaveffi generate samples/kvstore/src/lib.rs -o generated --target c,node cd generated/node npm install # builds build/Release/weaveffi.node @@ -301,14 +493,26 @@ DYLD_LIBRARY_PATH=../../target/debug node -e " const kv = require('./index.js'); const store = kv.Store.open('/tmp/cache.kv'); console.log(store.count()); + store.close(); " ``` -(Use `LD_LIBRARY_PATH` on Linux.) Then publish the generated directory -as a private npm package or ship it inside your app. Copying a prebuilt -platform binary in as `index.node` also works, and the `WEAVEFFI_ADDON` -env var can point the loader at any built addon (the -`conformance/node/` consumers use it; see `conformance/run.sh`). +(Use `LD_LIBRARY_PATH` on Linux.) Copying a prebuilt platform binary in +as `index.node` also works, and the `WEAVEFFI_ADDON` env var can point +the loader at any built addon (the `conformance/node/` consumers use it; +see `conformance/run.sh`). + +## Packaging + +`weaveffi package --target node` emits the main npm package (`index.js`, +`types.d.ts`, `weaveffi_addon.c`, `binding.gyp`, `package.json`) plus one +platform package per desktop slice under `node/npm/--/` +(`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, +`win32-x64`), each gated by npm `os`/`cpu` fields and bundling its +prebuilt producer library. The main package lists them as +`optionalDependencies`, so npm installs only the one matching the host. +Android and `wasm32` binaries have no npm tokens and are skipped. See +[Packaging and Distribution](../guides/packaging.md). ## Memory and ownership @@ -317,13 +521,11 @@ env var can point the loader at any built addon (the storage, so consumers never need to think about freeing memory. - Buffered values (structs, rich enums, optionals, lists, maps) are returned as plain JS values: the loader decodes the returned value - buffer and frees it with the native free before the call returns, so - there is nothing to dispose on the JS side. -- Interface wrappers own their native pointer; release it with - `destroy()` (a `FinalizationRegistry` backstops forgotten handles at - GC time). -- Typed handles (`handle`) pass through as opaque values; - release them through the API's own teardown function. + buffer, which the addon has already freed with the native free. Object + fields inside are cloned on the way in and adopted on the way out. +- Object wrappers own one strong reference; release it with `close()` + (or `using`); the `FinalizationRegistry` backstops forgotten wrappers + at GC time. - `iter` returns are lazy JS iterables; see [Iterators](#iterators). The native handle is released on exhaustion, on early exit, or by a finalizer as a backstop. @@ -333,10 +535,10 @@ env var can point the loader at any built addon (the ## Async support -Async IDL functions are exposed as JS functions that return a Promise: +Async IDL functions return a `Promise`: ```typescript -export function runTask(name: string): Promise +compact(): Promise; ``` The addon creates the promise with `napi_create_promise` and calls the @@ -347,19 +549,14 @@ callback only stashes the result (or error) and posts it through a loop and calls `napi_resolve_deferred` / `napi_reject_deferred` there: ```c -static void weaveffi_tasks_run_task_napi_cb(void* context, weaveffi_error* err, - const uint8_t* result_ptr, size_t result_len) { - weaveffi_tasks_run_task_napi_actx* ctx = (weaveffi_tasks_run_task_napi_actx*)context; +static void weaveffi_events_EventBus_publish_later_napi_cb(void* context, weaveffi_error* err, int64_t result) { + weaveffi_events_EventBus_publish_later_napi_actx* ctx = (weaveffi_events_EventBus_publish_later_napi_actx*)context; if (err != NULL && err->code != 0) { ctx->err_code = err->code; ctx->err_msg = err->message ? strdup(err->message) : strdup("unknown error"); + /* ... copy the payload buffer the same way ... */ } else { - ctx->result_len = result_len; - if (result_ptr != NULL && result_len > 0) { - ctx->result = (uint8_t*)malloc(result_len); - memcpy(ctx->result, result_ptr, result_len); - } - weaveffi_free_bytes((uint8_t*)result_ptr, result_len); + ctx->result = result; } weaveffi_error_free(err); napi_call_threadsafe_function(ctx->tsfn, ctx, napi_tsfn_blocking); @@ -367,28 +564,22 @@ static void weaveffi_tasks_run_task_napi_cb(void* context, weaveffi_error* err, ``` The completion callback fires exactly once, on the producer thread. -Result buffers passed to it (strings, byte buffers, and the serialized -value buffers of buffered results such as the `TaskResult` record here) -are owned by the consumer, so the callback deep-copies them (note the -`strdup` and `memcpy` above) and then releases them with -`weaveffi_free_string` or `weaveffi_free_bytes`; a reported error is -heap-boxed and released with `weaveffi_error_free` after its fields -are copied. Owned interface results transfer ownership too: the -callback receives the object pointer, which the settle callback wraps -into the JS-side owner. - -Rejected promises carry the C error message plus a numeric `code` -property; the loader rebrands the rejection into the module's typed -error class when the callable declares `throws: true` (an async method -like `Store.compact()` rejects with `KvError` subclasses), and into the -generic `WeaveFFIError` otherwise. The settle callback releases the -threadsafe function once the promise is settled, so a pending async -call keeps the event loop alive until it completes. +Result buffers passed to it (strings, byte buffers, and value buffers) +are owned by the consumer, so the callback deep-copies them and then +releases them with `weaveffi_free_string` or `weaveffi_free_bytes`; a +reported error is heap-boxed and released with `weaveffi_error_free` +after its fields are copied. An object result is adopted into a wrapper +by the loader when the promise resolves. + +Rejected promises carry a numeric `code`; the loader rebrands the +rejection into the module's typed error class when the callable declares +`throws: true` (`Store.compact()` rejects with `KvError` subclasses), and +into the generic `WeaveFFIError` otherwise. A pending async call keeps +the event loop alive until it settles. For functions marked `cancellable: true` the addon passes `NULL` for the C ABI's cancel-token slot; the token is not surfaced to JS and -there is no `AbortSignal` parameter. Only the C, C++, and Kotlin -targets expose cancellation tokens. +there is no `AbortSignal` parameter. ## Iterators @@ -396,12 +587,9 @@ targets expose cancellation tokens. wrapping the native iterator handle, and the loader wraps it in a shared `WeaveFFIIterator` class implementing the JS iterator protocol. Nothing is drained up front; each `next()` issues exactly one native -`_iterNext` call. From the `events` sample's `index.js`: +`_iterNext` call: ```js -// Lazy iterator over a native producer: one native `next` per step. -// The native handle is released on exhaustion, by `return()` on early -// exit, or by the external's finalizer if the iterator is abandoned. class WeaveFFIIterator { next() { if (this._done) { @@ -426,76 +614,39 @@ class WeaveFFIIterator { } } -wv.getMessages = function () { - const _it = __invoke(addon.getMessages, [], __generic); - return new WeaveFFIIterator(_it, addon.getMessages_iterNext, addon.getMessages_iterDestroy, __generic, null); -}; +listKeys(prefix) { + const _it = __invoke(addon.Store_list_keys, [__borrow(this, Store), __encode((w, v) => __wOpt(w, v, (w, v) => w.str(v)), prefix)], __kvErrorFrom); + return new WeaveFFIIterator(_it, addon.Store_list_keys_iterNext, addon.Store_list_keys_iterDestroy, __kvErrorFrom, null); +} ``` The TypeScript declaration is `IterableIterator`, so a plain -`for...of` loop works and `break`ing out of it triggers `return()`, -which destroys the native iterator early. The addon's `_iterNext` -binding pulls one element, copies it into a JS value (freeing the -native string), and destroys the iterator eagerly when the producer -reports exhaustion; the external's N-API finalizer backstops abandoned -iterators at GC time, nulling the stored handle so a double destroy is -impossible. Buffered elements (structs, rich enums, composites) arrive -as producer-allocated value buffers: the addon decodes each one into a -plain JS value and frees the buffer per step. +`for...of` loop (or `[...store.listKeys(null)]`) works, and `break`ing +out of the loop triggers `return()`, which destroys the native iterator +early. The addon's `_iterNext` binding pulls one element, copies it into +a JS value (freeing the native string or buffer), and destroys the +iterator eagerly when the producer reports exhaustion; the external's +N-API finalizer backstops abandoned iterators at GC time. Buffered +elements are decoded per step; object elements are adopted through +`_wrapElem`. Errors from the launcher and each `next` step follow the function's error strategy: `Store.listKeys` (throws) rebrands a failing step -through `__kvErrorFrom` into the typed `KvError` subclasses, while -the non-throwing `getMessages` throws the generic `WeaveFFIError` -only for producer bugs. - -## Callbacks and listeners - -An IDL `listener` becomes a register/unregister pair. Registration -takes a plain JS function and returns a numeric subscription id; -unregistration takes that id back: - -```typescript -export function registerMessageListener(callback: (message: string) => void): number -export function unregisterMessageListener(id: number): void -``` - -The id is the `uint64` returned by the C ABI's -`weaveffi_events_register_message_listener(callback_fn, context)`; each -registration gets its own id and threadsafe function. - -The native callback fires on the producer's thread, and the addon never -calls into JS from there. Registration wraps the JS function in a -`napi_threadsafe_function`, and a C trampoline copies the payload and -queues it onto the JS event loop: - -```c -static void weaveffi_events_OnMessage_fn_napi_tramp(const char* message, void* context) { - weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context; - weaveffi_events_OnMessage_fn_payload* p = (weaveffi_events_OnMessage_fn_payload*)calloc(1, sizeof(weaveffi_events_OnMessage_fn_payload)); - p->message = message ? strdup(message) : NULL; - napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking); -} -``` - -The threadsafe function is unref'd immediately after registration: - -```c -napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_events_OnMessage_fn_napi_calljs, &ctx->tsfn); -napi_unref_threadsafe_function(env, ctx->tsfn); -uint64_t id = weaveffi_events_register_message_listener(weaveffi_events_OnMessage_fn_napi_tramp, ctx); -``` - -Threading caveats: - -- The JS callback always runs on the JS thread; delivery is - asynchronous and the producer does not wait for it - (`napi_tsfn_nonblocking`). -- Because the threadsafe function is unref'd, a registered listener - does not keep the process alive; the loop may exit with listeners - still registered. -- Unregistering calls the C ABI unregister, releases the threadsafe - function, and frees the listener context. +through `__kvErrorFrom` into the typed `KvError` subclasses, while a +non-throwing function throws the generic `WeaveFFIError`. + +## Known limitations + +- Callback methods run only on the JS thread. A producer thread that + invokes one blocks until the event loop services it, so a synchronous + call that waits on such a thread deadlocks; prefer async callables for + callback-heavy paths. +- `i64`/`u64` require `bigint`; a `number` in those positions is rejected + at the boundary. +- Cancellation tokens aren't exposed; there is no `AbortSignal` support. +- `Symbol.dispose` is only wired when the runtime defines it. +- The loader is CommonJS; import it from ESM with `createRequire` or a + default import. ## Troubleshooting @@ -506,8 +657,15 @@ Threading caveats: - **`dlopen: ... image not found`**: the addon links against the Rust cdylib at runtime; set `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` or copy the cdylib next to `index.node`. -- **`BigInt` errors with `handle`**: handles are 64-bit; pass them as - `bigint`, not `number`. +- **`WeaveFFIError: (-3) Store used after close()`**: the wrapper was + closed (or disposed by `using`) before the call. Adopt a second wrapper + if you need a longer-lived reference. +- **`WeaveFFIError: (-4) ...`**: your callback implementation threw, is + missing a method, or returned the wrong type; check that `bigint` + results are returned as `1n`, not `1`. +- **Process hangs during a sync call**: a callback is being invoked from a + producer thread while the JS thread is blocked in that call. Switch the + call to an async callable. - **TypeScript complains about missing types**: point `tsconfig`'s `paths` at `generated/node/types.d.ts` or include the generated package in `compilerOptions.types`. diff --git a/docs/src/generators/python.md b/docs/src/generators/python.md index 99e3cc14..a6bd26e1 100644 --- a/docs/src/generators/python.md +++ b/docs/src/generators/python.md @@ -2,13 +2,21 @@ ## Overview -The Python target produces pure-Python ctypes bindings, type stubs, and -packaging files. Calls go through Python's built-in `ctypes` module so -there is no compilation step, no native extension, and no third-party -runtime dependency. The generated package works on any Python 3.7+ -interpreter that can `dlopen` the shared library. - -The trade-off is that ctypes calls are slower than compiled extensions +The Python target produces pure-Python `ctypes` bindings, `.pyi` type +stubs, and packaging files over the C ABI (revision 2). Calls go +through Python's built-in `ctypes` module, so there's no compilation +step, no native extension, and no third-party runtime dependency. The +generated package works on any Python 3.7+ interpreter that can +`dlopen` the shared library. + +Records and rich enums are dataclasses that cross the boundary as value +buffers; interfaces are reference-counted wrapper classes with `close()` +and a `__del__` backstop; callback interfaces are abstract base classes +the consumer subclasses; async functions surface as `async def` wrappers +integrated with asyncio; and `iter` returns are lazy Python +iterators. + +The trade-off is that `ctypes` calls are slower than compiled extensions (`cffi`, `pybind11`, PyO3). For typical FFI workloads the overhead is negligible compared to the work done inside the Rust library. @@ -16,56 +24,90 @@ negligible compared to the work done inside the Rust library. | File | Purpose | |------|---------| -| `python/weaveffi/__init__.py` | Re-exports the public API from `weaveffi.py` | -| `python/weaveffi/weaveffi.py` | ctypes bindings: library loader, wrappers, classes | -| `python/weaveffi/weaveffi.pyi` | Type stub for IDE autocompletion and `mypy` | +| `python//__init__.py` | Re-exports the public API from `weaveffi.py` | +| `python//weaveffi.py` | ctypes bindings: library loader, ABI check, codecs, wrappers, classes | +| `python//weaveffi.pyi` | Type stub for IDE autocompletion and `mypy` | | `python/pyproject.toml` | PEP 621 project metadata | | `python/setup.py` | Fallback setuptools script | | `python/README.md` | Basic usage instructions | The package directory follows the IDL `package.name` (a package named -`events` produces `python/events/...`); `weaveffi` is the default. +`kvstore` produces `python/kvstore/...`); `weaveffi` is the default and +`package_name` under `[generators.python]` overrides it. + +The module verifies the producer's ABI revision at import time, so a +stale library fails fast instead of misreading the error struct or a +value buffer later: + +```python +_ABI_VERSION = 2 + + +def _check_abi_version(lib: ctypes.CDLL) -> None: + try: + fn = lib.weaveffi_abi_version + except AttributeError: + raise ImportError( + "the loaded WeaveFFI library predates ABI versioning " + f"(these bindings expect ABI revision {_ABI_VERSION})" + ) from None + fn.argtypes = [] + fn.restype = ctypes.c_uint32 + found = fn() + if found != _ABI_VERSION: + raise ImportError( + f"WeaveFFI ABI mismatch: these bindings expect revision {_ABI_VERSION} " + f"but the loaded library reports revision {found}" + ) +``` ## Type mapping | IDL type | Python type hint | ctypes type | |--------------|----------------------|------------------------------------| -| `i32` | `int` | `ctypes.c_int32` | -| `u32` | `int` | `ctypes.c_uint32` | -| `i64` | `int` | `ctypes.c_int64` | -| `f64` | `float` | `ctypes.c_double` | -| `i8` | `int` | `ctypes.c_int8` | -| `i16` | `int` | `ctypes.c_int16` | -| `u8` | `int` | `ctypes.c_uint8` | -| `u16` | `int` | `ctypes.c_uint16` | -| `u64` | `int` | `ctypes.c_uint64` | -| `f32` | `float` | `ctypes.c_float` | -| `bool` | `bool` | `ctypes.c_int32` | -| `string` | `str` | `ctypes.c_char_p` | -| `bytes` | `bytes` | `ctypes.POINTER(ctypes.c_uint8)` + `ctypes.c_size_t` | -| `handle` | `int` | `ctypes.c_uint64` | -| `Struct` | `"StructName"` (a `@dataclass`) | value buffer: `ctypes.POINTER(ctypes.c_uint8)` + `ctypes.c_size_t` | -| `Interface` | `"InterfaceName"` | `ctypes.c_void_p` | -| `Enum` (plain) | `"EnumName"` | `ctypes.c_int32` | +| `i8`, `i16`, `i32`, `i64` | `int` | `c_int8`, `c_int16`, `c_int32`, `c_int64` | +| `u8`, `u16`, `u32`, `u64` | `int` | `c_uint8`, `c_uint16`, `c_uint32`, `c_uint64` | +| `f32`, `f64` | `float` | `c_float`, `c_double` | +| `bool` | `bool` | `c_int32` | +| `string` | `str` | `c_char_p` | +| `bytes` | `bytes` | `POINTER(c_uint8)` + `c_size_t` | +| `Struct` | `"StructName"` (a `@dataclass`) | value buffer: `POINTER(c_uint8)` + `c_size_t` | +| `Enum` (plain) | `"EnumName"` (`IntEnum`) | `c_int32` | | `Enum` (rich) | `"EnumName"` (variant dataclasses) | value buffer, like `Struct` | -| `T?` | `Optional[T]` | value buffer (`Interface?` stays `ctypes.c_void_p`) | +| `Interface` | `"InterfaceName"` (wrapper class) | `c_void_p` | +| `Interface?` | `Optional["InterfaceName"]` | `c_void_p` (NULL for `None`) | +| `CallbackInterface` | `"CallbackName"` (subclass of the generated ABC) | `c_void_p` ctx + `POINTER(Vtable)` | +| `T?` | `Optional[T]` | value buffer | | `[T]` | `List[T]` | value buffer | | `{K: V}` | `Dict[K, V]` | value buffer | -| `iter` | `Iterator[T]` (lazy) | opaque `ctypes.c_void_p` iterator handle | +| `iter` | `Iterator[T]` (lazy) | opaque `c_void_p` iterator handle | Buffered types (structs, rich enums, optionals, lists, maps) cross the boundary serialized in the -[value-buffer format](../reference/value-buffers.md); the generated module -ships a private `_BufferWriter`/`_BufferReader` pair plus one pack and one -unpack function per record and rich enum. +[value-buffer format](../reference/value-buffers.md); the generated +module ships a private `_BufferWriter`/`_BufferReader` pair plus one +pack and one unpack function per record and rich enum. Objects nested +inside a buffered value travel as object tokens (see +[Objects](#objects-interfaces)). Booleans cross the boundary as `c_int32` (`0`/`1`) because C has no standard fixed-width boolean type across ABIs. -## Example IDL → generated code +### 64-bit integers and floats + +Python's `int` is arbitrary precision, so `i64` and `u64` round-trip +exactly with no wrapper type: the `ctypes` slots are `c_int64` and +`c_uint64`, and the value-buffer codec packs them with `struct` as +` int: - _fn = _lib.weaveffi_contacts_create_contact - _fn.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32, - ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_uint64 +def get_stats(store: "Store") -> "Stats": + _fn = _lib.weaveffi_kv_stats_get_stats + _fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p _err = _WeaveFFIErrorStruct() - _result = _fn(_string_to_bytes(name), _string_to_bytes(email), - contact_type.value, ctypes.byref(_err)) - _check_error(_err) - return _result + _out_len = ctypes.c_size_t(0) + _result = _fn(_borrow(store), ctypes.byref(_out_len), ctypes.byref(_err)) + _check_kv_error(_err) + _data = _take_buffer(_result, _out_len.value) + return _unpack_Stats(_data) ``` Enums become `IntEnum` subclasses: @@ -150,9 +190,9 @@ class ContactType(IntEnum): ``` Structs become plain `@dataclass` value classes. There are no C symbols -per struct: construction, equality, and repr come from the dataclass, and -instances cross the boundary serialized in value buffers by generated -private codec functions (`_pack_Contact` / `_unpack_Contact`): +per struct: construction, equality, and repr come from the dataclass, +and instances cross the boundary serialized in value buffers by +generated private codec functions (`_pack_Contact` / `_unpack_Contact`): ```python @dataclass @@ -164,11 +204,6 @@ class Contact: age: int ``` -A wrapper that returns a `Contact` calls the C function, unpacks the -returned buffer with `_unpack_Contact`, and frees the buffer with -`weaveffi_free_bytes`; a wrapper that takes one packs it with -`_pack_Contact` and passes the borrowed `(ptr, len)` pair. - The accompanying `.pyi` stub mirrors the public surface for IDE/mypy: ```python @@ -183,7 +218,7 @@ class Contact: age: int def __init__(self, name: str, email: Optional[str], age: int) -> None: ... -def create_contact(name: str, email: Optional[str], contact_type: "ContactType") -> int: ... +def create_contact(name: str, email: Optional[str], contact_type: "ContactType") -> "Contact": ... ``` Wrapper names drop the IDL module prefix by default and stay @@ -197,137 +232,230 @@ plain `create_contact` (the C symbol keeps its full ## Typed errors Every generated module defines `WeaveFFIError(Exception)` with `code` -and `message` attributes. A module that declares an error domain also -gets a domain base class and one subclass per code, each pinning its -stable `CODE`; from the `contacts` sample: +and `message` attributes and the four runtime trap codes as class +constants: ```python -class ContactsError(WeaveFFIError): - """Base exception for the `contacts` module's error domain.""" - +class WeaveFFIError(Exception): + """An error reported through the C ABI. -class InvalidName(ContactsError): - """name must not be empty""" + Positive codes are a module's declared error codes (raised as the + module's typed subclasses); negative codes are runtime traps: a generic + producer error (-1), a producer panic (-2), a marshalling failure (-3), + or an exception raised by a callback-interface implementation (-4). + """ - CODE = 1 + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + FOREIGN_ERROR_CODE = -4 - def __init__(self, message: str = "name must not be empty") -> None: - super().__init__(1, message) + def __init__(self, code: int, message: str) -> None: + self.code = code + self.message = message + super().__init__(f"({code}) {message}") +``` +A module that declares an error domain also gets a domain base class +and one subclass per code, each pinning its stable `CODE`; from the +`kvstore` sample: -class NotFound(ContactsError): - """contact not found""" +```python +class KvError(WeaveFFIError): + """Base exception for the `kv` module's error domain.""" - CODE = 2 - def __init__(self, message: str = "contact not found") -> None: - super().__init__(2, message) +class KeyNotFound(KvError): + """key not found""" + CODE = 1 -ContactsError.InvalidName = InvalidName -ContactsError.NotFound = NotFound + def __init__(self, message: str = "key not found") -> None: + super().__init__(1, message) ``` Only callables marked `throws: true` in the IDL raise these typed -errors: their wrappers check the error slot with -`_check_contacts_error`, which maps the code through -`_contacts_error_from` and raises `NotFound`, `InvalidName`, or (for -codes outside the domain, such as producer panics) a plain -`WeaveFFIError`. Their docstrings carry a `Raises` section naming the -domain. A callable without `throws` uses the generic `_check_error`, -which raises `WeaveFFIError` only if the producer misbehaves: +errors: their wrappers check the error slot with `_check_kv_error`, +which maps the code through `_kv_error_from` and raises `KeyNotFound`, +`IoError`, or (for codes outside the domain, such as a producer panic) +a plain `WeaveFFIError`. Their docstrings carry a `Raises` section +naming the domain. A callable without `throws` uses the generic +`_check_error`, which raises `WeaveFFIError` only if the producer +misbehaves. The error slot's message and payload are copied and then +released with `weaveffi_error_clear` before the exception is raised. ```python try: - contact = book.get(999) -except NotFound: + store.put("k", b"", EntryKind.Persistent, None) +except StoreFull: ... # specific code -except ContactsError as e: +except KvError as e: print(e.code, e.message) # any domain error ``` -## Interfaces +### Runtime error codes + +| Code | Constant | Meaning | Where it surfaces | +|------|----------|---------|-------------------| +| `-1` | `GENERIC_ERROR_CODE` | The producer reported an error without a declared code, or the wrapper itself detected misuse (a `None` object pointer, a wrapper used after `close()`). | Raised as `WeaveFFIError` from the call. | +| `-2` | `PANIC_ERROR_CODE` | The Rust implementation panicked; the export macros and the async spawner catch the unwind. | Raised as `WeaveFFIError`, or set as an awaited future's exception. | +| `-3` | `MARSHAL_ERROR_CODE` | Malformed input at the boundary (invalid UTF-8, a truncated value buffer, a bad enum discriminant). | Raised as `WeaveFFIError`. | +| `-4` | `FOREIGN_ERROR_CODE` | A callback-interface method implemented in Python raised. | Raised as `WeaveFFIError` from the producer call that invoked the callback (see [Callback interfaces](#callback-interfaces)). | -An `interfaces:` entry becomes a Python class wrapping the opaque -pointer. A constructor named `new` renders as `__init__`; any other -constructor becomes a `@classmethod` factory. Methods are instance -methods, statics are `@staticmethod`s, and `__del__` calls the C -destructor; `_from_ptr` builds an instance around a pointer the C side -already owns. From the `kvstore` sample (trimmed): +Python has no non-raising call path: a non-throwing callable whose +error slot comes back non-zero still raises `WeaveFFIError`, so a +producer bug or a raising callback never goes unnoticed. + +## Objects (interfaces) + +An `interfaces:` entry becomes a Python class wrapping one strong +reference to a reference-counted producer object. Every interface +exports a `_clone` and `_destroy` pair on the C +ABI, bound once at module scope: ```python -class Store: - """An embedded key-value store owning its entries""" +_lib.weaveffi_kv_Store_clone.argtypes = [ctypes.c_void_p] +_lib.weaveffi_kv_Store_clone.restype = ctypes.c_void_p +_lib.weaveffi_kv_Store_destroy.argtypes = [ctypes.c_void_p] +_lib.weaveffi_kv_Store_destroy.restype = None +``` +The wrapper class (from the `kvstore` sample, trimmed): + +```python +class Store: @classmethod def _from_ptr(cls, ptr) -> "Store": + """Adopt one strong reference the producer handed over.""" _obj = cls.__new__(cls) _obj._ptr = ptr return _obj + def __init__(self) -> None: + raise TypeError("Store cannot be instantiated directly") + + def _clone_ptr(self): + """A new strong reference to the same object (a raw pointer the + receiver must eventually release), leaving this wrapper's own + reference untouched.""" + return _lib.weaveffi_kv_Store_clone(_borrow(self)) + + def close(self) -> None: + """Release this wrapper's reference. The object itself is dropped + when its last reference (here or in the producer) is released. + Idempotent; the wrapper is unusable afterwards.""" + _p = getattr(self, "_ptr", None) + self._ptr = None + if _p is not None: + _lib.weaveffi_kv_Store_destroy(_p) + + def __enter__(self) -> "Store": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False + def __del__(self) -> None: - if self._ptr is not None: - _lib.weaveffi_kv_Store_destroy.argtypes = [ctypes.c_void_p] - _lib.weaveffi_kv_Store_destroy.restype = None - _lib.weaveffi_kv_Store_destroy(self._ptr) - self._ptr = None + self.close() @classmethod - def open(cls, path: str) -> "Store": - """Open (or create) a store backed by the given filesystem path - - Raises - ------ - KvError - If the call reports one of the domain's error codes. - """ - _fn = _lib.weaveffi_kv_Store_open - _fn.argtypes = [ctypes.c_char_p, ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_void_p - _err = _WeaveFFIErrorStruct() - _result = _fn(_string_to_bytes(path), ctypes.byref(_err)) - _check_kv_error(_err) - if _result is None: - raise WeaveFFIError(-1, "null pointer") - return cls._from_ptr(_result) - - def get(self, key: str) -> Optional["Entry"]: ... - def delete(self, key: str) -> bool: ... - - async def compact(self) -> int: - _fn = _lib.weaveffi_kv_Store_compact_async - _loop = asyncio.get_running_loop() - _fut = _loop.create_future() - # ... completion callback resolves _fut via call_soon_threadsafe ... - return await _fut - - def legacy_put(self, key: str, value: bytes) -> bool: - import warnings - warnings.warn("use put() with explicit kind", DeprecationWarning, stacklevel=2) - ... - - @staticmethod - def default_capacity() -> int: ... + def open(cls, path: str) -> "Store": ... ``` -A constructor named `new` (as on the `contacts` sample's `ContactBook`) -lets you write `book = ContactBook()`; named constructors read as -`store = Store.open("/tmp/cache.kv")`. Methods on the C ABI take the -receiver as the leading argument (`weaveffi_kv_Store_put(self._ptr, -...)`), and functions elsewhere in the IDL accept or return the wrapper -directly (`get_stats(store)` passes `store._ptr`). Deprecated members -emit `DeprecationWarning` at call time. +- **Construction.** A constructor named `new` renders as `__init__` + (the `events` sample's `EventBus()`); any other constructor becomes a + `@classmethod` factory (`Store.open("/tmp/cache.kv")`). An interface + without a `new` constructor cannot be instantiated directly; its + `__init__` raises `TypeError`. Methods are instance methods, statics + are `@staticmethod`s, and deprecated members emit `DeprecationWarning` + at call time. +- **Disposal.** `close()` releases the wrapper's reference through the + `_destroy` symbol; it's idempotent, and the wrapper is a context + manager (`with Store.open(path) as store:`). `__del__` is a backstop + that closes an unclosed wrapper when the garbage collector reaches it. + The producer object itself is dropped only when the last reference + anywhere (any Python wrapper, the producer's own clones) is released. +- **Use after close.** `_borrow(obj)` is the only way a wrapper's + pointer reaches the producer. On a closed wrapper it raises + `WeaveFFIError(-1, "Store used after close()")`, whether the wrapper + is the receiver, a parameter, or a field inside a record or list being + packed: + + ```python + def _borrow(obj): + """The raw pointer of a live object wrapper, lent to the producer for the + duration of one call. Raises if the wrapper was already closed.""" + _p = obj._ptr + if _p is None: + raise WeaveFFIError(-1, f"{type(obj).__name__} used after close()") + return _p + ``` + +- **Copies mint new references.** A method that returns the receiver or + another existing object (`share()`, `fork()`) hands back a fresh + strong reference, adopted by `_from_ptr` into a new wrapper; closing + one wrapper never affects the other. The same holds for every object + read out of a value buffer. + +### Nullable objects, and objects inside values + +An `Interface?` parameter is passed as NULL for `None` and an +`Interface?` return maps a NULL pointer to `None`: ```python -import asyncio -from kvstore import EntryKind, Store +def larger(self, other: Optional["Store"]) -> Optional["Store"]: + _fn = _lib.weaveffi_kv_Store_larger + _fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = ctypes.c_void_p + _err = _WeaveFFIErrorStruct() + _result = _fn(_borrow(self), (_borrow(other) if other is not None else None), ctypes.byref(_err)) + _check_error(_err) + if _result is None: + return None + return Store._from_ptr(_result) +``` + +Objects inside records, lists, maps, and optionals travel as 8-byte +object tokens in the value buffer. Writing a token mints a new strong +reference with `_clone_ptr()`, so the value can be dropped by the +producer without touching the wrapper's own reference; reading a token +adopts the reference into a fresh wrapper. From the `StoreInfo` record +(`store: Store`, `mirror: Store?`): -store = Store.open("/tmp/cache.kv") -store.put("alpha", b"\x01", EntryKind.Persistent, None) -print(store.count(), Store.default_capacity()) -reclaimed = asyncio.run(store.compact()) +```python +def _write_StoreInfo(_w: _BufferWriter, value: "StoreInfo") -> None: + _w.write_string(value.label) + _w.write_object(value.store) + if value.mirror is None: + _w.write_option_flag(False) + else: + _w.write_option_flag(True) + _w.write_object(value.mirror) + _w.write_i64(value.count) + + +def _read_StoreInfo(_r: _BufferReader) -> "StoreInfo": + return StoreInfo( + label=_r.read_string(), + store=Store._from_ptr(_r.read_object()), + mirror=(Store._from_ptr(_r.read_object()) if _r.read_option_flag() else None), + count=_r.read_i64(), + ) ``` +`_BufferWriter.write_object` checks the wrapper is live immediately but +defers the actual clone to `finish()`, so an encoding that raises +part-way (a closed wrapper, a field of the wrong type) leaks no strong +references. `Store.open_many(paths) -> List[Store]` and +`Store.total_count(stores, extra)` in the `kvstore` sample show lists of +objects in both directions; each wrapper in a returned list owns its own +reference and must be closed (or left to `__del__`) individually. + +Iterators over objects (`iter`) adopt one reference per +`__next__`; async functions returning an object adopt the pointer inside +the completion callback and hand the wrapper to the awaiting coroutine. + ## Rich (algebraic) enums A rich (algebraic) enum is a sum type whose variants carry associated @@ -388,10 +516,115 @@ bigger = scale(circle, 3.0) # returns a brand-new Shape value Wrappers pack a `Shape` argument with the generated `_pack_Shape` codec and unpack a returned buffer with `_unpack_Shape`, freeing the returned buffer with `weaveffi_free_bytes`. Values are plain Python objects with -no native handle and no destructor. The `.pyi` stub mirrors the class +no native handle and no destructor. Variant fields of interface type +follow the object-token rules above. The `.pyi` stub mirrors the class hierarchy (nested `Tag`, variant subclasses with typed fields) for IDE and `mypy` support. +## Callback interfaces + +A `callback_interfaces:` entry becomes an abstract base class. The +consumer subclasses it, implements every abstract method, and passes +an instance wherever the API takes that type. From the `kvstore` +sample: + +```python +class EvictionListener(abc.ABC): + """ + Consumer-implemented callback interface. Subclass it, implement every abstract method, and pass an instance wherever the API takes a `EvictionListener`; the producer may call the methods from any thread until it releases the instance. An exception raised by a method is reported to the producer as WeaveFFIError.FOREIGN_ERROR_CODE (-4) and aborts the call that was in progress. + """ + + @abc.abstractmethod + def on_evict(self, entry: "Entry", reason: "EvictionReason") -> bool: + """ + An entry left the store. Returns whether the listener wants to keep + receiving notifications; `false` detaches it. + """ +``` + +```python +class Auditor(kvstore.EvictionListener): + def on_evict(self, entry, reason): + print(entry.key, reason) + return True + +store.set_eviction_listener(Auditor()) +``` + +Under the hood there's exactly one process-wide C vtable per callback +interface, built from `ctypes.CFUNCTYPE` trampolines held at module +scope so their function pointers stay valid for the process lifetime. +Passing an implementation registers it in the module-level `_cb_impls` +dict and hands the producer the integer key as its `ctx`, so the +producer never holds a raw reference to a Python object: + +```python +def _EvictionListener_on_evict_trampoline(ctx, entry_ptr, entry_len, reason, out_err): + try: + _impl = _cb_impls[ctx] + _ret = _impl.on_evict(_unpack_Entry(ctypes.string_at(entry_ptr, entry_len) if entry_ptr else b""), EvictionReason(reason)) + return 1 if _ret else 0 + except Exception as _exc: + # Never unwind through the C frame: report the failure and return a + # default; the producer aborts its call with FOREIGN_ERROR_CODE. + _lib.weaveffi_error_set(out_err, -4, str(_exc).encode("utf-8", "replace")) + return 0 + + +def _EvictionListener_vtable_free_trampoline(ctx): + # The producer's last reference is gone; it never touches `ctx` again. + _cb_impls.pop(ctx, None) +``` + +```python +def set_eviction_listener(self, listener: "EvictionListener") -> None: + _fn = _lib.weaveffi_kv_Store_set_eviction_listener + _fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(_EvictionListenerVtable), ctypes.POINTER(_WeaveFFIErrorStruct)] + _fn.restype = None + _listener_ctx = _cb_register(listener) + _err = _WeaveFFIErrorStruct() + _fn(_borrow(self), _listener_ctx, ctypes.byref(_EvictionListener_vtable), ctypes.byref(_err)) + _check_error(_err) +``` + +- **Lifetime.** The implementation stays registered (and therefore + alive) exactly as long as the producer may call it: the entry is + removed when the producer invokes the vtable's `free(ctx)`. A producer + that retains the implementation (a store's eviction listener) keeps it + alive across calls; one that doesn't (the `events` sample's + `route_once`) frees it before the call returns. The same Python object + may be passed more than once; each pass registers a new `ctx`. +- **Argument ownership.** Borrowed strings and buffers are copied before + the method runs (`_unpack_Entry(ctypes.string_at(...))`), so the + implementation may keep them. An object passed to a callback method is + owned by the implementation: the trampoline adopts it with + `_from_ptr`, so the method receives a full wrapper it may store, use + after the callback returns, and must eventually `close()` (or leave to + `__del__`). The `events` sample's `on_attached(bus)` demonstrates this; + the received `EventBus` stays usable independently of the caller's + wrapper. +- **Return values.** A method's return value is converted back to its C + representation (`bool` to `0`/`1`, a plain enum to its discriminant, a + record to a value buffer the producer frees). +- **Exceptions.** An exception escaping a method never unwinds through + the C frame. The trampoline writes `FOREIGN_ERROR_CODE` (-4) with + `str(exc)` as the message into the producer's error slot and returns a + default value; the producer aborts the call that was in progress, and + the original caller sees `WeaveFFIError` with `code == -4`. For a + callable marked `throws`, the domain mapper passes -4 through + unchanged (it's outside the domain), so `except KvError` doesn't catch + it but `except WeaveFFIError` does. The interpreter is never taken + down. +- **Threads.** The producer may call a method from any thread. `ctypes` + acquires the GIL before entering a `CFUNCTYPE` trampoline, so a + callback running on a producer thread is an ordinary Python call, but + it runs on that thread: don't touch an asyncio loop or UI toolkit from + it directly; use `loop.call_soon_threadsafe` or a queue. A blocking + producer call that waits for a callback dispatched on another thread + works only because the calling thread releases the GIL while inside + `ctypes`; a callback that itself blocks on the calling thread will + deadlock. + ## Build instructions 1. Generate the bindings: @@ -413,8 +646,11 @@ and `mypy` support. pip install -e . ``` -4. Make the shared library findable at runtime: +4. Make the shared library findable at runtime. `WEAVEFFI_LIBRARY` may + point at an exact file; otherwise the loader asks the dynamic linker + for `libweaveffi.dylib` / `libweaveffi.so` / `weaveffi.dll`: + - Any OS: `export WEAVEFFI_LIBRARY=$PWD/../../target/release/libweaveffi.dylib` - macOS: `export DYLD_LIBRARY_PATH=$PWD/../../target/release` - Linux: `export LD_LIBRARY_PATH=$PWD/../../target/release` - Windows: place `weaveffi.dll` next to your script or add its @@ -423,19 +659,41 @@ and `mypy` support. 5. Use the bindings: ```python - from weaveffi import ( - ContactType, - count_contacts, - create_contact, - get_contact, - ) - - handle = create_contact("Alice", "alice@example.com", ContactType.Work) - contact = get_contact(handle) - print(f"{contact.name} ({contact.email})") - print(f"Total: {count_contacts()}") + import asyncio + from kvstore import EntryKind, Store + + with Store.open("/tmp/cache.kv") as store: + store.put("alpha", b"\x01", EntryKind.Persistent, None) + print(store.count(), Store.default_capacity()) + for key in store.list_keys(None): + print(key) + reclaimed = asyncio.run(store.compact()) ``` +## Packaging + +`weaveffi package --target python` assembles one wheel source tree per +supplied desktop binary under `python//`, each containing +the generated package, the native library copied next to `weaveffi.py`, +and a `setup.py` that ships it as package data and forces a +platform-tagged (non-pure) wheel. The packaged loader still honours +`WEAVEFFI_LIBRARY` first, then opens the bundled library if it exists, +then falls back to the system path. Only platforms with a wheel +platform tag are emitted: + +| Platform | Wheel tag | +|----------|-----------| +| `macos-arm64` | `macosx_11_0_arm64` | +| `macos-x64` | `macosx_10_12_x86_64` | +| `linux-x64` | `manylinux2014_x86_64` | +| `linux-arm64` | `manylinux2014_aarch64` | +| `windows-x64` | `win_amd64` | + +Android and `wasm32` binaries are skipped. Build each tree with +`python -m build --wheel` and tag the result with the platform tag +listed in its `README.md`. See [Packaging](../guides/packaging.md) for the +shared workflow. + ## Memory and ownership - **Strings in:** Python `str` is encoded to UTF-8 by `_string_to_bytes` @@ -443,61 +701,56 @@ and `mypy` support. temporary buffer. - **Strings out:** owned `const char*` returns come back as raw addresses; `_take_string` copies the text and immediately calls - `weaveffi_free_string` on the producer's buffer. (`_bytes_to_string` - is reserved for borrowed strings, such as listener callback - parameters, which the wrapper must not free.) -- **Bytes:** copied in via a ctypes array, copied out via slicing - (`_result[:_out_len.value]`); the wrapper then releases the - producer's buffer with `weaveffi_free_bytes`. + `weaveffi_free_string` on the producer's buffer. `_bytes_to_string` + handles borrowed strings, such as callback-method parameters, which + the wrapper must not free. +- **Bytes:** copied in via a ctypes array, copied out via + `ctypes.string_at`; the wrapper then releases the producer's buffer + with `weaveffi_free_bytes`. - **Buffered values out (structs, rich enums, optionals, lists, maps):** the wrapper decodes the returned value buffer into plain Python values - (dataclasses, `dict`s, `list`s, `None`), then releases the one buffer - with `weaveffi_free_bytes`. Nothing is freed per element. + (dataclasses, `dict`s, `list`s, `None`) with `_take_buffer`, which + copies then releases the one buffer with `weaveffi_free_bytes`. + Nothing is freed per element; object tokens are adopted into wrappers. - **Buffered values in:** the wrapper packs the Python value with the generated codec into a `bytes` object it owns for the duration of the - call; the producer never frees it. -- **Interfaces:** wrappers hold an opaque `c_void_p`. `__del__` calls the - matching `_destroy` C function. For deterministic cleanup, use the - `_PointerGuard` context manager: - - ```python - with _PointerGuard(ptr, _lib.weaveffi_kv_Store_destroy): - ... - ``` + call; the producer never frees it. Object tokens inside it are fresh + strong references the producer owns. +- **Interfaces:** one strong reference per wrapper, released by `close()` + (or the `with` statement) with `__del__` as the backstop. +- **Callback implementations:** pinned in `_cb_impls` until the producer + calls the vtable's `free`. ## Async support -Async IDL functions (`async: true`) are exposed as `async def` -wrappers that integrate directly with asyncio; no worker thread blocks -waiting for the result. The wrapper creates a future on the running -loop, builds a `ctypes.CFUNCTYPE` completion callback, calls the -`_async`-suffixed C launcher (which returns immediately), and awaits -the future. From the `kvstore` sample's `Store.compact`: +Async IDL functions (`async: true`) are exposed as `async def` wrappers +that integrate directly with asyncio; no worker thread blocks waiting +for the result. The wrapper creates a future on the running loop, builds +a `ctypes.CFUNCTYPE` completion callback, calls the `_async`-suffixed C +launcher (which returns immediately), and awaits the future. From the +`kvstore` sample's `Store.compact`: ```python async def compact(self) -> int: - """Reclaim space asynchronously; returns the number of bytes reclaimed - - Raises - ------ - KvError - If the call reports one of the domain's error codes. - """ _fn = _lib.weaveffi_kv_Store_compact_async _loop = asyncio.get_running_loop() _fut = _loop.create_future() def _cb_impl(context, err, result): - # Fires exactly once, on a producer thread: convert (copying - # borrowed buffers) here, then hop back to the event loop. + # Fires exactly once, on a producer thread: take ownership of + # the result here, then hop back to the event loop. _state = {"err": None, "val": None} if err and err.contents.code != 0: _code = err.contents.code _msg = err.contents.message.decode("utf-8") if err.contents.message else "" + _payload = ctypes.string_at(err.contents.payload_ptr, err.contents.payload_len) if err.contents.payload_ptr else b"" _lib.weaveffi_error_free(err) - _state["err"] = _kv_error_from(_code, _msg) + _state["err"] = _kv_error_from(_code, _msg, _payload) else: - _state["val"] = result + try: + _state["val"] = result + except Exception as _exc: + _state["err"] = _exc def _resolve(): _async_pending.pop(_token, None) @@ -523,23 +776,22 @@ async def compact(self) -> int: The completion callback fires exactly once, on an arbitrary producer thread. Result buffers passed to it (strings, bytes, and the serialized value buffers of buffered results) are owned by the consumer, so the -wrapper copies or decodes them inside the callback and then releases -them with `weaveffi_free_string` or `weaveffi_free_bytes` (the -`_take_string` and `_take_buffer` helpers pair the copy with the -free). A reported error is heap-boxed and released with -`weaveffi_error_free` after its fields are copied. Owned interface -results transfer ownership too: the callback adopts the pointer into a -wrapper class. Conversion happens on the producer thread; the +wrapper copies or decodes them inside the callback and releases them +with `weaveffi_free_string` or `weaveffi_free_bytes`. A reported error +is heap-boxed and released with `weaveffi_error_free` after its fields +are copied. An object result is adopted into a wrapper with `_from_ptr` +inside the callback. Conversion happens on the producer thread; the wrapper then hops back to the event loop with `loop.call_soon_threadsafe` to resolve the future, since asyncio futures must not be touched from foreign threads. When the callable is marked `throws: true`, an error reported through -the callback is mapped through the domain mapper (here -`_kv_error_from`) and set as the future's exception, so `await` raises -the typed error. For a non-throwing callable a non-zero code can only -be a producer bug; the wrapper raises the generic `WeaveFFIError` -rather than swallowing it. +the callback is mapped through the domain mapper (here `_kv_error_from`) +and set as the future's exception, so `await` raises the typed error. +For a non-throwing callable a non-zero code can only be a producer bug +or a raising callback; the wrapper raises the generic `WeaveFFIError` +rather than swallowing it. A panic inside the spawned future surfaces +as `PANIC_ERROR_CODE` (-2). Each callback trampoline is pinned in the module-level `_async_pending` dict until completion, so the GC cannot collect an object the producer @@ -547,125 +799,41 @@ still holds, even if the awaiting coroutine is cancelled. A cancelled future is never resolved, but the native operation itself keeps running. -Async interface methods work the same way as bound methods: the -receiver pointer is passed as the launcher's leading argument. - For functions marked `cancellable: true` the C launcher takes an extra cancel-token parameter; the Python wrapper always passes `None` (NULL) -for it, as in the `compact` example above. The token is not exposed, -so cancelling the awaiting asyncio task does not stop the native -operation. Cancellation tokens are currently surfaced only by the C -and C++ targets. - -## Callbacks and listeners - -IDL `callbacks` declare a C function-pointer type; a `listener` pairs -one with register/unregister entry points: - -```yaml -callbacks: - - name: OnMessage - params: - - { name: message, type: string } -listeners: - - name: message_listener - event_callback: OnMessage -``` - -Each listener becomes a register/unregister pair of module functions. -Registering wraps the Python callable in a `ctypes.CFUNCTYPE` -trampoline that decodes each C slot, and returns a `uint64` -subscription id: - -```python -_CFUNC_weaveffi_events_OnMessage_fn = ctypes.CFUNCTYPE( - None, ctypes.c_char_p, ctypes.c_void_p) - - -def register_message_listener(callback: Callable[[str], None]) -> int: - def _trampoline(message, _context): - callback(_bytes_to_string(message)) - _cfunc = _CFUNC_weaveffi_events_OnMessage_fn(_trampoline) - _fn = _lib.weaveffi_events_register_message_listener - _fn.argtypes = [_CFUNC_weaveffi_events_OnMessage_fn, ctypes.c_void_p] - _fn.restype = ctypes.c_uint64 - _listener_id = int(_fn(_cfunc, None)) - _listener_refs[_listener_id] = _cfunc - return _listener_id - - -def unregister_message_listener(listener_id: int) -> None: - _fn = _lib.weaveffi_events_unregister_message_listener - # ... - _fn(ctypes.c_uint64(listener_id)) - _listener_refs.pop(listener_id, None) -``` - -- **GC safety**: the ctypes function object is pinned in the - module-level `_listener_refs` dict, keyed by subscription id, so the - garbage collector cannot reclaim a trampoline the producer may still - call. Unregistering drops the reference. -- **Subscription ids**: registration returns the `uint64` id produced - by `weaveffi_events_register_message_listener(fn, context)`; pass it - to `unregister_message_listener` to stop delivery and release - the trampoline. -- **Threading**: the callback fires on the producer's thread, not the - thread that registered it. Do not block inside it; if results must - reach an asyncio loop or UI thread, marshal them yourself (e.g. with - `loop.call_soon_threadsafe`). - -Typical round trip: - -```python -listener_id = register_message_listener(lambda m: print(m)) -send_message("hello") -unregister_message_listener(listener_id) -``` +for it, as in the `compact` example above. The token is not exposed, so +cancelling the awaiting asyncio task does not stop the native operation. +Cancellation tokens are currently surfaced only by the C and C++ +targets. ## Iterators -Functions returning `iter` receive an opaque iterator handle from -the C ABI (`weaveffi_events_get_messages`) and wrap it in a generated -lazy iterator class. The wrapper returns immediately; nothing is -drained, and each consumer step issues exactly one producer `next` -call (`weaveffi_events_GetMessagesIterator_next`). The signature is -annotated `Iterator[str]`: - -```python -def get_messages() -> Iterator[str]: - """ - Return an iterator over all sent messages - - Returns a lazy iterator: each step pulls one element from the producer. Exhaust or close() the iterator to release its native handle (garbage collection also releases it). - """ - _fn = _lib.weaveffi_events_get_messages - _fn.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)] - _fn.restype = ctypes.c_void_p - _err = _WeaveFFIErrorStruct() - _result = _fn(ctypes.byref(_err)) - _check_error(_err) - return _GetMessagesIterator(_result) -``` - -The per-function iterator class implements the Python iterator -protocol. Each `__next__` pulls one element, checks the step's error -slot, and copies the yielded string with `_take_string` (which also -frees the producer's buffer per element): +Functions returning `iter` receive an opaque iterator handle from the +C ABI and wrap it in a generated lazy iterator class. The wrapper +returns immediately; nothing is drained, and each consumer step issues +exactly one producer `next` call. The signature is annotated +`Iterator[T]`, so `for` loops, `list(...)`, and `next(...)` all work. +From the `kvstore` sample's `Store.list_keys`: ```python -class _GetMessagesIterator: +class _StoreListKeysIterator: """Lazy iterator over a producer stream: each step pulls one element across the C boundary. The native handle is released exactly once, on exhaustion, on close(), or when the iterator is garbage collected.""" + def __iter__(self): + return self + def __next__(self): if self._done: raise StopIteration - # ... argtypes/restype for _next_fn ... + _next_fn = _lib.weaveffi_kv_Store_ListKeysIterator_next + _next_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(_WeaveFFIErrorStruct)] + _next_fn.restype = ctypes.c_int32 _out_item = ctypes.c_void_p() _err = _WeaveFFIErrorStruct() _has = _next_fn(self._ptr, ctypes.byref(_out_item), ctypes.byref(_err)) - _check_error(_err) + _check_kv_error(_err) if not _has: self._done = True self._destroy() @@ -676,30 +844,63 @@ class _GetMessagesIterator: """Release the native iterator without draining it.""" self._done = True self._destroy() + + def __del__(self): + self._destroy() ``` -The native handle is destroyed exactly once: eagerly on exhaustion, -via `close()` when iteration is abandoned early, or from `__del__` as -a garbage-collection backstop. `_destroy` nulls the stored pointer, so -a double destroy is impossible. +The native handle is destroyed exactly once: eagerly on exhaustion, via +`close()` when iteration is abandoned early (a second `close()` is a +no-op), or from `__del__` as a garbage-collection backstop. `_destroy` +nulls the stored pointer, so a double destroy is impossible. + +Each yielded element is owned by the consumer: strings are copied and +freed with `_take_string`, buffered elements are decoded and freed with +`_take_buffer`, and an object element is adopted into a wrapper with +`_from_ptr` (or `None` for a NULL `Interface?` element). Errors from the launcher and from each `next` follow the function's -error strategy. A throwing iterator such as the `kvstore` sample's -`Store.list_keys` checks each step with `_check_kv_error` and raises -the typed domain error (`KeyNotFound`, `IoError`, ...) from the step -that failed; a non-throwing iterator like `get_messages` raises the -generic `WeaveFFIError` only for producer bugs. +error strategy. A throwing iterator such as `list_keys` checks each step +with `_check_kv_error` and raises the typed domain error (`KeyNotFound`, +`IoError`, ...) from the step that failed; a non-throwing iterator +raises the generic `WeaveFFIError` only for producer bugs. + +## Known limitations + +- `ctypes` dispatch is slower than a compiled extension; hot loops + crossing the boundary per element will feel it. +- Async cancellation doesn't propagate: cancelling the awaiting task + leaves the native operation running, and `cancellable: true` tokens + are not exposed. +- Callback methods run on whatever thread the producer uses, under the + GIL; long-running callbacks stall the producer thread, and a callback + that blocks waiting for the calling thread deadlocks. +- Interface wrappers without a `new` constructor can't be instantiated + from Python; use the generated factories. +- The plain `generate` output relies on the dynamic linker (or + `WEAVEFFI_LIBRARY`) to find the library; only `weaveffi package` + bundles it. ## Troubleshooting -- **`OSError: cannot find ...`**: the loader could not locate the - shared library. Set `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` or copy - the library next to your script. -- **`WeaveFFIError: ...`**: the Rust side returned a non-zero error - code. Catch `WeaveFFIError` and inspect `.code` / `.message`. -- **`AttributeError: ... has no attribute 'argtypes'`**: the wrapper - sets `argtypes`/`restype` at the call site; ensure you're calling - the generated function, not reaching into `_lib` directly. -- **Garbage-collected interface object still referenced from Rust**: keep - a Python reference until you're done; Python will call `__del__` only - after the last reference is dropped. +- **`OSError: cannot find ...`**: the loader could not locate the shared + library. Set `WEAVEFFI_LIBRARY`, `DYLD_LIBRARY_PATH` / + `LD_LIBRARY_PATH`, or copy the library next to your script. +- **`ImportError: WeaveFFI ABI mismatch`**: the library was built by a + different `weaveffi` release than the bindings. Regenerate the bindings + and rebuild the library together. +- **`WeaveFFIError: (-1) Store used after close()`**: a closed wrapper + was passed to the producer (as the receiver, a parameter, or a field + of a record or list). Keep the wrapper open for as long as it's in + use. +- **`WeaveFFIError: (-4) ...`**: a callback-interface method you + implemented raised; the message is `str(exc)`. Catch the exception + inside the method if the producer call should succeed anyway. +- **`WeaveFFIError: ...` with a positive code from a non-throwing + call**: the Rust side returned a domain error the IDL didn't declare + as `throws`; inspect `.code` / `.message`. +- **`TypeError: Store cannot be instantiated directly`**: the interface + has no `new` constructor; call its `@classmethod` factory instead. +- **`RuntimeError: no running event loop`**: `async def` wrappers need a + running loop; call them with `asyncio.run(...)` or from inside a + coroutine. diff --git a/docs/src/generators/ruby.md b/docs/src/generators/ruby.md index 6ee4c0f7..7113522b 100644 --- a/docs/src/generators/ruby.md +++ b/docs/src/generators/ruby.md @@ -3,10 +3,18 @@ ## Overview The Ruby target produces pure-Ruby FFI bindings using the -[ffi](https://github.com/ffi/ffi) gem to call the C ABI directly. There's -no native extension to compile; `gem install ffi` is the only -prerequisite. The generator emits a single `.rb` file plus a `gemspec` -ready for `gem build` and `gem install`. +[ffi](https://github.com/ffi/ffi) gem to call the C ABI (revision 2) +directly. There's no native extension to compile; `gem install ffi` is +the only prerequisite. The generator emits a single `.rb` file plus a +`gemspec` ready for `gem build` and `gem install`. + +Interfaces become reference-counted wrapper classes (`close` plus a GC +finalizer backstop, `dup`/`clone` for a second reference), records and +rich enums become value classes packed into value buffers (with +interfaces inside them carried as object tokens), callback interfaces +become duck-typed Ruby modules backed by one static vtable of pinned +`FFI::Function` trampolines per interface, async functions block on a +`Queue`, and `iter` returns are lazy `Enumerator`s. The trade-off is that FFI gem calls are slower than a hand-written C extension. For typical FFI workloads the overhead is negligible compared @@ -16,48 +24,71 @@ to the work done inside the Rust library. | File | Purpose | |------|---------| -| `ruby/lib/weaveffi.rb` | FFI bindings: library loader, `attach_function` declarations, wrapper classes | +| `ruby/lib/weaveffi.rb` | FFI bindings: library loader, ABI check, `attach_function` declarations, codecs, wrapper classes | | `ruby/weaveffi.gemspec` | Gem specification with `ffi ~> 1.15` dependency | | `ruby/README.md` | Prerequisites and usage instructions | -The file names follow the gem name (IDL `package.name`): a package -named `events` produces `lib/events.rb` and `events.gemspec`; -`weaveffi` is the default. +The file names follow the gem name (IDL `package.name`): a package named +`kvstore` produces `lib/kvstore.rb`, `kvstore.gemspec`, and +`module Kvstore`; `weaveffi` is the default. The module verifies the +producer's ABI revision at `require` time: + +```ruby + ABI_VERSION = 2 + begin + attach_function :weaveffi_abi_version, [], :uint32 + rescue FFI::NotFoundError + raise LoadError, 'the loaded WeaveFFI library predates ABI versioning ' \ + "(these bindings expect ABI revision #{ABI_VERSION})" + end + _wv_abi = weaveffi_abi_version + unless _wv_abi == ABI_VERSION + raise LoadError, "WeaveFFI ABI mismatch: these bindings expect revision #{ABI_VERSION} " \ + "but the loaded library reports revision #{_wv_abi}" + end +``` ## Type mapping | IDL type | Ruby type | FFI type | |--------------|--------------------|--------------------------------| -| `i32` | `Integer` | `:int32` | -| `u32` | `Integer` | `:uint32` | -| `i64` | `Integer` | `:int64` | -| `f64` | `Float` | `:double` | -| `i8` | `Integer` | `:int8` | -| `i16` | `Integer` | `:int16` | -| `u8` | `Integer` | `:uint8` | -| `u16` | `Integer` | `:uint16` | -| `u64` | `Integer` | `:uint64` | -| `f32` | `Float` | `:float` | +| `i8`, `i16`, `i32`, `i64` | `Integer` | `:int8`, `:int16`, `:int32`, `:int64` | +| `u8`, `u16`, `u32`, `u64` | `Integer` | `:uint8`, `:uint16`, `:uint32`, `:uint64` | +| `f32`, `f64` | `Float` | `:float`, `:double` | | `bool` | `true`/`false` | `:int32` (0/1 conversion) | | `string` | `String` | `:string` (param) / `:pointer` (return) | | `bytes` | `String` (binary) | `:pointer` + `:size_t` | -| `handle` | `Integer` | `:uint64` | | `Struct` | `StructName` (plain class) | value buffer (`:pointer` + `:size_t`) | -| `Interface` | `InterfaceName` | `:pointer` | -| `Enum` (plain) | `Integer` | `:int32` | -| `Enum` (rich) | `EnumName` (nested variant classes) | value buffer (`:pointer` + `:size_t`) | -| `T?` | `T` or `nil` | value buffer; `Interface?` stays a nullable `:pointer` | -| `[T]` | `Array` | value buffer (`:pointer` + `:size_t`) | -| `{K: V}` | `Hash` | value buffer (`:pointer` + `:size_t`) | +| `Enum` (plain) | `Integer` (constants in a module) | `:int32` | +| `Enum` (rich) | `EnumName` (nested variant classes) | value buffer | +| `Interface` | `InterfaceName` (wrapper class) | `:pointer` | +| `Interface?` | `InterfaceName` or `nil` | `:pointer` (NULL for `nil`) | +| `CallbackInterface` | any object responding to the methods (include the module) | `:pointer` ctx + vtable `:pointer` | +| `T?` | `T` or `nil` | value buffer | +| `[T]` | `Array` | value buffer | +| `{K: V}` | `Hash` | value buffer | | `iter` | `Enumerator` (lazy) | `:pointer` iterator handle | -Booleans cross as `:int32` (`0`/`1`); the wrapper converts both -directions. +Buffered types cross the boundary serialized in the +[value-buffer format](../reference/value-buffers.md); the module carries +private `WvBufferWriter`/`WvBufferReader` classes plus one +`_wv_write_*`/`_wv_read_*` pair per record and rich enum. Objects +nested inside a buffered value travel as object tokens (see +[Objects](#objects-interfaces)). Booleans cross as `:int32` (`0`/`1`); +the wrapper converts both directions. -## Example IDL → generated code +### 64-bit integers and floats + +Ruby's `Integer` is arbitrary precision, so `i64` and `u64` round-trip +exactly through the `:int64`/`:uint64` FFI types and the value-buffer +codec's `write_i64`/`write_u64` (`String#pack` with `q<`/`Q<`). +`f32`/`f64` map to `Float`; the `codec` conformance consumer verifies +NaN, both infinities, and `-0.0` survive a round trip bit-for-bit. + +## Example IDL and generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -82,11 +113,6 @@ modules: - { name: first_name, type: string } - { name: email, type: "string?" } - { name: contact_type, type: ContactType } - return: handle - - - name: get_contact - params: - - { name: id, type: handle } return: Contact - name: list_contacts @@ -94,8 +120,8 @@ modules: return: "[Contact]" ``` -The generated module extends `FFI::Library` and selects the right -shared library at load time: +The generated module extends `FFI::Library` and selects the right shared +library at load time: ```ruby require 'ffi' @@ -154,86 +180,64 @@ class Contact end ``` -A `Contact` crosses the ABI serialized in the -[value-buffer format](../reference/value-buffers.md) as a single -pointer-plus-length pair; the module carries private -`_wv_write_contact`/`_wv_read_contact` codec helpers over a small buffer -writer and reader. - Functions are snake_case class methods on the module, with the IDL module prefix stripped by default (a `kv.open_store` function surfaces as `open_store`, not `kv_open_store`; the `attach_function` bindings keep the full C symbol names). Set `strip_module_prefix: false` in the -Ruby generator config (or under `[global]`) to keep prefixed names: +Ruby generator config (or under `[global]`) to keep prefixed names. A +buffered return is copied out, freed, and decoded; from the `kvstore` +sample's cross-module `get_stats`: ```ruby -def self.create_contact(first_name, email, contact_type) - err = ErrorStruct.new - # Optionals are buffered: pack the argument into a value buffer that - # the producer borrows for the duration of the call. - email_buf = ... # generated pack code writes the optional into a binary String - result = weaveffi_contacts_create_contact( - first_name, email_buf, email_buf.bytesize, contact_type, err) - check_error!(err) - result -end - -def self.get_contact(id) - err = ErrorStruct.new - out_len = FFI::MemoryPointer.new(:size_t) - result = weaveffi_contacts_get_contact(id, out_len, err) - check_error!(err) - # Decode the returned value buffer, then release it. - len = out_len.read(:size_t) - value = _wv_read_contact(WvBufferReader.new(result.read_string(len))) - weaveffi_free_bytes(result, len) - value -end -``` - -The shared error machinery: - -```ruby -class ErrorStruct < FFI::Struct - layout :code, :int32, - :message, :pointer, - :payload_ptr, :pointer, - :payload_len, :size_t -end - -class Error < StandardError - attr_reader :code - - def initialize(code, message) - @code = code - super(message) + def self.get_stats(store) + err = ErrorStruct.new + out_len = FFI::MemoryPointer.new(:size_t) + result = weaveffi_kv_stats_get_stats(store.handle, out_len, err) + check_kv_error!(err) + len = out_len.read(:size_t) + data = result.null? ? ''.b : result.read_string(len) + weaveffi_free_bytes(result, len) unless result.null? + _wv_r = WvBufferReader.new(data) + _wv_value = _wv_read_stats(_wv_r) + _wv_r.expect_end! + _wv_value end -end - -def self.check_error!(err) - return if err[:code].zero? - code = err[:code] - msg_ptr = err[:message] - msg = msg_ptr.null? ? '' : msg_ptr.read_string - weaveffi_error_clear(err.to_ptr) - raise Error.new(code, msg) -end ``` -Catch errors with standard `begin`/`rescue`: +## Typed errors + +The shared error machinery: an `ErrorStruct` mirroring the C error +slot, the four runtime trap codes as module constants, and a generic +`Error`: ```ruby -require 'weaveffi' + class ErrorStruct < FFI::Struct + layout :code, :int32, + :message, :pointer, + :payload_ptr, :pointer, + :payload_len, :size_t + end -begin - handle = WeaveFFI.create_contact("Alice", nil, WeaveFFI::ContactType::WORK) -rescue WeaveFFI::Error => e - puts "Error #{e.code}: #{e.message}" -end + # The runtime-reserved error codes. Positive codes belong to a module's + # declared error domain; these negative codes are programming errors the + # wrappers raise as a plain Error regardless of the function's `throws`. + GENERIC_ERROR_CODE = -1 + PANIC_ERROR_CODE = -2 + MARSHAL_ERROR_CODE = -3 + # A callback-interface implementation raised; the message carries the + # exception text. + FOREIGN_ERROR_CODE = -4 + + class Error < StandardError + attr_reader :code + + def initialize(code, message) + @code = code + super(message) + end + end ``` -## Typed errors - A module's error domain adds a base class extending `Error` with one nested class per code, each pinning its stable `CODE`, plus a mapper that falls back to the generic `Error` for codes outside the domain. @@ -256,7 +260,7 @@ end # Builds the KvError subclass matching `code`, or a generic Error # for codes outside the domain (panics, marshalling). -def self.kv_error_from(code, message) +def self.kv_error_from(code, message, payload = nil) cls = KV_ERROR_CODES[code] return Error.new(code, message) if cls.nil? message.empty? ? cls.new : cls.new(message) @@ -267,98 +271,189 @@ Only callables marked `throws: true` in the IDL raise the typed classes: their wrappers call `check_kv_error!`, so you can rescue `Kvstore::KvError::KeyNotFound` for one code or `Kvstore::KvError` for the whole domain. A callable without `throws` uses the generic -`check_error!`, which raises `Error` only if the producer misbehaves. +`check_error!`, which raises `Error` if the producer misbehaves. Both +copy the message (and payload) out and release the slot with +`weaveffi_error_clear` before raising. -An error code that declares payload `fields:` carries them serialized -in the error's payload buffer (`payload_ptr`/`payload_len` on +An error code that declares payload `fields:` carries them serialized in +the error's payload buffer (`payload_ptr`/`payload_len` on `ErrorStruct`); the domain factory decodes them into attributes on the -raised error object before `weaveffi_error_clear` releases the buffer. - -## Interfaces - -An `interfaces:` entry becomes a class wrapping an `FFI::AutoPointer` -subclass, so the C destructor runs when Ruby garbage-collects the -wrapper. Constructors become class methods (`Store.open`; a -constructor named `new` maps to the ordinary `Store.new`), methods are -snake_case instance methods, statics are class methods, and `destroy` -frees the native object deterministically. From the `kvstore` sample -(trimmed): +raised error object before the buffer is released. ```ruby -class StorePtr < FFI::AutoPointer - def self.release(ptr) - Kvstore.weaveffi_kv_Store_destroy(ptr) - end +begin + store.delete('missing') +rescue Kvstore::KvError::KeyNotFound + # specific code +rescue Kvstore::KvError => e + puts "kv error #{e.code}: #{e.message}" end +``` -# An embedded key-value store owning its entries -class Store - attr_reader :handle +### Runtime error codes - # Wraps an owned pointer the producer handed over, without - # re-running initialize. - def self._from_ptr(ptr) - obj = allocate - obj.instance_variable_set(:@handle, StorePtr.new(ptr)) - obj - end +| Code | Constant | Meaning | Where it surfaces | +|------|----------|---------|-------------------| +| `-1` | `GENERIC_ERROR_CODE` | The producer reported an error without a declared code, or the wrapper itself detected misuse (a NULL object pointer, a wrapper used after `close`). | Raised as `Error`. | +| `-2` | `PANIC_ERROR_CODE` | The Rust implementation panicked; the export macros and the async spawner catch the unwind. | Raised as `Error` (a blocking async call re-raises it on the caller's thread). | +| `-3` | `MARSHAL_ERROR_CODE` | Malformed input at the boundary (invalid UTF-8, a truncated value buffer, a bad enum discriminant). | Raised as `Error`. | +| `-4` | `FOREIGN_ERROR_CODE` | A callback-interface method implemented in Ruby raised. | Raised as `Error` from the producer call that invoked the callback (see [Callback interfaces](#callback-interfaces)). | - def destroy - return if @handle.nil? - @handle.free - @handle = nil - end +There's no non-raising call path in Ruby: a non-throwing callable whose +error slot comes back non-zero still raises `Error`. - # Open (or create) a store backed by the given filesystem path - def self.open(path) - err = ErrorStruct.new - result = Kvstore.weaveffi_kv_Store_open(path, err) - Kvstore.check_kv_error!(err) - raise Error.new(-1, 'null pointer') if result.null? - _from_ptr(result) - end +## Objects (interfaces) - def put(key, value, kind, ttl_seconds) # raises typed KvError subclasses - # ... - end +An `interfaces:` entry becomes a class holding an `FFI::AutoPointer` +subclass that owns one strong reference to a reference-counted producer +object; the `AutoPointer`'s `release` hook calls the `_destroy` symbol +when Ruby garbage-collects it. From the `kvstore` sample (trimmed): - def list_keys(prefix) # lazy Enumerator; see Iterators - # ... +```ruby + # Owns one strong reference to a Store; releases it exactly once. + class StorePtr < FFI::AutoPointer + def self.release(ptr) + Kvstore.weaveffi_kv_Store_destroy(ptr) + end end - def count() # generic check only (no throws) - # ... - end + class Store + # Adopts one strong reference the producer handed over, without + # re-running initialize. + def self._from_ptr(ptr) + obj = allocate + obj.instance_variable_set(:@handle, StorePtr.new(ptr)) + obj + end - def compact() # blocking async; see Async support - # ... - end + # The borrowed object pointer passed to producer calls. + def handle + raise Error.new(-1, 'Store used after close') if @handle.nil? + @handle + end - # Legacy single-shot put kept for compatibility - def legacy_put(key, value) - warn "[DEPRECATED] use put() with explicit kind" - # ... - end + # Whether close has released this wrapper's reference. + def closed? + @handle.nil? + end - # The largest number of live entries one store will hold - def self.default_capacity() - # ... + # Releases this wrapper's reference now rather than at GC time. + # Idempotent; the object itself is dropped when the last reference + # anywhere (another wrapper, a record field, the producer) goes. + def close + return if @handle.nil? + @handle.free + @handle = nil + end + + # Mints a new strong reference the caller owns (used when this + # object is written into a value buffer). + def _wv_clone_ptr + Kvstore.weaveffi_kv_Store_clone(handle) + end + + # dup and clone produce an independent wrapper with its own reference. + def initialize_copy(other) + super + @handle = StorePtr.new(Kvstore.weaveffi_kv_Store_clone(other.handle)) + end + + def self.open(path) + err = ErrorStruct.new + result = Kvstore.weaveffi_kv_Store_open(path, err) + Kvstore.check_kv_error!(err) + raise Error.new(-1, 'null pointer') if result.null? + _from_ptr(result) + end end -end ``` -Functions elsewhere in the IDL pass the wrapper's `handle` across the -boundary (`Kvstore.get_stats(store)` returns a new `Stats`). -Deprecated members print a `[DEPRECATED]` warning at call time: +- **Construction.** A constructor named `new` becomes an ordinary + `initialize`, so `EventBus.new` works as usual (the `events` sample); + any other constructor is a class method (`Store.open(path)`). Methods + are snake_case instance methods, statics are class methods + (`Store.default_capacity`), and deprecated members print a + `[DEPRECATED]` warning at call time. +- **Disposal.** `close` releases this wrapper's reference through the + `_destroy` symbol now; it's idempotent, and `closed?` reports the + state. If you never call `close`, the `FFI::AutoPointer` releases the + reference when the wrapper is garbage-collected. The producer object + itself is dropped only when the last reference anywhere is released. +- **Use after close.** Every call goes through `handle`, which raises + `Error.new(-1, 'Store used after close')` on a closed wrapper, whether + it's the receiver, a parameter, or a field of a record being packed. +- **Copies mint new references.** `dup` and `clone` are overridden via + `initialize_copy` to mint a new strong reference, so the copy is an + independent wrapper; closing one never affects the other. Methods that + return the receiver or an existing object (`share`, `fork`) likewise + return a fresh reference adopted into a new wrapper. ```ruby store = Kvstore::Store.open('/tmp/cache.kv') -store.put('alpha', "\x01".b, Kvstore::EntryKind::PERSISTENT, nil) -puts "#{store.count} / #{Kvstore::Store.default_capacity}" -reclaimed = store.compact -store.destroy +begin + store.put('alpha', "\x01".b, Kvstore::EntryKind::PERSISTENT, nil) + puts "#{store.count} / #{Kvstore::Store.default_capacity}" + reclaimed = store.compact +ensure + store.close +end ``` +### Nullable objects, and objects inside values + +An `Interface?` parameter passes `nil` as NULL (`other&.handle`), and an +`Interface?` return maps a NULL pointer to `nil`: + +```ruby + def larger(other) + err = ErrorStruct.new + result = Kvstore.weaveffi_kv_Store_larger(handle, other&.handle, err) + Kvstore.check_error!(err) + return nil if result.null? + Store._from_ptr(result) + end +``` + +Objects inside records, arrays, hashes, and optionals travel as 8-byte +object tokens in the value buffer. Writing a token mints a new strong +reference with `_wv_clone_ptr`; reading one adopts the reference into a +fresh wrapper. From the `StoreInfo` record (`store: Store`, +`mirror: Store?`): + +```ruby + def self._wv_write_store_info(w, v) + w.write_string(v.label) + w.write_u64(v.store._wv_clone_ptr.address) + if v.mirror.nil? + w.write_flag(false) + else + w.write_flag(true) + w.write_u64(v.mirror._wv_clone_ptr.address) + end + w.write_i64(v.count) + end + + def self._wv_read_store_info(r) + _wv_label = r.read_string + _wv_store = Store._from_ptr(r.read_object_token) + if r.read_flag + _wv_mirror = Store._from_ptr(r.read_object_token) + else + _wv_mirror = nil + end + _wv_count = r.read_i64 + StoreInfo.new(label: _wv_label, store: _wv_store, mirror: _wv_mirror, count: _wv_count) + end +``` + +Arrays of objects work the same way in both directions +(`Store.open_many(paths)` returns an `Array` of `Store`, +`Store.total_count(stores, extra)` takes one); each wrapper in a returned +array owns its own reference and should be closed (or left to GC) +individually. Iterators over objects adopt one reference per pull, and a +blocking async call returning an object adopts the pointer inside the +completion callback. + ## Rich (algebraic) enums A rich (algebraic) enum is a sum type whose variants carry associated @@ -367,73 +462,176 @@ rich enum instead becomes a plain class hierarchy: a base class with a `tag` reader plus one nested subclass per variant carrying that variant's fields as attributes, each pinning its `TAG` constant. Rich enums declare no C symbols; values cross the ABI serialized in value -buffers as an `i32` tag followed by the active variant's fields. - -For a `Shape` enum with variants `Empty`, `Circle { radius: f64 }`, -`Rectangle { width: f32, height: f32 }`, and `Labeled { label: string, -count: u8 }`, the generator emits: +buffers as an `i32` tag followed by the active variant's fields. From +the `codec` sample: ```ruby -# An algebraic shape (sum type with associated data) -class Shape - # The active variant's integer tag. - def tag - self.class::TAG - end + class Shape + # The active variant's integer tag. + def tag + self.class::TAG + end - # The empty shape - class Empty < Shape - TAG = 0 - end + # No payload. + class Empty < Shape + TAG = 0 - # A circle with a radius - class Circle < Shape - TAG = 1 + # Structural equality over the variant and its fields. + def ==(other) + return false unless other.is_a?(Empty) + true + end + end + + # One `f64`. + class Circle < Shape + TAG = 1 - # Radius in points - attr_reader :radius + # Radius. + attr_reader :radius + + def initialize(radius:) + @radius = radius + end - def initialize(radius:) - @radius = radius + # Structural equality over the variant and its fields. + def ==(other) + return false unless other.is_a?(Circle) + # ... + end end end +``` - # A labeled shape with a small count - class Labeled < Shape - TAG = 3 +Construct variants directly and branch with `case`/`when` on the class: - attr_reader :label - attr_reader :count +```ruby +circle = Codec::Shape::Circle.new(radius: 2.0) - def initialize(label:, count:) - @label = label - @count = count +case circle +when Codec::Shape::Circle + puts circle.radius # 2.0 +when Codec::Shape::Empty + puts 'empty' +end +``` + +Values are plain Ruby data; there's no native handle and nothing to +close. The private `_wv_write_shape`/`_wv_read_shape` codec helpers +write and read the tagged wire format; variant fields of interface type +follow the object token rules above. + +## Callback interfaces + +A `callback_interfaces:` entry becomes a Ruby module. Any object that +responds to the methods is accepted (duck typing); including the module +gives you `NotImplementedError` defaults for the methods you don't +override. From the `kvstore` sample: + +```ruby + # Consumer-implemented callback interface. Any object responding to the + # methods below is accepted wherever a EvictionListener parameter is expected; + # include this module to inherit NotImplementedError defaults. The + # producer may call the methods from any thread until it releases the + # implementation. + module EvictionListener + # An entry left the store. Returns whether the listener wants to keep + # receiving notifications; `false` detaches it. + # @return [Object] a bool + def on_evict(entry, reason) + raise NotImplementedError, "#{self.class}#on_evict is not implemented" end end +``` + +```ruby +class Auditor + include Kvstore::EvictionListener + + def on_evict(entry, reason) + puts "#{entry.key}: #{reason}" + true + end end + +store.set_eviction_listener(Auditor.new) ``` -Construct variants directly and branch with `case`/`when` on the class: +Behind the module is one process-wide vtable per callback interface, +whose entries are `FFI::Function` trampolines held in constants so they +live for the process lifetime. Passing an implementation registers it in +a mutex-guarded registry under an integer key; the key (as an +`FFI::Pointer`) crosses as `ctx`, so the producer never holds a Ruby +object: ```ruby -require 'weaveffi' + # Trampoline for EvictionListener#on_evict. + WV_EVICTION_LISTENER_ON_EVICT = FFI::Function.new(:int32, [:pointer, :pointer, :size_t, :int32, :pointer]) do |ctx, entry_ptr, entry_len, reason, out_err| + begin + impl = _wv_cb_lookup(ctx) + entry_r = WvBufferReader.new(entry_ptr.null? ? ''.b : entry_ptr.read_string(entry_len)) + entry_v = _wv_read_entry(entry_r) + entry_r.expect_end! + reason_v = reason + impl.on_evict(entry_v, reason_v) ? 1 : 0 + rescue Exception => e + _wv_cb_fail(out_err, e) + 0 + end + end -circle = WeaveFFI::Shape::Circle.new(radius: 2.0) -labeled = WeaveFFI::Shape::Labeled.new(label: 'unit', count: 3) + # Releases a EvictionListener implementation when the producer drops its last + # reference. + WV_EVICTION_LISTENER_FREE = FFI::Function.new(:void, [:pointer]) do |ctx| + _wv_cb_free(ctx) + end -case circle -when WeaveFFI::Shape::Circle - puts circle.radius # 2.0 -end -puts labeled.count # 3 + WV_EVICTION_LISTENER_VTABLE = WvEvictionListenerVtable.new + WV_EVICTION_LISTENER_VTABLE[:on_evict] = WV_EVICTION_LISTENER_ON_EVICT + WV_EVICTION_LISTENER_VTABLE[:free] = WV_EVICTION_LISTENER_FREE +``` -puts WeaveFFI.describe(circle) # packs the buffer via the C ABI -bigger = WeaveFFI.scale(circle, 3.0) # returns a new Shape value +```ruby + def set_eviction_listener(listener) + err = ErrorStruct.new + listener_ctx = Kvstore._wv_cb_register(listener) + Kvstore.weaveffi_kv_Store_set_eviction_listener(handle, listener_ctx, WV_EVICTION_LISTENER_VTABLE.to_ptr, err) + Kvstore.check_error!(err) + end ``` -Values are plain Ruby data; there's no native handle and nothing to -destroy. The private `_wv_write_shape`/`_wv_read_shape` codec helpers -write and read the tagged wire format. +- **Lifetime.** The registry entry keeps the implementation alive + exactly as long as the producer may call it; the vtable's `free` + trampoline removes it when the producer drops its last reference. A + producer that retains the implementation (a store's eviction listener) + keeps it alive across calls; one that doesn't (the `events` sample's + `route_once`) frees it before returning. Passing the same object twice + registers two entries. +- **Argument ownership.** Borrowed strings and buffers are copied into + Ruby values before the method runs, so the implementation may keep + them. An object passed to a callback method is owned by the + implementation: the trampoline adopts it into a new wrapper + (`impl.on_attached(EventBus._from_ptr(bus))` in the `events` sample), + and the implementation should `close` it when done (or let GC do it). +- **Return values.** A method's return value is converted back to its C + representation (truthiness to `1`/`0`, an enum constant as its + `:int32`, a record as a value buffer the producer frees). +- **Exceptions.** Any exception escaping a method (including the + `NotImplementedError` default) never unwinds through the C frame. + `_wv_cb_fail` writes `FOREIGN_ERROR_CODE` (-4) with the exception's + message into the producer's error slot, and the trampoline returns a + default; the producer aborts the call in progress, and the original + caller sees `Error` with `code == -4`. For a callable marked `throws`, + the domain mapper falls through to `Error` (so `rescue KvError` + doesn't catch it but `rescue Kvstore::Error` does). The VM is never + taken down. +- **Threads.** The producer may call a method from any thread. The ffi + gem dispatches a callback arriving on a non-Ruby thread onto the VM + by acquiring the GVL first, so the method runs as ordinary Ruby code + on that thread. A blocking producer call that waits for a callback + dispatched on another thread works because the ffi gem releases the + GVL around the C call; a callback that itself blocks waiting on the + calling thread will deadlock. ## Build instructions @@ -457,7 +655,8 @@ write and read the tagged wire format. gem install weaveffi-0.1.0.gem ``` -4. Make the cdylib findable at runtime: +4. Make the cdylib findable at runtime. `WEAVEFFI_LIBRARY` may point at + an exact file; otherwise: - macOS: `DYLD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb` - Linux: `LD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb` @@ -472,235 +671,217 @@ module_name = "MyBindings" gem_name = "my_bindings" ``` +## Packaging + +`weaveffi package --target ruby` assembles one precompiled platform gem +tree per supplied desktop binary under `ruby//`, each with +the generated library, the native library at `lib/native/`, and a +gemspec pinned to the RubyGems platform string. The packaged loader +still honours `WEAVEFFI_LIBRARY` first, then opens the bundled library +if it exists, then falls back to the system path. + +| Platform | RubyGems platform | +|----------|-------------------| +| `macos-arm64` | `arm64-darwin` | +| `macos-x64` | `x86_64-darwin` | +| `linux-x64` | `x86_64-linux` | +| `linux-arm64` | `aarch64-linux` | +| `windows-x64` | `x64-mingw-ucrt` | + +Android and `wasm32` binaries have no RubyGems platform and are skipped. +Run `gem build` in each tree to produce the platform gems. See +[Packaging](../guides/packaging.md) for the shared workflow. + ## Memory and ownership - **Strings in:** Ruby strings are passed as `:string` parameters and the FFI gem encodes them to null-terminated C strings. - **Strings out:** the wrapper reads the returned `:pointer` with - `read_string`, then calls `weaveffi_free_string` to release the - Rust-owned buffer. + `read_string` (forcing UTF-8), then calls `weaveffi_free_string` to + release the Rust-owned buffer. - **Bytes:** an `FFI::MemoryPointer` is allocated for inputs; outputs - are copied with `read_string(len)` and the returned buffer is - released with `weaveffi_free_bytes`. -- **Interfaces:** wrappers hold an `FFI::AutoPointer` whose `release` - callback invokes the C `_destroy` function on GC. Use the explicit - `destroy` method for deterministic cleanup. -- **Buffered values (structs, rich enums, optionals, lists, maps):** - parameters are packed into a binary `String` value buffer that the - producer borrows for the duration of the call; returns are copied - into Ruby memory with `read_string(len)`, released with - `weaveffi_free_bytes`, and decoded with the `_wv_read_*` helper. - Nothing to destroy afterward. + are copied with `read_string(len)` and the returned buffer is released + with `weaveffi_free_bytes`. +- **Buffered values (structs, rich enums, optionals, arrays, hashes):** + parameters are packed into a binary `String` staged in an + `FFI::MemoryPointer` the producer borrows for the duration of the + call; returns are copied into Ruby memory with `read_string(len)`, + released with `weaveffi_free_bytes`, and decoded with the `_wv_read_*` + helper. Object tokens written into a buffer are fresh strong + references the producer owns; tokens read out are adopted into + wrappers. +- **Interfaces:** one strong reference per wrapper, released by `close` + or by the `FFI::AutoPointer` on GC. +- **Callback implementations:** held in the module's registry until the + producer calls the vtable's `free`. ## Async support Async IDL functions (`async: true`) are exposed as blocking wrapper methods. The wrapper creates a `Queue`, builds an `FFI::Function` -completion callback that pushes either the converted result or an -error onto it, calls the `_async`-suffixed C launcher, then pops the -queue and raises if the producer reported an error. For a callable -marked `throws: true`, the error goes through the domain mapper -(`task_error_from` here, `kv_error_from` on `Store#compact`), so the -raised object is the typed class: +completion callback that pushes either the converted result or an error +onto it, calls the `_async`-suffixed C launcher, then pops the queue and +raises if the producer reported an error. For a callable marked +`throws: true`, the error goes through the domain mapper +(`kv_error_from` here), so the raised object is the typed class. From +the `kvstore` sample's `Store#compact`: ```ruby -# Blocks until the async producer completes. -def self.run_task(name) - queue = Queue.new - callback = FFI::Function.new( - :void, [:pointer, :pointer, :pointer, :size_t] - ) do |_context, err_ptr, result_ptr, result_len| - err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) - if err && err[:code] != 0 - # ... read code/message/payload, then weaveffi_error_free ... - queue << task_error_from(code, msg) - else - # TaskResult is a record: copy the owned buffer, free it, decode. - reader = WvBufferReader.new(result_ptr.read_string(result_len)) - weaveffi_free_bytes(result_ptr, result_len) unless result_ptr.null? - queue << _wv_read_task_result(reader) + # Blocks the current thread until the async producer completes; the + # result (or error) is delivered through the completion callback (cancellation token not exposed; pass-through is NULL). + def compact() + queue = Queue.new + callback = FFI::Function.new(:void, [:pointer, :pointer, :int64]) do |_context, err_ptr, result| + err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr) + if err && err[:code] != 0 + code = err[:code] + msg = err[:message].null? ? '' : err[:message].read_string.force_encoding(Encoding::UTF_8) + payload = err[:payload_ptr].null? ? nil : err[:payload_ptr].read_string(err[:payload_len]) + Kvstore.weaveffi_error_free(err_ptr) + queue << Kvstore.kv_error_from(code, msg, payload) + else + queue << result + end + end + Kvstore.weaveffi_kv_Store_compact_async(handle, FFI::Pointer::NULL, callback, FFI::Pointer::NULL) + value = queue.pop + raise value if value.is_a?(Error) + value end - end - weaveffi_tasks_run_task_async(name, callback, FFI::Pointer::NULL) - value = queue.pop - raise value if value.is_a?(Error) - value -end ``` -There is no promise/future type and no `concurrent-ruby` dependency: -the calling thread blocks until the completion callback fires. Wrap -the call in a `Thread` when you need concurrency: +There is no promise/future type and no `concurrent-ruby` dependency: the +calling thread blocks until the completion callback fires. `Queue#pop` +releases the GVL, and the ffi gem delivers the cross-thread completion +callback safely. Wrap the call in a `Thread` when you need concurrency: ```ruby -t = Thread.new { WeaveFFI.run_task('demo') } -result = t.value # joins; re-raises a WeaveFFI::Error from the call +t = Thread.new { store.compact } +reclaimed = t.value # joins; re-raises a Kvstore::Error from the call ``` The local `callback` reference keeps the `FFI::Function` alive until `queue.pop` returns, so the completion callback cannot be collected mid-flight. -Result ownership follows the async contract: string, bytes, and -buffered results (records, rich enums, optionals, arrays, and maps, -arriving as a pointer-plus-length pair) are owned by the consumer, so -the callback copies or decodes them into Ruby values and then releases -them with `weaveffi_free_string` or `weaveffi_free_bytes`. A reported -error is heap-boxed: the callback copies its code, message, and -payload, then releases it with `weaveffi_error_free`. An owned -interface result transfers ownership too: the wrapper adopts the -pointer into its `FFI::AutoPointer`, so the destructor runs on GC or -an explicit `destroy`. +Result ownership follows the async contract: string, bytes, and buffered +results (records, rich enums, optionals, arrays, and hashes, arriving as +a pointer-plus-length pair) are owned by the consumer, so the callback +copies or decodes them into Ruby values and then releases them with +`weaveffi_free_string` or `weaveffi_free_bytes`. A reported error is +heap-boxed: the callback copies its code, message, and payload, then +releases it with `weaveffi_error_free`. An owned interface result +transfers ownership too: the callback adopts the pointer into a new +wrapper. A non-throwing async callable raises the generic `Error` on a +trap code; a panic inside the spawned future surfaces as +`PANIC_ERROR_CODE` (-2). For functions marked `cancellable: true` the C launcher takes an extra cancel-token parameter. The wrapper always passes `FFI::Pointer::NULL` -for it. The token isn't exposed (the generated comment reads -"cancellation token not exposed; pass-through is NULL"). Cancellation -tokens are currently surfaced only by the C and C++ targets. - -## Callbacks and listeners - -IDL `callbacks` declare a C function-pointer type; a `listener` pairs -one with register/unregister entry points: - -```yaml -callbacks: - - name: OnMessage - params: - - { name: message, type: string } -listeners: - - name: message_listener - event_callback: OnMessage -``` - -The generated module declares the FFI callback type and exposes a -register/unregister pair. Registering takes a block, wraps it in an -`FFI::Function` trampoline, and returns a `uint64` subscription id: - -```ruby -callback :weaveffi_events_OnMessage_fn, [:string, :pointer], :void -attach_function :weaveffi_events_register_message_listener, - [:weaveffi_events_OnMessage_fn, :pointer], :uint64 -attach_function :weaveffi_events_unregister_message_listener, [:uint64], :void - -# Registers a OnMessage listener block. Returns a subscription id for -# unregister_message_listener. -def self.register_message_listener(&block) - trampoline = FFI::Function.new(:void, [:string, :pointer]) do |message, _context| - block.call(message) - end - listener_id = weaveffi_events_register_message_listener(trampoline, FFI::Pointer::NULL) - @listener_refs[listener_id] = trampoline - listener_id -end - -def self.unregister_message_listener(listener_id) - weaveffi_events_unregister_message_listener(listener_id) - @listener_refs.delete(listener_id) - nil -end -``` - -- **GC safety**: the `FFI::Function` trampoline is pinned in a - module-level registry (`@listener_refs`), keyed by subscription id, - so it cannot be garbage-collected while the producer may still call - it. Unregistering deletes the registry entry. -- **Subscription ids**: registration returns the `uint64` id produced - by `weaveffi_events_register_message_listener(fn, context)`; pass it - to `unregister_message_listener` to stop delivery and release the - trampoline. -- **Threading**: the callback fires on the producer's thread, not the - thread that registered it. Do not block inside it; marshal results - to your own thread or event loop (a `Queue` works well). - -Typical round trip: - -```ruby -id = WeaveFFI.register_message_listener { |message| puts message } -WeaveFFI.send_message('hello') -WeaveFFI.unregister_message_listener(id) -``` +for it and doesn't expose the token. Cancellation tokens are currently +surfaced only by the C and C++ targets. ## Iterators Functions returning `iter` return a lazy `Enumerator` that streams -one element per pull: each consumer step issues exactly one call to -the generated `_next` binding, so nothing is drained up front. Call -`.to_a` if you want an eager `Array`: +one element per pull: each consumer step issues exactly one call to the +generated `_next` binding, so nothing is drained up front. Call `.to_a` +if you want an eager `Array`. From the `kvstore` sample's `list_keys` +(argument packing elided): ```ruby -attach_function :weaveffi_events_get_messages, [:pointer], :pointer -attach_function :weaveffi_events_GetMessagesIterator_next, - [:pointer, :pointer, :pointer], :int32 -attach_function :weaveffi_events_GetMessagesIterator_destroy, - [:pointer], :void - -# Return an iterator over all sent messages -# Returns a lazy Enumerator that streams one element per pull; call -# `.to_a` to collect eagerly. The underlying producer iterator is -# launched on the first pull, so launch errors raise at that point -# rather than when this method returns. The iterator handle is -# released exactly once, when iteration finishes or is abandoned -# early (for example by `break`). -def self.get_messages() - Enumerator.new do |y| - err = ErrorStruct.new - iter = weaveffi_events_get_messages(err) - begin - check_error!(err) - unless iter.null? - loop do - out_item = FFI::MemoryPointer.new(:pointer) - item_err = ErrorStruct.new - has_item = weaveffi_events_GetMessagesIterator_next(iter, out_item, item_err) - check_error!(item_err) - break if has_item.zero? - item_ptr = out_item.read_pointer - if item_ptr.null? - y << '' - else - item = item_ptr.read_string - weaveffi_free_string(item_ptr) - y << item + # Returns a lazy Enumerator that streams one element per pull; call + # `.to_a` to collect eagerly. The underlying producer iterator is + # launched on the first pull, so launch errors raise at that point + # rather than when this method returns. The iterator handle is + # released exactly once, when iteration finishes or is abandoned + # early (for example by `break`). + def list_keys(prefix) + # ... pack the optional prefix into prefix_buf ... + Enumerator.new do |y| + err = ErrorStruct.new + iter = Kvstore.weaveffi_kv_Store_list_keys(handle, prefix_buf, prefix_data.bytesize, err) + begin + Kvstore.check_kv_error!(err) + unless iter.null? + loop do + out_item = FFI::MemoryPointer.new(:pointer) + item_err = ErrorStruct.new + has_item = Kvstore.weaveffi_kv_Store_ListKeysIterator_next(iter, out_item, item_err) + Kvstore.check_kv_error!(item_err) + break if has_item.zero? + item_ptr = out_item.read_pointer + if item_ptr.null? + y << '' + else + item = item_ptr.read_string.force_encoding(Encoding::UTF_8) + Kvstore.weaveffi_free_string(item_ptr) + y << item + end + end end + ensure + Kvstore.weaveffi_kv_Store_ListKeysIterator_destroy(iter) unless iter.null? end end - ensure - weaveffi_events_GetMessagesIterator_destroy(iter) unless iter.null? end - end -end ``` The producer iterator launches on the first pull, so a launch error -raises then, not when the method returns. Each string element is -copied with `read_string` and freed with `weaveffi_free_string`; -a buffered element arrives as a value buffer that's decoded with the -`_wv_read_*` helper and released with `weaveffi_free_bytes`. The -`ensure` block destroys the handle exactly once, whether -iteration exhausts, raises, or is abandoned early (Ruby runs `ensure` -when the enumerator's fiber is torn down, for example after `break`). +raises then, not when the method returns. Each string element is copied +with `read_string` and freed with `weaveffi_free_string`; a buffered +element arrives as a value buffer that's decoded with the `_wv_read_*` +helper and released with `weaveffi_free_bytes`; an object element is +adopted into a new wrapper. The `ensure` block destroys the handle +exactly once, whether iteration exhausts, raises, or is abandoned early +(Ruby runs `ensure` when the enumerator's fiber is torn down, for +example after `break`). Enumerating the same `Enumerator` again +launches a fresh producer iterator. The per-step error check follows the function's error strategy: the -throwing `kvstore` sample's `Store#list_keys` checks the launcher and -each `next` with `check_kv_error!`, so a failing step raises the typed -`KvError` subclass; the non-throwing `get_messages` uses the generic -`check_error!`, which raises only on a producer bug. +throwing `list_keys` checks the launcher and each `next` with +`check_kv_error!`, so a failing step raises the typed `KvError` +subclass; a non-throwing iterator uses the generic `check_error!`, which +raises only on a producer bug. + +## Known limitations + +- Async functions block the calling thread; there's no `Fiber`- or + promise-based variant, and `cancellable: true` tokens are not exposed. +- Callback methods run on whatever thread the producer uses, under the + GVL; a callback that blocks waiting for the calling thread deadlocks. +- Callback-interface parameters are duck-typed: a missing method is only + detected when the producer calls it (surfacing as `Error` with + code -4, or `NoMethodError` text if the module wasn't included). +- The plain `generate` output relies on the dynamic loader (or + `WEAVEFFI_LIBRARY`) to find the library; only `weaveffi package` + bundles it. ## Troubleshooting - **`LoadError: Could not open library 'libweaveffi.dylib'`**: the - cdylib is not on the loader path. Set `DYLD_LIBRARY_PATH` / - `LD_LIBRARY_PATH` or copy the library next to your script. -- **`FFI::NotFoundError: Function 'weaveffi_*' not found`**: the - cdylib does not export the symbol. Rebuild the Rust crate after - regenerating the IDL. -- **Segmentation faults on Ruby exit**: the generated wrappers pin - listener trampolines in `@listener_refs` and keep async completion - callbacks referenced until they fire. If you call the - `attach_function` bindings directly, keep your own `FFI::Function` - objects alive for the lifetime of the call; letting them be - garbage-collected mid-call corrupts the C side. + cdylib is not on the loader path. Set `WEAVEFFI_LIBRARY`, + `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH`, or copy the library next to + your script. +- **`LoadError: WeaveFFI ABI mismatch`**: the library was built by a + different `weaveffi` release than the bindings. Regenerate the bindings + and rebuild the library together. +- **`FFI::NotFoundError: Function 'weaveffi_*' not found`**: the cdylib + does not export the symbol. Rebuild the Rust crate after regenerating + the IDL. +- **`Error: Store used after close` (code -1)**: a closed wrapper was + used as a receiver, a parameter, or a record field. Keep the wrapper + open for as long as the object is in use; `dup` it if another owner + needs an independent reference. +- **`Error` with `code == -4`**: a callback-interface method you + implemented raised (or wasn't implemented); the message is the + exception's text. +- **Segmentation faults on Ruby exit**: the generated wrappers keep + vtable trampolines in constants and async completion callbacks + referenced until they fire. If you call the `attach_function` bindings + directly, keep your own `FFI::Function` objects alive for the lifetime + of the call; letting them be garbage-collected mid-call corrupts the C + side. - **Strings come back as binary garbage**: UTF-8 strings should round - trip through `read_string`; for binary data use - `read_bytes(length)` with the `out_len` returned by the C ABI. + trip through `read_string`; for binary data use `read_bytes(length)` + with the `out_len` returned by the C ABI. diff --git a/docs/src/generators/swift.md b/docs/src/generators/swift.md index d6b11881..9e00c84e 100644 --- a/docs/src/generators/swift.md +++ b/docs/src/generators/swift.md @@ -4,8 +4,11 @@ The Swift target emits a SwiftPM System Library (`CWeaveFFI`) that references the generated C header via a `module.modulemap`, plus a thin -Swift module (`WeaveFFI`) that wraps the C ABI in idiomatic Swift with -`throws`-based error handling and Swift-native types. +Swift module (`WeaveFFI`) that wraps the C ABI in idiomatic Swift: +`throws`-based error handling, plain structs and enums for values, +ARC-managed `final class` wrappers for reference-counted objects, Swift +protocols for callback interfaces, `async` methods for async callables, +and lazy `Sequence`s for iterators. The surface follows ABI revision 2. ## What gets generated @@ -13,7 +16,7 @@ Swift module (`WeaveFFI`) that wraps the C ABI in idiomatic Swift with |------|---------| | `generated/swift/Package.swift` | SwiftPM manifest declaring `CWeaveFFI` (system library) and `WeaveFFI` (Swift wrapper) | | `generated/swift/Sources/CWeaveFFI/module.modulemap` | C module map pointing at the generated header | -| `generated/swift/Sources/WeaveFFI/WeaveFFI.swift` | Swift wrapper: enums, struct classes, namespaced module functions | +| `generated/swift/Sources/WeaveFFI/WeaveFFI.swift` | Swift wrapper: enums, structs, object classes, callback protocols, namespaced module functions | The module name shown above (`WeaveFFI`) is the default. It is overridden by `[generators.swift] module_name` in `weaveffi.toml` or, failing that, by the @@ -39,21 +42,27 @@ stays buildable under any name. | `f64` | `Double` | Direct value | | `bool` | `Bool` | C `bool` at the ABI | | `string` | `String` | NUL-terminated UTF-8 (`withCString`) | -| `bytes` | `Data` / `[UInt8]` | Pointer + length | -| `handle` | `UInt64` | Direct value | +| `bytes` | `Data` | Pointer + length | | `StructName` | `StructName` (`struct`) | Plain value type; crosses as a value buffer | -| `InterfaceName` | `InterfaceName` (`final class`) | Wraps `OpaquePointer`; see [Interfaces](#interfaces) | +| `InterfaceName` | `InterfaceName` (`final class`) | One strong reference per instance; see [Objects](#objects-interfaces) | +| `InterfaceName?` | `InterfaceName?` | Nullable pointer at the ABI | +| `CallbackName` | `protocol CallbackName` | Implement and pass any conforming value; see [Callback interfaces](#callback-interfaces) | | `EnumName` (plain) | `EnumName` (`enum`) | Backed by `UInt32` | | `EnumName` (rich) | `EnumName` (`enum` with associated values) | Crosses as a value buffer | -| `T?` | `T?` | Value buffer; `Interface?` stays a nullable pointer | +| `T?` | `T?` | Value buffer | | `[T]` | `[T]` | Value buffer | | `{K: V}` | `[K: V]` | Value buffer | | `iter` | generated `Sequence` class | Lazy; one `_next` call per step | +64-bit integers are native `Int64`/`UInt64`. `Float` and `Double` cross the +ABI as IEEE values (the value-buffer codec writes `bitPattern`), so NaN, +the infinities, and `-0.0` round-trip unchanged; the `codec` sample's +`Codec.roundtripU64` and `Codec.roundtripF64` exercise this. + ## Example IDL → generated code ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -143,37 +152,146 @@ public enum Contacts { public static func createContact(name: String, age: Int32) throws -> Contact { var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) var outLen: Int = 0 - let result = name.withCString { name_ptr in - return weaveffi_contacts_create_contact(name_ptr, age, &outLen, &err) + let rv = name.withCString { name_ptr in + return weaveffi_contacts_create_contact(name_ptr, age, &outLen, &err) } try checkContacts(&err) - // Decode the returned value buffer, then release it. - var reader = WvReader(ptr: result, len: outLen) - let value = wvReadContact(&reader) - weaveffi_free_bytes(result, outLen) - return value + guard let rv = rv else { throw WeaveFFIError.error(code: -1, message: "null buffer") } + let rvBytes = [UInt8](UnsafeBufferPointer(start: rv, count: outLen)) + weaveffi_free_bytes(UnsafeMutablePointer(mutating: rv), outLen) + var rvReader = WvReader(bytes: rvBytes) + let v0 = wvReadContact(&rvReader) + rvReader.finish() + return v0 } } ``` Call it as `try Contacts.createContact(name: "Grace", age: 46)`. A -function without `throws` keeps a plain non-throwing signature; its only -possible failures are producer bugs, which trap via `fatalError`. Nested -IDL modules become nested namespace enums (`Kv.Stats.getStats(store:)` in -the `kvstore` sample). +function without `throws` keeps a plain non-throwing signature; a failure +reported there traps via `fatalError` (see [Runtime error +codes](#runtime-error-codes)). Nested IDL modules become nested namespace +enums (`Kv.Stats.getStats(store:)` in the `kvstore` sample). Optionals, lists, and maps are buffered: the wrapper packs the argument into a `WvWriter` and passes the resulting bytes as a borrowed -pointer-plus-length pair for the duration of the call: +pointer-plus-length pair for the duration of the call. + +## Objects (interfaces) + +An `interfaces:` entry becomes a `final class` holding an `OpaquePointer` +that is one strong reference to a reference-counted producer object. A +constructor named `new` becomes `init`; any other constructor becomes a +static factory. Methods are instance methods and statics are static +methods, all in lowerCamelCase with argument labels. From the `kvstore` +sample's `Store`: + +```swift +public final class Store { + let ptr: OpaquePointer + + init(ptr: OpaquePointer) { + self.ptr = ptr + } + + deinit { + weaveffi_kv_Store_destroy(ptr) + } + + /// Returns a new strong reference to the same object, for a position that + /// takes ownership of it (an object token inside a value buffer). + func clonePtr() -> OpaquePointer { + weaveffi_kv_Store_clone(ptr)! + } + + public static func open(path: String) throws -> Store { + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + let rv: OpaquePointer? = path.withCString { path_ptr in + return weaveffi_kv_Store_open(path_ptr, &err) + } + try checkKv(&err) + guard let rv = rv else { throw WeaveFFIError.error(code: -1, message: "null pointer") } + return Store(ptr: rv) + } + + public func delete(key: String) throws -> Bool + public func count() -> Int64 + public func listKeys(prefix: String?) throws -> KvStoreListKeysIterator + public func compact() async throws -> Int64 + public static func defaultCapacity() -> Int64 +} +``` + +```swift +let store = try Store.open(path: "/tmp/cache.kv") +_ = try store.put(key: "alpha", value: Data("1".utf8), kind: .volatile, ttlSeconds: nil) +print(store.count()) +let reclaimed = try await store.compact() +``` + +- **Disposal is ARC.** Each wrapper instance owns exactly one strong + reference and `deinit` releases it with `_destroy`. There is no public + `close()`; drop your last Swift reference and the producer drops the + object when its own last reference goes. Because the wrapper is a class, + assigning it around shares one Swift object (and one native reference); + you never see a use-after-release from Swift code alone. +- **Copies mint new references at the boundary.** The internal + `clonePtr()` calls `_clone` whenever the wrapper must hand the producer a + reference it will own (an object inside a value buffer); a returned + object is adopted into a new instance. `share()` in the sample returns a + second wrapper over the same producer object, and the two are + independently released. +- The `events` sample's `EventBus` declares a constructor named `new`, + which surfaces as a real initializer: `let bus = EventBus()`. + +Deprecated members carry `@available(*, deprecated, message:)`. + +### Objects as parameters, returns, and inside values + +A top-level object parameter is borrowed for the call (the wrapper passes +`ptr`); a returned object is adopted. `Store?` is a nullable pointer both +ways: ```swift -var w = WvWriter() -// ... generated write statements for the optional/list/map argument ... -let result = w.bytes.withUnsafeBufferPointer { buf in - weaveffi_contacts_find_contact(buf.baseAddress, buf.count, &err) +public func larger(other: Store?) -> Store? { + var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) + let rv = weaveffi_kv_Store_larger(ptr, other?.ptr, &err) + trap(&err) + return rv.map { Store(ptr: $0) } +} +``` + +An object inside a record, list, map value, optional, or rich-enum payload +is an ordinary property (`public var store: Store; public var mirror: Store?` +in `StoreInfo`). On the wire it's a `u64` token: the codec writes a fresh +`clonePtr()` for each object (so your wrapper keeps its own reference) and +adopts the token on read: + +```swift +func wvWriteStoreInfo(_ value: StoreInfo, into w: inout WvWriter) { + w.writeString(value.label) + w.writeObject(value.store.clonePtr()) + if let v0 = value.mirror { + w.writeOptionFlag(true) + w.writeObject(v0.clonePtr()) + } else { + w.writeOptionFlag(false) + } + w.writeInt64(value.count) +} + +func wvReadStoreInfo(_ r: inout WvReader) -> StoreInfo { + let v1 = r.readString() + let v2 = Store(ptr: r.readObject()) + // ... } ``` +`Store.openMany(paths:)` returns `[Store]`, one adopted instance per +element, and `Store.totalCount(stores:extra:)` clones each object it +encodes. An async callable returning an object adopts the pointer inside +the completion callback, and an `iter` adopts one per `next()`. + ## Typed errors A module's error domain becomes a Swift error enum conforming to `Error` @@ -203,7 +321,7 @@ public enum KvError: Error, LocalizedError { Callables with `throws: true` route failures through a per-domain checker (`checkKv`) that maps the ABI code to the matching case, falling back to the generic `WeaveFFIError.error(code:message:)` for codes the domain -doesn't declare (a producer panic, for example): +doesn't declare: ```swift do { @@ -212,81 +330,127 @@ do { print("no such key") } catch let e as KvError { print("kv failure \(e.errorCode)") +} catch let e as WeaveFFIError { + print("runtime failure \(e.errorCode): \(e.localizedDescription)") } ``` -Callables without `throws` have plain, non-throwing signatures. They still -check the error slot after the call, but a non-zero code there can only be -a producer bug, so it traps with `fatalError("\(code): \(message)")` -instead of throwing. - An error code that declares payload `fields:` carries them as additional labeled associated values on its case, alongside `message:`, decoded from the error's payload buffer before `weaveffi_error_clear` releases it. -## Interfaces +### Runtime error codes -An `interfaces:` entry becomes a `final class` owning an `OpaquePointer`, -with `deinit` calling the implicit C destructor. A constructor named `new` -becomes `init`; any other constructor becomes a throwing static factory. -Methods are instance methods and statics are static methods, all in -lowerCamelCase with argument labels. From the `kvstore` sample's `Store` -(trimmed): +Negative codes are never domain codes, so they always arrive as +`WeaveFFIError.error(code:message:)`: -```swift -/// An embedded key-value store owning its entries -public final class Store { - let ptr: OpaquePointer +| Code | ABI name | When | +|------|----------|------| +| -1 | `GENERIC_ERROR_CODE` | The producer reported a failure with no domain code | +| -2 | `PANIC_ERROR_CODE` | The Rust producer panicked inside an export or a spawned async future | +| -3 | `MARSHAL_ERROR_CODE` | A null object or a malformed value buffer or string was rejected at the boundary | +| -4 | `FOREIGN_ERROR_CODE` | A callback-interface implementation threw | - deinit { - weaveffi_kv_Store_destroy(ptr) - } - - /// Open (or create) a store backed by the given filesystem path - public static func open(path: String) throws -> Store { - var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let result: OpaquePointer? = path.withCString { path_ptr in - return weaveffi_kv_Store_open(path_ptr, &err) - } - try checkKv(&err) - guard let result = result else { throw WeaveFFIError.error(code: -1, message: "null pointer") } - return Store(ptr: result) - } +Where they surface depends on the callable: - /// Remove the entry for the given key, returning true if it existed - public func delete(key: String) throws -> Bool +- A callable with `throws: true` throws `WeaveFFIError` (sync), rejects its + continuation (async), or, for an iterator, ends iteration and stores the + error in the sequence's `error` property. +- A callable without `throws` has a non-throwing Swift signature. The + wrapper still checks the error slot after the call and **traps** with + `fatalError("\(code): \(message)")` when it's nonzero; an async one traps + inside the completion callback. This is the trap idiom: -1 through -3 + there are producer bugs, and -4 means a callback you passed threw on a + path the IDL declared as infallible. +- A malformed value buffer detected on the Swift side (`wvDecodeFailure`) + also traps. - /// Return the number of live entries in the store - public func count() -> Int64 // no throws: traps on producer bugs +## Callback interfaces - /// Stream every key, optionally filtered by a prefix - public func listKeys(prefix: String?) throws -> KvStoreListKeysIterator +A `callback_interfaces:` entry becomes a Swift protocol with one `throws` +requirement per IDL method. Conform any class or struct to it and pass the +value wherever the API expects the interface. From the `events` sample: - /// Reclaim space asynchronously; returns the number of bytes reclaimed - public func compact() async throws -> Int64 +```swift +public protocol Subscriber { + /// Decide how the bus should treat `topic` for this subscriber. + func route(topic: String) throws -> Delivery + /// Receive an accepted message. Returns the subscriber's running count + /// of received messages. + func onMessage(message: Message) throws -> Int64 + /// Receive the bus itself (an object handed through a callback). The + /// consumer adopts the reference and may keep or drop it. + func onAttached(bus: EventBus) throws +} +``` - /// Legacy single-shot put kept for compatibility - @available(*, deprecated, message: "use put() with explicit kind") - public func legacyPut(key: String, value: Data) throws -> Bool +```swift +final class LoggingSubscriber: Subscriber { + var attachedBus: EventBus? - /// The largest number of live entries one store will hold - public static func defaultCapacity() -> Int64 + func route(topic: String) throws -> Delivery { + topic == "quiet" ? .skip : .accept + } + func onMessage(message: Message) throws -> Int64 { + print("\(message.topic): \(message.text)") + return 1 + } + func onAttached(bus: EventBus) throws { + attachedBus = bus // keep it, or let it go to release the reference + } } + +let bus = EventBus() +_ = bus.subscribe(subscriber: LoggingSubscriber()) ``` +The wrapper boxes your value (`WvSubscriberBox`), retains it with +`Unmanaged.passRetained`, and passes the box as the vtable `ctx` along with +a process-wide static vtable of `@convention(c)` trampolines. The vtable's +`free` entry releases the box when the producer drops its last reference, +which is when your object is deallocated if nothing else holds it: + ```swift -let store = try Store.open(path: "/tmp/cache.kv") -_ = try store.put(key: "alpha", value: Data("1".utf8), kind: .volatile, ttlSeconds: nil) -print(store.count()) -let reclaimed = try await store.compact() +enum WvSubscriberVtable { + static let value = weaveffi_events_Subscriber_vtable( + route: { ctx, topic, out_err in + let wvBox = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + do { + return weaveffi_events_Delivery(try wvBox.impl.route(topic: String(cString: topic!)).rawValue) + } catch { + wvForeignError(out_err, error) + return weaveffi_events_Delivery(0) + } + }, + // on_message, on_attached ... + free: { ctx in + Unmanaged.fromOpaque(ctx!).release() + } + ) +} ``` -The `contacts` sample's `ContactBook` declares a constructor named `new`, -which surfaces as a real initializer: `let book = ContactBook()`. ARC -releases the underlying object when the last reference goes away; there's -no manual `close`. An interface parameter is borrowed for the call (the -wrapper passes its pointer); an interface return wraps the owned pointer -in a new instance. +- **Argument ownership.** Strings and buffered values are copied into + Swift values before your method runs. An object argument (`bus: + EventBus`) is adopted into a fresh wrapper that your implementation + owns: store it to keep the reference, or let it fall out of scope and + `deinit` releases it. +- **Lifetime.** The producer may retain the implementation indefinitely + (`subscribe` holds it until `clearSubscribers()` or the bus is dropped); + a function that only uses it for the call (`Events.routeOnce`) frees it + before returning. +- **Errors.** A thrown Swift error never unwinds through the C frame. The + trampoline reports it with `weaveffi_error_set(out_err, -4, message)`, + using `errorDescription` for a `LocalizedError` and + `String(describing:)` otherwise. The producer aborts the call that + triggered the callback and the original caller sees + `WeaveFFIError.error(code: -4, ...)` if the callable throws; on a + non-throwing callable the wrapper traps. The implementation stays + attached either way. +- **Threads.** Methods run on whichever thread the producer calls from: the + calling thread for a synchronous call, a producer worker for an async one + (`publishLater` in the sample). The wrapper doesn't hop to the main actor + or any queue; synchronize shared state yourself. ## Rich (algebraic) enums @@ -326,38 +490,50 @@ let bigger = Shapes.scale(shape: shape, factor: 3.0) There are no C symbols behind a rich enum: on the wire it's a value buffer holding the `i32` variant tag followed by the active variant's fields, written and read by the generated `wvWriteShape`/`wvReadShape` -codec pair. Values are plain Swift data; nothing to free. +codec pair. Values are plain Swift data; nothing to free. A payload that +holds an object follows the token rules above. ## Build instructions Build the producer cdylib, generate the bindings, and compile your -program against the generated Swift module (the `contacts` sample shown; -its module resolves to `Contacts` + `CContacts` from the package name): +program against the generated Swift module (the `kvstore` sample shown; +its module resolves to `Kvstore` + `CKvstore` from the package name): ```bash -cargo build -p contacts -weaveffi generate samples/contacts/src/lib.rs -o generated +cargo build -p kvstore +weaveffi generate samples/kvstore/src/lib.rs -o generated --target c,swift swiftc \ - -I generated/swift/Sources/CContacts \ - -L target/debug -lcontacts \ + -I generated/swift/Sources/CKvstore \ + -L target/debug -lkvstore \ -Xlinker -rpath -Xlinker target/debug \ - generated/swift/Sources/Contacts/Contacts.swift main.swift -o app + generated/swift/Sources/Kvstore/Kvstore.swift main.swift -o app DYLD_LIBRARY_PATH=target/debug ./app # LD_LIBRARY_PATH on Linux ``` -In a real SwiftPM application, add the generated package as a path -dependency, link the system-library and wrapper targets, and ship the -cdylib as part of an XCFramework or bundled `.dylib`/`.so`. The -`conformance/swift/` consumers show a complete SwiftPM assembly for every -sample (see `conformance/run.sh`). +The module map points at `../../../c/.h`, so generate the C target +alongside Swift. In a real SwiftPM application, add the generated package +as a path dependency, link the system-library and wrapper targets, and +ship the cdylib as part of an XCFramework or bundled `.dylib`/`.so`. The +`conformance/swift/` consumers show a complete assembly for every sample +(see `conformance/run.sh`). + +## Packaging + +`weaveffi package --target swift` emits a SwiftPM package whose C module is +a `binaryTarget` xcframework, with the prebuilt desktop libraries under +`swift/lib//` and a README describing how to fuse them into +`C.xcframework` with `lipo` and `xcodebuild -create-xcframework` +(the one step that needs Apple tooling). Only desktop slices are bundled +(`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`); +iOS and Android slices aren't produced. See +[Packaging and Distribution](../guides/packaging.md). ## Memory and ownership -- Interface classes own an `OpaquePointer`. The class `deinit` calls - the matching C destructor. Structs and rich enums are plain Swift - values with nothing to free. +- Interface classes own one strong reference; `deinit` releases it. + Structs and rich enums are plain Swift values with nothing to free. - Returned strings are copied into Swift `String` and the raw pointer is freed via `weaveffi_free_string` immediately. - `withUnsafeBufferPointer` keeps input buffers alive only for the @@ -369,130 +545,60 @@ sample (see `conformance/run.sh`). - Buffered values (structs, rich enums, optionals, arrays, and dictionaries) cross as one value buffer: parameters are packed into a `WvWriter` whose bytes the producer borrows for the call; returns are - decoded with the matching `wvRead*` routine and the producer's buffer - is released with `weaveffi_free_bytes`. + copied out, released with `weaveffi_free_bytes`, and decoded with the + matching `wvRead*` routine. Object fields inside are cloned on the way in + and adopted on the way out. +- Callback-interface implementations are retained by the producer's box + until its `free` runs. ## Async support -Async IDL functions (`async: true`) are exposed as `async throws` -methods that bridge the C ABI completion callback into Swift structured -concurrency via `withCheckedThrowingContinuation`. The continuation is -boxed in a `ContinuationRef`, retained with `Unmanaged.passRetained`, -and released exactly once, by `takeRetainedValue()` inside the C -completion callback. From the `async-demo` sample: +Async IDL functions (`async: true`) are exposed as `async` methods that +bridge the C ABI completion callback into Swift structured concurrency via +a checked continuation. The continuation is boxed in a `ContinuationRef`, +retained with `Unmanaged.passRetained`, and released exactly once by +`takeRetainedValue()` inside the completion callback. From the `kvstore` +sample: ```swift -private final class ContinuationRef { - let value: CheckedContinuation - init(_ value: CheckedContinuation) { self.value = value } -} - -public static func runTask(name: String) async throws -> TaskResult { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in +public func compact() async throws -> Int64 { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let ctx = Unmanaged.passRetained(ContinuationRef(continuation)).toOpaque() - name.withCString { name_ptr in - weaveffi_tasks_run_task_async(name_ptr, { context, err, resultPtr, resultLen in - let contRef = Unmanaged>.fromOpaque(context!).takeRetainedValue() - if let err = err, err.pointee.code != 0 { - let code = err.pointee.code - let msg = err.pointee.message.flatMap { String(cString: $0) } ?? "" - weaveffi_error_free(err) - contRef.value.resume(throwing: mapTasks(code: code, message: msg)) - } else { - // TaskResult is a record: copy the owned value buffer, - // free it, then decode from the copy. - let resultBytes = [UInt8](UnsafeBufferPointer(start: resultPtr, count: resultLen)) - weaveffi_free_bytes(UnsafeMutablePointer(mutating: resultPtr), resultLen) - var reader = WvReader(bytes: resultBytes) - contRef.value.resume(returning: wvReadTaskResult(&reader)) - } - }, ctx) - } + weaveffi_kv_Store_compact_async(ptr, nil, { context, err, result in + let contRef = Unmanaged>.fromOpaque(context!).takeRetainedValue() + if let err = err, err.pointee.code != 0 { + let code = err.pointee.code + let msg = err.pointee.message.flatMap { String(cString: $0) } ?? "" + let payload: [UInt8]? = err.pointee.payload_ptr.map { [UInt8](UnsafeBufferPointer(start: $0, count: err.pointee.payload_len)) } + weaveffi_error_free(err) + contRef.value.resume(throwing: mapKv(code: code, message: msg, payload: payload)) + } else { + contRef.value.resume(returning: result) + } + }, ctx) } } ``` The completion callback fires exactly once, on an arbitrary producer thread, and the continuation is resumed exactly once from inside it. -Result buffers passed to the callback (strings, bytes, and buffered -values) are owned by the consumer: the wrapper copies or decodes them -(for example `String(cString:)`, or the byte-array copy above) and -then releases them with `weaveffi_free_string` or -`weaveffi_free_bytes`. A reported error is heap-boxed: the wrapper -copies its code, message, and payload, then releases it with -`weaveffi_error_free`. An owned interface result transfers ownership -too: the callback adopts the pointer into a new wrapper instance, -whose `deinit` eventually frees it. - -`run_task` declares `throws: true`, so the continuation rejects with the -typed `TaskError` (via `mapTasks`). An async callable without `throws` is -`async` but not `throws`: it uses a plain `withCheckedContinuation` whose -failure type is `Never`, and a producer bug traps instead. +Result buffers passed to the callback are owned by the consumer: the +wrapper copies or decodes them and releases them with +`weaveffi_free_string` or `weaveffi_free_bytes`; a reported error is +heap-boxed and released with `weaveffi_error_free`; an object result is +adopted into a new wrapper whose `deinit` eventually releases it. + +`compact` declares `throws: true`, so the continuation rejects with the +typed `KvError` (via `mapKv`) or the generic `WeaveFFIError` for a runtime +code. An async callable without `throws` (the `events` sample's +`publishLater`) is `async` but not `throws`: it uses a plain +`withCheckedContinuation` whose failure type is `Never`, and a nonzero +code in the callback traps with `fatalError`. For callables marked `cancellable: true`, the C ABI takes an extra `weaveffi_cancel_token*` parameter. The Swift wrapper passes `nil` for that slot; cancellation isn't surfaced in Swift, and Swift `Task` -cancellation doesn't propagate to the native operation (from the -`kvstore` sample's `Store.compact`): - -```swift -weaveffi_kv_Store_compact_async(ptr, nil, { context, err, result in -``` - -## Callbacks and listeners - -IDL `callbacks` paired with `listeners` produce a register/unregister -pair. From the `events` sample: - -```yaml -modules: - - name: events - callbacks: - - name: OnMessage - params: - - { name: message, type: string } - listeners: - - name: message_listener - event_callback: OnMessage -``` - -Registration is a static method on the module's namespace enum: it -takes a plain Swift closure and returns a `UInt64` subscription id; -pass that id back to unregister. The closure is boxed -(`WvCallbackBox`), retained with `Unmanaged.passRetained`, and handed -to the C ABI as the `void* context` of a C trampoline. The context -pointer is kept in a global `wvListenerContexts` dictionary keyed by -subscription id and guarded by an `NSLock` (`wvListenerLock`); -unregistering removes the entry and releases the box: - -```swift -public static func registerMessageListener(_ callback: @escaping (String) -> Void) -> UInt64 { - let box = WvCallbackBox(callback) - let ctx = Unmanaged.passRetained(box).toOpaque() - let id = weaveffi_events_register_message_listener({ message, context in - let cb = Unmanaged Void>>.fromOpaque(context!).takeUnretainedValue().value - cb(String(cString: message!)) - }, ctx) - wvListenerLock.lock() - wvListenerContexts[id] = ctx - wvListenerLock.unlock() - return id -} - -public static func unregisterMessageListener(_ id: UInt64) { - weaveffi_events_unregister_message_listener(id) - wvListenerLock.lock() - let ctx = wvListenerContexts.removeValue(forKey: id) - wvListenerLock.unlock() - if let ctx = ctx { - Unmanaged Void>>.fromOpaque(ctx).release() - } -} -``` - -The callback runs on the producer's thread, whichever thread the -native side fires the event from. For UI work, hop to the main thread -yourself (e.g. `DispatchQueue.main.async` or `await MainActor.run`). +cancellation doesn't propagate to the native operation. ## Iterators @@ -500,32 +606,33 @@ yourself (e.g. `DispatchQueue.main.async` or `await MainActor.run`). `final class` conforming to `Sequence` and `IteratorProtocol` that wraps the opaque C iterator handle. Nothing is drained up front; each `next()` call pulls exactly one element from the producer, copies it -into Swift memory, and frees the element's native allocation (strings -via `weaveffi_free_string`). From the `events` sample (`get_messages` -returns `iter` and doesn't declare `throws`, so the wrapper is -non-throwing and traps on producer bugs): +into Swift memory, and frees the element's native allocation. From the +`kvstore` sample (`Store.listKeys` returns `iter`): ```swift -/// A lazy sequence over the `String` elements streamed by `weaveffi_events_get_messages`. -/// -/// Each `next()` call pulls exactly one element from the producer. The -/// underlying C iterator is destroyed eagerly on exhaustion and from -/// `deinit` when iteration is abandoned early. -public final class EventsGetMessagesIterator: Sequence, IteratorProtocol { +public final class KvStoreListKeysIterator: Sequence, IteratorProtocol { private var handle: OpaquePointer? + /// The error that ended iteration early, if any. + public private(set) var error: Error? deinit { destroyHandle() } - /// Pulls the next element from the producer, or returns `nil` once the - /// stream is exhausted (destroying the underlying iterator). + private func destroyHandle() { + guard let handle = handle else { return } + weaveffi_kv_Store_ListKeysIterator_destroy(handle) + self.handle = nil + } + public func next() -> String? { guard let handle = handle else { return nil } var item: UnsafePointer? = nil var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - if weaveffi_events_GetMessagesIterator_next(handle, &item, &err) == 0 { - // ... a non-zero code is a producer bug: fatalError ... + if weaveffi_kv_Store_ListKeysIterator_next(handle, &item, &err) == 0 { + if err.code != 0 { + // ... map the code through mapKv and store it in `error` ... + } destroyHandle() return nil } @@ -534,46 +641,44 @@ public final class EventsGetMessagesIterator: Sequence, IteratorProtocol { return element } } - -public static func getMessages() -> EventsGetMessagesIterator { - var err = weaveffi_error(code: 0, message: nil, payload_ptr: nil, payload_len: 0) - let iter = weaveffi_events_get_messages(&err) - trap(&err) - guard let iter = iter else { fatalError("-1: null iterator") } - return EventsGetMessagesIterator(handle: iter) -} ``` The handle is destroyed exactly once: eagerly when `next()` reports -exhaustion, or from `deinit` when the sequence is abandoned early -(`destroyHandle` nulls the stored handle, so a double destroy is -impossible). Since the class conforms to `Sequence`, callers just -write `for message in Events.getMessages() { ... }`; the sequence is -single-pass. - -A throwing iterator function like the `kvstore` sample's -`Store.listKeys(prefix:)` declares `throws` and routes a launch -failure through the typed checker (`checkKv`). Swift's -`IteratorProtocol.next()` can't throw, so a mid-stream error can't -surface on the step that failed; instead it ends iteration and is -stored in the sequence's public `error` property for the caller to -inspect after the loop: +exhaustion, or from `deinit` when the sequence is abandoned early. Since +the class conforms to `Sequence`, callers just write +`for key in try store.listKeys(prefix: nil) { ... }`; the sequence is +single-pass. A buffered element is decoded from its value buffer and +released with `weaveffi_free_bytes`; an object element is adopted into a +wrapper. + +A throwing iterator function like `listKeys` routes a launch failure +through the typed checker (`checkKv`). Swift's `IteratorProtocol.next()` +can't throw, so a mid-stream error can't surface on the step that failed; +instead it ends iteration and is stored in the sequence's public `error` +property for the caller to inspect after the loop: ```swift -/// If the producer reports an error mid-stream, iteration ends and the -/// error is stored in ``error`` for the caller to inspect after the loop. -public final class KvStoreListKeysIterator: Sequence, IteratorProtocol { - /// The error that ended iteration early, if any. - public private(set) var error: Error? - // ... same handle lifecycle as above; a failing next() maps the - // code through mapKv, stores it in `error`, and returns nil ... -} - let keys = try store.listKeys(prefix: nil) for key in keys { print(key) } if let error = keys.error { throw error } ``` +A non-throwing iterator function (the `events` sample's `messages()`) +traps on a mid-stream code instead. + +## Known limitations + +- Cancellation tokens aren't exposed; async wrappers always pass `nil`. +- Mid-stream iterator errors are stored in `error`, not thrown, and only + for throwing callables. +- Non-throwing callables trap (`fatalError`) on any runtime code, + including -4 from a callback you passed; declare `throws: true` on any + callable whose callback implementations may fail. +- The module map references the C header by relative path + (`../../../c/.h`), so the C target must be generated next to the + Swift one. +- Callback methods aren't marshalled to any particular thread or actor. + ## Troubleshooting - **`module 'CWeaveFFI' not found`**: Xcode/SwiftPM didn't pick up @@ -583,9 +688,9 @@ if let error = keys.error { throw error } - **`Library not loaded: libweaveffi.dylib`**: set `DYLD_LIBRARY_PATH` for development or embed the dylib in your application bundle for distribution. -- **Crashes after `deinit`**: never reuse an `OpaquePointer` after the - owning Swift wrapper goes out of scope. The C side has already freed - it. +- **`Fatal error: -4: ...`**: a callback implementation threw on a + callable without `throws`. Either make the implementation not throw or + mark the callable `throws: true` so the error is catchable. - **Optional value ends up `nil` even when present**: an optional crosses inside a value buffer as a presence flag followed by the value; double-check the Rust implementation actually returns diff --git a/docs/src/generators/wasm.md b/docs/src/generators/wasm.md index 7263c1a5..a31a7a89 100644 --- a/docs/src/generators/wasm.md +++ b/docs/src/generators/wasm.md @@ -3,30 +3,44 @@ ## Overview The Wasm target produces a typed ES module loader for -`wasm32-unknown-unknown` builds of WeaveFFI cdylibs. The loader wraps -the raw exports in idiomatic JavaScript: per-module namespaces, plain -JS objects for records and tagged objects for rich enums, thrown -`Error`s instead of error slots, `Promise`-based async functions, and -automatic string/bytes staging in linear memory. TypeScript -declarations describe the whole surface. +`wasm32-unknown-unknown` builds of WeaveFFI cdylibs, speaking ABI +revision 2. The loader wraps the raw exports in idiomatic JavaScript: +per-module namespaces, plain JS objects for records and tagged objects +for rich enums, reference-counted object wrappers with `close()` and +`Symbol.dispose`, `Promise`-based async functions, lazy iterators, +callback interfaces implemented as plain JS objects, thrown `Error`s +instead of error slots, and automatic string/bytes staging in linear +memory. TypeScript declarations describe the whole surface. C and C++ producers compiled with Emscripten are supported through a dedicated loader variant; see [Emscripten mode](#emscripten-mode). -Callbacks and listeners are supported with **synchronous, same-thread -delivery**: a `wasm32-unknown-unknown` module is single-threaded, so -events fire only while a call into the module is on the stack; see -[Callbacks and listeners](#callbacks-and-listeners). +Callback interfaces and async functions deliver **synchronously, on +the calling thread**: a `wasm32-unknown-unknown` module is +single-threaded, so a callback runs only while a call into the module is +on the stack; see [Callback interfaces](#callback-interfaces). ## What gets generated | File | Purpose | |------|---------| -| `generated/wasm/weaveffi_wasm.js` | ES module: memory helpers, value-buffer codecs, and the async `loadWeaveffiWasm(url)` loader returning typed bindings | -| `generated/wasm/weaveffi_wasm.d.ts` | TypeScript declarations for the loader and every module namespace | +| `generated/wasm/weaveffi_wasm.js` | ES module: memory helpers, value-buffer codecs, object and iterator runtime, and the async `loadWeaveffiWasm(source)` loader returning typed bindings | +| `generated/wasm/weaveffi_wasm.d.ts` | TypeScript declarations for the loader, every module namespace, object class, and callback interface | | `generated/wasm/package.json` | npm package manifest (`type: "module"`) | | `generated/wasm/README.md` | Quickstart and boundary conventions | +## Configuration + +```toml +[generators.wasm] +module_name = "weaveffi_wasm" # basename of the emitted .js/.d.ts +emscripten = false # see Emscripten mode +allow_unsupported = false # emit throwing stubs instead of failing in Emscripten mode +``` + +`prefix` is also accepted and defaults to `[global] c_prefix`, so the +glue calls the same exported symbols the producer emits. + ## Type mapping | IDL type | Wasm boundary | JavaScript surface | @@ -34,26 +48,30 @@ events fire only while a call into the module is on the stack; see | `i32` / `u32`| `i32` | `number` | | `i8` / `i16` | `i32` | `number` | | `u8` / `u16` | `i32` | `number` | -| `i64` | `i64` | `BigInt` | -| `u64` | `i64` | `BigInt` | +| `i64` | `i64` | `bigint` | +| `u64` | `i64` | `bigint` | | `f64` | `f64` | `number` | | `f32` | `f32` | `number` | | `bool` | `i32` | `boolean` (0/1 at the boundary) | | `string` | `i32` pointer (NUL-terminated UTF-8) | `string`, staged via `weaveffi_alloc` | | `bytes` | `i32` pointer + `i32` length | `Uint8Array` copy | -| `handle` / `InterfaceName` | `i32` pointer into linear memory (0 = null) | interface wrapper class | +| `InterfaceName` | `i32` pointer into linear memory; one strong reference per wrapper | `class` with `close()` and `Symbol.dispose` | +| `InterfaceName?` | `i32` pointer, 0 = absent | `InterfaceName \| null` | +| `CallbackName` | `i32` context key + `i32` pointer to a static vtable | any object with the interface's methods | | `StructName` | value buffer (`i32` pointer + `i32` length) | plain JS object | -| `EnumName` (plain, C-style) | `i32` discriminant | `number` | -| `EnumName` (rich / algebraic) | value buffer (`i32` pointer + `i32` length) | tagged plain object (`{ tag: "Circle", radius: 2 }`) | -| `T?` | value buffer; `Interface?` stays a 0/null pointer | `T \| null` | -| `[T]` | value buffer (`i32` pointer + `i32` length) | `Array` copy | -| `{K: V}` | value buffer (`i32` pointer + `i32` length) | plain object (`Map` accepted on input) | -| `iter` | iterator handle + `next` out-param | lazy `IterableIterator` | +| `EnumName` (plain, C-style) | `i32` discriminant | `number` (frozen constants object) | +| `EnumName` (rich / algebraic) | value buffer | tagged plain object (`{ tag: "Circle", radius: 2 }`) | +| `T?` (non-object) | value buffer | `T \| null` | +| `[T]` | value buffer | `Array` copy | +| `{K: V}` | value buffer | plain object (`Map` accepted on input) | +| `iter` | iterator handle + `next` out-slot | lazy `IterableIterator` | ## Example IDL → generated code -The loader exports a single async entry point that fetches, -instantiates, and wraps a `.wasm` module: +The loader exports a single async entry point that compiles, +instantiates, and wraps a module. It accepts a URL to fetch, the bytes, +or an already compiled `WebAssembly.Module`, and checks the ABI revision +first: ```javascript import { loadWeaveffiWasm } from './weaveffi_wasm.js'; @@ -61,25 +79,52 @@ import { loadWeaveffiWasm } from './weaveffi_wasm.js'; const api = await loadWeaveffiWasm('/your_library.wasm'); ``` -Functions are grouped by IDL module in lowerCamelCase (nested IDL -modules nest namespaces, e.g. `api.kv.stats`) and have idiomatic -signatures; strings, arrays, and error handling are taken care of -inside the wrapper: +```js +const _ABI_VERSION = 2; -```javascript -api.events.sendMessage('hello'); // throws WeaveFFIError on failure -for (const m of api.events.getMessages()) { // iter -> lazy iterable - console.log(m); +function _checkAbiVersion(wasm) { + if (typeof wasm.weaveffi_abi_version !== 'function') { + throw new Error(`the loaded WeaveFFI module predates ABI versioning (this glue expects ABI revision ${_ABI_VERSION})`); + } + const found = wasm.weaveffi_abi_version() >>> 0; + if (found !== _ABI_VERSION) { + throw new Error(`WeaveFFI ABI mismatch: this glue expects revision ${_ABI_VERSION} but the loaded module reports revision ${found}`); + } } ``` -Structs come back as plain JS objects, serialized in a value buffer at -the boundary and packed/unpacked automatically by the glue; there are -no handles, no accessor functions, and nothing to free: +Functions are grouped by IDL module in lowerCamelCase (nested IDL +modules nest namespaces, e.g. `api.kv.stats.getStats(store)`), object +classes hang off their module namespace (`api.kv.Store`), and error +classes and plain-enum constant objects are top-level named exports: ```javascript -const result = await api.tasks.runTask('build'); -console.log(result.id, result.value, result.success); +import { loadWeaveffiWasm, KvError, KeyNotFound, EntryKind } from './weaveffi_wasm.js'; + +const api = await loadWeaveffiWasm('/kvstore.wasm'); +const store = api.kv.Store.open('/tmp/cache.kv'); +store.put('alpha', new Uint8Array([1]), EntryKind.Volatile, null); +for (const key of store.listKeys(null)) console.log(key); +store.close(); +``` + +Records come back as plain JS objects, serialized in a value buffer at +the boundary and packed/unpacked by generated per-record codecs. From +the `kvstore` sample: + +```js +// Decode a `kv.Entry` record from the value-buffer wire format. +function _read_kv_Entry(r) { + const v = {}; + v.id = r.i64(); + v.key = r.str(); + v.value = r.bytes(); + v.created_at = r.i64(); + v.expires_at = (r.flag() ? r.i64() : null); + v.tags = (() => { const _n = r.len(); const _arr = []; for (let _i = 0; _i < _n; _i++) _arr.push(r.str()); return _arr; })(); + v.metadata = (() => { const _n = r.len(); const _obj = {}; for (let _i = 0; _i < _n; _i++) { const _k = r.str(); _obj[_k] = r.str(); } return _obj; })(); + return v; +} ``` The raw exports stay reachable for anything not covered by the typed @@ -89,21 +134,6 @@ surface: api._raw.weaveffi_alloc(16); ``` -The generated `weaveffi_wasm.d.ts` mirrors all of this for TypeScript -consumers: - -```typescript -export interface WeaveffiWasmModule { - _raw: WebAssembly.Exports; - events: { - sendMessage(text: string): void; - getMessages(): IterableIterator; - }; -} - -export function loadWeaveffiWasm(url: string): Promise; -``` - ## Typed errors The module exports `WeaveFFIError` (extending `Error` with a numeric @@ -113,6 +143,10 @@ the domain plus one exported class per code, each carrying its stable `kvstore` sample: ```js +/** Base error for WeaveFFI failures: domain errors extend it, and it is + * thrown directly for unknown codes, marshalling failures, producer + * panics, and callback-interface implementations that raised (code -4). + * Carries the stable ABI `code`. */ export class WeaveFFIError extends Error { constructor(code, message) { super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`); @@ -139,65 +173,277 @@ A callable with `throws: true` checks the error slot through the domain's mapper (`_kvErrorFrom`), so a failure arrives as the matching subclass (`KeyNotFound`), the domain (`KvError`), or, for codes outside the domain, the generic `WeaveFFIError`. A callable without `throws` -uses the generic checker only; a failure there can only be a producer -bug and throws `WeaveFFIError`. +uses the generic checker only; a non-zero code there can only be a +producer panic, a marshalling failure, or a callback implementation that +threw, and it surfaces as `WeaveFFIError`. There is no separate trap +idiom on this target: JavaScript has exceptions, so both paths throw. An error code that declares payload `fields:` carries them serialized in the error's payload buffer; the checker decodes them onto the thrown error object before clearing the slot. -## Interfaces +### Runtime error codes + +| Code | ABI name | When you see it on this target | +|------|----------|--------------------------------| +| `-1` | `GENERIC_ERROR_CODE` | A producer reported a failure without a domain code; a `#[weaveffi::export]` body that returns a bare `Err(String)`. | +| `-2` | `PANIC_ERROR_CODE` | The producer panicked. `wasm32-unknown-unknown` has no unwinding runtime: a panic executes `unreachable`, the engine throws `WebAssembly.RuntimeError`, and the wrapper's `catch` translates it (`_trap`/`_trapError`) into `WeaveFFIError(-2, 'producer panicked: ...')`. The producer frames are not unwound, so a lock the producer held across the call stays locked. | +| `-3` | `MARSHAL_ERROR_CODE` | The consumer passed something the boundary can't carry, most often a closed object wrapper (`_borrow` throws `WeaveFFIError(-3, 'expected a live object wrapper')`), or the producer returned a malformed value buffer (`_BufReader` reports a truncated buffer or invalid UTF-8). | +| `-4` | `FOREIGN_ERROR_CODE` | A callback-interface method you implemented threw. The trampoline records the failure on the producer's `out_err`; the wrapper you called throws `WeaveFFIError` with code `-4` and your exception's message. | + +Errors surface wherever the call was made: thrown from synchronous +methods and free functions, as a rejected `Promise` from async +functions, and thrown from `next()` (or the `for...of` loop) for +iterators. + +## Objects (interfaces) An `interfaces:` entry becomes a class exposed on its module's namespace -(`api.kv.Store`). Constructors are static factories, methods are -camelCased instance methods, statics are static methods, and `free()` -releases the native object (there's no `FinalizationRegistry` on this -target). From the `kvstore` sample (trimmed): +(`api.kv.Store`). A constructor named `new` becomes the JS +`constructor`; other constructors are static factories; methods are +camelCased instance methods; statics are static methods. Each wrapper +owns exactly one strong reference to the producer object, and `close()` +releases it. From the `kvstore` sample (trimmed): ```js -// An embedded key-value store owning its entries class Store { - free() { - if (this._handle !== 0) { - wasm.weaveffi_kv_Store_destroy(this._handle); - this._handle = 0; - } + static _wrap(handle) { + return _adopt(Object.create(Store.prototype), handle, wasm.weaveffi_kv_Store_destroy); + } + _clone() { + return wasm.weaveffi_kv_Store_clone(_borrow(this)); + } + close() { + _release(this); + } + [_dispose]() { + _release(this); } static open(path) { const [a0_p, a0_s] = _cstr(wasm, path); const _err = _allocErr(wasm); - const _r = wasm.weaveffi_kv_Store_open(a0_p, _err); - wasm.weaveffi_dealloc(a0_p, a0_s); + let _r; + try { + _r = wasm.weaveffi_kv_Store_open(a0_p, _err); + } catch (e) { + throw _trap(wasm, _err, e); + } finally { + wasm.weaveffi_dealloc(a0_p, a0_s); + } _checkKvError(wasm, _err); _freeErr(wasm, _err); return Store._wrap(_r); } - delete(key) { /* throws typed KvError subclasses */ } - count() { /* generic check only (no throws) */ } - compact() { - return new Promise((resolve, reject) => { - const ctxId = _nextCtxId++; - _asyncContexts.set(ctxId, { resolve, reject, mkErr: _kvErrorFrom }); - wasm.weaveffi_kv_Store_compact_async(this._handle, 0, _cbPtr_i32_i32_i64, ctxId); - }); + share() { /* ... */ return Store._wrap(_r); } + larger(other) { + /* ... */ + _r = wasm.weaveffi_kv_Store_larger(_borrow(this), (other === null || other === undefined ? 0 : _borrow(other)), _err); + /* ... */ + return _r === 0 ? null : Store._wrap(_r); } - /** @deprecated use put() with explicit kind */ - legacyPut(key, value) { /* ... */ } - static defaultCapacity() { /* ... */ } } ``` +The shared runtime behind those methods: + ```js -const store = api.kv.Store.open('/tmp/cache.kv'); -store.put('alpha', new Uint8Array([1]), api.kv.EntryKind.Volatile, null); -console.log(store.count()); -store.free(); +// Bind one strong reference to a wrapper and arm the backstop. +function _adopt(obj, handle, destroy) { + obj._handle = handle; + obj._destroy = destroy; + if (_finalizer !== null) _finalizer.register(obj, [destroy, handle], obj); + return obj; +} + +// Release a wrapper's reference exactly once; later calls are no-ops. +function _release(obj) { + if (!obj._handle) return; + if (_finalizer !== null) _finalizer.unregister(obj); + obj._destroy(obj._handle); + obj._handle = 0; +} + +// The pointer of a live wrapper, lent to the producer for one call (the +// wrapper keeps its own reference). A closed or non-object argument is a +// consumer programming error, reported with the marshalling code. +function _borrow(obj) { + if (obj === null || obj === undefined || !obj._handle) { + throw new WeaveFFIError(-3, 'expected a live object wrapper'); + } + return obj._handle; +} ``` -An interface parameter accepts the wrapper and reads `_handle` -(`api.kv.stats.getStats(store)`); an interface return wraps the owned -pointer in a fresh instance. Call `free()` when done; otherwise the -allocation lives until the module instance is dropped. +- **Disposal.** `close()` calls the producer's `_destroy` symbol and + drops one reference; it is idempotent. The same method is reachable as + `[Symbol.dispose]`, so `using store = api.kv.Store.open(path)` releases + it at block exit on runtimes with explicit resource management (older + runtimes get `Symbol.for('Symbol.dispose')` as a stand-in). A + `FinalizationRegistry` backstop releases wrappers that are garbage + collected without `close()`; on runtimes without + `FinalizationRegistry`, `close()` is the only release path and a + forgotten wrapper keeps its reference until the module instance is + dropped. Don't rely on the backstop for timely release. +- **Use after close.** Every method and every parameter site goes + through `_borrow`, so touching a closed wrapper throws + `WeaveFFIError` with code `-3`, synchronously for methods and as a + rejection for async methods. +- **Passing objects lends, returning objects owns.** A wrapper passed as + a parameter lends its pointer for the duration of the call and keeps + its own reference. A returned pointer is adopted into a fresh wrapper + that owns that reference. `share()` in the sample returns a second + wrapper around the same producer object; both must be closed. +- **Copies mint a new strong reference.** When a wrapper is placed + inside a value buffer (a record field, a list element, an optional) + the codec calls `_clone()`, which invokes the producer's `_clone` + symbol, so the buffer carries its own reference and the producer + adopts it: + +```js +// Serialize a `kv.StoreInfo` record into the value-buffer wire format. +function _write_kv_StoreInfo(w, v) { + w.str(v.label); + w.obj(v.store._clone()); + if (v.mirror === null || v.mirror === undefined) { + w.flag(false); + } else { + w.flag(true); + w.obj(v.mirror._clone()); + } + w.i64(v.count); +} + +// Decode a `kv.StoreInfo` record from the value-buffer wire format. +function _read_kv_StoreInfo(r) { + const v = {}; + v.label = r.str(); + v.store = Store._wrap(r.obj()); + v.mirror = (r.flag() ? Store._wrap(r.obj()) : null); + v.count = r.i64(); + return v; +} +``` + +- **Nullable objects** (`Store?`) are `Store | null`: `null` or + `undefined` becomes a 0 pointer on input, and a 0 return becomes + `null` (`larger` above). +- **Lists of objects** decode into arrays of fresh wrappers, each owning + one reference (`Store.openMany(paths)` returns + `_arr.push(Store._wrap(_rd.obj()))` per element); close each one. +- **Iterators over objects** yield a fresh owning wrapper per `next()`. +- **Async functions returning objects** resolve the `Promise` with a + fresh owning wrapper. + +The TypeScript declarations document the ownership rule at every object +return: + +```typescript +/** + * A second reference to this same store (the returned pointer equals + * the receiver's; both must eventually be destroyed). + * @returns A wrapper owning one reference; `close()` it (or bind it with `using`) when done. + * @throws {WeaveFFIError} if the native call fails + */ +share(): Store; +larger(other: Store | null): Store | null; +describe(label: string, mirror: Store | null): StoreInfo; +``` + +## Callback interfaces + +A `callback_interfaces:` entry becomes a TypeScript `interface`; the +consumer implements it with any object that has the methods (a class +instance or an object literal, duck-typed). From the `events` sample's +`weaveffi_wasm.d.ts`: + +```typescript +export interface Subscriber { + /** Decide how the bus should treat `topic` for this subscriber. */ + route(topic: string): Delivery; + /** Receive an accepted message. Returns the subscriber's running count + * of received messages. */ + onMessage(message: Message): bigint; + /** Receive the bus itself (an object handed through a callback). The + * consumer adopts the reference and may keep or drop it. */ + onAttached(bus: EventBus): void; +} +``` + +```js +const received = []; +const sub = { + route(topic) { return topic.startsWith('alerts/') ? Delivery.Accept : Delivery.Skip; }, + onMessage(message) { received.push(message.text); return BigInt(received.length); }, + onAttached(bus) { bus.close(); }, // owned by the implementation +}; +const bus = new api.events.EventBus(); +bus.subscribe(sub); +bus.publish('alerts/disk', 'low space', []); // route and onMessage run before publish returns +``` + +Under the hood the loader registers the implementation in a `Map` keyed +by an integer context id and passes that id plus a pointer to a static +vtable in linear memory. The vtable is filled once per loaded instance +with function-table indices of `WebAssembly.Function` trampolines, one +per method plus `free`: + +```js +const _cb_Subscriber_on_attached = _registerTrampoline(_table, ['i32', 'i32', 'i32'], [], (_ctx, a0, _err) => { + try { + const _impl = _callbacks.get(_ctx); + const _p0 = EventBus._wrap(a0); + if (_pendingForeign !== null) { + _reportForeign(wasm, _err, _pendingForeign); + return; + } + _impl.onAttached(_p0); + } catch (e) { + _setForeignError(wasm, _err, e); + } +}); +// `free(ctx)`: the producer dropped its last reference; forget the +// implementation so it can be collected. +const _cb_Subscriber_free = _registerTrampoline(_table, ['i32'], [], (_ctx) => { _callbacks.delete(_ctx); }); +const _vtable_Subscriber = wasm.weaveffi_alloc(16); +{ + const _dv = new DataView(wasm.memory.buffer); + _dv.setUint32(_vtable_Subscriber + 0, _cb_Subscriber_route, true); + _dv.setUint32(_vtable_Subscriber + 4, _cb_Subscriber_on_message, true); + _dv.setUint32(_vtable_Subscriber + 8, _cb_Subscriber_on_attached, true); + _dv.setUint32(_vtable_Subscriber + 12, _cb_Subscriber_free, true); +} +``` + +- **Argument ownership.** Strings, bytes, and buffered values (records, + rich enums, optionals, lists, maps) are borrowed from the producer for + the duration of the dispatch and decoded into fresh JavaScript values + before your method runs, so they're safe to retain. An object argument + (`onAttached(bus)`) is **owned by the implementation**: the trampoline + adopts the reference into a wrapper, and you `close()` it when you're + done (or keep it; the `FinalizationRegistry` backstop applies). +- **Lifetime.** The implementation stays registered until the producer + drops its last reference and calls the vtable's `free`, which removes + the `Map` entry. Replacing a listener (`setEvictionListener`) frees the + previous one. +- **When the implementation throws.** The trampoline catches the + exception, writes `FOREIGN_ERROR_CODE` (`-4`) and the message into the + producer's `out_err`, and returns the method's default value. Because + `wasm32` can't unwind, the producer keeps running to completion; the + message is parked in `_pendingForeign` so any further callback during + that same call is refused with the same error instead of reaching your + implementation again. When the producer call returns, the wrapper you + called throws `WeaveFFIError` with code `-4` carrying your message + (async: the `Promise` rejects). The object stays usable afterwards; the + conformance consumer keeps publishing on the same bus. A method that + returns the wrong type (a non-`bigint`-coercible value from + `onMessage`) surfaces the same way. +- **Thread affinity.** The target is single-threaded, so a callback runs + only while a call into the module is on the stack, before that call + returns. A producer that calls back from a spawned thread cannot run + on `wasm32-unknown-unknown` at all (`std::thread::spawn` fails there). + There is no re-entrancy hazard beyond the ordinary JavaScript one: + calling back into the same object from inside your callback is a + synchronous nested call. ## Rich (algebraic) enums @@ -225,23 +471,34 @@ Construct values as object literals and branch on `tag`; TypeScript narrows the union automatically: ```js -const api = await loadWeaveffiWasm('/shapes.wasm'); - const circle = { tag: 'Circle', radius: 2.0 }; -const label = { tag: 'Labeled', label: 'unit', count: 3 }; +console.log(api.shapes.describe(circle)); +const bigger = api.shapes.scale(circle, 3.0); // a fresh Shape object +``` -if (circle.tag === 'Circle') { - console.log(circle.radius); // 2 -} +Values are plain JavaScript data with nothing to free. A variant field +of interface type follows the object rule: `_clone()` on the way in, a +fresh owning wrapper on the way out. -console.log(api.shapes.describe(circle)); // native-rendered description -const bigger = api.shapes.scale(circle, 3.0); // a fresh Shape object +## 64-bit integers and floats + +`i64` and `u64` are `bigint` everywhere: as parameters, returns, record +fields, and callback returns (`onMessage(...): bigint`). The value-buffer +codec reads them with `getBigInt64`/`getBigUint64` and writes them with +`BigInt(v)`, so a `number` is accepted on input but the full 64-bit range +only round-trips as `bigint`. From the `codec` sample's declarations: + +```typescript +i64_value: bigint; +u64_value: bigint; +some_i64: bigint | null; +by_name: Record; ``` -Values are plain JavaScript data: there are no handles, no per-variant -factories or getters, and nothing to `free()`. The glue packs an -argument into linear memory for the duration of the call and unpacks a -returned buffer into a fresh object. +Floats cross as IEEE-754 bit patterns (`setFloat32`/`setFloat64` in +the codec, native `f32`/`f64` params at the boundary), so `NaN`, +`Infinity`, `-Infinity`, and `-0` are preserved; the codec sample's +conformance consumer checks each of them. ## Async support @@ -250,160 +507,100 @@ module's `__indirect_function_table` and registers one JavaScript trampoline per completion-callback signature using the [JS Type Reflection API](https://github.com/WebAssembly/js-types) (`new WebAssembly.Function(...)`); each call stores its -`resolve`/`reject` pair in a context map keyed by an integer id: +`resolve`/`reject` pair in a context map keyed by an integer id. From the +`kvstore` sample: -```javascript -runTask(name) { +```js +compact() { return new Promise((resolve, reject) => { const ctxId = _nextCtxId++; - _asyncContexts.set(ctxId, { resolve, reject, mkErr: _taskErrorFrom /* decodes the result value buffer */ }); - const [a0_p, a0_s] = _cstr(wasm, name); - wasm.weaveffi_tasks_run_task_async(a0_p, _cbPtr_i32_i32_i32, ctxId); - wasm.weaveffi_dealloc(a0_p, a0_s); + _asyncContexts.set(ctxId, { resolve, reject, mkErr: _kvErrorFrom }); + try { + wasm.weaveffi_kv_Store_compact_async(_borrow(this), 0, _cbPtr_i32_i32_i64, ctxId); + } catch (e) { + _asyncContexts.delete(ctxId); + reject(_trapError(e)); + } }); } ``` +```js +function _asyncHandler(ctxId, errPtr, ...results) { + const ctx = _asyncContexts.get(ctxId); + if (!ctx) return; + _asyncContexts.delete(ctxId); + try { + if (errPtr !== 0) _checkErrRef(wasm, errPtr, ctx.mkErr); + ctx.resolve(ctx.unwrap ? ctx.unwrap(wasm, ...results) : results[0]); + } catch (e) { + ctx.reject(e); + } +} +``` + When the producer invokes the completion callback, the trampoline looks up the context, settles the promise, and removes the entry. A callable with `throws: true` stores the module's typed error mapper in the context (`mkErr`), so the rejection carries the domain error; a non-throwing async callable rejects with the generic `WeaveFFIError` -only when the producer has a bug. +for panics, marshalling failures, and callback failures. A result of +object type resolves to a fresh owning wrapper; a buffered result is +decoded into a plain object. Two caveats apply: - `WebAssembly.Function` requires a runtime with JS Type Reflection - (recent V8/SpiderMonkey; Chrome, Firefox, Node 16+, Deno). -- The module is single-threaded: the producer must complete the - callback on the calling thread (e.g. an executor polled by the same - thread). A producer that spawns OS threads will not work on + (recent V8/SpiderMonkey; Chrome, Firefox, Deno, and Node with + `--experimental-wasm-type-reflection`). +- The module is single-threaded. The default `weaveffi-abi` spawner + drives the future inline on `wasm32`, so the completion callback has + already fired by the time the launcher returns and the `Promise` is + settled before you `await` it. A producer that installs a spawner + which defers to another thread cannot run on `wasm32-unknown-unknown`. A `cancellable` function's ABI symbol takes a `weaveffi_cancel_token*` -parameter; the loader passes a null token, so cancellation isn't -surfaced on this target (`Store.compact()` runs to completion). An IDL -function that models cancellation itself is exposed as a plain function -in the same namespace (e.g. `api.tasks.cancelTask(id)`). +parameter; the loader passes a null token (the `0` in `compact` above), +so cancellation isn't surfaced on this target. An IDL function that +models cancellation itself is exposed as a plain function in the same +namespace. ## Iterators `iter` returns are lazy: the wrapper launches the producer iterator -and hands back a shared `_WeaveFFIIterator` implementing the JS -iterator protocol over the iterator handle. Nothing is drained; each -`next()` issues exactly one producer `next` call through a per-element -slot staged in linear memory. From the `events` sample: +and hands back a `_WeaveFFIIterator` implementing the JS iterator +protocol over the iterator handle. Nothing is drained; each `next()` +issues exactly one producer `next` call through a per-element slot +staged in linear memory. From the `kvstore` sample: ```js -getMessages() { - const _err = _allocErr(wasm); - const _it = wasm.weaveffi_events_get_messages(_err); - _checkErr(wasm, _err); - _freeErr(wasm, _err); +listKeys(prefix) { + /* ... stage the `string?` prefix in a value buffer ... */ return new _WeaveFFIIterator(wasm, _it, 4, - (it, slot, ep) => wasm.weaveffi_events_GetMessagesIterator_next(it, slot, ep), - (it) => wasm.weaveffi_events_GetMessagesIterator_destroy(it), - _checkErr, (w, p) => _takeCStr(w, new DataView(w.memory.buffer).getUint32(p, true))); + (it, slot, ep) => wasm.weaveffi_kv_Store_ListKeysIterator_next(it, slot, ep), + (it) => wasm.weaveffi_kv_Store_ListKeysIterator_destroy(it), + _checkKvError, (w, p) => _takeCStr(w, new DataView(w.memory.buffer).getUint32(p, true))); } ``` The class settles the handle's lifecycle exactly once: `_close()` -destroys the producer iterator, frees the element slot, and nulls the -handle. It runs eagerly on exhaustion, on a `next` error, or from +destroys the producer iterator, frees the element slot, and zeroes the +handle. It runs eagerly on exhaustion, on a `next` error or trap, or from `return()` when iteration stops early; a `for...of` loop calls -`return()` automatically on `break` or `throw`. There is no reliable -finalization hook across the runtimes this loader supports, so -abandoning an iterator without exhausting or closing it leaks the -producer handle. +`return()` automatically on `break` or `throw`. There is no +finalization backstop for iterators (unlike objects), so abandoning one +without exhausting or closing it leaks the producer handle. Each decoded element is copied out of linear memory and its producer allocation released (`_takeCStr` frees strings via -`weaveffi_free_string`). Errors from the launcher and from each `next` -follow the function's error strategy: a throwing function such as the -`kvstore` sample's `Store.listKeys` checks each step with the domain -checker and throws the typed `KvError` subclasses; a non-throwing one -like `getMessages` throws the generic `WeaveFFIError` only for -producer bugs. The TypeScript declaration is `IterableIterator`. - -## Callbacks and listeners - -Each listener surfaces on its module namespace as a -`register.../unregister...` pair. `register` takes a plain JavaScript -function and returns a numeric subscription id; `unregister` takes that -id and stops delivery. From the `events` sample: - -```js -const received = []; -const sub = api.events.registerMessageListener((message) => received.push(message)); - -api.events.sendMessage('alpha'); // emit_message_listener fires synchronously -console.log(received); // ['alpha'] - -api.events.unregisterMessageListener(sub); -``` - -Under the hood the loader reuses the async machinery: it installs one -long-lived trampoline per callback typedef in the module's -`__indirect_function_table` (via `WebAssembly.Function`, the same -[JS Type Reflection API](https://github.com/WebAssembly/js-types) -dependency async functions have) and hands the trampoline's table index -plus a per-subscription context id to the producer's `register_*` -symbol. When the producer's `emit_*` helper fires, the trampoline looks -up the subscription by context id, decodes each argument, and invokes -the JavaScript callback: - -```js -let _nextLsnId = 1; -const _listeners = new Map(); - -const _lsnPtr_weaveffi_events_OnMessage_fn = _registerTrampoline(_table, ['i32', 'i32'], (a0, _ctx) => { - const _l = _listeners.get(_ctx); - if (_l === undefined) return; - const _p0 = _readCStr(wasm, a0); - _l.callback(_p0); -}); - -// On the module object: -registerMessageListener(callback) { - const _id = _nextLsnId++; - const _rid = wasm.weaveffi_events_register_message_listener(_lsnPtr_weaveffi_events_OnMessage_fn, _id); - _listeners.set(_id, { callback, rid: _rid }); - return _id; -}, -unregisterMessageListener(id) { - const _l = _listeners.get(id); - if (_l === undefined) return; - _listeners.delete(id); - wasm.weaveffi_events_unregister_message_listener(_l.rid); -}, -``` - -One trampoline serves every subscription to callbacks of the same -typedef (the context id disambiguates), so register/unregister churn -never grows the function table. The producer's `uint64_t` subscription -id stays internal to the loader; the public surface deals only in plain -numbers. - -Two semantic points to keep in mind: - -- **Delivery is synchronous and same-thread.** The target is - single-threaded, so `emit_*` can only run while a call into the - module is on the stack, and your callback runs before that call - returns. This is not a limitation of the bindings: a producer that - emits from a spawned thread cannot run on `wasm32-unknown-unknown` - at all (`std::thread::spawn` fails there). -- **Callback arguments are borrowed.** The producer owns every argument - for the duration of the dispatch. Strings, byte buffers, and buffered - values (records, rich enums, optionals, lists, maps) are copied or - decoded into fresh JavaScript values before your callback runs, so - they're safe to retain. An interface argument wraps producer-owned - memory: read what you need inside the callback and do not retain the - wrapper or call `free()` on it. - -In [Emscripten mode](#emscripten-mode) callbacks and listeners are not -supported; each register/unregister entry point becomes an explicit -throwing stub and is omitted from the TypeScript declarations, exactly -like async functions in that mode. +`weaveffi_free_string`); an element of object type is adopted into a +fresh owning wrapper. Errors from the launcher and from each `next` +follow the function's error strategy: a throwing function such as +`Store.listKeys` checks each step with the domain checker and throws the +typed `KvError` subclasses; a non-throwing one throws the generic +`WeaveFFIError` for producer bugs. The TypeScript declaration is +`IterableIterator`. ## Emscripten mode @@ -476,13 +673,14 @@ writes linear memory through `Module['HEAPU8']`. ### Limitations -Async functions, callbacks, and listeners are not supported in -Emscripten mode. The trampoline registration in the standard loader -relies on `WebAssembly.Function` and a growable -`__indirect_function_table`, neither of which an Emscripten module -exposes portably. Each async, register, and unregister entry point -becomes an explicit stub that throws at call time and is omitted from -the TypeScript declarations. +Async functions and callback interfaces are not supported in Emscripten +mode. The trampoline registration in the standard loader relies on +`WebAssembly.Function` and a growable `__indirect_function_table`, +neither of which an Emscripten module exposes portably. By default +`weaveffi generate` fails when the IDL uses either feature; with +`allow_unsupported = true` each affected entry point becomes an explicit +stub that throws at call time and is omitted from the TypeScript +declarations. Objects and iterators work normally. ## Build instructions @@ -503,42 +701,93 @@ Serve it over HTTP and load it with the generated helper: ``` +In Node there is no `file://` fetch; pass the bytes instead: + +```bash +node --experimental-wasm-type-reflection consumer.mjs +``` + +```javascript +import { readFile } from 'node:fs/promises'; +const api = await loadWeaveffiWasm(await readFile(new URL('./your_library.wasm', import.meta.url))); +``` + +## Packaging + +`weaveffi package --target wasm` assembles an npm package under `wasm/` +containing the ES-module loader, its `.d.ts`, a README, and the +`wasm32` binary copied in as `.wasm`. `package.json` lists +exactly those files so `npm publish` ships nothing else. Only the +`wasm32` platform slice is consulted; with no `wasm32/` binary in +`--binaries` the target is skipped with a note, and in Emscripten mode +the package is glue only (the consumer links the module into their own +Emscripten build). Build the slice with +`cargo build --release --target wasm32-unknown-unknown` (the producer +crate needs `crate-type = ["cdylib"]`) and place it under +`/wasm32/`. See the +[packaging guide](../guides/packaging.md) for the full flow. + ## Memory and ownership - The wrapper stages strings, bytes, and value buffers into linear memory with the exported `weaveffi_alloc` / `weaveffi_dealloc` and - releases them after the call; you don't manage buffers for typed - calls. -- Producer-owned returns (strings and value buffers) are copied or - decoded into JavaScript values and freed via `weaveffi_free_string` / - `weaveffi_free_bytes` inside the wrapper. + releases them after the call (in a `finally`, so a trap can't leak + them); you don't manage buffers for typed calls. +- Producer-owned returns (strings, bytes, and value buffers) are copied + or decoded into JavaScript values and freed via + `weaveffi_free_string` / `weaveffi_free_bytes` inside the wrapper. - Records, rich enums, optionals, lists, and maps are plain JavaScript - values with nothing to free. Interface wrapper objects hold a native - handle: call `free()` when done, or the allocation lives until the - module is dropped. + values with nothing to free. Object wrappers own one producer + reference each: `close()` them (or bind with `using`), and let the + `FinalizationRegistry` backstop catch what you miss. +- Callback implementations are pinned in a `Map` until the producer's + `free` runs; object arguments handed to them are owned by the + implementation. - Error slots are allocated, checked, and cleared internally; failures surface as thrown `Error`s with the producer's code and message. - When you bypass the typed surface via `_raw`, the conventions at the top of `weaveffi_wasm.js` apply and every alloc must be paired with a dealloc. +## Known limitations + +- **Single-threaded delivery.** Callbacks and async completions fire + only while a call into the module is on the stack. Producers that + spawn threads or install a deferring spawner can't run on + `wasm32-unknown-unknown`. +- **Panics don't unwind.** A producer panic traps; the glue reports + `-2` (or `-4` after a callback failure), but producer frames aren't + unwound, so state the producer was mutating may be left as is. +- **`WebAssembly.Function` is required** for async functions and + callback interfaces (Node needs `--experimental-wasm-type-reflection`). +- **No cancellation.** Cancel tokens are passed as null. +- **Iterators have no finalizer.** Exhaust or `return()` every + iterator; abandoning one leaks the producer handle. +- **Emscripten mode** supports objects and iterators but not async + functions or callback interfaces. + ## Troubleshooting - **`WebAssembly.Function is not a constructor`**: the runtime lacks - JS Type Reflection. Use a current Chrome/Firefox/Node/Deno, or avoid - async functions, callbacks, and listeners for this target. + JS Type Reflection. Use a current Chrome/Firefox/Deno, run Node with + `--experimental-wasm-type-reflection`, or avoid async functions and + callback interfaces for this target. +- **`WeaveFFI ABI mismatch`** at load: the `.wasm` was built against a + different `weaveffi-abi` revision than the glue; regenerate and + rebuild together. +- **`WeaveFFIError: WeaveFFI error -3: expected a live object wrapper`**: + a wrapper was used (as receiver or argument) after `close()`. - **`LinkError: import object field 'env' is not a Function`**: the loader instantiates with an empty imports object. If your Rust crate imports host functions, extend `loadWeaveffiWasm` to pass them in. If the module was built with Emscripten, use [Emscripten mode](#emscripten-mode) instead. -- **An async call never settles**: the producer must invoke the - completion callback on the same thread; `std::thread::spawn` does not - exist on `wasm32-unknown-unknown`. -- **A registered listener never fires**: delivery is synchronous, so - events arrive only while a call into the module is on the stack. The - producer must `emit_*` during one of your calls; there is no - background delivery on this target. +- **`WeaveFFI error -2: producer panicked`**: the producer hit a panic + (on `wasm32` often `SystemTime::now()` or another API the target has + no host for); the message carries the engine's trap text. +- **A callback never fires**: delivery is synchronous, so callbacks run + only during one of your calls into the module. There is no background + delivery on this target. - **Out-of-memory after many `_raw` calls**: every pointer returned from the module must be deallocated; the typed wrappers do this for you, raw calls do not. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5c997486..a24ba3b0 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -40,7 +40,7 @@ Describe the API once in a language-neutral IDL. Create `math.yml` with a record and a function: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: math structs: @@ -57,10 +57,15 @@ modules: ``` The IDL describes only the API. It supports primitives (`i32`, `f64`, `bool`, -`string`, `bytes`, `handle`), optionals (`string?`), lists (`[i32]`), -interfaces (objects with constructors, methods, and statics), and typed error -domains (opt in per function with `throws: true`). See the -[IDL Schema](reference/idl.md) reference for the full specification. +`string`, `bytes`, and the rest of the fixed-width integers and floats), +optionals (`string?`), lists (`[i32]`), maps (`{string:i64}`), lazy iterators +(`iter`), records and rich enums, interfaces (reference-counted +objects with constructors, methods, and statics), callback interfaces +(methods the consumer implements and the native library calls), async +functions, and typed error domains (opt in per function with `throws: true`). +See the [IDL Schema](reference/idl.md) reference for the full specification +and the [C ABI Contract](reference/abi.md) for how each of them crosses the +boundary. Everything about how the API is *published* lives in an optional `weaveffi.toml` next to it. Create one so the generated package manifests @@ -101,7 +106,7 @@ generated/ ├── c/ # C header + convenience stubs ├── cpp/ # RAII C++ header + CMakeLists.txt ├── swift/ # SwiftPM package + Swift wrapper -├── android/ # Kotlin JNI wrapper + Gradle skeleton +├── kotlin/ # Kotlin JNI wrapper + Gradle (build.gradle.kts) project ├── node/ # N-API addon + TypeScript types ├── wasm/ # JavaScript loader + TypeScript types ├── python/ # ctypes bindings + .pyi stubs @@ -129,9 +134,18 @@ reporting: * format ... */ -int32_t weaveffi_math_add(int32_t a, int32_t b, weaveffi_error* out_err); +// Module: math +WEAVEFFI_API int32_t weaveffi_math_add(int32_t a, int32_t b, weaveffi_error* out_err); ``` +The header also declares the fixed runtime surface every producer exports +(`weaveffi_abi_version`, the `weaveffi_error` struct and its helpers, +`weaveffi_free_string`/`weaveffi_free_bytes`, and the cancel-token family); +see the [C ABI Contract](reference/abi.md). Had `math.yml` declared an +interface, the header would also carry an opaque typedef plus `_clone` and +`_destroy` symbols for it, and a callback interface would appear as a vtable +typedef. + ### Swift wrapper (`generated/swift/Sources/MyMath/MyMath.swift`) Structs become plain Swift structs with typed properties, packed and @@ -208,9 +222,9 @@ pub extern "C" fn weaveffi_math_add( a + b } -// Emit the fixed WeaveFFI C ABI runtime surface (abi_version, free_string, -// free_bytes, error_clear, cancel_token_*) in one line. Call this exactly -// once per cdylib. +// Emit the fixed WeaveFFI C ABI runtime surface (abi_version, error_set, +// error_clear, error_free, free_string, free_bytes, cancel_token_*) in one +// line. Call this exactly once per cdylib. abi::export_runtime!(); ``` @@ -223,9 +237,15 @@ Key points: - The library must export the WeaveFFI runtime symbols: invoke [`weaveffi_abi::export_runtime!()`][export-runtime-doc] to emit all of them in one line instead of writing each `#[no_mangle]` thunk by hand. - Among them is `weaveffi_abi_version()`, which the generated Python, Ruby, - Dart, Go, .NET, Node.js, and Wasm bindings call at load time to refuse a - library built against a different ABI revision. + Among them is `weaveffi_abi_version()`, which reports ABI revision 2 and + which the generated Python, Ruby, Dart, Go, .NET, Node.js, and Wasm + bindings call at load time to refuse a library built against a different + revision. +- An interface in your IDL adds a `_clone` and `_destroy` pair you implement + with `weaveffi_abi::object_clone` and `object_destroy` over an `Arc`; + the [C ABI Contract](reference/abi.md#objects-interfaces) spells out the + reference-counting rules. The `#[weaveffi::module]` macro writes all of + this for you. [export-runtime-doc]: https://docs.rs/weaveffi-abi/latest/weaveffi_abi/macro.export_runtime.html @@ -289,10 +309,15 @@ add(3, 4) = 7 ## Next steps - Read the [IDL Schema](reference/idl.md) reference for all supported types - and features. + and features, and the [C ABI Contract](reference/abi.md) for how objects, + callback interfaces, value buffers, async functions, and iterators cross + the boundary. - Writing a Rust producer? See [The Rust Producer Macro](guides/producer-macro.md) to generate the C ABI directly from annotated Rust instead of implementing the header by hand. +- Look at the [samples](samples.md): `kvstore` exercises every IDL feature, + and `events` is the smallest example of a reference-counted object plus a + callback interface. - See the [Calculator tutorial](tutorials/calculator.md) for a full end-to-end walkthrough including Swift and Node.js. - Explore the [Generators](generators/README.md) section for target-specific diff --git a/docs/src/guides/async.md b/docs/src/guides/async.md index 2a698cb2..ae585c4f 100644 --- a/docs/src/guides/async.md +++ b/docs/src/guides/async.md @@ -3,53 +3,59 @@ ## Overview WeaveFFI exposes asynchronous Rust operations through a single -callback-based C ABI and language-native async wrappers in every -target. Mark a function with `async: true` (and optionally -`cancellable: true`) in the IDL and the generators emit the right -shape per target: `async` in Swift (`async throws` when the function -also declares `throws: true`), `suspend fun` in Kotlin, `Promise` -in JS, `async def` in Python, `Task` in .NET, and so on. When an -async function declares `throws: true`, the failure that settles the +callback-based C ABI and language-native async wrappers in every target. Mark +a function with `async: true` (and optionally `cancellable: true`) in the +IDL, or write a plain `async fn` under `#[weaveffi::module]`, and the +generators emit the right shape per target: `async` in Swift (`async throws` +when the function also declares `throws: true`), `suspend fun` in Kotlin, +`Promise` in JS, `async def` in Python, `Task` in .NET, and so on. +When an async function declares `throws: true`, the failure that settles the future is the module's typed domain error (see the [Error Handling Guide](errors.md)). The completion contract every wrapper implements is stated once, in -`weaveffi_core::plan::AsyncProtocol`: the callback fires exactly once -per launch, from an arbitrary producer thread, and everything it -receives (the error and the result) is owned by the consumer, which -releases it through the runtime free symbols or adopts it. See -[Result ownership and threading](#result-ownership-and-threading) -below. +`weaveffi_core::plan::AsyncProtocol`: the callback fires exactly once per +launch, from an arbitrary producer thread, and everything it receives (the +error and the result) is owned by the consumer, which releases it through the +runtime free symbols or adopts it. See +[Result ownership and threading](#result-ownership-and-threading) below. + +On the Rust side, something has to drive the future between the launcher +returning and the callback firing. That is the **spawner**: a process-wide +executor hook with a dependency-free default, replaceable once at startup +with `weaveffi::set_spawner` so producers built on Tokio (or any other +runtime) can run their futures where their I/O drivers live. ## When to use Use async functions for: - I/O-bound work (network, disk, database). -- Long-running operations that should not block the consumer's - event loop (UI threads, JS event loop, asyncio loop). +- Long-running operations that should not block the consumer's event loop + (UI threads, JS event loop, asyncio loop). - Operations the consumer should be able to cancel (combine with `cancellable: true`). Avoid async for: -- Short CPU-bound work (math, parsing, validation). The callback - overhead is more expensive than the call itself. -- Functions whose Rust implementation is purely synchronous and - finishes in microseconds. +- Short CPU-bound work (math, parsing, validation). The callback overhead is + more expensive than the call itself. +- Functions whose Rust implementation is purely synchronous and finishes in + microseconds. ## Step-by-step ### 1. Declare the function in the IDL ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: net errors: name: NetError codes: - { name: Unreachable, code: 1, message: "host unreachable" } + - { name: Cancelled, code: 2, message: "the operation was cancelled" } functions: - name: fetch_data params: @@ -59,34 +65,173 @@ modules: throws: true doc: "Fetches data from the given URL" - - name: upload_file - params: - - { name: path, type: string } - - { name: data, type: bytes } - return: bool - async: true - cancellable: true - doc: "Uploads a file, can be cancelled" + interfaces: + - name: Pool + constructors: + - name: new + params: + - { name: url, type: string } + methods: + - name: connect + params: [] + return: Conn + async: true + cancellable: true + throws: true + - name: cached + params: [] + return: Conn? + async: true + + - name: Conn + methods: + - name: id + params: [] + return: i64 ``` -| Field | Type | Default | Description | -|---------------|--------|---------|------------------------------------------------| -| `async` | bool | `false` | Mark the function as asynchronous | -| `cancellable` | bool | `false` | Allow the async operation to be cancelled | -| `throws` | bool | `false` | Deliver failures as the module's typed domain error | +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `async` | bool | `false` | Mark the function as asynchronous | +| `cancellable` | bool | `false` | Allow the async operation to be cancelled | +| `throws` | bool | `false` | Deliver failures as the module's typed domain error | -Here `fetch_data` fails with a typed `NetError`, while `upload_file` -is non-throwing: apart from cancellation, the only failures it can -surface are producer bugs. +Here `fetch_data` and `connect` fail with a typed `NetError`, while `cached` +is non-throwing: the only failures it can surface are producer bugs. Async +works on free functions, interface methods, and statics alike; interface +constructors are always synchronous. An async function may return any +by-value type, a string, bytes, a buffered type (record, rich enum, list, map, +optional), an object (`Conn`), or a nullable object (`Conn?`); the one shape +the validator rejects is an async `iter` (return a list instead). ### 2. Implement it in Rust -The generated C ABI symbol takes a callback pointer and an opaque -`void* context`. The Rust worker invokes the callback exactly once -when it is done. With the `#[weaveffi::module]` macro you write a -plain `async fn` (see `samples/async-demo/src/lib.rs`) and the -launcher below is generated for you; a hand-written producer -implements the same pattern: +With the `#[weaveffi::module]` macro you write plain `async fn`s. The snippet +below implements the IDL above and installs Tokio as the spawner; it compiles +against `weaveffi` plus +`tokio = { version = "1", features = ["rt-multi-thread", "time"] }`: + +```rust +#[weaveffi::module] +pub mod net { + use std::sync::Arc; + + /// The network error domain. + #[weaveffi::error] + #[derive(Debug)] + pub enum NetError { + /// host unreachable + Unreachable = 1, + /// the operation was cancelled + Cancelled = 2, + } + + /// Fetch a URL on the installed spawner. + #[weaveffi::export] + pub async fn fetch_data(url: String) -> Result { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + Ok(format!("payload from {url}")) + } + + /// Install the Tokio runtime as the spawner. Call once, before the + /// first async export is launched. + #[weaveffi::export] + pub fn init() -> bool { + static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + let rt = RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime") + }); + let handle = rt.handle().clone(); + weaveffi::set_spawner(move |fut: weaveffi::BoxFuture| { + handle.spawn(fut); + }) + .is_ok() + } + + /// A connection pool. + #[weaveffi::interface] + pub struct Pool { + url: String, + } + + /// One pooled connection. + #[weaveffi::interface] + pub struct Conn { + pool: Arc, + id: i64, + } + + impl Pool { + /// Open a pool for `url`. + pub fn new(url: String) -> Pool { + Pool { url } + } + + /// Lease a connection. The returned object holds a reference to + /// the pool, so the pool outlives every connection. + #[weaveffi::cancellable] + pub async fn connect(self: Arc, cancel: weaveffi::CancelToken) -> Result, NetError> { + for _ in 0..10 { + if cancel.is_cancelled() { + return Err(NetError::Cancelled); + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + Ok(Arc::new(Conn { pool: self, id: 1 })) + } + + /// Look up a cached connection, if any. + pub async fn cached(&self) -> Option> { + let _ = &self.url; + None + } + } + + impl Conn { + /// The connection id. + pub fn id(&self) -> i64 { + self.id + } + } +} + +weaveffi::export_runtime!(); +``` + +What the macro does with each piece: + +- **Inputs are owned before the spawn.** The foreign caller may free or reuse + its argument buffers as soon as the launcher returns, so strings and bytes + are copied, value buffers are copied raw (and decoded inside the future), + objects are retained with a new strong reference, and callback interfaces + are lifted into their `Arc` on the caller's thread. A `&str` or + `&T` spelling is then satisfied by lending the owned value. +- **The receiver is retained for the life of the call.** An async method + takes one strong reference to its object before spawning, whether you wrote + `&self` or `self: Arc`, so the consumer may drop its own wrapper the + moment the launcher returns and the future still has a live object. Write + `self: Arc` when the future needs to move the object into something + it returns or stores (as `connect` does). +- **Object results transfer one strong reference.** `Arc` and + `Option>` lower exactly like their synchronous counterparts: the + callback's `result` slot is a `{tag}*` the consumer adopts and eventually + releases with `weaveffi_net_Conn_destroy`; `None` is null. +- **`#[weaveffi::cancellable]` adds the token.** The attribute is required; + the macro does not infer it from the parameter type. The producer declares + a trailing `weaveffi::CancelToken` parameter that does not appear in the + IDL signature, and the launcher fills it from its `cancel_token` slot. +- **Panics are caught.** The future runs under `weaveffi::abi::CatchUnwind`, + so a panic (or a consumer callback failure) is delivered through the + callback rather than into the executor. See + [Panic handling](#panic-handling). + +A hand-written producer implements the same pattern directly against +`weaveffi-abi`; the launcher symbol always carries the `_async` suffix +(`weaveffi_net_fetch_data_async`), keeping the plain name free for a +possible synchronous variant: ```rust #![allow(unsafe_code)] @@ -108,7 +253,7 @@ pub extern "C" fn weaveffi_net_fetch_data_async( ) { let url_str = abi::c_ptr_to_string(url).unwrap_or_default(); let ctx = context as usize; - std::thread::spawn(move || { + abi::spawn(async move { let payload = abi::string_to_c_ptr(&format!("payload from {url_str}")); // Ownership of the string transfers to the consumer, which // releases it with `weaveffi_free_string` after copying. @@ -117,92 +262,112 @@ pub extern "C" fn weaveffi_net_fetch_data_async( } ``` -The async launcher symbol always carries the `_async` suffix -(`weaveffi_net_fetch_data_async`), keeping the name free for a possible -synchronous variant. Note who frees the result: everything passed to -the callback is owned by the consumer. String results are released -with `weaveffi_free_string`, byte and serialized value buffers (the -buffered results such as records, rich enums, lists, and maps) with -`weaveffi_free_bytes`, and a reported error (heap-boxed on the failure -path) with `weaveffi_error_free`; owned interface results transfer -ownership of the object pointer. The macro-generated launchers -allocate results this way for you, and every generated wrapper frees -them for you. -See [Result ownership and threading](#result-ownership-and-threading). - -### 3. Call it from each target +### 3. Choose a spawner + +`weaveffi::abi::spawn` routes every async export's future to the installed +`Spawner`, or to the default when none is installed: + +| | Behaviour | +|-|-----------| +| **Default** (nothing installed) | Each launch spawns a fresh OS thread and drives the future to completion there with `weaveffi::abi::block_on`, a minimal park/unpark executor. No runtime dependency; fine for CPU-bound futures and for futures woken from other threads (a channel, a `JoinHandle`). It has **no reactor**, so a future that awaits Tokio's I/O or timers never wakes. | +| **Custom** (`set_spawner`) | Your closure or `Spawner` implementation receives a `BoxFuture` (`Pin + Send + 'static>>`) and schedules it wherever you like. The future is already wrapped so a panic inside it is caught; a spawner never observes an unwinding future. | +| **`wasm32`** | There are no threads, so the default drives the future inline before the launcher returns; a future that returns `Pending` without an external wake would hang the module. See [WebAssembly](#webassembly). | + +`set_spawner` is first-one-wins: it returns `Err(SpawnerAlreadySet)` on the +second call and never replaces an installed spawner. Install it from an +initialization export (as `init` above does), a library constructor, or the +first constructor of your main interface, before the first async export is +launched. `Spawner` is implemented for every +`Fn(BoxFuture) + Send + Sync + 'static`, so a closure forwarding to a +`tokio::runtime::Handle`, an `async_std::task::spawn`, or a `smol::Executor` +is enough. The trait's one rule: `spawn` must not block, because the launcher +that calls it runs on the consumer's thread. + +### 4. Call it from each target Swift: ```swift -let payload = try await Net.fetchData("https://example.com/data") +let payload = try await Net.fetchData(url: "https://example.com/data") +let conn = try await pool.connect() ``` -Kotlin/Android: +Kotlin: ```kotlin val payload = Net.fetchData("https://example.com/data") +val conn = pool.connect() ``` Node.js: ```typescript const payload = await fetchData("https://example.com/data"); +const conn = await pool.connect(); ``` Python: ```python payload = await fetch_data("https://example.com/data") +conn = await pool.connect() ``` .NET: ```csharp -var payload = await Net.FetchDataAsync("https://example.com/data"); +var payload = await Net.FetchData("https://example.com/data"); +var conn = await pool.Connect(); ``` Dart: ```dart final payload = await fetchData('https://example.com/data'); +final conn = await pool.connect(); ``` -Because `fetch_data` declares `throws: true`, the error that rejects -the promise (or resumes the continuation, or fails the task) is the -typed `NetError`, so a Swift consumer writes `catch NetError.unreachable` -and a Python consumer writes `except NetError.Unreachable`. +Because `fetch_data` declares `throws: true`, the error that rejects the +promise (or resumes the continuation, or fails the task) is the typed +`NetError`, so a Swift consumer writes `catch NetError.unreachable` and a +Python consumer writes `except Unreachable`. An object result arrives as a +normal wrapper that owns one reference and releases it through the target's +disposal idiom. -### 4. Cancel a running operation +### 5. Cancel a running operation For `cancellable: true` functions the C launcher gains a -`weaveffi_cancel_token*` slot (before `callback` and `context`), and -the `weaveffi-abi` runtime provides the token lifecycle: +`weaveffi_cancel_token*` slot (before `callback` and `context`), and the +`weaveffi-abi` runtime provides the token lifecycle: ```c weaveffi_cancel_token* token = weaveffi_cancel_token_create(); -weaveffi_net_upload_file_async(path, data, data_len, token, on_done, ctx); +weaveffi_net_Pool_connect_async(pool, token, on_done, ctx); /* later, from any thread: */ weaveffi_cancel_token_cancel(token); +/* after on_done has fired: */ +weaveffi_cancel_token_destroy(token); ``` -The Rust worker polls `weaveffi_cancel_token_is_cancelled(token)` and -stops early, but the callback is **always** invoked exactly once: -either with the result or with a `Cancelled` error. The pin/unpin pair -(see Reference) runs on the cancellation path identically to the -success path. +The Rust worker polls `cancel.is_cancelled()` (the `CancelToken` wraps +`weaveffi_cancel_token_is_cancelled`) at safe points and stops early, but the +callback is **always** invoked exactly once: either with the result or with +whatever error the producer chose to report for cancellation (`connect` above +returns `NetError::Cancelled`; the runtime has no reserved code for it). The +pin/unpin pair (see Reference) runs on the cancellation path identically to +the success path. A null token reads as "never cancelled", which is what the +wrappers that don't expose cancellation pass. Today the **C and C++** surfaces expose the token (C++ as a trailing -`cancel_token = nullptr` parameter); the other wrappers pass `NULL`. -The operation runs to completion even if the consumer-side future is -abandoned. +`cancel_token = nullptr` parameter); the other wrappers pass null. The +operation runs to completion even if the consumer-side future is abandoned. ## Reference ### C ABI shape -Each async function gets its own callback typedef of the form `(context, err, -)`, and a launcher with the `_async` suffix: +Each async function gets its own callback typedef of the form +`(context, err, )`, and a launcher with the `_async` suffix: ```c typedef void (*weaveffi_net_fetch_data_callback)( @@ -217,19 +382,23 @@ void weaveffi_net_fetch_data_async( ``` The `err` argument of the callback carries the domain code for a -`throws: true` function; on a non-throwing function a non-zero code -only ever reports a producer bug (see +`throws: true` function; on a non-throwing function a non-zero code only ever +reports a producer bug (see [Throws versus Trap](errors.md#throws-versus-trap)). -For `cancellable: true` the launcher takes a token slot before the -callback, and the runtime provides the token lifecycle: +An async method takes its receiver first, and an object result is a `{tag}*` +the callback adopts: ```c -void weaveffi_net_upload_file_async( - const char* path, - const uint8_t* data, size_t data_len, +typedef void (*weaveffi_net_Pool_connect_callback)( + void* context, + weaveffi_error* err, + weaveffi_net_Conn* result); + +void weaveffi_net_Pool_connect_async( + const weaveffi_net_Pool* self, weaveffi_cancel_token* cancel_token, - weaveffi_net_upload_file_callback callback, + weaveffi_net_Pool_connect_callback callback, void* context); weaveffi_cancel_token* weaveffi_cancel_token_create(void); @@ -243,81 +412,132 @@ void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token); The completion contract has three clauses, stated once in `weaveffi_core::plan::AsyncProtocol` and rendered by every wrapper: -1. **Single completion.** The callback fires exactly once per launch. - The wrapper resolves its native future idiom (a Python `asyncio` - future, a JS `Promise`, a Swift continuation, a C# - `TaskCompletionSource`, a Go channel) exactly once and then - releases the registration. -2. **Owned results.** Result buffers passed to the callback (strings, - bytes, and buffered results delivered as a +1. **Single completion.** The callback fires exactly once per launch. The + wrapper resolves its native future idiom (a Python `asyncio` future, a JS + `Promise`, a Swift continuation, a C# `TaskCompletionSource`, a Go channel) + exactly once and then releases the registration. This holds on every path: + success, domain error, marshalling failure of an input (there is no + `out_err` on a launcher, so a null string or malformed buffer is reported + through the callback with `-3`), a null receiver, a panic, and + cancellation. +2. **Owned results.** Result buffers passed to the callback (strings, bytes, + and buffered results delivered as a `(const uint8_t* result_ptr, size_t result_len)` pair holding the - serialized value buffer) are owned by the consumer: the wrapper - copies or decodes them, then releases them through the runtime free - symbols (`weaveffi_free_string` for strings, `weaveffi_free_bytes` - for byte and value buffers). This is what lets runtimes that defer - callback bodies past the native return, such as Dart's - `NativeCallable.listener`, decode safely. Owned interface results - (including `Interface?`) transfer ownership the same way: the - callback adopts the pointer into the wrapper's disposal idiom, which - eventually calls the type's `_destroy` symbol. -3. **Foreign-thread delivery.** The callback runs on an arbitrary - producer thread, so the wrapper hops back to its native scheduler - before touching consumer state (Python's - `call_soon_threadsafe`, Node's thread-safe function, a dispatched - Swift continuation) rather than resolving inline where the target's - runtime forbids it. - -A non-null error passed to the callback is heap-boxed and also owned -by the consumer: the wrapper copies the code, message, and payload, -then releases the box exactly once with `weaveffi_error_free`. (This -differs from the synchronous `out_err` slot, which is caller-allocated -and cleared with `weaveffi_error_clear`.) - -If you consume the raw C surface directly, the same rules apply to -your callback: copy or decode every buffer and then free it, release a -non-null error with `weaveffi_error_free`, and adopt object pointers. + serialized value buffer) are owned by the consumer: the wrapper copies or + decodes them, then releases them through the runtime free symbols + (`weaveffi_free_string` for strings, `weaveffi_free_bytes` for byte and + value buffers). This is what lets runtimes that defer callback bodies past + the native return, such as Dart's `NativeCallable.listener`, decode + safely. Object results (including `Interface?`) transfer one strong + reference the same way: the callback adopts the pointer into the wrapper's + disposal idiom, which eventually calls the type's `_destroy` symbol. +3. **Foreign-thread delivery.** The callback runs on an arbitrary producer + thread (a default-spawner thread, or a Tokio worker), so the wrapper hops + back to its native scheduler before touching consumer state (Python's + `call_soon_threadsafe`, Node's thread-safe function, a dispatched Swift + continuation) rather than resolving inline where the target's runtime + forbids it. + +A non-null error passed to the callback is heap-boxed and also owned by the +consumer: the wrapper copies the code, message, and payload, then releases +the box exactly once with `weaveffi_error_free`. (This differs from the +synchronous `out_err` slot, which is caller-allocated and cleared with +`weaveffi_error_clear`.) + +If you consume the raw C surface directly, the same rules apply to your +callback: copy or decode every buffer and then free it, release a non-null +error with `weaveffi_error_free`, and adopt object pointers. + +### Panic handling + +The generated launcher awaits the producer's future inside +`weaveffi::abi::CatchUnwind`, a future adapter that wraps each `poll` in +`catch_unwind` and resolves to `Err(payload)` instead of unwinding into the +executor. The launcher then reports the payload through the callback's `err` +with `error_set_panic`: + +- an ordinary panic becomes code `-2` (`PANIC_ERROR_CODE`) with the message + `producer panicked: `; +- a `ForeignError` payload (a consumer callback-interface implementation + raised while the future was calling it) becomes code `-4` + (`FOREIGN_ERROR_CODE`) with the consumer's own message. + +Either way the callback fires exactly once and the installed spawner never +sees an unwinding task, so a panicking export can't take down a Tokio worker +or leave a JS `Promise` pending forever. On a `throws: true` function the +consumer sees these as the generic branded error; on a non-throwing function +they trap (see [Throws versus Trap](errors.md#throws-versus-trap)). ### Per-target async surface -| Target | Async surface | Cancel token exposure (`cancellable: true`) | -|---------|--------------------------------------------|-------------------------------------------| -| C | Raw callback + `_async` launcher | `weaveffi_cancel_token*` slot before the callback | -| C++ | `std::future` | trailing `cancel_token = nullptr` parameter | -| Swift | `async` (`async throws` with `throws: true`) | not exposed; wrapper passes `nil` | -| Kotlin | `suspend fun` | not exposed; wrapper passes `0L` | -| Node.js | `Promise` (thread-safe function settling) | not exposed; wrapper passes `NULL` | -| Python | `async def` (asyncio future settled via `call_soon_threadsafe`) | not exposed; wrapper passes `None` | -| .NET | `Task` | not exposed; wrapper passes `IntPtr.Zero` | -| Dart | `Future` (`NativeCallable.listener`) | not exposed; wrapper passes `nullptr` | -| Wasm | `Promise` (table trampolines) | not exposed; wrapper passes `0` | -| Go | blocking bridge (`chan` receive); call from a goroutine | not exposed; wrapper passes `nil` | -| Ruby | blocking bridge (`Queue#pop`); call from a Thread | not exposed; wrapper passes `NULL` | - -A wrapper that does not expose the token still launches and completes -the call correctly; the operation simply runs to completion even if -the consumer abandons the future. Drop to the C surface when you need -cooperative cancellation from one of those targets. +| Target | Async surface | Cancel token exposure (`cancellable: true`) | +|--------|---------------|---------------------------------------------| +| C | Raw callback + `_async` launcher | `weaveffi_cancel_token*` slot before the callback | +| C++ | `std::future` | trailing `cancel_token = nullptr` parameter | +| Swift | `async` (`async throws` with `throws: true`) | not exposed; wrapper passes `nil` | +| Kotlin | `suspend fun` | not exposed; wrapper passes `0L` | +| Node.js | `Promise` (thread-safe function settling) | not exposed; wrapper passes `NULL` | +| Python | `async def` (asyncio future settled via `call_soon_threadsafe`) | not exposed; wrapper passes `None` | +| .NET | `Task` | not exposed; wrapper passes `IntPtr.Zero` | +| Dart | `Future` (`NativeCallable.listener`) | not exposed; wrapper passes `nullptr` | +| Wasm | `Promise` (table trampolines) | not exposed; wrapper passes `0` | +| Go | blocking bridge (`chan` receive); call from a goroutine | not exposed; wrapper passes `nil` | +| Ruby | blocking bridge (`Queue#pop`); call from a Thread | not exposed; wrapper passes `NULL` | + +A wrapper that does not expose the token still launches and completes the +call correctly; the operation simply runs to completion even if the consumer +abandons the future. Drop to the C surface when you need cooperative +cancellation from one of those targets. + +### WebAssembly + +`wasm32-unknown-unknown` has no threads, so the async story is different on +both sides of the boundary: + +- **Producer.** The default spawner drives the future inline with `block_on` + before the launcher returns, and `set_spawner` has nothing better to offer: + there is no runtime to hand the future to. Futures that resolve after a + bounded amount of computation work; a future that returns `Pending` + waiting for an external wake (a timer, I/O, another thread) parks a thread + that doesn't exist and hangs the module. Keep async exports on wasm to work + that completes synchronously, or gate the awaiting code with + `#[cfg(not(target_arch = "wasm32"))]` as `samples/kvstore` does for its + clock. +- **Consumer.** The generated JavaScript glue installs one long-lived + trampoline per async result shape in the module's function table + (`__indirect_function_table`), registers a per-call `{ resolve, reject }` + context by integer id, and passes that id as `context`. Because the + producer completes inline, the trampoline runs while the launcher call is + still on the stack; the wrapper still returns a real `Promise`, so consumer + code is identical to Node's. Callback interfaces use the same table + mechanism, which is why a producer that calls back from a spawned thread + cannot run on this target at all. +- **Emscripten mode** (`emscripten = true` in `[generators.wasm]`) targets a + pre-initialized Emscripten `Module` that exposes neither + `WebAssembly.Function` nor a portable growable table, so async functions + and callback interfaces are **unsupported** there. `weaveffi generate` + refuses an API that uses them unless `allow_unsupported = true`, in which + case they are emitted as explicit throwing stubs. ### Pin / unpin matrix -Every binding pins the user-supplied `void* context` and the callback -closure for the lifetime of the operation, then releases them exactly -once on the callback path. The matrix below is the contract every -generator implements; each row is asserted by that generator's unit -tests. - -| Target | Pin (allocate / retain) | Unpin (free / release) on callback | Notes | -|---------|---------------------------------------------------------|------------------------------------------------|-------| -| Swift | `Unmanaged.passRetained(ContinuationRef(...))` | `Unmanaged.fromOpaque(ctx).takeRetainedValue()` | The retained `+1` is dropped exactly once when the continuation resumes. | -| .NET | `GCHandle.Alloc(callback, GCHandleType.Normal)` | `GCHandle.FromIntPtr(context).Free()` | The catch path also frees the handle on synchronous failure. | -| Kotlin | JNI `(*env)->NewGlobalRef(env, callback)` | `(*env)->DeleteGlobalRef(env, ctx->callback)` | The JNI shim `malloc`s and `free`s the per-call context exactly once. | -| Node.js | `napi_create_promise(env, &deferred, &promise)` | `napi_resolve_deferred` or `napi_reject_deferred` | The N-API runtime owns the deferred; the per-call context is `malloc`-ed and freed exactly once. | -| Python | `_token = _async_register(_cb)` stores the `ctypes.CFUNCTYPE` trampoline in the module-level `_async_pending` dict | `_async_pending.pop(_token, None)` when the callback fires | The callback settles the `asyncio` future via `loop.call_soon_threadsafe`; no thread blocks waiting. | -| C++ | `new std::promise()` plus the lambda capture | `delete p;` once at the end of the lambda | The lambda owns the heap promise on every exit branch. | -| Dart | `NativeCallable<...>.listener(...)` | `callable.close()` in `finally` and on the catch path | Pointer-typed parameters are kept alive in `whenComplete`. | -| Wasm | `_registerTrampoline` per signature plus `_asyncContexts.set(ctxId, ...)` per call | `_asyncContexts.delete(ctxId)` in the trampoline | Per-call resolver closures are removed after resolve/reject. | -| Go | `wvCallbackStore(ch)` registers the channel in a global registry keyed by an integer id | `wvCallbackTake(id)` removes it when the exported trampoline fires | The context crossing C is an integer id, never a Go pointer (cgo rule); the channel is buffered so the producer thread never blocks. | -| Ruby | the `FFI::Function` trampoline is a local kept alive by the enclosing method scope | the blocking `queue.pop` returns only after the callback ran | The wrapper blocks the calling Ruby thread, so the trampoline cannot be collected while the producer can still call it. | +Every binding pins the user-supplied `void* context` and the callback closure +for the lifetime of the operation, then releases them exactly once on the +callback path. The matrix below is the contract every generator implements; +each row is asserted by that generator's unit tests. + +| Target | Pin (allocate / retain) | Unpin (free / release) on callback | Notes | +|--------|-------------------------|------------------------------------|-------| +| Swift | `Unmanaged.passRetained(ContinuationRef(...))` | `Unmanaged.fromOpaque(ctx).takeRetainedValue()` | The retained `+1` is dropped exactly once when the continuation resumes. | +| .NET | `GCHandle.Alloc(callback, GCHandleType.Normal)` | `GCHandle.FromIntPtr(context).Free()` | The catch path also frees the handle on synchronous failure. | +| Kotlin | JNI `(*env)->NewGlobalRef(env, callback)` | `(*env)->DeleteGlobalRef(env, ctx->callback)` | The JNI shim `malloc`s and `free`s the per-call context exactly once. | +| Node.js | `napi_create_promise(env, &deferred, &promise)` | `napi_resolve_deferred` or `napi_reject_deferred` | The N-API runtime owns the deferred; the per-call context is `malloc`-ed and freed exactly once. | +| Python | `_token = _async_register(_cb)` stores the `ctypes.CFUNCTYPE` trampoline in the module-level `_async_pending` dict | `_async_pending.pop(_token, None)` when the callback fires | The callback settles the `asyncio` future via `loop.call_soon_threadsafe`; no thread blocks waiting. | +| C++ | `new std::promise()` plus the lambda capture | `delete p;` once at the end of the lambda | The lambda owns the heap promise on every exit branch. | +| Dart | `NativeCallable<...>.listener(...)` | `callable.close()` in `finally` and on the catch path | Pointer-typed parameters are kept alive in `whenComplete`. | +| Wasm | one table trampoline per signature plus `_asyncContexts.set(ctxId, ...)` per call | `_asyncContexts.delete(ctxId)` in the trampoline | Per-call resolver closures are removed after resolve/reject. | +| Go | `wvCallbackStore(ch)` registers the channel in a global registry keyed by an integer id | `wvCallbackTake(id)` removes it when the exported trampoline fires | The context crossing C is an integer id, never a Go pointer (cgo rule); the channel is buffered so the producer thread never blocks. | +| Ruby | the `FFI::Function` trampoline is a local kept alive by the enclosing method scope | the blocking `queue.pop` returns only after the callback ran | The wrapper blocks the calling Ruby thread, so the trampoline cannot be collected while the producer can still call it. | ### Audit invariants @@ -326,41 +546,58 @@ For every async-capable target: 1. The `void* context` has exactly one owner at any moment. 2. The callback closure is pinned by an explicit "+1" allocation (`GCHandle.Alloc`, `Unmanaged.passRetained`, `NewGlobalRef`, - `NativeCallable.listener`, …) before the C worker can see it, and + `NativeCallable.listener`, and so on) before the C worker can see it, and released by the matching "-1" exactly once on the callback path. -3. Synchronous failure of the C call (the callback never fires) is - handled in a `catch` / `try` that frees the pin so it does not leak. -4. The async-demo sample exports `weaveffi_tasks_active_callbacks()` - so a harness can assert the count returns to zero after a burst of - concurrent calls. +3. Synchronous failure of the C call (the callback never fires) is handled in + a `catch` / `try` that frees the pin so it does not leak. +4. The async-demo sample exports `weaveffi_tasks_active_callbacks()` so a + harness can assert the count returns to zero after a burst of concurrent + calls. ## Pitfalls -- **Async void functions**: the validator emits a warning. They are - valid but almost always indicate a missing return type. +- **Awaiting Tokio without installing Tokio**: the default spawner has no + reactor, so `tokio::time::sleep` or a `TcpStream` read under it never + wakes and the callback never fires. Call `weaveffi::set_spawner` with a + runtime handle before the first async launch. +- **Installing the spawner twice**: `set_spawner` is first-one-wins and + returns `Err(SpawnerAlreadySet)` afterwards; a second runtime is silently + not used. Install once, from a single well-defined initialization point. +- **Blocking inside `Spawner::spawn`**: the launcher calls it on the + consumer's thread. Hand the future off and return. +- **Forgetting `#[weaveffi::cancellable]`**: a `CancelToken` parameter alone + does not make a function cancellable; without the attribute the macro + reports a missing-argument error at the call site. +- **Async void functions**: the validator emits a warning. They are valid but + almost always indicate a missing return type. - **Forgetting `cancellable: true`**: without it, the launcher has no cancel-token slot and the operation cannot be cancelled at all. -- **Using async for CPU-bound work**: the callback overhead exceeds - the work being done; keep it synchronous. -- **Calling Go/Ruby async functions on a latency-sensitive thread**: - both wrappers block the calling thread until the producer completes. - Wrap the call in a goroutine / Ruby `Thread` when you need - concurrency; the native work already runs off-thread. -- **Letting the callback closure get garbage-collected**: every - generator pins it explicitly. Do not strip those pins when editing - generated code by hand. -- **Returning `null` instead of invoking the callback**: the contract - is that the callback fires **exactly once** for every async call, - including on cancellation. -- **Forgetting to free an owned result**: strings, bytes, and - serialized value buffers passed to the callback belong to the - consumer. Copy or decode them, then release them with - `weaveffi_free_string` or `weaveffi_free_bytes`; a callback that - only copies leaks the producer's allocation. The generated wrappers - do this for you; the rule matters when you consume the raw C - surface. -- **Freeing the boxed error with the wrong symbol**: the error passed - to an async callback is heap-boxed, so it is released with - `weaveffi_error_free` (which frees the message, the payload, and the - box). Calling only `weaveffi_error_clear` on it leaks the box; - calling `free()` on it leaks the message and payload. +- **Expecting a reserved "cancelled" code**: there is none. Return one of + your own domain codes when the token is set, or complete with a partial + result. +- **Using async for CPU-bound work**: the callback overhead exceeds the work + being done; keep it synchronous. +- **Calling Go/Ruby async functions on a latency-sensitive thread**: both + wrappers block the calling thread until the producer completes. Wrap the + call in a goroutine / Ruby `Thread` when you need concurrency; the native + work already runs off-thread. +- **Letting the callback closure get garbage-collected**: every generator + pins it explicitly. Do not strip those pins when editing generated code by + hand. +- **Returning `null` instead of invoking the callback**: the contract is that + the callback fires **exactly once** for every async call, including on + cancellation and on panic. +- **Forgetting to free an owned result**: strings, bytes, and serialized + value buffers passed to the callback belong to the consumer. Copy or decode + them, then release them with `weaveffi_free_string` or + `weaveffi_free_bytes`; a callback that only copies leaks the producer's + allocation. The generated wrappers do this for you; the rule matters when + you consume the raw C surface. +- **Freeing the boxed error with the wrong symbol**: the error passed to an + async callback is heap-boxed, so it is released with `weaveffi_error_free` + (which frees the message, the payload, and the box). Calling only + `weaveffi_error_clear` on it leaks the box; calling `free()` on it leaks + the message and payload. +- **Awaiting an external wake on wasm**: the inline executor has no way to + resume the future, so the module hangs. Keep wasm async exports to work + that completes without waiting. diff --git a/docs/src/guides/config.md b/docs/src/guides/config.md index e7e9daee..5d0d922e 100644 --- a/docs/src/guides/config.md +++ b/docs/src/guides/config.md @@ -23,7 +23,7 @@ c_prefix = "kv" [generators.swift] module_name = "KVStore" -[generators.android] +[generators.kotlin] package = "com.example.kvstore" ``` @@ -52,8 +52,11 @@ weaveffi generate src/lib.rs -o generated --config ci.toml # a specific file Without any config file, every generator runs with its defaults and the package name falls back as described under [Package identity](#package-identity). -Unknown tables and keys are rejected, so a typo like `[generator.swift]` or -`modulename` fails the run instead of silently doing nothing. +Every unknown table and key is rejected, so a typo like `[generator.swift]`, +`c_prefx`, or `modulename` fails the run with the offending name, and so does +a table for a target that no longer exists (such as the pre-0.22 +`[generators.android]`; the target is now `kotlin`). The accepted per-target +keys are listed in the [table below](#generatorstarget). ## `[package]` @@ -115,12 +118,13 @@ wrappers call; you rarely set it per target because of the fan-out above. | `[generators.cpp]` | `standard` | string | `"17"` | C++ standard for the generated `CMakeLists.txt` | | `[generators.swift]` | `module_name` | string | identity | Swift module name in `Package.swift` and the `Sources/` directory | | `[generators.swift]` | `strip_module_prefix` | bool | `true` | Strip the module prefix from emitted Swift symbols | -| `[generators.android]` | `package` | string | `"com.weaveffi"` | Java/Kotlin package declaration in the JNI wrapper | -| `[generators.android]` | `strip_module_prefix` | bool | `true` | Strip the module prefix from emitted Kotlin symbols | +| `[generators.kotlin]` | `package` | string | `"com.weaveffi"` | JVM package for the generated Kotlin wrapper and the `namespace` in `build.gradle.kts` | +| `[generators.kotlin]` | `strip_module_prefix` | bool | `true` | Strip the module prefix from emitted Kotlin symbols | | `[generators.node]` | `package_name` | string | identity | npm package name | | `[generators.node]` | `strip_module_prefix` | bool | `true` | Strip the module prefix from emitted JS/TS symbols | | `[generators.wasm]` | `module_name` | string | `"weaveffi_wasm"` | Module name in the Wasm JS loader | -| `[generators.wasm]` | `emscripten` | bool | `false` | Target an Emscripten build: the loader accepts a pre-initialized Emscripten `Module` (or its `MODULARIZE` factory promise) instead of a `.wasm` URL; async, callbacks, and listeners become throwing stubs | +| `[generators.wasm]` | `emscripten` | bool | `false` | Target an Emscripten build: the loader accepts a pre-initialized Emscripten `Module` (or its `MODULARIZE` factory promise) instead of a `.wasm` source. Async functions and callback interfaces are unsupported in this mode | +| `[generators.wasm]` | `allow_unsupported` | bool | `false` | In Emscripten mode, generate anyway when the API uses async functions or callback interfaces: the capability failure becomes a warning and those entry points are emitted as explicit throwing stubs. No effect outside Emscripten mode | | `[generators.python]` | `package_name` | string | identity | Python package name | | `[generators.python]` | `strip_module_prefix` | bool | `true` | Strip the module prefix from emitted Python symbols | | `[generators.dotnet]` | `namespace` | string | identity | .NET namespace and NuGet package id | @@ -147,11 +151,19 @@ module_name = "MyAppFFI" ``` ```toml -# Android library -[generators.android] +# Android or desktop-JVM library +[generators.kotlin] package = "com.example.myapp.ffi" ``` +```toml +# Browser bundle linked into an existing Emscripten build (no async or +# callback interfaces in the API) +[generators.wasm] +module_name = "myapp" +emscripten = true +``` + ```toml # Scoped npm package, module-prefixed names everywhere [global] diff --git a/docs/src/guides/errors.md b/docs/src/guides/errors.md index a2fa27b4..12861976 100644 --- a/docs/src/guides/errors.md +++ b/docs/src/guides/errors.md @@ -12,136 +12,170 @@ catch and match on the codes you declared. A callable **without** `throws` has a plain signature: no `throws` clause, no error return. It cannot report a domain error; the only failures it can -experience are producer bugs (a panic, a marshalling failure), and those -trap loudly through the target's programming-error idiom rather than -surfacing as a typed error. The two interpretations are named once, in -`weaveffi_core::plan::ErrorStrategy`, and every generator renders the -same pair; see [Throws versus Trap](#throws-versus-trap). +experience are producer bugs (a panic, a marshalling failure) or a failing +consumer callback, and those trap loudly through the target's +programming-error idiom rather than surfacing as a typed error. The two +interpretations are named once, in `weaveffi_core::plan::ErrorStrategy`, and +every generator renders the same pair; see +[Throws versus Trap](#throws-versus-trap). Underneath, every generated symbol still reports through the C-level -out-error parameter (`weaveffi_error*`) with an integer code, an -optional message string, and an optional structured payload; the typed -surface is built on top of it. +out-error parameter (`weaveffi_error*`) with an integer code, an optional +message string, and an optional structured payload; the typed surface is +built on top of it. ABI revision 2 adds one code to the reserved negative +range, `-4` (`FOREIGN_ERROR_CODE`), for failures that originate in a +consumer's callback-interface implementation, and one runtime symbol, +`weaveffi_error_set`, that consumers use to report them. ## When to use Reach for this guide when: -- You are designing an IDL and want to surface stable, named error - codes to consumers as typed errors. -- You are writing the Rust implementation of a module and need to - return errors over the C ABI. -- You are debugging an "unknown error" surface in a generated - binding. -- You are reviewing or extending a generator and need to know what the - error contract guarantees. +- You are designing an IDL and want to surface stable, named error codes to + consumers as typed errors. +- You are writing the Rust implementation of a module and need to return + errors over the C ABI. +- You implement a callback interface in a consumer language and want to know + what happens when your implementation throws. +- You are debugging an "unknown error" surface in a generated binding. +- You are reviewing or extending a generator and need to know what the error + contract guarantees. ## Step-by-step ### Declare a domain and opt in with `throws` ```yaml -version: "0.8.0" +version: "0.9.0" modules: - - name: contacts + - name: kv errors: - name: ContactsError + name: KvError codes: - - name: InvalidName - code: 1 - message: "name must not be empty" - - name: NotFound - code: 2 - message: "contact not found" - - functions: - - name: get_contact - params: - - { name: id, type: i64 } - return: string - throws: true - - - name: count_contacts - params: [] - return: i32 + - name: KeyNotFound + code: 1001 + message: "key not found" + - name: Expired + code: 1002 + message: "entry expired" + - name: StoreFull + code: 1003 + message: "store has reached capacity" + - name: IoError + code: 1004 + message: "I/O failure" + + interfaces: + - name: Store + constructors: + - name: open + params: + - { name: path, type: string } + throws: true + methods: + - name: get + params: + - { name: key, type: string } + return: Entry? + throws: true + - name: count + params: [] + return: i64 ``` -`get_contact` is fallible and delivers `ContactsError` values; -`count_contacts` has a plain signature in every target. Code names are -PascalCase by convention (`NotFound`, not `not_found`); each generator -re-cases them into its own idiom. +`open` and `get` are fallible and deliver `KvError` values; `count` has a +plain signature in every target. Code names are PascalCase by convention +(`KeyNotFound`, not `key_not_found`); each generator re-cases them into its +own idiom. -The domain is in scope for its module and every module nested inside it, -so one domain on a parent module can serve a whole subtree. Interface -constructors and methods opt in with the same `throws: true` flag. +The domain is in scope for its module and every module nested inside it, so +one domain on a parent module can serve a whole subtree. Free functions, +interface constructors, methods, and statics all opt in with the same +`throws: true` flag. Callback-interface methods can't: they are implemented +by the consumer, and a consumer failure has its own channel (see +[Foreign errors](#foreign-errors)). -A code may additionally declare structured payload `fields:` (the same -shape as struct fields). When a matching error is raised, those fields -travel across the ABI in the error's payload buffer and surface as -properties on the typed error the consumer catches: +A code may additionally declare structured payload `fields:` (the same shape +as record fields). When a matching error is raised, those fields travel +across the ABI in the error's payload buffer and surface as properties on the +typed error the consumer catches: ```yaml codes: - - name: NotFound - code: 2 - message: "contact not found" + - name: StoreFull + code: 1003 + message: "store has reached capacity" fields: - - { name: id, type: i64 } + - { name: capacity, type: i64 } ``` See [Structured error payloads](../reference/idl.md#structured-error-payloads) for the schema and the -[Value Buffer Protocol](../reference/value-buffers.md#structured-errors) -for the wire format. +[Value Buffer Protocol](../reference/value-buffers.md#structured-errors) for +the wire format. The validator enforces: -- Codes must be positive. `0` is reserved for success and the whole - negative range belongs to the runtime (`-1` generic error, `-2` - producer panic, `-3` marshalling failure). +- Codes must be positive. `0` is reserved for success and the whole negative + range belongs to the runtime (`-1` generic error, `-2` producer panic, `-3` + marshalling failure, `-4` foreign error). - Numeric codes are unique within a domain. - Code names are unique within a domain **and across every domain in the - API**. Backends with flat namespaces derive one error class or constant - per code, so two domains both declaring `NotFound` would collide; - qualify one of them (e.g. `OrderNotFound`). + API**. Backends with flat namespaces derive one error class or constant per + code, so two domains both declaring `NotFound` would collide; qualify one of + them (for example `OrderNotFound`). - The domain `name` must not be empty, must not collide with any function - name in the module, and shares the API-wide type namespace with struct, - enum, and interface names. -- `throws: true` with no domain in scope (on the module or an ancestor) - is an error. + name in the module, and shares the API-wide type namespace with record, + enum, interface, and callback-interface names. +- `throws: true` with no domain in scope (on the module or an ancestor) is an + error, and so is `throws: true` on a callback-interface method. ### Report errors from the producer **With the Rust macro**, declare the domain as a `#[weaveffi::error]` enum whose discriminants are the ABI codes (doc comments become the default -messages), and return `Result` from fallible functions: +messages), and return `Result` from fallible functions. This is +the `kvstore` sample's domain: ```rust #[weaveffi::module] -pub mod contacts { +pub mod kv { + /// The store's error domain. #[weaveffi::error] #[derive(Debug)] - pub enum ContactsError { - /// name must not be empty - InvalidName = 1, - /// contact not found - NotFound = 2, + pub enum KvError { + /// key not found + KeyNotFound = 1001, + /// entry expired + Expired = 1002, + /// store has reached capacity + StoreFull = 1003, + /// I/O failure + IoError = 1004, } #[weaveffi::export] - pub fn get_contact(id: i64) -> Result { - Err(ContactsError::NotFound) + pub fn open_path(path: String) -> Result { + if path.is_empty() { + return Err(KvError::IoError); + } + Ok(path) } } + +weaveffi::export_runtime!(); ``` The macro generates the `ErrorReport` implementation and the C ABI thunks -that write the matching code and message into `out_err`. +that write the matching code and message into `out_err`. `Result` +is also accepted; it reports the generic code `-1` with the string as the +message, which consumers see as the untyped fallback rather than a domain +case. -A variant may carry named fields, which become the code's structured -payload; the macro serializes them into the error's payload buffer in -declaration order. Field-carrying variants with explicit discriminants -require a primitive repr on the enum: +A variant may carry named fields, which become the code's structured payload; +the macro serializes them into the error's payload buffer in declaration +order. Field-carrying variants with explicit discriminants require a +primitive repr on the enum: ```rust #[weaveffi::error] @@ -155,34 +189,36 @@ pub enum QuotaError { } ``` -**If you hand-implement the C ABI** (a non-Rust producer, or Rust without -the macro), report through the `weaveffi-abi` helpers, preferring the -codes you declared in the IDL: +**If you hand-implement the C ABI** (a non-Rust producer, or Rust without the +macro), report through the `weaveffi-abi` helpers, preferring the codes you +declared in the IDL: ```rust use weaveffi_abi::{self as abi, weaveffi_error}; #[no_mangle] -pub extern "C" fn weaveffi_contacts_get_contact( - id: i64, +pub extern "C" fn weaveffi_kv_open_path( + path: *const std::ffi::c_char, out_err: *mut weaveffi_error, ) -> *const std::ffi::c_char { - abi::error_set(out_err, 2, "contact not found"); + let _ = path; + abi::error_set(out_err, 1004, "I/O failure"); std::ptr::null() } ``` -| Helper | Effect | -|----------------------------------------|-----------------------------------------------------| -| `error_set_ok(out_err)` | Sets `code = 0`, frees any prior message and payload | -| `error_set(out_err, code, msg)` | Sets a non-zero code and allocates a message | +| Helper | Effect | +|--------|--------| +| `error_set_ok(out_err)` | Sets `code = 0`, frees any prior message and payload | +| `error_set(out_err, code, msg)` | Sets a non-zero code and allocates a message | | `error_set_with_payload(out_err, code, msg, payload)` | Like `error_set`, plus an owned payload buffer (the code's fields in value-buffer format) | -| `result_to_out_err(result, out_err)` | Maps `Result` through `ErrorReport` (domain code, message, and payload for implementors, generic `-1` for `String` and `&str` errors) | -| `error_set_panic(out_err, payload)` | Reports a caught panic with the reserved code `-2` | +| `result_to_out_err(result, out_err)` | Maps `Result` through `ErrorReport` (domain code, message, and payload for implementors; generic `-1` for `String` and `&str` errors) | +| `error_set_panic(out_err, payload)` | Reports a caught unwind: `-4` with the consumer's message for a `ForeignError` payload, `-2` for anything else | +| `check_foreign_error(err)` | After a vtable call: no-op on `code == 0`, otherwise unwinds with a `ForeignError` | Hand-written `ErrorReport` implementations can override the trait's -`payload(&self) -> Vec` method to serialize a code's declared fields; -the default reports no payload. +`payload(&self) -> Vec` method to serialize a code's declared fields; the +default reports no payload. ### Handle errors in C @@ -191,7 +227,7 @@ The C surface is the raw out-error struct: ```c weaveffi_error err = {0}; -const char* contact = weaveffi_contacts_get_contact(id, &err); +weaveffi_kv_Store* store = weaveffi_kv_Store_open("", &err); if (err.code) { fprintf(stderr, "error %d: %s\n", err.code, err.message ? err.message : "unknown"); @@ -199,179 +235,279 @@ if (err.code) { return 1; } -printf("contact: %s\n", contact); -weaveffi_free_string(contact); +weaveffi_kv_Store_destroy(store); ``` The pattern is always: 1. Zero-initialise: `weaveffi_error err = {0};`. 2. Call the function with `&err` as the last argument. -3. Check `err.code`; if non-zero, read `err.message` and call +3. Check `err.code`; if non-zero, read `err.message` (and `payload_ptr` / + `payload_len` if the code declares fields) and call `weaveffi_error_clear(&err)`. 4. Reuse the struct for subsequent calls. -The domain's codes are also emitted as a C enum, so a consumer can match -on names instead of magic numbers: +The domain's codes are also emitted as a C enum, so a consumer can match on +names instead of magic numbers: ```c typedef enum { - weaveffi_contacts_ContactsError_InvalidName = 1, - weaveffi_contacts_ContactsError_NotFound = 2 -} weaveffi_contacts_ContactsError; + weaveffi_kv_KvError_KeyNotFound = 1001, + weaveffi_kv_KvError_Expired = 1002, + weaveffi_kv_KvError_StoreFull = 1003, + weaveffi_kv_KvError_IoError = 1004 +} weaveffi_kv_KvError; ``` +Async completion callbacks receive a *heap-boxed* `weaveffi_error*` instead +of writing into a caller-owned slot; release it with `weaveffi_error_free`, +which clears the slot and frees the box. See +[Async Functions](async.md#result-ownership-and-threading). + ### What consumers see -Every other target wraps that struct into a typed error construct named -after the domain. In Swift, the domain becomes an error enum with one -case per code (named in lowerCamelCase), and throwing wrappers `throw` -it: +Every other target wraps that struct into a typed error construct named after +the domain. The table below is taken from the bindings `weaveffi generate` +emits for `samples/kvstore` (domain `KvError`, code `KeyNotFound = 1001`): + +| Target | Domain type | The `KeyNotFound` case | Generic fallback | +|--------|-------------|------------------------|------------------| +| Swift | `enum KvError: Error, LocalizedError` | `.keyNotFound(message:)` | `WeaveFFIError.error(code:message:)` | +| Kotlin | `sealed class KvException : WeaveFFIException` | `KvException.KeyNotFound` | `WeaveFFIException(code, message)` | +| Python | `class KvError(WeaveFFIError)` | `class KeyNotFound(KvError)` | `WeaveFFIError` | +| Node.js | `class KvError extends WeaveFFIError` | `class KeyNotFoundError extends KvError` (`KeyNotFoundError.CODE === 1001`) | `WeaveFFIError` | +| WASM | `class KvError extends WeaveFFIError` | `class KeyNotFound extends KvError` (`KeyNotFound.CODE === 1001`) | `WeaveFFIError` | +| .NET | `class KvException : WeaveFFIException` | `KvException.KeyNotFound` constant, matched on `Code` | `WeaveFFIException` | +| Dart | `class KvException extends WeaveFFIException` | `class KeyNotFoundException extends KvException` | `WeaveFFIException` | +| Ruby | `class KvError < Kvstore::Error` | `KvError::KeyNotFound` | `Kvstore::Error` (the wrapping module is `[generators.ruby] module_name`, `WeaveFFI` by default; the sample sets `Kvstore`) | +| Go | `type KvError struct { Code, Message }` | `KvErrorKeyNotFound` code constant | `*WeaveFFIError` | +| C++ | `class KvError : public WeaveFFIError` | `class KeyNotFoundError : public KvError` | `kvstore::WeaveFFIError` | + +In Swift, throwing wrappers `throw` the domain enum: ```swift -public enum ContactsError: Error, LocalizedError { - case invalidName(message: String) - case notFound(message: String) -} - do { - let contact = try Contacts.getContact(id: 42) - print(contact) -} catch ContactsError.notFound { - print("no such contact") + let store = try Store.open(path: "") +} catch KvError.ioError(let message) { + print("cannot open: \(message)") } ``` -In Python, the domain becomes an exception class (subclassing the generic +In Python, the domain is an exception class (subclassing the generic `WeaveFFIError`) with one subclass per code carrying its stable `CODE`: ```python try: - contact = contacts_get_contact(42) -except ContactsError.NotFound: - print("no such contact") + store = Store.open("") +except IoError as e: + print("cannot open:", e) ``` -The remaining targets follow the same conceptual shape in their own -idiom: one typed error construct per domain, one case or subclass per -code, delivered through the language's native error channel. Ecosystems -that suffix exceptions rename the domain accordingly (`ContactsError` -becomes `ContactsException` in Kotlin, .NET, and Dart). A code the -consumer doesn't recognize (from a newer producer, for example) falls -back to the generic branded error rather than being dropped. - -When a code declares payload `fields:`, each generator decodes the -error's payload buffer and exposes the fields as properties of the -raised exception (or returned error value), keyed by the field names. +The remaining targets follow the same conceptual shape in their own idiom: +one typed error construct per domain, delivered through the language's native +error channel. Ecosystems that suffix exceptions rename the domain +accordingly (`KvError` becomes `KvException` in Kotlin, .NET, and Dart, and +`IoError` becomes `IoException`). A code the consumer doesn't recognize (from +a newer producer, for example) falls back to the generic branded error rather +than being dropped. + +When a code declares payload `fields:`, each generator decodes the error's +payload buffer and exposes the fields as properties of the raised exception +(or returned error value), keyed by the field names. + +### Foreign errors + +A callback interface is implemented by the consumer, so its methods can fail +in the consumer's language: a Swift `throw`, a Python exception, a Go panic. +A callback method has no `throws` flag and returns only a scalar, so that +failure travels the other way through the ABI: + +1. The generated trampoline catches the failure and reports it into the + vtable entry's `out_err` slot by calling + `weaveffi_error_set(out_err, -4, message)`. The runtime copies the + borrowed message with the producer's allocator, so the trampoline can free + its own copy immediately. +2. On the producer side, the generated trait implementation calls + `check_foreign_error` after every vtable call. A non-zero code unwinds the + producer's current call with a `ForeignError` payload (via + `resume_unwind`, so the panic hook does not fire; this is control flow, not + a bug report). A negative code is kept as written; a positive one is + replaced by `-4`, so a consumer bug can't masquerade as one of the + producer's domain codes. On a `panic = "abort"` build (notably + `wasm32-unknown-unknown`) there's no unwinding: the failure is recorded in + a thread-local slot, the vtable entry's zero return value flows back into + the producer's code, and the thunk picks the recorded failure up with + `take_foreign_error` once the producer returns. +3. The enclosing thunk reports `FOREIGN_ERROR_CODE` (`-4`) with the + consumer's message to the **original caller**, the code that invoked the + producer function that in turn invoked the callback. + +Every generated trampoline already does step 1; the table shows what your +implementation writes and what the original caller reads: + +| Target | How an implementation fails | What the original caller sees | +|--------|-----------------------------|-------------------------------| +| Swift | protocol method is `throws`; throw any `Error` | `WeaveFFIError.error(code: -4, ...)` on a throwing call; `fatalError` on a non-throwing call | +| Kotlin | throw any `Throwable` | `WeaveFFIException` with `code == -4` | +| Python | raise any exception | `WeaveFFIError` with `code == -4` (`WeaveFFIError.FOREIGN_ERROR_CODE`) | +| Node.js / WASM | throw | `WeaveFFIError` with `code === -4` | +| .NET | throw | `WeaveFFIException` with `Code == WeaveFFIException.ForeignErrorCode` | +| Dart | throw | `WeaveFFIException` with `code == WeaveFFIException.foreignCode` | +| Ruby | raise | `Kvstore::Error` with `code == -4` (`Kvstore::FOREIGN_ERROR_CODE`) | +| Go | `panic` inside the method | `*WeaveFFIError` with `Code == -4` on a `(T, error)` call; `panic` on a plain call | +| C++ | throw a `std::exception` (or anything; non-standard exceptions get a fixed message) | `WeaveFFIError` with `code() == -4` | +| C | `weaveffi_error_set(out_err, -4, "message")` and return a default | `err.code == -4` | + +Because `-4` is not a domain code, the typed path never maps it onto a +domain case: a throwing call surfaces it as the generic branded error, and a +non-throwing call traps. If the producer function that called the callback +was declared without `throws`, the consumer's own exception therefore comes +back as a trap (`fatalError` in Swift, `panic` in Go). Declare `throws: true` +on producer functions that invoke callbacks if you want consumers to be able +to catch their own failures. + +A foreign error also unwinds through whatever the producer was holding at the +time. If that was a `std::sync::Mutex` guard, the mutex is poisoned; +producers that call out to consumers should snapshot state under the lock, +release it, and then call. See +[Memory Ownership](memory.md#producer-side-rules). ### Producer panics Generated Rust thunks wrap the producer call in `catch_unwind`. A panic is reported through `out_err` with the reserved code `-2` -(`weaveffi_abi::PANIC_ERROR_CODE`) and the panic message, so a consumer -can always distinguish "the producer has a bug" from any declared domain -error. Panics never surface as typed domain errors: on a throwing -callable they arrive as the generic branded error, and on a non-throwing -callable they surface as the target's unrecoverable idiom (a Swift -`fatalError`, a Go `panic`, a generic exception elsewhere). +(`weaveffi_abi::PANIC_ERROR_CODE`) and the message +`producer panicked: `, so a consumer can always distinguish "the +producer has a bug" from any declared domain error and from a foreign error. +Panics never surface as typed domain errors: on a throwing callable they +arrive as the generic branded error, and on a non-throwing callable they +surface as the target's unrecoverable idiom (a Swift `fatalError`, a Go +`panic`, a generic exception elsewhere). + +Object destructors (`{tag}_destroy`) and iterator destructors have no +`out_err` slot; a panic inside a `Drop` implementation is swallowed rather +than unwinding into C. Async functions report panics through the completion +callback's error slot; see [Async Functions](async.md#panic-handling). ## Reference ### Throws versus Trap -Every synchronous C ABI entry point carries a trailing `out_err`, and -every async completion callback carries an `err` slot, regardless of -`throws`. What differs is the *meaning* of a non-zero code, and every -backend agrees on it because the two interpretations are stated once as +Every synchronous C ABI entry point carries a trailing `out_err`, and every +async completion callback carries an `err` slot, regardless of `throws`. What +differs is the *meaning* of a non-zero code, and every backend agrees on it +because the two interpretations are stated once as `weaveffi_core::plan::ErrorStrategy`: -- **Throws** (`throws: true`): a non-zero code is a typed domain error. - The wrapper maps the code onto the module's error domain (an - exception subclass, a Swift `Error` enum case, a Go `error` value) - and surfaces it through the target's normal error channel so callers - can catch and match on it. +- **Throws** (`throws: true`): a non-zero code is a typed domain error. The + wrapper maps the code onto the module's error domain (an exception + subclass, a Swift `Error` enum case, a Go `error` value) and surfaces it + through the target's normal error channel so callers can catch and match + on it. Negative codes fall through to the generic branded error. - **Trap** (no `throws`): the only way `out_err` reports failure is a - producer bug (most commonly a caught panic, code `-2`). The wrapper + producer bug or a failing callback (codes `-2`, `-3`, `-4`). The wrapper surfaces it through the target's *programming-error* idiom (a Python - `WeaveFFIError`, a Go `panic`, a Swift `fatalError`, a C# exception). - A trapped failure is never silently ignored, and it is never dressed - up as a typed domain error. + `WeaveFFIError`, a Go `panic`, a Swift `fatalError`, a C# exception). A + trapped failure is never silently ignored, and it is never dressed up as a + typed domain error. The per-target rendering of both strategies is tabulated [below](#per-target-surface). At the ABI level, `weaveffi_error.code` means: -| Code | Meaning | -|--------------------|------------------------------------------------------| -| `0` | Success | -| a declared code | A typed producer error from the module's domain (always positive) | -| `-1` | Generic error (a `Result` producer error) | -| `-2` | Producer panic (`PANIC_ERROR_CODE`) | -| `-3` | Marshalling failure (`MARSHAL_ERROR_CODE`): a null or invalid argument, a non-UTF-8 string, an out-of-range enum value, or a malformed value buffer | - -Domain codes are validated positive-only, so the two ranges can't -collide: on the typed path a wrapper maps a positive code to the -matching declared case of the domain type and falls back to the generic -branded error for any code the domain doesn't declare (including every -negative runtime code). +| Code | Constant | Meaning | +|------|----------|---------| +| `0` | | Success | +| a declared code | | A typed producer error from the module's domain (always positive) | +| `-1` | `GENERIC_ERROR_CODE` | Untyped producer error (a `Result`, or an error type without a domain code) | +| `-2` | `PANIC_ERROR_CODE` | Producer panic, caught by the thunk's `catch_unwind` | +| `-3` | `MARSHAL_ERROR_CODE` | Marshalling failure: a null or invalid argument, a non-UTF-8 string, an out-of-range enum value, or a malformed value buffer | +| `-4` | `FOREIGN_ERROR_CODE` | A consumer callback-interface implementation failed; the message is the consumer's | + +Domain codes are validated positive-only, so the two ranges can't collide: on +the typed path a wrapper maps a positive code to the matching declared case of +the domain type and falls back to the generic branded error for any code the +domain doesn't declare (including every negative runtime code). Generated +wrappers also raise the generic branded error themselves, without calling the +producer, for contract violations they detect locally (a disposed object +wrapper, a value that isn't an object of the expected class, a malformed +value buffer). ### Per-target surface Per target, the two strategies surface as: -| Target | Throws (`throws: true`) | Trap (producer bug) | -|----------|----------------------------------------------|------------------------------------------| -| C | `weaveffi_error { code, message, payload }` struct | same struct (code `-2` or `-3`) | -| Swift | `throws`, typed domain enum | `fatalError` | -| Python | `raise`, domain exception subclass | `raise WeaveFFIError` | -| Kotlin | `throw`, typed domain exception | `throw WeaveFFIException` | -| C# | `throw`, typed domain exception | `throw WeaveFFIException` | -| Dart | `throw`, typed domain exception | `throw WeaveFFIException` | -| JS/TS | `throw`, typed domain error | `throw WeaveFFIError` | -| Ruby | `raise`, typed domain error | `raise WeaveFFI::Error` | -| Go | `(T, error)` return, typed domain error | `panic` | -| C++ | `throw`, typed domain error | `throw weaveffi::Error` | +| Target | Throws (`throws: true`) | Trap (producer bug or foreign error) | +|--------|-------------------------|--------------------------------------| +| C | `weaveffi_error { code, message, payload_ptr, payload_len }` struct | same struct (code `-2`, `-3`, or `-4`) | +| Swift | `throws`, typed domain enum | `fatalError` | +| Python | `raise`, domain exception subclass | `raise WeaveFFIError` | +| Kotlin | `throw`, typed domain exception | `throw WeaveFFIException` | +| C# | `throw`, typed domain exception | `throw WeaveFFIException` | +| Dart | `throw`, typed domain exception | `throw WeaveFFIException` | +| Node.js / WASM | `throw`, typed domain error | `throw WeaveFFIError` | +| Ruby | `raise`, typed domain error | `raise ::Error` (`WeaveFFI::Error` by default) | +| Go | `(T, error)` return, typed domain error | `panic` | +| C++ | `throw`, typed domain error | `throw ::WeaveFFIError` | All targets share the canonical `WeaveFFI` brand (never the `heck`-derived -`Weaveffi`) for the generic fallback type. Error type names are derived -from a single naming policy: ecosystems that suffix with `Error` (Swift, -C++, Python, Node, Ruby, Go) use `WeaveFFIError`; ecosystems that suffix -with `Exception` (Kotlin, .NET, Dart) use `WeaveFFIException`. Per-code -names are PascalCased from the IDL, and domain type names keep exactly one -`Error` (or `Exception`) suffix. - -| Field | Type | Description | -|---------------|------------------|---------------------------------------------------| -| `code` | `int32_t` | `0` = success, non-zero = error | -| `message` | `const char*` | `NULL` on success; Rust-allocated string on error | +`Weaveffi`) for the generic fallback type. Error type names are derived from +a single naming policy: ecosystems that suffix with `Error` (Swift, C++, +Python, Node, Go) use `WeaveFFIError`; ecosystems that suffix with +`Exception` (Kotlin, .NET, Dart) use `WeaveFFIException`; Ruby nests a plain +`Error` inside the configured wrapper module. Per-code names are PascalCased from the +IDL, and domain type names keep exactly one `Error` (or `Exception`) suffix. + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `int32_t` | `0` = success, non-zero = error | +| `message` | `const char*` | `NULL` on success; Rust-allocated string on error | | `payload_ptr` | `const uint8_t*` | `NULL` unless the matched code declares `fields:`; the fields serialized in the value-buffer format | -| `payload_len` | `size_t` | Byte length of `payload_ptr`; `0` when null | +| `payload_len` | `size_t` | Byte length of `payload_ptr`; `0` when null | + +Three runtime symbols operate on the struct: + +| Symbol | Who calls it | Effect | +|--------|--------------|--------| +| `weaveffi_error_clear(err)` | consumer, after reading a sync error | frees message and payload, zeroes the slot; idempotent | +| `weaveffi_error_free(err)` | consumer, after reading an async error | clears the slot and frees the heap box it arrived in | +| `weaveffi_error_set(err, code, message)` | consumer, inside a callback-interface trampoline | copies `message` with the producer's allocator and sets `code` | See the [Memory Ownership Guide](memory.md) for the freeing contract on -`err.message` and the payload (`weaveffi_error_clear` releases both). +`err.message` and the payload. ## Pitfalls -- **Forgetting to call `weaveffi_error_clear`**: the message and the - payload are Rust-allocated. Skipping the clear leaks them. -- **Reading `err.message` after clearing**: the pointer is invalid as - soon as `weaveffi_error_clear` returns. -- **Using `0` or a negative number as a domain code**: the validator - rejects both; `0` always means success and negative codes are reserved - for the runtime (`-1` generic, `-2` panic, `-3` marshalling failure). -- **Reusing a code name in two domains**: code names are unique across - the whole API, so the validator rejects a second `NotFound`. Qualify - one of them (`OrderNotFound`). -- **Declaring `throws: true` without a domain in scope**: a throwing - callable needs an `errors:` block on its module or an ancestor. +- **Forgetting to call `weaveffi_error_clear`**: the message and the payload + are Rust-allocated. Skipping the clear leaks them. +- **Reading `err.message` after clearing**: the pointer is invalid as soon as + `weaveffi_error_clear` returns. +- **Using `0` or a negative number as a domain code**: the validator rejects + both; `0` always means success and negative codes are reserved for the + runtime (`-1` generic, `-2` panic, `-3` marshalling failure, `-4` foreign + error). +- **Reusing a code name in two domains**: code names are unique across the + whole API, so the validator rejects a second `NotFound`. Qualify one of + them (`OrderNotFound`). +- **Declaring `throws: true` without a domain in scope**: a throwing callable + needs an `errors:` block on its module or an ancestor. +- **Declaring `throws: true` on a callback-interface method**: rejected. The + consumer reports failures through `weaveffi_error_set` and the producer's + caller sees `-4`. - **Expecting a typed error from a non-throwing function**: a callable without `throws` cannot deliver a domain error; a failure there is a - producer bug and traps through the target's programming-error idiom - (see [Throws versus Trap](#throws-versus-trap)). -- **Not initialising the struct**: always start with - `{0}` (or the language equivalent). Stale `code` values from - earlier calls produce confusing failures. -- **Ignoring the return value when `code != 0`**: Rust does not - promise the return value is meaningful on failure. For pointer - returns it is typically `NULL`; do not free it. + producer bug (or a foreign error) and traps through the target's + programming-error idiom (see [Throws versus Trap](#throws-versus-trap)). +- **Expecting your own callback exception to come back typed**: it surfaces + as the generic branded error with code `-4`, never as a domain case, and + only if the producer function you called was declared `throws: true`. +- **Writing `out_err->message` directly from a hand-written C callback**: + the producer frees that pointer with Rust's allocator. Use + `weaveffi_error_set`, which copies. +- **Not initialising the struct**: always start with `{0}` (or the language + equivalent). Stale `code` values from earlier calls produce confusing + failures. +- **Ignoring the return value when `code != 0`**: Rust does not promise the + return value is meaningful on failure. For pointer returns it is typically + `NULL`; do not free it. diff --git a/docs/src/guides/extract.md b/docs/src/guides/extract.md index 72b8881c..079ebb6f 100644 --- a/docs/src/guides/extract.md +++ b/docs/src/guides/extract.md @@ -5,15 +5,15 @@ One way to drive WeaveFFI is to make annotated Rust your source of truth. The `#[weaveffi::module]` proc-macro reads that annotated source to generate the producer's C ABI glue (see [The Rust Producer Macro](producer-macro.md)), and -the CLI reads the -*same* annotations to derive the IDL and bindings. Both paths call into one -shared extractor (`weaveffi-bridge`), so the IDL the CLI emits and the symbols -the macro produces cannot drift. +the CLI reads the *same* annotations to derive the IDL and bindings. Both +paths call into one shared extractor (`weaveffi-bridge`), so the IDL the CLI +emits and the symbols the macro produces cannot drift. You can point `weaveffi generate` and `weaveffi extract` straight at a `.rs` -file. `generate` lowers the source to the IR in memory and runs the generators; -`extract` writes the derived IDL to disk (handy for review, for committing a -canonical IDL alongside the source, or for piping into another command). +file. `generate` lowers the source to the IR in memory and runs the +generators; `extract` writes the derived IDL to disk (handy for review, for +committing a canonical IDL alongside the source, or for piping into another +command). ## When to use @@ -28,21 +28,23 @@ Author an IDL document (YAML/JSON/TOML) directly when: - You want to design the API before any Rust exists. - You need a feature the extractor cannot infer from Rust syntax, such as - struct field defaults, package and per-generator configuration, or - `since:` without an accompanying `#[deprecated]`. See [Pitfalls](#pitfalls). + record field defaults or a code `doc:` distinct from its `message:`. See + [Pitfalls](#pitfalls). ## Step-by-step ### 1. Annotate the Rust source -Mark an inline module with `#[weaveffi::module]` and tag the items you want to -export. The attributes come from the `weaveffi` crate; the same crate's macro -generates the producer glue when you compile the library. +Mark an inline module with `#[weaveffi::module]` and tag the items you want +to export. The attributes come from the `weaveffi` crate; the same crate's +macro generates the producer glue when you compile the library. ```rust /// Catalog operations. #[weaveffi::module] pub mod inventory { + use std::sync::Arc; + /// A product in the catalog. #[weaveffi::record] #[derive(Clone)] @@ -52,6 +54,8 @@ pub mod inventory { pub name: String, pub price: f64, pub tags: Vec, + /// The warehouse that stocks it, if known. + pub warehouse: Option>, } /// Product availability. @@ -64,17 +68,51 @@ pub mod inventory { Preorder = 2, } + /// A stocking location. + #[weaveffi::interface] + pub struct Warehouse { + code: String, + } + + impl Warehouse { + /// Open a warehouse by code. + pub fn new(code: String) -> Warehouse { + Warehouse { code } + } + + /// The warehouse code. + pub fn code(&self) -> String { + self.code.clone() + } + } + + /// Consumer-implemented stock observer. + #[weaveffi::callback_interface] + pub trait StockObserver: Send + Sync { + /// A product's availability changed. + fn on_change(&self, product: &Product, now: Availability); + } + /// Look up a product by ID. #[weaveffi::export] pub fn get_product(id: i32) -> Option { - todo!() + let _ = id; + None + } + + /// Watch for availability changes; returns the observer count. + #[weaveffi::export] + pub fn watch(observer: Arc) -> i32 { + let _ = observer; + 1 } /// Replaced by `search_v2` in 0.3.0. #[weaveffi::export] #[deprecated(since = "0.2.0", note = "use search_v2 instead")] pub fn search(query: String, limit: i32) -> Vec { - todo!() + let _ = (query, limit); + Vec::new() } /// A nested namespace. @@ -92,16 +130,16 @@ pub mod inventory { ### 2. Run `weaveffi extract` ```sh -weaveffi extract src/lib.rs # YAML to stdout -weaveffi extract src/lib.rs -o api.yml # YAML to file +weaveffi extract src/lib.rs # YAML to stdout +weaveffi extract src/lib.rs -o api.yml # YAML to file weaveffi extract src/lib.rs -f json -o api.json # JSON to file weaveffi extract src/lib.rs | weaveffi generate -o generated ``` The extracted IDL is validated automatically and **extraction fails loudly** -if the result would not generate, for example a `handle` whose target type -the source never declares, a duplicate name, or a listener pointing at a -missing callback. Pass `--warn` to downgrade those errors to a `warning:` line +if the result would not generate, for example an `Arc` whose type the +source never declares, a duplicate name, or a callback-interface method that +returns a string. Pass `--warn` to downgrade those errors to a `warning:` line on stderr and emit the IDL anyway, which is useful when bootstrapping from source that references types you have not declared yet: @@ -109,6 +147,10 @@ source that references types you have not declared yet: weaveffi extract src/lib.rs --warn # best-effort, errors as warnings ``` +Type *syntax* WeaveFFI can't express at all (a raw pointer, a `Box`, a +tuple) is a hard error even under `--warn`; those are parse-time rejections, +not validation findings. See [What is rejected](#what-is-rejected). + ### 3. Generate directly, or validate and generate the IDL Skip the intermediate file and generate from the source: @@ -133,12 +175,12 @@ weaveffi generate api.yml -o generated/ weaveffi extract [--output ] [--format ] [--warn] ``` -| Flag | Default | Description | -|------------------|----------|-----------------------------------------------| -| `` | required | Path to a `.rs` source file | -| `-o`, `--output` | stdout | Write to a file instead of stdout | -| `-f`, `--format` | `yaml` | Output format: `yaml`, `json`, or `toml` | -| `--warn` | off | Downgrade validation errors to warnings and emit the IDL anyway | +| Flag | Default | Description | +|------|---------|-------------| +| `` | required | Path to a `.rs` source file | +| `-o`, `--output` | stdout | Write to a file instead of stdout | +| `-f`, `--format` | `yaml` | Output format: `yaml`, `json`, or `toml` | +| `--warn` | off | Downgrade validation errors to warnings and emit the IDL anyway | ### Attribute reference @@ -147,113 +189,142 @@ namespaced form (`#[weaveffi::record]`) and a bare form (`#[record]`) resolve identically. Prefer the namespaced form: it is what the `#[weaveffi::module]` macro consumes, and it reads unambiguously. -| Attribute | Where it goes | Effect | -|-------------------------------------------------|-------------------------------------|------------------------------------------------------------------------------------------| -| `#[weaveffi::module]` | inline `mod` | Marks an exported namespace. Required: only modules carrying it are extracted. Modules may nest. | -| `#[weaveffi::export]` | free `fn` | Emits a [`Function`] in the enclosing module. `async fn` sets `async: true`; a `Result` return sets `throws: true` (the IDL return type is `T`). | -| `#[weaveffi::record]` | named-field `struct` | Emits a [`StructDef`]. | -| `#[weaveffi::interface]` | `struct` with an `impl` block | Emits an [`InterfaceDef`]. The `impl` block's `pub fn`s become constructors (those returning `Self`), methods (`&self` receivers), and statics. | -| `#[weaveffi::error]` | `enum` with explicit discriminants | Emits the module's error domain. Every variant needs an explicit `= N` discriminant; the first doc line is the code's message. Named-field variants emit the code's payload `fields:` (and require a primitive repr such as `#[repr(i32)]`). | -| `#[weaveffi::enumeration]` + `#[repr(i32)]` | `enum` | Emits an [`EnumDef`]. Every variant must have an explicit `= N` discriminant. | -| `#[weaveffi::cancellable]` | exported `async fn` | Sets `cancellable: true`. | -| `#[weaveffi::callback]` | free `fn` | Emits a module-level [`CallbackDef`] using the function's name and parameters. | -| `#[weaveffi::listener(event = "Name")]` | free `fn` | Emits a [`ListenerDef`] referencing the named callback (the legacy `event_callback` key is also accepted). | -| `#[deprecated(since = "...", note = "...")]` | exported `fn` | Populates `since` and `deprecated`. Bare `#[deprecated]` sets `deprecated = "deprecated"`. | +| Attribute | Where it goes | Effect | +|-----------|---------------|--------| +| `#[weaveffi::module]` | inline `mod` | Marks an exported namespace. Required: only modules carrying it are extracted. Modules may nest. | +| `#[weaveffi::export]` | free `fn` | Emits a [`Function`] in the enclosing module. `async fn` sets `async: true`; a `Result` return sets `throws: true` (the IDL return type is `T`). | +| `#[weaveffi::record]` | named-field `struct` | Emits a [`StructDef`]. Fields may be any IDL type, including `Arc` and `Option>` object references. | +| `#[weaveffi::interface]` | `struct` with an inherent `impl` block | Emits an [`InterfaceDef`]. The `impl` block's `pub fn`s become constructors (no receiver, returning `Self`/`Arc`, optionally in a `Result`), methods (`&self` or `self: Arc` receivers), and statics (no receiver, any other return). | +| `#[weaveffi::callback_interface]` | `trait` | Emits a [`CallbackInterfaceDef`]. Every trait method is a consumer-implemented callback taking `&self`. | +| `#[weaveffi::error]` | `enum` with explicit discriminants | Emits the module's error domain. Every variant needs an explicit `= N` discriminant; the first doc line is the code's message. Named-field variants emit the code's payload `fields:` (and require a primitive repr such as `#[repr(i32)]`). | +| `#[weaveffi::enumeration]` + `#[repr(i32)]` | `enum` | Emits an [`EnumDef`]. Every variant must have an explicit `= N` discriminant. | +| `#[weaveffi::cancellable]` | exported `async fn` | Sets `cancellable: true` and strips the trailing `weaveffi::CancelToken` parameter from the IDL signature. | +| `#[deprecated(note = "...")]` | exported `fn`, interface, record, enum, callback interface | Populates `deprecated` with the note. Bare `#[deprecated]` sets `deprecated = "deprecated"`; a `since = "..."` is parsed but has no IDL field (schema 0.9.0 dropped `since`). | [`Function`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.Function.html [`StructDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.StructDef.html [`InterfaceDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.InterfaceDef.html +[`CallbackInterfaceDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.CallbackInterfaceDef.html [`EnumDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.EnumDef.html -[`CallbackDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.CallbackDef.html -[`ListenerDef`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.ListenerDef.html -Doc comments (`///`) on items, fields, and enum variants become the `doc` -field in the IR. +Doc comments (`///`) on items, fields, methods, and enum variants become the +`doc` field in the IR. + +The module-level `#[weaveffi::callback]` and `#[weaveffi::listener]` markers +from schema 0.8 are gone. A consumer-implemented hook is now a +`#[weaveffi::callback_interface]` trait passed as `Arc`, and a +`handle` is now an interface object passed as `Arc` or `&T`. > **Macro versus extraction.** Both the CLI extractor and the -> `#[weaveffi::module]` proc-macro understand the full annotation surface above, -> including interfaces, error domains, async, callbacks, listeners, iterators, -> rich enums, and maps. A hand-authored IDL can additionally carry -> metadata that source can't yet express (package and per-generator -> configuration, struct field defaults, and standalone `since` tags), which is -> why the advanced samples keep a committed YAML IDL for generation. See -> [Feature support](producer-macro.md#feature-support) for the macro's current -> matrix. +> `#[weaveffi::module]` proc-macro understand the full annotation surface +> above, including interfaces, callback interfaces, error domains, async, +> iterators, rich enums, maps, and objects inside records and collections. +> A hand-authored IDL can additionally carry metadata that source can't +> express (record field defaults and a code `doc:` separate from its +> `message:`), which is why some samples keep a committed YAML IDL for +> generation. See +> [Feature support](producer-macro.md#feature-support) for the macro's +> current matrix. ### Type mapping -| Rust type | WeaveFFI TypeRef | IDL string | -|----------------------|--------------------------|------------------| -| `i8` | `I8` | `i8` | -| `i16` | `I16` | `i16` | -| `i32` | `I32` | `i32` | -| `i64` | `I64` | `i64` | -| `u8` | `U8` | `u8` | -| `u16` | `U16` | `u16` | -| `u32` | `U32` | `u32` | -| `f32` | `F32` | `f32` | -| `f64` | `F64` | `f64` | -| `bool` | `Bool` | `bool` | -| `String` | `StringUtf8` | `string` | -| `Vec` | `Bytes` | `bytes` | -| `u64` | `U64` | `u64` | -| `weaveffi::Handle` | `Handle` | `handle` | -| `&str` | `BorrowedStr` | `&str` | -| `&[u8]` | `BorrowedBytes` | `&[u8]` | -| `*mut T` / `*const T`| `TypedHandle("T")` | `handle` | -| `Vec` | `List(T)` | `[T]` | -| `Option` | `Optional(T)` | `T?` | -| `weaveffi::Iter` | `Iterator(T)` | `iter` | -| `HashMap` | `Map(K, V)` | `{K:V}` | -| `BTreeMap` | `Map(K, V)` | `{K:V}` | -| `&T` (other) | inner type | `T` | -| `&mut T` (other) | inner type, `mutable` | `T` | -| Any other identifier | `Struct(name)` | `name` | - -Compositions work recursively: `Option>` becomes `[i32]?` and -`Vec>` becomes `[string?]`. - -`&mut T` parameters are reduced to `T` and the surrounding [`Param`] record -gets `mutable: true`. `&T` for any non-`str`/`[u8]` type is also reduced to -`T` with `mutable: false`. - -[`Param`]: https://weaveffi.com/api/rust/weaveffi_ir/struct.Param.html +| Rust type | WeaveFFI TypeRef | IDL string | +|-----------|------------------|------------| +| `i8`, `i16`, `i32`, `i64` | `I8`, `I16`, `I32`, `I64` | `i8`, `i16`, `i32`, `i64` | +| `u8`, `u16`, `u32`, `u64` | `U8`, `U16`, `U32`, `U64` | `u8`, `u16`, `u32`, `u64` | +| `f32`, `f64` | `F32`, `F64` | `f32`, `f64` | +| `bool` | `Bool` | `bool` | +| `String`, `&str` | `StringUtf8` | `string` | +| `Vec`, `&[u8]` | `Bytes` | `bytes` | +| `Vec`, `&[T]` | `List(T)` | `[T]` | +| `Option` | `Optional(T)` | `T?` | +| `HashMap`, `BTreeMap` | `Map(K, V)` | `{K:V}` | +| `weaveffi::Iter` | `Iterator(T)` | `iter` | +| `Arc`, `&T` (`T` a `#[weaveffi::interface]`) | `Named("T")` | `T` | +| `Arc` (`Trait` a `#[weaveffi::callback_interface]`) | `Named("Trait")` | `Trait` | +| `weaveffi::CancelToken` | (removed from the signature) | | +| Any other identifier | `Named(name)` | `name` | + +Compositions work recursively: `Option>` becomes `[i32]?`, +`Vec>` becomes `[string?]`, `Vec>` becomes +`[Gadget]`, and `weaveffi::Iter>` becomes `iter`. + +A reference is a producer-side calling convention, not an IDL distinction: +`&str` and `String` both extract as `string`, `&T` and `Arc` both extract +as the interface `T`. The macro uses the spelling to decide whether the thunk +lends a borrow or takes a new strong reference; the IDL sees the same type +either way. `Arc` in an interface's return position names the interface +itself. A `Named` reference is resolved by the validator to a record, enum, +interface, or callback interface declared somewhere in the API; a name nothing +declares is a validation error (downgradable with `--warn`). + +### What is rejected + +The extractor refuses type syntax that has no ABI representation. These are +`syn` errors with a span, so `weaveffi extract` prints the offending line; the +macro reports the same message as a compile error (each is pinned by a +`trybuild` test under `crates/weaveffi-macros/tests/ui/`): + +| Rust syntax | Diagnostic | +|-------------|------------| +| `*const T`, `*mut T` | `` raw pointers cannot cross the FFI boundary; declare the pointee as a #[weaveffi::interface] and pass it as `&T` or `Arc` `` | +| `Box`, `Box`, `Rc` | `` `Box` cannot cross the FFI boundary; objects and callback interfaces are shared, so spell them as `Arc` / `Arc` `` | +| `&mut T` parameter | `` `&mut` parameters cannot cross the FFI boundary; take the value by `&T` or by value and return the updated result `` | +| `&mut self` or by-value `self` receiver | `` interface methods must take `&self` or `self: Arc`; use interior mutability (Mutex, RwLock, atomics) for mutable state, because the object is shared across the FFI boundary `` | +| `Arc` naming two traits | `` a trait object must name exactly one #[weaveffi::callback_interface] trait (plus optional `Send`/`Sync` bounds) `` | +| tuples, `impl Trait`, function pointers, arrays | `unsupported type syntax` | +| `weaveffi::Handle` | no longer exists; extracts as a `Named("Handle")` reference and fails validation with `unknown type reference: Handle` (the macro fails to compile it) | + +Interface objects must be spelled `Arc` or `&T` and callback interfaces +`Arc` because both are shared across the boundary: the producer +and the consumer each hold references, so a uniquely owned `Box` or a +single-threaded `Rc` cannot describe them. ### Round-trip integrity The `roundtrip_kitchen_sink` integration test in `crates/weaveffi-cli/tests/extract_roundtrip.rs` proves that the -hand-annotated form of the kitchen-sink IDL round-trips through `weaveffi -extract` and matches the original IR for every supported feature: modules, -nested modules, structs, enums, interfaces, error -domains, per-function `throws`, callbacks, listeners, every primitive type, -borrowed types, typed handles, optional/list/map composites, async, -cancellable, and deprecated/since. +hand-annotated form of the kitchen-sink IDL +(`crates/weaveffi-cli/tests/fixtures/kitchen_sink_annotated.rs`) round-trips +through `weaveffi extract` and matches the original IR for every supported +feature: modules, nested modules, records (including object and optional +object fields), enums, interfaces with constructors, methods, and statics, +callback interfaces, error domains, per-function `throws`, every primitive +type, borrowed spellings, optional/list/map composites, objects inside lists +and iterators, `Interface?` in both directions, async, cancellable, and +deprecated. + +The gaps the test deliberately tolerates are listed at the top of that +fixture and reproduced in [Pitfalls](#pitfalls). ## Pitfalls -The extractor parses syntax, not semantics. The items below cannot be inferred -from Rust source alone and either must be added to the generated IDL by hand or -are documented as round-trip gaps. +The extractor parses syntax, not semantics. The items below cannot be +inferred from Rust source alone and either must be added to the generated IDL +by hand or are documented as round-trip gaps. -- **Package and per-generator configuration.** These aren't part of the - API definition at all; they live in the `weaveffi.toml` beside the crate - and apply equally to extracted and hand-written IDLs. -- **Struct field default values.** The IDL's `default:` field cannot be - derived from Rust syntax (Rust struct fields have no default expressions). -- **Standalone `since:` without `#[deprecated]`.** `since` is only recovered - when paired with `#[deprecated(since = "...")]`. To set `since` on a - non-deprecated function, edit the YAML manually. - **An error code's `doc:` separate from its `message:`.** In Rust the first doc line on a `#[weaveffi::error]` variant is the code's message; the IDL - can carry both, so a distinct `doc:` is dropped on round-trip. + can carry both, so a distinct `doc:` is dropped on round-trip. The + round-trip test compares codes by name, value, and message only. +- **Record field default values.** The IDL's `default:` field cannot be + derived from Rust syntax (Rust struct fields have no default expressions). - **Doc comments on parameters.** Rust accepts `///` on `fn` parameters but most formatters strip them; when present, the extractor preserves them, but plan for `Param.doc` to be lossy. +- **`#[deprecated(since = "...")]` versions.** Schema 0.9.0 has no `since` + field, so the version is dropped; only the `note` survives as + `deprecated:`. +- **Package and per-generator configuration.** These aren't part of the API + definition at all; they live in the `weaveffi.toml` beside the crate and + apply equally to extracted and hand-written IDLs. - **Generics, trait `impl` blocks, and macros.** The extractor never resolves generics or expands macros, and it only reads the inherent `impl` block of a `#[weaveffi::interface]` type. Items produced by proc-macros and declarative macros are invisible. +- **Non-`pub` `impl` items.** Only `pub fn`s in an interface's `impl` block + are exported; private helpers stay private. Free functions need the + `#[weaveffi::export]` marker regardless of visibility. - **External `mod foo;` declarations.** Only inline modules (`mod foo { ... }`) are processed; declarations that point to other files are skipped. - **Tuple and unit structs.** Only structs with named fields work with @@ -261,3 +332,7 @@ are documented as round-trip gaps. - **Enum discriminants are mandatory.** C-style enums need `#[repr(i32)]` with explicit `= N` discriminants, and rich (payload-carrying) enum variants must use named fields; tuple-style variants are rejected. +- **`Result` hides `E`.** The extractor records only `throws: true`; + the error domain it maps to is the module's (or an ancestor's) + `#[weaveffi::error]` enum. A `Result` still extracts as + `throws: true`, which the validator rejects if no domain is in scope. diff --git a/docs/src/guides/memory.md b/docs/src/guides/memory.md index 8f0e4fa9..c0ed618f 100644 --- a/docs/src/guides/memory.md +++ b/docs/src/guides/memory.md @@ -2,46 +2,49 @@ ## Overview -WeaveFFI exposes Rust functionality through a stable C ABI. Because -Rust and the consumer languages (C, Swift, Kotlin, Python, ...) have -different memory models, every allocation that crosses the boundary -follows strict ownership rules. - -**Golden rule:** whoever allocates owns it, and ownership must be -explicitly transferred back for deallocation. Rust allocates; the -consumer frees through the designated `weaveffi_free_*` functions or -the matching `_destroy` symbol. - -The full release contract, exactly which call a wrapper owes after -copying a returned value or a collection element, is stated once, in -`weaveffi_core::plan` (`ReturnFree` for returns, `ElemFree` for -array, map, and iterator elements). Every generated wrapper renders -that plan, and this guide describes the same rules in prose. +WeaveFFI exposes Rust functionality through a stable C ABI (revision 2). +Because Rust and the consumer languages (C, Swift, Kotlin, Python, ...) have +different memory models, every allocation that crosses the boundary follows +strict ownership rules. + +**Golden rule:** whoever allocates owns it, and ownership must be explicitly +transferred back for deallocation. Rust allocates; the consumer frees through +the designated `weaveffi_free_*` functions, releases object references with +the type's `_destroy` symbol, and hands callback-interface implementations +over with a `free` entry the producer calls when it's done. + +The full release contract, exactly which call a wrapper owes after copying a +returned value or a collection element, is stated once, in +`weaveffi_core::plan` (`ReturnFree` for returns, `ElemFree` for array, map, +and iterator elements). Every generated wrapper renders that plan, and this +guide describes the same rules in prose. The normative statement of the ABI is +[C ABI Contract](../reference/abi.md). ## When to use Read this guide when: -- You are writing a consumer in C/C++ where the compiler will not free +- You are writing a consumer in C or C++ where the compiler won't free anything for you. -- You are debugging a leak, double-free, or use-after-free in a - generated binding. -- You are extending a generator and need to verify the ownership - contract for a new type. -- You are reviewing PRs that add new IDL types that involve - heap-allocated data. - -For higher-level languages (Swift, Kotlin, Python, .NET, Dart, Ruby, -Go) the generated wrappers handle most of this automatically; the rules -below explain what those wrappers are doing under the hood. +- You are writing a Rust producer and want to know what the + `#[weaveffi::module]` thunks do with the `Arc`s and `Arc`s you + hand them. +- You are debugging a leak, double-free, or use-after-free in a generated + binding. +- You are extending a generator and need to verify the ownership contract for + a new type. + +For higher-level languages (Swift, Kotlin, Python, .NET, Dart, Ruby, Go) the +generated wrappers handle most of this automatically; the rules below explain +what those wrappers are doing under the hood. ## Step-by-step ### Strings -Rust returns NUL-terminated, UTF-8, heap-allocated C strings created -via `CString::into_raw`. The consumer must free them with -`weaveffi_free_string`. +Rust returns NUL-terminated, UTF-8, heap-allocated C strings. The consumer +must free them with `weaveffi_free_string`. String parameters are borrowed +`const char*` views the caller keeps alive for the call. ```c weaveffi_error err = {0}; @@ -56,7 +59,7 @@ printf("result: %s\n", echoed); weaveffi_free_string(echoed); ``` -Generated wrappers do the same with `defer`: +Generated wrappers do the same with `defer`, `finally`, or RAII: ```swift let raw = weaveffi_calculator_echo(...) @@ -66,9 +69,9 @@ return String(cString: raw!) ### Byte buffers -Byte buffers are returned as `const uint8_t*` plus an `out_len`. Free -them with `weaveffi_free_bytes(ptr, len)`; the length must match what -the C ABI returned. +Byte buffers are returned as `const uint8_t*` plus an `out_len`. Free them +with `weaveffi_free_bytes(ptr, len)`; the length must match what the C ABI +returned. `bytes` parameters are borrowed `(ptr, len)` pairs. ```c size_t out_len = 0; @@ -84,17 +87,17 @@ weaveffi_free_bytes((uint8_t*)buf, out_len); ### Buffered values: records, rich enums, optionals, lists, and maps -Records, rich enums, optionals (except `Interface?`), lists, and maps -cross the boundary by value as one serialized -[value buffer](../reference/value-buffers.md) each, no matter how deeply -they nest, so their ownership rules are exactly the byte-buffer rules: - -- **As a parameter**, the caller owns the encoding, keeps it alive for - the duration of the call, and frees (or reuses) it afterward; the - callee never frees it. -- **As a return**, the producer allocates the encoding and hands it back - as a `const uint8_t*` return value plus a `size_t* out_len` - out-parameter. The consumer decodes it, then releases it with a single +Records, rich enums, optionals (except `Interface?`), lists, and maps cross +the boundary by value as one serialized +[value buffer](../reference/value-buffers.md) each, no matter how deeply they +nest, so their ownership rules are exactly the byte-buffer rules: + +- **As a parameter**, the caller owns the encoding, keeps it alive for the + duration of the call, and frees (or reuses) it afterward; the callee never + frees it. +- **As a return**, the producer allocates the encoding and hands it back as a + `const uint8_t*` return value plus a `size_t* out_len` out-parameter. The + consumer decodes it, then releases it with a single `weaveffi_free_bytes(ptr, len)`. There are no per-element frees: the strings, nested records, and inner collections all live inside the one buffer. @@ -112,64 +115,39 @@ weaveffi_free_bytes((uint8_t*)buf, out_len); ``` Generated wrappers hide the encoding entirely: they pack and unpack the -buffer into an idiomatic value type (a data class, struct, or sealed -class hierarchy) and free the returned buffer for you. +buffer into an idiomatic value type (a data class, struct, or sealed class +hierarchy) and free the returned buffer for you. -### Iterator elements +One exception to "the buffer is just bytes": a buffer that contains objects +also carries references. See [Objects inside value buffers](#objects-inside-value-buffers). -An `iter` return hands the consumer an opaque iterator handle, not a -buffer, so there is nothing to free on launch. Ownership flows per -step: - -- Each `_next` call writes an element the consumer now owns. After - copying or decoding it, free it per its type: `weaveffi_free_string` - for strings, `weaveffi_free_bytes` for bytes and for buffered - elements (records, rich enums, composites), nothing for by-value - elements. -- The handle is released with the iterator's own `_destroy` symbol, - exactly once: eagerly on exhaustion, and from the wrapper's disposal - idiom (RAII destructor, finalizer, `close()`, generator cleanup) when - iteration is abandoned early. - -Generated wrappers do both for you; they surface `iter` as the -target's native lazy iteration idiom and pull one element per consumer -step. See the [IDL reference](../reference/idl.md#iterator-types). +### Interface objects -### Sync versus async returns +Interface objects are **reference counted by the producer**. On the Rust side +every `#[weaveffi::interface]` value is an `Arc`; at the ABI a `{tag}*` is +one strong reference to it. Each interface exports two lifecycle symbols: -Everything above describes **synchronous** returns: the consumer -receives an owned value and owes the matching release call after -copying it. - -**Async results follow the same owned-value rule.** The buffers passed -to an async completion callback (strings, bytes, and the serialized -value buffers of buffered results) are owned by the consumer: copy or -decode them, then release them with `weaveffi_free_string` or -`weaveffi_free_bytes`. A non-null error is heap-boxed and released -with `weaveffi_error_free` after its code, message, and payload are -copied. Owned interface results (including `Interface?`) transfer -ownership too: the callback adopts the pointer and eventually calls -`_destroy`, exactly as a synchronous interface return would. See -[Result ownership and threading](async.md#result-ownership-and-threading). +```c +weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self); +void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); +``` -### Interface lifecycle +`_clone` returns a new strong reference to the same object (the pointer value +is identical) and `_destroy` releases one. The object is dropped when the last +reference goes, wherever that reference lived: a consumer wrapper, a record +field, a list element, an in-flight async call, or the producer's own state. +Both accept null as a no-op. -Interface objects are opaque on the consumer side. The lifecycle is: +The rule for every position an object can appear in: -1. A declared constructor such as `*_open` allocates and returns a - pointer; the consumer owns it. -2. `*_destroy` frees the object. Call exactly once. -3. Methods take the receiver as their leading argument and borrow it - for the duration of the call. +| Position | Direction | Rule | +|----------|-----------|------| +| top-level parameter (`Store`, `Store?`) | consumer to producer | **borrowed** for the call; the consumer keeps its reference and the producer clones if it retains the object | +| return, async result, iterator element | producer to consumer | **one strong reference transfers**; the consumer adopts it and owes one `_destroy` | +| callback-interface method parameter | producer to consumer | **one strong reference transfers** (the slot is `{tag}*`, not `const`); the consumer adopts it | +| inside a value buffer (any direction) | either | the `u64` token **carries one strong reference**; the reader adopts it | -Functions that take an interface or `handle` parameter always -**borrow** it: the producer must never free a receiver it is passed, -even for `close`-style functions. The only function that frees an -object is its `*_destroy` symbol. Generated wrappers call `*_destroy` -automatically (Swift `deinit`, Python `__del__`, Ruby -`FFI::AutoPointer`, ...), so a producer that frees a receiver inside -an ordinary function causes a double-free as soon as the wrapper is -garbage collected. +A consumer-side sequence in C, using the `kvstore` sample: ```c weaveffi_error err = {0}; @@ -180,26 +158,149 @@ if (err.code) { return 1; } -int64_t n = weaveffi_kv_Store_count(store, &err); +/* `share` returns the receiver with one more reference: same pointer, + two references to release. */ +weaveffi_kv_Store* again = weaveffi_kv_Store_share(store, &err); +assert(again == store); + +int64_t n = weaveffi_kv_Store_count(store, &err); /* borrows */ -weaveffi_kv_Store_destroy(store); +weaveffi_kv_Store_destroy(store); /* releases one */ +n = weaveffi_kv_Store_count(again, &err); /* still alive */ +weaveffi_kv_Store_destroy(again); /* last one; dropped */ ``` -The generated Swift wrapper invokes `_destroy` from `deinit`: +A method never frees its receiver, and neither does any other function that +takes an object parameter, even a `close`-style one: the only symbol that +releases a reference is `_destroy`. Because the count lives in the producer, +a consumer may hold several wrappers for one object, and each wrapper releases +its own reference independently. + +Every generated wrapper owns exactly one reference and releases it +deterministically, with a garbage-collection backstop where the language has +one: + +| Target | Deterministic release | Backstop | +|--------|-----------------------|----------| +| Swift | `deinit` on the `final class` | (ARC is deterministic) | +| Kotlin | `close()` (`AutoCloseable`) | `java.lang.ref.Cleaner` | +| Python | `close()`, `with` block | `__del__` | +| Node.js | `close()`, `Symbol.dispose` | `FinalizationRegistry` | +| .NET | `Dispose()` (`IDisposable`) | finalizer | +| Dart | `close()` | `NativeFinalizer` | +| Go | `Close()` | `runtime.SetFinalizer` | +| Ruby | `close` | `FFI::AutoPointer` | +| C++ | destructor (RAII) | none needed | + +Using a wrapper after `close()` raises the target's programming-error idiom +(for example a Python `WeaveFFIError` "used after close()" or a C# +`ObjectDisposedException`); it never reaches the producer. + +The generated Swift wrapper, for reference: ```swift -public class Store { +public final class Store { let ptr: OpaquePointer init(ptr: OpaquePointer) { self.ptr = ptr } deinit { weaveffi_kv_Store_destroy(ptr) } } ``` +### Objects inside value buffers + +When an interface appears inside a buffered type (a record field, a list +element, a map value, an optional inside a record), the encoder writes a +`u64` **object token**: the object's pointer, carrying exactly one strong +reference. Whoever decodes the buffer adopts that reference. + +- A **consumer encoding** a buffer that contains an object it holds must call + `_clone` and write the returned pointer, never the pointer its wrapper still + owns. The generated bindings do this (`_clone_ptr()` in Python, + `weaveffi_kv_Store_clone(ptr)` in Swift, and so on). +- A **consumer decoding** a buffer wraps each token in a new object wrapper + that will `_destroy` it. Decoding a returned `[Store]` of three elements + therefore yields three wrappers, each owing one release, and then one + `weaveffi_free_bytes` for the buffer itself. +- A **producer** writes `Arc::into_raw(arc.clone())` and reads with + `Arc::from_raw`; the macro's generated `BufferValue` implementation does + both for you. +- Because each token is one reference, a buffer that contains objects is + **decoded exactly once**. Decode it twice and the second reader adopts + references that no longer exist. Generated bindings encode a fresh buffer per + call and never reuse one. + +The `kvstore` sample exercises every shape: `describe` returns a `StoreInfo` +record whose `store` field is the receiver and whose `mirror` is `Store?`, +`open_many` returns `[Store]`, and `total_count([Store], StoreInfo?)` takes +objects inside buffers as parameters. + +### Callback interfaces + +A callback interface is memory the **consumer** owns and the **producer** +borrows for as long as it likes. A callback-interface parameter lowers to two +slots, `void* ctx` and `const {tag}_vtable* vtable`: + +- The consumer allocates `ctx` (typically a boxed object or a key into a + handle map that keeps the implementing object alive) and points `vtable` at + a process-wide static with one function pointer per method plus the trailing + `free`. +- The producer may call any entry any number of times, from any thread, + until it calls `free(ctx)` **exactly once**, after which it never touches + `ctx` again. On the Rust side the pair lives inside an `Arc`, so + the producer clones it freely and `free` fires when the last clone drops + (the `events` sample's `clear_subscribers` test asserts one `free` per + subscriber). +- Strings, bytes, and buffered method arguments are borrowed for the duration + of the call; the consumer copies or decodes them before returning. Object + arguments transfer one strong reference the consumer adopts. Method returns + are limited to `void`, scalars, `bool`, and C-style enums, so nothing the + consumer allocates crosses back. +- A consumer implementation that fails reports through the method's + `out_err` with `weaveffi_error_set(out_err, -4, message)`; the runtime copies + the borrowed message with the producer's allocator, so the consumer must not + write `message` itself. See [Error Handling](errors.md#foreign-errors). + +### Iterator elements + +An `iter` return hands the consumer an opaque iterator handle, not a +buffer, so there is nothing to free on launch. Ownership flows per step: + +- Each `_next` call writes an element the consumer now owns. After copying or + decoding it, release it per its type: `weaveffi_free_string` for strings, + `weaveffi_free_bytes` for bytes and for buffered elements (records, rich + enums, composites), `_destroy` for object elements (`iter` yields one + strong reference per step), nothing for by-value elements. +- The handle is released with the iterator's own `_destroy` symbol, exactly + once: eagerly on exhaustion, and from the wrapper's disposal idiom (RAII + destructor, finalizer, `close()`, generator cleanup) when iteration is + abandoned early. + +Generated wrappers do both for you; they surface `iter` as the target's +native lazy iteration idiom and pull one element per consumer step. See the +[IDL reference](../reference/idl.md#iterator-types). + +### Sync versus async returns + +Everything above describes **synchronous** returns: the consumer receives an +owned value and owes the matching release call after copying it. + +**Async results follow the same owned-value rule.** The buffers passed to an +async completion callback (strings, bytes, and the serialized value buffers of +buffered results) are owned by the consumer: copy or decode them, then release +them with `weaveffi_free_string` or `weaveffi_free_bytes`. A non-null error is +heap-boxed and released with `weaveffi_error_free` after its code, message, +and payload are copied. Object results (including `Interface?`) transfer one +strong reference the callback adopts, exactly as a synchronous object return +would. An in-flight async method holds its own reference to its receiver, so +destroying the consumer's wrapper while the call is pending is safe. See +[Result ownership and threading](async.md#result-ownership-and-threading). + ### Error struct lifecycle -Every C ABI function takes a trailing `weaveffi_error* out_err`. On -failure Rust writes a non-zero `code` and a Rust-allocated `message`. -Clearing the error frees the message: +Every synchronous C ABI function takes a trailing `weaveffi_error* out_err`. +On failure Rust writes a non-zero `code`, a Rust-allocated `message`, and +(for codes that declare payload fields) a Rust-allocated payload buffer. +Clearing the error frees both: ```c weaveffi_error err = {0}; @@ -213,26 +314,31 @@ if (err.code) { result = weaveffi_calculator_add(1, 2, &err); ``` -Generated wrappers clear the slot for you. On a `throws: true` -function they convert non-zero codes into the module's typed domain -error (`throw`, `raise`, `(T, error)`); on a non-throwing function a -non-zero code only ever reports a producer bug, so the wrapper panics -or traps instead. See the [Error Handling Guide](errors.md). +Generated wrappers clear the slot for you. On a `throws: true` function they +convert non-zero codes into the module's typed domain error (`throw`, `raise`, +`(T, error)`); on a non-throwing function a non-zero code only ever reports a +producer bug, so the wrapper panics or traps instead. See the +[Error Handling Guide](errors.md). -`weaveffi_error_clear` is idempotent: it frees the message and nulls -the pointer, so clearing an already-cleared slot is safe. Async +`weaveffi_error_clear` is idempotent: it frees the message and payload and +nulls the pointers, so clearing an already-cleared slot is safe. Async completion callbacks use a different symbol: the error they receive is heap-boxed and consumer-owned, so it is released exactly once with -`weaveffi_error_free`, which clears the slot and then frees the box -itself. +`weaveffi_error_free`, which clears the slot and then frees the box itself. +`weaveffi_error_set` is the one runtime symbol a consumer calls to *fill* a +slot, from inside a callback-interface implementation; it copies the borrowed +message so the producer can free it with its own allocator. ### Thread safety -Generated FFI functions are expected to be called from a **single -thread** unless the module's documentation says otherwise. Concurrent -calls from multiple threads can cause data races and undefined -behaviour. Synchronise externally, for example with a mutex or a -serial dispatch queue: +Every `#[weaveffi::interface]` type is `Send + Sync` (the macro asserts it), +so a producer built with the macro is safe to call from any thread, and the +generated wrappers place no thread restriction of their own. Callback-interface +methods likewise may be invoked from any producer thread, including the thread +an async future runs on, so consumer implementations must be thread-safe or +must hop to their own scheduler. A hand-written producer that keeps +non-thread-safe state should say so in its documentation; consumers of such a +library synchronise externally: ```swift let queue = DispatchQueue(label: "com.app.weaveffi") @@ -241,50 +347,119 @@ queue.sync { } ``` +## Producer-side rules + +If you write the producer with `#[weaveffi::module]`, the thunks apply the +rules above for you. What you see in safe Rust: + +- An object parameter typed `&T` is the borrowed reference; typed `Arc` + or `Option>`, the thunk has already cloned it, so you may store it. +- An object you return (`Self`, `Arc`, `Option>`, `Vec>`, + a record with an `Arc` field) transfers one reference per object. A + method that returns `self` on a `self: Arc` receiver hands back the + same pointer with a fresh reference. +- An `Arc` parameter is the consumer's callback; clone and store it + as needed. Its `free` runs when your last clone drops. +- **A callback-interface method may unwind.** A consumer failure surfaces to + the producer as a panic-like unwind that the enclosing thunk catches and + reports as `FOREIGN_ERROR_CODE`. If you hold a `std::sync::Mutex` guard + across the call, the mutex is poisoned when the unwind passes through, and + your next `lock().unwrap()` panics (which the consumer then sees as a + producer panic, `-2`). Either recover from poisoning with + `unwrap_or_else(PoisonError::into_inner)` or, better, snapshot the state you + need under the lock, release the lock, and only then call out. This is the + discipline `samples/events/src/lib.rs` follows in `EventBus::publish`: + +```rust +// Snapshot so no lock is held while the consumer runs. +let subs: Vec> = self + .subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); +let mut delivered = 0; +for sub in &subs { + match sub.route(message.topic.clone()) { + Delivery::Skip => {} + Delivery::Accept => { + sub.on_message(&message); + delivered += 1; + } + Delivery::AcceptAndStop => { + sub.on_message(&message); + delivered += 1; + break; + } + } +} +``` + +- Never free anything a thunk lends you (`&str`, `&[u8]`, `&T`); the thunk + owns the lifetime of borrowed arguments. + ## Reference -| Resource | Allocator | Free function | Notes | -|--------------------|-----------|----------------------------|--------------------------------------| -| Returned string | Rust | `weaveffi_free_string` | Every `const char*` return | -| Returned bytes | Rust | `weaveffi_free_bytes` | Pass both pointer and length | +| Resource | Allocator | Free function | Notes | +|----------|-----------|---------------|-------| +| Returned string | Rust | `weaveffi_free_string` | Every `const char*` return | +| Returned bytes | Rust | `weaveffi_free_bytes` | Pass both pointer and length | | Returned buffered value (record, rich enum, optional, list, map) | Rust | `weaveffi_free_bytes` | One serialized buffer; decode, then free once with `out_len` | -| Buffered parameter | Caller | none owed to Rust | Borrowed for the call; the caller keeps ownership | -| Interface instance | Rust | `*_destroy` | Call exactly once; methods borrow | -| Iterator handle | Rust | the iterator's `_destroy` | Exactly once: on exhaustion or abandonment | -| Iterator element | Rust | `weaveffi_free_string` / `weaveffi_free_bytes` / nothing | Each `_next` yields a consumer-owned element | -| Async result buffer | Rust | `weaveffi_free_string` / `weaveffi_free_bytes` | Consumer-owned: copy or decode, then free | -| Async interface result | Rust | `*_destroy` | Callback adopts ownership | +| Buffered parameter | Caller | none owed to Rust | Borrowed for the call; the caller keeps ownership | +| Object reference (return, async result, iterator element) | Rust | the type's `_destroy` | One strong reference per pointer; `_clone` to take another | +| Object parameter | Consumer | none owed | Borrowed for the call; the producer clones if it retains | +| Object token inside a value buffer | Rust | the type's `_destroy` on the adopted pointer | One reference per token; decode the buffer exactly once | +| Object passed to a callback method | Rust | the type's `_destroy` | The consumer adopts one reference | +| Callback-interface `ctx` | Consumer | the vtable's `free(ctx)`, called by the producer | Exactly once, when the producer's last clone drops | +| Iterator handle | Rust | the iterator's `_destroy` | Exactly once: on exhaustion or abandonment | +| Iterator element | Rust | `weaveffi_free_string` / `weaveffi_free_bytes` / `_destroy` / nothing | Each `_next` yields a consumer-owned element | +| Async result buffer | Rust | `weaveffi_free_string` / `weaveffi_free_bytes` | Consumer-owned: copy or decode, then free | +| Async object result | Rust | the type's `_destroy` | Callback adopts one reference | | Sync error slot (message and payload) | Rust | `weaveffi_error_clear` | Clears code, frees message and payload; idempotent | -| Async boxed error | Rust | `weaveffi_error_free` | Frees message, payload, and the box; exactly once | +| Async boxed error | Rust | `weaveffi_error_free` | Frees message, payload, and the box; exactly once | +| Cancel token | Rust | `weaveffi_cancel_token_destroy` | Keep alive until the completion callback fires | ## Pitfalls -- **Use-after-free**: reading a string after freeing it, or accessing - a struct after `_destroy`. Once the consumer frees something, the - pointer is invalid. -- **Double-free**: freeing the same pointer twice (e.g. calling - `weaveffi_free_string` twice or invoking `_destroy` after the wrapper - has already done so). -- **Wrong length to `weaveffi_free_bytes`**: always free with the - exact length the C ABI returned in `out_len`. -- **Forgetting to clear error structs**: `err.message` and - `err.payload_ptr` are Rust-allocated; failing to call - `weaveffi_error_clear` after a non-zero code leaks them. -- **Calling FFI from multiple threads without synchronisation**: the - default contract is single-threaded; synchronise externally if you - need parallelism. -- **Manually freeing pointers passed in as borrowed parameters**: - borrowed inputs (`&str`, `&[u8]`, `const T*`) are owned by the - caller and must not be passed to `weaveffi_free_*`. +- **Use-after-free**: reading a string after freeing it, or calling a method + through a pointer whose last reference was destroyed. Once the consumer + releases something, the pointer is invalid. +- **Double-free**: freeing the same pointer twice (calling + `weaveffi_free_string` twice, or `_destroy` on a reference the wrapper + already released). Note that `_destroy` on two *different* references to + the same object (one from `open`, one from `share` or `_clone`) is correct, + not a double-free. +- **Forgetting to clone before encoding**: a consumer that writes a wrapper's + own pointer into a value buffer as an object token hands away a reference it + doesn't have; the wrapper's later `_destroy` then over-releases. Always + `_clone` first. +- **Decoding an object-carrying buffer twice**: each token is one reference; + the second decode adopts references that don't exist. +- **Freeing the receiver inside a producer function**: only `_destroy` + releases a reference. A producer that frees a borrowed receiver causes a + double-free as soon as the consumer's wrapper releases its own reference. +- **Holding a lock across a callback-interface call**: the call may unwind + with a foreign error and poison the mutex. Snapshot, release, then call. +- **Wrong length to `weaveffi_free_bytes`**: always free with the exact length + the C ABI returned in `out_len`. +- **Forgetting to clear error structs**: `err.message` and `err.payload_ptr` + are Rust-allocated; failing to call `weaveffi_error_clear` after a non-zero + code leaks them. +- **Writing `out_err.message` yourself in a callback implementation**: the + producer frees that pointer with Rust's allocator. Call `weaveffi_error_set` + instead. +- **Manually freeing pointers passed in as borrowed parameters**: borrowed + inputs (`const char*`, `(ptr, len)` pairs, `const T*`) are owned by the + caller and must not be passed to `weaveffi_free_*` or `_destroy`. - **Freeing pieces of a returned buffer**: a returned `[string]` or - `[Contact]` is one serialized buffer; the strings and records you - decode out of it are copies. Free the buffer once with - `weaveffi_free_bytes(ptr, out_len)` and nothing else. -- **Leaking an async result buffer**: buffers passed to a completion - callback are consumer-owned. Copy or decode the data, then free the - buffer with the matching runtime symbol; a callback that only copies - leaks the producer's allocation. -- **Destroying an iterator handle twice**: destroy it once, on - exhaustion or when abandoning iteration early. Generated wrappers - null the handle so their disposal idiom cannot double-destroy; - hand-written C consumers must do the same. + `[Contact]` is one serialized buffer; the strings and records you decode + out of it are copies. Free the buffer once with + `weaveffi_free_bytes(ptr, out_len)` and nothing else (objects inside it are + the exception: each adopted token owes one `_destroy`). +- **Leaking an async result buffer**: buffers passed to a completion callback + are consumer-owned. Copy or decode the data, then free the buffer with the + matching runtime symbol; a callback that only copies leaks the producer's + allocation. +- **Destroying an iterator handle twice**: destroy it once, on exhaustion or + when abandoning iteration early. Generated wrappers null the handle so their + disposal idiom can't double-destroy; hand-written C consumers must do the + same. diff --git a/docs/src/guides/packaging.md b/docs/src/guides/packaging.md index 73fa85b4..97b3d0a2 100644 --- a/docs/src/guides/packaging.md +++ b/docs/src/guides/packaging.md @@ -2,12 +2,13 @@ ## Overview -`weaveffi generate` emits binding *source*: the consumer still has to compile it -or point it at a native library. `weaveffi package` goes one step further and -assembles ready-to-publish packages that bundle a prebuilt native library for -each target platform, laid out the idiomatic way each ecosystem expects. The -goal is that `dotnet add package`, `pip install`, `gem install`, `npm install`, -and friends "just work" with no local toolchain on a supported platform. +`weaveffi generate` emits binding *source*: the consumer still has to compile +it or point it at a native library. `weaveffi package` goes one step further +and assembles ready-to-publish packages that bundle a prebuilt native library +for each target platform, laid out the idiomatic way each ecosystem expects. +The goal is that `dotnet add package`, `pip install`, `gem install`, +`npm install`, and friends "just work" with no local toolchain on a supported +platform. ```bash weaveffi package api.yml --binaries ./prebuilt --target dotnet,python,ruby -o dist @@ -15,16 +16,17 @@ weaveffi package api.yml --binaries ./prebuilt --target dotnet,python,ruby -o di ## Choosing where the native libraries come from -A package can only bundle libraries you have already built. `weaveffi package` -gets them one of two ways: +A package can only bundle libraries you have already built. +`weaveffi package` gets them one of two ways: - `--binaries `: a directory of prebuilt libraries laid out as - `//`. This is the path CI uses, building each platform - on its own runner and collecting the results. -- `--build `: cross-compile the given Cargo package as a `cdylib` for each - platform's Rust target triple. Convenient locally, but every target needs its - rustup target and a working cross-linker installed - (`rustup target add aarch64-unknown-linux-gnu`, and so on). + `//`. This is the path CI uses, building each + platform on its own runner and collecting the results. +- `--build `: cross-compile the given Cargo package as a `cdylib` for + each platform's Rust target triple. Convenient locally, but every target + needs its rustup target and a working cross-linker installed + (`rustup target add aarch64-unknown-linux-gnu`, and so on; the Android + targets additionally need the NDK's linker on `PATH`). The two are mutually exclusive. A `--build` run fails with the missing `rustup target add` command when a platform's Rust target isn't installed. @@ -32,7 +34,7 @@ The two are mutually exclusive. A `--build` run fails with the missing ### The `--binaries` layout Each platform gets a subdirectory named for its platform id, holding that -platform's shared library: +platform's shared library (or, for `wasm32`, the `.wasm` module): ```text prebuilt/ @@ -41,46 +43,78 @@ prebuilt/ linux-x64/libcontacts.so linux-arm64/libcontacts.so windows-x64/contacts.dll + android-arm64/libcontacts.so + android-x64/libcontacts.so + wasm32/contacts.wasm ``` -A platform with no subdirectory is skipped with a warning, so a partial matrix -still produces artifacts for what is available. When a platform directory holds -more than one library, name the one to bundle after the resolved package -identity (for example `libcontacts.dylib`) to disambiguate. +A platform with no subdirectory is skipped with a warning, so a partial +matrix still produces artifacts for what is available. When a platform +directory holds more than one library, name the one to bundle after the +resolved package identity (for example `libcontacts.dylib`) to disambiguate. + +## The platform matrix + +Every platform WeaveFFI can bundle, with the identifier each ecosystem uses +for it. A blank cell means that ecosystem has no slot for the platform, and a +packaging backend **skips** a binary it has no slot for (a wheel has no wasm +tag; a NuGet package has no Android RID) rather than failing. + +| Platform id | OS / arch | Rust target | NuGet RID | Node `os`/`cpu` | Python tag | Ruby platform | Android ABI | +|-------------|-----------|-------------|-----------|-----------------|------------|---------------|-------------| +| `darwin-arm64` | macOS arm64 | `aarch64-apple-darwin` | `osx-arm64` | `darwin`/`arm64` | `macosx_11_0_arm64` | `arm64-darwin` | | +| `darwin-x64` | macOS x64 | `x86_64-apple-darwin` | `osx-x64` | `darwin`/`x64` | `macosx_10_12_x86_64` | `x86_64-darwin` | | +| `linux-x64` | Linux x64 glibc | `x86_64-unknown-linux-gnu` | `linux-x64` | `linux`/`x64` | `manylinux2014_x86_64` | `x86_64-linux` | | +| `linux-arm64` | Linux arm64 glibc | `aarch64-unknown-linux-gnu` | `linux-arm64` | `linux`/`arm64` | `manylinux2014_aarch64` | `aarch64-linux` | | +| `windows-x64` | Windows x64 | `x86_64-pc-windows-msvc` | `win-x64` | `win32`/`x64` | `win_amd64` | `x64-mingw-ucrt` | | +| `android-arm64` | Android arm64 | `aarch64-linux-android` | | | | | `arm64-v8a` | +| `android-x64` | Android x64 (emulator) | `x86_64-linux-android` | | | | | `x86_64` | +| `wasm32` | WebAssembly | `wasm32-unknown-unknown` | | | | | | + +The first five rows are the **desktop** matrix; every dynamic-library +ecosystem (NuGet, npm, wheels, gems, C/C++, Go, Swift, Dart, desktop JVM) +bundles those. The Android rows are consumed only by the `kotlin` target, and +`wasm32` only by the `wasm` target. + +Restrict the build with `--platforms` (a comma-separated list of platform +ids); the default is the full matrix. Restrict the languages with `--target` +exactly as in `weaveffi generate`. The command prints one line per target it +packaged and a `note:` listing any target that produced nothing, either +because it has no binary packaging at all or because no binary was found for a +platform it ships: -## The v1 platform matrix - -| Platform id | OS / arch | Rust target | NuGet RID | Node `os`/`cpu` | Python tag | Ruby platform | -| -------------- | --------------- | ---------------------------- | ----------- | --------------- | ---------------------- | --------------- | -| `darwin-arm64` | macOS arm64 | `aarch64-apple-darwin` | `osx-arm64` | `darwin`/`arm64`| `macosx_11_0_arm64` | `arm64-darwin` | -| `darwin-x64` | macOS x64 | `x86_64-apple-darwin` | `osx-x64` | `darwin`/`x64` | `macosx_10_12_x86_64` | `x86_64-darwin` | -| `linux-x64` | Linux x64 glibc | `x86_64-unknown-linux-gnu` | `linux-x64` | `linux`/`x64` | `manylinux2014_x86_64` | `x86_64-linux` | -| `linux-arm64` | Linux arm64 glibc | `aarch64-unknown-linux-gnu`| `linux-arm64`| `linux`/`arm64`| `manylinux2014_aarch64`| `aarch64-linux` | -| `windows-x64` | Windows x64 | `x86_64-pc-windows-msvc` | `win-x64` | `win32`/`x64` | `win_amd64` | `x64-mingw-ucrt`| +```text +Packaging 'contacts' for platforms: darwin-arm64, linux-x64, wasm32 + dotnet: 3 file(s), 2 bundled binary(ies) + wasm: 4 file(s), 1 bundled binary(ies) +Packaged 2 target(s) into dist +``` -Restrict the build with `--platforms` (a comma-separated list of platform ids); -the default is the full matrix. Restrict the languages with `--target` exactly -as in `weaveffi generate`. +The `wasm` target is the one that can come up empty on a desktop-only matrix: +with no `wasm32/` binary it is skipped with a note, and in Emscripten mode it +ships glue only and never bundles a binary. Every other target packages as +long as at least one of its platforms is present. ## Per-ecosystem layout -Each target lays the bundled libraries out where its ecosystem resolves native -code automatically. +Each target lays the bundled libraries out where its ecosystem resolves +native code automatically. ### .NET (`dotnet`) -A single NuGet-ready project with libraries under `runtimes//native/`, the -layout NuGet selects at restore time. The `[DllImport]` library name is rebound -from the WeaveFFI brand to the bundled library's base name, and the `.csproj` -packs the `runtimes/` tree. Just `dotnet add package`. +A single NuGet-ready project with libraries under `runtimes//native/`, +the layout NuGet selects at restore time. The `[DllImport]` library name is +rebound from the WeaveFFI brand to the bundled library's base name, and the +`.csproj` packs the `runtimes/` tree. Just `dotnet add package`. ### Python (`python`) One wheel-ready tree per platform under `python//`, with the library -bundled inside the import package. The loader prefers the bundled library, so no -`WEAVEFFI_LIBRARY` or system install is needed. The generated `setup.py` forces a -non-pure (platform-tagged) wheel; build it with `python -m build --wheel` and tag -it for the target platform before publishing. +bundled inside the import package. The loader prefers the bundled library, so +no `WEAVEFFI_LIBRARY` or system install is needed. The generated `setup.py` +forces a non-pure (platform-tagged) wheel; build it with +`python -m build --wheel` and tag it for the target platform before +publishing. ### Ruby (`ruby`) @@ -90,44 +124,111 @@ prefers the bundled library. ### Node.js (`node`) -The idiomatic `optionalDependencies` layout: a main package that depends on one -per-platform package per target (each gated by npm `os`/`cpu`, so only the +The idiomatic `optionalDependencies` layout: a main package that depends on +one per-platform package per target (each gated by npm `os`/`cpu`, so only the matching one installs) under `node/npm/--/`, each bundling its -prebuilt library. Because the Node binding is an N-API addon, the thin addon is -still compiled at install (`node-gyp rebuild`) and links the prebuilt library -from the selected platform package, so no Rust toolchain is needed; a C compiler -and the generated C header (package the `c` target alongside) are. +prebuilt library. Because the Node binding is an N-API addon, the thin addon +is still compiled at install (`node-gyp rebuild`) and links the prebuilt +library from the selected platform package, so no Rust toolchain is needed; a +C compiler and the generated C header (package the `c` target alongside) are. + +### WebAssembly (`wasm`) + +An npm package under `wasm/` containing the ES-module loader +(`.js`), its TypeScript declarations (`.d.ts`), a +README, and the `wasm32` binary copied in as `.wasm`. The +`package.json` lists exactly those files so `npm publish` ships nothing else. +Consumers call the loader's `load…` function with the bundled `.wasm` URL (or +a `BufferSource` or compiled `WebAssembly.Module`) and get the API object back +as a `Promise`. + +Build the binary with `cargo build --release --target wasm32-unknown-unknown` +(the producer crate needs `crate-type = ["cdylib"]`) and place it under +`prebuilt/wasm32/`. In Emscripten mode (`[generators.wasm] emscripten = +true`) the package is glue only: the consumer links the module into their own +Emscripten build, so no binary is bundled and the `wasm32/` directory is not +consulted. + +### Kotlin (`kotlin`) + +A Gradle Android library module under `kotlin/`: `settings.gradle.kts`, +`build.gradle.kts` (`com.android.library` plus the Kotlin Android plugin, +`minSdk = 33` because the wrappers' disposal backstop is +`java.lang.ref.Cleaner`), the Kotlin wrapper under +`src/main/kotlin//WeaveFFI.kt`, and the JNI shim under +`src/main/cpp/` with its own `CMakeLists.txt` and a bundled copy of the C +header under `src/main/cpp/include/`. + +The binaries land in two places depending on the platform: + +- **Android** (`android-arm64`, `android-x64`) libraries are copied to + `src/main/jniLibs//` (`arm64-v8a`, `x86_64`), which the packaged + `build.gradle.kts` registers as a JNI source set. The CMake script links the + shim against the bundled library for the ABI being built, so an app that + depends on the module gets both `.so`s in its APK with no further setup. +- **Desktop JVM** libraries (the five desktop platforms) are copied to + `src/main/resources/natives//`. At runtime the Kotlin loader + picks `natives/-/` from `os.name` and `os.arch`, extracts the + producer library and the shim from the classpath into a temporary + directory, and `System.load`s them, falling back to + `System.loadLibrary("_jni")` on the `java.library.path` when the + resources are absent. When building the shim for a desktop host, pass + `-DWEAVEFFI_PLATFORM_ID=` to CMake so it links the right bundled + library; the generated README has the exact command. + +The `.wasm` binary is ignored by this target. One caveat: the generated JNI +glue always defines `JNI_OnLoad` (it caches the `JavaVM*`, checks the ABI +revision, and resolves the classes callback dispatch and async exception +routing need). Each generated module must therefore link into its own shared +library; compiling the glue for two modules into one `.so` would collide on +the duplicate `JNI_OnLoad` symbol. ### Swift (`swift`) -A SwiftPM package that consumes its C ABI through a `binaryTarget` xcframework. -The prebuilt libraries are bundled under `lib//`; assembling them into -the xcframework is the one step that needs Apple tooling (`lipo` plus -`xcodebuild -create-xcframework`, run on macOS). The generated `README.md` -includes the exact recipe. +A SwiftPM package that consumes its C ABI through a `binaryTarget` +xcframework. The prebuilt desktop libraries are bundled under +`lib//`; assembling them into the xcframework is the one step that +needs Apple tooling (`lipo` plus `xcodebuild -create-xcframework`, run on +macOS). The generated `README.md` includes the exact recipe. ### C and C++ (`c`, `cpp`) -The header (`include/`) plus a prebuilt library for every platform under -`lib//`, with a `CMakeLists.txt` that selects the right library for the -host and exposes it as an imported target. `add_subdirectory` and link. +The header (`include/`) plus a prebuilt library under `lib//`, with +a `CMakeLists.txt` that selects the right library for the host and exposes it +as an imported target. `add_subdirectory` and link. The `c` package copies +every binary present (Android and `wasm32` included, since a C consumer may be +an NDK or Emscripten build); the `cpp` package bundles the desktop platforms +only, which are the ones its packaged `CMakeLists.txt` can select among. ### Go (`go`) -A Go module that bundles a library per platform under `lib//`. The cgo -preamble adds the matching `${SRCDIR}`-relative library search path and rpath per -GOOS/GOARCH, so `go build` links the right library with no manual `CGO_LDFLAGS`. -The C ABI header is expected at `../c/include/`, so package the `c` target -alongside Go (`weaveffi package --target c,go`). +A Go module that bundles a library per desktop platform under +`lib//`. The cgo preamble adds the matching `${SRCDIR}`-relative +library search path and rpath per GOOS/GOARCH, so `go build` links the right +library with no manual `CGO_LDFLAGS`. The C ABI header is expected at +`../c/include/`, so package the `c` target alongside Go +(`weaveffi package --target c,go`). + +### Dart (`dart`) + +A pub package under `dart/` with the desktop libraries under +`native//`. The packaged `dart:ffi` loader tries the bundled +library for the host OS first, then the bare system name; `WEAVEFFI_LIBRARY` +still overrides. Android libraries are not bundled here: on Flutter they ship +through the app's `jniLibs`, and a `.wasm` module can't be opened with +`DynamicLibrary`. ## Continuous integration recipe -In CI the cleanest approach is to build each platform's library on a runner of -that platform (native builds avoid cross-linker setup), collect the results into -the `--binaries` layout, then run `weaveffi package` once. The matrix below -builds a Cargo producer crate (`my-producer`, declaring -`crate-type = ["cdylib"]`) and uploads each library under its platform id, then a -final job assembles the packages. +In CI the cleanest approach is to build each platform's library on a runner +of that platform (native builds avoid cross-linker setup), collect the +results into the `--binaries` layout, then run `weaveffi package` once. The +matrix below builds a Cargo producer crate (`my-producer`, declaring +`crate-type = ["cdylib"]`) and uploads each library under its platform id, +then a final job assembles the packages. The Android and wasm rows +cross-compile from Linux: the wasm target needs only `rustup target add`, and +the Android targets need the NDK that GitHub's Ubuntu runners ship, exposed +to Cargo through `CARGO_TARGET__LINKER`. ```yaml name: package @@ -140,17 +241,28 @@ jobs: strategy: matrix: include: - - { platform: darwin-arm64, runner: macos-14, target: aarch64-apple-darwin, lib: libmy_producer.dylib } - - { platform: darwin-x64, runner: macos-13, target: x86_64-apple-darwin, lib: libmy_producer.dylib } - - { platform: linux-x64, runner: ubuntu-latest, target: x86_64-unknown-linux-gnu, lib: libmy_producer.so } - - { platform: linux-arm64, runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, lib: libmy_producer.so } - - { platform: windows-x64, runner: windows-latest, target: x86_64-pc-windows-msvc, lib: my_producer.dll } + - { platform: darwin-arm64, runner: macos-14, target: aarch64-apple-darwin, lib: libmy_producer.dylib } + - { platform: darwin-x64, runner: macos-13, target: x86_64-apple-darwin, lib: libmy_producer.dylib } + - { platform: linux-x64, runner: ubuntu-latest, target: x86_64-unknown-linux-gnu, lib: libmy_producer.so } + - { platform: linux-arm64, runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, lib: libmy_producer.so } + - { platform: windows-x64, runner: windows-latest, target: x86_64-pc-windows-msvc, lib: my_producer.dll } + - { platform: android-arm64, runner: ubuntu-latest, target: aarch64-linux-android, lib: libmy_producer.so } + - { platform: android-x64, runner: ubuntu-latest, target: x86_64-linux-android, lib: libmy_producer.so } + - { platform: wasm32, runner: ubuntu-latest, target: wasm32-unknown-unknown, lib: my_producer.wasm } runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - name: Point Cargo at the NDK linker + if: startsWith(matrix.platform, 'android-') + run: | + bin="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + case "${{ matrix.target }}" in + aarch64-linux-android) echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$bin/aarch64-linux-android33-clang" >> "$GITHUB_ENV" ;; + x86_64-linux-android) echo "CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=$bin/x86_64-linux-android33-clang" >> "$GITHUB_ENV" ;; + esac - run: cargo build --release -p my-producer --target ${{ matrix.target }} - run: | mkdir -p "prebuilt/${{ matrix.platform }}" @@ -172,24 +284,12 @@ jobs: path: prebuilt merge-multiple: true - run: cargo install weaveffi-cli - - run: weaveffi package api.yml --binaries prebuilt --target dotnet,python,node,ruby -o dist + - run: weaveffi package api.yml --binaries prebuilt --target dotnet,python,node,ruby,kotlin,wasm -o dist # ... then publish each package with `dotnet nuget push`, `npm publish`, etc. ``` A platform you can't build (no runner, missing target) can simply be dropped -from the matrix; `weaveffi package` warns and produces artifacts for whatever is -present. - -## Targets that do not bundle binaries yet - -`wasm` and `android` are skipped (with a note) because they need a different -artifact model than the native matrix: a WebAssembly module is a single portable -binary, and Android ships per-ABI libraries (`arm64-v8a`, and so on) that map to -Android-specific targets rather than the desktop/server platforms above. Use -`weaveffi generate` for their source bindings. - -One Android packaging caveat: the generated JNI glue defines a `JNI_OnLoad` -whenever the module declares async functions or listeners (it caches the -class references the exception-handler hook needs). Each generated module -must therefore link into its own shared library; compiling the glue for two -modules into one `.so` would collide on the duplicate `JNI_OnLoad` symbol. +from the matrix; `weaveffi package` warns and produces artifacts for whatever +is present. Each target then bundles the subset of platforms it has a slot +for, so a matrix without the Android rows still yields a `kotlin` package +(desktop JVM only) and one without `wasm32` yields everything except `wasm`. diff --git a/docs/src/guides/producer-macro.md b/docs/src/guides/producer-macro.md index 569a9a26..69084af4 100644 --- a/docs/src/guides/producer-macro.md +++ b/docs/src/guides/producer-macro.md @@ -3,19 +3,18 @@ If your producer is written in Rust, the most ergonomic workflow is to write a normal, safe Rust library, annotate it with the `#[weaveffi::module]` family of attributes, and let the `weaveffi` crate generate the `#[no_mangle] extern "C"` -thunks that back the -stable C ABI. The same annotated source is what `weaveffi generate src/lib.rs` -reads to emit the IDL, the C header, and every language binding, so the -producer you compile and the bindings you ship cannot drift: they are two -views of one parse. +thunks that back the stable C ABI. The same annotated source is what +`weaveffi generate src/lib.rs` reads to emit the IDL, the C header, and every +language binding, so the producer you compile and the bindings you ship can't +drift: they are two views of one parse. -This is the "Rust as the source of truth" model. You never hand-write -`unsafe` FFI glue, and there is no separate IDL file to keep in sync. +This is the "Rust as the source of truth" model. You never hand-write `unsafe` +FFI glue, and there is no separate IDL file to keep in sync. ## Setup -Add the single `weaveffi` facade crate and build a `cdylib` (plus an `rlib` -if you also want to unit-test the safe functions in-crate): +Add the single `weaveffi` facade crate and build a `cdylib` (plus an `rlib` if +you also want to unit-test the safe functions in-crate): ```toml [package] @@ -27,7 +26,7 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -weaveffi = "0.14" +weaveffi = "0.22" ``` ## A complete example @@ -87,26 +86,27 @@ weaveffi generate src/lib.rs -o generated --target c,swift,python | Attribute | Where it goes | Effect | |-----------|---------------|--------| | `#[weaveffi::module]` | inline `mod foo { ... }` | Marks an exported namespace and drives the codegen. Modules may nest. | -| `#[weaveffi::export]` | `fn` | Exports a function. A `Result` return is `throws: true`; `()` (and `Result<(), E>`) is a `void` return. | +| `#[weaveffi::export]` | `fn` or `async fn` | Exports a function. A `Result` return is `throws: true`; `()` (and `Result<(), E>`) is a `void` return; `async fn` is `async: true`. | | `#[weaveffi::record]` | named-field `struct` | A by-value record. Generates a `BufferValue` implementation (encode and decode in the value-buffer format); no per-record C symbols. | -| `#[weaveffi::interface]` | `struct` with an `impl` block | An opaque object type. The `impl` block's `pub fn`s become constructors (those returning `Self`), methods (`&self`), and statics; a `destroy` symbol is implicit. | +| `#[weaveffi::interface]` | `struct` with an inherent `impl` block | A reference-counted object type (see [Interfaces](#interfaces)). The `impl` block's `pub fn`s become constructors, methods, and statics; `_clone` and `_destroy` symbols are implicit. | +| `#[weaveffi::callback_interface]` | `trait Name: Send + Sync` | A set of methods the consumer implements and the producer calls (see [Callback interfaces](#callback-interfaces)). Producers accept one as `Arc`. | | `#[weaveffi::error]` | `enum` with explicit discriminants | Declares the module's error domain. Every variant needs an explicit `= N` discriminant; the doc comment is the code's default message. A variant may carry named fields, which become the code's structured payload (this requires a primitive repr such as `#[repr(i32)]`). | -| `#[weaveffi::enumeration]` | `#[repr(i32)]` `enum` | A C-style enum. Every variant needs an explicit `= N` discriminant. | -| `#[weaveffi::callback]` | `fn` | Declares a callback signature (see [roadmap](#feature-support)). | -| `#[weaveffi::listener(event = "Name")]` | `fn` | Declares an event listener bound to a callback (see [roadmap](#feature-support)). | -| `#[weaveffi::cancellable]` | `async fn` | Marks an async function as accepting a cancel token (see [roadmap](#feature-support)). | +| `#[weaveffi::enumeration]` | `#[repr(i32)]` `enum`, or an `enum` with named-field variants | A C-style enum (every variant needs an explicit `= N` discriminant) or a rich enum whose variants carry named fields and cross as a value buffer. | +| `#[weaveffi::cancellable]` | exported `async fn` | Marks the function as accepting a cancel token; its final parameter must be a `weaveffi::CancelToken`. | Only items carrying a marker are exported. Private helpers, `use` items, the module's in-memory state, and free functions without `#[weaveffi::export]` are left untouched, so a module can freely mix its exported surface with its implementation. Doc comments (`///`) on items, fields, and variants flow into -the generated IDL and every binding. +the generated IDL and every binding. `#[deprecated(note = "...")]` on an +exported function or method marks it `deprecated:` in the IDL. Call `weaveffi::export_runtime!()` exactly once in the crate (not per module). -It emits the fixed C ABI runtime symbols (`weaveffi_free_string`, -`weaveffi_free_bytes`, `weaveffi_error_clear`, `weaveffi_error_free`, -the cancel-token helpers, and the arena) that every binding links -against. +It emits the fixed C ABI runtime symbols (`weaveffi_abi_version`, +`weaveffi_error_set`, `weaveffi_error_clear`, `weaveffi_error_free`, +`weaveffi_free_string`, `weaveffi_free_bytes`, the cancel-token helpers, and +on `wasm32` the `weaveffi_alloc`/`weaveffi_dealloc` pair) that every binding +links against. See [C ABI Contract](../reference/abi.md#runtime-surface). ## How values cross the boundary @@ -118,18 +118,24 @@ You write ordinary Rust types; the macro picks the matching ABI shape: | Rust type | IDL type | C ABI shape | |-----------|----------|-------------| | `i8`..`i64`, `u8`..`u64`, `f32`, `f64`, `bool` | same | the scalar | -| `String`, `&str` | `string` | `const char*` | -| `Vec`, `&[u8]` | `bytes` | `const uint8_t* ptr, size_t len` | -| `weaveffi::Handle` | `handle` | `weaveffi_handle_t` | -| `*mut T`, `*const T` | `handle` | opaque `T*` | +| `String` (or `&str` as a parameter) | `string` | `const char*` | +| `Vec` (or `&[u8]` as a parameter) | `bytes` | `const uint8_t* ptr, size_t len` | | a `#[weaveffi::record]` struct | the record | serialized value buffer (`ptr` + `len`) | -| a `#[weaveffi::enumeration]` enum | the enum | `int`-sized discriminant | -| `Option` | `T?` | serialized value buffer (nullable pointer for `Option`) | -| `Vec` | `[T]` | serialized value buffer (`ptr` + `len`) | - -Every integer primitive (including `u64`) is the matching scalar. Spell an -opaque token as `weaveffi::Handle` (an alias of `u64`) to declare the IDL -`handle` type. See +| a `#[repr(i32)]` `#[weaveffi::enumeration]` enum | the enum | `int`-sized discriminant | +| a data-carrying `#[weaveffi::enumeration]` enum | the rich enum | serialized value buffer | +| `Option` | `T?` | serialized value buffer (nullable pointer for `Option>`) | +| `Vec` | `[T]` | serialized value buffer | +| `HashMap`, `BTreeMap` | `{K:V}` | serialized value buffer | +| `Arc` or `&T` where `T` is a `#[weaveffi::interface]` | the interface | `const {tag}*` parameter (borrowed); `{tag}*` return (one strong reference) | +| `Arc` where `Trait` is a `#[weaveffi::callback_interface]` | the callback interface | `void* ctx, const {tag}_vtable* vtable` (parameters only) | +| `weaveffi::Iter` (returns only) | `iter` | opaque iterator with `_next`/`_destroy` | +| `weaveffi::CancelToken` (final parameter of a `#[weaveffi::cancellable]` `async fn`) | not in the IDL | the launcher's `weaveffi_cancel_token*` slot | + +A reference such as `&str`, `&[u8]`, `&Contact`, or `&Shelf` is a +producer-side calling convention, not an IDL distinction: the thunk lifts the +argument and lends it to your function. Interfaces compose with every buffered +shape, so `Option>`, `Vec>`, `{string: Arc}`, a record field +of type `Arc`, and `weaveffi::Iter>` all work. See [Annotated Rust Extraction](extract.md#type-mapping) for the exhaustive table. ## Records @@ -147,56 +153,452 @@ routines. #[weaveffi::record] #[derive(Clone, Debug)] pub struct Contact { + /// Stable identifier. pub id: i64, + /// Given name. pub first_name: String, + /// Optional email address. pub email: Option, + /// Kind of contact. pub kind: ContactType, } ``` +A record field may hold an object (`Arc`, `Option>`, +`Vec>`). Inside the buffer the object is a `u64` token carrying +one strong reference; the macro's generated `encode` clones the `Arc` into the +token and `decode` adopts it, so a record that carries objects is always +encoded fresh and decoded exactly once. + ## Interfaces -A `#[weaveffi::interface]` struct is a first-class object type: the consumer -holds an opaque pointer to a live instance rather than a copied value. The -`impl` block defines the surface; the struct's own fields (its state) never -cross the boundary, so they need no annotations. +A `#[weaveffi::interface]` struct is a first-class, reference-counted object +type: the consumer holds a strong reference to a live `Arc` rather than a +copied value. The `impl` block defines the surface; the struct's own fields +(its state) never cross the boundary, so they need no annotations. + +Because the object is shared across the FFI boundary (several consumer +wrappers, records, collections, and in-flight async calls may all hold a +reference), the macro asserts that the type is `Send + Sync`, and the +receivers are restricted: + +- Constructors are `pub fn`s without a receiver that return `Self` or + `Arc` (spelled either way or by the type's own name), optionally + inside a `Result`. +- Methods take `&self` or `self: Arc`. Use interior mutability + (`Mutex`, `RwLock`, atomics) for mutable state; `&mut self` and `self` by + value are rejected. +- Statics are `pub fn`s without a receiver that return anything else. ```rust -#[weaveffi::interface] -pub struct ContactBook { - contacts: Mutex>, - next_id: AtomicI64, +#[weaveffi::module] +pub mod library { + use std::sync::{Arc, Mutex, PoisonError}; + + /// The library's error domain. + #[weaveffi::error] + #[derive(Debug)] + pub enum LibraryError { + /// no such shelf + NoSuchShelf = 1, + } + + /// A shelf snapshot that carries the shelf itself. + #[weaveffi::record] + #[derive(Clone)] + pub struct ShelfInfo { + /// A caller-chosen label. + pub label: String, + /// The described shelf (one strong reference). + pub shelf: Arc, + /// A neighbouring shelf, if any. + pub neighbour: Option>, + } + + /// A shelf of titles. Shared across the FFI boundary, so its state + /// lives behind a `Mutex`. + #[weaveffi::interface] + pub struct Shelf { + titles: Mutex>, + } + + impl Shelf { + /// Create an empty shelf. // constructor -> Self + pub fn new() -> Self { + Self { + titles: Mutex::new(Vec::new()), + } + } + + /// Open a named shelf. // fallible constructor -> Arc + pub fn open(name: String) -> Result, LibraryError> { + if name.is_empty() { + return Err(LibraryError::NoSuchShelf); + } + Ok(Arc::new(Self::new())) + } + + /// Add a title. // method on &self + pub fn add(&self, title: String) { + self.titles + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(title); + } + + /// Number of titles. + pub fn count(&self) -> i64 { + self.titles + .lock() + .unwrap_or_else(PoisonError::into_inner) + .len() as i64 + } + + /// A second reference to this same shelf. // method on self: Arc + pub fn share(self: Arc) -> Arc { + self + } + + /// Whichever shelf holds more titles. // Option> in and out + pub fn larger(self: Arc, other: Option>) -> Option> { + match other { + Some(o) if o.count() > self.count() => Some(o), + Some(_) => Some(self), + None => None, + } + } + + /// Snapshot into a record that carries the shelf. + pub fn describe(self: Arc, label: String) -> ShelfInfo { + ShelfInfo { + label, + shelf: self, + neighbour: None, + } + } + + /// Split into `n` empty shelves. // Vec> return + pub fn split(&self, n: i32) -> Vec> { + (0..n).map(|_| Arc::new(Shelf::new())).collect() + } + + /// Stream every title lazily. // iter + pub fn titles(&self) -> weaveffi::Iter { + let snapshot: Vec = self + .titles + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + weaveffi::Iter::new(snapshot) + } + + /// Copy the shelf on a producer thread. // async returning an object + pub async fn duplicate(self: Arc) -> Arc { + let copy = Shelf::new(); + for t in self.titles.lock().unwrap_or_else(PoisonError::into_inner).iter() { + copy.add(t.clone()); + } + Arc::new(copy) + } + + /// The largest shelf a library will hold. // static (no receiver) + pub fn capacity() -> i64 { + 10_000 + } + } + + /// Total titles across shelves and an optional snapshot. + #[weaveffi::export] + pub fn total(shelves: Vec>, extra: Option) -> i64 { + let base: i64 = shelves.iter().map(|s| s.count()).sum(); + base + extra.map_or(0, |info| info.shelf.count()) + } + + /// Stream shelves lazily. // iter + #[weaveffi::export] + pub fn stream_shelves(n: i32) -> weaveffi::Iter> { + weaveffi::Iter::new((0..n).map(|_| Arc::new(Shelf::new()))) + } } +``` + +The macro emits `weaveffi_library_Shelf_clone` and +`weaveffi_library_Shelf_destroy` alongside the constructors, methods, and +statics. `_clone` returns a new strong reference to the same object and +`_destroy` releases one; the `Shelf` is dropped when the last reference goes. +An object parameter (`&Shelf`, `Arc`, `Option>`) is +borrowed for the call: when your function takes `Arc` the thunk bumps +the count for you, so retaining it past the call is safe. An object return +transfers one strong reference to the consumer; a `self: Arc` method +that returns `self` hands back the same pointer the consumer passed in, now +with one more reference, which the consumer must eventually release too. + +Every binding wraps the reference in an idiomatic class (a Swift `final class` +with `deinit`, a Kotlin `AutoCloseable` backed by a `Cleaner`, a Python class +with `close()` and `__del__`, C# `IDisposable`, Go `Close()` with a +finalizer, Dart `NativeFinalizer`, Ruby `FFI::AutoPointer`, C++ RAII) that +releases its reference exactly once. See `samples/kvstore` and +`samples/events` for complete producers and +[Memory Ownership](memory.md#interface-objects) for the full contract. + +## Callback interfaces + +A `#[weaveffi::callback_interface]` trait is the inverse of an interface: the +**consumer** implements it and the **producer** calls it. Declare it as a +`Send + Sync` trait whose methods take `&self`, and accept it in exported +functions, constructors, statics, or methods as `Arc`. The macro +implements the trait on a foreign wrapper around the consumer's `(ctx, vtable)` +pair, so your code calls the methods like any other trait object and can clone +and retain the `Arc` for as long as it likes. When the last `Arc` drops the +consumer's `free(ctx)` entry runs exactly once. -impl ContactBook { - /// Create an empty book. // -> constructor - pub fn new() -> Self { /* ... */ } +```rust +#[weaveffi::module] +pub mod events { + use std::sync::{Arc, Mutex, PoisonError}; + + /// How a subscriber wants to be told about a message. + #[weaveffi::enumeration] + #[repr(i32)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Delivery { + /// Deliver the message. + Accept = 0, + /// Skip this subscriber for this message. + Skip = 1, + } - /// Add a contact to the book. // -> throwing method - pub fn add(&self, first_name: String, /* ... */) -> Result { /* ... */ } + /// A published message as subscribers see it. + #[weaveffi::record] + #[derive(Clone, Debug, PartialEq)] + pub struct Message { + /// Monotonic sequence number, starting at 1. + pub seq: i64, + /// Message text. + pub text: String, + } + + /// A consumer-implemented subscriber. + #[weaveffi::callback_interface] + pub trait Subscriber: Send + Sync { + /// Decide how the bus should treat `topic` for this subscriber. + fn route(&self, topic: String) -> Delivery; + /// Receive an accepted message. Returns the running count. + fn on_message(&self, message: &Message) -> i64; + /// Receive the bus itself; the consumer adopts the reference. + fn on_attached(&self, bus: Arc); + } + + /// A bus that retains its subscribers. + #[weaveffi::interface] + pub struct EventBus { + subscribers: Mutex>>, + seq: Mutex, + } - /// Number of contacts in the book. // -> method - pub fn count(&self) -> i32 { /* ... */ } + impl EventBus { + /// Create an empty bus. + pub fn new() -> Arc { + Arc::new(Self { + subscribers: Mutex::new(Vec::new()), + seq: Mutex::new(0), + }) + } + + /// Retain `subscriber` and tell it which bus it joined. + pub fn subscribe(self: Arc, subscriber: Arc) -> i64 { + subscriber.on_attached(Arc::clone(&self)); + let mut subs = self + .subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner); + subs.push(subscriber); + subs.len() as i64 + } + + /// Publish `text` under `topic`, returning how many subscribers + /// accepted it. A subscriber failure aborts the call. + pub fn publish(&self, topic: String, text: String) -> i64 { + let message = { + let mut seq = self.seq.lock().unwrap_or_else(PoisonError::into_inner); + *seq += 1; + Message { seq: *seq, text } + }; + // Snapshot so no lock is held while the consumer runs. + let subs: Vec> = self + .subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + let mut delivered = 0; + for sub in &subs { + if sub.route(topic.clone()) == Delivery::Accept { + sub.on_message(&message); + delivered += 1; + } + } + delivered + } + } + + /// Ask `subscriber` how it would route `topic` without a bus. + #[weaveffi::export] + pub fn route_once(subscriber: Arc, topic: String) -> Delivery { + subscriber.route(topic) + } } ``` -Every binding wraps the pointer in an idiomatic class (Swift `class` with -`deinit`, Kotlin `Closeable`, Python `__del__`, Go `Close()`, and so on) and -the implicit `destroy` symbol frees the instance exactly once. See -`samples/contacts` and `samples/inventory` for complete producers. +The allowed method shapes are deliberately narrow so that nothing the consumer +allocates has to cross back into the producer: + +- The receiver is `&self`. `&mut self`, `self`, and associated functions + without a receiver are rejected. +- Parameters may be any IDL type except another callback interface or an + iterator: scalars, strings, bytes, records, enums, optionals, lists, maps, + and objects (`Arc` or `Option>`). Strings, bytes, and buffered + values are borrowed by the consumer for the duration of the call; an object + parameter transfers one strong reference to the consumer, which adopts it. +- The return type is `()`, a scalar, `bool`, or a C-style enum. Strings, + bytes, records, and objects can't be returned. Methods can't be `async` and + can't return `Result`. +- A callback interface may only appear as a parameter of a function, + constructor, static, or method. It can't be returned, stored in a record, + wrapped in `Option`, put in a list, or passed to another callback method. + +The generated C header declares one vtable per trait, with an entry per method +plus the trailing `free`. This is the `Subscriber` vtable from the example +above: + +```c +typedef struct weaveffi_events_Subscriber_vtable { + /** Decide how the bus should treat `topic` for this subscriber. */ + weaveffi_events_Delivery (*route)(void* ctx, const char* topic, weaveffi_error* out_err); + /** + * Receive an accepted message. Returns the running count. + */ + int64_t (*on_message)(void* ctx, const uint8_t* message_ptr, size_t message_len, weaveffi_error* out_err); + /** Receive the bus itself; the consumer adopts the reference. */ + void (*on_attached)(void* ctx, weaveffi_events_EventBus* bus, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_events_Subscriber_vtable; +``` + +### When the consumer fails + +Every vtable entry carries a trailing `out_err`. A consumer implementation +that raises (a Python exception, a Swift `throw`, a Go panic, and so on) is +reported there by the generated binding with `FOREIGN_ERROR_CODE` (`-4`) and +the foreign error's text. Because callback methods never return `Result`, the +generated trait implementation can't hand you that error as a value. Instead it +aborts the producer call by unwinding: `weaveffi::abi::check_foreign_error` +raises a `ForeignError` payload with `std::panic::resume_unwind`, the unwind +travels through your frames to the nearest exported thunk, and the thunk's +`catch_unwind` reports `-4` with the consumer's message to the original caller +(the same channel a producer panic uses, with a different code). + +Treat every callback method call as potentially panicking, exactly as you'd +treat a call into an arbitrary closure: + +- Don't hold a `std::sync::Mutex` guard across the call unless you recover + from poisoning (`unwrap_or_else(PoisonError::into_inner)`, as the example + does). Otherwise the next lock attempt after a foreign failure panics too, + and that panic *is* reported as a producer bug (`-2`). +- Prefer the snapshot pattern: clone the list of subscribers (or the state you + need) under the lock, release the lock, then call out. +- Make state changes before the call or make them idempotent, because the + code after a failing callback doesn't run. +- Tolerate a callback method returning its type's zero value (`0`, `false`, + an empty string) after a failure. On a `panic = "abort"` build such as + `wasm32-unknown-unknown` there's no unwinding, so the runtime records the + failure in a thread-local slot instead, your code keeps running on the + zero return, and the thunk reports the recorded failure once you return. + +`samples/events/src/lib.rs` follows these rules and has a test +(`foreign_error_aborts_publish`) showing the bus remains usable after a +subscriber failure. + +## Errors + +Declare a domain as a `#[weaveffi::error]` enum whose discriminants are the +ABI codes (positive, unique) and whose doc comments are the default messages. +Return `Result` from anything that can fail; the macro generates +the `ErrorReport` implementation that writes the code, message, and payload +into `out_err`. A variant may carry named fields, which travel as a structured +payload and surface as properties on the typed error the consumer catches; +field-carrying variants with explicit discriminants require a primitive repr: + +```rust +#[weaveffi::error] +#[derive(Debug)] +#[repr(i32)] +pub enum QuotaError { + /// quota exceeded + Exceeded { limit: i64, used: i64 } = 3001, + /// quota service unavailable + Unavailable = 3002, +} +``` + +A module may declare at most one error domain, and it's in scope for the +module and every nested module. `Result` also compiles (it reports +the generic code `-1`), but only a `#[weaveffi::error]` enum gives consumers +named codes to match on. See [Error Handling](errors.md). + +## Iterators and cancel tokens + +Return `weaveffi::Iter` when the consumer should pull elements lazily +instead of receiving one materialized `[T]` buffer. Build it from any +`Send + 'static` iterator with `Iter::new`; the macro emits the +`{IterTag}*` launcher plus `_next` and `_destroy` symbols, and every binding +surfaces it as the language's native lazy iteration idiom. `T` may be any +IDL type including an object (`weaveffi::Iter>`). Iterators are +returns only; an `Iter` parameter isn't supported. + +Mark an exported `async fn` `#[weaveffi::cancellable]` and accept a +`weaveffi::CancelToken` as its final parameter. The token is part of the async +calling convention rather than the IDL signature: the launcher gains a +`weaveffi_cancel_token*` slot and the macro lifts it for you. Poll +`is_cancelled()` at safe points and return early: + +```rust +/// Sum bytes on a producer thread, stopping early when cancelled. +#[weaveffi::export] +#[weaveffi::cancellable] +pub async fn checksum(data: Vec, cancel: weaveffi::CancelToken) -> Result { + let mut total = 0i64; + for chunk in data.chunks(4096) { + if cancel.is_cancelled() { + return Err(ContactsError::InvalidName); + } + total += chunk.iter().map(|b| i64::from(*b)).sum::(); + } + Ok(total) +} +``` + +Async functions and methods run on the spawner installed with +`weaveffi::set_spawner` (a detached thread per future by default); see +[Async Functions](async.md). ## Cross-module references -Modules can reference each other's records and enums. Import the type with a -normal `use` and pass it by value or by reference: +Modules can reference each other's records, enums, interfaces, and callback +interfaces. Import the type with a normal `use` and pass it by value or by +reference: ```rust #[weaveffi::module] pub mod products { + /// A product in the catalog. #[weaveffi::record] #[derive(Clone)] - pub struct Product { pub id: i64, pub price: f64 } - // ... + pub struct Product { + /// Stable identifier. + pub id: i64, + /// Unit price. + pub price: f64, + } } #[weaveffi::module] @@ -206,7 +608,7 @@ pub mod orders { /// Takes a `products::Product` across the module boundary. #[weaveffi::export] pub fn add_product(order_id: u64, product: Product) -> bool { - // ... look up the order and append the product ... + let _ = (order_id, product); true } } @@ -216,7 +618,127 @@ Each module is expanded on its own, so the macro emits a thunk named for its own module while the CLI (which sees the whole crate) resolves the reference to `products.Product` in the IDL and header. Both spellings are the same serialized value buffer at the ABI level, so the producer and the generated -bindings agree. See `samples/inventory` for a complete two-module example. +bindings agree. See `samples/inventory` and the `kv.stats` submodule of +`samples/kvstore` for complete examples. + +## What the macro rejects + +When the macro can't express a producer it fails at compile time with a +spanned diagnostic rather than emitting glue that disagrees with the header. +Every rejection below is pinned by a `trybuild` test in +`crates/weaveffi-macros/tests/ui/`; the messages are quoted from those tests' +`.stderr` files. + +**Raw pointers.** There is no `handle` type in ABI 2; declare the pointee +as an interface. + +```text +error: weaveffi: raw pointers cannot cross the FFI boundary; declare the pointee as a #[weaveffi::interface] and pass it as `&T` or `Arc` + --> tests/ui/fail_raw_pointer.rs:6:22 + | +6 | pub fn open() -> *mut Token { + | ^ +``` + +**`Box` and `Rc`.** Objects are shared, so only `Arc` is accepted. + +```text +error: weaveffi: `Box` cannot cross the FFI boundary; objects and callback interfaces are shared, so spell them as `Arc` / `Arc` + --> tests/ui/fail_box_param.rs:7:20 + | +7 | pub fn take(w: Box) { + | ^^^ +``` + +**`&mut` parameters.** Nothing mutates a caller's value in place across the +boundary. + +```text +error: weaveffi: `&mut` parameters cannot cross the FFI boundary; take the value by `&T` or by value and return the updated result + --> tests/ui/fail_mut_ref_param.rs:4:23 + | +4 | pub fn fill(text: &mut String) { + | ^ +``` + +**Interface methods taking `self` by value or `&mut self`.** + +```text +error: weaveffi: interface methods must take `&self` or `self: Arc`; use interior mutability (Mutex, RwLock, atomics) for mutable state, because the object is shared across the FFI boundary + --> tests/ui/fail_interface_self_by_value.rs:7:24 + | +7 | pub fn consume(self) {} + | ^^^^ +``` + +**Interfaces that aren't `Send + Sync`.** The assertion is a plain trait +bound, so the compiler's own diagnostic names the offending field: + +```text +error[E0277]: `Cell` cannot be shared between threads safely + --> tests/ui/fail_interface_not_sync.rs:1:1 + | +1 | #[weaveffi::module] + | ^^^^^^^^^^^^^^^^^^^ `Cell` cannot be shared between threads safely + | + = help: within `Counter`, the trait `Sync` is not implemented for `Cell` + = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead +note: required because it appears within the type `Counter` + --> tests/ui/fail_interface_not_sync.rs:6:16 + | +6 | pub struct Counter { + | ^^^^^^^ +note: required by a bound in `__wv_assert_send_sync` +``` + +**Callback methods with the wrong receiver or a non-direct return.** + +```text +error: weaveffi: callback interface methods must take `&self` + --> tests/ui/fail_callback_mut_self.rs:5:21 + | +5 | fn on_event(&mut self, n: i32); + | ^ +``` + +```text +error: weaveffi: unsupported non-direct return type for `callback return` (not yet implemented by #[weaveffi::module]) + --> tests/ui/fail_callback_string_return.rs:1:1 + | +1 | #[weaveffi::module] + | ^^^^^^^^^^^^^^^^^^^ +``` + +**A `Result` with no error domain in scope.** + +```text +error: weaveffi: `risky` returns a Result but no error domain is in scope; declare a #[weaveffi::error] enum in this module (or a parent module) + --> tests/ui/fail_result_without_error_domain.rs:2:5 + | +2 | mod bad { + | ^^^ +``` + +**A C-style enum without `#[repr(i32)]`.** + +```text +error: enum `Mode` must have #[repr(i32)] to be a #[weaveffi::enumeration] + --> tests/ui/fail_enum_without_repr.rs:3:5 + | +3 | #[weaveffi::enumeration] + | ^ +``` + +Two removals from earlier releases have no dedicated diagnostic because the +spelling simply no longer exists: `weaveffi::Handle` (the untyped `handle` +type) is gone from the crate, so referring to it is an ordinary unresolved +type error, and the IDL's borrowed `&str`/`&[u8]` types are gone. Rust `&str` +and `&[u8]` parameters are still accepted; they are the borrowed spellings of +`string` and `bytes`. Also rejected, with a spanned message: tuple-style +rich-enum variants (use named fields), error variants without an explicit +discriminant, a second `#[weaveffi::error]` in one module, an `Arc` +naming more than one trait, and a callback interface not spelled +`Arc`. ## Feature support @@ -229,28 +751,23 @@ straight to a `weaveffi_*` cdylib with no hand-written `extern "C"` layer. |---------|---------------|------------------| | Modules, nested modules | Supported | `inventory`, `kvstore` | | Sync functions, `Result` errors | Supported | `calculator`, `contacts` | -| Error domains (`#[weaveffi::error]`) | Supported | `calculator`, `contacts` | -| Interfaces (constructors / methods / statics) | Supported | `contacts`, `inventory` | -| Records (value-buffer encode / decode) | Supported | `contacts` | +| Error domains (`#[weaveffi::error]`), structured payloads | Supported | `calculator`, `contacts`, `weaveffi` crate's runtime tests | +| Reference-counted interfaces (constructors, methods, statics, `_clone`/`_destroy`) | Supported | `contacts`, `kvstore`, `events` | +| Objects in optionals, lists, maps, record fields, iterators, and async returns | Supported | `kvstore` (`share`, `fork`, `larger`, `describe`, `open_many`, `total_count`), `codec` | +| Callback interfaces (`Arc`) | Supported | `events`, `kvstore` (`EvictionListener`) | +| Records (value-buffer encode / decode) | Supported | `contacts`, `codec` | | C-style enums | Supported | `contacts`, `shapes` | -| Scalars, `string`, `bytes`, handles, typed handles | Supported | `kvstore` | -| Optionals, lists, maps (nested composites included) | Supported | `inventory`, `kvstore` | -| Async (and cancellable) functions | Supported | `async-demo`, `kvstore` | -| Callbacks and event listeners | Supported | `events`, `kvstore` | -| Iterator returns | Supported | `events`, `kvstore` | | Rich (data-carrying) enums | Supported | `shapes` | -| Structured error payloads | Supported | `weaveffi` crate's runtime tests | - -A few narrow shapes are still rejected at compile time with a clear message -rather than emitting glue that disagrees with the header, notably iterator -parameters (as opposed to iterator returns) and tuple-style rich-enum variants -(use named fields instead). When the macro can't express a producer it fails -loudly, so it never drifts silently from the IDL. The generators deliver the -full feature set on the consumer side regardless; the samples in the right-hand -column are working references for each pattern. +| Scalars, `string`, `bytes` | Supported | `calculator`, `kvstore` | +| Optionals, lists, maps (nested composites included) | Supported | `inventory`, `kvstore`, `codec` | +| Async functions and methods, pluggable spawner | Supported | `async-demo`, `kvstore`, `events` | +| Cancellable async (`weaveffi::CancelToken`) | Supported | `kvstore` (`compact`) | +| Iterator returns | Supported | `events`, `kvstore` | ## See also - [Getting Started](../getting-started.md): the end-to-end IDL-first walkthrough; this guide is the Rust-macro alternative to its step 2. - [Annotated Rust Extraction](extract.md): the `weaveffi extract`/`generate ` CLI and the full attribute and type reference. -- [Memory Ownership](memory.md) and [Error Handling](errors.md): the ABI contracts the macro upholds for you. +- [C ABI Contract](../reference/abi.md): the normative description of the symbols the macro emits. +- [Memory Ownership](memory.md), [Error Handling](errors.md), and [Async Functions](async.md): the contracts the macro upholds for you. +- [Rust API](../api/rust.md): the public items of the `weaveffi` and `weaveffi-abi` crates. diff --git a/docs/src/intro.md b/docs/src/intro.md index 8bd8495a..fcb4c178 100644 --- a/docs/src/intro.md +++ b/docs/src/intro.md @@ -6,10 +6,10 @@ language: no hand-written JNI, no duplicate implementations, no unsafe boilerplate.** Define your API once as an IDL in YAML, JSON, or TOML, and WeaveFFI generates -idiomatic packages for C, C++, Swift, Kotlin/Android, Node.js, WebAssembly, -Python, .NET, Dart, Go, and Ruby, all talking to the same stable C ABI. Any -backend that implements the symbols declared in the generated C header can be -the producer. +idiomatic packages for C, C++, Swift, Kotlin, Node.js, WebAssembly, Python, +.NET, Dart, Go, and Ruby, all talking to the same stable C ABI. Any backend +that implements the symbols declared in the generated C header can be the +producer. Writing your producer in Rust? Annotate a normal module with `#[weaveffi::module]` and the macro generates the C ABI and derives the IDL for @@ -27,29 +27,42 @@ the producer you build and the bindings you ship cannot drift. so a Rust producer writes no `unsafe` glue and the IDL is derived from the code rather than maintained beside it. - **Stable C ABI underneath.** Every target speaks to the same `extern "C"` - contract, so adding a new platform later is a code-gen change, not a - rewrite. + contract ([ABI revision 2](reference/abi.md)), so adding a new platform + later is a code-gen change, not a rewrite. +- **Objects and callbacks that behave the same everywhere.** Interfaces are + reference-counted objects (`Arc` in Rust) that consumers can share, + nest inside records and collections, and release deterministically, with a + garbage-collector backstop where the language has one. Callback interfaces + are method sets the consumer implements as a protocol, interface, or + abstract class and the native library calls from any thread. - **Idiomatic per-target output.** No lowest-common-denominator surface area. Swift gets `async/await` and `throws`, Kotlin gets `suspend` and - JNI glue, Python gets typed `.pyi` stubs, TypeScript gets `Promise`s, - Dart gets `dart:ffi`, all from the same definition. + JNI glue, Python gets typed `.pyi` stubs, TypeScript gets `Promise`s and + `BigInt`, Dart gets `dart:ffi` with `NativeFinalizer`, all from the same + definition. Async producers keep their own executor: plug Tokio in with + `weaveffi::set_spawner`, or let the default thread-per-future spawner do + the work. ## Design principle: standalone generated packages Generated packages are fully self-contained and publishable to their -native ecosystem (npm, CocoaPods, Maven Central, PyPI, NuGet, pub.dev, -RubyGems, etc.) without requiring consumers to install WeaveFFI tooling +native ecosystem (npm, SwiftPM, Maven Central, PyPI, NuGet, pub.dev, +RubyGems, and so on) without requiring consumers to install WeaveFFI tooling or runtime dependencies. WeaveFFI is a build-time tool for library authors; consumers should never need to know it exists. Helper code -(error types, memory management utilities) is generated inline into each -package rather than pulled from a shared runtime dependency. +(error types, the value-buffer codec, object wrappers, callback trampolines) +is generated inline into each package rather than pulled from a shared +runtime dependency. ## Where to next - [Getting Started](getting-started.md): install, define an IDL, generate, and call from C. -- [The Rust Producer Macro](guides/producer-macro.md): the `#[weaveffi::module]` attribute family, the supported feature set, and the roadmap. -- [Comparison](comparison.md): feature matrix vs UniFFI, cbindgen, diplomat, SWIG, autocxx, and an honest "when to choose WeaveFFI" guide. -- [FAQ](faq.md): runtime cost, customization, Windows support, distribution, licensing. -- [Samples](samples.md): the kitchen-sink `kvstore` reference plus calculator/contacts/inventory walkthroughs. +- [The Rust Producer Macro](guides/producer-macro.md): the `#[weaveffi::module]` attribute family and the supported feature set. +- [C ABI Contract](reference/abi.md): the normative description of objects, callback interfaces, value buffers, async functions, and iterators at the boundary. +- [Comparison](comparison.md): feature matrix vs UniFFI, Diplomat, cbindgen, swift-bridge, napi-rs, wasm-bindgen, SWIG, autocxx, and an honest "when to choose WeaveFFI" guide. +- [FAQ](faq.md): object ownership, callback interfaces, the async spawner, runtime cost, customization, Windows support, distribution, licensing. +- [Samples](samples.md): the kitchen-sink `kvstore` reference, the `events` callback-interface bus, the `codec` round-trip oracle, and the smaller walkthroughs. +- [Stability and Versioning](stability.md): what schema `0.9.0` and ABI 2 changed and how to migrate. +- [Roadmap](roadmap.md): what's planned after ABI 2. - [Generators](generators/README.md): per-target reference for each of the eleven languages. -- [Guides](guides/README.md): memory ownership, error handling, async, configuration. +- [Guides](guides/README.md): memory ownership, error handling, async, configuration, packaging. diff --git a/docs/src/performance.md b/docs/src/performance.md index 12b5a638..9368884c 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -57,10 +57,13 @@ build (`--release`, `lto = false`), no other heavy processes running. ## Latest measurements -These numbers were captured on the most recent baseline run after the -hot-path optimizations described below. Each row is the criterion -median; the parentheses show the headroom relative to the documented -target. +These numbers were captured on the baseline run that followed the hot-path +optimizations described below, before the ABI revision 2 generators landed. +Each row is the criterion median; the last column shows the headroom +relative to the documented target. ABI 2 added `_clone` symbols, callback +vtables, and object-token codec paths to every generator's output, so expect +the absolute medians to move slightly on the next baseline; the targets are +unchanged and CI's `bench-results` artifact (below) has the current figures. | Benchmark | Median | Headroom vs target | | -------------------------------- | -------- | ------------------ | @@ -88,8 +91,9 @@ table above. `render_swift_wrapper` started from `String::new()` and let the buffer grow by doubling, copying the entire string on each re-allocation. They now estimate the final output size from the - number of modules, functions, structs, and callbacks in the API and - pre-allocate accordingly via `String::with_capacity`. + number of modules, functions, records, interfaces, and callback + interfaces in the API and pre-allocate accordingly via + `String::with_capacity`. 2. **`write!` instead of `push_str(&format!(...))`** in the per-function hot loop of `render_module_header` (C generator) and the function @@ -117,6 +121,28 @@ in the original performance plan, in order of impact. faster for our schemas. We accept it as the dominant CLI startup cost and document it here. +## Runtime cost of the ABI 2 object model + +The benchmarks above measure code generation. At run time, the constructs +ABI revision 2 introduced are deliberately cheap: + +- **Object references.** `{tag}_clone` is one atomic increment on the + producer's `Arc` and `{tag}_destroy` is one atomic decrement (plus the + drop when it reaches zero). Passing an object as a parameter is a single + pointer with no count traffic; the producer clones only if it retains the + object. +- **Objects inside value buffers.** An object token is eight bytes in the + buffer and one `_clone` at encode time; decoding adopts the reference with + no further call into the producer. +- **Callback interfaces.** A method call is one indirect call through a + static vtable plus the usual marshalling of its arguments. Registering an + implementation allocates one context per registration on the consumer side + and one `Arc` on the producer side; there is no per-call allocation for + scalar arguments. +- **Async spawner.** The default spawner costs one thread per in-flight + future. Producers with many concurrent async calls should install a pooled + runtime with `weaveffi::set_spawner`. + ## CI artifacts The [`bench.yml`][bench-workflow] workflow runs `cargo bench` on every diff --git a/docs/src/reference/README.md b/docs/src/reference/README.md index cf5aa074..46369624 100644 --- a/docs/src/reference/README.md +++ b/docs/src/reference/README.md @@ -1 +1,21 @@ # Reference + +The reference pages state the contracts the rest of the documentation +assumes. Two of them are normative: the [C ABI Contract](abi.md) and the +[Value Buffer Protocol](value-buffers.md) define what a producer exports and +what a consumer may rely on, and every generator, sample, and conformance +lane is checked against them. The others summarize those contracts from a +particular angle and point back to the normative text rather than +restating it. + +| Page | Read it when you need | +|------|-----------------------| +| [IDL Reference](idl.md) | The complete schema `0.9.0` grammar: every field, every type position, every `ValidationError` code with its message and suggestion, and validated examples of each construct | +| [C ABI Contract](abi.md) | The normative ABI revision 2: symbol shapes, ownership by position, object reference counting, callback vtables, error codes, cancellation, and the revision check | +| [Value Buffer Protocol](value-buffers.md) | The byte-level encoding of records, rich enums, optionals, lists, maps, object tokens, and structured error payloads | +| [Memory and Error Model](memory-error.md) | A working summary of who frees what: strings, bytes, buffers, `_clone`/`_destroy`, callback `free`, `weaveffi_error_set`/`_clear`/`_free`, and cancel tokens | +| [Naming and Package Conventions](naming.md) | How the project names itself and how the generators spell C symbols, wrapper types, error classes, and the Kotlin package | + +For the pipeline that turns an IDL into these outputs, see +[Architecture](../architecture.md); for per-language usage, see the +generator pages. diff --git a/docs/src/reference/abi.md b/docs/src/reference/abi.md new file mode 100644 index 00000000..7168e3b1 --- /dev/null +++ b/docs/src/reference/abi.md @@ -0,0 +1,247 @@ +# C ABI Contract + +This page is the normative description of the WeaveFFI C ABI, revision 2. It +is the contract the `#[weaveffi::module]` macro implements on the producer +side and that every language generator consumes. Where another page and this +one disagree, this one wins. + +The ABI revision is exported by every producer as `{prefix}_abi_version()` +and embedded in every generated consumer as `{PREFIX}_ABI_VERSION`. A +consumer that can do so cheaply compares the two at load time and refuses to +run against a producer built for another revision. + +## Vocabulary + +- **Producer**: the native library that implements the symbols (a Rust cdylib + built with the macro, or a C, C++, or Zig library implementing the header). +- **Consumer**: the generated bindings in one of the eleven target languages. +- **Prefix**: the configurable symbol prefix, `weaveffi` by default. Runtime + symbols (`weaveffi_error`, `weaveffi_free_string`, and so on) always keep + the `weaveffi_` spelling; a non-default prefix is aliased onto them in the + generated C header. +- **Slot**: one C parameter. An IDL parameter lowers to one or more slots. + +## Type families + +Every resolved IDL type belongs to exactly one family, which decides how it +crosses a call boundary. + +| Family | IDL types | Parameter slots | Return | +|------------|---------------------------------------------------------------|---------------------------------------------------------|-----------------------------------------------| +| Direct | `i8`..`u64`, `f32`, `f64`, `bool`, C-style enums | one slot by value | by value | +| String | `string` | `const char* {name}` (NUL-terminated, borrowed) | `const char*`, freed with `free_string` | +| Bytes | `bytes` | `const uint8_t* {name}_ptr, size_t {name}_len` (borrowed) | `const uint8_t*` + `size_t* out_len`, freed with `free_bytes` | +| Buffer | records, rich enums, `T?` (except `Interface?`), `[T]`, `{K:V}` | `const uint8_t* {name}_ptr, size_t {name}_len` (borrowed) | `const uint8_t*` + `size_t* out_len`, freed with `free_bytes` | +| Object | interfaces, `Interface?` | `const {tag}* {name}` (borrowed; null is "none" for `?`) | `{tag}*` (owned reference; null is "none" for `?`) | +| Callback | callback interfaces | `void* {name}_ctx, const {tag}_vtable* {name}_vtable` | not allowed | +| Iterator | `iter` | not allowed | `{IterTag}*` plus `_next`/`_destroy` | + +C-style enums are `typedef enum` with `int` storage and cross as `int32_t`. +Every synchronous symbol carries a trailing `{prefix}_error* out_err` slot +after all inputs and out-parameters. + +## Runtime surface + +Every producer exports these symbols (the Rust macro's `export_runtime!` +emits them): + +```c +uint32_t weaveffi_abi_version(void); +void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); +void weaveffi_error_clear(weaveffi_error* err); +void weaveffi_error_free(weaveffi_error* err); +void weaveffi_free_string(const char* ptr); +void weaveffi_free_bytes(uint8_t* ptr, size_t len); +uint8_t* weaveffi_alloc(uint32_t size); /* wasm32 producers only */ +void weaveffi_dealloc(uint8_t* ptr, uint32_t size); /* wasm32 producers only */ +weaveffi_cancel_token* weaveffi_cancel_token_create(void); +void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token); +bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token); +void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token); +``` + +### Errors + +```c +typedef struct weaveffi_error { + int32_t code; + const char* message; + const uint8_t* payload_ptr; + size_t payload_len; +} weaveffi_error; +``` + +`code == 0` is success. Positive codes are the module's declared domain codes. +Negative codes are reserved for the runtime: + +| Code | Constant | Meaning | +|------|-------------------------|------------------------------------------------------------------| +| `-1` | `GENERIC_ERROR_CODE` | an untyped producer error (`Result`) | +| `-2` | `PANIC_ERROR_CODE` | the producer panicked; the message carries the panic text | +| `-3` | `MARSHAL_ERROR_CODE` | an argument could not be lifted (null, non-UTF-8, bad enum, malformed buffer) | +| `-4` | `FOREIGN_ERROR_CODE` | a consumer callback-interface implementation raised; the message carries the foreign error text | + +On a `throws: true` function a non-zero code is a typed domain error the +wrapper surfaces through the language's error channel; on a non-throwing +function any non-zero code is a programming error the wrapper traps on. See +[Memory and Error Model](memory-error.md). + +## Objects (interfaces) + +An interface `Name` declared in module path `p` has the opaque tag +`{prefix}_{p}_{Name}` and these lifecycle symbols: + +```c +typedef struct {tag} {tag}; +{tag}* {tag}_clone(const {tag}* self); +void {tag}_destroy({tag}* self); +``` + +Objects are **reference counted by the producer**. A `{tag}*` is a strong +reference. `_clone` returns a new strong reference to the same object (the +pointer value is identical); `_destroy` releases one strong reference. The +object is dropped when the last reference is released. Both accept null as a +no-op. Because the count lives in the producer, an object may be shared by any +number of consumer wrappers, stored inside records and collections, and +outlive the call that produced it. An in-flight async call holds its own +reference, so destroying a wrapper while a call is pending is safe. + +Ownership rules for every position an object can appear in: + +| Position | Direction | Rule | +|----------------------------------------------|--------------------|-------------------------------------------------------------| +| top-level parameter (`Store`, `Store?`) | consumer -> producer | borrowed for the call; the producer clones if it retains it | +| return, async result, iterator element | producer -> consumer | one strong reference transfers; the consumer adopts it | +| callback-interface method parameter | producer -> consumer | one strong reference transfers (the slot is `{tag}*`, not `const`); the consumer adopts it | +| inside a value buffer (any direction) | either | the token carries one strong reference; the reader adopts it | + +A consumer that encodes an object into a value buffer must therefore call +`_clone` and write the returned pointer, never the pointer it still holds. +See [Value Buffer Protocol](value-buffers.md#objects). + +Methods take the receiver as the leading `const {tag}* self` slot. +Constructors are statics returning `{tag}*`. + +## Callback interfaces + +A callback interface `Name` in module path `p` is a set of methods the +**consumer** implements and the **producer** calls. It lowers to a vtable +type and no exported symbols: + +```c +typedef struct {prefix}_{p}_{Name}_vtable { + /* one entry per method, in declaration order */ + (*{method})(void* ctx, , weaveffi_error* out_err); + /* always last */ + void (*free)(void* ctx); +} {prefix}_{p}_{Name}_vtable; +``` + +A callback-interface parameter `listener` lowers to two slots: +`void* listener_ctx, const {tag}_vtable* listener_vtable`. The consumer owns +`ctx` (typically a key into a handle map that keeps the implementing object +alive) and a process-wide static vtable per callback interface. The producer +may call any method any number of times, from any thread, until it calls +`free(ctx)` exactly once, after which it never touches `ctx` again. The +producer holds the pair behind a reference count of its own, so it may clone +the callback freely; `free` fires when its last clone drops. + +Method parameters follow the usual families with these ownership rules: +strings, bytes, and buffers are borrowed for the duration of the call (the +consumer copies what it needs); objects transfer one strong reference. Method +returns are restricted to `void` and the Direct family, so no allocation +crosses back from the consumer. Methods can't be `async` or `throws`. + +`out_err` is the consumer's channel for an implementation that raised. The +consumer must not write `message` with its own allocator (the producer frees +it); instead it calls the runtime helper + +```c +void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); +``` + +with `FOREIGN_ERROR_CODE` (`-4`) and a borrowed message, which the producer +copies. A positive code written here is normalized to `-4` (a consumer failure +must never masquerade as one of the producer's domain errors); the other +reserved negative codes pass through. The producer abandons the current call +when a callback method reports a non-zero code, and the original caller +observes the code with the foreign message, exactly as it would observe a +producer panic. + +On the Rust side, on any build with unwinding (`panic = "unwind"`, the default +for every native target), the abandonment is literally a panic: the generated +trait implementation unwinds from the callback method, through the producer's +frames, to the nearest exported thunk, which catches it. Producer code should +therefore treat every callback-interface method call as potentially panicking, +exactly as it would treat a call into an arbitrary closure. In particular, don't +hold a `std::sync::Mutex` guard across a callback call unless the lock is +tolerant of poisoning (or snapshot the state and release the lock first). + +### Builds without unwinding + +On a `panic = "abort"` build (notably `wasm32-unknown-unknown`) nothing can +unwind, so the runtime takes a second route with the same observable result +for the caller: the failure is recorded in a thread-local slot, the callback +method returns the value the vtable entry produced (the consumer's default: +zero, `false`, or the enum's first variant), and the producer's code runs to +completion. Every exported thunk drains that slot after the producer returns +and reports the recorded failure through `out_err` (or the async completion +callback) in place of the result, which is discarded. The first failure recorded +during a call wins. + +Two consequences follow for producers that must work on such builds. The +producer's code observes the default return value once and may act on it (a +"return `false` to detach" protocol detaches), so producers should prefer +protocols where the zero value is harmless. And any side effect the producer +performs after the failed callback still happens, though a consumer wrapper is +expected to refuse further callback invocations during the same producer call +with the same error, so the implementation itself is not consulted again. +Destroy entry points discard a failure recorded while dropping, since they have +no `out_err` to report it through. + +Callback interfaces can't appear in returns, inside buffers, as iterator +elements, or as callback-method parameters. + +## Value buffers + +Records, rich enums, optionals, lists, and maps cross as one packed +little-endian encoding; see [Value Buffer Protocol](value-buffers.md). Revision +2 adds the object token (`u64` pointer, one strong reference) so interfaces +compose with every buffered shape. + +## Async functions + +An `async` function `f` lowers to a launcher and a completion callback +typedef: + +```c +typedef void (*{sym}_callback)(void* context, weaveffi_error* err, ); +void {sym}_async(, [weaveffi_cancel_token* cancel_token,] {sym}_callback callback, void* context); +``` + +The launcher returns immediately; the callback fires exactly once, from an +arbitrary producer thread, with everything it delivers owned by the consumer: +a non-null `err` is heap-boxed and released with `weaveffi_error_free`; +string results with `free_string`; bytes and buffered results with +`free_bytes`; object results are adopted. The producer runs the future on +its configured spawner (`weaveffi::set_spawner`); the default spawner drives +each future on a dedicated thread. + +## Iterators + +An `iter` return lowers to a launcher returning `{IterTag}*`, an +`int32_t {IterTag}_next({IterTag}* iter, , weaveffi_error* out_err)` +that writes one element (returning `1`) or reports exhaustion (returning `0`), +and `void {IterTag}_destroy({IterTag}* iter)`, called exactly once. Elements +follow the return rules of their family: strings are freed with +`free_string`, bytes and buffers with `free_bytes`, objects are adopted. + +## What revision 2 removed + +Revision 1 had untyped `handle` tokens and typed `handle` pointers, +borrowed `&str`/`&[u8]` parameter spellings, `mutable` parameters, +module-level `callbacks:` and `listeners:` (`register_*`/`unregister_*` +returning a subscription id), and a batch-free `weaveffi_arena_*` runtime. +All of them are gone: interfaces with reference counting replace handles, +`string`/`bytes` parameters are always borrowed views at the ABI, and callback +interfaces replace callbacks and listeners. diff --git a/docs/src/reference/idl.md b/docs/src/reference/idl.md index 577b86dd..a3e1e2e3 100644 --- a/docs/src/reference/idl.md +++ b/docs/src/reference/idl.md @@ -1,21 +1,27 @@ -# IDL Type Reference +# IDL Reference -WeaveFFI consumes a declarative IDL (Interface Definition Language) that +WeaveFFI consumes a declarative IDL (interface definition language) that describes modules, types, and functions. YAML, JSON, and TOML are all -supported; this reference uses YAML throughout. +supported; this reference uses YAML throughout. This page documents schema +version `0.9.0`, the only version the current tools accept. + +Every YAML block on this page is a complete document that passes +`weaveffi validate`, except the blocks whose first line is `# rejected:`, +which show exactly what the validator refuses and which error code it +reports. ## Editor autocomplete (JSON Schema) WeaveFFI ships a JSON Schema for the IDL. To get autocomplete and validation in editors that support the [YAML Language Server](https://github.com/redhat-developer/yaml-language-server) -(VS Code, Neovim, Helix, ...), add the following header comment to the top of +(VS Code, Neovim, Helix, and others), add this header comment to the top of your YAML file: ```yaml # yaml-language-server: $schema=./weaveffi.schema.json ``` -The schema is generated by `weaveffi schema --format json-schema` and a copy +The schema is generated by `weaveffi schema --format json-schema`, and a copy is checked in at [`weaveffi.schema.json`](https://github.com/weavefoundry/weaveffi/blob/main/weaveffi.schema.json) in the repository root. @@ -26,70 +32,87 @@ and objects: ```text # yaml-language-server: $schema=./weaveffi.schema.json -version: "0.8.0" +version: "0.9.0" modules: - name: my_module doc: "..." structs: [...] enums: [...] interfaces: [...] + callback_interfaces: [...] functions: [...] - callbacks: [...] - listeners: [...] errors: { ... } modules: [...] ``` The document describes only the API. Package identity and per-generator options live in a `weaveffi.toml` next to it; see -[Project Configuration](../guides/config.md). Unknown top-level keys are -rejected, so a stray `package:` or `generators:` block fails validation -instead of being silently ignored. +[Project Configuration](../guides/config.md). Every object in the schema +rejects unknown keys, so a stray `package:` block, a leftover +`callbacks:` list, or a misspelled field fails to parse instead of being +silently ignored. A complete, validating example lives at the bottom of this page in the [Complete example](#complete-example) section. -| Field | Type | Required | Description | -|--------------|-----------------------------|----------|------------------------------------------| -| `version` | string | yes | Schema version; only the current version (`"0.8.0"`) is accepted | -| `modules` | array of Module | yes | One or more modules | +| Field | Type | Required | Description | +|-----------|-----------------|----------|----------------------------------------------------------------------| +| `version` | string | yes | Schema version; only the current version (`"0.9.0"`) is accepted | +| `modules` | array of Module | yes | One or more modules | ### Module -| Field | Type | Required | Description | -|--------------|--------------------|----------|------------------------------------------| -| `name` | string | yes | Lowercase identifier (e.g. `calculator`) | -| `functions` | array of Function | no | Free functions exported by this module | -| `interfaces` | array of Interface | no | Interface (object) type definitions (see [Interfaces](#interfaces)) | -| `structs` | array of Struct | no | Struct type definitions | -| `enums` | array of Enum | no | Enum type definitions | -| `callbacks` | array of Callback | no | Callback type definitions | -| `listeners` | array of Listener | no | Listener (event subscription) definitions| -| `errors` | ErrorDomain | no | Optional error domain (see [Error domain](#error-domain)) | -| `modules` | array of Module | no | Nested sub-modules (see [nested modules](#nested-modules)) | +| Field | Type | Required | Description | +|-----------------------|-----------------------------|----------|---------------------------------------------------------------------------------| +| `name` | string | yes | Identifier used as a namespace segment and C symbol component (e.g. `kv`) | +| `doc` | string | no | Documentation for the module as a whole | +| `functions` | array of Function | no | Free functions exported by this module | +| `interfaces` | array of Interface | no | Reference-counted object types (see [Interfaces](#interfaces)) | +| `callback_interfaces` | array of CallbackInterface | no | Method sets the consumer implements (see [Callback interfaces](#callback-interfaces)) | +| `structs` | array of Struct | no | Record (value) types | +| `enums` | array of Enum | no | C-style or algebraic enums | +| `errors` | ErrorDomain | no | The module's error domain (see [Error domain](#error-domain)) | +| `modules` | array of Module | no | Nested submodules (see [Nested modules](#nested-modules)) | ### Function -| Field | Type | Required | Description | -|---------------|------------------|----------|-------------------------------------------| -| `name` | string | yes | Function identifier | -| `params` | array of Param | yes | Input parameters (may be empty `[]`) | -| `return` | TypeRef | no | Return type (omit for void functions) | -| `doc` | string | no | Documentation string | -| `throws` | bool | no | Mark as fallible with a typed domain error (default `false`); requires an error domain in scope, on the same module or an ancestor (see [Error domain](#error-domain)) | -| `async` | bool | no | Mark as asynchronous (default `false`) | -| `cancellable` | bool | no | Allow cancellation (only meaningful when `async: true`) | -| `deprecated` | string | no | Deprecation message shown to consumers | -| `since` | string | no | Version when this function was introduced | +| Field | Type | Required | Description | +|---------------|----------------|----------|--------------------------------------------------------------------------------------------------------------------| +| `name` | string | yes | Function identifier | +| `params` | array of Param | no | Ordered input parameters; defaults to none (`params: []` and omitting the key are equivalent) | +| `return` | TypeRef | no | Return type; omit for a function that returns nothing | +| `doc` | string | no | Documentation string | +| `throws` | bool | no | Report typed domain errors (default `false`); requires an error domain in scope (see [Error domain](#error-domain)) | +| `async` | bool | no | Lower to an asynchronous launcher plus completion callback (default `false`) | +| `cancellable` | bool | no | Accept a cancel token (only meaningful with `async: true`) | +| `deprecated` | string | no | Deprecation message propagated to every generated binding | + +The same shape describes an interface's constructors, methods, and statics, +and a callback interface's methods, with the restrictions noted in their +sections. ### Param -| Field | Type | Required | Description | -|-----------|---------|----------|------------------------------------------| -| `name` | string | yes | Parameter name | -| `type` | TypeRef | yes | Parameter type | -| `mutable` | bool | no | Mark as mutable (default `false`). Indicates the callee may modify the value in-place. | -| `doc` | string | no | Documentation string (see [Documentation comments](#documentation-comments)) | +| Field | Type | Required | Description | +|--------|---------|----------|----------------------------------------------------------------------| +| `name` | string | yes | Parameter name | +| `type` | TypeRef | yes | Parameter type | +| `doc` | string | no | Documentation string (see [Documentation comments](#documentation-comments)) | + +### What schema 0.9.0 removed + +Schema 0.9.0 accompanies ABI revision 2 and drops the pre-1.0 spellings +below. There is no compatibility shim; the parser rejects each with a +message pointing at its replacement. + +| Removed | Replacement | +|-------------------------------------|--------------------------------------------------------------------------------------------------------------| +| `handle`, `handle` types | Declare an [interface](#interfaces) and reference it by name; objects are reference counted by the producer | +| `&str`, `&[u8]` types | `string` and `bytes`; parameters are always borrowed views at the ABI, so the borrowed spellings said nothing | +| `Param.mutable` | Removed; no type crosses the boundary by mutable reference | +| `Function.since` | Use `deprecated:` text when a function is on its way out | +| module-level `callbacks:` | A [callback interface](#callback-interfaces) with one method | +| module-level `listeners:` | A callback interface passed to a `subscribe`-style method on an interface | --- @@ -98,8 +121,8 @@ A complete, validating example lives at the bottom of this page in the The IDL carries no package metadata. The name, version, and other distribution identity stamped into every generated ecosystem manifest (`package.json`, `pyproject.toml`, `*.gemspec`, `*.csproj`, `pubspec.yaml`, -`Package.swift`, `go.mod`, `settings.gradle`, `CMakeLists.txt`) come from the -`[package]` table of the `weaveffi.toml` beside the definition: +`Package.swift`, `go.mod`, `settings.gradle.kts`, `CMakeLists.txt`) come +from the `[package]` table of the `weaveffi.toml` beside the definition: ```toml [package] @@ -116,45 +139,65 @@ the per-ecosystem name resolution rules. --- +## Type syntax + +A `TypeRef` is written as a string. The grammar is small: + +| Syntax | Meaning | +|------------|---------------------------------------------------------------| +| `i32`, `string`, ... | A [primitive](#primitive-types) | +| `Name` | A user type: struct, enum, interface, or callback interface, by bare or dot-qualified name | +| `T?` | [Optional](#optional-types) | +| `[T]` | [List](#list-types) | +| `{K:V}` | [Map](#map-types) | +| `iter` | [Lazy iterator](#iterator-types), returns only | + +The parser reads outside in: `[Contact?]` is a list of optional contacts, +`[Contact]?` an optional list. Quote anything that YAML would otherwise +interpret (`"string?"`, `"[i32]"`, `"{string:i32}"`). + +--- + ## Primitive types -The following primitive types are supported. All primitives are valid in both -parameters and return types. - -| Type | Description | Example value | -|---------------|--------------------------------------|---------------| -| `i8` | Signed 8-bit integer | `-12` | -| `i16` | Signed 16-bit integer | `-1000` | -| `i32` | Signed 32-bit integer | `-42` | -| `i64` | Signed 64-bit integer | `9000000000` | -| `u8` | Unsigned 8-bit integer | `200` | -| `u16` | Unsigned 16-bit integer | `60000` | -| `u32` | Unsigned 32-bit integer | `300` | -| `u64` | Unsigned 64-bit integer | `18000000000` | -| `f32` | 32-bit floating point | `1.5` | -| `f64` | 64-bit floating point | `3.14` | -| `bool` | Boolean | `true` | -| `string` | UTF-8 string (owned copy) | `"hello"` | -| `bytes` | Byte buffer (owned copy) | binary data | -| `handle` | Opaque 64-bit identifier | resource id | -| `handle` | Typed handle scoped to type `T` | resource id | -| `&str` | Borrowed string (zero-copy, param-only) | `"hello"` | -| `&[u8]` | Borrowed byte slice (zero-copy, param-only) | binary data | - -> **Note on JavaScript/Wasm:** 64-bit integers (`i64`, `u64`) surface as -> `BigInt` in the Node and WebAssembly backends; all narrower integers and the -> floats surface as `number`. +Every primitive is valid in parameters, returns, fields, and every composite +position. + +| Type | Description | Example value | +|----------|---------------------------------|---------------| +| `i8` | Signed 8-bit integer | `-12` | +| `i16` | Signed 16-bit integer | `-1000` | +| `i32` | Signed 32-bit integer | `-42` | +| `i64` | Signed 64-bit integer | `9000000000` | +| `u8` | Unsigned 8-bit integer | `200` | +| `u16` | Unsigned 16-bit integer | `60000` | +| `u32` | Unsigned 32-bit integer | `300` | +| `u64` | Unsigned 64-bit integer | `18000000000` | +| `f32` | 32-bit floating point | `1.5` | +| `f64` | 64-bit floating point | `3.14` | +| `bool` | Boolean | `true` | +| `string` | UTF-8 string | `"hello"` | +| `bytes` | Byte buffer | binary data | + +`string` and `bytes` are borrowed for the duration of a call when passed in +(the producer copies what it keeps) and owned by the receiver when returned +(the consumer frees them with `weaveffi_free_string` or +`weaveffi_free_bytes`). See [Memory and Error Model](memory-error.md). + +`[u8]` is accepted and canonicalizes to `bytes` at parse time: the two +encode identically inside a value buffer, and `bytes` is what a Rust +producer's `Vec` maps to. + +> **Note on JavaScript and Wasm:** 64-bit integers (`i64`, `u64`) surface as +> `BigInt` in the Node and WebAssembly backends; every narrower integer and +> the floats surface as `number`. ### Primitive examples ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: primitives - structs: - - name: Session - fields: - - { name: id, type: i64 } functions: - name: add params: @@ -174,7 +217,6 @@ modules: return: u32 - name: timestamp - params: [] return: i64 - name: is_valid @@ -191,98 +233,37 @@ modules: params: - { name: data, type: bytes } return: bytes - - - name: open_resource - params: - - { name: path, type: string } - return: handle - - - name: close_resource - params: - - { name: id, type: handle } - - - name: open_session - params: - - { name: config, type: string } - return: "handle" - doc: "Returns a typed handle scoped to Session" - - - name: write_fast - params: - - { name: data, type: "&str" } - doc: "Borrowed string: no copy at the FFI boundary" - - - name: send_raw - params: - - { name: payload, type: "&[u8]" } - doc: "Borrowed byte slice: no copy at the FFI boundary" -``` - -### Typed handles - -`handle` is a typed variant of `handle` that associates the opaque -identifier with a named type `T`. This gives generators type-safety -information, for example, generating a distinct wrapper class per handle -type. `T` must be a struct defined in the same module so the generator -knows how to spell the handle's type. At the C ABI level, `handle` is -still a `uint64_t`. - -```yaml -version: "0.8.0" -modules: - - name: sessions - structs: - - name: Session - fields: - - { name: id, type: i64 } - functions: - - name: create_session - params: [] - return: "handle" - - - name: close_session - params: - - { name: session, type: "handle" } ``` -### Borrowed types - -`&str` and `&[u8]` are zero-copy borrowed variants of `string` and `bytes`. -They indicate that the callee only reads the data for the duration of the -call and does **not** take ownership. This avoids an allocation and copy at -the FFI boundary. - -> **YAML note:** Quote borrowed types like `"&str"` and `"&[u8]"` because -> YAML interprets `&` as an anchor indicator. - --- ## Struct definitions -Structs define composite types with named, typed fields. Define structs under -the `structs` key of a module, then reference them by name in function +Structs define composite value types with named, typed fields. Define them +under the `structs` key of a module, then reference them by name in function signatures and other type positions. ### Struct schema -| Field | Type | Required | Description | -|-----------|------------------|----------|--------------------------------| -| `name` | string | yes | Struct name (e.g. `Contact`) | -| `doc` | string | no | Documentation string | -| `fields` | array of Field | yes | Must have at least one field | +| Field | Type | Required | Description | +|--------------|----------------|----------|------------------------------------------------------| +| `name` | string | yes | Struct name (e.g. `Contact`) | +| `doc` | string | no | Documentation string | +| `deprecated` | string | no | Deprecation message applied to the generated type | +| `fields` | array of Field | yes | Must have at least one field | Each field: -| Field | Type | Required | Description | -|-----------|---------|----------|------------------------------------| -| `name` | string | yes | Field name | -| `type` | TypeRef | yes | Field type | -| `doc` | string | no | Documentation string | +| Field | Type | Required | Description | +|--------|---------|----------|----------------------| +| `name` | string | yes | Field name | +| `type` | TypeRef | yes | Field type | +| `doc` | string | no | Documentation string | ### Struct example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: geometry structs: @@ -327,46 +308,46 @@ modules: return: Rect ``` -Struct fields may reference other structs, enums, optionals, lists, or maps, -nested to any depth. Interface references, borrowed types, and iterators are -not valid field types (see [Type compatibility](#type-compatibility)). +Struct fields may reference other structs, enums, interfaces, optionals, +lists, or maps, nested to any depth. Iterators and callback interfaces are +not valid field types (see [Type positions](#type-positions)). A struct crosses the C ABI **by value** as a serialized value buffer: one `(const uint8_t*, size_t)` pair holding the fields packed in declaration order. Generators map it to an idiomatic value type (a data class, struct, or record) with a generated pack and unpack routine; no per-struct C -functions exist. See the -[Value Buffer Protocol](value-buffers.md) for the wire format and ownership -rules. +functions exist. An interface-typed field is encoded as an object token that +carries one strong reference; see [Objects in value buffers](value-buffers.md#objects). --- ## Enum definitions Enums define a fixed set of named integer variants. Each variant has an -explicit `value` (i32). Define enums under the `enums` key. +explicit `value` (`i32`). Define enums under the `enums` key. ### Enum schema -| Field | Type | Required | Description | -|------------|-------------------|----------|--------------------------------| -| `name` | string | yes | Enum name (e.g. `Color`) | -| `doc` | string | no | Documentation string | -| `variants` | array of Variant | yes | Must have at least one variant | +| Field | Type | Required | Description | +|--------------|------------------|----------|---------------------------------------------------| +| `name` | string | yes | Enum name (e.g. `Color`) | +| `doc` | string | no | Documentation string | +| `deprecated` | string | no | Deprecation message applied to the generated type | +| `variants` | array of Variant | yes | Must have at least one variant | Each variant: -| Field | Type | Required | Description | -|----------|------------------|----------|----------------------------------------------| -| `name` | string | yes | Variant name (e.g. `Red`) | -| `value` | i32 | yes | Integer discriminant | -| `doc` | string | no | Documentation string | -| `fields` | array of Field | no | Associated data: makes the enum a *sum type* | +| Field | Type | Required | Description | +|----------|----------------|----------|----------------------------------------------| +| `name` | string | yes | Variant name (e.g. `Red`) | +| `value` | i32 | yes | Integer discriminant | +| `doc` | string | no | Documentation string | +| `fields` | array of Field | no | Associated data; makes the enum a *sum type* | ### Enum example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts enums: @@ -389,18 +370,18 @@ modules: return: i32 ``` -Variant values must be unique within an enum, and variant names must be unique -within an enum. +Variant values must be unique within an enum, and variant names must be +unique within an enum. ### Rich (algebraic) enums: sum types -When one or more variants declare `fields`, the enum becomes a *rich* enum: an -algebraic sum type whose variants carry associated data (like a Rust `enum` or a -Swift `enum` with associated values). A *unit* variant (no `fields`) and a *data* -variant may coexist in the same enum. +When one or more variants declare `fields`, the enum becomes a *rich* enum: +an algebraic sum type whose variants carry associated data (like a Rust +`enum` or a Swift `enum` with associated values). A *unit* variant (no +`fields`) and a *data* variant may coexist in the same enum. ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: shapes enums: @@ -434,10 +415,14 @@ type (a Rust-style enum, a Swift enum with associated values, a sealed class hierarchy, a tagged union class) with a generated pack and unpack routine. See the [Value Buffer Protocol](value-buffers.md) for the encoding. -A variant `fields` entry uses the same shape as a struct field (`name`, `type`, -`doc`), but obeys the positional rules of a buffered slot: no borrowed -(`&str`/`&[u8]`) types, no iterators, and no interfaces. Field names must be -unique within a variant. +A variant `fields` entry uses the same shape as a struct field (`name`, +`type`, `doc`) and obeys the positional rules of a buffered slot: no +iterators and no callback interfaces. Field names must be unique within a +variant. + +Only a C-style enum (no variant carries fields) is a member of the *direct* +family: it may be a map key and may be returned from a callback-interface +method. A rich enum is a buffered value like a struct. --- @@ -447,19 +432,20 @@ An interface is a first-class object type: a stateful resource with identity and behavior, declared under the `interfaces` key of a module. Where a struct models a plain data record with fields, an interface models something you *hold and operate on*: a store, a session, a connection. The object lives -behind the FFI boundary and crosses it as an opaque reference; consumers see -a real class (or the target's closest analogue) whose constructors, methods, -and statics call back into the producer. +behind the FFI boundary and crosses it as an opaque, reference-counted +pointer; consumers see a real class (or the target's closest analogue) whose +constructors, methods, and statics call into the producer. ### Interface schema -| Field | Type | Required | Description | -|----------------|---------------------|----------|------------------------------------------------| -| `name` | string | yes | Interface type name (e.g. `Store`) | -| `doc` | string | no | Documentation string | -| `constructors` | array of Constructor| no | Static functions returning a new instance | -| `methods` | array of Function | no | Instance methods | -| `statics` | array of Function | no | Static functions namespaced under the interface| +| Field | Type | Required | Description | +|----------------|----------------------|----------|-----------------------------------------------------| +| `name` | string | yes | Interface type name (e.g. `Store`) | +| `doc` | string | no | Documentation string | +| `deprecated` | string | no | Deprecation message applied to the generated class | +| `constructors` | array of Constructor | no | Static functions returning a new instance | +| `methods` | array of Function | no | Instance methods | +| `statics` | array of Function | no | Static functions namespaced under the interface | An interface must declare at least one member, and constructor, method, and static names share one namespace per interface (no duplicates across the @@ -467,12 +453,12 @@ three lists). Each constructor: -| Field | Type | Required | Description | -|----------|----------------|----------|-------------------------------------------------| -| `name` | string | yes | Constructor identifier (e.g. `open`) | -| `params` | array of Param | yes | Input parameters (may be empty `[]`) | -| `doc` | string | no | Documentation string | -| `throws` | bool | no | Mark as fallible with a typed domain error (default `false`) | +| Field | Type | Required | Description | +|----------|----------------|----------|---------------------------------------------------------------| +| `name` | string | yes | Constructor identifier (e.g. `open`) | +| `params` | array of Param | no | Input parameters | +| `doc` | string | no | Documentation string | +| `throws` | bool | no | Report a typed domain error on failure (default `false`) | A constructor implicitly returns a new instance of its interface, so it declares no `return` field, and it may not be `async` (expose an async @@ -482,37 +468,77 @@ becomes the canonical constructor where the target language has one (Swift method. Methods and statics use the full [Function schema](#function), including -`throws`, `async`, `cancellable`, `deprecated`, and `since`. A method -receives the instance implicitly; it does not declare a `self` parameter. -A static takes no instance at all. - -### Lifecycle +`throws`, `async`, `cancellable`, and `deprecated`. A method receives the +instance implicitly; it does not declare a `self` parameter. A static takes +no instance at all. + +### Reference counting and lifecycle + +Objects are **reference counted by the producer**. Every interface receives +two implicit lifecycle symbols, `{tag}_clone` and `{tag}_destroy`; you never +declare either in the IDL. A pointer to an object is a strong reference: +`_clone` returns a second strong reference to the same object (the pointer +value is identical), `_destroy` releases one, and the object is dropped when +the last reference goes. + +Every generated wrapper owns exactly one strong reference and releases it +deterministically through the language's natural disposal hook (C++ +destructor, Swift `deinit`, Kotlin `close()` and `AutoCloseable`, Python +`close()` and context managers, .NET `IDisposable`, Ruby `AutoPointer`, Go +`Close()`, Dart `close()`, JavaScript `close()` and `Symbol.dispose`), with a +finalizer or `Cleaner` backstop where the runtime has one. Because the count +lives in the producer, the same object can be held by any number of +wrappers, stored inside records and collections, retained by the producer, +and outlive the call that produced it. + +Ownership follows the reference direction: + +| Position | Direction | Rule | +|---------------------------------------------|----------------------|-------------------------------------------------------------------------| +| parameter (`Store`, `Store?`) | consumer to producer | borrowed for the call; the producer clones if it retains the object | +| return, async result, iterator element | producer to consumer | one strong reference transfers; the wrapper adopts it | +| callback-interface method parameter | producer to consumer | one strong reference transfers; the consumer adopts it | +| inside a value buffer (record field, list element, map value, optional) | either | the object token carries one strong reference; the reader adopts it | + +The normative statement of these rules is in the +[C ABI Contract](abi.md#objects-interfaces). -Every interface receives an implicit destructor: the C ABI gains a -`*_destroy` symbol, and each generated wrapper releases the underlying -object through its language's natural disposal hook (RAII destructors, -`deinit`, `__del__`, `IDisposable`, finalizers, `close()`). You never -declare `destroy` in the IDL. +### Where interface types may appear -Ownership follows the reference direction: an interface passed as a -parameter is borrowed for the duration of the call, while an interface -returned from a call is a new owned reference the caller (or its wrapper) -must eventually release. +An interface name is a valid `TypeRef` in **every** position except a map +key: parameters, returns, optionals (`Store?`), lists (`[Store]`), map +values (`{string:Store}`), struct fields, rich-enum variant fields, error +payload fields, iterator elements (`iter`), async returns, and +callback-interface method parameters. `Store?` at the top level of a +parameter or return is a nullable pointer (null means none); inside a value +buffer it uses the ordinary optional flag byte followed by the object token. -### Where interface types may appear +The one rejection is the map key: no target can hash an object by identity +in a way that survives the ABI. The validator reports it as +`InterfaceInInvalidPosition` (alongside `InvalidMapKey`): -An interface name is a valid `TypeRef` in function and method parameters, -in return types, and in optionals of those (`Store`, `Store?`). It is -**not** valid as a struct field, a collection element (`[Store]`), a map -key or value, or a callback parameter; the validator rejects those -positions. +```yaml +# rejected: InterfaceInInvalidPosition +version: "0.9.0" +modules: + - name: kv + interfaces: + - name: Store + methods: + - { name: count, return: i64 } + functions: + - name: index_by_store + params: + - { name: stores, type: "{Store:i32}" } +``` ### Interface example -A trimmed version of the `kvstore` sample's `Store` interface: +A trimmed version of the `kvstore` sample's `Store` interface, showing an +object in each of the positions above: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: kv errors: @@ -520,6 +546,14 @@ modules: codes: - { name: KeyNotFound, code: 1001, message: "key not found" } - { name: IoError, code: 1004, message: "I/O failure" } + structs: + - name: StoreInfo + doc: A named view of a store; `store` carries a strong reference + fields: + - { name: label, type: string } + - { name: store, type: Store } + - { name: mirror, type: "Store?" } + - { name: count, type: i64 } interfaces: - name: Store doc: An embedded key-value store owning its entries @@ -536,73 +570,275 @@ modules: - { name: key, type: string } return: bytes throws: true - - name: delete - doc: Remove the entry for the given key, returning true if it existed - params: - - { name: key, type: string } - return: bool - throws: true - name: count doc: Return the number of live entries in the store - params: [] return: i64 + - name: share + doc: A second reference to this same store + return: Store + - name: larger + doc: Whichever of `self` and `other` holds more entries + params: + - { name: other, type: "Store?" } + return: "Store?" + - name: describe + doc: Snapshot this store into a record that carries the object itself + params: + - { name: label, type: string } + - { name: mirror, type: "Store?" } + return: StoreInfo statics: - - name: default_capacity - doc: The largest number of live entries one store will hold - params: [] + - name: open_many + doc: Open one store per path + params: + - { name: paths, type: "[string]" } + return: "[Store]" + throws: true + - name: total_count + doc: Total live entries across `stores` + params: + - { name: stores, type: "[Store]" } + - { name: extra, type: "StoreInfo?" } return: i64 ``` ### C ABI lowering -An interface lowers to an opaque struct tag plus one C symbol per member. -Constructors return an owned pointer; methods take a leading -`const {tag}* self` argument before their declared parameters; statics take -no `self`; and the implicit destructor releases the object: +An interface lowers to an opaque struct tag, one C symbol per member, and +the two lifecycle symbols. Constructors return an owned reference; methods +take a leading `const {tag}* self` slot before their declared parameters; +statics take no `self`; a top-level object parameter is a borrowed +`const {tag}*`; an object return is an owned `{tag}*`; and a list or record +that carries objects is an ordinary value buffer whose object tokens each +carry one reference. The header the C generator emits for the example above: ```c typedef struct weaveffi_kv_Store weaveffi_kv_Store; -/* Constructor: returns a new owned instance. */ -weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err); +/* Constructor: returns one strong reference. */ +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err); -/* Methods: an implicit leading self slot. */ -bool weaveffi_kv_Store_delete(const weaveffi_kv_Store* self, const char* key, - weaveffi_error* out_err); -int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err); - -/* Statics: no self slot. */ -int64_t weaveffi_kv_Store_default_capacity(weaveffi_error* out_err); +/* Statics: no self slot. `[Store]` in and out is a value buffer of object tokens. */ +WEAVEFFI_API const uint8_t* weaveffi_kv_Store_open_many(const uint8_t* paths_ptr, size_t paths_len, size_t* out_len, weaveffi_error* out_err); +WEAVEFFI_API int64_t weaveffi_kv_Store_total_count(const uint8_t* stores_ptr, size_t stores_len, const uint8_t* extra_ptr, size_t extra_len, weaveffi_error* out_err); -/* Implicit destructor: releases the object. */ -void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); +/* Methods: an implicit leading self slot. */ +WEAVEFFI_API const uint8_t* weaveffi_kv_Store_get(const weaveffi_kv_Store* self, const char* key, size_t* out_len, weaveffi_error* out_err); +WEAVEFFI_API int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err); +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_share(const weaveffi_kv_Store* self, weaveffi_error* out_err); +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_larger(const weaveffi_kv_Store* self, const weaveffi_kv_Store* other, weaveffi_error* out_err); +WEAVEFFI_API const uint8_t* weaveffi_kv_Store_describe(const weaveffi_kv_Store* self, const char* label, const weaveffi_kv_Store* mirror, size_t* out_len, weaveffi_error* out_err); + +/* Implicit lifecycle symbols. */ +WEAVEFFI_API weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self); +WEAVEFFI_API void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); ``` Async and iterator-returning members follow the same shapes as free functions (the async launcher and the iterator handle simply carry the `self` slot). Free functions and interface members share the module's C symbol namespace, so a free function named `Store_get` would collide with a -`get` method on an interface `Store`; the validator rejects the collision. +`get` method on an interface `Store`, and a free function named +`Store_clone` would collide with the implicit lifecycle symbol; the +validator rejects both as `AbiSymbolCollision`. + +On the Rust side an interface is a `#[weaveffi::interface]` struct that +must be `Send + Sync`; constructors return `Self` or `Arc`, methods take +`&self` or `self: Arc`, and an object in any other position is an +`Arc`. See [The Rust Producer Macro](../guides/producer-macro.md). + +--- + +## Callback interfaces + +A callback interface is the inverse of an interface: a set of methods the +**consumer** implements and the **producer** calls. Declare one under the +`callback_interfaces` key of a module, then take it as a parameter wherever +the producer needs to call back into the host language: an event +subscriber, an eviction observer, a progress reporter. The consumer passes +an implementation (a class instance, a closure record, a trait object); the +producer keeps it alive as long as it needs to and may call its methods any +number of times, from any thread. + +### Callback interface schema + +| Field | Type | Required | Description | +|--------------|-------------------|----------|----------------------------------------------------------------------| +| `name` | string | yes | Callback interface type name (e.g. `EvictionListener`) | +| `doc` | string | no | Documentation string | +| `deprecated` | string | no | Deprecation message applied to the generated type | +| `methods` | array of Function | yes | Methods the consumer implements, in vtable order; must be non-empty | + +Each method uses the [Function schema](#function) with these restrictions, +each enforced by the validator as `InvalidCallbackMethod`: + +- **Synchronous.** `async: true` and `cancellable: true` are rejected. +- **Never `throws`.** A method has no typed error channel. A consumer + implementation that raises is reported to the original caller as the + runtime's foreign error (`-4`); see below. +- **Direct returns only.** A method returns nothing, a scalar (`i8`..`u64`, + `f32`, `f64`), `bool`, or a C-style enum. Strings, bytes, records, rich + enums, optionals, lists, maps, and objects can't be returned, because no + producer-owned allocation may flow back from the consumer. +- **Parameters may be anything except another callback interface or an + iterator.** Strings, bytes, records, enums, optionals, lists, maps, and + interface objects are all valid; objects transfer a strong reference to + the consumer. + +### Where callback interfaces may appear + +A callback interface is valid **only** as a bare top-level parameter of a +function, constructor, method, or static. It can't be returned, wrapped in an +optional or collection (`Listener?`, `[Listener]`), used as a struct or +variant field, or passed to another callback-interface method. The +validator reports every other position as +`CallbackInterfaceInInvalidPosition`: + +```yaml +# rejected: CallbackInterfaceInInvalidPosition +version: "0.9.0" +modules: + - name: events + callback_interfaces: + - name: Listener + methods: + - { name: on_event, params: [{ name: id, type: i64 }] } + functions: + - name: maybe_listen + params: + - { name: listener, type: "Listener?" } + - name: current_listener + return: Listener +``` + +### Callback interface example + +A trimmed version of the `events` sample: a `Subscriber` the consumer +implements, an `EventBus` object that retains subscribers, and an object +(`EventBus`) handed back to the consumer through a callback method: + +```yaml +version: "0.9.0" +modules: + - name: events + doc: A publish/subscribe event bus driven by a consumer-implemented subscriber. + enums: + - name: Delivery + doc: How a subscriber wants to be told about a message. + variants: + - { name: Accept, value: 0 } + - { name: Skip, value: 1 } + - { name: AcceptAndStop, value: 2 } + structs: + - name: Message + fields: + - { name: seq, type: i64 } + - { name: topic, type: string } + - { name: text, type: string } + - { name: tags, type: "[string]" } + callback_interfaces: + - name: Subscriber + doc: A consumer-implemented subscriber. + methods: + - name: route + doc: Decide how the bus should treat `topic` for this subscriber. + params: + - { name: topic, type: string } + return: Delivery + - name: on_message + doc: Receive an accepted message; returns the running count. + params: + - { name: message, type: Message } + return: i64 + - name: on_attached + doc: Receive the bus itself; the consumer adopts the reference. + params: + - { name: bus, type: EventBus } + interfaces: + - name: EventBus + constructors: + - name: new + methods: + - name: subscribe + params: + - { name: subscriber, type: Subscriber } + return: i64 + - name: publish + params: + - { name: topic, type: string } + - { name: text, type: string } + - { name: tags, type: "[string]" } + return: i64 + functions: + - name: route_once + doc: Ask `subscriber` how it would route `topic` without a bus. + params: + - { name: subscriber, type: Subscriber } + - { name: topic, type: string } + return: Delivery +``` + +### C ABI lowering + +A callback interface lowers to a vtable type and no exported symbols. The +vtable has one function-pointer entry per method, in declaration order, and +a trailing `free`. Every entry takes the consumer's `void* ctx` first and a +`weaveffi_error* out_err` last. A parameter of the type lowers to two slots, +`void* {name}_ctx` and `const {tag}_vtable* {name}_vtable`. The header for +the example above: + +```c +typedef struct weaveffi_events_Subscriber_vtable { + weaveffi_events_Delivery (*route)(void* ctx, const char* topic, weaveffi_error* out_err); + int64_t (*on_message)(void* ctx, const uint8_t* message_ptr, size_t message_len, weaveffi_error* out_err); + void (*on_attached)(void* ctx, weaveffi_events_EventBus* bus, weaveffi_error* out_err); + void (*free)(void* ctx); +} weaveffi_events_Subscriber_vtable; + +WEAVEFFI_API int64_t weaveffi_events_EventBus_subscribe(const weaveffi_events_EventBus* self, void* subscriber_ctx, const weaveffi_events_Subscriber_vtable* subscriber_vtable, weaveffi_error* out_err); +WEAVEFFI_API weaveffi_events_Delivery weaveffi_events_route_once(void* subscriber_ctx, const weaveffi_events_Subscriber_vtable* subscriber_vtable, const char* topic, weaveffi_error* out_err); +``` + +Note the `on_attached` slot: an object parameter of a callback method is a +mutable `{tag}*`, not the borrowed `const {tag}*` of an ordinary parameter, +because the producer transfers one strong reference that the consumer adopts. +Strings, bytes, and buffers arriving in a callback method are borrowed for +the duration of the call; the consumer copies or decodes them before +returning. + +The consumer owns `ctx` (generated wrappers use a handle-table key that +keeps the implementing object alive) and one process-wide static vtable per +callback interface. The producer may call any entry from any thread until it +calls `free(ctx)` exactly once, after which it never touches `ctx` again. A +consumer implementation that raises calls +`weaveffi_error_set(out_err, -4, message)` instead of letting the exception +unwind through C; the producer aborts the call it was making and the +original caller observes `FOREIGN_ERROR_CODE` with the consumer's message. +The full contract, including the Rust-side unwinding behavior producers +must tolerate, is in the [C ABI Contract](abi.md#callback-interfaces). + +On the Rust side a callback interface is a `#[weaveffi::callback_interface]` +trait with `Send + Sync` supertraits and `&self` methods; producers accept +one as `Arc`. --- ## Optional types -Append `?` to any type to make it optional (nullable). When a value is absent, -the default is null. +Append `?` to any type to make it optional (nullable). -| Syntax | Meaning | -|--------------|-------------------------------| -| `string?` | Optional string | -| `i32?` | Optional i32 | -| `Contact?` | Optional struct reference | -| `Color?` | Optional enum reference | -| `Store?` | Optional interface reference (params and returns only) | +| Syntax | Meaning | +|------------|---------------------------------------------------------------------------| +| `string?` | Optional string | +| `i32?` | Optional i32 | +| `Contact?` | Optional struct | +| `Color?` | Optional enum | +| `Store?` | Optional interface (a nullable pointer at the top level, an optional token inside a buffer) | ### Optional example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts structs: @@ -628,29 +864,38 @@ modules: > **YAML note:** Quote optional types like `"string?"` and `"Contact?"` to > prevent the YAML parser from treating `?` as special syntax. +Every optional except `Interface?` crosses the ABI as a value buffer (a flag +byte, then the value when present). `Interface?` stays a nullable object +pointer so the common "maybe an object" shape needs no encoding step. + --- ## List types Wrap a type in `[T]` brackets to declare a list (variable-length sequence). -| Syntax | Meaning | -|--------------|----------------------------| -| `[i32]` | List of i32 | -| `[string]` | List of strings | -| `[Contact]` | List of structs | -| `[Color]` | List of enums | +| Syntax | Meaning | +|-------------|-------------------------------------------------| +| `[i32]` | List of i32 | +| `[string]` | List of strings | +| `[Contact]` | List of structs | +| `[Color]` | List of enums | +| `[Store]` | List of objects (one strong reference per element) | ### List example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: lists structs: - name: Contact fields: - { name: id, type: i64 } + interfaces: + - name: Store + methods: + - { name: count, return: i64 } functions: - name: sum params: @@ -658,13 +903,17 @@ modules: return: i32 - name: list_contacts - params: [] return: "[Contact]" - name: batch_delete params: - { name: ids, type: "[i64]" } return: i32 + + - name: open_all + params: + - { name: paths, type: "[string]" } + return: "[Store]" ``` > **YAML note:** Quote list types like `"[i32]"` and `"[Contact]"` because @@ -674,22 +923,23 @@ modules: ## Map types -Wrap a key-value pair in `{K:V}` braces to declare a map (dictionary / -associative array). Keys must be scalar, string, or enum types; composites, -optionals, `bytes`, and handles are not valid key types. Values may be any -valid `TypeRef`. +Wrap a key-value pair in `{K:V}` braces to declare a map (dictionary, +associative array). Keys must be scalar, `bool`, `string`, or C-style enum +types; values may be any valid `TypeRef` other than an iterator or a +callback interface. -| Syntax | Meaning | -|-------------------|---------------------------------------| -| `{string:i32}` | Map from string to i32 | -| `{string:Contact}`| Map from string to struct | -| `{i32:string}` | Map from i32 to string | -| `{string:[i32]}` | Map from string to list of i32 | +| Syntax | Meaning | +|--------------------|----------------------------------| +| `{string:i32}` | Map from string to i32 | +| `{string:Contact}` | Map from string to struct | +| `{i32:string}` | Map from i32 to string | +| `{string:[i32]}` | Map from string to list of i32 | +| `{string:Store}` | Map from string to object | ### Map example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: maps structs: @@ -706,7 +956,6 @@ modules: doc: "Update player scores by name" - name: get_contacts - params: [] return: "{string:Contact}" doc: "Returns a map of name to Contact" @@ -737,11 +986,7 @@ A map return comes back as a `const uint8_t*` return value plus a generates: ```c -void weaveffi_mymod_update_scores( - const uint8_t* scores_ptr, - size_t scores_len, - weaveffi_error* out_err -); +WEAVEFFI_API bool weaveffi_maps_update_scores(const uint8_t* scores_ptr, size_t scores_len, weaveffi_error* out_err); ``` See the [Value Buffer Protocol](value-buffers.md) for the encoding and @@ -749,11 +994,21 @@ ownership rules. ### Key type restrictions -Only scalar types (the integers, `f32`, `f64`, `bool`), `string`, and enum -types are valid map keys, so every target language can use the key in its -native dictionary idiom. The validator rejects structs, lists, maps, -optionals, `bytes`, and handles as key types. Values may be any valid -`TypeRef`, including nested composites. +Only the integers, `f32`, `f64`, `bool`, `string`, and C-style enum types +are valid map keys, so every target language can use the key in its native +dictionary idiom. The validator rejects structs, rich enums, interfaces, +lists, maps, optionals, and `bytes` as key types with `InvalidMapKey`: + +```yaml +# rejected: InvalidMapKey +version: "0.9.0" +modules: + - name: maps + functions: + - name: by_blob + params: + - { name: index, type: "{bytes:i32}" } +``` --- @@ -769,11 +1024,12 @@ because every composite serializes into one value buffer: | `[string?]` | List of optional strings | | `{string:[i32]}` | Map from string to list of i32 | | `{string:i32}?` | Optional map (the entire map may be null) | +| `{string:[Store]}` | Map from string to list of objects | ### Nested type example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: nested structs: @@ -801,7 +1057,8 @@ modules: The parser evaluates type syntax outside-in: `[Contact?]` is parsed as `List(Optional(Contact))`, while `[Contact]?` is parsed as -`Optional(List(Contact))`. +`Optional(List(Contact))`. Nesting deeper than three levels passes +validation but emits the `DeepNesting` warning under `--warn`. --- @@ -811,16 +1068,17 @@ Wrap a type in `iter` to declare a lazy iterator over values of type `T`. Unlike `[T]` (which materializes the full list), iterators yield elements one at a time and are suitable for large or streaming result sets. -| Syntax | Meaning | -|-------------------|-------------------------------| -| `iter` | Iterator over i32 values | -| `iter` | Iterator over strings | -| `iter` | Iterator over structs | +| Syntax | Meaning | +|-----------------|-------------------------------------------| +| `iter` | Iterator over i32 values | +| `iter` | Iterator over strings | +| `iter` | Iterator over structs | +| `iter` | Iterator over objects (one reference each) | ### Iterator example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: streaming structs: @@ -835,132 +1093,70 @@ modules: doc: "Lazily iterates over matching contacts" ``` -Laziness is part of the binding contract, not just the C ABI shape. -Every generated wrapper surfaces `iter` as its language's native -lazy iteration idiom: an `iter.Seq` in Go (`iter.Seq2` when the -function throws), a `Sequence` in Swift, an input-iterator range in -C++, an iterator in Python, an `Enumerator` in Ruby, an -`IEnumerable` in C#, an `Iterable` in Dart, an iterable in -JavaScript, and an `Iterator` with `close()` in Kotlin. The wrapper -issues one producer `next` call per consumer step; no target drains -the sequence into a hidden list. The underlying iterator handle is -destroyed exactly once, eagerly on exhaustion or from the wrapper's -disposal idiom when iteration is abandoned early, and errors from the -launch and from each `next` follow the function's error strategy (see -the [Error Handling guide](../guides/errors.md#throws-versus-trap)). -The full pull contract is stated in -`weaveffi_core::plan::IteratorProtocol`; element ownership is covered -in the [Memory Ownership guide](../guides/memory.md#iterator-elements). - -Iterators are only valid as **return types**. The validator rejects -iterators in parameter positions. - ---- - -## Callbacks - -Callbacks define function signatures that can be passed from the host -language into Rust. They enable event-driven patterns where Rust code -invokes a caller-provided function. - -### Callback schema - -| Field | Type | Required | Description | -|----------|----------------|----------|------------------------------| -| `name` | string | yes | Callback name | -| `params` | array of Param | yes | Parameters passed to the callback | -| `doc` | string | no | Documentation string | - -### Callback example - -```yaml -version: "0.8.0" -modules: - - name: events - functions: [] - callbacks: - - name: on_data - params: - - { name: payload, type: string } - doc: "Fired when data arrives" - - - name: on_error - params: - - { name: code, type: i32 } - - { name: message, type: string } -``` - -Callback names are not a valid `TypeRef`. Callbacks are wired up at the -module level: declare them under `callbacks:`, reference them from a -`listeners:` entry via `event_callback`, and emit asynchronous results -from functions marked `async: true`. - ---- - -## Listeners - -Listeners provide a higher-level abstraction over callbacks for -event subscription patterns. A listener combines an event callback -with subscribe/unsubscribe lifecycle management. - -### Listener schema - -| Field | Type | Required | Description | -|------------------|--------|----------|----------------------------------------| -| `name` | string | yes | Listener name | -| `event_callback` | string | yes | Name of the callback this listener uses| -| `doc` | string | no | Documentation string | - -### Listener example +Laziness is part of the binding contract, not just the C ABI shape. Every +generated wrapper surfaces `iter` as its language's native lazy iteration +idiom: an `iter.Seq` in Go (`iter.Seq2` when the function throws), a +`Sequence` in Swift, an input-iterator range in C++, an iterator in Python, +an `Enumerator` in Ruby, an `IEnumerable` in C#, an `Iterable` in +Dart, an iterable in JavaScript, and an `Iterator` with `close()` in +Kotlin. The wrapper issues one producer `next` call per consumer step; no +target drains the sequence into a hidden list. The underlying iterator +handle is destroyed exactly once, eagerly on exhaustion or from the +wrapper's disposal idiom when iteration is abandoned early, and errors from +the launch and from each `next` follow the function's error strategy (see +the [Error Handling guide](../guides/errors.md#throws-versus-trap)). The +full pull contract is stated in `weaveffi_core::plan::IteratorProtocol`; +element ownership is covered in the +[Memory and Error Model](memory-error.md#iterators). + +Iterators are only valid as the **outermost** return type of a synchronous +callable. The validator rejects an iterator in a parameter, a struct or +variant field, an error payload field, or a callback-method parameter, and a +return type that nests one (`[iter]`, `iter?`, `iter>`), as +`IteratorInInvalidPosition`, and an iterator returned from an `async` +function as `AsyncIteratorReturn`: ```yaml -version: "0.8.0" +# rejected: IteratorInInvalidPosition +version: "0.9.0" modules: - - name: events - functions: [] - callbacks: - - name: on_data + - name: streaming + functions: + - name: consume params: - - { name: payload, type: string } - - listeners: - - name: data_stream - event_callback: on_data - doc: "Subscribe to data events" + - { name: items, type: "iter" } ``` -The `event_callback` must reference a callback defined in the same module. - --- ## Nested modules -Modules can contain sub-modules, enabling hierarchical organization of -large APIs. Nested modules share the same validation rules as top-level -modules. +Modules can contain submodules, enabling hierarchical organization of large +APIs. Nested modules share the same validation rules as top-level modules, +and an error domain declared on a parent module is in scope for every +module nested inside it. ### Nested module example ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: app functions: - name: init - params: [] modules: - name: auth - structs: + interfaces: - name: Session - fields: - - { name: id, type: i64 } - functions: - - name: login - params: - - { name: username, type: string } - - { name: password, type: string } - return: "handle" + constructors: + - name: login + params: + - { name: username, type: string } + - { name: password, type: string } + methods: + - name: user_id + return: i64 - name: data structs: @@ -977,21 +1173,22 @@ modules: ``` C ABI symbols for nested modules use underscores to join the path: -`weaveffi_app_auth_login`, `weaveffi_app_data_get_record`. +`weaveffi_app_auth_Session_login`, `weaveffi_app_data_get_record`. ### Cross-module type references -Struct, enum, interface, and error domain names are unique across the whole -API (the validator rejects two types sharing a bare name, wherever they are -declared). A bare type name therefore resolves unambiguously from any -module: reference the type by name and the toolchain qualifies the -reference to its owning module internally. +Struct, enum, interface, callback interface, and error domain names are +unique across the whole API (the validator rejects two types sharing a bare +name, wherever they are declared, as `DuplicateTypeName`). A bare type name +therefore resolves unambiguously from any module: reference the type by name +and the toolchain qualifies the reference to its owning module internally. +A dot-qualified spelling (`kv.Store`) is also accepted. For example, a nested `stats` module can take the parent `kv` module's `Store` interface as a parameter: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: kv errors: @@ -1007,7 +1204,6 @@ modules: throws: true methods: - name: count - params: [] return: i64 modules: - name: stats @@ -1025,7 +1221,8 @@ modules: ``` The generated bindings spell a cross-module reference with the owning -module's qualification where the target needs one. +module's qualification where the target needs one; the C symbol is +`weaveffi_kv_stats_get_stats(const weaveffi_kv_Store* store, ...)`. --- @@ -1033,12 +1230,12 @@ module's qualification where the target needs one. ### Async functions -Functions can be marked as asynchronous. See the +Functions, methods, and statics can be marked as asynchronous. See the [Async Functions guide](../guides/async.md) for detailed per-target -behaviour. +behavior. ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: net errors: @@ -1061,23 +1258,41 @@ modules: cancellable: true ``` +An async function lowers to a launcher `{sym}_async` that returns +immediately and a completion callback typedef `{sym}_callback` that fires +exactly once, from an arbitrary producer thread. A `cancellable` function +adds a `weaveffi_cancel_token*` slot to the launcher before the callback: + +```c +typedef void (*weaveffi_net_upload_file_callback)(void* context, weaveffi_error* err, bool result); +WEAVEFFI_API void weaveffi_net_upload_file_async(const char* path, weaveffi_cancel_token* cancel_token, weaveffi_net_upload_file_callback callback, void* context); +``` + `async` composes with `throws`: an async function that also declares `throws: true` delivers its failure as the module's typed domain error through the target's async idiom (an `async throws` function in Swift, a rejected promise carrying the typed error in JavaScript, and so on). An async function without `throws` gets a plain async shape, and a failure can -only be a producer bug. +only be a producer bug. An async function may return an interface object +(the callback adopts one strong reference) or any buffered type; it cannot +return an iterator. + +Async void functions (no return type) emit the `AsyncVoidFunction` +validator warning since they are unusual. Constructors can't be `async` +(`AsyncConstructor`); expose an async static factory returning the interface +instead. -Async void functions (no return type) emit a validator **warning** since -they are unusual. An async function cannot return an iterator (`iter`); -return a list instead or make the function synchronous. +The producer runs each future on its configured spawner +(`weaveffi::set_spawner`); the default drives every future on a dedicated +thread. See [Async Functions](../guides/async.md). -### Deprecated functions +### Deprecated declarations -Mark a function as deprecated with a migration message: +Mark a function, method, static, struct, enum, interface, or callback +interface as deprecated with a migration message: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: legacy functions: @@ -1087,35 +1302,19 @@ modules: - { name: b, type: i32 } return: i32 deprecated: "Use add_v2 instead" - since: "0.1.0" ``` -Generators propagate the deprecation message to the target language -(e.g. `@available(*, deprecated)` in Swift, `@Deprecated` in Kotlin, -`warn` in Ruby). - -### Mutable parameters - -Mark a parameter as mutable when the callee may modify it in-place: - -```yaml -version: "0.8.0" -modules: - - name: buffers - functions: - - name: fill_buffer - params: - - { name: buf, type: bytes, mutable: true } -``` - -This affects the C ABI signature (non-const pointer) and may influence -generated wrapper code in target languages. +Generators propagate the deprecation message to the target language (a +`WEAVEFFI_DEPRECATED("...")` attribute in C, `@available(*, deprecated)` in +Swift, `@Deprecated` in Kotlin, `warn` in Ruby, and so on). A deprecated +free function also emits the `DeprecatedFunction` warning under `--warn`, +which is informational. --- ## Generator options -Per-generator options (Swift module name, Android package, Go module path, +Per-generator options (Swift module name, Kotlin package, Go module path, and so on) are not part of the IDL. They live under `[generators.]` tables in `weaveffi.toml`, where `` matches the `--target` flag: @@ -1123,7 +1322,7 @@ tables in `weaveffi.toml`, where `` matches the `--target` flag: [generators.swift] module_name = "MyMathLib" -[generators.android] +[generators.kotlin] package = "com.example.math" [generators.go] @@ -1135,51 +1334,39 @@ of options. --- -## Type compatibility - -All types are valid in both parameter and return positions unless noted. - -| Type | Params | Returns | Struct fields | Notes | -|----------------|--------|---------|---------------|------------------------| -| `i8` | yes | yes | yes | | -| `i16` | yes | yes | yes | | -| `i32` | yes | yes | yes | | -| `i64` | yes | yes | yes | | -| `u8` | yes | yes | yes | | -| `u16` | yes | yes | yes | | -| `u32` | yes | yes | yes | | -| `u64` | yes | yes | yes | `BigInt` in JS/Wasm | -| `f32` | yes | yes | yes | | -| `f64` | yes | yes | yes | | -| `bool` | yes | yes | yes | | -| `string` | yes | yes | yes | | -| `bytes` | yes | yes | yes | | -| `handle` | yes | yes | yes | | -| `handle` | yes | yes | yes | Typed handle | -| `&str` | yes | no | no | Borrowed, zero-copy, param-only | -| `&[u8]` | yes | no | no | Borrowed, zero-copy, param-only | -| `StructName` | yes | yes | yes | | -| `EnumName` | yes | yes | yes | | -| `InterfaceName`| yes | yes | no | Also `InterfaceName?`; not in collections | -| `T?` | yes | yes | yes | | -| `[T]` | yes | yes | yes | | -| `[T?]` | yes | yes | yes | | -| `[T]?` | yes | yes | yes | | -| `{K:V}` | yes | yes | yes | | -| `{K:V}?` | yes | yes | yes | | -| `iter` | no | yes | no | Return-only | +## Type positions + +The validator admits each type in the positions below. "Buffered field" +covers struct fields, rich-enum variant fields, and error payload fields; +"callback param" and "callback return" are the parameters and return of a +callback-interface method. + +| Type | Param | Return | Buffered field | Element of `T?`, `[T]`, `{K:V}` value | Map key | Callback param | Callback return | Notes | +|-------------------------|-------|--------|----------------|----------------------------------------|---------|----------------|-----------------|-------| +| integers, `f32`, `f64`, `bool` | yes | yes | yes | yes | yes | yes | yes | the *direct* family | +| `string` | yes | yes | yes | yes | yes | yes | no | | +| `bytes` | yes | yes | yes | yes | no | yes | no | | +| C-style enum | yes | yes | yes | yes | yes | yes | yes | direct family | +| rich enum | yes | yes | yes | yes | no | yes | no | buffered | +| struct | yes | yes | yes | yes | no | yes | no | buffered | +| interface | yes | yes | yes | yes | no | yes | no | reference counted everywhere | +| callback interface | yes | no | no | no | no | no | no | bare top-level parameter only | +| `T?`, `[T]`, `{K:V}` | yes | yes | yes | yes | no | yes | no | buffered; obey the inner type's rules | +| `iter` | no | yes | no | no | no | no | no | outermost return of a sync callable only | --- ## Complete example -A full IDL combining structs, enums, optionals, lists, an interface, and a -typed error domain (a trimmed version of the `contacts` sample): +A full IDL combining structs, enums, optionals, lists, an interface, a +callback interface, and a typed error domain (a trimmed version of the +`contacts` sample): ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts + doc: "An in-memory address book" enums: - name: ContactType variants: @@ -1203,12 +1390,20 @@ modules: - { name: InvalidName, code: 1, message: "name must not be empty" } - { name: NotFound, code: 2, message: "contact not found" } + callback_interfaces: + - name: ChangeListener + doc: "Notified whenever a contact is added or removed" + methods: + - name: on_change + params: + - { name: contact, type: Contact } + - { name: removed, type: bool } + interfaces: - name: ContactBook - doc: "An in-memory address book owning its contacts" + doc: "An address book owning its contacts" constructors: - name: new - params: [] methods: - name: add doc: "Add a contact, returning the stored record with its assigned id" @@ -1228,7 +1423,6 @@ modules: throws: true - name: list - params: [] return: "[Contact]" - name: remove @@ -1236,72 +1430,180 @@ modules: - { name: id, type: i64 } return: bool + - name: watch + doc: "Attach a listener; the book retains it until it is dropped" + params: + - { name: listener, type: ChangeListener } + - name: count - params: [] return: i32 ``` -## Validation rules - -- Module, function, parameter, struct, enum, interface, field, and variant - names must be valid identifiers (start with a letter or `_`, contain only - alphanumeric characters and `_`). -- Names must be unique within their scope (no duplicate module names, no - duplicate function names within a module, etc.). -- Struct, enum, interface, and error domain names must be unique across the - **whole API**, not just their module: generators emit flat per-language - type names, and bare-name references resolve API-wide. -- Reserved keywords are rejected: `if`, `else`, `for`, `while`, `loop`, - `match`, `type`, `return`, `async`, `await`, `break`, `continue`, `fn`, - `struct`, `enum`, `mod`, `use`. -- Structs must have at least one field. Enums must have at least one variant. - Interfaces must have at least one constructor, method, or static. -- Enum variant values must be unique within their enum. -- Type references to structs, enums, and interfaces must resolve to a - definition somewhere in the API (see - [Cross-module type references](#cross-module-type-references)). -- Interface members (constructors, methods, and statics) share one namespace - per interface; a constructor may not declare a `return` type and may not - be `async`. -- Interface types are valid only as parameters, return types, and optionals - of those; not as struct fields, collection elements, map keys or values, - or callback parameters. +## Validation + +`weaveffi validate ` (and every generating command) runs the complete +rule set below and reports **every** violation it finds in one pass, each +with a message, a suggestion, and, when the offending name can be located, +an underlined span in the source. `weaveffi validate --format json` emits +the same diagnostics as machine-readable objects whose `code` field is the +variant name listed in the catalog; `--warn` adds the advisory warnings. + +### Rules + +Names and uniqueness: + +- Module, function, parameter, struct, field, enum, variant, variant field, + interface, callback interface, callback method, error domain, and error + code names must be valid + identifiers (start with a letter or `_`, contain only ASCII alphanumerics + and `_`) and must not be one of the reserved words `if`, `else`, `for`, + `while`, `loop`, `match`, `type`, `return`, `async`, `await`, `break`, + `continue`, `fn`, `struct`, `enum`, `mod`, `use`. +- Names must be unique within their scope: module names among siblings, + function names within a module, parameter names within a callable, field + names within a struct or variant, variant names and values within an enum, + and struct, enum, interface, and callback interface names within a + module. +- Struct, enum, interface, callback interface, and error domain names must + be unique across the **whole API**, not just their module: generators emit + flat per-language type names, and bare-name references resolve API-wide. - Free functions and interface members share the module's C symbol - namespace; two declarations lowering to the same symbol are rejected. -- `throws: true` requires an error domain in scope: on the same module or an - ancestor module. + namespace. A free function claims `{name}`; an interface claims + `{Interface}_{member}` for every member plus `{Interface}_clone` and + `{Interface}_destroy`. Two declarations lowering to the same symbol are + rejected. + +Shapes: + +- Structs must have at least one field. Enums must have at least one + variant. Interfaces must have at least one constructor, method, or static. + Callback interfaces must have at least one method. +- Interface members share one namespace per interface. A constructor may + not declare a `return` type and may not be `async`. +- Callback-interface methods are synchronous (no `async`, no + `cancellable`), never `throws`, and return nothing or a direct value (a + scalar, `bool`, or C-style enum). + +Types and positions: + +- Every user type reference must resolve to a struct, enum, interface, or + callback interface declared somewhere in the API. +- Map keys must be scalars, `bool`, `string`, or a C-style enum. +- An interface may appear anywhere except as a map key. +- A callback interface may appear only as a bare top-level parameter of a + function, constructor, static, or method. +- An iterator may appear only as the outermost return type of a + synchronous callable. + +Errors: + +- `throws: true` requires an error domain in scope: on the same module or + an ancestor module. - Error codes must be positive (`0` means success and negative codes are - reserved for the runtime) and unique within their domain. Error code - **names** must be unique within their domain and across every domain in - the API. -- Error domain names must not collide with function names in the same + reserved for the runtime) and unique within their domain. Code names must + be unique within their domain and across every domain in the API. +- The error domain's name must not collide with a function name in the same module. -- Async functions are allowed. Async void functions (no return type) emit a - warning. An async function cannot return an iterator. -- Listener `event_callback` must reference a callback in the same module. + +### Error catalog + +Every `ValidationError` the validator can report. The **Code** column is +the `code` field of `weaveffi validate --format json` and the identifier to +match on in CI scripts; the other two columns are the message and the +suggestion the CLI prints. Braces mark the names the message interpolates. + +| Code | Message | Suggestion | +|------|---------|------------| +| `UnsupportedSchemaVersion` | unsupported schema version '{version}'; supported versions: {supported} | set the version field to the current schema version and update the document to match the current schema | +| `NoModuleName` | module has no name | every module must have a non-empty 'name' field | +| `DuplicateModuleName` | duplicate module name: {name} | module names must be unique within an API definition; rename or merge the duplicate | +| `InvalidModuleName` | invalid module name '{name}': {reason} | choose a valid identifier (a-z, A-Z, 0-9, _) that is not a reserved word | +| `InvalidIdentifier` | invalid identifier '{name}': {reason} | identifiers must start with a letter or underscore and contain only alphanumeric or underscore characters | +| `ReservedKeyword` | reserved keyword used: {name} | choose a different name that is not a language reserved word | +| `DuplicateFunctionName` | duplicate function name in module '{module}': {function} | function names must be unique within a module; rename the duplicate | +| `DuplicateParamName` | duplicate param name in function '{function}' of module '{module}': {param} | parameter names must be unique within a function; rename the duplicate | +| `DuplicateStructName` | duplicate struct name in module '{module}': {name} | struct names must be unique within a module; rename the duplicate | +| `DuplicateStructField` | duplicate field name in struct '{struct_name}': {field} | field names must be unique within a struct; rename the duplicate | +| `EmptyStruct` | empty struct in module '{module}': {name} | structs must have at least one field; add a field or remove the struct | +| `DuplicateEnumName` | duplicate enum name in module '{module}': {name} | enum names must be unique within a module; rename the duplicate | +| `EmptyEnum` | empty enum in module '{module}': {name} | enums must have at least one variant; add a variant or remove the enum | +| `DuplicateEnumVariant` | duplicate enum variant in enum '{enum_name}': {variant} | variant names must be unique within an enum; rename the duplicate | +| `DuplicateEnumValue` | duplicate enum value in enum '{enum_name}': {value} | variant numeric values must be unique within an enum; assign a different value | +| `DuplicateEnumVariantField` | duplicate field '{field}' in variant '{variant}' of enum '{enum_name}' | associated field names must be unique within an enum variant; rename the duplicate | +| `DuplicateInterfaceName` | duplicate interface name in module '{module}': {name} | interface names must be unique within a module; rename the duplicate | +| `DuplicateInterfaceMember` | duplicate member name in interface '{interface}': {name} | constructor, method, and static names share one namespace per interface; rename the duplicate | +| `EmptyInterface` | empty interface in module '{module}': {name} | interfaces must declare at least one constructor, method, or static; add a member or remove the interface | +| `ConstructorHasReturn` | constructor '{constructor}' of interface '{interface}' declares a return type | a constructor implicitly returns a new instance of its interface; remove the `return` field | +| `AsyncConstructor` | constructor '{constructor}' of interface '{interface}' cannot be async | constructors are synchronous; expose an async static factory returning the interface instead | +| `InterfaceInInvalidPosition` | interface type '{name}' is not valid in {location} | interface objects may appear anywhere except as map keys; key a map by a scalar, string, or C-style enum instead | +| `DuplicateCallbackInterfaceName` | duplicate callback interface name in module '{module}': {name} | callback interface names must be unique within a module; rename the duplicate | +| `EmptyCallbackInterface` | empty callback interface in module '{module}': {name} | callback interfaces must declare at least one method; add a method or remove the callback interface | +| `DuplicateCallbackMethod` | duplicate method name in callback interface '{interface}': {name} | method names must be unique within a callback interface; rename the duplicate | +| `InvalidCallbackMethod` | method '{method}' of callback interface '{interface}' {reason} | callback-interface methods are synchronous, never throw, and return either nothing or a direct value (a scalar, bool, or C-style enum): the consumer implements them, so no producer-owned allocation or error payload can flow back | +| `CallbackInterfaceInInvalidPosition` | callback interface '{name}' is not valid in {location} | a callback interface is a consumer-implemented vtable: it may be passed as a parameter of a function, constructor, static, or method, but it can't be returned, nested inside another type, or passed to another callback-interface method | +| `DuplicateTypeName` | duplicate type name '{name}' (declared in '{first}' and '{second}') | struct, enum, interface, callback interface, and error domain names must be unique across the whole API; rename one of the declarations | +| `UnknownTypeRef` | unknown type reference: {name} | define a struct, enum, interface, or callback interface with this name, or check for typos | +| `InvalidMapKey` | invalid map key type: {key_type}; only scalars, strings, and C-style enums are allowed as map keys | map keys must be integers (i8 through u64), f32, f64, bool, string, or a C-style enum; structs, rich enums, interfaces, optionals, lists, and maps cannot be keys | +| `IteratorInInvalidPosition` | iterator type is only valid as a function return type, found in {location} | iterator types can only be used as function return types, not as parameters or struct fields | +| `AsyncIteratorReturn` | async function '{module}::{function}' cannot return an iterator | the callback-completed async ABI has no streaming protocol; return a list (`[T]`) from the async function, or make the function synchronous and return `iter` | +| `AbiSymbolCollision` | C symbol collision in module '{module}': two declarations lower to '..._{symbol}' | free functions and interface members share the module's C symbol namespace (interface members are prefixed with the interface name); rename one of the colliding declarations | +| `ThrowsWithoutErrorDomain` | function '{module}::{function}' declares throws but no error domain is in scope | a throwing function reports codes from its module's error domain; declare an `errors:` block on this module (or an ancestor module), or remove `throws: true` | +| `ErrorDomainMissingName` | error domain missing name in module '{module}' | add a non-empty 'name' field to the error domain | +| `DuplicateErrorName` | duplicate error code name in module '{module}': {name} | error code names must be unique within a module; rename the duplicate | +| `DuplicateErrorCode` | duplicate error numeric code in module '{module}': {code} | numeric error codes must be unique within a module; assign a different value | +| `DuplicateErrorCodeName` | duplicate error code name '{name}' (declared in '{first}' and '{second}') | error code names must be unique across the whole API; qualify one of them (e.g. 'OrderNotFound') | +| `InvalidErrorCode` | invalid error code in module '{module}' for '{name}': must be a positive integer | 0 means success and negative codes are reserved for the runtime (-1 generic, -2 panic, -3 marshalling failure, -4 foreign callback error); use a positive integer | +| `NameCollisionWithErrorDomain` | function name collides with error domain name in module '{module}': {name} | function and error domain names share a namespace; rename one to avoid the collision | + +A document with an unsupported `version` reports only +`UnsupportedSchemaVersion`; the remaining rules assume the current schema's +shape and are not run. Removed type spellings (`handle`, `&str`) and +removed keys (`callbacks:`, `since:`, `mutable:`) are rejected earlier, by +the parser, with a message naming the replacement. + +### Warnings + +Warnings never fail validation. `weaveffi validate --warn` and +`weaveffi generate --warn` print them; the JSON output lists them under +`warnings` with the same `code` names. + +| Code | Fires when | +|------|------------| +| `LargeEnumVariantCount` | an enum declares more than 100 variants | +| `DeepNesting` | a struct field, parameter, or return type nests composites deeper than 3 levels (`[[[[i32]]]]`) | +| `EmptyModuleDoc` | a module declares functions but has no `doc:` on itself or on any of them | +| `AsyncVoidFunction` | a free function is `async: true` with no return type | +| `DeprecatedFunction` | a free function carries `deprecated:` (informational) | ## ABI mapping -- Scalar parameters map to their C ABI types; `string` passes as a C string - and `bytes` as pointer + length. +A one-screen summary; the [C ABI Contract](abi.md) is normative. + +- Scalars, `bool`, and C-style enums pass by value in one slot; C-style + enums cross as `int32_t`. +- `string` is one `const char*` slot; `bytes` is a + `const uint8_t* {name}_ptr, size_t {name}_len` pair. Both are borrowed for + the call as parameters. - Structs, rich enums, optionals (except `Interface?`), lists, and maps are *buffered*: they cross by value as one serialized `(const uint8_t* {name}_ptr, size_t {name}_len)` pair, borrowed for the - duration of the call (see the - [Value Buffer Protocol](value-buffers.md)). -- Return values are direct scalars except: - - `string`: returns `const char*` allocated by Rust; caller must free via - `weaveffi_free_string`. - - `bytes` and every buffered type: returns `const uint8_t*` plus an extra - `size_t* out_len` param; caller frees with `weaveffi_free_bytes`. -- Interfaces cross as opaque object pointers; `Interface?` as a nullable - object pointer. -- Each function takes a trailing `weaveffi_error* out_err` for error reporting. + duration of the call (see the [Value Buffer Protocol](value-buffers.md)). +- Interfaces cross as `const {tag}*` parameters (borrowed) and `{tag}*` + returns (one strong reference); `Interface?` is the same slot with null + meaning none. Every interface has `{tag}_clone` and `{tag}_destroy`. +- Callback interfaces lower to `void* {name}_ctx, const {tag}_vtable* {name}_vtable`. +- Return values are direct scalars except: `string` returns `const char*` + (freed with `weaveffi_free_string`); `bytes` and every buffered type + return `const uint8_t*` plus a `size_t* out_len` out-parameter (freed with + `weaveffi_free_bytes`); `iter` returns an iterator handle with `_next` + and `_destroy` symbols. +- Every synchronous symbol takes a trailing `weaveffi_error* out_err` + after all inputs and out-parameters. `_clone` and `_destroy` take none. ## Error domain A module can declare one error domain: a named set of symbolic error codes -its fallible functions report. The domain generates a *typed* error +its fallible callables report. The domain generates a *typed* error construct in every target language (an error enum in Swift, an exception class hierarchy in Python, sealed exception subclasses in Kotlin, and so on), so consumers catch and match on the codes you declared rather than on @@ -1309,58 +1611,68 @@ raw integers. ### Error domain schema -| Field | Type | Required | Description | -|---------|---------------|----------|---------------------------------------------------| -| `name` | string | yes | Domain name, used to name the generated error type (e.g. `KvError`) | -| `codes` | array of Code | yes | The named codes belonging to this domain | +| Field | Type | Required | Description | +|---------|---------------|----------|-----------------------------------------------------------------------------| +| `name` | string | yes | Domain name, used to name the generated error type (e.g. `KvError`) | +| `codes` | array of Code | yes | The named codes belonging to this domain | Each code: -| Field | Type | Required | Description | -|-----------|----------------|----------|-------------------------------------------------| -| `name` | string | yes | Code name, lowered to a case or subclass on the generated error type (e.g. `KeyNotFound`) | -| `code` | i32 | yes | Stable numeric value carried across the C ABI | -| `message` | string | yes | Default human-readable message | -| `doc` | string | no | Documentation string | +| Field | Type | Required | Description | +|-----------|----------------|----------|----------------------------------------------------------------------------------------------------| +| `name` | string | yes | Code name, lowered to a case or subclass on the generated error type (e.g. `KeyNotFound`) | +| `code` | i32 | yes | Stable numeric value carried across the C ABI; must be positive | +| `message` | string | yes | Default human-readable message | +| `doc` | string | no | Documentation string | | `fields` | array of Field | no | Structured payload fields this error carries (see [Structured error payloads](#structured-error-payloads)) | PascalCase code names (`KeyNotFound`, `StoreFull`) are the convention: each -generator re-cases them into its own idiom. +generator re-cases them into its own idiom (see +[Naming and Package Conventions](naming.md#error-domains-and-codes)). ### Structured error payloads A code may declare `fields:` using the same shape as struct fields (`name`, -`type`, `doc`). When a matching error is -raised, those fields are serialized into the `weaveffi_error` struct's -`payload_ptr`/`payload_len` slots in the value-buffer format, and each -generator decodes them into properties of the raised exception or returned -error value, keyed by the field names: +`type`, `doc`). When a matching error is raised, those fields are serialized +into the `weaveffi_error` struct's `payload_ptr`/`payload_len` slots in the +value-buffer format, and each generator decodes them into properties of the +raised exception or returned error value, keyed by the field names: ```yaml -errors: - name: KvError - codes: - - name: KeyNotFound - code: 1001 - message: "key not found" - fields: - - { name: key, type: string } - - { name: attempts, type: i32 } +version: "0.9.0" +modules: + - name: kv + errors: + name: KvError + codes: + - name: KeyNotFound + code: 1001 + message: "key not found" + fields: + - { name: key, type: string } + - { name: attempts, type: i32 } + functions: + - name: lookup + params: + - { name: key, type: string } + return: bytes + throws: true ``` -Payload fields obey the buffered positional rules: no borrowed types, no -iterators, no interfaces. A code without `fields` leaves the payload slots -null. See the +Payload fields obey the buffered positional rules: no iterators and no +callback interfaces (an interface object is allowed and carries a +reference like any other object token). A code without `fields` leaves the +payload slots null. See the [Value Buffer Protocol](value-buffers.md#structured-errors) for the wire format and freeing rules. ### Opting in with `throws` -Declaring a domain reserves the codes; a function, method, or constructor -joins the typed error path by declaring `throws: true`: +Declaring a domain reserves the codes; a function, method, static, or +constructor joins the typed error path by declaring `throws: true`: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: contacts errors: @@ -1376,7 +1688,6 @@ modules: throws: true - name: count_contacts - params: [] return: i32 ``` @@ -1392,21 +1703,35 @@ including panic behavior. The domain is in scope for its module **and every module nested inside it**, so a domain declared on a parent module serves the whole subtree. -Declaring `throws: true` with no domain in scope is a validation error. +Declaring `throws: true` with no domain in scope is a validation error +(`ThrowsWithoutErrorDomain`): + +```yaml +# rejected: ThrowsWithoutErrorDomain +version: "0.9.0" +modules: + - name: contacts + functions: + - name: get_contact + params: + - { name: id, type: i64 } + return: string + throws: true +``` ### Validation - Codes must be positive: `0` means success, and the negative range is reserved for the runtime (`-1` generic error, `-2` producer panic, `-3` - marshalling failure). + marshalling failure, `-4` foreign callback error). - Numeric codes must be unique within a domain. - Code names must be unique within a domain **and across every domain in the API**: backends with flat namespaces derive one error class or constant per code, so two domains both declaring `NotFound` would collide. Qualify one of them (e.g. `OrderNotFound`). - The domain name must not collide with a function name in the same module, - and it shares the API-wide type namespace with struct, enum, and - interface names. + and it shares the API-wide type namespace with struct, enum, interface, + and callback interface names. ## Documentation comments @@ -1418,36 +1743,38 @@ syntax allows. Supported sites: +- `Module.doc` - `Function.doc`, `Param.doc` - `InterfaceDef.doc`, plus each of its constructors, methods, and statics (interface members use the function schema, so they carry `Function.doc` and `Param.doc`) +- `CallbackInterfaceDef.doc`, plus each of its methods and their parameters - `StructDef.doc`, `StructField.doc` - `EnumDef.doc`, `EnumVariant.doc` -- `CallbackDef.doc`, `ListenerDef.doc` - `ErrorCode.doc` Per-target syntax: -| Target | Comment syntax | Param docs | -|---------------------|---------------------------------------------------|-----------------------------------------| -| C / C++ | `/** ... */` directly above the declaration | not emitted | -| Swift | `/// ...` per line | `/// - Parameter name: ...` | -| Kotlin / Android | `/** ... */` KDoc block | `@param name ...` inside the KDoc block | -| TypeScript (Node) | `/** ... */` JSDoc | `@param name ...` | -| TypeScript (Wasm) | `/** ... */` JSDoc | `@param name ...` | -| Python | `"""..."""` first statement; `# ...` above C ABI binds | NumPy-style `Parameters` section in the wrapper docstring | -| .NET (C#) | `/// ...` XML doc | `/// ...` | -| Dart | `/// ...` | not emitted | -| Go | `// SymbolName ...` per Go's convention | trailing `// Parameters:` block | -| Ruby | `# ...` lines above the `def` | `# @param name [Object] ...` | +| Target | Comment syntax | Param docs | +|-------------------|---------------------------------------------------------|-----------------------------------------------------------| +| C / C++ | `/** ... */` directly above the declaration | not emitted | +| Swift | `/// ...` per line | `/// - Parameter name: ...` | +| Kotlin | `/** ... */` KDoc block | `@param name ...` inside the KDoc block | +| TypeScript (Node) | `/** ... */` JSDoc | `@param name ...` | +| TypeScript (Wasm) | `/** ... */` JSDoc | `@param name ...` | +| Python | `"""..."""` first statement; `# ...` above C ABI binds | NumPy-style `Parameters` section in the wrapper docstring | +| .NET (C#) | `/// ...` XML doc | `/// ...` | +| Dart | `/// ...` | not emitted | +| Go | `// SymbolName ...` per Go's convention | trailing `// Parameters:` block | +| Ruby | `# ...` lines above the `def` | `# @param name [Object] ...` | Example IDL: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: docs + doc: Document tracking structs: - name: Document doc: | @@ -1465,6 +1792,6 @@ modules: return: Document ``` -All eleven generators emit `emit_doc` calls before every documented +All eleven generators emit doc comments before every documented declaration; absent or empty `doc:` fields produce no extra output, so the feature is fully opt-in. diff --git a/docs/src/reference/memory-error.md b/docs/src/reference/memory-error.md index 48f07eaf..b367f40b 100644 --- a/docs/src/reference/memory-error.md +++ b/docs/src/reference/memory-error.md @@ -1,25 +1,33 @@ # Memory and Error Model -This section summarizes the C ABI conventions exposed by WeaveFFI and how to manage -ownership across the FFI boundary. - -## Error handling - -- Every generated C function ends with an `out_err` parameter of type - `weaveffi_error*`, except `_destroy` symbols. -- On success: `out_err->code == 0` and `out_err->message == NULL`. -- On failure: `out_err->code != 0` and `out_err->message` points to a Rust-allocated - NUL-terminated UTF-8 string that must be cleared. -- On a `throws: true` function, a non-zero code is one of the module's declared - domain codes (the header emits an enum constant per code, such as - `weaveffi_kv_KvError_KeyNotFound`); on a non-throwing function a non-zero - code only ever reports a producer bug such as a panic. -- When the matched error code declares payload `fields:`, `payload_ptr` and - `payload_len` hold those fields serialized in the - [value-buffer format](value-buffers.md#structured-errors); both are null - and zero for a code without fields. - -Relevant declarations (from the generated header): +This page is the working summary of who owns what across the WeaveFFI C +ABI (revision 2) and how failures travel back to the caller. It is written +for someone consuming the generated C header directly or auditing a +generated wrapper. The normative text is the [C ABI Contract](abi.md); where +the two disagree, that page wins, and this one points at the relevant +section rather than restating it. + +The model has five kinds of values, each with one ownership rule: + +| Kind | Crosses as | Parameter | Return, async result, iterator element | Release | +|-------------------------------|----------------------------------------------|------------------------------------|---------------------------------------------|-------------------------------------------------| +| direct (scalars, `bool`, C-style enums) | one slot by value | copied | copied | nothing | +| `string` | `const char*` | borrowed for the call | owned by the consumer | `weaveffi_free_string` | +| `bytes` and value buffers | `const uint8_t*` + `size_t` | borrowed for the call | owned by the consumer | `weaveffi_free_bytes(ptr, len)` | +| objects (interfaces) | `{tag}*` | borrowed for the call | one strong reference, adopted | `{tag}_destroy` | +| callback interfaces | `void* ctx` + `const {tag}_vtable*` | owned by the consumer until `free` | never returned | the producer calls `free(ctx)` exactly once | + +"Borrowed for the call" means the producer may read the value only until +the symbol returns and must copy anything it keeps. Everything a producer +hands back is owned by the receiver, who releases it with the runtime +function in the last column exactly once. + +## Errors + +Every synchronous symbol takes a trailing `weaveffi_error* out_err` slot +after all inputs and out-parameters; `_clone` and `_destroy` are the only +exported symbols without one. The caller owns the struct (stack or heap), +zero-initializes it, and checks `code` after the call: ```c typedef struct weaveffi_error { @@ -28,104 +36,285 @@ typedef struct weaveffi_error { const uint8_t* payload_ptr; size_t payload_len; } weaveffi_error; + +void weaveffi_error_set(weaveffi_error* err, int32_t code, const char* message); void weaveffi_error_clear(weaveffi_error* err); +void weaveffi_error_free(weaveffi_error* err); ``` -Typical C usage: +- `code == 0` is success; `message` and `payload_ptr` are null. +- A non-zero `code` means failure. `message` is a producer-allocated, + NUL-terminated UTF-8 string, and `payload_ptr`/`payload_len` hold the + matched error code's `fields:` serialized in the + [value-buffer format](value-buffers.md#structured-errors), or null and + zero for a code without fields. Both belong to the caller until it calls + `weaveffi_error_clear`, which frees the message and the payload, nulls + both pointers, and resets `code` to `0`. Clearing an already-clear struct + is a no-op. +- Interior NUL bytes are stripped from messages on the producer side so + `message` is always a well-formed C string. + +Typical C usage against the `kvstore` sample: ```c -struct weaveffi_error err = {0}; -int32_t sum = weaveffi_calculator_add(3, 4, &err); -if (err.code) { fprintf(stderr, "%s\n", err.message ? err.message : ""); weaveffi_error_clear(&err); } +weaveffi_error err = {0}; +int64_t n = weaveffi_kv_Store_count(store, &err); +if (err.code != 0) { + fprintf(stderr, "count failed (%d): %s\n", err.code, err.message ? err.message : ""); + weaveffi_error_clear(&err); +} ``` -Notes: -- The default unspecified error code used by the runtime is `-1`. -- `weaveffi_error_clear` is idempotent: it frees the message and the payload - and nulls both pointers, so clearing an already-cleared struct is safe. -- Module error domains declare their own codes in the IDL; see the - [Error Handling Guide](../guides/errors.md) for the typed error model, - including the Throws versus Trap interpretation of non-zero codes. +### Domain codes and runtime codes + +Positive codes are the module's declared [error domain](idl.md#error-domain); +the C header emits one enum constant per code +(`weaveffi_kv_KvError_KeyNotFound = 1001`). Negative codes belong to the +runtime and are the same in every module: + +| Code | Constant | Meaning | +|------|----------------------|------------------------------------------------------------------------------------------------| +| `-1` | `GENERIC_ERROR_CODE` | an untyped producer error (a Rust `Result` or any error type without a domain code) | +| `-2` | `PANIC_ERROR_CODE` | the producer panicked; `message` carries the panic text | +| `-3` | `MARSHAL_ERROR_CODE` | an argument couldn't be lifted: null where a value was required, non-UTF-8 text, an out-of-range enum, a malformed value buffer | +| `-4` | `FOREIGN_ERROR_CODE` | a consumer callback-interface implementation raised; `message` carries the foreign error text | + +How a wrapper interprets a non-zero code depends on the callable's +`throws` flag, and every generator follows the same rule +(`weaveffi_core::plan::ErrorStrategy`). On a `throws: true` callable a +positive code is a typed domain error surfaced through the language's +normal error channel (a Swift `throw`, a Python exception subclass, a Go +`error`). On a callable without `throws`, and for every negative code on +any callable, the failure is a programming error: the wrapper traps (a +branded `WeaveFFIError`/`WeaveFFIException`, a Go `panic`, a Swift +`fatalError`) and never dresses it up as a domain error. See the +[Error Handling guide](../guides/errors.md#throws-versus-trap). + +### `weaveffi_error_set`: the consumer's write channel + +Only one situation asks a consumer to *write* an error: a callback-interface +method that fails. The vtable entry receives an `out_err` the producer +owns; the consumer must not store a pointer from its own allocator there, +because the producer will free `message` with the Rust allocator. Instead it +calls `weaveffi_error_set(out_err, -4, "what went wrong")` with a borrowed +message, which the producer copies. See +[Callback interfaces](#callback-interfaces) below. + +### `weaveffi_error_free`: heap-boxed async errors + +An async completion callback receives `weaveffi_error* err` rather than +filling a caller-owned slot: the producer heap-allocates the struct so the +consumer can read it after the callback returns. A non-null `err` is owned +by the consumer, who copies `code`, `message`, and the payload, then calls +`weaveffi_error_free(err)` exactly once; it releases the message, the +payload, and the box. Never call `weaveffi_error_clear` alone on an async +error (it leaks the box) and never call `weaveffi_error_free` on a +stack-allocated `out_err`. ## Strings and bytes -Returned strings are owned by Rust and must be freed by the caller: +A `string` parameter is a borrowed `const char*` and a `bytes` parameter a +borrowed `(const uint8_t* {name}_ptr, size_t {name}_len)` pair; the caller +keeps them alive until the symbol returns and frees them however it +allocated them. A returned `string` is a producer-allocated `const char*` +released with `weaveffi_free_string`; returned `bytes` come back as +`const uint8_t*` plus a `size_t* out_len` out-parameter and are released +with `weaveffi_free_bytes(ptr, len)` using that exact length: + +```c +void weaveffi_free_string(const char* ptr); +void weaveffi_free_bytes(uint8_t* ptr, size_t len); +``` ```c -const char* s = weaveffi_calculator_echo(msg, &err); -// ... use s ... -weaveffi_free_string(s); +size_t len = 0; +const uint8_t* value = weaveffi_kv_Store_get(store, "user:1", &len, &err); +if (err.code == 0) { + /* decode or copy value[0..len) before freeing it */ + weaveffi_free_bytes((uint8_t*)value, len); +} ``` -Returned bytes include a separate out-length parameter and must be freed by the caller: +Both free functions accept null as a no-op. Passing a pointer to +`weaveffi_free_string` that did not come from the producer (or freeing a +returned buffer with the consumer's own allocator) is undefined behavior. + +## Value buffers + +Records, rich enums, optionals (except `Interface?`), lists, and maps cross +the boundary by value as serialized [value buffers](value-buffers.md). +Ownership is exactly the `bytes` rule: a buffered parameter is a borrowed +`(ptr, len)` pair the caller encodes, keeps alive for the call, and frees +itself; a buffered return is a producer-allocated `(ptr, out_len)` pair the +consumer decodes and then releases with `weaveffi_free_bytes`. + +Two extra rules apply when a buffer contains **objects**. Each object token +inside the encoding carries one strong reference, so a consumer that +encodes an object it holds must write a freshly cloned pointer +(`{tag}_clone(obj)`), never the pointer its own wrapper still owns; and a +buffer that carries objects is consumed exactly once, because decoding it +adopts those references. Generated wrappers encode a fresh buffer per call +and never reuse one. If decoding fails partway, the reader releases every +token it already adopted. See +[Objects in value buffers](value-buffers.md#objects). + +## Objects + +An interface `Name` in module path `p` is an opaque `{prefix}_{p}_{Name}` +tag with two lifecycle symbols the IDL never declares: ```c -size_t out_len = 0; -const uint8_t* buf = weaveffi_module_fn(/* params ... */, &out_len, &err); -// ... copy data from buf ... -weaveffi_free_bytes((uint8_t*)buf, out_len); +weaveffi_kv_Store* weaveffi_kv_Store_clone(const weaveffi_kv_Store* self); +void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self); ``` -Relevant declarations: +Objects are **reference counted by the producer**; a `{tag}*` is one strong +reference. `_clone` returns a second strong reference to the same object +(the pointer value is unchanged), `_destroy` releases one, and the object +is dropped when the last reference goes. Both accept null as a no-op, and +`_destroy` never unwinds into C even if the object's destructor panics. + +The invariant every generated wrapper maintains is **one strong reference +per wrapper**: a wrapper object (a Swift class instance, a Python object, a +Kotlin `AutoCloseable`, a C++ RAII class, and so on) owns exactly one +reference, which it acquired either by adopting a returned pointer or by +calling `_clone`, and releases exactly once through the language's disposal +idiom, with a finalizer or `Cleaner` as a backstop where the runtime has +one. Two wrappers may point at the same object; each still owes its own +`_destroy`. + +Ownership by position (the normative table is in +[C ABI Contract: Objects](abi.md#objects-interfaces)): + +- **Parameter** (`Store`, `Store?`): borrowed. Pass the pointer you hold; + the producer clones internally if it retains the object. Null is "none" + for `Store?`. +- **Return, async result, iterator element**: one strong reference + transfers to you. Adopt it (wrap it) and eventually `_destroy` it. A + method such as `share` that returns the receiver itself hands back the + same pointer value with the count bumped; both references must be + destroyed. +- **Callback-interface method parameter**: one strong reference transfers + to you (the slot is `{tag}*`, not `const`). Adopt it or `_destroy` it + before returning; dropping it on the floor leaks. +- **Inside a value buffer**: the token carries one reference; the reader + adopts it. Writers clone before encoding. + +Because the count lives in the producer, an in-flight async call and any +producer-side retention hold their own references, so destroying a wrapper +while a call is pending or while the producer still uses the object is +safe. ```c -void weaveffi_free_string(const char* ptr); -void weaveffi_free_bytes(uint8_t* ptr, size_t len); +weaveffi_kv_Store* a = weaveffi_kv_Store_open("/tmp/db", &err); +weaveffi_kv_Store* b = weaveffi_kv_Store_share(a, &err); /* same object, count 2 */ +weaveffi_kv_Store* c = weaveffi_kv_Store_clone(a); /* count 3, no out_err */ +weaveffi_kv_Store_destroy(c); +weaveffi_kv_Store_destroy(b); +weaveffi_kv_Store_destroy(a); /* dropped here */ ``` -## Buffered values +## Callback interfaces -Records, rich enums, optionals (except `Interface?`), lists, and maps cross -the boundary by value as serialized -[value buffers](value-buffers.md). A buffered parameter is a borrowed -`(const uint8_t* {name}_ptr, size_t {name}_len)` pair the caller keeps -alive for the duration of the call and remains responsible for freeing. A -buffered return follows the bytes convention exactly: the producer -allocates the encoding, hands it back as the return value plus a -`size_t* out_len` out-parameter, and the caller releases it with -`weaveffi_free_bytes(ptr, len)` after decoding. +A callback interface is the one value the **consumer** owns and the +**producer** borrows. A parameter of callback-interface type lowers to +`void* {name}_ctx, const {tag}_vtable* {name}_vtable`. The consumer supplies +`ctx` (generated wrappers use a handle-table key that keeps the +implementing object alive, never a raw GC pointer) and a process-wide static +vtable with one entry per method plus a trailing `void (*free)(void* ctx)`. + +The producer may call any entry any number of times, from any thread, until +it calls `free(ctx)` exactly once; after that it never touches `ctx` again. +The producer holds the pair behind a reference count of its own, so it may +clone the callback freely (an `Arc` in Rust); `free` fires when +the last clone drops, which may be long after the call that passed it or +never, if the producer keeps it until process exit. + +Inside a method call the usual families apply with these directions: +strings, bytes, and buffers arriving as arguments are borrowed for the +duration of the dispatch (the consumer copies or decodes them before +returning and frees nothing); object arguments transfer one strong +reference the consumer adopts; the return is a direct value written +straight into the C return, so nothing allocated by the consumer ever +crosses back. + +### Foreign errors + +Callback methods can't `throws`, but a consumer implementation can still +fail. The vtable entry's trailing `weaveffi_error* out_err` is the channel: +the trampoline catches the exception, calls +`weaveffi_error_set(out_err, -4, message)` with a borrowed message (the +producer copies it), and returns a default value. It must never let an +exception unwind through the C frame. The producer then aborts the call it +was making, and the original caller observes `FOREIGN_ERROR_CODE` with the +consumer's message in `out_err`, exactly as it would observe a producer +panic. On the Rust side the abort is an unwind through the producer's +frames to the exported thunk, so producer code treats every callback call +as potentially panicking; see +[C ABI Contract: Callback interfaces](abi.md#callback-interfaces). ## Iterators -An `iter` return yields an opaque iterator handle. Each `_next` call -writes an element the caller now owns: free string elements with -`weaveffi_free_string` and buffered elements (records, rich enums, -composites) with `weaveffi_free_bytes` after decoding; by-value elements -need nothing. Call the iterator's `_destroy` exactly once, whether -iteration ran to exhaustion or was abandoned early. - -## Async completion callbacks - -Everything passed to an async completion callback is owned by the -consumer. Result buffers (strings, bytes, and serialized value buffers -for buffered results) are copied or decoded, then released with -`weaveffi_free_string` or `weaveffi_free_bytes`. Owned interface -results transfer ownership of the object pointer, which the consumer -must eventually `_destroy`. A non-null `err` is heap-boxed: copy its -code, message, and payload, then release the box exactly once with -`weaveffi_error_free` (which frees the message, the payload, and the -box itself). - -## Handles and interfaces - -Untyped opaque resources are represented as `weaveffi_handle_t` (64-bit). -Treat them as tokens; their lifecycle APIs are defined by your module. -Interface objects cross the boundary as typed opaque pointers -(`weaveffi_kv_Store*`): constructors and methods take `out_err`, methods take -the receiver as their leading argument, and the `_destroy` symbol frees the -instance exactly once. - -## Language wrappers - -- Swift: the generated wrapper automatically clears errors and frees returned - strings; a `throws: true` function throws the module's typed error enum, and - a non-throwing function traps on a poisoned error slot. -- Node: the generated `weaveffi_addon.c` clears errors and frees returned - strings; the JS loader prefers the node-gyp output - (`build/Release/weaveffi.node`), honors a `WEAVEFFI_ADDON` path override, and - falls back to a prebuilt `index.node` next to it. - -## C-string safety - -When constructing C strings, interior NUL bytes are sanitized on the Rust side to maintain -valid C semantics. +An `iter` return yields an opaque iterator handle `{IterTag}*`. Each +`int32_t {IterTag}_next(iter, , out_err)` writes one element the +caller now owns and returns `1`, or returns `0` on exhaustion. Elements +follow the return rules of their family: free a string element with +`weaveffi_free_string`, a bytes or buffered element with +`weaveffi_free_bytes` after decoding, adopt an object element, and do +nothing for a direct element. Call `{IterTag}_destroy(iter)` exactly once, +whether iteration ran to exhaustion or was abandoned early. Each `next` +carries `out_err` and follows the owning function's error strategy. + +## Async completions + +An `async` symbol lowers to a `{sym}_async` launcher and a `{sym}_callback` +typedef. The launcher returns immediately; the callback fires exactly once, +from an arbitrary producer thread. Everything it receives is owned by the +consumer: a string result is released with `weaveffi_free_string`, a bytes +or buffered result with `weaveffi_free_bytes`, an object result is adopted, +and a non-null `err` is heap-boxed and released with `weaveffi_error_free`. +The `void* context` you passed to the launcher comes back untouched. +Wrappers hop back to their own scheduler before touching consumer state. + +Panics inside the producer's future are caught and reported through the +callback as `PANIC_ERROR_CODE`, so the callback fires even when the +producer is buggy. The producer runs futures on its configured spawner +(`weaveffi::set_spawner`); the default drives each future on a dedicated +thread. + +## Cancel tokens + +A `cancellable` async launcher takes a `weaveffi_cancel_token*` before the +callback and context. The consumer creates it, may cancel it from any +thread, and destroys it after the completion callback has fired (the +producer only reads the atomic flag, never frees the token): + +```c +weaveffi_cancel_token* weaveffi_cancel_token_create(void); +void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token); +bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token); +void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token); +``` + +Passing null as the token means "never cancelled". Cancellation is +cooperative: the producer polls the token at safe points and returns early +(typically with a domain error) when it observes the flag. Every function +above treats null as a no-op. + +## ABI revision check + +Every producer exports `uint32_t weaveffi_abi_version(void)` (`2` for this +revision) and every generated consumer embeds the revision it was built for. +Consumers that can do so cheaply compare the two at load time and refuse to +run against a producer built for another revision (Python raises +`ImportError`, Ruby `LoadError`, Go panics in `init`, and so on), so a +layout mismatch is never a garbled buffer later. A hand-written C consumer +should do the same before calling anything else. + +## Non-default prefixes + +Runtime symbols always keep the `weaveffi_` spelling in the producer. When +a project configures another `c_prefix`, the generated header `#define`s +each prefixed runtime name (`{prefix}_error`, `{prefix}_free_string`, +`{prefix}_error_set`, and so on) onto its canonical symbol, so consumer +code may use either spelling. diff --git a/docs/src/reference/naming.md b/docs/src/reference/naming.md index 11ab38f7..e2c4a08e 100644 --- a/docs/src/reference/naming.md +++ b/docs/src/reference/naming.md @@ -1,86 +1,311 @@ -## Naming and Package Conventions +# Naming and Package Conventions -This guide standardizes how we name the Weave projects, repositories, packages, modules, and identifiers across ecosystems. +This page has two halves. The first is the naming policy for the WeaveFFI +project itself: brand names, repository slugs, and package names across +registries. The second is the naming policy the **generators** apply to +the identifiers they emit from your IDL: C symbols, wrapper classes, +functions, error types, and the Kotlin package. Every rule in the second +half was checked against the output of `weaveffi generate` for the +`kvstore` and `events` samples. + +## Project names ### Human-facing brand names (prose) -- Use condensed names in sentences and documentation: - - WeaveFFI - - WeaveHeap +Use the condensed names in sentences and documentation: WeaveFFI, +WeaveHeap. The brand stem is spelled `WeaveFFI` with an uppercase `FFI` +everywhere, including in generated code (`WeaveFFIError`, +`WeaveFFIException`), never the case-converter's `Weaveffi`. ### Repository and package slugs (URLs and registries) -- Use condensed lowercase slugs for top-level repositories: - - GitHub: `weaveffi`, `weaveheap` (repos: `weavefoundry/weaveffi`, `weavefoundry/weaveheap`) +- Use condensed lowercase slugs for top-level repositories: GitHub + `weavefoundry/weaveffi`, `weavefoundry/weaveheap`. +- Use hyphenated slugs for subpackages and components, prefixed with the + top-level slug: `weaveffi-core`, `weaveffi-ir`, `weaveffi-gen-kotlin`. +- Planned package names (not yet published): crates.io `weaveffi`, + `weaveffi-core`, `weaveffi-ir`, and so on; npm `@weavefoundry/weaveffi`; + PyPI `weaveffi`; SPM repo slug `weaveffi`. -- Use hyphenated slugs for subpackages and components, prefixed with the top-level slug: - - Examples: `weaveffi-core`, `weaveffi-ir`, `weaveheap-core` +Rationale: condensed top-level slugs unify handles across registries and +are ergonomic to type; hyphenated subpackages remain idiomatic and map +cleanly to ecosystems that normalize to underscores or CamelCase. -- **Planned package names** (not yet published): - - crates.io: `weaveffi`, `weaveffi-core`, `weaveffi-ir`, etc. - - npm: `@weavefoundry/weaveffi` - - PyPI: `weaveffi` - - SPM (repo slug): `weaveffi` +### Code identifiers by ecosystem -Rationale: condensed top-level slugs unify handles across registries and are ergonomic to type; hyphenated subpackages remain idiomatic and map cleanly to ecosystems that normalize to underscores or CamelCase. +- Rust: hyphenated subcrates on crates.io (`weaveffi-core`), imported with + underscores (`weaveffi_core`); the facade crate is `weaveffi`. Modules + and paths are snake_case; types, traits, and enums are CamelCase. +- Swift: package products and modules are UpperCamelCase (`WeaveFFI`); + the repo slug stays condensed and the SPM product provides the CamelCase + surface. +- Java and Kotlin: group ID and package base are reverse-DNS lowercase + (`com.weavefoundry.weaveffi`); artifact IDs are condensed at the top level + (`weaveffi`) and hyphenated for sub-artifacts (`weaveffi-android`); class + names are UpperCamelCase (`WeaveFFI`). +- JavaScript and TypeScript: scope plus condensed name for the top-level + package (`@weavefoundry/weaveffi`), hyphenated for subpackages + (`@weavefoundry/weaveffi-core`). +- Python: PyPI name condensed at the top level (`weaveffi`), hyphenated for + subpackages (`weaveffi-core`); import as `weaveffi` and `weaveffi_core`. +- C and CMake: target and library names are snake_case (`weaveffi`, + `weaveffi_core`); includes are directory based (`#include `). -### Code identifiers by ecosystem +### Writing guidelines -- Rust - - Crates: hyphenated subcrates on crates.io (e.g., `weaveffi-core`), imported as underscores (e.g., `weaveffi_core`). Top-level crate (if any): `weaveffi`. - - Modules/paths: snake_case. - - Types/traits/enums: CamelCase (e.g., `WeaveFFI`). +- In prose, prefer the condensed brand names. +- In code snippets, follow the host language conventions above. +- For cross-language docs, show both the repo or package slug and the + language-appropriate identifier on first mention: "Install `weaveffi` + (import as `weaveffi`, Swift module `WeaveFFI`)." +- New crates and packages follow the condensed-top-level plus + hyphenated-subpackage pattern (`weaveffi-*`, `weaveheap-*`). Avoid + hyphenated top-level slugs such as `weave-ffi`. -- Swift / Apple platforms - - Package products and modules: UpperCamelCase (e.g., `WeaveFFI`, `WeaveHeap`). - - Keep repo slug condensed; SPM product name provides the CamelCase surface. +## Generated identifiers -- Java / Kotlin (Android) - - Group ID / package base: reverse-DNS, all lowercase (e.g., `com.weavefoundry.weaveffi`). - - Artifact ID: top-level condensed (e.g., `weaveffi`); sub-artifacts hyphenated (e.g., `weaveffi-android`). - - Class names: UpperCamelCase (e.g., `WeaveFFI`). +The generators derive every emitted name from three inputs: the IDL name, +the module path, and the target's casing idiom. The rules below are what +the shared model in `weaveffi-core` decides once for all targets +(`weaveffi_core::utils`, `weaveffi_core::errors`, and the ABI slot +assignment in `weaveffi_core::model`), followed by each target's casing. +IDL names are expected to be `snake_case` for modules, functions, +parameters, and fields and `PascalCase` for types, variants, and error +codes; the validator only enforces that they are identifiers, so a name +written in another style is re-cased on a best-effort basis. -- JavaScript / TypeScript (Node, bundlers) - - Package name: scope + condensed for top-level, hyphenated for subpackages (e.g., `@weavefoundry/weaveffi`, `@weavefoundry/weaveffi-core`). - - Import alias: flexible, prefer `WeaveFFI` in examples when using default exports or named namespaces. +### C ABI symbols -- Python - - PyPI name: top-level condensed (e.g., `weaveffi`); subpackages hyphenated (e.g., `weaveffi-core`). - - Import module: condensed for top-level (e.g., `import weaveffi`); underscores for hyphenated subpackages (e.g., `import weaveffi_core`). +The C names are normative in the [C ABI Contract](abi.md); this is the +summary. `{prefix}` is `weaveffi` unless a project sets `c_prefix`, and +`{path}` is the module path joined with underscores (`kv`, `kv_stats`). -- C / CMake - - Target/library names: snake_case (e.g., `weaveffi`, `weaveffi_core`). - - Header guards / include dirs: snake_case or directory-based (e.g., `#include `). +| Declaration | C spelling | `kvstore` example | +|--------------------------------------|----------------------------------------------|---------------------------------------------------| +| free function `f` in module `{path}` | `{prefix}_{path}_f` | `weaveffi_kv_stats_get_stats` | +| interface `T` (opaque tag) | `{prefix}_{path}_T` | `weaveffi_kv_Store` | +| interface member `m` (ctor, method, static) | `{tag}_m` | `weaveffi_kv_Store_open`, `weaveffi_kv_Store_get` | +| implicit lifecycle | `{tag}_clone`, `{tag}_destroy` | `weaveffi_kv_Store_clone`, `weaveffi_kv_Store_destroy` | +| callback interface `L` (vtable type) | `{prefix}_{path}_L_vtable` | `weaveffi_kv_EvictionListener_vtable` | +| callback parameter `p` | `void* p_ctx, const {vtable}* p_vtable` | `listener_ctx`, `listener_vtable` | +| C-style enum `E` and variant `V` | `{prefix}_{path}_E`, `{prefix}_{path}_E_V` | `weaveffi_kv_EntryKind_Volatile` | +| error domain `D` and code `C` | `{prefix}_{path}_D` enum, `{prefix}_{path}_D_C` | `weaveffi_kv_KvError_KeyNotFound` | +| iterator returned by `f` | `{owner}_{PascalF}Iterator` tag with `_next` and `_destroy` | `weaveffi_kv_Store_ListKeysIterator` | +| async function `f` | `{sym}_async` launcher, `{sym}_callback` typedef | `weaveffi_kv_Store_compact_async` | +| `bytes` or buffered parameter `p` | `p_ptr`, `p_len` | `value_ptr`, `value_len` | +| out-length of a returned buffer | `out_len` | | +| error out-parameter | `out_err` | | -### Writing guidelines +The iterator `{owner}` is `{prefix}_{path}` for a free function and +`{prefix}_{path}_T` for a method or static of interface `T`; the function +name is re-cased to PascalCase (`list_keys` becomes `ListKeys`). Types are +never re-cased: the IDL spelling of `Store`, `EntryKind`, and `KeyNotFound` +appears verbatim in every C symbol. A cross-module type reference resolves +to its owner's path, so a `stats` function taking the parent module's +`Store` still spells the parameter `const weaveffi_kv_Store*`. -- In prose, prefer the condensed brand names: "WeaveFFI", "WeaveHeap". -- In code snippets, follow the host language conventions above. -- For cross-language docs, show both the repo/package slug and the language-appropriate identifier on first mention, e.g., "Install `weaveffi` (import as `weaveffi`, Swift module `WeaveFFI`). For subpackages, install `weaveffi-core` (import as `weaveffi_core`)." +Runtime symbols (`weaveffi_error`, `weaveffi_error_set`, +`weaveffi_error_clear`, `weaveffi_error_free`, `weaveffi_free_string`, +`weaveffi_free_bytes`, `weaveffi_abi_version`, and the four +`weaveffi_cancel_token_*` functions) always keep the `weaveffi_` spelling +in the producer. Under a non-default prefix the generated header adds +`#define {prefix}_{name} weaveffi_{name}` aliases for each of them, so +consumer code may use either spelling. + +Because free functions and interface members share `{prefix}_{path}_`, a +free function named `Store_get` next to a `get` method on `Store` is a +validation error (`AbiSymbolCollision`), as is a free function named +`Store_clone` or `Store_destroy`. + +### Functions, methods, and parameters + +Free functions, methods, statics, constructors, and parameters are re-cased +to the target's idiom: + +| Target | Callables and parameters | Example (`get_stats`, `open_many`) | +|---------------|--------------------------|------------------------------------------------| +| C, C++, Python, Ruby | `snake_case` (unchanged) | `get_stats`, `open_many` | +| Swift, Kotlin, Dart, Node, Wasm | `camelCase` | `getStats`, `openMany` | +| Go, .NET | `PascalCase` | `GetStats`, `OpenMany` | + +A free function keeps its bare name by default. Every target except `c`, +`cpp`, and `wasm` has a `strip_module_prefix` option (see +[Project Configuration](../guides/config.md)); setting it to `false` +prepends `{module}_` before re-casing, giving `kv_stats_get_stats`, +`kvStatsGetStats`, or `KvStatsGetStats`. + +Where a free function lives depends on the target's notion of a +namespace. Python, Ruby, C++, Dart, Node, and Wasm emit module-level +functions (Ruby's live on the package module, `Kvstore.get_stats`). Swift +nests them in one `enum` per module path segment (`Kv.Stats.getStats`). +.NET puts them on a static class per module whose name is the PascalCase +module path (`Kv.GetStats`, `KvStats.GetStats`). Kotlin puts every free +function on the companion object of a single `WeaveFFI` holder class +(`WeaveFFI.getStats(...)`). Go exports them at package level. + +Constructors follow the interface: a constructor named `new` becomes the +canonical constructor where the target has one (Swift `init`, Python +`__init__`, Ruby `initialize`, a C# or C++ constructor, a Dart `factory +EventBus()`, JavaScript `new EventBus()`, and in Kotlin a companion +`operator fun invoke()` so `EventBus()` reads like a constructor); every +other constructor becomes a static factory in the target's casing +(`Store.open`, `Store.openMany`, `Store.Open`). Go has no constructors, so +each becomes a package-level function named `{PascalCtor}{Type}` +(`NewEventBus`, `OpenStore`); Dart uses a named constructor +(`Store.open`). + +### Records and enums + +Struct and rich-enum names are kept verbatim (`Entry`, `StoreInfo`, +`Shape`) in every target; each is an idiomatic value type (a Kotlin `data +class`, a Swift `struct`, a Go `struct`, a .NET `sealed class`, a +TypeScript `interface`, a Python class with annotated attributes). +Fields are re-cased like parameters (`created_at` becomes `createdAt` in +Swift, Kotlin, Dart, and JavaScript and `CreatedAt` in Go and .NET). + +C-style enum names are kept verbatim. Variant casing follows each target's +enum idiom: + +| Target | Variant of `EntryKind { Volatile }` | +|-----------------------------------------|-------------------------------------| +| C, C++, Kotlin, Python, .NET, Node, Wasm, Dart | `Volatile` | +| Swift | `.volatile` (lowerCamelCase case) | +| Go | `EntryKindVolatile` (type-prefixed constant) | +| Ruby | `EntryKind::VOLATILE` (module constant) | + +### Interfaces + +An interface `Store` is a class named `Store` in every target (a Swift +`final class`, a Kotlin `class ... : AutoCloseable`, a Python class, a Ruby +class over an `FFI::AutoPointer`, a Go `struct` used through `*Store`, a +.NET `class Store : IDisposable`, a Dart `class Store implements +Finalizable`, a C++ RAII class with copy semantics that call `_clone`, a +TypeScript class). Methods and statics are re-cased like free functions +and attached to the class; statics use the target's static idiom +(`static func`, `companion object`, `@staticmethod`, `def self.`, C# +`static`, and package-level functions in Go). + +The wrapper's disposal method is named by the target's convention, never +by the IDL: `close()` in Kotlin, Python, Dart, Node, and Wasm (also +`Symbol.dispose` in JavaScript and `AutoCloseable` in Kotlin), `Close()` +in Go, `Dispose()` in .NET, and the destructor or `deinit` in C++ and +Swift. Ruby releases through the `AutoPointer` and exposes `close`. No +target exposes `clone`; copying a wrapper (C++ copy constructor, or simply +holding two references) is the only way to get a second reference from the +consumer side. + +### Callback interfaces + +A callback interface `EvictionListener` becomes the target's "abstract +method set" type, named after the IDL with at most one idiomatic prefix: + +| Target | Declaration | +|----------|----------------------------------------------------------| +| C | `weaveffi_kv_EvictionListener_vtable` struct | +| C++ | `class EvictionListener` with pure virtual methods | +| Swift | `protocol EvictionListener` | +| Kotlin | `interface EvictionListener` | +| Python | `class EvictionListener(abc.ABC)` | +| Ruby | `module EvictionListener` (mixin with `NotImplementedError` stubs) | +| Go | `type EvictionListener interface` | +| .NET | `interface IEvictionListener` (C#'s `I` prefix) | +| Dart | `abstract class EvictionListener` | +| Node, Wasm | `interface EvictionListener` in the `.d.ts` | + +Methods are re-cased like other callables (`on_evict` becomes `onEvict`, +`OnEvict`, or stays `on_evict`). No lifecycle name is exposed to the +consumer: the vtable's trailing `free` entry and the `ctx` handle are +generated glue, and the consumer only ever hands over an implementation. + +### Error domains and codes + +The base error type is branded, and every target uses one of two brands +from `weaveffi_core::errors`: `WeaveFFIError` where the ecosystem's errors +end in `Error` (Swift, Python, TypeScript, C++, Go) and +`WeaveFFIException` where they end in `Exception` (Kotlin, .NET, Dart). +Ruby is the one exception: its base class is `Error` nested in the package +module (`Kvstore::Error`), because the module already provides the brand. + +A domain `KvError` with codes `KeyNotFound` and `IoError` yields: + +| Target | Domain type | Per-code name | +|---------|----------------------------------|--------------------------------------------------| +| C, C++ header enum | `weaveffi_kv_KvError` | `weaveffi_kv_KvError_KeyNotFound` constants | +| C++ | `class KvError : WeaveFFIError` | `class KeyNotFoundError : KvError`, `class IoError` | +| Swift | `enum KvError: Error` | `case keyNotFound(message:)`, `case ioError(message:)` | +| Kotlin | `sealed class KvException : WeaveFFIException` | nested `KvException.KeyNotFound`, `KvException.IoError` | +| Python | `class KvError(WeaveFFIError)` | `class KeyNotFound(KvError)`, also reachable as `KvError.KeyNotFound` | +| Ruby | `class KvError < Kvstore::Error` | nested `KvError::KeyNotFound`, `KvError::IoError` | +| Go | `type KvError struct` | `KvErrorKeyNotFound` and `KvErrorIoError` code constants | +| .NET | `class KvException : WeaveFFIException` | `KvException.KeyNotFound` code constants | +| Dart | `class KvException extends WeaveFFIException` | `class KeyNotFoundException`, `class IoException` | +| Node, Wasm | `class KvError extends WeaveFFIError` | `class KeyNotFoundError`, `class IoError` | + +The rules behind the table: + +- `type_name(raw, suffix)` re-cases a name to PascalCase and appends the + suffix exactly once: `KEY_NOT_FOUND` with `Error` is `KeyNotFoundError`, + and `AlreadyError` stays `AlreadyError`. C++, Node, and Wasm use it with + `Error`; Dart uses it with `Exception`. +- `exception_type_name(raw)` swaps a trailing `Error` stem for `Exception` + instead of stacking them: `KvError` becomes `KvException` (Kotlin, .NET, + Dart), `Failure` becomes `FailureException`, and a domain named just + `Error` falls back to the brand. +- `pascal(raw)` is the suffix-free PascalCase form used where codes are + nested cases or constants (Kotlin, Ruby, Python, .NET, Go). +- Swift lowerCamelCases the code into an enum case (`keyNotFound`). + +The upshot for IDL authors: write PascalCase code names and let the +generator add or replace suffixes. Writing `KeyNotFoundError` as the code +name produces `KeyNotFoundError` in C++ and `KeyNotFoundException` in Dart +(the stem is normalized), but `KeyNotFoundError` as the Kotlin nested class +and `keyNotFoundError` as the Swift case, which is why the samples leave +the suffix off. Code names are unique across the whole API for exactly this +reason: every target flattens them into one namespace. -### Migration guidance +### Package, module, and file names -- New crates and packages should follow the condensed top-level + hyphenated subpackage pattern: - - Rust crates: `weaveffi-*`, `weaveheap-*`. - - npm packages (planned): `@weavefoundry/weaveffi-*`, `@weavefoundry/weaveheap-*`. - - Swift products: UpperCamelCase (e.g., `WeaveFFICore`). -- Prefer condensed top-level slugs. Avoid hyphenated top-level slugs like `weave-ffi`, `weave-heap` going forward. +Ecosystem package names come from `[package] name` in `weaveffi.toml` +(`kvstore` in the samples) and are re-cased per ecosystem; see +[Project Configuration](../guides/config.md#package). The names that +appear in code: -### Examples +| Target | Namespace derived from the package name | +|---------|----------------------------------------------------------------------| +| Swift | module `Kvstore` (PascalCase); free functions under per-module enums | +| Ruby | `module Kvstore` wrapping everything | +| .NET | `namespace Kvstore` | +| C++ | `namespace kvstore` (lowercase) | +| Go | `package kvstore`; the import path comes from `[generators.go] module_path` | +| Python | package directory `kvstore/` containing `weaveffi.py` and `weaveffi.pyi` | +| Kotlin | Gradle `rootProject.name = "kvstore"`; the JVM package comes from config | -- Rust - - Crate: `weaveffi-core` - - Import: `use weaveffi_core::{WeaveFFI};` +### Kotlin package and class names -- Swift (SPM) - - Repo: `weaveffi` - - Package product: `WeaveFFI` - - Import: `import WeaveFFI` +The `kotlin` target is configured under `[generators.kotlin]`: -- Python (planned) - - Package: `weaveffi` - - Import: `import weaveffi as ffi` +```toml +[generators.kotlin] +package = "com.example.kv" # default "com.weaveffi" +strip_module_prefix = true # default +``` -- Node (planned) - - Package: `@weavefoundry/weaveffi` - - Import: `import { WeaveFFI } from '@weavefoundry/weaveffi'` +- **Package.** Every generated Kotlin file starts with `package {package}` + and is written under `src/main/kotlin/{package with dots as slashes}/`, + so the default lands at `src/main/kotlin/com/weaveffi/WeaveFFI.kt`. The + JNI glue is `src/main/cpp/weaveffi_jni.c`, and its exported JNI symbols + are derived from the package (`Java_com_weaveffi_WeaveFFI_...`), so + changing the package regenerates both files consistently. +- **Holder class.** Free functions live on the companion object of + `class WeaveFFI`, together with `setCallbackExceptionHandler`. The + holder's name is fixed; only the package is configurable. +- **Types.** Interfaces, records, enums, callback interfaces, and errors + are top-level declarations in the same package, named as in the tables + above (`Store`, `Entry`, `EntryKind`, `EvictionListener`, `KvException`). + An iterator returned by `list_keys` on `Store` is + `KvStoreListKeysIterator` (`{PascalModule}{Interface}{PascalFn}Iterator`), + implementing `Iterator` and `AutoCloseable`. +- **Runtime glue.** Internal helpers are prefixed `Weave` or `weave` + (`WeaveNativeLibrary`, `WeaveBufferReader`, `weaveCleaner`) and marked + `internal`, so they never collide with an IDL type whose name starts + with `WeaveFFI`. diff --git a/docs/src/reference/value-buffers.md b/docs/src/reference/value-buffers.md index a4a90b7f..fcec0900 100644 --- a/docs/src/reference/value-buffers.md +++ b/docs/src/reference/value-buffers.md @@ -21,8 +21,10 @@ pass by value; strings pass as C strings; `bytes` as a raw pointer plus length; interfaces and iterators as opaque object pointers; `Interface?` as a nullable object pointer; C-style enums as `int32_t`. -Interfaces, iterators, and borrowed views (`str`, `bytes_view`) never appear -*inside* a buffered type; validation rejects them in buffered positions. +Interfaces *may* appear inside a buffered type (a record field, a list +element, a map value, an optional) and encode as an object token; see +[Objects](#objects). Iterators and callback interfaces never appear inside a +buffered type; validation rejects them in buffered positions. ## Encoding @@ -41,7 +43,7 @@ alignment; values are packed back to back. Lengths and element counts are | `f32` | 4 bytes (IEEE 754 bits) | | `f64` | 8 bytes (IEEE 754 bits) | | enum (C-style) | `i32` discriminant | -| `handle` / `handle`| `u64` | +| interface | `u64` object token (one strong reference) | | `string` | `u32` byte length, then UTF-8 bytes (no terminator) | | `bytes` | `u32` length, then raw bytes | | `T?` | 1 flag byte (`0` absent, `1` present), then the value when present | @@ -67,6 +69,31 @@ the complete value. A malformed buffer is a producer/consumer contract violation (both sides are generated from one IDL), so consumers surface it through the same channel as a producer panic, not as a typed domain error. +## Objects + +An interface value inside a buffer is an **object token**: the object's +pointer widened to `u64` (zero-extended on 32-bit targets). A token carries +exactly one strong reference, in either direction. + +- **Writing.** The side that encodes a token must own the reference it + writes. A consumer that holds an object wrapper calls `{tag}_clone` and + writes the returned pointer, keeping its own reference for the wrapper; a + producer writes `Arc::into_raw(arc.clone())`. +- **Reading.** The side that decodes a token adopts the reference: a consumer + wraps the pointer in a new object wrapper whose finalizer will call + `{tag}_destroy`; a producer calls `Arc::from_raw`. +- **Failure.** If decoding fails partway through a buffer, the reader is + responsible for releasing every token it already adopted. The writer keeps + no responsibility once the buffer is handed over. +- **Single use.** Because each token is one reference, an encoding that + contains objects is consumed exactly once: decode it twice and the second + reader adopts references that no longer exist. Generated bindings encode a + fresh buffer per call and never reuse one. + +A zero token is invalid in a non-optional position; `Interface?` inside a +buffer uses the ordinary optional flag byte followed by the token when +present. + ## ABI slots and ownership **Parameters.** A buffered parameter named `v` lowers to two slots: @@ -99,8 +126,10 @@ frees it with `weaveffi_free_bytes` per element. Owned interface results transfer ownership the same way: the callback adopts the object pointer. -**Callback and listener arguments.** Buffered callback arguments are borrowed -`(ptr, len)` pairs valid only for the duration of the dispatch. +**Callback-interface method arguments.** Buffered arguments to a +callback-interface method are borrowed `(ptr, len)` pairs valid only for the +duration of the dispatch; the consumer decodes before returning. Object tokens +inside such a buffer still transfer one strong reference to the consumer. ## Structured errors diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 47309b20..97ac1814 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -1,11 +1,165 @@ # Roadmap -This page is a placeholder. WeaveFFI is in active `0.x` development, and we'll -use this page to share a public roadmap once there's a concrete plan worth -publishing. +WeaveFFI is in active `0.x` development. Schema `0.9.0` and ABI revision 2 +landed the object model the project had been building toward: reference-counted +interfaces that compose with every other type, callback interfaces the consumer +implements, and a pluggable async executor. This page lists what comes next. +Items are marked **planned** (the design is settled and the work is scheduled) +or **exploring** (we want it, but the design has open questions). Nothing here +carries a date; the [CHANGELOG][changelog] is the source of truth for what has +shipped, and [Stability and Versioning](stability.md) explains how releases and +schema versions work. -For now, the [CHANGELOG][changelog] is the source of truth for what has -shipped, and [Stability and Versioning](stability.md) explains how releases -and schema versioning work. +## Callback interfaces + +### Callback methods returning strings, buffers, and objects (planned) + +Today a callback-interface method returns nothing, a scalar, `bool`, or a +C-style enum, because the ABI has no way for a consumer allocation to cross +back into the producer safely: the producer frees with its own allocator, and +eleven languages have eleven allocators. The planned fix is an explicit +allocator contract for callback returns. The consumer will write the result +into producer-owned storage obtained through the runtime (in the spirit of +`weaveffi_alloc`, which the Wasm target already uses), or return a consumer +allocation together with a release function the producer calls once it has +copied the bytes. Once that contract exists, `string`, `bytes`, records, rich +enums, optionals, lists, maps, and objects become valid callback returns on +every target, and `throws` on callback methods (a typed domain error raised by +the consumer, rather than the catch-all `FOREIGN_ERROR_CODE`) follows +naturally. + +### Async callback methods (planned) + +A callback method that returns a future on the consumer side (`async` in +Swift, Python, Kotlin, and JavaScript; a `Task` in .NET; a `Future` in Dart) +needs a completion callback flowing the other way, plus a cancellation story +when the producer drops the future. The vtable shape is straightforward (an +extra completion function and context per async method); the hard part is +making the same producer code work whether the consumer runtime is an event +loop, a thread pool, or the single-threaded Wasm host. This lands after the +allocator contract above, because an async result needs to carry the same +return types a synchronous one does. + +## Type system + +### Generic and trait-object interfaces (exploring) + +WeaveFFI deliberately ships a fixed set of generic shapes (`T?`, `[T]`, +`{K:V}`, `iter`) rather than user-defined generics. Two extensions are under +discussion. The first is *trait-object interfaces*: an interface declared as a +set of methods with more than one producer-side implementation, so a Rust +`Arc` can be returned and each consumer sees one class. The second +is *parameterized interfaces* such as `Cache`, monomorphized per +instantiation in the IDL. The first is likely; the second only if it can be +done without every generator growing type-erasure machinery. + +### Duration and timestamp primitives (exploring) + +Most real APIs carry time. Today producers pass `i64` seconds or milliseconds +and document the unit. A `duration` primitive (nanoseconds as `i64`, mapped to +`Duration`, `TimeInterval`, `TimeSpan`, `timedelta`, `kotlin.time.Duration`, +and so on) and a `timestamp` primitive (a UTC instant) would remove the +ambiguity. The open question is representation: one width for everything, or a +`u64` seconds plus `u32` nanoseconds pair as UniFFI uses. + +## IDL + +### Multi-file IDL imports (planned) + +An API is one document today. Large APIs want to split by module and share +types across files, and a monorepo wants to reference an interface declared by +another package's IDL. The plan is an `imports:` list that resolves relative +paths at parse time, with bare type names remaining unique across the merged +API (as they are today) and `weaveffi diff`, `validate`, and the output cache +tracking every imported file. Annotated Rust already handles this shape +naturally (one crate, many `#[weaveffi::module]` blocks), so the IDL path is +the one catching up. + +## Generators and runtime + +### Per-language support packages (exploring) + +Every generated package inlines its own helper code: the value-buffer codec, +the error hierarchy, the object wrapper base, the callback trampolines. That +keeps consumers free of any WeaveFFI dependency, which is a design principle we +intend to keep. It also means a codec fix ships as a regeneration of every +package. We are exploring extracting the stable runtime portions into optional +per-language support packages (an npm package, a Swift target, a Python wheel, +and so on) that generated code can depend on *if the library author opts in*, +with inlining remaining the default. The conformance harness and the `codec` +sample exist so that this refactor, if it happens, is mechanical. + +### Wasm threads and spawner (exploring) + +On `wasm32-unknown-unknown` the default spawner drives each future inline, +callback-interface methods fire only while a call into the module is on the +stack, and a producer that calls back from a spawned thread can't run at all. +Two paths are being evaluated: a spawner that schedules futures on the JS +microtask queue through `wasm-bindgen-futures`-style glue, and support for +`wasm32` builds with shared memory and Web Workers (`atomics` plus +`bulk-memory`) where a real thread-per-future spawner works. Either would also +let Emscripten mode regain async functions and callback interfaces, which it +currently rejects at generation time. + +### Library naming in bare `generate` output (planned) + +`weaveffi package` names the native library after the package identity on +every target. The bare `weaveffi generate` trees are not yet consistent: Dart +loads `lib.`, while Python, Node (`binding.gyp`), Swift +(`module.modulemap`), and Kotlin (`CMakeLists.txt`) still assume a library +called `weaveffi`, and the bare Kotlin `CMakeLists.txt` doesn't link the JNI +shim against the producer at all (the packaged module does). The plan is to +derive one library base name from the package identity in the binding model +and have every generator use it, so `WEAVEFFI_LIBRARY` overrides stop being +necessary in the tutorials. + +### Cancel tokens on wasm (planned) + +The wasm glue passes a null cancel token for `#[weaveffi::cancellable]` +functions, so JS can't cancel an in-flight async call the way every native +target can (`AbortSignal` is the natural shape). This follows the spawner +work above, since inline completion leaves nothing to cancel today. + +### Kotlin Multiplatform and other targets (exploring) + +The Kotlin generator targets Android and the desktop JVM through JNI. A Kotlin +Multiplatform flavor (Kotlin/Native `cinterop` for iOS and desktop) is the +natural next step. Further targets (Java without Kotlin, PHP, Lua) are +possible because a generator is a self-contained crate implementing +`LanguageBackend`, but none is scheduled. + +## Toward 1.0 + +1.0 means the surfaces listed in [What semver covers](stability.md#what-semver-covers-post-10) +stop changing without a major release. We will cut it when all of the following +hold: + +- **ABI revision 2 has been stable for several minor releases** with no + incompatible change, and the callback-return allocator contract has shipped + (so that 1.0 doesn't immediately need revision 3). +- **Every target passes the full conformance matrix**, including the `codec` + round-trip lane, the async lanes, and the callback-interface lanes, on + Linux, macOS, and Windows in CI. +- **Multi-file imports and the deprecation policy are live**, so a post-1.0 + API can grow and shed surface the way the + [deprecation policy](stability.md#post-10-deprecation-policy) describes. +- **The schema has a migration tool** (`weaveffi migrate`) and + `SUPPORTED_VERSIONS` accepts more than one version. +- **The public Rust API of every published crate has been reviewed** for + items that should be `#[doc(hidden)]` or private before they become a + contract. + +Until then, expect pre-1.0 churn to be batched (one schema bump per minor +release) and documented, and pin the CLI version in CI as +[Stability and Versioning](stability.md) recommends. + +## Contributing to the roadmap + +Open a discussion or issue on +[GitHub](https://github.com/weavefoundry/weaveffi/issues) if one of the +exploring items matters to you, or if something you need isn't listed. Items +move from exploring to planned when a design has been written up and reviewed; +see [CONTRIBUTING.md](https://github.com/weavefoundry/weaveffi/blob/main/CONTRIBUTING.md) +for how proposals work. [changelog]: https://github.com/weavefoundry/weaveffi/blob/main/CHANGELOG.md diff --git a/docs/src/samples.md b/docs/src/samples.md index ae1b8d7b..b406df4e 100644 --- a/docs/src/samples.md +++ b/docs/src/samples.md @@ -1,13 +1,28 @@ # Samples -This repo includes sample projects under `samples/` that showcase end-to-end -usage of WeaveFFI. Every producer is written as safe Rust and annotated with the -`#[weaveffi::module]` family of attributes, so the macro generates its C ABI -(see [The Rust Producer Macro](guides/producer-macro.md)), and every sample -generates its bindings straight from that annotated `src/lib.rs`: there is no -parallel IDL file to keep in sync. Package identity and per-target options -live in each sample's `weaveffi.toml`, which the CLI discovers automatically -next to the crate. +This repo includes eight sample projects under `samples/` that showcase +end-to-end usage of WeaveFFI. Every producer is written as safe Rust and +annotated with the `#[weaveffi::module]` family of attributes, so the macro +generates its C ABI (see [The Rust Producer Macro](guides/producer-macro.md)), +and every sample generates its bindings straight from that annotated +`src/lib.rs`: there is no parallel IDL file to keep in sync. Package identity +and per-target options live in each sample's `weaveffi.toml`, which the CLI +discovers automatically next to the crate. + +Each sample builds as a `cdylib`, ships Rust tests that drive its own exported +C symbols the way a foreign consumer would, and is regenerated in CI with +`weaveffi diff --check` so the committed source and the generators can't drift. + +| Sample | Path | Showcases | +|---------------|-----------------------|--------------------------------------------------------------------------------| +| `calculator` | `samples/calculator` | The smallest module: scalars, strings, one error domain | +| `contacts` | `samples/contacts` | A record, a C-style enum, an interface with CRUD methods, list returns | +| `inventory` | `samples/inventory` | Two modules, cross-module records, an interface owning a collection | +| `shapes` | `samples/shapes` | Rich (algebraic) enums and the full numeric primitive set | +| `async-demo` | `samples/async-demo` | Async functions returning records and lists | +| `events` | `samples/events` | A callback interface, a shared reference-counted object, an iterator | +| `kvstore` | `samples/kvstore` | Every IDL feature, including an object graph and an eviction listener | +| `codec` | `samples/codec` | A round-trip oracle for every value-buffer wire shape, object tokens included | ## Kvstore (kitchen-sink reference) @@ -15,27 +30,38 @@ Path: `samples/kvstore` A production-quality, in-memory key/value store that exercises **every IDL feature WeaveFFI supports** in a single sample. Use this as the canonical -reference when learning the IDL surface or when you need to copy/paste a -real-world pattern for a new generator. +reference when learning the IDL surface or when you need to copy a real-world +pattern for a new generator. **What it demonstrates:** -- A first-class interface (`Store`) with a throwing constructor (`open`), - instance methods, a static (`default_capacity`), and implicit destroy -- A struct (`Entry`) with every primitive: `i64`, `string`, `bytes`, optional - field (`expires_at: i64?`), list field (`tags: [string]`), and map field - (`metadata: {string:string}`), plus per-field doc strings -- A documented enum (`EntryKind` with `Volatile`, `Persistent`, `Encrypted`) +- A reference-counted interface (`Store`) with a throwing constructor + (`open`), instance methods, statics (`default_capacity`, `open_many`, + `total_count`), and the implicit `_clone`/`_destroy` pair +- An **object graph**: `share` returns a second reference to the same store + (`self: Arc` in, `Arc` out), `fork` returns a new store, + `larger(other: Store?) -> Store?` takes and returns an optional object, + `describe -> StoreInfo` returns a record whose `store` field carries the + object itself (and an optional `mirror`), `open_many -> [Store]` returns a + list of objects, and `total_count([Store], StoreInfo?)` takes objects inside + a list and inside an optional record +- A **callback interface** (`EvictionListener`) attached with + `set_eviction_listener` and detached with `clear_eviction_listener`; the + store retains the consumer's implementation, notifies it outside every + lock, and drops it when `on_evict` returns `false` +- A record (`Entry`) with every primitive: `i64`, `string`, `bytes`, an + optional field (`expires_at: i64?`), a list field (`tags: [string]`), and a + map field (`metadata: {string:string}`), plus per-field doc strings +- Documented C-style enums (`EntryKind`, `EvictionReason`) - A documented error domain (`KvError` with `KeyNotFound`, `Expired`, `StoreFull`, `IoError`) and opt-in `throws: true` on the fallible methods -- A module-level callback (`OnEvict`) and listener (`eviction_listener`) -- A streaming iterator return (`list_keys -> iter`) with prefix filter -- A cancellable async method (`compact`, `async: true, cancellable: true`) - that respects a `weaveffi_cancel_token` while reclaiming bytes on a worker - thread -- A deprecated method (`legacy_put`) -- A nested sub-module (`kv.stats`) with its own struct (`Stats`) and a function - that takes a cross-module `Store` parameter +- A streaming iterator return (`list_keys -> iter`) with a prefix + filter +- A cancellable async method (`compact`, `#[weaveffi::cancellable]`) that + checks its `CancelToken` before reclaiming expired entries +- A deprecated method (`legacy_put`, via `#[deprecated(note = ...)]`) +- A nested sub-module (`kv.stats`) with its own record (`Stats`) and a + function that takes the parent module's `Store` by reference - A `weaveffi.toml` with `[generators.]` overrides for `swift.module_name`, `cpp.namespace`, `dotnet.namespace`, `dart.package_name`, `go.module_path`, and `ruby.module_name` @@ -49,8 +75,92 @@ weaveffi generate samples/kvstore/src/lib.rs -o generated ``` The `conformance/` harness ships a kvstore consumer for every language that -opens a `Store`, round-trips entries, drives the async `compact`, and asserts -the typed `KvError` surface; see `conformance/run.sh`. +opens a `Store`, round-trips entries, shares and forks stores, attaches an +eviction listener, drives the async `compact`, and asserts the typed `KvError` +surface; see `conformance/run.sh`. + +## Events (callback interface + shared object) + +Path: `samples/events` + +A publish/subscribe bus and the reference sample for **callback interfaces** +and **object sharing**. The consumer implements `Subscriber`; the bus retains +any number of subscribers, asks each how to `route` a message, delivers +accepted messages, and hands the bus itself back through a callback. + +**What it demonstrates:** + +- A callback interface (`Subscriber`) with three method shapes: `route(topic: + string) -> Delivery` returns a C-style enum, `on_message(message: Message) + -> i64` takes a record and returns a scalar, and `on_attached(bus: + EventBus)` receives an object the consumer adopts +- A reference-counted interface (`EventBus`) whose constructor returns + `Arc` and whose `subscribe` method takes `self: Arc` so it can + hand a strong reference to the subscriber +- Producer-side discipline for calling into consumer code: the bus snapshots + its subscriber list and never holds a lock across a callback, because a + failing subscriber aborts the publishing call with `FOREIGN_ERROR_CODE` +- Consumer `free` semantics: `clear_subscribers` (or destroying the bus) + releases the producer's references and each consumer implementation's + `free` entry runs exactly once +- An async method (`publish_later`), an iterator return (`messages -> + iter`), an optional record return (`last_message -> Message?`), and + a free function taking a callback interface (`route_once`) + +**Build and run tests:** + +```bash +cargo build -p events +cargo test -p events +weaveffi generate samples/events/src/lib.rs -o generated +``` + +The Rust tests build a `Subscriber` vtable by hand, exactly as a generated +binding does, and assert reference counts, `free` calls, and the foreign-error +path. The `conformance/` harness runs an `events` consumer in all eleven +languages. + +## Codec (value-buffer round-trip oracle) + +Path: `samples/codec` + +Every generated binding ships its own encoder and decoder for the +[value-buffer protocol](reference/value-buffers.md). This sample gives the +conformance harness one producer that exercises **every wire shape in both +directions**, so a codec bug in any language shows up as a concrete mismatch +rather than a subtle corruption. + +**What it demonstrates:** + +- `Scalars`: a record with every fixed-width scalar (`i8` through `u64`, + `f32`, `f64`, `bool`) and a C-style enum, using edge values such as + `u64::MAX`, `i64::MIN`, and a non-integer `f64` +- `Composite`: a record with strings (including non-ASCII), bytes, present + and absent optionals, lists, lists of lists, an empty list, string-keyed and + integer-keyed maps, a nested record, a rich enum, a list of rich enums, an + optional rich enum, an optional list, a list of optionals, and a list of + enums +- `Shape`: a rich enum with unit, scalar, mixed, string, and nested-record + variants +- `Holder`: **objects inside buffers** (a required `Token`, an optional one, + and a list of them), with `make_holder`, `sum_holder`, `primary_of`, and + `same_primary` proving that each token carries one strong reference, that a + buffer is decoded exactly once, and that identity survives the round trip +- Three function families per shape: `sample_*` (producer encodes, consumer + decodes), `verify_*` (consumer encodes, producer decodes and fails with + `CodecError::Mismatch` on any difference), and `roundtrip_*` (echo), plus + `describe_*` helpers that render what the producer actually saw + +**Build and run tests:** + +```bash +cargo build -p codec +cargo test -p codec +weaveffi generate samples/codec/src/lib.rs -o generated +``` + +The `conformance/` harness runs the `codec` consumer in all eleven languages; +it is the lane to watch when touching any generator's buffer code. ## Shapes (rich enums + numerics) @@ -69,7 +179,7 @@ how each backend surfaces it as an idiomatic sum type. `i32` tag followed by the active variant's fields - A plain C-style enum (`Channel`) alongside the rich enum, showing both enum flavors in one module -- The new numeric primitives (`f32`, `u8`, `u64`) as variant fields, parameters, +- The numeric primitives `f32`, `u8`, and `u64` as variant fields, parameters, and return types - Functions that take and return a rich enum (`describe`, `scale`) and a list-of-bytes reduction (`sum_bytes(values: [u8]) -> u64`) @@ -84,7 +194,7 @@ weaveffi generate samples/shapes/src/lib.rs -o generated The `conformance/` harness ships a `shapes` consumer for every language that constructs each variant, reads the tag and fields back, and round-trips through -`describe`/`scale`; see `conformance/run.sh`. +`describe`/`scale`. ## Calculator @@ -97,7 +207,8 @@ understanding the basic C ABI contract and the macro workflow. **What it demonstrates:** - Scalar parameters and return values (`i32`) -- String parameters and return values (C string ownership) +- String parameters and return values (borrowed in, producer-owned out, freed + with `weaveffi_free_string`) - The smallest possible typed error surface: a `#[weaveffi::error]` enum (`CalcError`) and one throwing function (`div` returns `Result`) @@ -110,18 +221,16 @@ cargo build -p calculator weaveffi generate samples/calculator/src/lib.rs -o generated ``` -This produces target-specific output under `generated/` (C headers, Swift -wrapper, Android skeleton, Node addon sources, Wasm loader). The -[Calculator tutorial](tutorials/calculator.md) walks through running C, -Node, and Swift consumers against it. +This produces target-specific output under `generated/` for all eleven +languages. The [Calculator tutorial](tutorials/calculator.md) walks through +running C, Node, and Swift consumers against it. ## Contacts Path: `samples/contacts` -A CRUD-style sample with a single module, written as safe Rust and annotated -with `#[weaveffi::module]`. It exercises richer type-system features than the -calculator while writing no `unsafe` glue. +A CRUD-style sample with a single module. It exercises richer type-system +features than the calculator while writing no `unsafe` glue. **What it demonstrates:** @@ -130,7 +239,8 @@ calculator while writing no `unsafe` glue. encode/decode impl - Optional fields (`Option` for the email) - A `#[weaveffi::interface]` (`ContactBook`) with a `new` constructor, - instance methods, and implicit destroy + `&self` methods guarding a `Mutex`, and the implicit `_clone`/`_destroy` + pair - List return types (`Vec` from `ContactBook::list`) - A `#[weaveffi::error]` domain (`ContactsError`) surfaced by the throwing methods via `Result` @@ -142,13 +252,15 @@ cargo build -p contacts weaveffi generate samples/contacts/src/lib.rs -o generated ``` +The `conformance/` harness runs a `contacts` consumer in all eleven languages. + ## Inventory Path: `samples/inventory` A richer, multi-module sample with `products` and `orders` modules, written as safe Rust with two `#[weaveffi::module]` blocks. It exercises cross-module -references and record lists across the macro. +references and record lists. **What it demonstrates:** @@ -171,22 +283,30 @@ cargo build -p inventory weaveffi generate samples/inventory/src/lib.rs -o generated ``` +The `conformance/` harness runs `inventory` consumers in C and Python. + ## Async Demo Path: `samples/async-demo` -Demonstrates the async function pattern using callback-based invocation. Async -functions in the YAML definition get an `_async` suffix at the C ABI layer and -accept a callback + context pointer instead of returning directly. +Demonstrates the async function pattern. An `async fn` export lowers to an +`_async` launcher at the C ABI that accepts a completion callback plus a +context pointer instead of returning directly, and each target wraps it in its +native awaitable. **What it demonstrates:** -- Async function declarations (`async: true` in the YAML) -- Callback-based C ABI pattern (`weaveffi_tasks_run_task_async`) -- Callback type definitions (`weaveffi_tasks_run_task_callback`) -- Batch async operations (`run_batch` processes a list of names sequentially) -- Synchronous fallback functions (`cancel_task` is non-async in the same module) -- Struct return types through callbacks (`TaskResult` delivered via callback) +- Async exports (`pub async fn` under `#[weaveffi::export]`) returning a + record (`run_task -> TaskResult`), a list of records (`run_batch`), and a + scalar (`run_n_tasks`) +- A throwing async function (`run_task` returns + `Result`), delivered as a heap-boxed error the + consumer frees with `weaveffi_error_free` +- Synchronous functions in the same module (`cancel_task`, + `active_callbacks`) +- The default spawner (one thread per future) in action; see the + [Async Functions guide](guides/async.md) for plugging in Tokio with + `weaveffi::set_spawner` **Build and run tests:** @@ -195,72 +315,26 @@ cargo build -p async-demo cargo test -p async-demo ``` -> **Note:** Async functions are fully supported by the validator. This -> sample focuses on the C ABI callback pattern; see the -> [Async Functions guide](guides/async.md) for the per-target async/await -> story. - -## Events - -Path: `samples/events` - -Demonstrates callbacks, event listeners, and iterator-based return types. - -**What it demonstrates:** - -- Callback type definitions (`OnMessage` callback) -- Listener registration and unregistration (`message_listener`) -- Event-driven patterns (sending a message triggers the registered callback) -- Iterator return types (`iter` in the YAML) -- Iterator lifecycle (`get_messages` returns a `GetMessagesIterator`, advanced - with `_next`, freed with `_destroy`) - -**Build and run tests:** - -```bash -cargo build -p events -cargo test -p events -``` - -## Node Addon - -Path: `samples/node-addon` - -An N-API addon crate that loads the calculator's C ABI shared library at runtime -via `libloading` and exposes the functions as JavaScript-friendly `#[napi]` -exports. It shows the hand-rolled alternative to the generated -`weaveffi_addon.c`, which the Node generator now emits for you. - -**What it demonstrates:** - -- Dynamic loading of a `weaveffi_*` shared library from JavaScript -- Mapping C ABI error structs to N-API errors -- String ownership across the FFI boundary (CString in, CStr out, free) - -**Build (requires the calculator library first):** - -```bash -cargo build -p calculator -cargo build -p weaveffi-node-addon -``` +The `conformance/` harness runs an `async-demo` consumer in all eleven +languages, awaiting the results through each target's native idiom. ## End-to-end testing -The `conformance/` directory is the end-to-end regression oracle for -the code generators. Every consumer under `conformance//` -binds through the *generated* wrappers (not the raw C ABI) and asserts -concrete results against the contacts, events, kvstore, and shapes -samples. The `conformance/run.sh` harness builds each producer cdylib, -runs `weaveffi generate` for it, then compiles and runs every -per-(language, sample) consumer: +The `conformance/` directory is the end-to-end regression oracle for the code +generators. Every consumer under `conformance//` binds through the +*generated* wrappers (not the raw C ABI) and asserts concrete results against +the samples: `contacts`, `events`, `kvstore`, `shapes`, `codec`, and +`async-demo` in all eleven languages, plus `inventory` in C and Python. The +`conformance/run.sh` harness builds each producer cdylib, runs +`weaveffi generate` for it, then compiles and runs every per-(language, +sample) consumer: ```bash bash conformance/run.sh ``` -It prints `[OK] {target}` for each consumer that succeeds and reports -a pass/fail summary at the end. Use `ONLY=c-contacts,cpp-contacts` to -run a subset, or `SKIP=go-contacts` to omit individual targets. -Missing toolchains cause the affected target to fail; skip those -explicitly. See the comment block at the top of `conformance/run.sh` -for the per-target prerequisites. +It prints `[OK] {target}` for each consumer that succeeds and reports a +pass/fail summary at the end. Use `ONLY=c-contacts,cpp-contacts` to run a +subset, or `SKIP=go-contacts` to omit individual targets. Missing toolchains +cause the affected target to fail; skip those explicitly. See the comment +block at the top of `conformance/run.sh` for the per-target prerequisites. diff --git a/docs/src/stability.md b/docs/src/stability.md index b598d0fd..805c3d35 100644 --- a/docs/src/stability.md +++ b/docs/src/stability.md @@ -15,8 +15,8 @@ After the 1.0.0 release, the following surfaces will be governed by SemVer: (`--format json` payloads in particular). Adding a new optional flag is a minor bump; removing or renaming one is a breaking change. - **IDL schema.** The set of accepted top-level keys, type-reference syntax - (`handle`, `iter`, `[T]`, `{K:V}`, `T?`, `&str`, `&[u8]`, primitives, - user-defined struct/enum/interface names), `version` semantics, and the + (`iter`, `[T]`, `{K:V}`, `T?`, primitives, and user-defined struct, enum, + interface, and callback-interface names), `version` semantics, and the JSON Schema exported by `weaveffi schema --format json-schema`. - **Generated code shape.** The exported symbol names, function signatures, type names, package layouts, and ABI conventions of every generator's @@ -25,7 +25,7 @@ After the 1.0.0 release, the following surfaces will be governed by SemVer: ones; a major release may break. - **Public Rust API of every published crate.** That is `weaveffi-ir`, `weaveffi-abi`, `weaveffi-core`, `weaveffi-gen-c`, `weaveffi-gen-cpp`, - `weaveffi-gen-swift`, `weaveffi-gen-android`, `weaveffi-gen-node`, + `weaveffi-gen-swift`, `weaveffi-gen-kotlin`, `weaveffi-gen-node`, `weaveffi-gen-wasm`, `weaveffi-gen-python`, `weaveffi-gen-dotnet`, `weaveffi-gen-dart`, `weaveffi-gen-go`, `weaveffi-gen-ruby`, and `weaveffi-cli`. The `LanguageBackend` trait, the `Orchestrator`, the IR @@ -33,11 +33,13 @@ After the 1.0.0 release, the following surfaces will be governed by SemVer: public contracts. - **The C ABI revision.** The `weaveffi_abi_version()` runtime symbol reports the revision of the runtime surface (the `weaveffi_error` layout, - the value-buffer encoding, and the `weaveffi_*` symbol set). Generated + the value-buffer encoding, the object and callback-interface conventions, + and the `weaveffi_*` symbol set); the current revision is 2, and + [C ABI Contract](reference/abi.md) is its normative description. Generated consumers embed the revision they were built for and, where a load-time check is cheap (Python, Ruby, Dart, Go, .NET, Node.js, Wasm), refuse to - run against a producer reporting a different one. The revision only - changes with a major release. + run against a producer reporting a different one. Post-1.0, the revision + only changes with a major release. ## What is NOT covered pre-1.0 @@ -87,11 +89,111 @@ contract." Things that have already changed during 0.x: peripheral `new`, `scaffold`, `watch`, `format`, `lint`, `doctor`, and `man` CLI commands were removed; `validate --warn` reports the former lint warnings. +- Schema `0.9.0` and **ABI revision 2** made interface objects reference + counted (every interface gained `_clone` beside `_destroy`, and objects may + now appear inside records, optionals, lists, maps, iterators, and async + results as `u64` tokens carrying one strong reference), replaced + module-level `callbacks:`/`listeners:` with `callback_interfaces:` (a + consumer-implemented vtable of methods), made the async executor pluggable + (`weaveffi::set_spawner`), and added `weaveffi_error_set` plus the + `FOREIGN_ERROR_CODE` (`-4`) reserved code. It removed the `handle`, + `handle`, `&str`, and `&[u8]` type spellings, `Param.mutable`, + `Function.since`, the `weaveffi_arena_*` runtime, and `weaveffi::Handle`, + and renamed the `android` target to `kotlin`. See the + [migration guide](#migrating-from-schema-080--abi-1-to-090--abi-2) below. Pin the WeaveFFI version in CI (`cargo install weaveffi-cli --version -=0.3.0`) and vendor the generated output in your repository so that +=0.22.0`) and vendor the generated output in your repository so that upgrades are an explicit, reviewable event. +## Migrating from schema 0.8.0 / ABI 1 to 0.9.0 / ABI 2 + +This release removed constructs rather than deprecating them, so a `0.8.0` +document is rejected until it's rewritten. Change `version: "0.8.0"` to +`version: "0.9.0"`, then work through the tables below. `weaveffi validate` +names every remaining offender with a hint; a Rust producer gets the same +guidance as compile errors from the macros. + +### Removed IDL constructs + +| Removed in `0.8.0` | Replacement in `0.9.0` | +|-------------------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| `type: handle` (untyped opaque token) | Declare an interface for the resource and use its name as the type. Every interface is reference counted and may appear anywhere a type can. | +| `type: handle` | `type: T`, where `T` is an interface declared under `interfaces:`. | +| `type: "&str"` / `type: "&[u8]"` | `type: string` / `type: bytes`. Parameters of these families are always borrowed views at the ABI, so the explicit borrowed spelling no longer means anything. | +| `mutable: true` on a parameter | Drop it. Interface methods take a shared `&self`; guard interior state with a `Mutex` (or another `Sync` primitive) in the producer. | +| `since:` on a function | Drop it. Use `deprecated: ""` to mark surface that's on its way out; generators emit the target's deprecation marker. | +| module-level `callbacks:` | A `callback_interfaces:` entry whose `methods:` list the calls the producer makes. Functions that took a callback type take the callback interface by name instead. | +| module-level `listeners:` (`register_*` / `unregister_*` returning a subscription id) | A `callback_interfaces:` entry plus an ordinary interface method (or function) that accepts it, such as `subscribe(listener: MyListener)`. The producer retains the implementation for as long as it likes; the consumer's `free` fires when the last reference drops, so there's no unregister token to manage. | + +A callback-interface method is synchronous, can't declare `throws`, `async`, +or `cancellable`, and returns nothing, a scalar, `bool`, or a C-style enum. +Its parameters may use any type except another callback interface or an +iterator. Callback interfaces may appear only as parameters of functions, +constructors, statics, and methods (not as returns, not inside records or +collections, and not as `T?`). + +### Renamed things + +| `0.8.0` / ABI 1 | `0.9.0` / ABI 2 | +|----------------------------------------------|------------------------------------------------------------------------| +| `--target android`, output dir `android/` | `--target kotlin`, output dir `kotlin/` (with `build.gradle.kts`) | +| `[generators.android]` in `weaveffi.toml` | `[generators.kotlin]` | +| crate `weaveffi-gen-android` | crate `weaveffi-gen-kotlin` | +| `#[weaveffi::callback]` / `#[weaveffi::listener]` | `#[weaveffi::callback_interface]` on a `trait Name: Send + Sync` | +| `weaveffi::Handle` | `Arc` of a `#[weaveffi::interface]` type | +| `weaveffi_arena_*` runtime symbols | Removed. Buffered values are individually owned and freed with `weaveffi_free_bytes`. | +| `errors::ResolvedError`, `ConstPos::East` (Rust API) | Removed. | +| `ABI_VERSION == 1` | `ABI_VERSION == 2` | + +The reserved error codes are now `GENERIC_ERROR_CODE = -1`, +`PANIC_ERROR_CODE = -2`, `MARSHAL_ERROR_CODE = -3`, and the new +`FOREIGN_ERROR_CODE = -4` (a consumer callback-interface implementation +raised). Consumers report the last one through the new runtime symbol +`weaveffi_error_set(err, code, message)` so no foreign allocator touches +`weaveffi_error.message`. + +### Rust producer changes + +- Every `#[weaveffi::interface]` type must be `Send + Sync`; the macro + asserts it. Constructors return `Self`, `Arc`, or a `Result` of + either; methods take `&self` or `self: Arc`. `&mut self` methods no + longer compile. +- Interface types can be used as `Arc` (or `&T` for a borrowed parameter) + in any position: `Option>`, `Vec>`, `BTreeMap>`, + record fields, iterator items, and async returns. +- Replace `#[weaveffi::callback]`/`#[weaveffi::listener]` with a + `#[weaveffi::callback_interface] trait Name: Send + Sync { ... }` and accept + it as `Arc`. Treat every call into it as potentially panicking + (a consumer failure unwinds to the exported thunk, which reports + `FOREIGN_ERROR_CODE`): snapshot state and release locks before calling out. +- Async exports run on the installed spawner. Nothing changes if the default + thread-per-future executor is fine; call `weaveffi::set_spawner(...)` once + at startup to run futures on Tokio or another runtime instead. + +### Consumer-facing ownership changes + +- **Every wrapper owns one strong reference.** Wrappers release it through + the language's disposal hook (`close()`, `Dispose()`, `deinit`, RAII + destructor, `dispose()`, `Close()`) with a garbage-collector backstop where + one exists. Two wrappers may refer to the same native object; releasing one + never invalidates the other. +- **Objects returned from calls, iterators, async results, and callback-method + parameters are adopted.** Consumers no longer need to think about whether a + returned pointer is borrowed: it's always one reference they own. +- **Objects inside value buffers are tokens carrying one reference.** A + wrapper that encodes an object into a record or list calls `_clone` first; + a buffer that contains objects is decoded exactly once. +- **Callback implementations are freed by the producer.** The producer calls + the vtable's `free(ctx)` exactly once when its last reference drops; there + is no unregister call. A consumer implementation that raises is reported to + the original caller as `FOREIGN_ERROR_CODE`. +- **Kotlin consumers move from `android/` to `kotlin/`** and from a Groovy + `build.gradle` to `build.gradle.kts`. The generated code still targets + Android (JNI plus Gradle) and also runs on the desktop JVM. +- **Node.js consumers receive `i64`/`u64` values as `BigInt`**, and Dart + wrappers use `NativeFinalizer` as their backstop. + ## Post-1.0 deprecation policy Once we reach 1.0.0, breaking changes will follow this path: @@ -120,16 +222,23 @@ tied to `weaveffi-ir`'s minor version: each `weaveffi-ir` minor bump corresponds to at most one schema version bump. [`CURRENT_SCHEMA_VERSION`](https://github.com/weavefoundry/weaveffi/blob/main/crates/weaveffi-ir/src/ir.rs) in `crates/weaveffi-ir/src/ir.rs` is the source of truth; the current -schema version is `0.8.0`. +schema version is `0.9.0`. Pre-1.0, **only the current schema version is accepted** (`SUPPORTED_VERSIONS` contains exactly `CURRENT_SCHEMA_VERSION`), so a document declaring any earlier revision is rejected with an actionable error. When a schema bump lands, update the `version` field in your IDL and adjust the document to the new schema by hand; the changes are documented -in `CHANGELOG.md` with a "Migration" section. Post-1.0, schema bumps will -ship with an automated migration tool and a widened `SUPPORTED_VERSIONS` -window. +in `CHANGELOG.md` and, for the `0.9.0` bump, in the +[migration guide](#migrating-from-schema-080--abi-1-to-090--abi-2) above. +Post-1.0, schema bumps will ship with an automated migration tool and a +widened `SUPPORTED_VERSIONS` window. + +The C ABI revision is independent of both: it changes only when the runtime +contract changes incompatibly. Schema `0.9.0` happened to ship with ABI +revision 2 because reference-counted objects and callback-interface vtables +needed new symbols; a future schema bump that only adds IDL surface would +leave the ABI revision alone. ## Generated-code stability (determinism) @@ -179,7 +288,7 @@ A typical GitHub Actions step: ```yaml - name: Verify generated bindings are up to date run: | - cargo install weaveffi-cli --locked --version =0.3.0 + cargo install weaveffi-cli --locked --version =0.22.0 weaveffi diff idl/api.yml --out generated/ --check ``` @@ -190,5 +299,8 @@ plus advisory lints) for a complete CI guard. - [IDL Schema](reference/idl.md): the type system the schema version governs. +- [C ABI Contract](reference/abi.md): the normative description of ABI + revision 2. +- [Roadmap](roadmap.md): what's planned after ABI 2 and the criteria for 1.0. - [Getting Started](getting-started.md): installation and the basic workflow `diff --check` plugs into. diff --git a/docs/src/tutorials/README.md b/docs/src/tutorials/README.md index 018eb568..a289ccb1 100644 --- a/docs/src/tutorials/README.md +++ b/docs/src/tutorials/README.md @@ -5,11 +5,17 @@ Each tutorial follows the same shape: **Goal**, **Prerequisites**, the target you're shipping to and follow it end-to-end. - [Calculator](calculator.md): fastest path to generate every target, - build the cdylib from the in-tree sample, and run small C/Node/Swift - consumers against it. -- [Swift iOS](swift.md): Rust → SwiftPM → Xcode iOS app. -- [Android](android.md): Rust → AAR → Android Studio app on - emulator/device. + build the cdylib from the in-tree sample, extract and validate its IDL, + and run small C/Node/Swift consumers against it. +- [Swift iOS](swift.md): Rust → SwiftPM → Xcode iOS app, with a macOS + smoke test first. +- [Kotlin](kotlin.md): Rust → `weaveffi package` → Gradle library module + with the cdylib bundled per Android ABI → Android Studio app, with a + desktop JVM smoke test first. - [Python](python.md): Rust → ctypes package → `pip install` and `python demo.py`. - [Node.js](node.md): Rust → N-API addon → `npm publish` shape. + +The four greeter tutorials share one IDL and one `#[weaveffi::module]` +crate, so you can generate every target from a single source and follow +them in any order. diff --git a/docs/src/tutorials/android.md b/docs/src/tutorials/android.md deleted file mode 100644 index 9b57039b..00000000 --- a/docs/src/tutorials/android.md +++ /dev/null @@ -1,274 +0,0 @@ -# Android App - -## Goal - -Build a small Rust greeter library, generate Kotlin/JNI bindings with -WeaveFFI, and call them from an Android Studio app running on an -emulator or a physical device. - -## Prerequisites - -- [Rust toolchain](https://rustup.rs/) (stable channel). -- Android Studio with the NDK installed (via SDK Manager). -- WeaveFFI CLI (`cargo install weaveffi-cli`). -- Android Rust targets: - - ```bash - rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - ``` - -## Step-by-step - -### 1. Author the IDL - -Save as `greeter.yml`: - -```yaml -version: "0.8.0" -modules: - - name: greeter - errors: - name: GreeterError - codes: - - { name: UnknownLang, code: 1, message: "unknown language" } - structs: - - name: Greeting - fields: - - { name: message, type: string } - - { name: lang, type: string } - functions: - - name: hello - params: - - { name: name, type: string } - return: string - - name: greeting - throws: true - params: - - { name: name, type: string } - - { name: lang, type: string } - return: Greeting -``` - -`hello` can't fail, so it stays non-throwing. `greeting` declares -`throws: true` and reports codes from the module's `GreeterError` -domain when the language is unknown. - -### 2. Generate bindings - -```bash -weaveffi generate greeter.yml -o generated -``` - -You should see, among other targets: - -```text -generated/ -├── c/ -│ └── weaveffi.h -└── android/ - ├── settings.gradle - ├── build.gradle - └── src/main/ - ├── kotlin/com/weaveffi/WeaveFFI.kt - └── cpp/ - ├── weaveffi_jni.c - └── CMakeLists.txt -``` - -### 3. Implement the Rust library - -```bash -cargo init --lib mygreeter -``` - -`mygreeter/Cargo.toml`: - -```toml -[package] -name = "mygreeter" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -weaveffi-abi = { version = "0.14" } -``` - -`mygreeter/src/lib.rs`: - -```rust -#![allow(unsafe_code)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use weaveffi_abi::{self as abi, weaveffi_error}; - -#[no_mangle] -pub extern "C" fn weaveffi_greeter_hello( - name: *const c_char, - out_err: *mut weaveffi_error, -) -> *const c_char { - abi::error_set_ok(out_err); - let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world"); - let msg = format!("Hello, {name}!"); - CString::new(msg).unwrap().into_raw() as *const c_char -} - -// Emit the WeaveFFI C ABI runtime symbols (abi_version, free_string, -// free_bytes, error_clear, cancel_token_*), one line per cdylib. -abi::export_runtime!(); -``` - -The generated `generated/c/weaveffi.h` lists every remaining symbol with -its exact signature; implement `weaveffi_greeter_greeting` the same way -(it returns the `Greeting` record as a serialized value buffer). -Alternatively, annotate the module with `#[weaveffi::module]` and let the -macro emit the whole C ABI; see -[The Rust Producer Macro](../guides/producer-macro.md). - -### 4. Configure the NDK toolchain - -```bash -export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/$(ls $HOME/Library/Android/sdk/ndk | sort -V | tail -1)" -export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" -``` - -Replace `darwin-x86_64` with `linux-x86_64` on Linux. Add the matching -`linker = ...` entries in `.cargo/config.toml`: - -```toml -[target.aarch64-linux-android] -linker = "aarch64-linux-android21-clang" - -[target.armv7-linux-androideabi] -linker = "armv7a-linux-androideabi21-clang" - -[target.x86_64-linux-android] -linker = "x86_64-linux-android21-clang" -``` - -### 5. Cross-compile for every ABI - -```bash -cargo build -p mygreeter --target aarch64-linux-android --release -cargo build -p mygreeter --target armv7-linux-androideabi --release -cargo build -p mygreeter --target x86_64-linux-android --release -``` - -You should now have: - -```text -target/aarch64-linux-android/release/libmygreeter.so -target/armv7-linux-androideabi/release/libmygreeter.so -target/x86_64-linux-android/release/libmygreeter.so -``` - -### 6. Wire it into Android Studio - -1. Create a new Android project (Empty Activity, Kotlin, `minSdk` 21+). -2. Include the generated module in the root `settings.gradle`: - - ```groovy - include ':weaveffi' - project(':weaveffi').projectDir = new File('generated/android') - ``` - -3. Add it as a dependency in your app's `build.gradle`: - - ```groovy - dependencies { - implementation project(':weaveffi') - } - ``` - -4. Copy the cdylib into `jniLibs` per ABI: - - ```bash - mkdir -p app/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64} - cp target/aarch64-linux-android/release/libmygreeter.so \ - app/src/main/jniLibs/arm64-v8a/libmygreeter.so - cp target/armv7-linux-androideabi/release/libmygreeter.so \ - app/src/main/jniLibs/armeabi-v7a/libmygreeter.so - cp target/x86_64-linux-android/release/libmygreeter.so \ - app/src/main/jniLibs/x86_64/libmygreeter.so - ``` - -5. Confirm the JNI `CMakeLists.txt` in - `generated/android/src/main/cpp/` includes - `target_include_directories(... PRIVATE ../../../../c)` so it can - find `weaveffi.h`. - -### 7. Call from Kotlin - -```kotlin -import com.weaveffi.WeaveFFI -import com.weaveffi.GreeterException - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - findViewById(R.id.textView).text = WeaveFFI.hello("Android") - - try { - val g = WeaveFFI.greeting("Hi", "en") - println("${g.message} (${g.lang})") - } catch (e: GreeterException) { - println("greeting failed: ${e.message}") - } - } -} -``` - -The generated `WeaveFFI` companion object loads the JNI library -lazily and exposes: - -- `WeaveFFI.hello(name: String): String`: non-throwing. -- `WeaveFFI.greeting(name: String, lang: String): Greeting`: declared - `throws` in the IDL, so the JNI shim raises `GreeterException` - subclasses (a sealed class extending `WeaveFFIException`, one - nested class per error code) on failure. - -`Greeting` is a plain Kotlin data class decoded from the value buffer -the C ABI returns; there's nothing to close. - -## Verification - -- Sync Gradle in Android Studio. -- Pick an emulator or a connected device and press **Run** (Shift+F10). -- The text view should display `Hello, Android!` and Logcat should - show `Hi (en)` from the `Greeting` block. -- Common error mappings: - - | Symptom | Likely cause | - |----------------------------------------------------|-----------------------------------------------------------------------------| - | `UnsatisfiedLinkError: dlopen failed` | The cdylib is missing from `jniLibs/` or built for the wrong ABI. | - | `WeaveFFIException` from JNI | A WeaveFFI error was raised; inspect the code and message. | - | Linker errors during `cargo build` | `ANDROID_NDK_HOME` is not set or the NDK toolchain is missing from `PATH`. | - | `No implementation found for native method` | JNI symbol names do not match the Kotlin package; re-run `weaveffi generate`. | - -## Cleanup - -```bash -rm -rf generated/ app/src/main/jniLibs -cargo clean -p mygreeter -``` - -Drop the `include ':weaveffi'` line from `settings.gradle` and remove -the dependency from your app module if you do not want to keep the -generated bindings around. - -## Next steps - -- See the [Android generator reference](../generators/android.md) for - the full type mapping and JNI conventions. -- Read [Error Handling](../guides/errors.md): JNI shims convert C - errors to `WeaveFFIException` (or a typed domain exception) - automatically. -- Try the [Calculator tutorial](calculator.md) for a simpler - end-to-end walkthrough or [Swift iOS](swift.md) for a sibling - mobile target. diff --git a/docs/src/tutorials/calculator.md b/docs/src/tutorials/calculator.md index 3f68efef..c740926d 100644 --- a/docs/src/tutorials/calculator.md +++ b/docs/src/tutorials/calculator.md @@ -5,10 +5,11 @@ Take the in-tree `samples/calculator` producer (safe Rust annotated with `#[weaveffi::module]`), generate bindings for every target, build the cdylib, and run the calculator from a real consumer (C, Node.js, Swift, then optionally -Android and Wasm). By the end you will have produced bindings, executed them on +Kotlin and Wasm). By the end you will have produced bindings, executed them on at least one host, and seen the same Rust `add(a, b)` answer come back through three different runtimes, plus the typed `CalcError` surface when you divide -by zero. +by zero. Along the way you'll extract the sample's IDL to YAML and validate +it, which is the same path a hand-written IDL takes. ## Prerequisites @@ -17,13 +18,134 @@ by zero. `cargo run -p weaveffi-cli --` if you are working in the repo). - macOS or Linux for the C/Node/Swift steps; Windows works for C and Node but the Swift step requires macOS. -- For the optional Android and Wasm steps: +- For the optional Kotlin and Wasm steps: - Android Studio with the NDK installed. - `rustup target add wasm32-unknown-unknown`. ## Step-by-step -### 1. Generate every target +### 1. Look at the producer + +The whole sample is one annotated module. The `#[weaveffi::error]` enum is +the module's error domain, `div` returns `Result` and so +becomes a `throws` function, and `echo` shows a `String` crossing in both +directions: + +```rust +#[weaveffi::module] +pub mod calculator { + /// The calculator's error domain: the codes its throwing functions report. + #[weaveffi::error] + #[derive(Debug)] + pub enum CalcError { + /// division by zero + DivisionByZero = 1, + } + + /// Add two integers. + #[weaveffi::export] + pub fn add(a: i32, b: i32) -> i32 { + a + b + } + + /// Multiply two integers. + #[weaveffi::export] + pub fn mul(a: i32, b: i32) -> i32 { + a * b + } + + /// Divide two integers, failing on a zero divisor. + #[weaveffi::export] + pub fn div(a: i32, b: i32) -> Result { + if b == 0 { + return Err(CalcError::DivisionByZero); + } + Ok(a / b) + } + + /// Echo a string back to the caller. + #[weaveffi::export] + pub fn echo(s: String) -> String { + s + } +} + +weaveffi::export_runtime!(); +``` + +The `Cargo.toml` builds it as a `cdylib` (plus an `rlib` so the unit tests +can call the thunks) and depends on the `weaveffi` facade crate. The +[Producer Macro](../guides/producer-macro.md) guide covers every attribute. + +### 2. Extract and validate the IDL + +Every generator consumes the IDL, whether it came from annotated Rust or a +YAML file you wrote by hand. Extract the sample's IDL so you can see what the +generators see, then validate it: + +```bash +weaveffi extract samples/calculator/src/lib.rs -o calculator.yml +weaveffi validate calculator.yml +``` + +The second command prints `Validation passed` and a one-line summary +(`1 modules, 4 functions, 0 structs, 0 enums`). The extracted file is the +complete IDL for this module: + +```yaml +version: 0.9.0 +modules: +- name: calculator + functions: + - name: add + params: + - name: a + type: i32 + - name: b + type: i32 + return: i32 + doc: Add two integers. + - name: mul + params: + - name: a + type: i32 + - name: b + type: i32 + return: i32 + doc: Multiply two integers. + - name: div + params: + - name: a + type: i32 + - name: b + type: i32 + return: i32 + doc: Divide two integers, failing on a zero divisor. + throws: true + - name: echo + params: + - name: s + type: string + return: string + doc: Echo a string back to the caller. + errors: + name: CalcError + codes: + - name: DivisionByZero + code: 1 + message: division by zero + doc: division by zero +``` + +If you'd rather author the IDL first and write the Rust afterwards, start +from this file: `weaveffi generate calculator.yml -o generated` produces the +same bindings as the next step, provided the same `weaveffi.toml` sits beside +the YAML (otherwise the package name falls back to the file stem, `calc` for +`calc.yml`, and the loaders look for `libcalc.dylib`). The +[IDL reference](../reference/idl.md) documents every field, and the +[Configuration](../guides/config.md) guide covers `weaveffi.toml`. + +### 3. Generate every target Point the generator at the annotated source; the sample's `weaveffi.toml` beside it supplies the package identity: @@ -32,18 +154,22 @@ beside it supplies the package identity: weaveffi generate samples/calculator/src/lib.rs -o generated ``` +The command finishes with +`Generated artifacts in generated (config: samples/calculator/weaveffi.toml)`. The output appears under `generated/`, one directory per target. The three this tutorial exercises: - `generated/c`: C header (`weaveffi.h`) and convenience C file -- `generated/swift`: SwiftPM System Library (`CWeaveFFI`) and Swift wrapper (`WeaveFFI`) -- `generated/node`: N-API addon source, JS loader, and `.d.ts` +- `generated/swift`: SwiftPM System Library (`CCalculator`) and Swift wrapper + (`Calculator`) +- `generated/node`: N-API addon source, JS loader, `binding.gyp`, and + `types.d.ts` -The rest (`cpp`, `android`, `wasm`, `python`, `dotnet`, `dart`, `go`, `ruby`) +The rest (`cpp`, `kotlin`, `wasm`, `python`, `dotnet`, `dart`, `go`, `ruby`) follow the same pattern; the [generator pages](../generators/README.md) cover each one. -### 2. Build the Rust sample +### 4. Build the Rust sample ```bash cargo build -p calculator @@ -55,12 +181,12 @@ The cdylib lands in `target/debug/`: - Linux: `libcalculator.so` - Windows: `calculator.dll` -### 3. Run a C consumer +### 5. Run a C consumer Write a minimal `main.c` that calls through the generated header. `add` is -non-throwing, so its error slot only trips on a poisoned call; `div` is -declared `throws`, so a zero divisor fills `out_err` with the typed -`CalcError` code: +non-throwing, so its error slot only trips on a panic or a marshalling +failure; `div` is declared `throws`, so a zero divisor fills `out_err` with +the typed `CalcError` code: ```c #include @@ -89,7 +215,7 @@ DYLD_LIBRARY_PATH=target/debug ./calc_c You should see `2 + 3 = 5` followed by `div(1, 0) failed: division by zero`. -### 4. Run a Node consumer +### 6. Run a Node consumer The generated `binding.gyp` links against `libweaveffi`, so give the sample cdylib that name with a symlink, then build the addon in place (`npm install` @@ -115,7 +241,7 @@ try { calc.div(1, 0); } catch (e) { console.log(e.name + ':', e.message); } This prints `2 + 3 = 5` and `DivisionByZeroError: (1) division by zero`. -### 5. Run a Swift consumer (macOS / Linux) +### 7. Run a Swift consumer (macOS / Linux) Write a `main.swift` at the repo root. The wrapper exposes the module as a `Calculator` enum namespace, and `div` is `throws`: @@ -130,40 +256,44 @@ do { ``` Compile the generated wrapper together with your `main.swift` (the module map -also links `libweaveffi`, so this reuses the symlink from step 4): +also links `libweaveffi`, so this reuses the symlink from step 6): ```bash swiftc \ - -I generated/swift/Sources/CWeaveFFI \ + -I generated/swift/Sources/CCalculator \ -L target/debug \ -Xlinker -rpath -Xlinker target/debug \ - generated/swift/Sources/WeaveFFI/WeaveFFI.swift main.swift -o calc_swift + generated/swift/Sources/Calculator/Calculator.swift main.swift -o calc_swift ./calc_swift ``` On Linux, export `LD_LIBRARY_PATH=target/debug` before running so the loader resolves the `libweaveffi.so` alias. -### 6. Optional: Android and Wasm +### 8. Optional: Kotlin and Wasm -- Open `generated/android` in Android Studio and build the `:weaveffi` - AAR. Combine with the steps in the - [Android tutorial](android.md). +- Open `generated/kotlin` in Android Studio and build the `calculator` + library module (that's the `rootProject.name` in the generated + `settings.gradle.kts`). The [Kotlin tutorial](kotlin.md) walks through + cross-compiling the cdylib for Android and wiring the AAR into an app. - For Wasm, run - `cargo build --target wasm32-unknown-unknown --release` and load the - `.wasm` file with `generated/wasm/weaveffi_wasm.js`. + `cargo build -p calculator --target wasm32-unknown-unknown --release` and + load `target/wasm32-unknown-unknown/release/calculator.wasm` with + `generated/wasm/weaveffi_wasm.js`; the generated `README.md` beside it has + a loading example. ## Verification You should see the same calculator output from each consumer. Concretely: +- `weaveffi validate` reports `Validation passed` for the extracted IDL. - The C consumer prints `2 + 3 = 5` and the typed division error. - The Node one-liner prints the sum, then `DivisionByZeroError` from the thrown error class. - The Swift binary prints the same arithmetic, catches the thrown `CalcError`, and exits cleanly. -For fuller consumers that exercise interfaces, callbacks, and async +For fuller consumers that exercise interfaces, callback interfaces, and async functions, see the `conformance/` directory: each `conformance//` file is a runnable program against the richer samples (contacts, events, kvstore, shapes), and `conformance/run.sh` builds and runs them all. @@ -176,8 +306,8 @@ libraries` (Linux). Re-export `DYLD_LIBRARY_PATH` / ## Cleanup ```bash -rm -rf generated/ main.c main.swift calc_c calc_swift -rm -f target/debug/libweaveffi.dylib # the link alias from step 4 +rm -rf generated/ calculator.yml main.c main.swift calc_c calc_swift +rm -f target/debug/libweaveffi.dylib # the link alias from step 6 cargo clean -p calculator ``` @@ -192,5 +322,5 @@ else in the repository depends on its contents. [Error Handling](../guides/errors.md) guides for the contracts every consumer must follow. - Try a target-specific tutorial: - [Swift iOS](swift.md), [Android](android.md), + [Swift iOS](swift.md), [Kotlin](kotlin.md), [Python](python.md), or [Node.js](node.md). diff --git a/docs/src/tutorials/kotlin.md b/docs/src/tutorials/kotlin.md new file mode 100644 index 00000000..33637749 --- /dev/null +++ b/docs/src/tutorials/kotlin.md @@ -0,0 +1,405 @@ +# Kotlin (Android and desktop JVM) + +## Goal + +Build a small Rust greeter library with `#[weaveffi::module]`, generate the +Kotlin/JNI bindings with WeaveFFI, package them as a Gradle library module +with the cross-compiled cdylib bundled per Android ABI, and call them from an +Android Studio app running on an emulator or a physical device. The same +module also runs on a desktop JVM, which is how you'll smoke-test it before +opening Android Studio. + +## Prerequisites + +- [Rust toolchain](https://rustup.rs/) (stable channel). +- Android Studio with the NDK installed (via SDK Manager). +- WeaveFFI CLI (`cargo install weaveffi-cli`). +- The Android Rust targets WeaveFFI packages (`android-arm64` and + `android-x64`; there's no 32-bit platform in the matrix): + + ```bash + rustup target add aarch64-linux-android x86_64-linux-android + ``` + +- For the desktop smoke test: a JDK (17 or newer), CMake 3.22+, and + `kotlinc`. + +## Step-by-step + +### 1. Author the IDL + +Save as `greeter.yml`: + +```yaml +version: "0.9.0" +modules: + - name: greeter + errors: + name: GreeterError + codes: + - { name: UnknownLang, code: 1, message: "unknown language" } + structs: + - name: Greeting + fields: + - { name: message, type: string } + - { name: lang, type: string } + functions: + - name: hello + params: + - { name: name, type: string } + return: string + - name: greeting + throws: true + params: + - { name: name, type: string } + - { name: lang, type: string } + return: Greeting +``` + +`hello` can't fail, so it stays non-throwing. `greeting` declares +`throws: true` and reports codes from the module's `GreeterError` +domain when the language is unknown. Check it: + +```bash +weaveffi validate greeter.yml +``` + +You should see `Validation passed` and `1 modules, 2 functions, 1 structs, +0 enums`. + +Put a `weaveffi.toml` beside it so the package and the native library get a +stable name (otherwise both default to the IDL's file stem, `greeter`): + +```toml +[package] +name = "mygreeter" +version = "0.1.0" +``` + +### 2. Generate bindings + +```bash +weaveffi generate greeter.yml -o generated +``` + +You should see, among other targets: + +```text +generated/ +├── c/ +│ ├── weaveffi.c +│ └── weaveffi.h +└── kotlin/ + ├── build.gradle.kts + ├── settings.gradle.kts + └── src/main/ + ├── kotlin/com/weaveffi/WeaveFFI.kt + └── cpp/ + ├── weaveffi_jni.c + └── CMakeLists.txt +``` + +`WeaveFFI.kt` holds the `WeaveFFI` class (a companion object with one +`@JvmStatic` function per IDL function), the `Greeting` data class, the +`GreeterException` sealed hierarchy, and the value-buffer codec. This bare +output is what you'd read to learn the API; step 6 produces the module you +actually build, with the native libraries bundled in. + +### 3. Implement the Rust library + +```bash +cargo init --lib mygreeter +``` + +`mygreeter/Cargo.toml`: + +```toml +[package] +name = "mygreeter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +weaveffi = "0.22" +``` + +`mygreeter/src/lib.rs` is plain safe Rust. The macro reads the annotated +items and emits the `extern "C"` thunks that the JNI shim calls, so the +module needs no `unsafe` and no hand-written signatures: + +```rust +#[weaveffi::module] +pub mod greeter { + /// Codes the greeter reports from its throwing functions. + #[weaveffi::error] + #[derive(Debug)] + pub enum GreeterError { + /// unknown language + UnknownLang = 1, + } + + /// A greeting and the language it was rendered in. + #[weaveffi::record] + pub struct Greeting { + pub message: String, + pub lang: String, + } + + /// Greet someone in English. + #[weaveffi::export] + pub fn hello(name: String) -> String { + format!("Hello, {name}!") + } + + /// Greet someone in the given language, failing on an unknown one. + #[weaveffi::export] + pub fn greeting(name: String, lang: String) -> Result { + let message = match lang.as_str() { + "en" => format!("Hello, {name}!"), + "es" => format!("Hola, {name}!"), + "fr" => format!("Bonjour, {name}!"), + _ => return Err(GreeterError::UnknownLang), + }; + Ok(Greeting { message, lang }) + } +} + +weaveffi::export_runtime!(); +``` + +`weaveffi extract mygreeter/src/lib.rs` produces the same IDL as +`greeter.yml` (plus the doc comments), so the two stay in step; you can +generate from either. The [Producer Macro](../guides/producer-macro.md) +guide covers every attribute. + +### 4. Configure the NDK toolchain + +```bash +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/$(ls $HOME/Library/Android/sdk/ndk | sort -V | tail -1)" +export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +``` + +Replace `darwin-x86_64` with `linux-x86_64` on Linux. Add the matching +`linker = ...` entries to `mygreeter/.cargo/config.toml`. The generated +module sets `minSdk = 33` (its wrappers use `java.lang.ref.Cleaner`), so +pick the API 33 clang wrappers: + +```toml +[target.aarch64-linux-android] +linker = "aarch64-linux-android33-clang" + +[target.x86_64-linux-android] +linker = "x86_64-linux-android33-clang" +``` + +### 5. Cross-compile for every ABI + +```bash +cd mygreeter +cargo build --target aarch64-linux-android --release +cargo build --target x86_64-linux-android --release +``` + +You should now have: + +```text +target/aarch64-linux-android/release/libmygreeter.so +target/x86_64-linux-android/release/libmygreeter.so +``` + +### 6. Package the Gradle module + +`weaveffi package` assembles the Kotlin module with the cdylib bundled under +`src/main/jniLibs//` and a `CMakeLists.txt` that links the JNI shim +against it per ABI. From inside `mygreeter/`, let the CLI run the cross +builds itself: + +```bash +weaveffi package ../greeter.yml --build mygreeter \ + --platforms android-arm64,android-x64 -t kotlin -o ../dist +``` + +It prints one `Building mygreeter for Android arm64 (aarch64-linux-android)...` +line per platform, then: + +```text +Packaging 'mygreeter' for platforms: android-arm64, android-x64 + kotlin: 7 file(s), 2 bundled binary(ies) +Packaged 1 target(s) into ../dist +``` + +If you'd rather build in CI and package afterwards, lay the binaries out as +`prebuilt//libmygreeter.so` and pass `--binaries prebuilt` +instead of `--build`; the [Packaging](../guides/packaging.md) guide has the +full recipe. Either way you get: + +```text +dist/kotlin/ +├── README.md +├── build.gradle.kts +├── settings.gradle.kts +└── src/main/ + ├── kotlin/com/weaveffi/WeaveFFI.kt + ├── cpp/ + │ ├── CMakeLists.txt + │ ├── include/weaveffi.h + │ └── weaveffi_jni.c + └── jniLibs/ + ├── arm64-v8a/libmygreeter.so + └── x86_64/libmygreeter.so +``` + +### 7. Optional: smoke-test on the desktop JVM + +The same module runs on your workstation, which is the quickest way to prove +the Rust and the bindings agree before involving an emulator. Add the host +platform to the package (`darwin-arm64`, `darwin-x64`, or `linux-x64`), then +build the shim against your JDK and run a `main`: + +```bash +weaveffi package ../greeter.yml --build mygreeter \ + --platforms android-arm64,android-x64,darwin-arm64 -t kotlin -o ../dist +cd ../dist/kotlin +cmake -S src/main/cpp -B build -DWEAVEFFI_PLATFORM_ID=darwin-arm64 +cmake --build build +``` + +CMake writes `libmygreeter_jni.dylib` next to the bundled producer library +under `src/main/resources/natives/darwin-arm64/`; at runtime the wrapper +extracts both from the classpath. Save this as `Main.kt`: + +```kotlin +import com.weaveffi.WeaveFFI +import com.weaveffi.GreeterException + +fun main() { + println(WeaveFFI.hello("Android")) + val g = WeaveFFI.greeting("Ada", "es") + println("${g.message} (${g.lang})") + try { + WeaveFFI.greeting("Ada", "tlh") + } catch (e: GreeterException.UnknownLang) { + println("greeting failed: ${e.message} (code ${e.code})") + } +} +``` + +Compile it together with the generated wrapper and run with the resources +directory on the classpath: + +```bash +kotlinc src/main/kotlin/com/weaveffi/WeaveFFI.kt Main.kt -include-runtime -d app.jar +java -cp app.jar:src/main/resources MainKt +``` + +Expected output: + +```text +Hello, Android! +Hola, Ada! (es) +greeting failed: unknown language (code 1) +``` + +### 8. Wire it into Android Studio + +1. Create a new Android project (Empty Activity, Kotlin, `minSdk` 33+; + the generated module won't go lower). +2. Include the packaged module in the root `settings.gradle.kts`: + + ```kotlin + include(":mygreeter") + project(":mygreeter").projectDir = file("../dist/kotlin") + ``` + +3. Add it as a dependency in your app's `build.gradle.kts`: + + ```kotlin + dependencies { + implementation(project(":mygreeter")) + } + ``` + +The module's `build.gradle.kts` applies `com.android.library`, points +`externalNativeBuild` at `src/main/cpp/CMakeLists.txt`, and depends on +`kotlinx-coroutines-core`. The NDK's CMake builds `libmygreeter_jni.so` +for each ABI against the bundled `libmygreeter.so`, so there's nothing to +copy into your app's own `jniLibs/`. + +### 9. Call from Kotlin + +```kotlin +import com.weaveffi.WeaveFFI +import com.weaveffi.GreeterException + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + findViewById(R.id.textView).text = WeaveFFI.hello("Android") + + try { + val g = WeaveFFI.greeting("Hi", "en") + println("${g.message} (${g.lang})") + } catch (e: GreeterException) { + println("greeting failed: ${e.message}") + } + } +} +``` + +The `WeaveFFI` companion object loads `libmygreeter_jni` on first use +(`System.loadLibrary("mygreeter_jni")` on Android) and exposes: + +- `WeaveFFI.hello(name: String): String`: non-throwing. +- `WeaveFFI.greeting(name: String, lang: String): Greeting`: declared + `throws` in the IDL, so the JNI shim raises `GreeterException.UnknownLang` + (a sealed class extending `WeaveFFIException`, one nested class per error + code) on failure. Catch the nested class, the sealed domain, or the + generic base. + +`Greeting` is a plain Kotlin data class decoded from the value buffer the +C ABI returns; there's nothing to close. + +## Verification + +- Sync Gradle in Android Studio. +- Pick an emulator or a connected device and press **Run** (Shift+F10). +- The text view should display `Hello, Android!` and Logcat should + show `Hi (en)` from the `Greeting` block. +- Common error mappings: + + | Symptom | Likely cause | + |----------------------------------------------------|-----------------------------------------------------------------------------| + | `UnsatisfiedLinkError: dlopen failed` | The ABI you're running on isn't in `jniLibs/`; add its platform to `--platforms` and repackage. | + | `WeaveFFIException` from JNI | A WeaveFFI error was raised; inspect the code and message. | + | Linker errors during `cargo build` | `ANDROID_NDK_HOME` is not set or the NDK toolchain is missing from `PATH`. | + | `No implementation found for native method` | JNI symbol names do not match the Kotlin package; re-run `weaveffi package`. | + | `minSdkVersion 33 cannot be smaller than version` | Your app's `minSdk` is below 33; raise it to match the module. | + +## Cleanup + +```bash +rm -rf generated/ dist/ +cargo clean -p mygreeter +``` + +Drop the `include(":mygreeter")` lines from `settings.gradle.kts` and +remove the dependency from your app module if you do not want to keep the +packaged bindings around. + +## Next steps + +- See the [Kotlin generator reference](../generators/kotlin.md) for + the full type mapping and JNI conventions. +- Read [Error Handling](../guides/errors.md): JNI shims convert C + errors to `WeaveFFIException` (or a typed domain exception) + automatically. +- Read [Packaging](../guides/packaging.md) for the full platform matrix + and a CI recipe that builds every platform before packaging. +- Try the [Calculator tutorial](calculator.md) for a simpler + end-to-end walkthrough or [Swift iOS](swift.md) for a sibling + mobile target. diff --git a/docs/src/tutorials/node.md b/docs/src/tutorials/node.md index 414d705b..3fdd5c53 100644 --- a/docs/src/tutorials/node.md +++ b/docs/src/tutorials/node.md @@ -22,7 +22,7 @@ package shape ready to publish. Save as `greeter.yml`: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: greeter errors: @@ -49,7 +49,17 @@ modules: `hello` can't fail, so it stays non-throwing. `greeting` declares `throws: true` and reports codes from the module's `GreeterError` -domain when the language is unknown. +domain when the language is unknown. Check it with +`weaveffi validate greeter.yml`; you should see `Validation passed`. + +Put a `weaveffi.toml` beside it so the npm package gets a stable name +(otherwise it defaults to the IDL's file stem, `greeter`): + +```toml +[package] +name = "mygreeter" +version = "0.1.0" +``` ### 2. Generate bindings @@ -62,6 +72,7 @@ Among other targets you should see: ```text generated/ ├── c/ +│ ├── weaveffi.c │ └── weaveffi.h └── node/ ├── binding.gyp @@ -73,7 +84,9 @@ generated/ `weaveffi_addon.c` is a complete N-API addon that bridges Node's runtime to the C ABI, and `binding.gyp` builds it with `node-gyp`; you -don't write any addon code yourself. +don't write any addon code yourself. `package.json` carries the name and +version from `weaveffi.toml` plus an `install` script that runs +`node-gyp rebuild`. ### 3. Implement the Rust library @@ -93,41 +106,57 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -weaveffi-abi = { version = "0.14" } +weaveffi = "0.22" ``` -`mygreeter/src/lib.rs`: +`mygreeter/src/lib.rs` is plain safe Rust. The macro reads the annotated +items and emits the `extern "C"` thunks the addon calls, so the module +needs no `unsafe` and no hand-written signatures: ```rust -#![allow(unsafe_code)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use weaveffi_abi::{self as abi, weaveffi_error}; - -#[no_mangle] -pub extern "C" fn weaveffi_greeter_hello( - name: *const c_char, - out_err: *mut weaveffi_error, -) -> *const c_char { - abi::error_set_ok(out_err); - let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world"); - let msg = format!("Hello, {name}!"); - CString::new(msg).unwrap().into_raw() as *const c_char +#[weaveffi::module] +pub mod greeter { + /// Codes the greeter reports from its throwing functions. + #[weaveffi::error] + #[derive(Debug)] + pub enum GreeterError { + /// unknown language + UnknownLang = 1, + } + + /// A greeting and the language it was rendered in. + #[weaveffi::record] + pub struct Greeting { + pub message: String, + pub lang: String, + } + + /// Greet someone in English. + #[weaveffi::export] + pub fn hello(name: String) -> String { + format!("Hello, {name}!") + } + + /// Greet someone in the given language, failing on an unknown one. + #[weaveffi::export] + pub fn greeting(name: String, lang: String) -> Result { + let message = match lang.as_str() { + "en" => format!("Hello, {name}!"), + "es" => format!("Hola, {name}!"), + "fr" => format!("Bonjour, {name}!"), + _ => return Err(GreeterError::UnknownLang), + }; + Ok(Greeting { message, lang }) + } } -// Emit the WeaveFFI C ABI runtime symbols (abi_version, free_string, -// free_bytes, error_clear, cancel_token_*), one line per cdylib. -abi::export_runtime!(); +weaveffi::export_runtime!(); ``` -The generated `generated/c/weaveffi.h` lists every remaining symbol the -addon expects, with exact signatures; implement `weaveffi_greeter_greeting` -the same way (it returns the `Greeting` record as a serialized value -buffer). Alternatively, annotate the module with `#[weaveffi::module]` and -let the macro emit the whole C ABI; see -[The Rust Producer Macro](../guides/producer-macro.md). +`weaveffi extract mygreeter/src/lib.rs` produces the same IDL as +`greeter.yml` (plus the doc comments), so the two stay in step; you can +generate from either. The [Producer Macro](../guides/producer-macro.md) +guide covers every attribute. ### 4. Build the cdylib and the N-API addon @@ -157,7 +186,9 @@ variable, or ship a prebuilt binary as `index.node` next to Save as `generated/node/demo.js`. Function names are camelCase with the module prefix stripped, and the throwing `greeting` raises typed error classes (`GreeterError` extends `WeaveFFIError`, with an -`UnknownLangError` subclass per code): +`UnknownLangError` subclass per code). Every error carries `code`, the +bare producer message in `errorMessage`, and `(code) message` in the +standard `message`: ```javascript const greeter = require("./index"); @@ -167,6 +198,7 @@ console.log(greeter.hello("Node")); try { const g = greeter.greeting("Node", "en"); console.log(`${g.message} (${g.lang})`); + greeter.greeting("Node", "tlh"); } catch (e) { if (e instanceof greeter.GreeterError) { console.log(`${e.name}: ${e.errorMessage}`); @@ -193,6 +225,14 @@ cd generated/node LD_LIBRARY_PATH=../../target/release node demo.js ``` +Expected output: + +```text +Hello, Node! +Hello, Node! (en) +UnknownLangError: unknown language +``` + For TypeScript consumers, the generated `types.d.ts` is enough: ```typescript @@ -212,7 +252,9 @@ consumers don't need node-gyp: cp build/Release/weaveffi.node index.node ``` -Then edit `generated/node/package.json`: +Then edit `generated/node/package.json`. The generated file sets +`"gypfile": true` and an `install` script that rebuilds the addon; drop +both so consumers use the prebuilt `index.node`, and add a `files` list: ```json { @@ -230,10 +272,12 @@ Then edit `generated/node/package.json`: } ``` -`files` must include `index.node`. For multi-platform packages, -publish per-platform optional dependencies (e.g. -`@myorg/greeter-darwin-arm64`) and use an install script to pick the -right binary. +`files` must include `index.node`. For multi-platform packages, publish +per-platform optional dependencies instead. `weaveffi package` automates +that shape: pass it prebuilt cdylibs per platform and it emits the main +package plus one `npm/mygreeter--/` package per platform, each +gated by npm `os`/`cpu` so only the matching one installs; see +[Packaging](../guides/packaging.md). ### 7. Publish @@ -256,7 +300,7 @@ console.log(hello("npm")); ## Verification -- `node demo.js` prints `Hello, Node!` and exits with code `0`. +- `node demo.js` prints the three lines above and exits with code `0`. - `npm pack` produces a `.tgz` containing `index.node`, `types.d.ts`, and `index.js`. - TypeScript consumers see the `Greeting` interface and `hello` @@ -268,12 +312,14 @@ console.log(hello("npm")); | `Error: Cannot find module './index.node'` | The addon isn't built; run `npm install` or set `WEAVEFFI_ADDON`. | | `Error: dlopen ... not found` | Cdylib not on the loader path; set `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH`. | | `TypeError: greeter.hello is not a function` | The addon is stale; rerun `npm install` after IDL edits. | + | `WeaveFFIError` with a negative code | A producer panic (-2) or marshalling failure (-3); see [Error Handling](../guides/errors.md). | | Crashes on `require()` | Addon built for the wrong Node.js version or architecture; rebuild. | ## Cleanup ```bash rm -rf generated/ +rm -f target/release/libweaveffi.dylib # the link alias from step 4 cargo clean -p mygreeter ``` diff --git a/docs/src/tutorials/python.md b/docs/src/tutorials/python.md index c73d9ee5..319bd512 100644 --- a/docs/src/tutorials/python.md +++ b/docs/src/tutorials/python.md @@ -20,7 +20,7 @@ script. Save as `greeter.yml`: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: greeter errors: @@ -47,7 +47,18 @@ modules: `hello` can't fail, so it stays non-throwing. `greeting` declares `throws: true` and reports codes from the module's `GreeterError` -domain when the language is unknown. +domain when the language is unknown. Check it with +`weaveffi validate greeter.yml`; you should see `Validation passed`. + +Put a `weaveffi.toml` beside it so the Python distribution and import +package get a stable name (otherwise both default to the IDL's file stem, +`greeter`): + +```toml +[package] +name = "mygreeter" +version = "0.1.0" +``` ### 2. Generate bindings @@ -60,21 +71,21 @@ Among other targets, you should see: ```text generated/ ├── c/ +│ ├── weaveffi.c │ └── weaveffi.h └── python/ ├── pyproject.toml ├── setup.py ├── README.md - └── greeter/ + └── mygreeter/ ├── __init__.py ├── weaveffi.py └── weaveffi.pyi ``` The package directory and distribution name follow the `[package]` name -from `weaveffi.toml`, falling back to the IDL file stem (here `greeter`). -The Python target uses ctypes: no native extension to compile on the Python -side. +from `weaveffi.toml`. The Python target uses ctypes: no native extension to +compile on the Python side. ### 3. Implement the Rust library @@ -94,41 +105,57 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -weaveffi-abi = { version = "0.14" } +weaveffi = "0.22" ``` -`mygreeter/src/lib.rs`: +`mygreeter/src/lib.rs` is plain safe Rust. The macro reads the annotated +items and emits the `extern "C"` thunks the ctypes loader binds to, so the +module needs no `unsafe` and no hand-written signatures: ```rust -#![allow(unsafe_code)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use weaveffi_abi::{self as abi, weaveffi_error}; - -#[no_mangle] -pub extern "C" fn weaveffi_greeter_hello( - name: *const c_char, - out_err: *mut weaveffi_error, -) -> *const c_char { - abi::error_set_ok(out_err); - let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world"); - let msg = format!("Hello, {name}!"); - CString::new(msg).unwrap().into_raw() as *const c_char +#[weaveffi::module] +pub mod greeter { + /// Codes the greeter reports from its throwing functions. + #[weaveffi::error] + #[derive(Debug)] + pub enum GreeterError { + /// unknown language + UnknownLang = 1, + } + + /// A greeting and the language it was rendered in. + #[weaveffi::record] + pub struct Greeting { + pub message: String, + pub lang: String, + } + + /// Greet someone in English. + #[weaveffi::export] + pub fn hello(name: String) -> String { + format!("Hello, {name}!") + } + + /// Greet someone in the given language, failing on an unknown one. + #[weaveffi::export] + pub fn greeting(name: String, lang: String) -> Result { + let message = match lang.as_str() { + "en" => format!("Hello, {name}!"), + "es" => format!("Hola, {name}!"), + "fr" => format!("Bonjour, {name}!"), + _ => return Err(GreeterError::UnknownLang), + }; + Ok(Greeting { message, lang }) + } } -// Emit the WeaveFFI C ABI runtime symbols (abi_version, free_string, -// free_bytes, error_clear, cancel_token_*), one line per cdylib. -abi::export_runtime!(); +weaveffi::export_runtime!(); ``` -The generated `generated/c/weaveffi.h` lists every remaining symbol the -bindings expect, with exact signatures; implement `weaveffi_greeter_greeting` -the same way (it returns the `Greeting` record as a serialized value -buffer). Alternatively, annotate the module with `#[weaveffi::module]` and -let the macro emit the whole C ABI; see -[The Rust Producer Macro](../guides/producer-macro.md). +`weaveffi extract mygreeter/src/lib.rs` produces the same IDL as +`greeter.yml` (plus the doc comments), so the two stay in step; you can +generate from either. The [Producer Macro](../guides/producer-macro.md) +guide covers every attribute. ### 4. Build the cdylib @@ -171,14 +198,14 @@ the loader path. macOS: ```bash -cp target/release/libmygreeter.dylib target/release/libweaveffi.dylib +ln -sf libmygreeter.dylib target/release/libweaveffi.dylib DYLD_LIBRARY_PATH=target/release python demo.py ``` Linux: ```bash -cp target/release/libmygreeter.so target/release/libweaveffi.so +ln -sf libmygreeter.so target/release/libweaveffi.so LD_LIBRARY_PATH=target/release python demo.py ``` @@ -190,16 +217,18 @@ directory to `PATH`. Save as `demo.py`. Function names are snake_case with the module prefix stripped, and the throwing `greeting` raises the typed exception hierarchy (`GreeterError` extends `WeaveFFIError`, with an -`UnknownLang` subclass per code): +`UnknownLang` subclass per code, also reachable as +`GreeterError.UnknownLang`): ```python -from greeter import hello, greeting, GreeterError +from mygreeter import hello, greeting, GreeterError print(hello("Python")) try: g = greeting("Python", "en") print(f"{g.message} ({g.lang})") + greeting("Python", "tlh") except GreeterError as e: print(f"Error {e.code}: {e.message}") ``` @@ -209,25 +238,34 @@ the C ABI returns; there's no native allocation to manage. ## Verification -- `pip show greeter` lists the package. -- Running `demo.py` prints `Hello, Python!` and `Hi (en)` (or whatever - `Greeting` you constructed). -- `mypy demo.py` reports no errors thanks to the generated - `weaveffi.pyi` stub. +- `pip show mygreeter` lists the package. +- Running `demo.py` prints: + + ```text + Hello, Python! + Hello, Python! (en) + Error 1: unknown language + ``` + +- `mypy demo.py` reports no errors thanks to the generated `weaveffi.pyi` + stub, which the package ships alongside a PEP 561 `py.typed` marker so the + installed wheel is typed too. - Common error mappings: | Symptom | Likely cause | |-----------------------------------------------------------|-------------------------------------------------------------------------------| | `OSError: dlopen ... not found` | Cdylib not on the loader path; set `WEAVEFFI_LIBRARY` or the loader path. | - | `GreeterError: ...` at runtime | Rust reported a domain error code; inspect `e.code` and `e.message`. | - | `ModuleNotFoundError: No module named 'greeter'` | Package not installed; rerun `pip install .` from `generated/python/`. | - | mypy complains about `greeter` | Make sure `weaveffi.pyi` ships next to `weaveffi.py` in the package. | + | `mygreeter.weaveffi.UnknownLang: (1) unknown language` | Rust reported a domain error code; inspect `e.code` and `e.message`. | + | `WeaveFFIError` with a negative code | A producer panic (-2) or marshalling failure (-3); see [Error Handling](../guides/errors.md). | + | `ModuleNotFoundError: No module named 'mygreeter'` | Package not installed; rerun `pip install .` from `generated/python/`. | + | mypy says the module is untyped | Reinstall from a fresh `generated/python/` so `py.typed` and `weaveffi.pyi` ship. | ## Cleanup ```bash -pip uninstall greeter +pip uninstall mygreeter rm -rf generated/ +rm -f target/release/libweaveffi.dylib # the link alias from step 6 cargo clean -p mygreeter ``` diff --git a/docs/src/tutorials/swift.md b/docs/src/tutorials/swift.md index ebb364fa..bc7e3aa4 100644 --- a/docs/src/tutorials/swift.md +++ b/docs/src/tutorials/swift.md @@ -4,7 +4,8 @@ Build a small Rust greeter library, generate Swift bindings with WeaveFFI, and call them from a SwiftUI iOS app running in the -simulator. +simulator. A macOS command-line smoke test along the way proves the +Rust and the bindings agree before Xcode enters the picture. ## Prerequisites @@ -24,7 +25,7 @@ simulator. Save as `greeter.yml`: ```yaml -version: "0.8.0" +version: "0.9.0" modules: - name: greeter errors: @@ -51,7 +52,17 @@ modules: `hello` can't fail, so it stays non-throwing. `greeting` declares `throws: true` and reports codes from the module's `GreeterError` -domain when the language is unknown. +domain when the language is unknown. Check it with +`weaveffi validate greeter.yml`; you should see `Validation passed`. + +Put a `weaveffi.toml` beside it so the Swift package gets a stable name +(otherwise it defaults to the IDL's file stem, `greeter`): + +```toml +[package] +name = "mygreeter" +version = "0.1.0" +``` ### 2. Generate bindings @@ -64,16 +75,23 @@ You should see, among other targets: ```text generated/ ├── c/ +│ ├── weaveffi.c │ └── weaveffi.h └── swift/ ├── Package.swift └── Sources/ - ├── CWeaveFFI/ + ├── CMygreeter/ │ └── module.modulemap - └── WeaveFFI/ - └── WeaveFFI.swift + └── Mygreeter/ + └── Mygreeter.swift ``` +The package name and both target names are the capitalized `[package]` +name: `CMygreeter` is the SwiftPM system library that wraps +`generated/c/weaveffi.h`, and `Mygreeter` is the Swift wrapper you +`import`. `Package.swift` declares iOS 13, macOS 10.15, tvOS 13, and +watchOS 6 as the minimum platforms. + ### 3. Implement the Rust library ```bash @@ -92,41 +110,59 @@ edition = "2021" crate-type = ["staticlib", "cdylib"] [dependencies] -weaveffi-abi = { version = "0.14" } +weaveffi = "0.22" ``` -`mygreeter/src/lib.rs`: +The `staticlib` is what the iOS app links; the `cdylib` is for the macOS +smoke test in step 5. `mygreeter/src/lib.rs` is plain safe Rust. The macro +reads the annotated items and emits the `extern "C"` thunks the Swift +wrapper calls, so the module needs no `unsafe` and no hand-written +signatures: ```rust -#![allow(unsafe_code)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use weaveffi_abi::{self as abi, weaveffi_error}; - -#[no_mangle] -pub extern "C" fn weaveffi_greeter_hello( - name: *const c_char, - out_err: *mut weaveffi_error, -) -> *const c_char { - abi::error_set_ok(out_err); - let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world"); - let msg = format!("Hello, {name}!"); - CString::new(msg).unwrap().into_raw() as *const c_char +#[weaveffi::module] +pub mod greeter { + /// Codes the greeter reports from its throwing functions. + #[weaveffi::error] + #[derive(Debug)] + pub enum GreeterError { + /// unknown language + UnknownLang = 1, + } + + /// A greeting and the language it was rendered in. + #[weaveffi::record] + pub struct Greeting { + pub message: String, + pub lang: String, + } + + /// Greet someone in English. + #[weaveffi::export] + pub fn hello(name: String) -> String { + format!("Hello, {name}!") + } + + /// Greet someone in the given language, failing on an unknown one. + #[weaveffi::export] + pub fn greeting(name: String, lang: String) -> Result { + let message = match lang.as_str() { + "en" => format!("Hello, {name}!"), + "es" => format!("Hola, {name}!"), + "fr" => format!("Bonjour, {name}!"), + _ => return Err(GreeterError::UnknownLang), + }; + Ok(Greeting { message, lang }) + } } -// Emit the WeaveFFI C ABI runtime symbols (abi_version, free_string, -// free_bytes, error_clear, cancel_token_*), one line per cdylib. -abi::export_runtime!(); +weaveffi::export_runtime!(); ``` -The generated `generated/c/weaveffi.h` lists every remaining symbol with -its exact signature; implement `weaveffi_greeter_greeting` the same way -(it returns the `Greeting` record as a serialized value buffer). -Alternatively, annotate the module with `#[weaveffi::module]` and let the -macro emit the whole C ABI; see -[The Rust Producer Macro](../guides/producer-macro.md). +`weaveffi extract mygreeter/src/lib.rs` produces the same IDL as +`greeter.yml` (plus the doc comments), so the two stay in step; you can +generate from either. The [Producer Macro](../guides/producer-macro.md) +guide covers every attribute. ### 4. Build for iOS targets @@ -136,45 +172,88 @@ cargo build -p mygreeter --target aarch64-apple-ios-sim --release cargo build -p mygreeter --target x86_64-apple-ios --release ``` -Combine the simulator architectures with `lipo` and bundle everything -in an `XCFramework` so Xcode can pick the right slice automatically: +The generated module map autolinks a library called `weaveffi` +(`link "weaveffi"`), so name the archives you hand to Xcode +`libweaveffi.a`. Combine the simulator architectures with `lipo` and +bundle everything in an `XCFramework` so Xcode can pick the right slice +automatically: ```bash mkdir -p target/universal-ios-sim/release lipo -create \ target/aarch64-apple-ios-sim/release/libmygreeter.a \ target/x86_64-apple-ios/release/libmygreeter.a \ - -output target/universal-ios-sim/release/libmygreeter.a + -output target/universal-ios-sim/release/libweaveffi.a +cp target/aarch64-apple-ios/release/libmygreeter.a \ + target/aarch64-apple-ios/release/libweaveffi.a xcodebuild -create-xcframework \ - -library target/aarch64-apple-ios/release/libmygreeter.a \ + -library target/aarch64-apple-ios/release/libweaveffi.a \ -headers generated/c/ \ - -library target/universal-ios-sim/release/libmygreeter.a \ + -library target/universal-ios-sim/release/libweaveffi.a \ -headers generated/c/ \ -output MyGreeter.xcframework ``` -### 5. Wire it into Xcode +### 5. Optional: smoke-test on macOS + +Before opening Xcode, compile the generated wrapper together with a +`main.swift` against the host cdylib. Build it, give it the `libweaveffi` +name the module map links, and run: + +```bash +cargo build -p mygreeter --release +ln -sf libmygreeter.dylib target/release/libweaveffi.dylib +``` + +`main.swift`: + +```swift +print(Greeter.hello(name: "Swift")) +do { + let g = try Greeter.greeting(name: "Ada", lang: "fr") + print("\(g.message) (\(g.lang))") + _ = try Greeter.greeting(name: "Ada", lang: "tlh") +} catch let e as GreeterError { + print("greeting failed: \(e.localizedDescription) (code \(e.errorCode))") +} +``` + +```bash +swiftc \ + -I generated/swift/Sources/CMygreeter \ + -L target/release \ + -Xlinker -rpath -Xlinker target/release \ + generated/swift/Sources/Mygreeter/Mygreeter.swift main.swift -o greet +./greet +``` + +Expected output: + +```text +Hello, Swift! +Bonjour, Ada! (fr) +greeting failed: unknown language (code 1) +``` + +### 6. Wire it into Xcode 1. Create a new iOS App in Xcode (SwiftUI or UIKit). 2. Drag `MyGreeter.xcframework` into the project navigator. Confirm it appears under **Build Phases > Link Binary With Libraries**. 3. **File > Add Package Dependencies > Add Local…** and pick - `generated/swift/`. The package contributes the `CWeaveFFI` and - `WeaveFFI` targets. + `generated/swift/`. The package contributes the `CMygreeter` and + `Mygreeter` targets; add the `Mygreeter` library product to your app. 4. **Build Settings > Header Search Paths**: add the path to - `generated/c/` (e.g. `$(SRCROOT)/../generated/c`). -5. **Build Settings > Library Search Paths**: add the path to the - matching Rust static library - (`$(SRCROOT)/../target/aarch64-apple-ios/release` for device - builds). -6. **Build Phases > Dependencies**: ensure `WeaveFFI` is listed. + `generated/c/` (e.g. `$(SRCROOT)/../generated/c`), which the module + map's relative `header` path resolves against. +5. **Build Phases > Dependencies**: ensure `Mygreeter` is listed. -### 6. Call from Swift +### 7. Call from Swift ```swift import SwiftUI -import WeaveFFI +import Mygreeter struct ContentView: View { @State private var greeting = "" @@ -185,49 +264,59 @@ struct ContentView: View { Button("Greet") { greeting = Greeter.hello(name: "Swift") } + Button("Greet in French") { + do { + greeting = try Greeter.greeting(name: "Swift", lang: "fr").message + } catch { + greeting = error.localizedDescription + } + } } .padding() } } ``` -The generated `WeaveFFI` module exposes: +The generated `Mygreeter` module exposes: - `Greeter.hello(name:)`: non-throwing, returns `String`. - `Greeter.greeting(name:lang:)`: declared `throws` in the IDL, so the Swift wrapper is `throws` and surfaces `GreeterError`; returns a `Greeting` value with `.message` and `.lang` properties. - `GreeterError`: the module's error domain as a Swift `enum` - conforming to `Error` and `LocalizedError`. + conforming to `Error` and `LocalizedError`, one case per code + (`.unknownLang(message:)`) plus an `errorCode` accessor. Runtime traps + (a producer panic or a marshalling failure) arrive as the generic + `WeaveFFIError.error(code:message:)` instead. - `Greeting`: a plain Swift struct decoded from the value buffer the - C ABI returns. + C ABI returns, with a public memberwise initializer. ## Verification - Select an iOS Simulator target and press **Cmd+R**. - Tap **Greet** in the running app; the label changes to - `Hello, Swift!`. + `Hello, Swift!`. **Greet in French** changes it to `Bonjour, Swift!`. - Re-run on a physical device after building for `aarch64-apple-ios` to confirm the device path also works. - Common error mappings: | Symptom | Likely cause | |---------------------------------------------------|------------------------------------------------------------------------------| - | `Undefined symbols for architecture arm64` | Static library not linked or the search path is wrong. | - | `Module 'CWeaveFFI' not found` | Header search path does not point at `generated/c/`. | - | `No such module 'WeaveFFI'` | Local Swift package not added under **Add Package Dependencies > Add Local…**.| + | `Undefined symbols for architecture arm64` | Static library not linked, or it isn't named `libweaveffi.a`. | + | `Module 'CMygreeter' not found` | Header search path does not point at `generated/c/`. | + | `No such module 'Mygreeter'` | Local Swift package not added under **Add Package Dependencies > Add Local…**.| | Crash when running on Intel simulator | Build for `x86_64-apple-ios` and combine with `lipo`. | ## Cleanup ```bash -rm -rf generated/ MyGreeter.xcframework +rm -rf generated/ MyGreeter.xcframework main.swift greet +rm -f target/release/libweaveffi.dylib # the link alias from step 5 cargo clean -p mygreeter ``` Remove the `MyGreeter.xcframework` reference from the Xcode project -and undo the **Header Search Paths** / **Library Search Paths** -edits. +and undo the **Header Search Paths** edit. ## Next steps @@ -236,4 +325,4 @@ edits. - Read the [Memory Ownership](../guides/memory.md) guide to understand buffered value and interface lifetime rules. - Try the [Calculator tutorial](calculator.md) for a simpler - end-to-end walkthrough or [Android](android.md) for a JVM target. + end-to-end walkthrough or [Kotlin](kotlin.md) for a JVM target. diff --git a/samples/async-demo/src/lib.rs b/samples/async-demo/src/lib.rs index c28fbe66..1e00ed12 100644 --- a/samples/async-demo/src/lib.rs +++ b/samples/async-demo/src/lib.rs @@ -214,10 +214,8 @@ mod tests { let (tx, rx) = mpsc::channel::(); let tx_ptr = Box::into_raw(Box::new(tx)); - // A null string lifts leniently to "" on the async path (the launcher - // has no out_err slot), so the producer's empty-name check rejects it - // with the TaskError::InvalidName domain code. - weaveffi_tasks_run_task_async(std::ptr::null(), task_callback, tx_ptr as *mut c_void); + let empty = CString::new("").unwrap(); + weaveffi_tasks_run_task_async(empty.as_ptr(), task_callback, tx_ptr as *mut c_void); let (code, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); leak_ctx(tx_ptr); @@ -225,6 +223,22 @@ mod tests { assert!(result.is_none(), "error path passes a null result buffer"); } + #[test] + fn run_task_null_name_reports_marshal_error_through_the_callback() { + let (tx, rx) = mpsc::channel::(); + let tx_ptr = Box::into_raw(Box::new(tx)); + + // The launcher has no out_err slot, so an argument that fails to lift + // is reported through the completion callback with the reserved + // marshalling code, exactly like the sync path would report it. + weaveffi_tasks_run_task_async(std::ptr::null(), task_callback, tx_ptr as *mut c_void); + + let (code, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + leak_ctx(tx_ptr); + assert_eq!(code, abi::MARSHAL_ERROR_CODE); + assert!(result.is_none()); + } + #[test] fn run_batch_processes_sequentially() { let (tx, rx) = mpsc::channel::(); diff --git a/samples/codec/Cargo.toml b/samples/codec/Cargo.toml new file mode 100644 index 00000000..c304bdbe --- /dev/null +++ b/samples/codec/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "codec" +version = "0.1.0" +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +weaveffi = { path = "../../crates/weaveffi" } + +[lints] +workspace = true diff --git a/samples/codec/src/lib.rs b/samples/codec/src/lib.rs new file mode 100644 index 00000000..de7a27ba --- /dev/null +++ b/samples/codec/src/lib.rs @@ -0,0 +1,522 @@ +//! Codec sample cdylib: a round-trip oracle for the WeaveFFI value-buffer +//! protocol. +//! +//! Every generated binding ships its own encoder and decoder for records, rich +//! enums, optionals, lists, maps, and object tokens. This module gives the +//! conformance harness one producer that exercises every wire shape in both +//! directions, so a codec bug in any language shows up as a concrete mismatch: +//! +//! * `sample_*` functions return a canonical fixture the consumer checks field +//! by field (producer encodes, consumer decodes); +//! * `verify_*` functions take the same fixture back and fail with +//! [`codec::CodecError::Mismatch`] unless it decodes to exactly the canonical +//! value (consumer encodes, producer decodes); +//! * `roundtrip_*` functions return their argument unchanged, which covers the +//! direct, string, and bytes families and the 64-bit edge values; +//! * `describe_*` functions render a value as text so a failing consumer can +//! print what the producer actually saw. +//! +//! The producer itself is pure safe Rust; the `#[weaveffi::module]` expansion +//! supplies the `BufferValue` codecs it is checking the consumers against. + +/// Value-buffer round-trip oracle covering every wire shape. +#[weaveffi::module] +pub mod codec { + use std::collections::BTreeMap; + use std::sync::Arc; + + /// The oracle's error domain. + #[weaveffi::error] + #[derive(Debug)] + pub enum CodecError { + /// value does not match the canonical fixture + Mismatch = 1, + } + + /// A C-style enum (crosses by value, and as an `i32` inside buffers). + #[weaveffi::enumeration] + #[repr(i32)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] + pub enum Color { + /// Red. + Red = 0, + /// Green. + Green = 1, + /// Blue. + Blue = 7, + } + + /// Every fixed-width scalar the protocol defines. + #[weaveffi::record] + #[derive(Clone, Debug, PartialEq)] + pub struct Scalars { + /// Signed 8-bit. + pub i8_value: i8, + /// Unsigned 8-bit. + pub u8_value: u8, + /// Signed 16-bit. + pub i16_value: i16, + /// Unsigned 16-bit. + pub u16_value: u16, + /// Signed 32-bit. + pub i32_value: i32, + /// Unsigned 32-bit. + pub u32_value: u32, + /// Signed 64-bit. + pub i64_value: i64, + /// Unsigned 64-bit. + pub u64_value: u64, + /// 32-bit float. + pub f32_value: f32, + /// 64-bit float. + pub f64_value: f64, + /// Boolean. + pub flag: bool, + /// C-style enum. + pub color: Color, + } + + /// A rich enum: unit, scalar, mixed, string, and nested-record variants. + #[weaveffi::enumeration] + #[derive(Clone, Debug, PartialEq)] + pub enum Shape { + /// No payload. + Empty, + /// One `f64`. + Circle { + /// Radius. + radius: f64, + }, + /// Two `f32`s. + Rect { + /// Width. + width: f32, + /// Height. + height: f32, + }, + /// A string and an `i32`. + Labeled { + /// Label text. + label: String, + /// Repeat count. + count: i32, + }, + /// A nested record and an optional. + Nested { + /// Inner record. + inner: Scalars, + /// Optional note. + note: Option, + }, + } + + /// Every composite wire shape, including nesting. + #[weaveffi::record] + #[derive(Clone, Debug, PartialEq)] + pub struct Composite { + /// UTF-8 text, including non-ASCII. + pub name: String, + /// Raw bytes. + pub blob: Vec, + /// A present optional. + pub some_i64: Option, + /// An absent optional. + pub none_i64: Option, + /// An optional string. + pub some_text: Option, + /// A list of strings. + pub names: Vec, + /// A list of lists. + pub matrix: Vec>, + /// An empty list. + pub empty: Vec, + /// A string-keyed map. + pub by_name: BTreeMap, + /// An integer-keyed map with record values. + pub by_id: BTreeMap, + /// A nested record. + pub scalars: Scalars, + /// A rich enum. + pub shape: Shape, + /// A list of rich enums, one of each variant. + pub shapes: Vec, + /// An optional rich enum. + pub maybe_shape: Option, + /// An optional list. + pub maybe_list: Option>, + /// A list of optionals. + pub sparse: Vec>, + /// A list of C-style enums. + pub colors: Vec, + } + + /// An opaque object whose identity and value are checked through buffers. + #[weaveffi::interface] + pub struct Token { + value: i64, + } + + impl Token { + /// Create a token. + pub fn new(value: i64) -> Token { + Token { value } + } + + /// The wrapped value. + pub fn value(&self) -> i64 { + self.value + } + } + + /// Objects in every buffered position: a field, an optional, and a list. + #[weaveffi::record] + #[derive(Clone)] + pub struct Holder { + /// A required object. + pub primary: Arc, + /// An optional object. + pub spare: Option>, + /// A list of objects. + pub many: Vec>, + } + + /// The canonical `Scalars` fixture. + fn canonical_scalars() -> Scalars { + Scalars { + i8_value: -8, + u8_value: 200, + i16_value: -16_000, + u16_value: 60_000, + i32_value: -2_000_000_000, + u32_value: 4_000_000_000, + i64_value: -9_007_199_254_740_993, + u64_value: 18_446_744_073_709_551_615, + f32_value: 1.5, + f64_value: -2.25e100, + flag: true, + color: Color::Blue, + } + } + + /// The canonical `Composite` fixture. + fn canonical_composite() -> Composite { + let scalars = canonical_scalars(); + let mut by_name = BTreeMap::new(); + by_name.insert("one".to_string(), 1); + by_name.insert("two".to_string(), 2); + by_name.insert("neg".to_string(), -3); + let mut by_id = BTreeMap::new(); + by_id.insert(-1, scalars.clone()); + by_id.insert( + 42, + Scalars { + flag: false, + ..scalars.clone() + }, + ); + Composite { + name: "héllo wörld ✓".to_string(), + blob: vec![0, 1, 2, 253, 254, 255], + some_i64: Some(i64::MIN), + none_i64: None, + some_text: Some(String::new()), + names: vec!["a".to_string(), String::new(), "ccc".to_string()], + matrix: vec![vec![1, 2, 3], vec![], vec![-4]], + empty: vec![], + by_name, + by_id, + scalars: scalars.clone(), + shape: Shape::Labeled { + label: "tag".to_string(), + count: 3, + }, + shapes: vec![ + Shape::Empty, + Shape::Circle { radius: 2.5 }, + Shape::Rect { + width: 1.0, + height: 0.5, + }, + Shape::Labeled { + label: String::new(), + count: -1, + }, + Shape::Nested { + inner: scalars, + note: Some("n".to_string()), + }, + ], + maybe_shape: Some(Shape::Nested { + inner: canonical_scalars(), + note: None, + }), + maybe_list: Some(vec![9, 8]), + sparse: vec![Some(true), None, Some(false)], + colors: vec![Color::Red, Color::Green, Color::Blue], + } + } + + /// The canonical `Scalars` value. + #[weaveffi::export] + pub fn sample_scalars() -> Scalars { + canonical_scalars() + } + + /// Fail unless `value` is exactly the canonical `Scalars`. + #[weaveffi::export] + pub fn verify_scalars(value: &Scalars) -> Result { + if *value == canonical_scalars() { + Ok(true) + } else { + Err(CodecError::Mismatch) + } + } + + /// The canonical `Composite` value. + #[weaveffi::export] + pub fn sample_composite() -> Composite { + canonical_composite() + } + + /// Fail unless `value` is exactly the canonical `Composite`. + #[weaveffi::export] + pub fn verify_composite(value: &Composite) -> Result { + if *value == canonical_composite() { + Ok(true) + } else { + Err(CodecError::Mismatch) + } + } + + /// Render a `Composite` as text (a debugging aid for failing consumers). + #[weaveffi::export] + pub fn describe_composite(value: &Composite) -> String { + format!("{value:?}") + } + + /// Render a `Shape` as text. + #[weaveffi::export] + pub fn describe_shape(value: &Shape) -> String { + format!("{value:?}") + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_scalars(value: Scalars) -> Scalars { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_composite(value: Composite) -> Composite { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_shape(value: Shape) -> Shape { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_shapes(value: Vec) -> Vec { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_opt_i64(value: Option) -> Option { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_map(value: BTreeMap) -> BTreeMap { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_string(value: String) -> String { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_bytes(value: Vec) -> Vec { + value + } + + /// Return the argument unchanged (covers the full 64-bit range). + #[weaveffi::export] + pub fn roundtrip_i64(value: i64) -> i64 { + value + } + + /// Return the argument unchanged (covers values above `2^63`). + #[weaveffi::export] + pub fn roundtrip_u64(value: u64) -> u64 { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_f64(value: f64) -> f64 { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_bool(value: bool) -> bool { + value + } + + /// Return the argument unchanged. + #[weaveffi::export] + pub fn roundtrip_color(value: Color) -> Color { + value + } + + /// Build a holder whose tokens carry `base`, `base + 1` (spare), and + /// `base + 2 ..= base + 4` (many). + #[weaveffi::export] + pub fn make_holder(base: i64, with_spare: bool) -> Holder { + Holder { + primary: Arc::new(Token::new(base)), + spare: with_spare.then(|| Arc::new(Token::new(base.wrapping_add(1)))), + many: (2..5) + .map(|i| Arc::new(Token::new(base.wrapping_add(i)))) + .collect(), + } + } + + /// Sum every token value inside `holder`, wrapping on overflow so that + /// consumer-built holders with extreme values can't trip a debug panic. + #[weaveffi::export] + pub fn sum_holder(holder: &Holder) -> i64 { + std::iter::once(&holder.primary) + .chain(holder.spare.iter()) + .chain(holder.many.iter()) + .fold(0i64, |acc, t| acc.wrapping_add(t.value())) + } + + /// The primary token of `holder`, as an object return. + #[weaveffi::export] + pub fn primary_of(holder: Holder) -> Arc { + holder.primary + } + + /// Whether two holders share the same primary object. + #[weaveffi::export] + pub fn same_primary(a: &Holder, b: &Holder) -> bool { + Arc::ptr_eq(&a.primary, &b.primary) + } +} + +weaveffi::export_runtime!(); + +#[cfg(test)] +#[allow(unsafe_code)] +mod tests { + use crate::codec::*; + use std::sync::Arc; + use weaveffi::abi::{self, weaveffi_error}; + + fn decode_and_free(ptr: *const u8, len: usize) -> T { + assert!(!ptr.is_null()); + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + let value = abi::decode_value::(bytes).expect("well-formed value buffer"); + abi::free_bytes(ptr as *mut u8, len); + value + } + + #[test] + fn composite_round_trips_through_the_abi() { + let mut err = weaveffi_error::default(); + let mut len = 0usize; + let ptr = weaveffi_codec_sample_composite(&mut len, &mut err); + let sample = decode_and_free::(ptr, len); + assert_eq!(sample.names.len(), 3); + assert_eq!(sample.by_id.len(), 2); + + let bytes = abi::encode_value(&sample); + assert!(weaveffi_codec_verify_composite( + bytes.as_ptr(), + bytes.len(), + &mut err + )); + assert_eq!(err.code, 0); + + let mut changed = sample.clone(); + changed.sparse[1] = Some(true); + let bytes = abi::encode_value(&changed); + assert!(!weaveffi_codec_verify_composite( + bytes.as_ptr(), + bytes.len(), + &mut err + )); + assert_eq!(err.code, 1); + abi::error_clear(&mut err); + } + + #[test] + fn scalars_round_trip() { + let mut err = weaveffi_error::default(); + let mut len = 0usize; + let ptr = weaveffi_codec_sample_scalars(&mut len, &mut err); + let sample = decode_and_free::(ptr, len); + assert_eq!(sample.u64_value, u64::MAX); + let bytes = abi::encode_value(&sample); + let out = weaveffi_codec_roundtrip_scalars(bytes.as_ptr(), bytes.len(), &mut len, &mut err); + assert_eq!(decode_and_free::(out, len), sample); + assert_eq!(weaveffi_codec_roundtrip_i64(i64::MIN, &mut err), i64::MIN); + assert_eq!(weaveffi_codec_roundtrip_u64(u64::MAX, &mut err), u64::MAX); + assert_eq!( + weaveffi_codec_roundtrip_color(Color::Blue as i32, &mut err), + Color::Blue as i32 + ); + } + + #[test] + fn holders_carry_object_references() { + let mut err = weaveffi_error::default(); + let mut len = 0usize; + let ptr = weaveffi_codec_make_holder(10, true, &mut len, &mut err); + let holder = decode_and_free::(ptr, len); + assert_eq!(holder.primary.value(), 10); + assert_eq!(holder.spare.as_ref().unwrap().value(), 11); + assert_eq!(holder.many.len(), 3); + + // Every encoding carries one fresh reference per token, and every + // decode adopts it, so an encoded buffer is consumed exactly once. + let bytes = abi::encode_value(&holder); + assert_eq!( + weaveffi_codec_sum_holder(bytes.as_ptr(), bytes.len(), &mut err), + 10 + 11 + 12 + 13 + 14 + ); + assert_eq!(Arc::strong_count(&holder.primary), 1); + + let bytes = abi::encode_value(&holder); + let primary = weaveffi_codec_primary_of(bytes.as_ptr(), bytes.len(), &mut err); + assert_eq!(primary as *const Token, Arc::as_ptr(&holder.primary)); + assert_eq!(Arc::strong_count(&holder.primary), 2); + weaveffi_codec_Token_destroy(primary); + assert_eq!(Arc::strong_count(&holder.primary), 1); + + let a = abi::encode_value(&holder); + let b = abi::encode_value(&holder); + assert!(weaveffi_codec_same_primary( + a.as_ptr(), + a.len(), + b.as_ptr(), + b.len(), + &mut err + )); + assert_eq!(Arc::strong_count(&holder.primary), 1); + + let ptr = weaveffi_codec_make_holder(0, false, &mut len, &mut err); + let without = decode_and_free::(ptr, len); + assert!(without.spare.is_none()); + } +} diff --git a/samples/codec/weaveffi.toml b/samples/codec/weaveffi.toml new file mode 100644 index 00000000..42ae2fad --- /dev/null +++ b/samples/codec/weaveffi.toml @@ -0,0 +1,26 @@ +[package] +name = "codec" +version = "1.0.0" +description = "Value-buffer round-trip oracle covering every wire shape." +license = "MIT" +authors = ["WeaveFoundry "] +homepage = "https://github.com/weavefoundry/weaveffi" +repository = "https://github.com/weavefoundry/weaveffi" + +[generators.ruby] +module_name = "Codec" + +[generators.dotnet] +namespace = "Codec" + +[generators.swift] +module_name = "Codec" + +[generators.cpp] +namespace = "codec" + +[generators.dart] +package_name = "codec" + +[generators.go] +module_path = "github.com/example/codec" diff --git a/samples/events/src/lib.rs b/samples/events/src/lib.rs index 5c923aa0..b57ecaf0 100644 --- a/samples/events/src/lib.rs +++ b/samples/events/src/lib.rs @@ -1,49 +1,181 @@ -//! Events sample cdylib demonstrating module-level callbacks, listeners, and an -//! iterator over the WeaveFFI C ABI. +//! Events sample cdylib: a publish/subscribe bus built on a WeaveFFI +//! callback interface, a reference-counted object, and an iterator. //! -//! The `#[weaveffi::module]` expansion emits *exactly* the ABI the WeaveFFI -//! generators emit (see the generated `weaveffi.h`): a context-carrying -//! callback typedef, `register_*`/`unregister_*` returning a `uint64_t` -//! subscription id, and an opaque iterator with an +//! The `#[weaveffi::module]` expansion emits exactly the ABI the WeaveFFI +//! generators bind to (see the generated `weaveffi.h`): a `Subscriber` vtable +//! the consumer implements, an `EventBus` object with `_clone`/`_destroy` +//! reference counting, and an opaque iterator with an //! `int32_t next(iter, out_item, out_err)` contract. The conformance harness -//! binds the *generated* wrappers against this library, so the two must agree. +//! binds the generated wrappers of every language against this library, so the +//! two must agree. //! -//! The producer writes only safe Rust: it declares the callback and listener -//! with marker attributes, keeps a message log, and calls the generated -//! `emit_message_listener` helper to fan a message out to every subscriber. +//! The producer writes only safe Rust. The consumer's subscriber arrives as an +//! `Arc`; the bus retains it for as long as it likes and the +//! consumer's `free` entry fires when the last reference drops. A subscriber +//! that fails aborts the publishing call with `FOREIGN_ERROR_CODE`, so the bus +//! snapshots its subscriber list before calling out and never holds a lock +//! across a callback. -/// Callback and listener-based event subscription example. +/// A publish/subscribe event bus driven by a consumer-implemented subscriber. #[weaveffi::module] pub mod events { - use std::sync::Mutex; + use std::sync::{Arc, Mutex, PoisonError}; - /// Every message ever sent, replayed by `get_messages`. `pub(crate)` so the - /// in-crate unit tests can reset it between runs; it is not a tagged item, - /// so it never appears in the generated bindings. - pub(crate) static MESSAGES: Mutex> = Mutex::new(Vec::new()); + /// How a subscriber wants to be told about a message. + #[weaveffi::enumeration] + #[repr(i32)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Delivery { + /// Deliver the message. + Accept = 0, + /// Skip this subscriber for this message. + Skip = 1, + /// Deliver the message and stop delivering to later subscribers. + AcceptAndStop = 2, + } - /// Fires once per sent message with the message text. - #[weaveffi::callback] - #[allow(non_snake_case, dead_code, unused_variables)] - fn OnMessage(message: String) {} + /// A published message as subscribers see it. + #[weaveffi::record] + #[derive(Clone, Debug, PartialEq)] + pub struct Message { + /// Monotonic sequence number, starting at 1. + pub seq: i64, + /// Topic the message was published under. + pub topic: String, + /// Message text. + pub text: String, + /// Free-form labels attached at publish time. + pub tags: Vec, + } - /// The set of subscribers notified on each `send_message`. - #[weaveffi::listener(event = "OnMessage")] - #[allow(dead_code)] - fn message_listener() {} + /// A consumer-implemented subscriber. The bus asks `route` whether to + /// deliver each message and then calls `on_message` for accepted ones. + #[weaveffi::callback_interface] + pub trait Subscriber: Send + Sync { + /// Decide how the bus should treat `topic` for this subscriber. + fn route(&self, topic: String) -> Delivery; + /// Receive an accepted message. Returns the subscriber's running count + /// of received messages. + fn on_message(&self, message: &Message) -> i64; + /// Receive the bus itself (an object handed through a callback). The + /// consumer adopts the reference and may keep or drop it. + fn on_attached(&self, bus: Arc); + } - /// Send a message, triggering the OnMessage callback - #[weaveffi::export] - pub fn send_message(text: String) { - MESSAGES.lock().unwrap().push(text.clone()); - emit_message_listener(&text); + /// A bus that retains its subscribers and logs every message. + #[weaveffi::interface] + pub struct EventBus { + subscribers: Mutex>>, + log: Mutex>, } - /// Return an iterator over all sent messages + impl EventBus { + /// Create an empty bus. + pub fn new() -> Arc { + Arc::new(Self { + subscribers: Mutex::new(Vec::new()), + log: Mutex::new(Vec::new()), + }) + } + + /// Retain `subscriber` and tell it which bus it joined. Returns the + /// new subscriber count. + pub fn subscribe(self: Arc, subscriber: Arc) -> i64 { + subscriber.on_attached(Arc::clone(&self)); + let mut subs = self + .subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner); + subs.push(subscriber); + subs.len() as i64 + } + + /// Publish `text` under `topic`, returning how many subscribers + /// accepted it. A subscriber failure aborts the call. + pub fn publish(&self, topic: String, text: String, tags: Vec) -> i64 { + let message = { + let mut log = self.log.lock().unwrap_or_else(PoisonError::into_inner); + let message = Message { + seq: log.len() as i64 + 1, + topic, + text, + tags, + }; + log.push(message.clone()); + message + }; + // Snapshot so no lock is held while the consumer runs. + let subs: Vec> = self + .subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + let mut delivered = 0; + for sub in &subs { + match sub.route(message.topic.clone()) { + Delivery::Skip => {} + Delivery::Accept => { + sub.on_message(&message); + delivered += 1; + } + Delivery::AcceptAndStop => { + sub.on_message(&message); + delivered += 1; + break; + } + } + } + delivered + } + + /// Publish from a producer thread, resolving with the accepted count. + pub async fn publish_later(&self, topic: String, text: String) -> i64 { + self.publish(topic, text, Vec::new()) + } + + /// Number of retained subscribers. + pub fn subscriber_count(&self) -> i64 { + self.subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .len() as i64 + } + + /// Drop every subscriber; each consumer `free` entry runs when its + /// last reference goes away. + pub fn clear_subscribers(&self) { + self.subscribers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } + + /// Stream the text of every message published so far, in order. + pub fn messages(&self) -> weaveffi::Iter { + let texts: Vec = self + .log + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .map(|m| m.text.clone()) + .collect(); + weaveffi::Iter::new(texts) + } + + /// The most recent message, if any. + pub fn last_message(&self) -> Option { + self.log + .lock() + .unwrap_or_else(PoisonError::into_inner) + .last() + .cloned() + } + } + + /// Ask `subscriber` how it would route `topic` without a bus. #[weaveffi::export] - pub fn get_messages() -> weaveffi::Iter { - let messages = MESSAGES.lock().unwrap().clone(); - weaveffi::Iter::new(messages) + pub fn route_once(subscriber: Arc, topic: String) -> Delivery { + subscriber.route(topic) } } @@ -55,106 +187,210 @@ mod tests { use crate::events::*; use std::ffi::CString; use std::os::raw::{c_char, c_void}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Mutex; + use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; + use std::sync::Arc; use weaveffi::abi::{self, weaveffi_error}; - static TEST_MUTEX: Mutex<()> = Mutex::new(()); + fn new_err() -> weaveffi_error { + weaveffi_error::default() + } - fn setup() -> std::sync::MutexGuard<'static, ()> { - let guard = TEST_MUTEX.lock().unwrap(); - crate::events::MESSAGES.lock().unwrap().clear(); - guard + /// A consumer-side subscriber, exactly as a generated binding builds one: + /// a heap-allocated context and a process-wide vtable. + struct SubState { + received: AtomicI64, + attached: AtomicUsize, + skip_topic: String, + fail_topic: String, + freed: Arc, } - fn new_err() -> weaveffi_error { - weaveffi_error::default() + unsafe extern "C" fn route( + ctx: *mut c_void, + topic: *const c_char, + out_err: *mut weaveffi_error, + ) -> i32 { + let state = &*(ctx as *const SubState); + let topic = abi::c_ptr_to_string(topic).unwrap(); + if topic == state.fail_topic { + abi::error_set( + out_err, + abi::FOREIGN_ERROR_CODE, + "subscriber rejected topic", + ); + return 0; + } + if topic == state.skip_topic { + Delivery::Skip as i32 + } else if topic == "stop" { + Delivery::AcceptAndStop as i32 + } else { + Delivery::Accept as i32 + } + } + + unsafe extern "C" fn on_message( + ctx: *mut c_void, + message_ptr: *const u8, + message_len: usize, + _out_err: *mut weaveffi_error, + ) -> i64 { + let state = &*(ctx as *const SubState); + let message: Message = + abi::decode_value(std::slice::from_raw_parts(message_ptr, message_len)).unwrap(); + assert!(message.seq >= 1); + state.received.fetch_add(1, Ordering::SeqCst) + 1 + } + + unsafe extern "C" fn on_attached( + ctx: *mut c_void, + bus: *mut EventBus, + _out_err: *mut weaveffi_error, + ) { + let state = &*(ctx as *const SubState); + state.attached.fetch_add(1, Ordering::SeqCst); + // The reference is ours: dropping it must not free the live bus. + weaveffi_events_EventBus_destroy(bus); + } + + unsafe extern "C" fn free(ctx: *mut c_void) { + let state = Box::from_raw(ctx as *mut SubState); + state.freed.fetch_add(1, Ordering::SeqCst); + } + + static VTABLE: weaveffi_events_Subscriber_vtable = weaveffi_events_Subscriber_vtable { + route, + on_message, + on_attached, + free, + }; + + fn new_sub(skip: &str, fail: &str, freed: &Arc) -> *mut c_void { + Box::into_raw(Box::new(SubState { + received: AtomicI64::new(0), + attached: AtomicUsize::new(0), + skip_topic: skip.to_string(), + fail_topic: fail.to_string(), + freed: Arc::clone(freed), + })) as *mut c_void + } + + fn publish(bus: *mut EventBus, topic: &str, text: &str, err: &mut weaveffi_error) -> i64 { + let topic = CString::new(topic).unwrap(); + let text = CString::new(text).unwrap(); + let tags = abi::encode_value(&vec!["a".to_string()]); + weaveffi_events_EventBus_publish( + bus, + topic.as_ptr(), + text.as_ptr(), + tags.as_ptr(), + tags.len(), + err, + ) } #[test] - fn send_and_iterate_messages() { - let _g = setup(); + fn subscribe_publish_and_iterate() { let mut err = new_err(); + let freed = Arc::new(AtomicUsize::new(0)); + let bus = weaveffi_events_EventBus_new(&mut err); + assert!(!bus.is_null()); - for t in ["hello", "world"] { - let c = CString::new(t).unwrap(); - weaveffi_events_send_message(c.as_ptr(), &mut err); - assert_eq!(err.code, 0); - } + let a = new_sub("quiet", "", &freed); + let b = new_sub("", "", &freed); + assert_eq!( + weaveffi_events_EventBus_subscribe(bus, a, &VTABLE, &mut err), + 1 + ); + assert_eq!( + weaveffi_events_EventBus_subscribe(bus, b, &VTABLE, &mut err), + 2 + ); + let a_state = unsafe { &*(a as *const SubState) }; + assert_eq!(a_state.attached.load(Ordering::SeqCst), 1); - let iter = weaveffi_events_get_messages(&mut err); + assert_eq!(publish(bus, "news", "hello", &mut err), 2); + assert_eq!(publish(bus, "quiet", "psst", &mut err), 1); + assert_eq!(publish(bus, "stop", "last", &mut err), 1); assert_eq!(err.code, 0); - assert!(!iter.is_null()); + let iter = weaveffi_events_EventBus_messages(bus, &mut err); let mut got = Vec::new(); loop { let mut item: *const c_char = std::ptr::null(); - let has = weaveffi_events_GetMessagesIterator_next(iter, &mut item, &mut err); - assert_eq!(err.code, 0); - if has == 0 { + if weaveffi_events_EventBus_MessagesIterator_next(iter, &mut item, &mut err) == 0 { break; } got.push(abi::c_ptr_to_string(item).unwrap()); abi::free_string(item); } - weaveffi_events_GetMessagesIterator_destroy(iter); - assert_eq!(got, vec!["hello".to_string(), "world".to_string()]); - } + weaveffi_events_EventBus_MessagesIterator_destroy(iter); + assert_eq!(got, vec!["hello", "psst", "last"]); - #[test] - fn get_messages_empty() { - let _g = setup(); - let mut err = new_err(); - let iter = weaveffi_events_get_messages(&mut err); - let mut item: *const c_char = std::ptr::null(); - let has = weaveffi_events_GetMessagesIterator_next(iter, &mut item, &mut err); - assert_eq!(err.code, 0); - assert_eq!(has, 0); - weaveffi_events_GetMessagesIterator_destroy(iter); + let mut len = 0usize; + let ptr = weaveffi_events_EventBus_last_message(bus, &mut len, &mut err); + let last: Option = + abi::decode_value(unsafe { std::slice::from_raw_parts(ptr, len) }).unwrap(); + abi::free_bytes(ptr as *mut u8, len); + assert_eq!(last.unwrap().text, "last"); + + weaveffi_events_EventBus_clear_subscribers(bus, &mut err); + assert_eq!( + freed.load(Ordering::SeqCst), + 2, + "free ran once per subscriber" + ); + weaveffi_events_EventBus_destroy(bus); } #[test] - fn listener_receives_messages_with_context() { - let _g = setup(); + fn foreign_error_aborts_publish() { let mut err = new_err(); + let freed = Arc::new(AtomicUsize::new(0)); + let bus = weaveffi_events_EventBus_new(&mut err); + let a = new_sub("", "boom", &freed); + weaveffi_events_EventBus_subscribe(bus, a, &VTABLE, &mut err); + publish(bus, "boom", "x", &mut err); + assert_eq!(err.code, abi::FOREIGN_ERROR_CODE); + assert!(abi::c_ptr_to_string(err.message) + .unwrap() + .contains("rejected topic")); + abi::error_clear(&mut err); - static COUNT: AtomicUsize = AtomicUsize::new(0); - extern "C" fn on_message(_msg: *const c_char, ctx: *mut c_void) { - assert!(!ctx.is_null()); - let counter = unsafe { &*(ctx as *const AtomicUsize) }; - counter.fetch_add(1, Ordering::Relaxed); - } - COUNT.store(0, Ordering::Relaxed); - - let id = weaveffi_events_register_message_listener( - on_message, - &COUNT as *const AtomicUsize as *mut c_void, + // The bus is still usable afterward. + assert_eq!(publish(bus, "ok", "y", &mut err), 1); + assert_eq!(err.code, 0); + weaveffi_events_EventBus_destroy(bus); + assert_eq!( + freed.load(Ordering::SeqCst), + 1, + "destroying the bus frees its subscriber" ); - assert!(id > 0); - - let text = CString::new("test").unwrap(); - weaveffi_events_send_message(text.as_ptr(), &mut err); - weaveffi_events_send_message(text.as_ptr(), &mut err); - assert_eq!(COUNT.load(Ordering::Relaxed), 2); - - weaveffi_events_unregister_message_listener(id); - weaveffi_events_send_message(text.as_ptr(), &mut err); - assert_eq!(COUNT.load(Ordering::Relaxed), 2); } #[test] - fn destroy_null_iterator_is_safe() { - weaveffi_events_GetMessagesIterator_destroy(std::ptr::null_mut()); + fn route_once_does_not_retain() { + let mut err = new_err(); + let freed = Arc::new(AtomicUsize::new(0)); + let a = new_sub("quiet", "", &freed); + let topic = CString::new("quiet").unwrap(); + let d = weaveffi_events_route_once(a, &VTABLE, topic.as_ptr(), &mut err); + assert_eq!(d, Delivery::Skip as i32); + assert_eq!(freed.load(Ordering::SeqCst), 1); } #[test] - fn next_null_iterator_returns_error() { + fn reference_counting() { let mut err = new_err(); - let mut item: *const c_char = std::ptr::null(); - let has = - weaveffi_events_GetMessagesIterator_next(std::ptr::null_mut(), &mut item, &mut err); - assert_eq!(has, 0); - assert_ne!(err.code, 0); - abi::error_clear(&mut err); + let bus = weaveffi_events_EventBus_new(&mut err); + let again = weaveffi_events_EventBus_clone(bus); + assert_eq!(bus, again); + weaveffi_events_EventBus_destroy(bus); + assert_eq!( + weaveffi_events_EventBus_subscriber_count(again, &mut err), + 0 + ); + weaveffi_events_EventBus_destroy(again); + weaveffi_events_EventBus_destroy(std::ptr::null_mut()); } } diff --git a/samples/events/weaveffi.toml b/samples/events/weaveffi.toml index 3069b858..bd94510e 100644 --- a/samples/events/weaveffi.toml +++ b/samples/events/weaveffi.toml @@ -1,7 +1,7 @@ [package] name = "events" version = "1.0.0" -description = "Callback and listener-based event subscription example." +description = "Publish/subscribe bus built on a callback interface, a shared object, and an iterator." license = "MIT" authors = ["WeaveFoundry "] homepage = "https://github.com/weavefoundry/weaveffi" diff --git a/samples/kvstore/src/lib.rs b/samples/kvstore/src/lib.rs index 7648450f..7749a211 100644 --- a/samples/kvstore/src/lib.rs +++ b/samples/kvstore/src/lib.rs @@ -1,18 +1,20 @@ //! Kvstore sample cdylib: a production-quality, in-memory key/value store that //! exercises every IDL feature WeaveFFI supports through the -//! `#[weaveffi::module]` macro: an interface with constructors, methods, -//! statics, and an implicit destroy symbol, a typed error domain -//! (`#[weaveffi::error]`), callbacks, listeners, optional/list/map/bytes -//! record fields, an iterator return, a cancellable async method, deprecated -//! and nested-submodule surface, all over the C ABI. Records cross the -//! boundary as value buffers: each `#[weaveffi::record]` gets a generated -//! `BufferValue` implementation instead of per-field C accessors. +//! `#[weaveffi::module]` macro: a reference-counted interface with +//! constructors, methods, statics, and `_clone`/`_destroy` symbols, a typed +//! error domain (`#[weaveffi::error]`), a consumer-implemented callback +//! interface, optional/list/map/bytes record fields, records and lists that +//! carry objects, `Store?` in both directions, an iterator return, a +//! cancellable async method, deprecated and nested-submodule surface, all +//! over the C ABI. Records cross the boundary as value buffers: each +//! `#[weaveffi::record]` gets a generated `BufferValue` implementation instead +//! of per-field C accessors. //! //! `Store` is exported as an interface, so each object owns its rich state //! (its entries and the monotonic entry-id counter) directly. Methods take //! `&self` and guard that state with a `Mutex` because the object is shared -//! across the FFI boundary; destroying the object (via the generated -//! `weaveffi_kv_Store_destroy`) releases the state with it. +//! across the FFI boundary; the last `weaveffi_kv_Store_destroy` releases the +//! state with it. #![allow(unsafe_code)] @@ -21,7 +23,7 @@ pub mod kv { use std::collections::BTreeMap; use std::sync::atomic::{AtomicI64, Ordering}; - use std::sync::Mutex; + use std::sync::{Arc, Mutex, PoisonError}; #[cfg(not(target_arch = "wasm32"))] use std::time::{SystemTime, UNIX_EPOCH}; @@ -101,24 +103,68 @@ pub mod kv { 1_700_000_000 } - /// Fires when an entry is evicted from the store. - #[weaveffi::callback] - #[allow(non_snake_case, dead_code, unused_variables)] - fn OnEvict(key: String) {} + /// Why an entry left the store. + #[weaveffi::enumeration] + #[repr(i32)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum EvictionReason { + /// Removed by an explicit `delete`. + Deleted = 0, + /// Its TTL elapsed and a read evicted it. + Expired = 1, + } - /// Subscribe to per-key eviction notifications. - #[weaveffi::listener(event = "OnEvict")] - #[allow(dead_code)] - fn eviction_listener() {} + /// A consumer-implemented observer of evictions. One listener at a time + /// is attached to a store with [`Store::set_eviction_listener`]; the store + /// retains it until it's replaced, cleared, or the store is dropped. + #[weaveffi::callback_interface] + pub trait EvictionListener: Send + Sync { + /// An entry left the store. Returns whether the listener wants to keep + /// receiving notifications; `false` detaches it. + fn on_evict(&self, entry: &Entry, reason: EvictionReason) -> bool; + } + + /// A named view of a store, used to exercise objects inside records: the + /// `store` field carries a strong reference and `mirror` may be absent. + #[weaveffi::record] + #[derive(Clone)] + pub struct StoreInfo { + /// A caller-chosen label. + pub label: String, + /// The described store. + pub store: Arc, + /// An optional second store to compare against. + pub mirror: Option>, + /// Live entry count at the time of the snapshot. + pub count: i64, + } /// An embedded key-value store owning its entries. Exported as an /// interface: each object holds its own entry map and id counter behind a /// `Mutex` (methods take `&self` because the object is shared across the - /// FFI boundary), and the generated destroy symbol releases the state. + /// FFI boundary), and the last generated `destroy` releases the state. #[weaveffi::interface] pub struct Store { entries: Mutex>, next_entry_id: AtomicI64, + listener: Mutex>>, + } + + impl Store { + /// Notify the attached listener (if any) outside every lock; detach it + /// when it asks to stop. + fn notify_eviction(&self, entry: &Entry, reason: EvictionReason) { + let listener = self + .listener + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(listener) = listener { + if !listener.on_evict(entry, reason) { + *self.listener.lock().unwrap_or_else(PoisonError::into_inner) = None; + } + } + } } impl Store { @@ -133,9 +179,84 @@ pub mod kv { Ok(Store { entries: Mutex::new(BTreeMap::new()), next_entry_id: AtomicI64::new(1), + listener: Mutex::new(None), }) } + /// Attach `listener`, replacing any previous one (whose consumer + /// `free` then runs). + pub fn set_eviction_listener(&self, listener: Arc) { + *self.listener.lock().unwrap_or_else(PoisonError::into_inner) = Some(listener); + } + + /// Detach the current listener, if any. + pub fn clear_eviction_listener(&self) { + *self.listener.lock().unwrap_or_else(PoisonError::into_inner) = None; + } + + /// A second reference to this same store (the returned pointer equals + /// the receiver's; both must eventually be destroyed). + pub fn share(self: Arc) -> Arc { + self + } + + /// A new store holding a copy of every live entry. + pub fn fork(&self) -> Arc { + let now = now_unix_seconds(); + let entries: BTreeMap = self + .entries + .lock() + .unwrap() + .iter() + .filter(|(_, e)| !e.is_expired(now)) + .map(|(k, e)| (k.clone(), e.clone())) + .collect(); + let next = self.next_entry_id.load(Ordering::Relaxed); + Arc::new(Store { + entries: Mutex::new(entries), + next_entry_id: AtomicI64::new(next), + listener: Mutex::new(None), + }) + } + + /// Whichever of `self` and `other` holds more live entries, or `None` + /// when `other` is absent and `self` is empty. Exercises `Store?` as + /// both a parameter and a return. + pub fn larger(self: Arc, other: Option>) -> Option> { + match other { + Some(o) if o.count() > self.count() => Some(o), + Some(_) => Some(self), + None if self.count() > 0 => Some(self), + None => None, + } + } + + /// Snapshot this store into a record that carries the object itself. + pub fn describe(self: Arc, label: String, mirror: Option>) -> StoreInfo { + let count = self.count(); + StoreInfo { + label, + store: self, + mirror, + count, + } + } + + /// Open one store per path. Exercises a list of objects as a return. + pub fn open_many(paths: Vec) -> Result>, KvError> { + paths + .into_iter() + .map(|p| Store::open(p).map(Arc::new)) + .collect() + } + + /// Total live entries across `stores`. Exercises a list of objects as + /// a parameter and an object inside a record as a parameter. + pub fn total_count(stores: Vec>, extra: Option) -> i64 { + let base: i64 = stores.iter().map(|s| s.count()).sum(); + base + extra.map_or(0, |info| info.store.count()) + } + /// Insert or replace a value, returning true on success. A new key is /// rejected with [`KvError::StoreFull`] once the store holds /// [`Store::default_capacity`] entries. @@ -174,33 +295,33 @@ pub mod kv { /// Look up an entry by key; returns null if missing or expired (and /// reports the matching [`KvError`] code through `out_err`). An - /// expired entry is evicted on read, firing the eviction listener. + /// expired entry is evicted on read, notifying the eviction listener. pub fn get(&self, key: String) -> Result, KvError> { let now = now_unix_seconds(); let (result, evicted) = { let mut entries = self.entries.lock().unwrap(); match entries.get(&key) { Some(entry) if entry.is_expired(now) => { - entries.remove(&key); - (Err(KvError::Expired), Some(key.clone())) + let gone = entries.remove(&key); + (Err(KvError::Expired), gone) } Some(entry) => (Ok(Some(entry.clone())), None), None => (Err(KvError::KeyNotFound), None), } }; - if let Some(evicted_key) = evicted { - emit_eviction_listener(&evicted_key); + if let Some(entry) = evicted { + self.notify_eviction(&entry, EvictionReason::Expired); } result } /// Remove the entry for the given key, returning true if it existed. - /// A removed entry fires the eviction listener. + /// A removed entry notifies the eviction listener. pub fn delete(&self, key: String) -> Result { let removed = self.entries.lock().unwrap().remove(&key); match removed { - Some(_) => { - emit_eviction_listener(&key); + Some(entry) => { + self.notify_eviction(&entry, EvictionReason::Deleted); Ok(true) } None => Ok(false), @@ -300,7 +421,7 @@ pub mod kv { /// Snapshot the current store statistics. Takes the parent module's /// `Store` interface by reference across the module boundary. #[weaveffi::export] - pub fn get_stats(store: &super::Store) -> Result { + pub fn get_stats(store: &Store) -> Result { let now = super::now_unix_seconds(); let entries = store.entries.lock().unwrap(); let total_entries = entries.len() as i64; @@ -326,7 +447,7 @@ mod tests { use std::ffi::{c_void, CString}; use std::os::raw::c_char; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{mpsc, Mutex}; + use std::sync::{mpsc, Arc, Mutex}; use std::time::Duration; use weaveffi::abi::{self, weaveffi_error}; @@ -339,10 +460,8 @@ mod tests { value } - // The macro-generated eviction-listener registry is process-global, so the - // tests that register a listener (or fire evictions a listener could - // observe) are serialized and each unregisters before releasing the guard; - // that keeps at most one subscriber live at a time. + // Stores are independent objects, but the tests share process-wide + // vtables and counters, so they run serialized for readable failures. static TEST_MUTEX: Mutex<()> = Mutex::new(()); fn setup() -> std::sync::MutexGuard<'static, ()> { @@ -785,44 +904,72 @@ mod tests { assert_eq!(back.expired_entries, 3); } - #[test] - fn eviction_listener_fires_on_delete() { - let _g = setup(); - let s = open(); - put_simple(s, "evict-me", b"v"); + /// A consumer-side eviction listener, exactly as a generated binding + /// builds one: a heap context plus a process-wide vtable. + struct ListenerState { + evictions: Mutex>, + keep_after: usize, + freed: Arc, + } - static COUNT: AtomicUsize = AtomicUsize::new(0); - COUNT.store(0, Ordering::Relaxed); - extern "C" fn on_evict(_key: *const c_char, _ctx: *mut c_void) { - COUNT.fetch_add(1, Ordering::Relaxed); - } - let id = weaveffi_kv_register_eviction_listener(on_evict, std::ptr::null_mut()); - assert!(id > 0); + unsafe extern "C" fn on_evict( + ctx: *mut c_void, + entry_ptr: *const u8, + entry_len: usize, + reason: i32, + _out_err: *mut weaveffi_error, + ) -> bool { + let state = &*(ctx as *const ListenerState); + let entry: Entry = + abi::decode_value(std::slice::from_raw_parts(entry_ptr, entry_len)).unwrap(); + let mut seen = state.evictions.lock().unwrap(); + seen.push((entry.key, reason)); + seen.len() < state.keep_after + } - let mut err = new_err(); - let key = CString::new("evict-me").unwrap(); - assert!(weaveffi_kv_Store_delete(s, key.as_ptr(), &mut err)); - assert_eq!(COUNT.load(Ordering::Relaxed), 1); + unsafe extern "C" fn free_listener(ctx: *mut c_void) { + let state = Box::from_raw(ctx as *mut ListenerState); + state.freed.fetch_add(1, Ordering::SeqCst); + } - weaveffi_kv_unregister_eviction_listener(id); - put_simple(s, "again", b"x"); - let key2 = CString::new("again").unwrap(); - weaveffi_kv_Store_delete(s, key2.as_ptr(), &mut err); - assert_eq!(COUNT.load(Ordering::Relaxed), 1); + static LISTENER_VTABLE: weaveffi_kv_EvictionListener_vtable = + weaveffi_kv_EvictionListener_vtable { + on_evict, + free: free_listener, + }; - weaveffi_kv_Store_destroy(s); + fn new_listener(keep_after: usize, freed: &Arc) -> *mut ListenerState { + Box::into_raw(Box::new(ListenerState { + evictions: Mutex::new(Vec::new()), + keep_after, + freed: Arc::clone(freed), + })) } #[test] - fn eviction_listener_fires_on_ttl_expiry() { + fn eviction_listener_sees_deletes_and_expiry_then_detaches() { let _g = setup(); let s = open(); + let freed = Arc::new(AtomicUsize::new(0)); + let listener = new_listener(2, &freed); let mut err = new_err(); - let key = CString::new("expiring").unwrap(); + weaveffi_kv_Store_set_eviction_listener( + s, + listener as *mut c_void, + &LISTENER_VTABLE, + &mut err, + ); + assert_eq!(err.code, 0); + + put_simple(s, "evict-me", b"v"); + let key = CString::new("evict-me").unwrap(); + assert!(weaveffi_kv_Store_delete(s, key.as_ptr(), &mut err)); + + let expiring = CString::new("expiring").unwrap(); let ttl = abi::encode_value(&Some(-1i64)); weaveffi_kv_Store_put( s, - key.as_ptr(), + expiring.as_ptr(), b"x".as_ptr(), 1, EntryKind::Volatile as i32, @@ -830,22 +977,143 @@ mod tests { ttl.len(), &mut err, ); - - static COUNT: AtomicUsize = AtomicUsize::new(0); - COUNT.store(0, Ordering::Relaxed); - extern "C" fn on_evict(_key: *const c_char, _ctx: *mut c_void) { - COUNT.fetch_add(1, Ordering::Relaxed); - } - let id = weaveffi_kv_register_eviction_listener(on_evict, std::ptr::null_mut()); - - let mut out_len: usize = 0; - let p = weaveffi_kv_Store_get(s, key.as_ptr(), &mut out_len, &mut err); + let mut out_len = 0usize; + let p = weaveffi_kv_Store_get(s, expiring.as_ptr(), &mut out_len, &mut err); assert!(p.is_null()); assert_eq!(err.code, 1002); - assert_eq!(COUNT.load(Ordering::Relaxed), 1); abi::error_clear(&mut err); - weaveffi_kv_unregister_eviction_listener(id); + // The second eviction returned `false`, so the store detached (and + // freed) the listener; a third eviction is not observed. + assert_eq!( + freed.load(Ordering::SeqCst), + 1, + "detached listener is freed" + ); + put_simple(s, "again", b"x"); + let again = CString::new("again").unwrap(); + weaveffi_kv_Store_delete(s, again.as_ptr(), &mut err); + weaveffi_kv_Store_destroy(s); + } + + #[test] + fn eviction_listener_is_retained_until_replaced() { + let _g = setup(); + let s = open(); + let freed = Arc::new(AtomicUsize::new(0)); + let mut err = new_err(); + let first = new_listener(usize::MAX, &freed); + weaveffi_kv_Store_set_eviction_listener( + s, + first as *mut c_void, + &LISTENER_VTABLE, + &mut err, + ); + assert_eq!(freed.load(Ordering::SeqCst), 0); + let second = new_listener(usize::MAX, &freed); + weaveffi_kv_Store_set_eviction_listener( + s, + second as *mut c_void, + &LISTENER_VTABLE, + &mut err, + ); + assert_eq!( + freed.load(Ordering::SeqCst), + 1, + "replaced listener is freed" + ); + weaveffi_kv_Store_clear_eviction_listener(s, &mut err); + assert_eq!(freed.load(Ordering::SeqCst), 2); + weaveffi_kv_Store_destroy(s); + } + + #[test] + fn share_and_fork_reference_counting() { + let _g = setup(); + let s = open(); + put_simple(s, "a", b"1"); + let mut err = new_err(); + + let shared = weaveffi_kv_Store_share(s, &mut err); + assert_eq!(shared, s, "share returns the same object"); + weaveffi_kv_Store_destroy(s); + assert_eq!(weaveffi_kv_Store_count(shared, &mut err), 1, "still alive"); + + let forked = weaveffi_kv_Store_fork(shared, &mut err); + assert_ne!(forked, shared); + put_simple(forked, "b", b"2"); + assert_eq!(weaveffi_kv_Store_count(forked, &mut err), 2); + assert_eq!(weaveffi_kv_Store_count(shared, &mut err), 1); + + let cloned = weaveffi_kv_Store_clone(forked); + weaveffi_kv_Store_destroy(forked); + assert_eq!(weaveffi_kv_Store_count(cloned, &mut err), 2); + weaveffi_kv_Store_destroy(cloned); + weaveffi_kv_Store_destroy(shared); + } + + #[test] + fn larger_handles_nullable_objects_both_ways() { + let _g = setup(); + let a = open(); + let b = open(); + put_simple(b, "x", b"1"); + let mut err = new_err(); + + assert!(weaveffi_kv_Store_larger(a, std::ptr::null(), &mut err).is_null()); + let bigger = weaveffi_kv_Store_larger(a, b, &mut err); + assert_eq!(bigger, b); + weaveffi_kv_Store_destroy(bigger); + let own = weaveffi_kv_Store_larger(b, std::ptr::null(), &mut err); + assert_eq!(own, b); + weaveffi_kv_Store_destroy(own); + + weaveffi_kv_Store_destroy(a); + weaveffi_kv_Store_destroy(b); + } + + #[test] + fn objects_inside_records_and_lists() { + let _g = setup(); + let s = open(); + put_simple(s, "k", b"v"); + let mut err = new_err(); + + let label = CString::new("primary").unwrap(); + let mut out_len = 0usize; + let ptr = + weaveffi_kv_Store_describe(s, label.as_ptr(), std::ptr::null(), &mut out_len, &mut err); + assert_eq!(err.code, 0); + let info = decode_and_free::(ptr, out_len); + assert_eq!(info.label, "primary"); + assert_eq!(info.count, 1); + assert!(info.mirror.is_none()); + // The record's object token carried its own reference. + assert_eq!(Arc::as_ptr(&info.store), s as *const Store); + assert_eq!(Arc::strong_count(&info.store), 2); + + let paths = abi::encode_value(&vec!["/a".to_string(), "/b".to_string()]); + let mut many_len = 0usize; + let many_ptr = + weaveffi_kv_Store_open_many(paths.as_ptr(), paths.len(), &mut many_len, &mut err); + assert_eq!(err.code, 0); + let many = decode_and_free::>>(many_ptr, many_len); + assert_eq!(many.len(), 2); + put_simple(Arc::as_ptr(&many[0]) as *mut Store, "m", b"1"); + + // Objects written into a parameter buffer carry one reference each. + let stores = abi::encode_value(&many); + let extra = abi::encode_value(&Some(info.clone())); + let total = weaveffi_kv_Store_total_count( + stores.as_ptr(), + stores.len(), + extra.as_ptr(), + extra.len(), + &mut err, + ); + assert_eq!(total, 2); + drop(info); + drop(many); weaveffi_kv_Store_destroy(s); } diff --git a/samples/node-addon/Cargo.toml b/samples/node-addon/Cargo.toml deleted file mode 100644 index e6e29222..00000000 --- a/samples/node-addon/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "weaveffi-node-addon" -version = "0.1.0" -edition.workspace = true -publish = false - -[lib] -crate-type = ["cdylib"] -name = "index" - -[dependencies] -napi = { version = "3", features = ["napi4"] } -napi-derive = "3" -once_cell = "1" -libloading = "0.8" -weaveffi-abi = { path = "../../crates/weaveffi-abi" } - -[build-dependencies] -napi-build = "2" - -[package.metadata.cargo-machete] -# Used in build.rs (napi_build::setup()); cargo-machete only inspects src/. -ignored = ["napi-build"] - -[lints] -workspace = true diff --git a/samples/node-addon/build.rs b/samples/node-addon/build.rs deleted file mode 100644 index 0f1b0100..00000000 --- a/samples/node-addon/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - napi_build::setup(); -} diff --git a/samples/node-addon/src/lib.rs b/samples/node-addon/src/lib.rs deleted file mode 100644 index d9c0e237..00000000 --- a/samples/node-addon/src/lib.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! N-API addon used by the Node.js consumer example to dynamically load -//! a WeaveFFI cdylib at runtime. - -#![allow(unsafe_code)] - -use libloading::{Library, Symbol}; -use napi::bindgen_prelude::*; -use napi_derive::napi; -use once_cell::sync::OnceCell; -use std::ffi::CStr; -use std::os::raw::c_char; -use weaveffi_abi::weaveffi_error; - -type AddFn = unsafe extern "C" fn(i32, i32, *mut weaveffi_error) -> i32; -type MulFn = unsafe extern "C" fn(i32, i32, *mut weaveffi_error) -> i32; -type DivFn = unsafe extern "C" fn(i32, i32, *mut weaveffi_error) -> i32; -type EchoFn = unsafe extern "C" fn(*const c_char, *mut weaveffi_error) -> *const c_char; -type FreeStringFn = unsafe extern "C" fn(*const c_char); -type ErrorClearFn = unsafe extern "C" fn(*mut weaveffi_error); - -struct FfiApi { - add: AddFn, - mul: MulFn, - div: DivFn, - echo: EchoFn, - free_string: FreeStringFn, - error_clear: ErrorClearFn, -} - -static API: OnceCell<(Library, FfiApi)> = OnceCell::new(); - -fn load_api() -> napi::Result<&'static (Library, FfiApi)> { - API.get_or_try_init(|| { - let path = std::env::var("WEAVEFFI_LIB").ok().unwrap_or_else(|| { - let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - p.pop(); // up to samples/ - p.pop(); // up to workspace root - p.push("target"); - p.push("debug"); - let lib_name = if cfg!(target_os = "macos") { - "libcalculator.dylib" - } else if cfg!(target_os = "windows") { - "calculator.dll" - } else { - "libcalculator.so" - }; - p.push(lib_name); - p.display().to_string() - }); - let lib = unsafe { Library::new(&path) } - .map_err(|e| Error::new(Status::GenericFailure, format!("load {}: {}", path, e)))?; - let api: FfiApi = unsafe { - let add: Symbol = lib.get(b"weaveffi_calculator_add").map_err(map_err)?; - let mul: Symbol = lib.get(b"weaveffi_calculator_mul").map_err(map_err)?; - let div: Symbol = lib.get(b"weaveffi_calculator_div").map_err(map_err)?; - let echo: Symbol = lib.get(b"weaveffi_calculator_echo").map_err(map_err)?; - let free_string: Symbol = - lib.get(b"weaveffi_free_string").map_err(map_err)?; - let error_clear: Symbol = - lib.get(b"weaveffi_error_clear").map_err(map_err)?; - FfiApi { - add: *add, - mul: *mul, - div: *div, - echo: *echo, - free_string: *free_string, - error_clear: *error_clear, - } - }; - Ok((lib, api)) - }) -} - -fn map_err(e: libloading::Error) -> Error { - Error::new(Status::GenericFailure, e.to_string()) -} - -fn take_error(api: &FfiApi, err: &mut weaveffi_error) -> Option<(i32, String)> { - if err.code == 0 { - return None; - } - let code = err.code; - let msg = if err.message.is_null() { - String::new() - } else { - // SAFETY: message is NUL-terminated string from Rust side - unsafe { CStr::from_ptr(err.message) } - .to_string_lossy() - .to_string() - }; - // SAFETY: clear frees message buffer - unsafe { (api.error_clear)(err as *mut weaveffi_error) }; - Some((code, msg)) -} - -#[napi] -pub fn add(a: i32, b: i32) -> napi::Result { - let mut err = weaveffi_error::default(); - let (_, api) = load_api()?; - let rv = unsafe { (api.add)(a, b, &mut err) }; - if let Some((code, msg)) = take_error(api, &mut err) { - return Err(Error::new( - Status::GenericFailure, - format!("({}) {}", code, msg), - )); - } - Ok(rv) -} - -#[napi] -pub fn mul(a: i32, b: i32) -> napi::Result { - let mut err = weaveffi_error::default(); - let (_, api) = load_api()?; - let rv = unsafe { (api.mul)(a, b, &mut err) }; - if let Some((code, msg)) = take_error(api, &mut err) { - return Err(Error::new( - Status::GenericFailure, - format!("({}) {}", code, msg), - )); - } - Ok(rv) -} - -#[napi] -pub fn div(a: i32, b: i32) -> napi::Result { - let mut err = weaveffi_error::default(); - let (_, api) = load_api()?; - let rv = unsafe { (api.div)(a, b, &mut err) }; - if let Some((code, msg)) = take_error(api, &mut err) { - return Err(Error::new( - Status::GenericFailure, - format!("({}) {}", code, msg), - )); - } - Ok(rv) -} - -#[napi] -pub fn echo(s: String) -> napi::Result { - let mut err = weaveffi_error::default(); - let (_, api) = load_api()?; - let c_str = - std::ffi::CString::new(s).map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; - let c_ptr = unsafe { (api.echo)(c_str.as_ptr(), &mut err) }; - if let Some((code, msg)) = take_error(api, &mut err) { - return Err(Error::new( - Status::GenericFailure, - format!("({}) {}", code, msg), - )); - } - if c_ptr.is_null() { - return Err(Error::new( - Status::GenericFailure, - "null string".to_string(), - )); - } - let out = unsafe { CStr::from_ptr(c_ptr) } - .to_string_lossy() - .to_string(); - unsafe { (api.free_string)(c_ptr) }; - Ok(out) -} diff --git a/scripts/publish-crates.sh b/scripts/publish-crates.sh index 34166130..1276924f 100755 --- a/scripts/publish-crates.sh +++ b/scripts/publish-crates.sh @@ -15,7 +15,7 @@ CRATES=( weaveffi weaveffi-gen-c weaveffi-gen-swift - weaveffi-gen-android + weaveffi-gen-kotlin weaveffi-gen-node weaveffi-gen-wasm weaveffi-gen-python diff --git a/weaveffi.schema.json b/weaveffi.schema.json index e2f99d5f..dca5f81d 100644 --- a/weaveffi.schema.json +++ b/weaveffi.schema.json @@ -9,7 +9,7 @@ ], "properties": { "version": { - "description": "IR schema version this document targets (for example `0.8.0`). Validation rejects any value not listed in [`SUPPORTED_VERSIONS`].", + "description": "IR schema version this document targets (for example `0.9.0`). Validation rejects any value not listed in [`SUPPORTED_VERSIONS`].", "type": "string" }, "modules": { @@ -23,7 +23,7 @@ "additionalProperties": false, "definitions": { "Module": { - "description": "A WeaveFFI module: a named group of functions, types, callbacks, listeners, and errors.", + "description": "A WeaveFFI module: a named group of functions, types, callback interfaces, and errors.", "type": "object", "required": [ "name" @@ -54,6 +54,13 @@ "$ref": "#/definitions/InterfaceDef" } }, + "callback_interfaces": { + "description": "Callback interfaces declared in this module: method sets the consumer implements and the producer calls.", + "type": "array", + "items": { + "$ref": "#/definitions/CallbackInterfaceDef" + } + }, "structs": { "description": "Record (struct) types declared in this module.", "type": "array", @@ -68,20 +75,6 @@ "$ref": "#/definitions/EnumDef" } }, - "callbacks": { - "description": "Callback signatures this module's functions and listeners can invoke.", - "type": "array", - "items": { - "$ref": "#/definitions/CallbackDef" - } - }, - "listeners": { - "description": "Event listeners (subscribe and unsubscribe endpoints) this module exposes.", - "type": "array", - "items": { - "$ref": "#/definitions/ListenerDef" - } - }, "errors": { "description": "Optional error domain: the named codes this module's fallible functions report. `None` when the module declares no errors.", "anyOf": [ @@ -100,10 +93,11 @@ "$ref": "#/definitions/Module" } } - } + }, + "additionalProperties": false }, "Function": { - "description": "A function exported across the FFI boundary.\n\nEach function becomes a C ABI entry point plus an idiomatic wrapper in every target language. The `async` and `cancellable` flags change how the symbol is lowered (a completion callback, an extra cancel-token parameter) without altering the parameter and return shape declared here. The same shape also describes an interface's constructors, methods, and statics (see [`InterfaceDef`]).", + "description": "A function exported across the FFI boundary.\n\nEach function becomes a C ABI entry point plus an idiomatic wrapper in every target language. The `async` and `cancellable` flags change how the symbol is lowered (a completion callback, an extra cancel-token parameter) without altering the parameter and return shape declared here. The same shape also describes an interface's constructors, methods, and statics (see [`InterfaceDef`]) and a callback interface's methods (see [`CallbackInterfaceDef`]).", "type": "object", "required": [ "name" @@ -156,18 +150,12 @@ "string", "null" ] - }, - "since": { - "description": "Version in which the function was introduced (for example `0.2.0`), surfaced as a \"since\" annotation where the target language supports one.", - "type": [ - "string", - "null" - ] } - } + }, + "additionalProperties": false }, "Param": { - "description": "A single parameter of a [`Function`] or [`CallbackDef`].", + "description": "A single parameter of a [`Function`].", "type": "object", "required": [ "name", @@ -186,10 +174,6 @@ } ] }, - "mutable": { - "description": "Whether the callee may write back through this parameter (for example a buffer filled in place). Defaults to `false`.", - "type": "boolean" - }, "doc": { "description": "Human-readable documentation for the parameter, propagated to the generated bindings. `None` when undocumented.", "type": [ @@ -197,11 +181,12 @@ "null" ] } - } + }, + "additionalProperties": false }, "TypeRef": { "title": "TypeRef", - "description": "Reference to a type. Encoded as a string with custom syntax: primitives (`i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `u64`, `f32`, `f64`, `bool`, `string`, `bytes`, `handle`), borrowed types (`&str`, `&[u8]`), typed handles (`handle<{name}>`), iterators (`iter<{T}>`), lists (`[{T}]`), maps (`{{K:V}}`), optionals (`{T}?`), or any user-defined struct/enum/interface name.", + "description": "Reference to a type. Encoded as a string with custom syntax: primitives (`i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `u64`, `f32`, `f64`, `bool`, `string`, `bytes`), iterators (`iter<{T}>`), lists (`[{T}]`), maps (`{{K:V}}`), optionals (`{T}?`), or any user-defined struct, enum, interface, or callback interface name.", "type": "string" }, "InterfaceDef": { @@ -250,7 +235,44 @@ "$ref": "#/definitions/Function" } } - } + }, + "additionalProperties": false + }, + "CallbackInterfaceDef": { + "description": "A callback interface: a set of methods the consumer implements and the producer calls.", + "type": "object", + "required": [ + "methods", + "name" + ], + "properties": { + "name": { + "description": "Callback interface type name (for example `MessageListener`).", + "type": "string" + }, + "doc": { + "description": "Human-readable documentation, propagated to the generated bindings. `None` when undocumented.", + "type": [ + "string", + "null" + ] + }, + "deprecated": { + "description": "Deprecation notice for the whole type; when set, generators annotate the emitted type. `None` means the callback interface is current.", + "type": [ + "string", + "null" + ] + }, + "methods": { + "description": "Methods the consumer implements, in vtable order. Must be non-empty.", + "type": "array", + "items": { + "$ref": "#/definitions/Function" + } + } + }, + "additionalProperties": false }, "StructDef": { "description": "A struct (record) type with named fields.", @@ -285,7 +307,8 @@ "$ref": "#/definitions/StructField" } } - } + }, + "additionalProperties": false }, "StructField": { "description": "A named field of a [`StructDef`], or the payload of an algebraic [`EnumVariant`].", @@ -314,7 +337,8 @@ "null" ] } - } + }, + "additionalProperties": false }, "EnumDef": { "description": "An enum type. C-style when every variant is a bare discriminant; an algebraic sum type when any variant declares fields.", @@ -349,7 +373,8 @@ "$ref": "#/definitions/EnumVariant" } } - } + }, + "additionalProperties": false }, "EnumVariant": { "description": "A single variant of an [`EnumDef`].", @@ -382,60 +407,8 @@ "$ref": "#/definitions/StructField" } } - } - }, - "CallbackDef": { - "description": "A callback signature: a function shape the host implements and native code invokes.\n\nCallbacks are declared at module scope rather than as a [`TypeRef`] so the C ABI can represent them uniformly as a function pointer plus a context pointer. A [`ListenerDef`] references one by name to model an event stream.", - "type": "object", - "required": [ - "name", - "params" - ], - "properties": { - "name": { - "description": "Callback name, used to name the generated function-pointer type and referenced by [`ListenerDef::event_callback`] (for example `on_message`).", - "type": "string" - }, - "params": { - "description": "Parameters passed to the callback each time it fires.", - "type": "array", - "items": { - "$ref": "#/definitions/Param" - } - }, - "doc": { - "description": "Human-readable documentation, propagated to the generated bindings. `None` when undocumented.", - "type": [ - "string", - "null" - ] - } - } - }, - "ListenerDef": { - "description": "An event listener: a subscribe and unsubscribe endpoint that delivers events through a [`CallbackDef`].\n\nGenerators expand a listener into register and unregister functions; the register call takes the named callback and returns a subscription id the caller later hands to unregister.", - "type": "object", - "required": [ - "event_callback", - "name" - ], - "properties": { - "name": { - "description": "Listener name, lowered into the generated `register_*` and `unregister_*` function names (for example `messages`).", - "type": "string" - }, - "event_callback": { - "description": "Name of the [`CallbackDef`] invoked for each event. Must match a callback declared on the same [`Module`].", - "type": "string" - }, - "doc": { - "description": "Human-readable documentation, propagated to the generated bindings. `None` when undocumented.", - "type": [ - "string", - "null" - ] - } - } + }, + "additionalProperties": false }, "ErrorDomain": { "description": "A module's error domain: the named set of error codes its fallible functions can report.", @@ -456,7 +429,8 @@ "$ref": "#/definitions/ErrorCode" } } - } + }, + "additionalProperties": false }, "ErrorCode": { "description": "A single named error within an [`ErrorDomain`].", @@ -472,7 +446,7 @@ "type": "string" }, "code": { - "description": "Stable numeric value carried across the C ABI to identify this error.", + "description": "Stable numeric value carried across the C ABI to identify this error. Must be positive; non-positive codes are reserved for the runtime.", "type": "integer", "format": "int32" }, @@ -494,7 +468,8 @@ "$ref": "#/definitions/StructField" } } - } + }, + "additionalProperties": false } } } From b22a7ffa711a5d36db1bade0c29a49c898f5a48e Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:36:42 -0700 Subject: [PATCH 2/3] test: normalize path separators in Kotlin layout test for Windows --- crates/weaveffi-gen-kotlin/src/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/weaveffi-gen-kotlin/src/tests.rs b/crates/weaveffi-gen-kotlin/src/tests.rs index a09a469f..fdf69c6d 100644 --- a/crates/weaveffi-gen-kotlin/src/tests.rs +++ b/crates/weaveffi-gen-kotlin/src/tests.rs @@ -122,7 +122,12 @@ fn layout_is_a_kotlin_gradle_module() { let api = api(); let model = BindingModel::build(&api, "weaveffi"); let files = KotlinGenerator.files(&api, &model, Utf8Path::new("out"), &KotlinConfig::default()); - let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + // `files()` joins with the host separator; the orchestrator's + // `output_files` normalizes to `/` later, so do the same here for Windows. + let paths: Vec = files + .iter() + .map(|f| f.path.as_str().replace('\\', "/")) + .collect(); assert_eq!( paths, [ From 090d763c5dbea66bb58d02e86b8cea31c10fe0a9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:00:02 -0700 Subject: [PATCH 3/3] ci: run conformance on Swift 6.0 to dodge a 5.10 Linux hang --- .github/workflows/ci.yml | 11 ++++++++++- conformance/swift/kvstore.swift | 6 +++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9acb71f5..8a029dad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,10 @@ jobs: # round-trip every value-buffer wire shape; see conformance/run.sh. name: Conformance harness runs-on: ubuntu-latest + # A healthy run takes about five minutes. Without a cap, a consumer that + # deadlocks (as the Swift events lane did under Swift 5.10, see below) + # holds the job for the six-hour default before it is marked failed. + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v4 @@ -156,10 +160,15 @@ jobs: - name: Set up Dart uses: dart-lang/setup-dart@v1 + # Swift 5.10 on Linux miscompiles the events consumer: the top-level + # async code returns to the runtime mid-statement (an iterator + # `first(where:)` call) without ever suspending, so `dispatch_main` + # parks the process forever. Swift 6.0 and 6.1 run the same source + # to completion; the generated package stays on swift-tools 5.7. - name: Set up Swift uses: swift-actions/setup-swift@v2 with: - swift-version: "5.10" + swift-version: "6.0" - name: Set up Java uses: actions/setup-java@v4 diff --git a/conformance/swift/kvstore.swift b/conformance/swift/kvstore.swift index d5a73e19..711c4ee1 100644 --- a/conformance/swift/kvstore.swift +++ b/conformance/swift/kvstore.swift @@ -127,7 +127,7 @@ do { // --- EvictionListener callback interface --------------------------------- var listener: Listener? = Listener() - weak let weakListener = listener + weak var weakListener = listener store.setEvictionListener(listener: listener!) expect(try store.delete(key: "beta"), "delete beta") expect(listener!.evicted.count == 1, "one eviction observed") @@ -158,7 +158,7 @@ do { listener = nil expect(weakListener != nil, "store retains the listener") var second: Listener? = Listener(keepAfter: 2) - weak let weakSecond = second + weak var weakSecond = second store.setEvictionListener(listener: second!) expect(weakListener == nil, "replaced listener is freed") @@ -213,7 +213,7 @@ do { // share(): a second wrapper to the SAME object; mutation through one is // visible through the other, and dropping one leaves the other usable. var shared: Store? = store.share() - weak let weakShared = shared + weak var weakShared = shared _ = try shared!.put(key: "via-share", value: payload, kind: .volatile, ttlSeconds: nil) expect(store.count() == 2, "put through share() visible through the original") expect(Array(try shared!.listKeys(prefix: nil)) == ["alpha", "via-share"], "share() reads the same entries")