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.

Start-up, step by step
tuna executablestartsFind plugin folders;build what changedCheck all of init.ssbefore running anyMark every listedplugin as dueRun init.ss linestop to bottomA plugin's file runstop to bottomFirst need: starta due plugin earlyjournal-store startsreads the journaldefine-saved readsits saved valueCommand loop runs;first frame drawnfirst lineuse-plugincalls itloadlast line done
  1. 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.
  2. It reads init.ss and checks all of it before running any line: every plugin named exists, every option name and value fits, every c:x exists in its contract. If anything is wrong, it lists every error with its line and runs nothing. tuna check does just this step.
  3. Every plugin with a use-plugin line is marked due, and each contract gets its chosen provider. Options given in use-plugin lines become layers owned by those lines.
  4. 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.
  5. Each use-plugin line starts its plugin, unless it already started: a new run begins and the plugin’s plugin.ss runs top to bottom, each change going on the run’s receipt.
  6. Each define-saved in 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 names restore-saved!.
  7. If a plugin’s file calls a contract whose provider is due but has not started (buffers calls text-store:make), that provider starts first, there and then: first need. A plugin not listed in init.ss never starts; calling it fails with “its provider is off”.
  8. 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.
  9. 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.

A key press: the command loop, the threads, the split drawing
HTTP thread (web plugin)Editor thread (the only one running plugin code)Worker threadsBrowser tabkey 'a' pressedInput hand-overplain data onlyCommand loop wakes; input firstkeys plugin: find the bindingcommand runs: buffers:insert!after-change-hook entries runsave point: changes to (storage)worker results applied, in orderframe-hook: display extractsthe visible lines as plain datahighlight's workerscans the textdrawing workerbuilds the pagesubmitwakestart-jobpostsend
  1. You press a in 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.
  2. 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.
  3. The loop always takes waiting input first. The key becomes a task. The keys plugin looks up the binding, in the current buffer’s modes, then the global map, and makes the selected window’s buffer current. a is bound to inserting itself.
  4. The command runs. buffers:insert! makes a new text and stores it in the saved map buffers:all.
  5. The insert runs after-change-hook. The highlighter’s entry starts a job for: this buffer: a new thread, given the read-only text without a copy. Its previous job for the buffer, if still running, is cancelled.
  6. The command ends. Save point: the core collects the saved data that changed and hands it to (storage); journal-store appends one record to its file.
  7. 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.
  8. The highlighter’s on-result calls buffers:set-faces!. Because its job was the newest for this buffer, the spans fit the current text.
  9. A frame is due, because input came. The entries of frame-hook run: 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.
  10. 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.

A start, and a failed start
New run of flymakereceipt is emptyplugin.ss runs form by formeach change goes on the receiptA form raises an error(or passes the time limit)Undo the receiptnewest firstUndo its own writes to saveddata where still its writesReport: file, line, errorPlugins needing it stay offwith a message naming itEvery other plugin starts
  1. 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.
  2. plugin.ss runs 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.
  3. 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.
  4. 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.
  5. 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-id stay used and are never given again.
  6. You get a report with the file, the line and the error.
  7. Plugins whose start needs flymake stay off; their message names flymake as the cause, not their own line.
  8. Every other plugin starts as usual. When you fix the file and reload, flymake starts and the plugins that needed it come back with it.
  9. 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.

Reload, and failed reload
You save gitstat'splugin.ss and reloadBuild the new code;check its interfaceOld run ends(run-ending) = reloadIts ties move tothe next runNew run startsfrom the new fileSuccess: plugins thatused it rerunNew code failsOld code starts againwith saved dataIts ordinary variables start fresh; you are told why
  1. You edit gitstat/plugin.ss and 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.
  2. The old run ends with (run-ending) = reload. Commands paused inside it are quit, as by C-g. Its timers stop, its jobs are cancelled, its entries and layers go, its on-end actions run. Your C-M-x edits of it end: they lasted until a reload from the file.
  3. 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.
  4. 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.
  5. It starts. Plugins that called gitstat while starting rerun, because what they called may now behave differently. Your redefine and advice-add layers on its functions apply to the new code.
  6. Or: the new code fails while starting. Its start is undone as in a failed start.
  7. Then the old code, still compiled, starts again from its file, with the saved data.
  8. 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.

