Skip to content

vm: invoke standard library functions internally, add typeof and throw - #509

Open
jow- wants to merge 3 commits into
masterfrom
feat/stcall-typeof-throw
Open

jow- wants to merge 3 commits into
masterfrom
feat/stcall-typeof-throw

Conversation

@jow-

@jow- jow- commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Adds I_STCALL, which calls a standard library function directly by slot with the values on the stack as arguments, without pushing a call frame — the arguments already lie where uc_fn_arg() expects them, and the callee restores the stack itself when it calls back into managed code. Slots are pinned by designated initializers in uc_stdlib_functions[], never renumbered, and retiring a function leaves its slot reserved and empty. Bypassing the runtime namespace makes such calls immune to user code shadowing the function name, and leaving out the call frame lets exceptions report the position in the calling chunk instead of that of a synthetic frame.

Two language features are built on it:

  • typeof as prefix operator, yielding the type name of a value (and null for null) — emitted as an internal call to type().
  • throw as statement, raising the exception described by its expression; a bare throw dies without a message, and a caught exception object can be thrown again — emitted as an internal call to die().

Both are sugar for standard library functions, so user code defining its own type or die cannot take them over. Both words are added to the reserved ones; using them as identifiers is a syntax error from now on.

The bytecode version is recorded per program rather than per release: a program starts out requiring the oldest version this release accepts and records a newer one only once it is compiled to use an instruction introduced since then. Bytecode using no such instruction therefore stays loadable by a VM predating it, while bytecode that does is rejected at load instead of a run ending on an unknown opcode partway through. The version occupies four bits sharing closure_refs' storage unit, so struct size, offsets and alignment are unchanged on LP64 and ILP32 alike.

Tests: tests/custom/00_syntax/32_typeof_and_throw; the full custom suite passes (281/281).

This is the preparatory part of #500 — the destructuring feature is stacked on top of it and uses I_STCALL for its splice()/rawdelete() calls instead of adding opcodes of its own.

Both listings that render bytecode now resolve the callee instead of showing the raw operand: trace output (-t) annotates the instruction (STCALL {0x210001} ; type) and the debugger's disassembly gains a stdlib_name field rendered as STCALL {0x1b0003 : splice, 3 args}, via a new uc_stdlib_function_name_by_slot() companion to the VM's slot lookup.

jow- added 2 commits October 24, 2026 00:36
Add I_STCALL, which calls a standard library function with the values on the
stack as arguments, without pushing a call frame: the arguments already lie
where uc_fn_arg() expects them, so the callee can be entered as an ordinary C
function and restores the stack itself when it calls back into managed code.

Address the callee by slot rather than by name: uc_stdlib_fn_t names every slot
and uc_stdlib_functions[] pins each one to its function with a designated
initializer, so the order the table is written in does not move any slot and a
slot can never silently name another function.  Retiring a function means
dropping its initializer, which leaves the slot reserved and empty; the VM
reports such a slot rather than invoking something else, and the lookup by name
in uc_stdlib_function() skips it, as do the helper macros registering a function
list.  Since the slot number is baked into compiled bytecode, the slots stay
reserved and are never renumbered.

Bypassing the runtime namespace makes such calls independent of user code
shadowing a function's name, and leaving the call frame out lets exceptions
report the position of the instruction in the calling chunk, rather than that of
a synthetic frame.

Keep the bytecode format versions of the program, rather than of this release:
a program starts out requiring the oldest version this release accepts and
records a newer one only once it is compiled to use an instruction introduced
since then, which is what uc_program_write() stores.  Bytecode using no such
instruction therefore stays loadable by a VM predating it, while bytecode which
does is rejected at load, rather than a run ending on an unknown opcode partway
through.  A program read from a stream keeps the version it came with.

Store the version in four bits sharing closure_refs' storage unit, since
trailing padding cannot be relied on across ABIs: on LP64 four padding bytes
follow closure_refs, while on ILP32 - i386, armel/armhf, mips and powerpc, all
of which are packaged - the struct ends exactly there and a field of its own
would grow it.  Size, offsets and alignment are thus unchanged on either ABI;
28 bits for closure_refs still far exceed the 25-bit reference count they are
compared against.  Bytecode of both versions loads now, which is why the
serialized program embedded in 61_loadstring needs no change.

The commits which follow use this to implement the `typeof` operator, the
`throw` statement and the rest elements of destructuring patterns; the first of
them records the newer version when emitting the instruction.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Support `typeof` as prefix operator, yielding the type name of a value and
null for a null value, and `throw` as statement, raising the exception
described by its expression.  A bare `throw` dies without a message and a
caught exception object can be thrown again.

Both are sugar for standard library functions and are emitted as internal
calls, see I_STCALL: `typeof` calls type() and `throw` calls die(), so user
code defining a `type` or `die` of its own cannot take them over.

Emitting that instruction records the newer bytecode version on the program,
so that only a script actually using one of these features is serialized in a
form which a VM predating the instruction rejects; a script using neither is
serialized as before.

Add both words to the reserved ones; using them as identifiers is a syntax
error from now on.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
@jow-
jow- force-pushed the feat/stcall-typeof-throw branch from 3b87b29 to a357bc0 Compare October 23, 2026 22:37
I_STCALL encodes its callee as a slot into uc_stdlib_functions[], which the
existing listings rendered as a bare operand - the one thing that actually
identifies the call was missing from both.

Resolve the slot to the function name with a new
uc_stdlib_function_name_by_slot(), the companion of the existing
uc_stdlib_function_by_slot() used by the VM; it returns NULL for slots beyond
the table and for empty ones, i.e. functions retired since the bytecode was
compiled, which both listings render as "(?)"/"(unknown)".

- Trace output (uc_dump_insn()) annotates the instruction like it already does
  for LOAD/LVAR/SVAR and the variable accesses: `STCALL {0x210001}  ; type`.

- The debugger's DISASSEMBLE response gains a `stdlib_name` field beside the
  existing `call_nargs` (the lower half of the operand is the argument count,
  as for CALL), and the client renders it as `STCALL {0x1b0003 : splice, 3
  args}`. I_STCALL never passes a this-context and is never a tail call, so
  neither the mcall nor the tail marker apply to it.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant