Skip to content

S2: dependency cook, source debugging and transactional replacement

S2 extends the private S1 two-instance door interaction with immutable dependency bundles, real Luau breakpoints/locals/steps, a declared-state codec, whole-VM replacement and native GameHost retirement. LUDUS_BUILD_SCRIPT_SESSION defaults to OFF. The runtime and generated catalog are not installed in the SDK. The optional GameApi 1.1 debugger callback is installed as a small provider-neutral contract. S0's exception-free interpreter review and missing device/performance acceptance remain production gates.

Cook and trust boundary

Run from a prepared checkout:

./scripts/script-session bootstrap
./scripts/script-session cook
./scripts/script-session run --preset linux-clang-development
./scripts/script-session check --preset linux-clang-development
./scripts/script-session run --preset linux-clang-asan-ubsan
./scripts/script-session run --preset web-emscripten-development
./scripts/script-session run --preset web-emscripten-release
node tools/script-session/browser.mjs out/build/web-emscripten-release/tools/script-session

Bootstrap uses the pinned, paired S1 compiler/analyzer and reviewed S0 sources. Cook accepts a closed bounded catalog: at most eight packages and eight programs per package, two entrypoints per cohort, direct source basenames, no path escape, 64 KiB sources and 256 KiB bytecode. Imports must be literal lowercase 16-digit asset IDs and exactly match declared edges. Missing, cyclic, duplicate and non-module dependencies fail before initialization. The lexer ignores comments and strings; aliases/dynamic imports are rejected. The analysis view rewrites literal identities to local analyzer module paths; emitted source preserves identities and line counts. Every program passes strict analysis before admission.

A program key includes source, transitive dependency keys, schema, binding manifest, paired tools, runtime pin and interpreter O1/g2 profile. The package key also includes revision and state range. Changing an imported constant invalidates both unchanged entrypoint sources. Validated content-addressed bytecode can be reused on subsequent cooks; strict analysis still runs. Cook builds in a temporary directory and publishes only after success, retaining the previous cooked output on analysis/compiler failure. Build verification fingerprints inputs, paired host tools, bytecode, generated catalog and maps. Tampered cache output is rejected.

out/script-session/cooked/<package>/bundle.json records identities and bytecode hashes. maps.json is a separate debug artifact mapping compiled lines to the original authored file, source hash, line count and program key. This debug-only profile retains g2 data; it is not a shipping stripping/transport profile.

The default S2 owner accepts its compiled trusted catalog. S3 also uses the private Session with an explicitly owner-admitted paired-cooker catalog; a checksum is not authorization to load arbitrary bytes. Runtime loading checks unique assets and dependency-first ordering again. Initializers receive no world or service facade. require exists only during initialization and resolves only the current program's declared earlier modules. S2 module exports are frozen, flat primitive constants; nested mutable objects/functions are rejected. Root functions can use local helpers. General module exports are future work.

Pause and inspect

Each debug invocation owns a rooted Luau thread. Actual lua_breakpoint, debugbreak, debugstep, lua_break and lua_resume implement source stops and continue/into/over/out. Steps compare source, line and stack depth. A stopped invocation keeps its POD transaction alive; authoritative state and command publication wait until successful completion. A debug pause is a partial tick, not a checkpoint or reload boundary. Closing a stopped VM cancels that transaction.

Inspection copies at most eight frames and sixteen top-frame locals inside the protected VM callback. The bounded JSON reply exposes at most four frames/eight locals and reports truncation. Nil, booleans, finite numbers and up to 64 raw string bytes (hex) are copied; tables, functions and userdata are opaque. No pointers, metamethods, getters or expression evaluation enter the response. Nonfinite values have a separate kind and no JSON nonfinite number. Source maps identify authored and compiled lines. Session, execution, stop, world, asset, instance and tick IDs use fixed-width hex strings, preserving IDs above 2^53.

Requests carry version 1, action, session, execution, revision and stop. stop must match the current stop sequence, or zero when not stopped. Supported actions are inspect, breakpoint (asset/line/enabled), continue, into, over, out and reload (package/expected). Unknown fields, malformed IDs, stale revisions/stops, unsupported watches and unavailable commands are rejected before mutation. Breakpoint replies return the actual resolved executable line. Runtime faults return explicit diagnostics, discard unpublished effects and halt the fixture; S2 does not implement an interactive break-on-error stack or arbitrary watches.

Native clients send protocol v1 ScriptDebug over the existing GameHost control connection, through the shared supervisor as well, with expected_generation, extension 1 and a bounded UTF-8 JSON payload string. The usual session/epoch/request ledger and generation check apply. The response is a CommandResult whose message contains the provider JSON. Status reports script_paused. The API tail is gated by Capability::ScriptDebug, negotiated size and ABI minor 1. Providers use Status::ScriptPaused from Update to suspend the host tick; the host continues control/platform pumping, with no SimTicks/FrameIndex advance. Ordinary simulation commands and native reload are Busy until script resume or Stop. Other providers need not implement or link Luau.

State and replacement

Only a completed EndTick with no pending invocation or commands is a safe point. Prepare checks expected package key, increasing revision, compatible compiler/ binding pin and schema direction. It migrates both declared states by stable field ID into POD candidate records, then initializes an entirely fresh VM. Init, OOM, schema/range or trust failure destroys the candidate and retains the active VM, execution identity and both states. An intervening authoritative tick cancels a staged candidate. Commit rechecks expected identity/tick/execution, swaps the whole cohort without allocation, advances execution identity and closes the old VM. Every callback, upvalue, thread root and userdata from that VM is retired. Breakpoints are revision-scoped and must be set again after replacement.

The state codec is explicit LE: magic/version/schema/count (four uint32s), then stable ID/kind/value (three uint32s) per field, up to sixteen fields. Booleans are exactly 0/1. Decode/migrate preserve outputs on failure; duplicate IDs, bad kinds, invalid ranges, kind changes and removed fields are rejected. New declared fields use validated defaults. This slice has uint32/boolean fields and schema 1→2; logical asset/entity references, timers, waits and subscriptions are not yet exposed and are not serialized as VM pointers or coroutine stacks.

The fixture checkpoint is 168 bytes: LE magic/version, session/tick, 64-byte package key and two 40-byte tagged state records. Restore validates the entire record into temporary state before publishing and rebinds fresh native entities. GameHost adds its normal project/game/generation/schema/body-digest envelope. Native modules own both VM banks inside their instance; Quiesce gates new work without destroying a stopped invocation, and Destroy closes both before the loader releases the module image. No callback or asynchronous lease escapes this synchronous fixture. Future external workers must participate in native work leases; this does not prove a general asynchronous provider implementation.

Acceptance and limits

The same executable runs on Clang and Node/Wasm. It verifies stable-tag codecs, output-preserving rejection, two-instance migration, changed import behavior, failed initialization, narrowing rejection, forged-catalog rejection, stale prepare/commit, genuine breakpoints/primitive locals/stepping, partial-tick gates, repeated replace/stop and zero live VM bytes. Allocation failure is injected at every observed candidate-staging allocation and every observed debugger setup/ local-capture allocation. S1's synchronous contract remains separately tested.

The native socket acceptance uses a real headless GameHost process, the same control connection and two separately loaded module images. It checks responsive Status/inspection with frozen host ticks, reload rejection while stopped, resume, checkpoint migration into a new module, and VM retirement before module release on reload, Stop and disconnect. ASan/UBSan covers these paths. Chromium drives the same bounded JSON control API through Wasm exports, with a timer heartbeat during a real source stop, unchanged tick/state, source locals and replacement behavior. The Wasm acceptance executable reserves a checked 1 MiB native stack for its multiple simultaneous test owners; this is a fixture setting, not a runtime performance budget. Evidence is saved in out/browser-qa/results/luau-s2.json; CI retains the Web executable, map/cook evidence and test output.

This milestone is an experimental vertical slice. It adds no installed scripting runtime, DAP server/editor debugger UI, arbitrary binding/package loader, shipping bytecode acquisition, asynchronous wait support, visual graph editor or C# provider. The public ABI extension and private ownership proof are foundations for those integrations. Performance/size claims require representative workloads and device evidence beyond these conformance tests.

Thanks to Roblox Corporation for the pinned Luau protected-call/debug/sandbox contracts (source, API, sandbox); Waldemar Celes, Luiz Henrique de Figueiredo and Roberto Ierusalimschy, “Binding C/C++ Objects to Lua”, Game Programming Gems 6, §4.2, pp. 341–355, for host lifetime separation; and Julien Hamaide, “Automatic Lua Binding System”, Game Programming Gems 7, §7.1, pp. 503–516, for generated binding/schema discipline. The implementation is original and adapts these ideas to bounded POD trampolines and a whole-VM swap. See the Gems review and GameHost reload design.