Turning flymake off: the six kinds of thing
flymake turns off1. Its registrationskey C-c ! n, command,mode-line item: removed2. Its contributionsunderlines and margin markson buffers: removed3. Work in progresstimer stopped, workercancelled, prompts quit4. Things made for itflymake-mode in a buffer:tie runs, mode goes5. Its saved datakept, for its returnor a replacement6. Plugins needing itrerun; or stay off witha message naming it
  1. You turn flymake off: remove its line from init.ss and reload-config, or use the turn-off command. The command first asks any question flymake’s views would ask on closing (a running compile would ask; flymake has none). Then everything happens in one step, which cannot pause. From reload-config there is no one to ask, so a view that would ask stays open and you are told why.
  2. Its registrations go. The key C-c ! n, the command flymake-goto-next-error, its mode-line item FlyM:1, its hook entries: each was made through a verb or add-hook! and is on its receipt, so each is removed.
  3. Its contributions to other plugins’ things go. The wavy underlines and the ! in the margin were its entries in buffers’ faces and margins, made by buffers:set-faces! for it; they are removed, and the next frame is drawn without them. Its buffer-scoped found layers go too. The buffer itself is untouched.
  4. 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.
  5. Things it had another plugin make go or stay, as that plugin said. flymake turned on flymake-mode in notes.txt through modes:enable-minor!, which tied the mode to its caller: the tie runs and the mode goes. A view from buffers:view would close; a buffer from buffers:create would stay; a process from processes:start-own would end.
  6. Its saved data is kept, for when it returns or for a plugin implementing the same contract.
  7. Plugins that depend on it rerun. One whose start calls it cannot start: it stays off, with a message naming flymake, and comes back when flymake does. 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’s unload-feature does.
  8. 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.

Swapping rope-store for string-store
init.ss: rope-store linereplaced by string-storerope-store turns off(as if never on)string-store startschecked against (text-store)Saved data named by thecontract is read by the new onebuffers, which used(text-store), rerunsIf string-store fails:rope-store starts againtext-store:insert nowreaches string-storeon error
  1. You replace (use-plugin rope-store) with (use-plugin string-store) in init.ss and run reload-config, or use the swap command. If rope-store also filled some other contract that string-store does not, the swap is refused before anything changes.
  2. rope-store turns off, as in turning off.
  3. string-store starts. It was checked against (text-store) at build (names and kinds) and is checked at load (numbers of arguments).
  4. 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.
  5. buffers used (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.
  6. Every text-store:insert call, including ones stored earlier, now reaches string-store, because contract names are wrappers that look up the current provider at each call.
  7. If string-store fails to start, rope-store starts 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.

A crash, and the next start
Command 1 endssave pointCommand 2 pausesat a prompt: saveCommand 3 halfdone: not savedCrashTuna startsagainjournal-store replays thejournal; plugins read itState as of the last save point; command 2is gone, what it did before the prompt staysShells still runningin their helpers: reattached
  1. Command 1 ends. Save point: its changes to saved data are handed to storage and written.
  2. Command 2 asks a question and pauses at the prompt. Save point again: what it did before the prompt is written.
  3. Command 3 is halfway through. Nothing of it is written yet.
  4. Tuna crashes, or the machine loses power.
  5. You start Tuna. journal-store replays its journal; a record cut off by the crash is dropped.
  6. 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.
  7. 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.

A fuzzy match on a worker, replaced by the next keystroke's
Editor threadThe job's own threadcpop: (start-job match input on-result for: 'match)Core checks input is plain data;shares read-only parts, copies the reston-result runs: candidates go todisplay:show-popup!; the frame shows themNext key: same for: 'matchcancels the old job, starts a new oneWorker runs: fuzzy-matches100,000 namespost: result copied to the editorin order; waits if 1,000 pile upTimer tick sees 'cancelled':the thread escapes and ends
  1. The completion popup calls (start-job match (vector names input) on-result for: 'match), where names is a vector of 100,000 candidate strings.
  2. 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.
  3. 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.
  4. 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, post would wait.
  5. On the editor thread, in the next turn of the loop and after any waiting input, on-result runs: if the same prompt is still open, the candidates go to display:show-popup! and the next frame shows them.
  6. You type another character. refresh! starts a new job for: 'match. The core cancels this plugin’s previous job for 'match and drops its waiting results.
  7. 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 guard in 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.
  8. 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.

A shell's lifetime across the terminal plugin
Tuna (one OS process)Other OS processesterminal pluginfollows the outputprocesses pluginowns the user's processesBuffer *shell*made by buffers:createReader workerasks from offset NSaved data: processes' list (buffer id, helper, delivered offset N)Helper processkeeps output with offsetsThe shell (bash)start!launchpipesfollow!
  1. M-x shell. The terminal makes *shell* with buffers:create (your buffer) and asks processes: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.
  2. 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.
  3. 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-processes shows it.
  4. The shell goes on printing. The helper keeps the output (the last million characters) with offsets.
  5. 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.
  6. You kill *shell*. The process service’s entry in kill-query-hook asks “Buffer has a running process; kill it?”, as Emacs does. Yes: its killed-hook entry 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.
  7. You quit Tuna normally. Shells keep running; the next start follows them again, as tmux does, not Emacs.

In Emacs a shell’s process belongs to its buffer and ends when Emacs exits, after asking.