sandbox: give a script its own heap with an exact memory limit #1

Open
opened 2026-10-05 21:06:59 +00:00 by orion · 5 comments
Collaborator

Goal

A sandboxed script's memory limit counts what the script holds (live bytes), not what it has ever allocated, and the memory of a script that stopped is given back at teardown. Today torb_sandbox_account only adds allocated bytes (runtime/sandbox.c), because free does not know a block's size, so a script that churns through garbage hits the limit while holding almost nothing, and a stop leaks what the script held (SCRIPTS.md section 4 "Memory is allocation, not residency, for now", section 6 "A stop leaks what the script held").

Implements

  • SCRIPTS.md slice 6: "The sandbox's own heap: an exact memory limit, a teardown that frees what a stopped script held" (state: Open)
  • ROADMAP.md, milestone 7, open work "the sandbox's own heap"
  • BACKEND.md section 5.4: "memory from the sandbox's own heap (its own allocator, so the limit is exact and the teardown is one free)"; SCRIPTS.md wins where they differ.

Acceptance criteria

Proposed against the recommended option of the owner decision below; they are final once it is answered.

  • While a sandbox is open, the live bytes of the blocks the script allocated (torb_allocate, torb_raw_allocate, sized by torb_platform_allocation_size as the counted memory limit of runtime/memory.c already does) are the number checked against the memory limit. A block freed while the sandbox is open gives its bytes back; a block that was allocated outside the sandbox never counts, and freeing it never lowers the count. Exact in the VM (torb run, torb test) and in a native binary that hosts scripts (--embed-vm, the script host).
  • A script that allocates and drops far more than the limit in total, but never holds more than it, runs to its end. A script that holds more than the limit stops with the SandboxError "The script allocated more than N bytes", never the process (compiler/tests/sandbox.test.trb, both cases, exact byte boundary).
  • A stopped (limit, panic, Process.exit) script's blocks are freed at teardown where nothing outside the sandbox holds them; a test shows torb_live_block_count back at its value before the script ran (VM-only conformance program or runtime test).
  • The existing sandbox tests, tests/conformance/sandbox-crossing.trb and tests/conformance/vm-only/sandbox-*.trb stay green; tier A and tier B gates green (runtime/ and the VM change).
  • SCRIPTS.md slice 6 marked Done (header status, sections 4 and 6 reworded from "allocation, not residency" and "a stop leaks"), BACKEND.md 5.4 and VM.md section 10 brought in line; build/release/torb.exe docs skill docs skills regenerated if a skill page changes.

Seed

Expected: does not touch the seed. No syntax change, no new native (the kernel operations SandboxOpen/SandboxClose exist; a change to them is an operation, SCRIPTS.md section 5), no new driver flag, no std name looked up by string. The work is runtime/memory.c, runtime/sandbox.c, runtime/machine.c and the VM's TorbScript where it calls them. If the implementation needs a new native or a changed std/sandbox name the compiler looks up, it stops and files a seed question instead of going on.

Size

One PR, landing as two Conventional Commits: feat(runtime): count the live bytes a sandboxed script holds (accounting plus the exact-limit tests) and feat(runtime): free what a stopped script held at teardown (teardown, the leak-count test, docs). Owner of the change: runtime/ and the VM, so an opus/high worker. No split needed.

Status

Waiting on the owner decision in the comments: what "teardown frees" may mean for a block the host still reaches (SCRIPTS.md section 5 passes the value by reference inside one VM), and whether slice 6 is a real arena allocator or accounting over libc's heap.

Moved from the planning lists of the docs; the design record stays the specification.

## Goal A sandboxed script's memory limit counts what the script **holds** (live bytes), not what it has ever allocated, and the memory of a script that stopped is given back at teardown. Today `torb_sandbox_account` only adds allocated bytes (`runtime/sandbox.c`), because `free` does not know a block's size, so a script that churns through garbage hits the limit while holding almost nothing, and a stop leaks what the script held (SCRIPTS.md section 4 "Memory is allocation, not residency, for now", section 6 "A stop leaks what the script held"). ## Implements - [SCRIPTS.md slice 6](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/design/SCRIPTS.md#L316): "The sandbox's own heap: an exact memory limit, a teardown that frees what a stopped script held" (state: Open) - [ROADMAP.md, milestone 7](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/ROADMAP.md#L43), open work "the sandbox's own heap" - BACKEND.md section 5.4: "`memory` from the sandbox's own heap (its own allocator, so the limit is exact and the teardown is one free)"; SCRIPTS.md wins where they differ. ## Acceptance criteria Proposed against the recommended option of the owner decision below; they are final once it is answered. - [ ] While a sandbox is open, the live bytes of the blocks the script allocated (`torb_allocate`, `torb_raw_allocate`, sized by `torb_platform_allocation_size` as the counted memory limit of `runtime/memory.c` already does) are the number checked against the `memory` limit. A block freed while the sandbox is open gives its bytes back; a block that was allocated outside the sandbox never counts, and freeing it never lowers the count. Exact in the VM (`torb run`, `torb test`) and in a native binary that hosts scripts (`--embed-vm`, the script host). - [ ] A script that allocates and drops far more than the limit in total, but never holds more than it, runs to its end. A script that holds more than the limit stops with the `SandboxError` "The script allocated more than N bytes", never the process (`compiler/tests/sandbox.test.trb`, both cases, exact byte boundary). - [ ] A stopped (limit, panic, `Process.exit`) script's blocks are freed at teardown where nothing outside the sandbox holds them; a test shows `torb_live_block_count` back at its value before the script ran (VM-only conformance program or runtime test). - [ ] The existing sandbox tests, `tests/conformance/sandbox-crossing.trb` and `tests/conformance/vm-only/sandbox-*.trb` stay green; tier A and tier B gates green (`runtime/` and the VM change). - [ ] SCRIPTS.md slice 6 marked **Done** (header status, sections 4 and 6 reworded from "allocation, not residency" and "a stop leaks"), BACKEND.md 5.4 and VM.md section 10 brought in line; `build/release/torb.exe docs skill docs skills` regenerated if a skill page changes. ## Seed Expected: **does not touch the seed.** No syntax change, no new native (the kernel operations `SandboxOpen`/`SandboxClose` exist; a change to them is an operation, SCRIPTS.md section 5), no new driver flag, no std name looked up by string. The work is `runtime/memory.c`, `runtime/sandbox.c`, `runtime/machine.c` and the VM's TorbScript where it calls them. If the implementation needs a new native or a changed `std/sandbox` name the compiler looks up, it stops and files a seed question instead of going on. ## Size One PR, landing as two Conventional Commits: `feat(runtime): count the live bytes a sandboxed script holds` (accounting plus the exact-limit tests) and `feat(runtime): free what a stopped script held at teardown` (teardown, the leak-count test, docs). Owner of the change: `runtime/` and the VM, so an opus/high worker. No split needed. ## Status Waiting on the owner decision in the comments: what "teardown frees" may mean for a block the host still reaches (SCRIPTS.md section 5 passes the value by reference inside one VM), and whether slice 6 is a real arena allocator or accounting over libc's heap. _Moved from the planning lists of the docs; the design record stays the specification._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop). Implements SCRIPTS.md slice 6 and ROADMAP milestone 7; the design record settles the goal. No collision with a design record or CONCEPT "Open Questions". Touches runtime/ and the VM (opus/high worker when implemented); seed impact to settle in refine. Release freeze for 0.1.0 (2026-10-06) holds new PRs, next: refine (buffer already >= 2 ready, so queued)

[loop:torbscript] intake: in scope (author orion, label loop). Implements SCRIPTS.md slice 6 and ROADMAP milestone 7; the design record settles the goal. No collision with a design record or CONCEPT "Open Questions". Touches `runtime/` and the VM (opus/high worker when implemented); seed impact to settle in refine. Release freeze for 0.1.0 (2026-10-06) holds new PRs, next: refine (buffer already >= 2 ready, so queued)
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261007 until 2026-10-07T13:13:06Z

[loop:torbscript] claim refine by session:loop-torbscript-20261007 until 2026-10-07T13:13:06Z
Author
Collaborator

[loop:torbscript] owner decision:

What. SCRIPTS.md slice 6 and BACKEND 5.4 say "own heap: exact limit, teardown is one free", but no design record says how a block that the script allocated and the host still reaches is treated. SCRIPTS.md section 5 decides that inside one VM the value is passed by reference and "the script configures the caller's value in place": lists, texts and boxes the script allocates end up inside the caller's value, on a normal return and also on a stop (the frames are abandoned, the value stays as the script left it). A literal "teardown frees every block of the sandbox" would leave the caller with dangling blocks. A native binary that hosts scripts is different: only text crosses (section 5), so there the sandbox's blocks are never shared.

Options.

  1. Real arena per sandbox (bump allocator with size-class free lists, BACKEND 2.5, one free at teardown). Exact by construction. Everything a script builds that the host keeps has to be copied out at close, which reverses "nothing is copied or converted" of section 5 for programs the VM runs; blocks in the arena must not be freed with libc's free (every free site of memory.c learns the owner). Largest change, touches the layout contract between runtime and VM.
  2. Accounting over libc's heap, owner known from a side table (recommended). While a sandbox is open every block the script allocates is recorded (pointer set, size from torb_platform_allocation_size, as the counted memory limit already does); a free of a recorded block subtracts its bytes, a free of any other block never does. The limit is exact in live bytes. On a normal close the surviving blocks are handed to the host (the table is dropped, they are ordinary blocks). On a stop: native host (slice 8, value crossed as text): everything still recorded is freed, the criterion "live-block counter back to its value before the script ran" holds. Program the VM runs (value by reference): blocks the script allocated stay valid because the caller's value may reach them, and what an abandoned frame held is freed only where its count shows nothing else holds it; a leak bounded by the limit remains in that one case and is documented (as section 6 says today for all cases). No change to the value-passing decision of section 5, no ABI change, no seed impact.
  3. Option 2 for accounting only, teardown deferred: ship the exact limit now, keep "a stop leaks what the script held" and leave teardown to a later slice together with a decision on copy-out (option 1).

Recommendation. Option 2. It delivers the exact limit everywhere, the full teardown where the host shares nothing (the case a server or an embedded script host has), and does not reopen section 5. If you prefer a clean break instead, option 1 with copy-out at close is the honest "own heap", at the price of a larger change and a revised section 5. Once you answer (comment here, or relay it from the TorbScript group), I set ready and the acceptance criteria in the issue body become final.

[loop:torbscript] owner decision: **What.** SCRIPTS.md slice 6 and BACKEND 5.4 say "own heap: exact limit, teardown is one free", but no design record says how a block that the script allocated and the **host still reaches** is treated. SCRIPTS.md section 5 decides that inside one VM the value is passed by reference and "the script configures the caller's value in place": lists, texts and boxes the script allocates end up inside the caller's value, on a normal return and also on a stop (the frames are abandoned, the value stays as the script left it). A literal "teardown frees every block of the sandbox" would leave the caller with dangling blocks. A native binary that hosts scripts is different: only text crosses (section 5), so there the sandbox's blocks are never shared. **Options.** 1. **Real arena per sandbox** (bump allocator with size-class free lists, BACKEND 2.5, one free at teardown). Exact by construction. Everything a script builds that the host keeps has to be copied out at close, which reverses "nothing is copied or converted" of section 5 for programs the VM runs; blocks in the arena must not be freed with libc's `free` (every `free` site of `memory.c` learns the owner). Largest change, touches the layout contract between runtime and VM. 2. **Accounting over libc's heap, owner known from a side table** (recommended). While a sandbox is open every block the script allocates is recorded (pointer set, size from `torb_platform_allocation_size`, as the counted memory limit already does); a free of a recorded block subtracts its bytes, a free of any other block never does. The limit is exact in live bytes. On a normal close the surviving blocks are handed to the host (the table is dropped, they are ordinary blocks). On a stop: **native host (slice 8, value crossed as text): everything still recorded is freed**, the criterion "live-block counter back to its value before the script ran" holds. **Program the VM runs (value by reference): blocks the script allocated stay valid** because the caller's value may reach them, and what an abandoned frame held is freed only where its count shows nothing else holds it; a leak bounded by the limit remains in that one case and is documented (as section 6 says today for all cases). No change to the value-passing decision of section 5, no ABI change, no seed impact. 3. **Option 2 for accounting only, teardown deferred**: ship the exact limit now, keep "a stop leaks what the script held" and leave teardown to a later slice together with a decision on copy-out (option 1). **Recommendation.** Option 2. It delivers the exact limit everywhere, the full teardown where the host shares nothing (the case a server or an embedded script host has), and does not reopen section 5. If you prefer a clean break instead, option 1 with copy-out at close is the honest "own heap", at the price of a larger change and a revised section 5. Once you answer (comment here, or relay it from the TorbScript group), I set `ready` and the acceptance criteria in the issue body become final.
Author
Collaborator

[loop:torbscript] refine: rewrote the body to the definition of ready (goal, criteria, SCRIPTS.md slice 6 / ROADMAP milestone 7 / BACKEND 5.4, seed: not expected, size: one PR in two commits). The design records leave open how teardown treats blocks the host still reaches (value passed by reference inside one VM) and arena vs accounting over libc; filed the owner decision above with a recommendation. No ready label until it is answered. Tokens used ~90000, next: waiting (owner decision)

[loop:torbscript] refine: rewrote the body to the definition of ready (goal, criteria, SCRIPTS.md slice 6 / ROADMAP milestone 7 / BACKEND 5.4, seed: not expected, size: one PR in two commits). The design records leave open how teardown treats blocks the host still reaches (value passed by reference inside one VM) and arena vs accounting over libc; filed the owner decision above with a recommendation. No `ready` label until it is answered. Tokens used ~90000, next: waiting (owner decision)
Author
Collaborator

[loop:torbscript] wait: owner decision of comment 504 (blocks teardown of host-reachable blocks, arena vs accounting) is still open since 2026-10-07; nothing to do until Torben answers. Re-check 2026-11-06.

[loop:torbscript] wait: owner decision of comment 504 (blocks teardown of host-reachable blocks, arena vs accounting) is still open since 2026-10-07; nothing to do until Torben answers. Re-check 2026-11-06.
Sign in to join this conversation.
No labels
loop
ready
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#1
No description provided.