Skip to content

Codegen: FFI returns:"i64" treats string-pointer returns as Number; auto-detect to string is name-based #222

Closed

Description

@proggeramlug

Perry: 0.5.326 native (linux x86_64).

When an FFI function in package.json's perry.nativeLibrary.functions is declared with returns: "i64" but returns a Perry-format *const StringHeader pointer, perry's codegen emits a numeric path (sitofp of the i64 to f64) and the TS-side as any cast leaves the value as a Number. data.length returns 0; data.charCodeAt(i) returns the decimal digits of the pointer's integer value; the actual string contents are unreachable.

The auto-detect-string heuristic at crates/perry-codegen/src/lower_call.rs:475-481 only fires when the manifest is silent on return type, and only matches by function name substring (read_file / clipboard_text / file_dialog):

let returns_string = matches!(manifest_ret, Some("string") | Some("ptr"))
    || matches!(ext_return_type, HirType::String)
    || (manifest_ret.is_none() && has_string_args && (
        name.contains("read_file")
        || name.contains("clipboard_text")
        || name.contains("file_dialog")
    ));

So returns: "i64" always wins over the heuristic, and the heuristic itself depends on naming.

Minimal repro

package.json:

{
  "perry": {
    "nativeLibrary": {
      "module": "myffi",
      "functions": [
        { "name": "ffi_get_string", "params": [], "returns": "i64" }
      ],
      "targets": { "linux": { "crate": "native/", "lib": "libmyffi.a" } }
    }
  }
}

native/src/lib.rs:

#[no_mangle]
pub extern "C" fn ffi_get_string() -> *const u8 {
    // Allocate a 20-byte Perry 0.5.x StringHeader + UTF-8 bytes for "hello".
    let s = b"hello";
    let total = 20 + s.len();
    unsafe {
        let p = std::alloc::alloc(std::alloc::Layout::from_size_align(total, 4).unwrap());
        *(p as *mut u32)        = 5; // utf16_len
        *(p.add(4)  as *mut u32) = 5; // byte_len
        *(p.add(8)  as *mut u32) = 5; // capacity
        *(p.add(12) as *mut u32) = 1; // refcount
        *(p.add(16) as *mut u32) = 0; // flags
        std::ptr::copy_nonoverlapping(s.as_ptr(), p.add(20), 5);
        p
    }
}

main.ts:

declare function ffi_get_string(): number;
const s = ffi_get_string() as any as string;
console.log("length:", s.length, "char[0]:", s.charCodeAt(0));

Output: length: 0 char[0]: 104 (104 = 'h' from "hello…" — but only because Number.toString().charCodeAt(0) happens to coincide for short numbers)

Expected: length: 5 char[0]: 104

Switching the manifest to "returns": "string" produces the correct output.

Real-world impact

Caught when bringing bloom-engine's Linux backend up. bloom_read_file, bloom_get_clipboard_text, bloom_open_file_dialog, bloom_save_file_dialog were all declared returns: "i64" despite returning *const StringHeader. On perry 0.5.x the file contents looked like garbled numbers; the author had relied on the (older?) auto-detect heuristic to coerce them to strings. Workaround: change the four entries to returns: "string". PR: Bloom-Engine/engine@8562897.

Suggestions

  • Treat returns: "i64" as a handle type and require an explicit returns: "string" / "ptr" for string-shaped returns (current behavior — but make this a hard error when a TS callsite uses as string against the return value? at least surface a warning).
  • Or: drop the name-based heuristic entirely (it silently rewards bloom_read_file over bloom_load_text) and require explicit declaration.
  • Document the contract in the package.json perry.nativeLibrary reference.

Activity

  1. added a commit that references this issue on Apr 29, 2026
    9d94e00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions