Files
jsrt/docs/model.md
T
2026-10-03 14:06:24 +00:00

18 KiB

The core model

This file is the spec for how the compiler represents JS values, objects, bindings and functions in C. Each part of the code must agree with it. A difference between the code and this file is a bug in one of them.

The method:

  1. State the JS semantics that the C code must keep (section 1).
  2. Define a default representation that keeps all of it with no analysis (section 2).
  3. Let types (sections 3 and 4), the value rule (section 5) and analyses (sections 6 and 7) narrow the representation. Each narrowing has one condition. If the condition is not proved, the default stays.

So a hole in an analysis costs speed. It never makes wrong code. Only a wrong rule can make wrong code.

1. Semantics

JS has four core concepts.

Value. A value is one of: undefined, null, bool, number, string, object. A value cannot change. A value is always copied: an assignment, an argument, a return and a field write each copy it. An object value is a reference to an object, so a copy of it copies the reference.

Object. An object is a cell with identity. It has own fields and a prototype (an object or null). A field read looks at the own fields. If the field is absent, the read goes on to the prototype, and at the end of the chain it gives undefined. An absent field and a field that holds undefined are different. Two object values are equal (===, ==) if and only if they refer to the same object.

Binding. A binding is the storage of a variable. Each entry into a scope makes new bindings. A loop body is entered once for each iteration. for (let …) copies the values of the loop vars into the new bindings of the next iteration. A closure captures bindings, not values. So a write to a captured binding, by the owner or by any closure, is seen by all of them.

Function. A function is an object that also has code, and the bindings that it captured.

When the representation is free

A representation is correct if no program can see a difference from these semantics.

  • A primitive cannot change and has no identity. A copy of it and a shared pointer to it look the same. Either is correct.
  • An object can be copied instead of shared only if no program can see the copy. There are two ways to see a copy:
    • a write through one holder, which another holder reads later
    • an identity test between two holders
  • A binding must be shared if a closure captures it and a write reaches it after the capture.

2. The default representation

With no types and no analysis, this representation is correct for every program that uses only supported ops.

  • Every value is an any_t: a tagged union of the kinds. An object in an any_t is a pointer to its cell, so it keeps its identity.
  • Every object is a cell on the heap. Each object starts with a header: the site that created it. The site tells the object's shape and, for a fn, its code. Each field starts as absent.
  • Every captured binding is a box: a cell on the heap with one slot.
    • The box is made when the fn is entered.
    • The owner and every closure that captured the binding read and write the slot.
    • A scope that is entered again is a loop body. QuickJS closes each captured binding of such a scope (close_loc k) at every way out of it. close_loc k puts a new box in k, which holds the current value. Closures made before keep the old box. So each iteration has its own bindings.
    • A box's slot is never absent: JS gives a binding a value before any read of it that does not throw. A var starts as undefined. A let or const has no value before its declaration, where a read throws (the TDZ, section 8).
    • A binding that no closure captures is a C local. Transform knows this from closure_vars, so no analysis decides it.
    • A module binding has no frame. Its box is made where the fn that declares it is made.
  • A function object holds a pointer to each box that it captured. The fn that makes the closure gives each box: from its own local if the binding is its own, else from its own env. So every capture is one copy of a pointer.

3. Types

A type tells which values a place can hold at runtime. Infer computes one type for each value id in each variant (section 3.5).

3.1 The types

A type is one of these:

type values
Unknown none: no value has reached the place yet
Undefined undefined
Null null
Bool(v) a bool. v is the value, if it is known
Int an int32 (provisional: the number type is not decided)
Float a number
Str(v) a string. v is the value, if it is known
Obj(site) an object that site creates. A fn is an object whose site has code
Sum(id) a value of one of the types in a list: Int | Str, Obj(A) | Null, Obj(A) | Obj(B)
Any any value. No rule gives it now (section 3.4)

Join:

          Unknown * T = T
              Any * T = Any
                T * T = T
    Bool(a) * Bool(b) = Bool, the value not known. The same for Str
          Int * Float = Float
                T * U = T | U

Sums. A sum is a list of the types above, except Unknown, Sum and Any. It has at most one type of each kind, except objects, which have one Obj for each site. A join of two sums is the union of their lists, where two types of one kind join by the rules above. So Int | Str joined with Float is Float | Str. Each list is kept once, by number, in the site table (SiteTable), which also holds a row for each site. There are finitely many kinds and sites, so there are finitely many sums.

Members. The members of a sum are the types in its list. The only member of any other type is the type itself, and Unknown has none. Every rule in infer works member by member, and joins the results (section 3.3).

3.2 The invariant

At every program point, every runtime value of a place is in the place's type.

Every rule in infer must keep this invariant. Every rule is monotone, and a rule with an Unknown operand gives Unknown. Infer starts from Unknown everywhere and computes a fixed point of the rules.

3.3 Objects

  • A site is an op that creates an object (object, fclosure), or a built-in fn. All objects of a site share one type for each field, for the whole program. Two sites are never merged: objects of two sites in one place give Any.
  • A field's type is the join of every value written to it. A field can be absent, unless every object of the site gets it when it is created (section 7). A field that no write reaches is only absent.
  • A field read on Obj(site) gives the own field's type. If the field can be absent, it also gives the read on the prototype (the __proto__ field). The chain ends at a prototype that is absent or not an object, where the read gives Undefined. So a field that can be absent reads as its type | Undefined.
  • A field write on Obj(site) joins the value's type into the site's field. So every value that holds an object of the site sees the write.
  • A primitive has no own fields. A read on it reads its kind's prototype, which the stdlib links with __link_proto_*.
  • A field read or write on undefined or null throws (section 8), so it gives Unknown.
  • An array is an object too, of a site whose kind is "array": array_from makes it. Besides its fields, the site has an element type: the join of every value written to an element. So the type of an array is Obj(site), and its site says what its elements can be (written [T] for short).
    • a[i] gives T | Undefined: an index can be a hole, or past the end.
    • a[i] = x joins the type of x into T. An index past the end makes the array longer, and the indexes between are holes.
    • a.length is an Int, and it is not a field. A write of it truncates the array, or makes it longer with holes.
    • An index is a number. Any other key is a computed field name (section 8).
    • An array's prototype is the one that the stdlib links for arrays (__link_proto_array), as for a primitive kind. Its methods are JS in the stdlib, on top of the three ops above.
  • On a sum, a read, a write or a call works on each member, and joins the results. So a read on Obj(A) | Null gives the read on A: the Null member throws, and gives nothing.

3.4 Any

Since sums, no join gives Any, so no value has the type Any. The rules for it stay, so that it can be used again (for example, as a limit on the size of a sum).

Code that holds an Any does not know the site of an object in it. So infer tracks which objects can be in an Any:

  • The mark. When a value of type Obj(site) goes into an Any, the site is marked in_any. A value goes into an Any in a join that gives Any, and when it is an operand or a result of a read, write or call on Any. The mark spreads to the sites of the objects in the site's fields, because code that holds the Any can read them.
  • A read on Any reads the field on every in_any site and on every primitive prototype. What it reads goes into the Any that it gives.
  • A write on Any writes the field on every in_any site, and sets their written flag (section 5).
  • A call on Any calls the code of every in_any site that has code, in the variant whose args are all Any, except the env, which is the fn object itself (Obj(site)). It gives Any.

So every object that code with an Any can reach is of an in_any site, and the C can choose the code by the site in the object's header.

3.5 Calls

  • A call of Obj(site) runs the site's code, with the fn object itself as its env. A call of a sum calls each Obj member with its own env, and joins the results. A call of a value that is not a fn throws (section 8). A call of Any is in section 3.4.
  • The program is closed. Every fn comes from an fclosure, or it is a native. So the code of every site is known.
  • A variant is one walk of a fn with one tuple of arg types. A known value does not cross a call (Bool(v) becomes Bool), so there are finitely many variants.
  • Rounds: infer walks every variant from the entry again, until a round changes nothing in the fields, the flags, the prototypes or the returns. A call of a variant that is still being walked (recursion) gives what that variant returned in the round before. When a round changes nothing, these returns are the fixed point.

4. Representation

The C representation of a value is a function of its type and of the value rule (section 5) only. Each type has one:

type C
Unknown the code cannot run
Undefined, Null undefined_t, null_t: unit types
Bool bool
Int int32_t
Float double
Str string_t
Obj(site), a value shape (section 5) S, the struct of the site
Obj(site), any other shape S *, a pointer to the struct of the site
Str, plus Undefined and/or Null string_t. NULL is null, and JSRT_UNDEFINED is undefined
Obj(site), plus Undefined and/or Null S *, with the same two sentinels
Obj of several sites, with or without Undefined and Null jsrt_header *, a pointer to the object's header, with the same two sentinels
any other sum any_t. It is the tagged union of every kind, so it holds every sum
Any any_t, a tagged union. An object in it is the pointer to its cell
  • An array is a pointer to the struct of its site, whose header is followed by length, cap and items: an array of cap elements, of the representation of T | Undefined. A hole holds undefined.
  • Absent is a field state, not a value. A field that can be absent needs one more immediate for absent: a sentinel address for string_t and S *, a header tag for S, and an any_t tag for every other type.
  • Conversions. A type grows in a join, so most conversions go to a representation that holds more: a member into its sum, or int32_t into double. These cannot fail. A conversion to a representation that holds less checks the value, and a failed check is a TypeError (section 8). For example, a string_t that can be undefined, passed to a native's string param.
  • An op on a sum tests which member the value is: a kind test on an any_t, a sentinel test for null and undefined, and a switch on the site in the header for objects. Each case then works on one member, as for a single type. A member on which the op throws (a field read on null) has no case, so it throws.
  • A read, a write or a call on any_t of type Any is a helper that emit writes: a switch on the site in the object's header, with one case for each in_any site (section 3.4). Every field name is known at compile time, so no hash map is needed. One comes with computed field names (section 8).

