Never restarting is the thesis; say where the holes in it are

The manual described the signature-change refusal as though it were the
design. It is not, and session.ml already said so at the refusal: a
signature change should make a new version, leave old callers on the old
one, and warn at the stale sites. plan.org calls it signature generations
and stale-caller warnings, and it is milestone 7's unfinished half.

The struct-layout rejection is the decided one and stays. Conflating them
made a placeholder look like a rule.
This commit is contained in:
Joseph Ferano 2026-09-12 09:38:26 +07:00
parent 50ed2cbef0
commit 3afce2aeac
3 changed files with 288 additions and 18 deletions

17
NEXT.md
View File

@ -210,6 +210,23 @@ vague intention — if it is listed, someone has already established it is real.
### Unblocked now, and ranked
**0. Signature generations and stale-caller warnings — milestone 7's unfinished half.**
Promoted here on the author's correction, and `session.ml:146` already says the same thing at the refusal itself. A
changed signature is refused today and **that is a placeholder, not the design**. plan.org's open decision #6 says what
should happen: a signature change makes a new version of the function, new callers resolve it, existing callers and any
stored `Fn` value stay safely on the old one, and the session *warns* at each tracked stale caller site. Milestone 7
names it outright — "signature generations and stale-caller warnings".
**The thesis of this project is that you never restart the program.** Every refusal that ends in "restart to change it"
is a hole in that, and this is the biggest one. It needs three things that do not exist: function versions, a
trampoline per version, and caller tracking good enough to name the sites. The cell already gives the indirection; what
is missing is that a cell holds one bare pointer with no signature, so there is nowhere to put a second version.
A changed **struct layout** is the genuinely hard case and plan.org still specifies it as a rejection — storage already
allocated has the old shape and a new body reads its fields at the wrong offsets. Managed classes are the planned way
through, with an explicit migration at a frame boundary. Do not conflate the two: one is unbuilt, the other is decided.
**From porting ten raylib examples** — the first code the language was pushed by that it was not designed around.
Ranked by how often they were hit, top two first because they are walls rather than conveniences:

View File

@ -194,19 +194,39 @@ press rather than a timer.
## When a change is refused
Some changes cannot be made to a running program. Change a struct's layout, or a
function's signature, and the daemon refuses and tells you to restart. It is
being honest: the session's idea of your types has to describe the memory of the
process it is talking to, and after a layout change it no longer does.
Two different things wear the same refusal today, and only one of them is the
design.
**`C-c C-x`** (`flan-dev-restart-program`) is the way out. It stops the program,
rebuilds from source, starts it again, and reconnects.
### A changed signature — a placeholder, not a rule
The program's state goes with it. That is the whole cost, and it is why this is a
separate key rather than something `C-c C-c` quietly falls back to — losing your
game's state should be something you asked for.
The intended behaviour, and what plan.org specifies, is that changing a
function's signature makes a **new version** of it: new callers resolve the new
one, existing callers and any stored `Fn` value stay safely on the old one, and
the session **warns** at each tracked stale caller site so you know what to
re-evaluate. Nothing should have to restart.
---
That needs function versions, trampolines and caller tracking, none of which are
built yet. Until they are, the session refuses rather than letting an
indirection cell hand old arguments to a new body — a wrong answer would be
worse than a refusal. `lib/session.ml` says so at the refusal itself, and
plan.org tracks it as open decision #6.
So if you hit this: it is a limitation with a date on it, not how the language
is meant to work.
### A changed struct layout — the genuinely hard one
Rejected while live values of that struct exist, and this one plan.org does
still specify as a rejection. Storage already allocated has the old shape; a new
body would read its fields at the wrong offsets and nothing at run time would
say so. Managed classes are the planned way through — an explicit migration at a
frame boundary — and they are not built either.
### The way out, for now
`C-c C-x` stops the program, rebuilds from source, starts it again and
reconnects. It costs the program's state, which is why it is a key you press
rather than something `C-c C-c` quietly falls back to.
## Under the debugger

View File

