Files
cell/docs/trampoline.md
2025-12-17 00:48:02 -06:00

5.9 KiB
Raw Blame History

Yep — heres the concrete picture, with the “no-Proxy” trampoline approach, and what it can/cant do.

A concrete hot-reload example (with trampolines)

sprite.cell v1

// sprite.cell
var X = key('x')
var Y = key('y')

def proto = {
  move: function(dx, dy) {
    this[X] += dx
    this[Y] += dy
  }
}

var make = function(x, y) {
  var s = meme(proto)
  s[X] = x
  s[Y] = y
  return s
}

return {
  proto: proto,
  make: make
}

What the runtime stores on first load

Internally (not visible to Cell), you keep a per-module record:

module = {
  scope: { X, Y, proto, make },        // bindings created by var/def in module
  export_current: { proto, make },     // what the module returned
  export_handle: null                 // stable thing returned by use() in hot mode
}

Now, in hot-reload mode, use('sprite') returns export_handle instead of export_current. Since you dont have Proxy/getters, the handle can only be “dynamic” for functions (because functions are called). So you generate trampolines for exported functions:

// export_handle is a plain object
export_handle = stone({
  // stable reference (proto is identity-critical and will be patched in place)
  proto: module.scope.proto,

  // trampoline (always calls the latest implementation)
  make: function(...args) {
    return module.scope.make.apply(null, args)
  }
})

Note what this buys you:

  • Anyone who cached var sprite = use('sprite') keeps the same sprite object forever.
  • Calling sprite.make(...) always goes through the trampoline and hits the current module.scope.make.

Reload to sprite.cell v2

Say v2 changes move and make:

def proto = {
  move: function(dx, dy) {
    // new behavior
    this[X] = this[X] + dx * 2
    this[Y] = this[Y] + dy * 2
  }
}

var make = function(x, y) { ... } // changed too
return { proto, make }

Runtime reload sequence (safe point: between actor turns):

  1. Evaluate the new module to produce new_scope and new_export.
  2. Reconcile into the old module record:
  • var bindings: rebind

    • old.scope.make = new.scope.make
    • (and any other vars)
  • def bindings: keep the binding identity, but if its an object you want hot-updatable, patch in place

    • old.scope.proto.move = new.scope.proto.move
    • (and other fields on proto)

Now the magic happens:

  • Existing instances s have prototype old.scope.proto (stable identity).
  • You patched old.scope.proto.move to point at the new function.
  • So s.move(...) immediately uses the new behavior.
  • And sprite.make(...) goes through the trampoline to old.scope.make, which you rebound to the new make.

Thats “real” hot reload without Proxy.


“Module exports just a function” — yes, and its actually the easiest

If a module returns a function:

// returns a function directly
return function(x) { ... }

Hot-reload mode can return a trampoline function:

handle = stone(function(...args) {
  return module.scope.export_function.apply(this, args)
})

On reload, you rebind module.scope.export_function to the new function, and all cached references keep working.


“Module exports just a string” — possible, but not hot-swappable by reference (without changing semantics)

If the export is a primitive (text/number/logical/null), theres no call boundary to hang a trampoline on. If you do:

return "hello"

Then anyone who did:

def msg = use('msg') // msg is a text value

…is holding the text itself. You cant “update” that value in place without either:

  • Hot reload still reloads the module.
  • But previously returned primitive exports dont change; callers must call use() again to see the new value.

This keeps your semantics clean.

Option 2: Dev-mode wrapping (changes semantics)

In hot-reload mode only, return a box/thunk instead:

  • box: { get: function(){...} }
  • thunk: function(){ return current_text }

But then code that expects a text breaks unless its written to handle the box/thunk. Usually not worth it unless you explicitly want “dev mode has different types”.

Best convention: if you want a reloadable “string export”, export a function:

var value = "hello"
return { get: function() { return value } }

Now get() is trampoline-able.


About var vs def on reload

Youre very close, just phrase it precisely:

  • var: binding is hot-rebindable On reload, old.scope[name] = new.scope[name].

  • def: binding identity is stable (const binding) On reload, you do not rebind the slot.

But: for def that points to mutable objects that must preserve identity (like prototypes), you can still patch the objects fields in place:

  • binding stays the same object
  • the objects contents update

Thats not “setting new defs to old defs”; its “keeping old defs, optionally copying new content into them”.

If you want to avoid surprises, make one explicit rule:

  • “def objects may be patched in place during hot reload; def primitives are never replaced.”

One important consequence of “no Proxy / no getters”

Your trampoline trick only guarantees hot-reload for:

  • exported functions (via trampolines)
  • exported objects whose identity never changes (like proto), because the handle can point at the stable old object

It does not guarantee hot-reload for exported scalars that you expect to change (because the handle cant dynamically compute a property value).

Thats fine! It just becomes a convention: “export state through functions, export identity anchors as objects.”


If you keep those rules crisp in the doc, your hot reload story becomes genuinely robust and lightweight: most work is “rebind vars” + “patch proto tables” + “trampoline exported functions.” The rest is just conventions that make distributed actor code sane.