Supporting caller-allocated, callee-written buffer without views #68
Description
For an API like the C standard library read
:
ssize_t read(int fildes, void *buf, size_t nbyte);
where the caller allocates a buffer which the callee (partially) fills in, to express the signature in wasm, ideally we want to avoid buf
being an i32
argument since (1) this implies linear memory (preventing GC buffers), (2) it isn't multi-memory future-compatible.
One solution is to have read
take a "slice"/"view" parameter (analogous to a Web IDL Uint8Array
). However, views on linear memory somewhat break encapsulation, by giving the callee a permanent window into the caller's linear memory, leading to use-after-free types of bugs, so I think it's worthwhile to ask whether we can support use cases like read
with value/copy semantics, without loss of performance.
Setting aside the issue of how to signal failure (probably via variant/option return type), I think a natural interface-typed signature would be:
(func (export "read") (param $fildes (ref $FD)) (param $nbyte u32) (result (list u8)))
where the API's contract is that the returned list's length is <= $nbyte
. The question is how we can have this interface, but achieve the same performance, in particular, allowing the caller to supply a fixed-size buffer.
One idea would be to introduce a variant of the *-to-memory
instructions which, instead of calling an exported function to allocate linear memory, would take a (pointer, length) i32
pair as operands from the stack (trapping if the required space is greater than the length). E.g.:
(func (import "" "read_") (param (ref $FD) i32 i32) (result i32))
(@interface func $read (import "libc" "read") (param (ref $FD) i32) (result (list u8)))
(@interface implement (import "" " read_")
(param $fd (ref $FD)) (param $nbytes i32) (param $ptr i32)
(result i32)
arg.get $fd
arg.get $nbytes
call-import $read
arg.get $ptr
arg.get $nbytes
list-to-preallocated-memory ;; [list i32 i32] -> [i32], returning actual list size
)
However, there will be many *-to-memory
instructions, and it feels wrong to have to add a *-to-preallocated-memory
variant for each.
An alternative is to generalize the *-to-memory
functions to, instead of only calling exports, to also be able to call any helper function (as described in #65), and for it to be possible for helper functions to be defined inline as unnamed (lambda) functions, so that they can reference the enclosing scope. This would allow the above example to be equivalently expressed as:
(func (import "" "read_") (param (ref $FD) i32 i32) (result i32))
(@interface func $read (import "libc" "read") (param (ref $FD) i32) (result (list u8)))
(@interface implement (import "" " read_")
(param $fd (ref $FD)) (param $nbytes i32) (param $ptr i32)
(result i32)
arg.get $fd
arg.get $nbytes
call-import $read
list-to-memory (func (param $needed i32) (result i32)
;; maybe assert $needed <= $nbytes
arg.get $ptr
) ;; [list] -> [i32], returning actual list size
)
This generalization would also allow interesting hybrid schemes, e.g., wherein a caller-supplied buffer was used opportunistically, falling back to malloc in too-big cases, which is another common C++ optimization pattern.
With the understanding that helper functions are always designed to be inlined at the callsite (which is always statically determinable), then these lambda functions should compile into direct stack access, with no worse perf than list-to-preallocated-memory
.
Thoughts?