The virtual machine
A compiler and a bytecode interpreter in Rust, compiled to WebAssembly, built so that a FoxPro program can stop for a dialog without stopping the window it is in.
Visual FoxPro compiled a program to p-code and shipped a runtime that executed it. FoxDev
Studio is the same arrangement made again: crates/foxvm is a lexer, an error-recovering
parser, a bytecode compiler and an interpreter, written in Rust and compiled to WebAssembly
with wasm-bindgen. One module runs your code in the IDE, in a shipped application, and in the
headless foxvm runner. The editor lints through the same compiler, so what it underlines and
what the runtime refuses cannot drift apart.
This page is about the machine. Bytecode is about what it runs.
Fibers
Every running program, form method, menu command and Command Window line is a fiber: a
value stack, a stack of frames, the TRY handlers its frames have installed, and the
bookkeeping for one pending host request. Several fibers can be parked at once - a program in
READ EVENTS, a Click method running in another, a Timer firing in a third - and the
scheduler in the host decides which one steps next.
A frame owns its LOCAL slots, its PRIVATE variables, THIS, its WITH stack and the
argument list it was called with. A LOCAL is a numbered slot and is never visible to a callee.
A PRIVATE or an undeclared name is dynamic: LoadName walks the privates of the current
frame, then of every caller, then the PUBLIC globals, which is what dynamic scoping has always
meant in this language.
A call is not a Rust call. run is one loop over the frame stack; a call pushes a frame and
goes round again, and a return pops one. Nothing recurses into the interpreter, which is what
makes the next section possible.
The VM never blocks
The wasm exports are not re-entrant: calling one while another is on the stack throws. The whole runtime is built around that, and it turns out to be the design rather than a constraint.
When a program needs something from the world outside - a message box, a modal form, the next
page of a table, a COM call, a file - the VM does not call out and wait. It yields a host
request and stops. step answers with one of three things:
Done { value, nodefault } the fiber finished, and this is what it returned
Error { error, stack } it failed, and here is the error and where it was
Suspend { request } it needs the host: perform this, then resume me
The host performs the request while the VM is off the stack and then calls resume with the
answer (or resume_error with an error number and message, which the fiber's own TRY and
ON ERROR machinery then sees). There are 73 kinds of request, from SetProp and CallMethod
through DoForm, CreateObject, LoadClassLib, the file and table requests, to the SQL
pass-through and the library calls.
Three things fall out of this:
- A modal dialog blocks the program, not the window.
MESSAGEBOX()is a request; the fiber is parked until the host answers, and the rest of the IDE - the Command Window, other forms, the debugger - carries on. - Nested events run in the order FoxPro ran them. Because the VM is off the stack while a
request is performed, the host may dispatch a nested event as a fiber of its own and drive it
to completion before the outer fiber resumes. That is how
SetFocus()firesGotFocus, howInitruns while a form is still being built, and how a Timer fires while a program is parked inREAD EVENTS. - A built-in cannot run FoxPro itself. A built-in handed a script, or a lambda, returns
RunScriptorCallFunctionand lets the VM push the frame, because the code it would run may stop for the host half way through.EXECSCRIPT()is the whole of the first; a built-in handed a function value is the whole of the second.
Reads are the one exception
A property read is synchronous. THISFORM.Caption in an expression cannot yield, because an
expression that stopped half way would not be an expression. So the VM is constructed with a
small object of imports - get_prop, get_member, object_class, now, random,
mouse, resolve_program and a few more - that the host answers immediately, without the VM
leaving the stack. Everything that changes the world goes the other way, as a request.
The scheduler
src/shared/runtime/scheduler.ts is the host's half of the bargain. It steps a fiber,
performs what the fiber yields, resumes it, and keeps going until the fiber is done, has failed,
or is parked waiting for something that will arrive later (a dialog's answer, a form's
Unload, the next request on a socket).
Its rules are simple and every part of the runtime leans on them:
- One fiber runs at a time, to completion or to a park. Two
Clickmethods do not interleave; two HTTP requests do not interleave. - An event that arrives while a fiber is on the JS stack is queued, and the queue drains in arrival order the moment the stack unwinds. Starting a fiber there would be a re-entrant call.
- A handler that blocks holds the queue. A long
SELECT, aMESSAGEBOX, aWAIT WINDOWwith no timeout stops every other event until it is done. This is the price of the single-threaded model, and it is written down here rather than left to be discovered. - Cancelling a session settles everything. Every parked fiber is aborted, every queued event is answered with "nothing ran", every open server is closed.
A host event is a function value and the arguments to call it with: scheduler.raise({ func, args }). That is the seam a Timer, a menu choice and an HTTP request all cross; see
the HTTP API for the one that arrives from a socket.
The data engine
The host owns the bytes and the VM owns the meaning. A USE asks the main process to open the
file; a record is read by a seek-and-read request with a 64-bit offset; a page of records is
decoded inside the VM; a change goes back as the field bytes at the offset they came from. A
table is never loaded into memory, which is why one can be larger than memory, and larger than
the two gigabytes Visual FoxPro stops at.
SCAN, LOCATE, SEEK against a .cdx tag, SET FILTER, SET RELATION and the SQL
SELECT all go through the same primitives. A query is compiled to a QueryPlan - nested loops
over the sources, with joins, WHERE, GROUP BY, HAVING, ORDER BY, DISTINCT, TOP n,
uncorrelated subqueries and INTO CURSOR or INTO ARRAY - and runs as bytecode over the same
SqlBegin, SqlRow, SqlEnd instructions a hand-written loop would, so it reads a table
larger than memory the same way a SCAN does.
Values
The VM's Value is what Visual FoxPro's is - character, numeric (with the width it was written
in, so ? 001 prints 1), currency, date, datetime, logical, null, an array, an object
handle - plus the two FoxScript adds: a function (a lambda, VARTYPE() "F") and a
JSON value (VARTYPE() "J"). Both are described under the new keywords.
An object is a handle. The host allocates handles from 1 upwards and reserves 0x7fff_ffff for
the application object; the VM's own objects - the FoxScript namespace and the servers it
makes - come from 0xF000_0000 upwards, and a test asserts the two ranges never meet. A value
crossing the boundary travels as JSON: an object as its handle, a function as its id, a JSON
value as its text, a date as an ISO string.
What crosses the boundary
The FoxVm wasm class is the whole surface the host sees. Its exports fall into five groups:
| Group | Exports |
|---|---|
| Compiling | compile_program, compile_form, compile_snippet, compile_expression, check; each answers bytes plus diagnostics with line and column. |
| Loading and starting | load_module, start (a function by name), start_method (a form event), start_class_method, start_function (a lambda by id, which is what a host event is), class_definitions. |
| Driving | step, resume, resume_error, abort, abort_all, route (which lambda answers an HTTP request), menu_chosen. |
| The debugger | set_breakpoint, clear_breakpoints, breakpoints, set_step_mode, frames, frame_variables, evaluate, evaluate_in, call_stack. |
| The environment | set_global, get_global, set_setting, get_setting, version, and the DBF helpers the table browser uses: read_dbf_header, decode_dbf_page, encode_dbf_field. |
The module itself is inlined into the renderer as base64 and instantiated with initSync,
because a packaged application loads its pages over file:// and the tests run under jsdom,
and neither can fetch a .wasm from a URL.
Errors, and how they are numbered
An error unwinds to the innermost TRY handler of the fiber, whichever frame installed it:
frames above it are popped, the value stack is cut back to where it was at TryPush, and the
fiber continues at CATCH or, with only a FINALLY, at that block with the error pending for
EndFinally. Without a handler, ON ERROR text is compiled as a snippet and run in a new
frame, and the failing frame continues at its next statement when it returns. Otherwise the
fiber ends with Error.
Error numbers are Visual FoxPro's, measured: a missing program is error 1, an unknown member 1925, a read-only property 1743. FoxScript's own failures are numbered from 3001, a long way above anything the product uses, so a program can tell one from the other by the number alone.
Runtime compilation
¯o, EXECSCRIPT(), EVALUATE() and a Command Window line are compiled on the spot,
against the slot names of the function they run in, and executed in an inline frame: a
frame with no storage of its own that forwards every local, private, WITH and argument access
to its owner. Slot numbers in the compiled code therefore line up with the owner's, a snippet
can declare a PRIVATE the owner then sees, and compiled snippets are cached per owner
function and text.