jessedoyle / duktape.cr

Evaluate JavaScript from Crystal!
MIT License
137 stars 17 forks source link

The stack gets reset after the push_c_function #63

Closed grkek closed 3 years ago

grkek commented 3 years ago
require "duktape/runtime"

module Duktape
  module API
    module Push
      def push_custom_proc(nargs : Int32 = 0, &block : LibDUK::Context -> Int32)
        LibDUK.push_pointer(ctx, Box.box(block))

        puts "Before push_c_function"
        (-100..100).each do |i|
          puts "#{LibDUK.get_pointer(ctx, i)} at #{i}"
        end

        LibDUK.push_c_function(ctx, ->(ctx) { 
          puts "After push_c_function"
          (-100..100).each do |i|
            puts "#{LibDUK.get_pointer(ctx, i)} at #{i}"
          end

          proc = Box(typeof(block)).unbox(LibDUK.get_pointer(ctx, -1))
          proc.call(ctx)
        }, nargs)
      end

      def push_custom_global_proc(name : String, nargs : Int32 = 0, &block : LibDUK::Context -> Int32)
        push_global_object
        push_custom_proc nargs, &block
        put_prop_string -3, name
        pop
      end
    end
  end

  class Runtime
    property context : Duktape::Sandbox | Duktape::Context
  end
end

runtime = Duktape::Runtime.new
context = runtime.context

context.push_custom_global_proc("exampleFunction", 0) do |ptr|
  env = Duktape::Sandbox.new(ptr)

  env.call_success
end

runtime.eval("exampleFunction()")

When I try to box the value and it pass it in by pointers the stack gets reset after the push, is this a supposed behavior?

jessedoyle commented 3 years ago

Hi @grkek, thanks for using duktape.cr!

Thanks for the example code, I haven't had a chance to run it yet, but plan to over the coming days.

At first glance, I wanted to mention a few things:

Thanks!

grkek commented 3 years ago

Hi @grkek, thanks for using duktape.cr!

Thanks for the example code, I haven't had a chance to run it yet, but plan to over the coming days.

At first glance, I wanted to mention a few things:

* It looks like you're calling `LibDUK.push_c_function` yourself, glad you got that figured out as it's rather low-level!

* The `LibDUK` methods are all just direct references to the native C implementation in Duktape. ([link](https://github.com/jessedoyle/duktape.cr/blob/master/src/lib_duktape.cr)). So you're likely noticing behaviour in the Duktape library itself, not the Crystal bindings.

* Duktape does change the stack pointer in the scope of a C function call. There's documentation about it [here](https://duktape.org/api.html#duk_push_c_function). Basically when defining the `nargs` parameter, Duktape will set the stack top (index 0) to be the first argument, followed by the next argument, etc.

Thanks!

Hey, @jessedoyle the documentation is understandable, the problem is something else.

When I try to push a pointer to the stack it stays on the stack, but when I enter the push_c_function block, the stack just null's out while having the same context.

jessedoyle commented 3 years ago

Hi @grkek, I tested your code snippet and noticed the issue.

I'm pretty sure Duktape heavily optimizes/manipulates the stack on a call into a native function. Your pointer reference may be getting cleaned up by the GC, but I think it's more likely an implementation detail in the engine itself.

Duktape uses the concept of "stash" objects (thread, heap, global stashes) to store native state. I was able to put together a proof of concept using stashes and boxing/unboxing to pass closure data to the native code:

require "duktape/runtime"

# our test closure proc
callable = ->(max : Int32) { puts "Max: #{max}, Random: #{rand(max)}" }

rt = Duktape::Runtime.new do |sbx|
  # push proc pointer to the heap stash
  sbx.push_heap_stash
  sbx.push_pointer(Box.box(callable))
  sbx.put_prop_string(-2, "callable")

  sbx.push_global_proc("box") do |ptr|
    env = Duktape::Sandbox.new(ptr)

    # reconstitute the proc from heap stash pointer
    env.push_heap_stash
    env.get_prop_string(-1, "callable")
    function = Box(Proc(Int32, Nil)).unbox(env.get_pointer(-1))

    # call the proc
    8.times { |i| function.call(i ** i) }
    env.call_success
  end
end

rt.call("box")

# Example Output:
#
# Max: 1, Random: 0
# Max: 1, Random: 0
# Max: 4, Random: 1
# Max: 27, Random: 19
# Max: 256, Random: 14
# Max: 3125, Random: 1960
# Max: 46656, Random: 7079
# Max: 823543, Random: 202953

This certainly falls under "advanced" functionality, but there's some documentation on stash objects here.

Please let me know if this pattern works for your use-case.

grkek commented 3 years ago

Thank you so much, @jessedoyle

jessedoyle commented 3 years ago

@grkek - You're more than welcome!

One last thing to note is that you may need to use class variables to protect the reference from Crystal's GC. This is mentioned briefly in the docs here.

grkek commented 3 years ago

@grkek - You're more than welcome!

One last thing to note is that you may need to use class variables to protect the reference from Crystal's GC. This is mentioned briefly in the docs here.

A quick question, how does one retrieve the arguments passed to a function, for example I want a function to get 2 arguments and then retrieve them using the env.require_int 0 function.

How would one approach that?

@jessedoyle

jessedoyle commented 3 years ago

Duktape will setup the stack top (index 0) of an invoked function call to equal the first function argument value. Consecutive stack entries (indices 1+) will be the values of the next function arguments in order.

When inside the scope of a native function call you can use the get_xxx API methods to retrieve values from the stack (possibly returning nil in Crystal), or the require_xxx API methods to ensure the value is present on the stack and matches the expected type.

Here's the native function above modified to accept 2 arguments: iterations and offset:

require "duktape/runtime"

callable = ->(iteration : Int32, offset : Int32) do
  content = {
    iteration: iteration,
    offset: offset,
    random_call: "rand(#{iteration + offset})",
    random_value: rand(iteration + offset)
  }.to_json
  puts content
end

rt = Duktape::Runtime.new do |sbx|
  sbx.push_heap_stash
  sbx.push_pointer(Box.box(callable))
  sbx.put_prop_string(-2, "callable")

  # call from JS like: box(4, 4)
  # first argument is the number of iterations
  # second argument is an integer offset
  sbx.push_global_proc("box", 2) do |ptr|
    env = Duktape::Sandbox.new(ptr)

    iterations = env.require_int(0) # get argument 1
    offset = env.require_int(1)     # get argument 2

    env.push_heap_stash
    env.get_prop_string(-1, "callable")
    function = Box(Proc(Int32, Int32, Nil)).unbox(env.get_pointer(-1))

    iterations.times { |i| function.call(i, offset) }
    env.call_success
  end
end

rt.eval("box(4, 4);") # success
rt.eval("box();")     # failure - Duktape::TypeError "type at 0 is not number"