5. Values: immutable shapes

The rule. Section 1 gives the two ways to see a copy of an object: a write through one holder that another holder reads later, and an identity test. An object that is never written after its creation, and never in an identity test, has neither. So it behaves like a primitive: a copy of it and a shared pointer to it cannot be told apart. It can be a C struct value, copied freely, with any number of holders.

The flags. Infer sets three flags on each site. Each flag only becomes true.

  • written: a field write that is not a creation write reaches an object of the site. A creation write is a define_field into the value that a creating op gives, before the object goes anywhere: an object literal's fields, a closure's captured boxes. Regify turns a define_field into anything else (a class field on this, a static field on the class) into a put_field. So every define_field is a creation write, and every put_field is a write after creation.
  • compared: an object of the site is an operand of ===, !==, == or !=, and the other operand can also be an object (Obj or Any).
  • in_any: an object of the site can be in an Any (section 3.4). Code that does not know the site can write it or compare it.

Value shapes. A site has a value shape if no flag is set, it is not an array, it is not a member of a sum (an element of an array is in the sum T | Undefined), and it does not contain itself through value fields. A sum is one C type for all its members, so its object members are pointers. A struct cannot contain itself, so a site that does stays a pointer. An object of an immutable site cannot hold itself, but objects of one site can hold each other, as in a list.

Representation. An object of a value shape is a struct value (S), in its holder: a C local, a param, a return value, or the struct of the object whose field holds it. In a field that can be absent, its header's site is the tag for absent (JSRT_SITE_ABSENT). It is not in a sum and not in_any, so it is never an any_t value. A runtime operation that only reads it (ToString, <) gets a heap copy.

Default. An object of any other shape is a pointer to a cell (S *).

Soundness. No write after creation can show a copy, and no identity test can. A creation write happens before the object goes anywhere, so no copy exists yet.

6. Escape

Question. Where can a cell still be reached: after its frame returns, or when the op that made it runs again?

Result. One of three values for each value that a creating op gives:

  • escapes if one of these is true:
    • After the frame of its fn returns, the cell can be reached in a way other than as the return value itself. For example, it is stored in a field of a cell that is not local, stored in a box that is not local, or given to a callee. Every call operand counts as kept, because the analysis does not know the callee.
    • A value that can reach the cell is still used when the creating op runs again: the op is in a loop, and the cell reaches a var or a join slot, which can keep it until the next run.
  • returned if it does not escape, the return value is the only way to reach it after the return, it is the only object that a return of the fn can give, and its creating op runs at most once in each call.
  • local otherwise: no holder outlives the frame.

A box is a cell, so the same rules apply to boxes.

Narrowing.

  • local: the cell is on the stack of its frame. The value is still a pointer (S *) to it, so only the creating op changes. A C compiler can also split such a cell into registers if its address goes nowhere.
  • returned: the caller allocates the cell. The variant gets one more param: a pointer to the cell to build into. The callee builds the object there and does not allocate. In the caller, the call is the creating op of the cell. So the caller's escape result for the call's dst chooses the stack or the heap, and the decision goes up through any number of fns that make and return objects.

Default. A cell that escapes is on the heap (jsrt_alloc).

Soundness.

  • A stack cell lives until its frame returns, and its creating op uses the same memory again each time it runs. local excludes both cases.
  • A returned object is in the caller's cell from the start and never moves. So every pointer to it stays valid, including a pointer from the object to itself.

Where it is used. Emit chooses the allocation of every cell in one function, alloc(). A creating op calls it, and so does a call of a variant whose result is returned.

7. Other narrowings

narrowing condition if not proved
no absent in a field a write to the field comes before every read of it (definite initialization). Object-literal fields have this: QuickJS fills them with define_field before the object goes anywhere the field can be absent (section 3.3)
a captured binding as a value no write reaches the binding after its first capture. Most const bindings have this. A const that a hoisted fn captures before its declaration does not a box
a direct call the callee's type is Obj(site): its code is known a call on Any: a switch on the site (section 3.4)

8. Out of scope

These are not part of this model, and they need their own design:

  • narrowing in branches: in if (x !== null) { x.f }, x keeps its whole type. This is correct, because the Null member of the read throws, but the C still tests for null
  • throw, try, catch and finally. These include the TypeErrors above: a call of a value that is not a fn, and a failed check of a conversion from any_t.
  • the TDZ (a read of a let before its declaration)
  • fields with computed names (o[k]), delete, in. The absent state is the base that in and delete need.
  • typed arrays, ArrayBuffer and DataView
  • iterators: for…of, spread and destructuring
  • getters and setters