What happens when…
Each section walks through one event. The diagrams show one step at a time: press Next, and the boxes and arrows involved in that step light up. All steps shows the whole list at once.
Tuna starts
Why it works this way: Tuna loads its saved state at every start, after a clean exit or a crash. And plugins should not have to be listed in the right order, so a plugin needed by another starts as soon as it is needed.
- You run
tuna. The core starts on the main thread, which becomes the editor thread. It finds every plugin folder and builds each plugin whose source changed since last time; the rest come from the cache. - It reads
init.ssand checks all of it before running any line: every plugin named exists, every option name and value fits, everyc:xexists in its contract. If anything is wrong, it lists every error with its line and runs nothing.tuna checkdoes just this step. - Every plugin with a
use-pluginline is marked due, and each contract gets its chosen provider. Options given inuse-pluginlines become layers owned by those lines. - The lines run, top to bottom. The first starts
journal-store, which reads the journal file: the saved data as of the last save point, whether Tuna exited cleanly or crashed. A last record torn by a crash in the middle of a write is dropped. - Each
use-pluginline starts its plugin, unless it already started: a new run begins and the plugin’splugin.ssruns top to bottom, each change going on the run’s receipt. - Each
define-savedin it gets its saved value from storage (or its initial value, the first time) and checks it. A value that fails and cannot be upgraded is set aside, and the plugin starts without it; a message namesrestore-saved!. - If a plugin’s file calls a contract whose provider is due but has not started (
bufferscallstext-store:make), that provider starts first, there and then: first need. A plugin not listed ininit.ssnever starts; calling it fails with “its provider is off”. - Other lines (
set!,keys:bind!, your definitions) run as they come; each owns what it does. A line that fails is undone and reported; the rest go on. - After the last line, the command loop runs and the first frame is drawn. Plugins have restarted their work from saved data: the display reconnects to its browser tabs, the terminal follows the running shells again. The core’s part of start-up with restored state took 5 ms in the prototype.
Emacs, by contrast, starts with its editor built in, then loads init.el in order; a require of a package not yet installed fails there and stops the rest of the file.
You press a key
Why it works this way: typing must never wait. Only the editor thread runs plugin code, so editor state needs no locks; other threads only hand plain data in and out. Heavy work and building the page happen elsewhere.
- You press
ain the browser. The web plugin’s handler runs on the HTTP library’s own thread, not the editor thread, so it runs no plugin code: it hands the key, as plain data, to the core’s input hand-over, which any thread may call. - The hand-over wakes the editor thread. It was asleep, waiting with a timeout set to the next timer’s due time; an idle editor runs nothing.
- The loop always takes waiting input first. The key becomes a task. The
keysplugin looks up the binding, in the current buffer’s modes, then the global map, and makes the selected window’s buffer current.ais bound to inserting itself. - The command runs.
buffers:insert!makes a new text and stores it in the saved mapbuffers:all. - The insert runs
after-change-hook. The highlighter’s entry starts a jobfor:this buffer: a new thread, given the read-only text without a copy. Its previous job for the buffer, if still running, is cancelled. - The command ends. Save point: the core collects the saved data that changed and hands it to
(storage);journal-storeappends one record to its file. - Meanwhile the worker posts its spans. The loop applies waiting results oldest first, up to 256 at a time, and at most 8 batches in a row with no input between them, so a stream of results cannot hold up your typing.
- The highlighter’s
on-resultcallsbuffers:set-faces!. Because its job was the newest for this buffer, the spans fit the current text. - A frame is due, because input came. The entries of
frame-hookrun: the display reads, for each browser tab, only the lines its windows show and the faces on them, as plain data, and sends it to that tab’s drawing worker. Windows that did not change are reused, so this is cheap: 59 µs after a keystroke for 3 windows from 10 MB of text. - The drawing worker turns the data into the page and sends it to the tab. Each tab has at most one frame in flight. Measured in the prototype, typing into a 1 MB buffer: 74 to 78 µs from key to done, with none, 4 or 8 busy workers.
Emacs runs the same loop on one thread: the command, after-change-functions, font-lock and redisplay all run in turn, so slow highlighting delays the next key.
A plugin starts, or fails to start
Why it works this way: a plugin that errors while starting is undone and reported; the others start; the plugins that need it stay off with a message naming the real cause. Undoing only its own writes keeps a failure from damaging what other plugins did.
- The core reaches
flymake’s line, or something first needs it. It starts a new run: a new owner with an empty receipt, and a time limit of its own. plugin.ssruns form by form. Each entry made through a verb, each hook entry, option layer, timer, job and tie goes on the receipt. Each write to saved data is recorded with the value before it. If every form succeeds, the plugin is on, and its procedures are checked against any contract it fills.- Suppose a form raises an error. Or the start runs past its time limit and is stopped as if quit. Or it tries to wait: a start cannot pause.
- The receipt is undone, newest first: entries removed, layers removed, timers stopped, jobs cancelled. Ties are kept aside, because the plugin is still chosen and may run again.
- Its own writes to saved data are undone, newest first, but only where the value is still what it wrote: if a plugin it started on first need wrote afterwards, that write stays. A persistent map is undone key by key. Ids it took from
new-idstay used and are never given again. - You get a report with the file, the line and the error.
- Plugins whose start needs
flymakestay off; their message namesflymakeas the cause, not their own line. - Every other plugin starts as usual. When you fix the file and reload,
flymakestarts and the plugins that needed it come back with it. - A special case at start-up: if a plugin fails with its restored saved data, it is tried once more with that data set aside, so Tuna comes up usable; the message names
restore-saved!.
In Emacs, an error while loading a package stops the load where it happened: what ran before the error stays (a hook added, a key bound), and what came after never ran.
You reload a plugin, and a reload fails
Why it works this way, “break before make”: the old version is stopped, the new one tried, and if it fails the old code starts again from its file with its saved data. Keeping the old version running while the new one is tried would keep its ordinary variables, but would need two runs alive at once and rules against their sharing resources.
- You edit
gitstat/plugin.ssand reload it. The build rebuilds it and checks its interface. If the new version drops a name another plugin uses, the reload is refused here, with each use’s file and line, and nothing changes. - The old run ends with
(run-ending)=reload. Commands paused inside it are quit, as byC-g. Its timers stop, its jobs are cancelled, its entries and layers go, itson-endactions run. YourC-M-xedits of it end: they lasted until a reload from the file. - Things other plugins made for it and tied to it (its
*git-status*view) are not undone: their ties are carried to the next run. - The new run starts from the new file, top to bottom. Its ordinary variables are fresh; saved data is as it was. Asking for its view again finds the same buffer and ties it again.
- It starts. Plugins that called
gitstatwhile starting rerun, because what they called may now behave differently. Yourredefineandadvice-addlayers on its functions apply to the new code. - Or: the new code fails while starting. Its start is undone as in a failed start.
- Then the old code, still compiled, starts again from its file, with the saved data.
- The old run was stopped, so its ordinary variables (caches, counters) start fresh. You are told: “plugin gitstat: the new code failed; the previous code runs again”. The view stayed open throughout.
Emacs’s load-file runs the file again over the old definitions: a removed add-hook stays, a removed defvar keeps its value, and a failure halfway leaves half the new file loaded.
You turn a plugin off
Why it works this way: afterwards, Tuna is as if the plugin had never been on, except your data, which is kept. That is only possible if everything a plugin leaves anywhere is recorded with its owner. There are six kinds of thing; each is handled.
- You turn
flymakeoff: remove its line frominit.ssandreload-config, or use the turn-off command. The command first asks any questionflymake’s views would ask on closing (a running compile would ask;flymakehas none). Then everything happens in one step, which cannot pause. Fromreload-configthere is no one to ask, so a view that would ask stays open and you are told why. - Its registrations go. The key
C-c ! n, the commandflymake-goto-next-error, its mode-line itemFlyM:1, its hook entries: each was made through a verb oradd-hook!and is on its receipt, so each is removed. - Its contributions to other plugins’ things go. The wavy underlines and the
!in the margin were its entries inbuffers’ faces and margins, made bybuffers:set-faces!for it; they are removed, and the next frame is drawn without them. Its buffer-scopedfoundlayers go too. The buffer itself is untouched. - Its work in progress is cancelled. Commands paused inside it are quit as by
C-g. Its timers stop. Its worker jobs are cancelled without waiting: even a worker in an endless loop stops in about 0.3 ms. - Things it had another plugin make go or stay, as that plugin said.
flymaketurned onflymake-modeinnotes.txtthroughmodes:enable-minor!, which tied the mode to its caller: the tie runs and the mode goes. A view frombuffers:viewwould close; a buffer frombuffers:createwould stay; a process fromprocesses:start-ownwould end. - Its saved data is kept, for when it returns or for a plugin implementing the same contract.
- Plugins that depend on it rerun. One whose start calls it cannot start: it stays off, with a message naming
flymake, and comes back whenflymakedoes. One that used it under(provided? 'flymake)reruns and works without it. A buffer whose major mode came from a plugin now off is treated as Fundamental mode, and gets its mode back when that plugin returns, as Emacs’sunload-featuredoes. - The prototype tested this literally: it recorded the whole state before the plugin was on (every table entry, option layer, saved value, timer, worker thread, paused command, tie, helper process, open file, and the drawn frame), used the plugin, turned it off, and compared. For highlighting, flymake, a theme, window management, the minibuffer, completion, a completion popup, magit, the terminal, the display, a language server, and everything at once, the only differences were the plugin’s own saved data and your data.
Emacs’s unload-feature refuses to unload a library others depend on unless forced. It removes the library’s named functions from hooks and switches buffers out of its major modes, but leaves anonymous hook functions, timers, buffers, and its changes to other packages’ variables.
You swap a plugin for another
Why it works this way: a swap is turning one implementation off and the other on, so it needs no rules of its own. Saved data the contract declares belongs to the contract, so the new implementation reads it.
- You replace
(use-plugin rope-store)with(use-plugin string-store)ininit.ssand runreload-config, or use the swap command. Ifrope-storealso filled some other contract thatstring-storedoes not, the swap is refused before anything changes. rope-storeturns off, as in turning off.string-storestarts. It was checked against(text-store)at build (names and kinds) and is checked at load (numbers of arguments).- Saved data the contract declares is stored under the contract’s name, so the new implementation reads what the old one saved.
(text-store)has none; the minibuffer’s history is an example of one that does. buffersused(text-store)while starting, so it reruns. Its saved buffers stay. The texts are strings in both implementations: the shape of data that crosses a contract is part of the contract.- Every
text-store:insertcall, including ones stored earlier, now reachesstring-store, because contract names are wrappers that look up the current provider at each call. - If
string-storefails to start,rope-storestarts again and keeps the contract; you are told “swap of text-store to string-store failed; rope-store keeps it”.
Emacs has no swap; the nearest is a package replacing a function variable such as completing-read-function, which takes effect for the next call and checks nothing.
Tuna crashes and restarts
Why it works this way: state is written after every command; a command interrupted at a prompt by a crash keeps what it did before the prompt; Tuna loads its saved state at every start.
- Command 1 ends. Save point: its changes to saved data are handed to storage and written.
- Command 2 asks a question and pauses at the prompt. Save point again: what it did before the prompt is written.
- Command 3 is halfway through. Nothing of it is written yet.
- Tuna crashes, or the machine loses power.
- You start Tuna.
journal-storereplays its journal; a record cut off by the crash is dropped. - Plugins start and read their saved data. You have the state as of the last save point: command 1 in full; command 2’s work before its prompt (the paused command itself is gone: a paused command is not saved); nothing of command 3. At most what an unfinished command did is lost. Undo history is saved data too, so you can undo across the crash.
- Shells were running in their own helper processes, so the crash did not touch them: the terminal follows them again and the output printed meanwhile appears. Views come back still tied to their plugins, because ties are saved. Saved data that makes a plugin fail is set aside, not deleted.
Emacs recovers file buffers from auto-save files, written every 300 keystrokes or 30 idle seconds, and only if you run recover-session; anything else is lost.
Heavy work on a worker, and its cancellation
Why it works this way: heavy work never blocks typing; workers get only what they need, as read-only data, and return data, as Racket’s places do; nothing copies the editor’s state. Chez cannot stop a thread from outside, so the worker’s own thread stops itself at its next timer tick once cancelled.
- The completion popup calls
(start-job match (vector names input) on-result for: 'match), wherenamesis a vector of 100,000 candidate strings. - The core walks the input. Read-only strings and vectors are shared, not copied; mutable ones are copied as read-only; lists are always copied; anything else (a procedure, a record, a hashtable) is refused with its value named. A vector found read-only is remembered, so the next keystroke’s hand-over of the same names costs about 20 µs instead of 1 ms.
- A new thread runs the worker, in about 15 µs. The thread sets its own timer to fire every 20,000 ticks. The worker can see only its input and its fixed vocabulary.
- The worker finds the best 50 matches, in about 9.5 ms, and posts them. The value is checked and copied as plain data in the worker’s own thread, into a slot the editor thread allocated. If 1,000 results were waiting,
postwould wait. - On the editor thread, in the next turn of the loop and after any waiting input,
on-resultruns: if the same prompt is still open, the candidates go todisplay:show-popup!and the next frame shows them. - You type another character.
refresh!starts a new jobfor: 'match. The core cancels this plugin’s previous job for'matchand drops its waiting results. - If the old job is still computing, its thread’s next timer tick sees the cancel and jumps out to the job’s start, past any
guardin the worker, and the thread ends: 0.28 ms after the cancel for an endless loop. Throughout, typing stayed at about 0.1 ms per key. - The limit: a worker blocked inside a system call (a read that never returns) stops only when the call returns. The core reports it after 3 s. A worker that reads must have its own way out: the process service releases its reader by closing the pipe. Turning a plugin off cancels all its jobs the same way, without waiting.
In Emacs the same match would run on the main thread, and a package keeps typing responsive by checking input-pending-p and giving up when a key arrives.
A shell outlives the terminal plugin
Why it works this way: processes you start are your work. They keep running when the terminal plugin turns off, reloads or is swapped, and across a clean exit; a terminal that comes back follows them again. Killing a shell’s buffer ends the shell. list-processes shows every process. A process a plugin starts for itself stops with that plugin.
M-x shell. The terminal makes*shell*withbuffers:create(your buffer) and asksprocesses:start!for a process for it. The process service launches a helper program in its own session, which runs the shell and owns its pipes, and records it in its saved data under the buffer’s id.- The terminal calls
processes:follow!. The service starts its reader worker as the terminal’s job: “from offset 0”. Each piece of output is posted with its offset; the service hands the terminal only what it has not delivered, and saves the new offset. - You turn the terminal plugin off. The reader was on the terminal’s receipt, so it is cancelled and the service releases it, through the end action
follow!made for the terminal. Its keys go.*shell*stays: it is your buffer; its mode is treated as Fundamental while the terminal is off. The shell keeps running;M-x list-processesshows it. - The shell goes on printing. The helper keeps the output (the last million characters) with offsets.
- You turn the terminal on again. The last form of its file runs: for each shell still running, follow it again. The service resumes from the delivered offset, so the output printed while it was off appears and nothing is repeated. A reload is the same, back to back: tested with 300 lines over two reloads, all in order, the same shell.
- You kill
*shell*. The process service’s entry inkill-query-hookasks “Buffer has a running process; kill it?”, as Emacs does. Yes: itskilled-hookentry ends the shell’s whole process group at once, whether the terminal is on or off. If the process service itself is off, the shell ends when the service next starts. - You quit Tuna normally. Shells keep running; the next start follows them again, as
tmuxdoes, not Emacs.
In Emacs a shell’s process belongs to its buffer and ends when Emacs exits, after asking.