208 lines
5.9 KiB
Markdown
208 lines
5.9 KiB
Markdown
Yep — here’s the concrete picture, with the “no-Proxy” trampoline approach, and what it can/can’t 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 don’t 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 it’s 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`.
|
||
|
||
That’s “real” hot reload without Proxy.
|
||
|
||
---
|
||
|
||
## “Module exports just a function” — yes, and it’s 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), there’s 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 can’t “update” that value in place without either:
|
||
|
||
### Option 1: Accept the limitation (recommended)
|
||
|
||
* Hot reload still reloads the module.
|
||
* But **previously returned primitive exports don’t 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 it’s 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
|
||
|
||
You’re 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 object’s fields in place*:
|
||
|
||
* binding stays the same object
|
||
* the object’s contents update
|
||
|
||
That’s not “setting new defs to old defs”; it’s “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 can’t dynamically compute a property value).
|
||
|
||
That’s 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.
|