@ -116,6 +116,34 @@ th, td { text-align: left; padding: .38rem .6rem .38rem 0; border-bottom: 1px so
th { font-weight: 600; color: var(--muted); }
.scroll { overflow-x: auto; margin: 0 0 1.15rem; }
/* Tabs. The panels are visible by default and only become switchable once
the script adds .tabbed to <main> — with no script the page is the one long
document it has always been, which is also what a printer and a text browser
get. */
.tabs {
display: flex; flex-wrap: wrap; gap: .4rem;
margin: 0 0 2rem; padding: 0 0 .6rem;
border-bottom: 1px solid var(--rule);
}
.tabs button {
font: inherit; font-size: .93rem; cursor: pointer;
background: none; border: 1px solid transparent; border-radius: 4px;
padding: .3rem .7rem; color: var(--muted);
}
.tabs button:hover { color: var(--link); }
.tabs button[aria-selected="true"] {
color: var(--text); background: var(--panel); border-color: var(--rule);
}
.tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
main:not(.tabbed) .tabs { display: none; }
main.tabbed .panel[hidden] { display: none; }
.toc-group {
margin: 1.1rem 0 .3rem; font-size: .72rem; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); opacity: .75;
}
.toc-group:first-child { margin-top: 0; }
.lede { font-size: 1.1rem; }
.note { color: var(--muted); font-size: .94rem; }
@ -190,6 +218,7 @@ footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ru
<nav class="toc" aria-label="Contents">
<p class="toc-title">Contents</p>
<p class="toc-group">Language</p>
<ul>
<li><a href="#what">What Flan is</a></li>
<li><a href="#start">Getting started</a></li>
@ -203,11 +232,23 @@ footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ru
<li><a href="#printing">Printing</a></li>
<li><a href="#prelude">The prelude</a></li>
<li><a href="#packages">Packages</a></li>
</ul>
<p class="toc-group">Conditions</p>
<ul>
<li><a href="#conditions">Conditions and restarts</a></li>
<li><a href="#break">The break loop</a></li>
</ul>
<p class="toc-group">The FFI</p>
<ul>
<li><a href="#ffi">The FFI</a></li>
</ul>
<p class="toc-group">The dev loop</p>
<ul>
<li><a href="#devloop">The dev loop</a></li>
<li><a href="#emacs">Emacs</a></li>
</ul>
<p class="toc-group">Builds</p>
<ul>
<li><a href="#targets">Targets and builds</a></li>
<li><a href="#notyet">Not implemented yet</a></li>
<li><a href="#reading">Further reading</a></li>
@ -217,6 +258,14 @@ footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ru
<main>
<div class="wrap">
<div class="tabs" role="tablist" aria-label="Sections">
<button type="button" role="tab" data-panel="panel-language" aria-selected="true">Language</button>
<button type="button" role="tab" data-panel="panel-conditions" aria-selected="false">Conditions</button>
<button type="button" role="tab" data-panel="panel-ffi" aria-selected="false">The FFI</button>
<button type="button" role="tab" data-panel="panel-devloop" aria-selected="false">The dev loop</button>
<button type="button" role="tab" data-panel="panel-builds" aria-selected="false">Builds</button>
</div>
<header>
<svg class="mark" viewBox="0 0 118 44" role="img" aria-label="Flan">
<!-- The dot is a full stop in the same run, so it stays beside the n
@ -242,6 +291,8 @@ with its output recorded beside it; <code>sh web/examples/check.sh</code> runs t
and compares, and <code>quotes.sh</code> re-derives the blocks that are transcripts
rather than programs.</p>
<div class="panel" id="panel-language">
<h2 id="what">What Flan is</h2>
<p>A minimal Lisp for games. In one line: Odin with a Lisp frontend and a live REPL.
@ -854,6 +905,10 @@ rather than from the import list: the compiler starts at <code>main</code>, foll
call, and a package none of whose externs survive contributes no C and no linker
argument, so a file that imports raylib still builds for wasm32.</p>
</div>
<div class="panel" id="panel-conditions">
<h2 id="conditions">Conditions and restarts</h2>
<p>A condition is a struct. There is no class hierarchy; matching is by type. The
@ -1014,6 +1069,10 @@ unhandled Missing
$ echo $?
134</code></pre>
</div>
<div class="panel" id="panel-ffi">
<h2 id="ffi">The FFI</h2>
<p>There are two declaration forms, and they are two forms rather than one because no
@ -1094,6 +1153,10 @@ refused too, so the narrow boundary cannot quietly acquire one.</p>
would not exist in the running process. Editing the body of a function that calls an
existing binding is unaffected.</p>
</div>
<div class="panel" id="panel-devloop">
<h2 id="devloop">The dev loop</h2>
<p>Edit the code, keep the sand.</p>
@ -1254,11 +1317,34 @@ retains three, permanently and correctly.</p>
<p><code>emacs/flan-mode.el</code> derives from <code>prog-mode</code> with
<code>lisp-mode</code>'s syntax table, so sexp motion, paren matching and indentation are
already right. It adds Flan's brackets <code>[</code> and <code>{</code> are brackets,
not symbol characters, since every binding list and every type is written with them and
already right. It adds Flan's brackets &mdash; <code>[</code> and <code>{</code> are brackets,
not symbol characters, since every binding list and every type is written with them &mdash; and
the characters a Flan name may contain. <code>emacs/flan-dev.el</code> is the client;
there is no parser in it.</p>
<h3>Setting up</h3>
<p>Put <code>emacs/</code> on your load path and require the mode. Nothing else is needed:
the client, the REPL, the inspector and the conditions buffer all load on first use.</p>
<pre><code class="sh">(add-to-list 'load-path "~/Development/flan/emacs")
(require 'flan-mode)
(require 'flan-dape) ; optional — lldb, and the only thing that binds C-c C-g</code></pre>
<p><code>flan-dape.el</code> is separate on purpose, so <code>flan-mode</code> works
without dape installed. You also need <code>flan</code> on your <code>PATH</code>, or
<code>flan-dev-command</code> pointed at it.</p>
<h3>Starting a program</h3>
<p><code>M-x flan-dev</code> runs <code>flan dev</code> on a file, waits for it to come up
and connects. <kbd>C-c C-z</kbd> attaches to one that is already running, looking for
<code>.flan-dev.sock</code> upward from the buffer, so from anywhere in the project it
finds the one program you have going. <kbd>C-c C-q</kbd> disconnects and leaves it
running; <code>M-x flan-dev-quit</code> stops it, but only one this Emacs started &mdash; a
daemon you launched in a terminal is not Emacs' to kill, and it says so rather than doing
something surprising.</p>
<div class="scroll">
<table>
<tr><th>Key</th><th>Does</th></tr>
@ -1275,7 +1361,7 @@ there is no parser in it.</p>
<tr><td><kbd>C-c C-g</kbd></td><td>debug under lldb, through dape &mdash; bound only once <code>flan-dape.el</code> is loaded, so <code>flan-mode</code> works without dape installed</td></tr>
<tr><td><kbd>C-c C-d</kbd></td><td>what the running program currently defines</td></tr>
<tr><td><kbd>C-c C-v</kbd></td><td>help on the name at point</td></tr>
<tr><td><kbd>C-c C-x</kbd></td><td>rebuild, relaunch and reconnect — the way out when a reload is refused</td></tr>
<tr><td><kbd>C-c C-x</kbd></td><td>rebuild, relaunch and reconnect</td></tr>
<tr><td><kbd>M-.</kbd> / <kbd>M-,</kbd></td><td>where a name is written, through an <code>xref</code> backend</td></tr>
</table>
</div>
@ -1284,17 +1370,103 @@ there is no parser in it.</p>
<code>defvar</code> and the function that uses it have to arrive in the same load, or the
first refers to storage that does not exist yet.</p>
<p><kbd>C-x C-e</kbd> compiles the expression before point and runs it <em>inside</em> the
running program &mdash; the actual process, with its actual state, not a copy. Both it and
<kbd>C-c C-c</kbd> work on buffer text rather than the saved file, and a change installs at
the next frame boundary.</p>
<h3>The buffers, and their own keys</h3>
<p>Three buffers have keymaps of their own. The conditions buffer
(<kbd>C-c C-b</kbd>) shows the condition, then the restarts, then the stack &mdash; in that
order, because the decision in front of you is which restart to take and the stack is only
the explanation for it.</p>
<div class="scroll">
<table>
<tr><th>Buffer</th><th>Keys</th></tr>
<tr><td>conditions</td>
<td><kbd>RET</kbd> take the restart at point &middot; <kbd>0</kbd>&ndash;<kbd>9</kbd> take one by number &middot;
<kbd>TAB</kbd>/<kbd>n</kbd>, <kbd>S-TAB</kbd>/<kbd>p</kbd> move &middot;
<kbd>f</kbd> fold a frame &middot; <kbd>i</kbd> inspect a local &middot;
<kbd>a</kbd> abort &middot; <kbd>g</kbd> re-read &middot; <kbd>q</kbd> close</td></tr>
<tr><td>inspector</td>
<td><kbd>RET</kbd> into the field at point &middot; <kbd>l</kbd> back out &middot;
<kbd>g</kbd> re-read &middot; <kbd>TAB</kbd>/<kbd>n</kbd>, <kbd>S-TAB</kbd>/<kbd>p</kbd> move &middot;
<kbd>q</kbd> close</td></tr>
<tr><td>repl</td>
<td><code>comint</code>, plus <kbd>C-c C-o</kbd>, <kbd>C-c C-d</kbd> and <kbd>C-c C-q</kbd></td></tr>
</table>
</div>
<p>A restart is taken by <em>position</em>, which is why the list is numbered: a name
resolves to the innermost frame offering it, so an outer <code>retry</code> shadowed by an
inner one is real, is on the list, and cannot be reached by name. One that genuinely cannot
be taken is drawn and refused with the reason rather than quietly omitted.</p>
<p>The inspector is unlike most: the view is never stale, because every step re-reads the
program as it is now. The cost is that <strong>the root expression runs again on every
step</strong> &mdash; going into a field sends <code>(.pos b)</code> where the last one sent
<code>b</code>, which is harmless, but inspecting <code>(spawn-enemy)</code> spawns one per
keystroke. That is why there is no auto-refresh and why <kbd>g</kbd> is a key you press.</p>
<p>The repl is program-scoped rather than buffer-scoped, so in sand you write
<code>sim/settle</code> and not <code>settle</code>. <code>*flan-output*</code> is separate
from it, because the program's stdout belongs to the program.</p>
<h3>When a change is refused</h3>
<p>Two different things wear the same refusal today, and only one of them is the design.</p>
<p><strong>A changed signature is a placeholder refusal.</strong> The intended behaviour,
and what <a href="#reading">plan.org</a> specifies, is that a signature change makes a new
version of the function: new callers resolve it, existing callers and any stored
<code>Fn</code> value stay safely on the old one, and the session <em>warns</em> at each
tracked stale caller site so you know what to re-evaluate. Nothing should have to restart.
That needs function versions, trampolines and caller tracking, none of which are built yet
&mdash; so until they are, the session refuses rather than letting a cell hand old arguments
to a new body. The refusal is a limitation with a date on it, not a rule.</p>
<p><strong>A changed struct layout is the genuinely hard case</strong>, and is rejected
while live values of that struct exist: storage already allocated has the old shape, and a
new body would read its fields at the wrong offsets with nothing to say so. plan.org keeps
this one as a rejection, and gives managed classes an explicit migration at a frame
boundary as the eventual way through.</p>
<p><kbd>C-c C-x</kbd> rebuilds, relaunches and reconnects, and is the way out while the
above is true. It costs the program's state, which is why it is a key you press rather than
something <kbd>C-c C-c</kbd> falls back to.</p>
<h3>Around the edges</h3>
<p>eldoc, completion and <kbd>M-.</kbd> all read one cached reply rather than asking per
keystroke, refreshed at the two moments the answer can have changed: on connect, and after
an evaluation the daemon accepted. The modeline says whether there is a program on the
other end, and says <code>stopped</code> when there is one sitting in the break loop — a
other end, and says <code>stopped</code> when there is one sitting in the break loop &mdash; a
stopped program looks exactly like a running one from anywhere else in Emacs.</p>
<p>An error comes back with a location and the client draws an overlay there, cleared the
next time that buffer's evaluation is accepted. The <code>repl</code> buffer is
<code>comint</code>-derived and every line goes through the same request <kbd>C-x C-e</kbd>
uses; it is program-scoped, so in sand you write <code>sim/settle</code> and not
<code>settle</code>.</p>
next time that buffer's evaluation is accepted. Under <kbd>C-c C-g</kbd> lldb needs no
plugin to read a Flan value, since a struct is its C struct; locals show under their real
names, and a shadowed one appears as <code>v~2</code> while plain <code>v</code> still
answers with the outer binding.</p>
<div class="scroll">
<table>
<tr><th>Setting</th><th>Default</th><th>What it is</th></tr>
<tr><td><code>flan-dev-command</code></td><td><code>"flan"</code></td><td>the compiler binary</td></tr>
<tr><td><code>flan-dev-socket-name</code></td><td><code>".flan-dev.sock"</code></td><td>what <kbd>C-c C-z</kbd> searches for</td></tr>
<tr><td><code>flan-dev-echo-result</code></td><td><code>t</code></td><td>print <kbd>C-x C-e</kbd>'s value in the echo area</td></tr>
<tr><td><code>flan-dev-names-shown</code></td><td><code>4</code></td><td>how many names to list before counting them</td></tr>
<tr><td><code>flan-dev-output-buffer</code></td><td><code>"*flan-output*"</code></td><td>where the program's output goes</td></tr>
<tr><td><code>flan-dev-poll-interval</code></td><td><code>1.0</code></td><td>seconds between checks for whether it stopped</td></tr>
<tr><td><code>flan-dev-start-timeout</code></td><td><code>60</code></td><td>seconds to wait for a program to come up</td></tr>
</table>
</div>
</div>
<div class="panel" id="panel-builds">
<h2 id="targets">Targets and builds</h2>
@ -1404,6 +1576,8 @@ macro is a function from <code>Form</code> to <code>Form</code>, which needs
real programs that run.</li>
</ul>
</div>
<footer>
<p>Flan is a custard. This page describes the compiler on branch
<code>dev-loop</code>; where a document and the compiler disagree, the compiler is what
@ -1413,6 +1587,65 @@ macro is a function from <code>Form</code> to <code>Form</code>, which needs
</div>
</main>
<script>
// Tabs. The panels are plain markup and stay visible until this runs, so the
// page without a script is the one long document it was before. A link into a
// panel that is not showing switches to it first -- every #anchor on the page,
// and every link from outside, has to keep working.
(function () {
var main = document.querySelector("main");
var bar = document.querySelector(".tabs");
if (!main || !bar) return;
var tabs = Array.prototype.slice.call(bar.querySelectorAll("button"));
var panels = tabs.map(function (t) { return document.getElementById(t.dataset.panel); });
if (panels.some(function (p) { return !p; })) return; // markup moved; leave it long
main.classList.add("tabbed");
function show(i, focus) {
tabs.forEach(function (t, j) {
t.setAttribute("aria-selected", j === i ? "true" : "false");
panels[j].hidden = j !== i;
});
if (focus) tabs[i].focus();
}
function panelOf(id) {
var el = id && document.getElementById(id);
while (el && el !== document.body) {
if (el.classList && el.classList.contains("panel")) return el.id;
el = el.parentElement;
}
return null;
}
function toHash() {
var want = panelOf(location.hash.slice(1));
if (!want) return false;
var i = panels.findIndex(function (p) { return p.id === want; });
if (i < 0) return false;
show(i);
var t = document.getElementById(location.hash.slice(1));
if (t) t.scrollIntoView();
return true;
}
tabs.forEach(function (t, i) {
t.addEventListener("click", function () { show(i); });
// Left and right move between tabs, which is what a tablist is expected
// to do once it says it is one.
t.addEventListener("keydown", function (e) {
var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
show((i + d + tabs.length) % tabs.length, true);
});
});
window.addEventListener("hashchange", toHash);
if (!toHash()) show(0);
})();
</script>
<script>
// A small hand-written highlighter for the Flan blocks. One pass, no library.
(function () {