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

208 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
```js
// 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:
```js
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:
```js
// 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`:
```js
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 `var`s)
* **`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:
```js
// returns a function directly
return function(x) { ... }
```
Hot-reload mode can return a **trampoline function**:
```js
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:
```js
return "hello"
```
Then anyone who did:
```js
def msg = use('msg') // msg is a text value
```
…is holding the text itself. You cant “update” that value in place without either:
### Option 1: Accept the limitation (recommended)
* 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:
```js
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.