13 KiB
Plan: Complete Copying GC Implementation
Overview
Remove reference counting (DupValue/FreeValue) entirely and complete the Cheney copying garbage collector. Each JSContext will use bump allocation from a heap block, and when out of memory, request a new heap from JSRuntime's buddy allocator and copy live objects to the new heap.
Target Architecture (from docs/memory.md)
Object Types (simplified from current):
Type 0 - Array: { header, length, elements[] }
Type 1 - Blob: { header, length, bits[] }
Type 2 - Text: { header, length_or_hash, packed_chars[] }
Type 3 - Record: { header, prototype, length, key_value_pairs[] }
Type 4 - Function: { header, code_ptr, outer_frame_ptr } - 3 words only, always stone
Type 5 - Frame: { header, function_ptr, caller_ptr, ret_addr, args[], closure_vars[], local_vars[], temps[] }
Type 6 - Code: Lives in immutable memory only, never copied
Type 7 - Forward: Object has moved; cap56 contains new address
Key Design Points:
- JSFunction is just a pointer to code and a pointer to the frame that created it (3 words)
- Closure variables live in frames - when a function returns, its frame is "reduced" to just the closure variables
- Code objects are immutable - stored in stone memory, never copied during GC
- Frame reduction: When a function returns,
calleris set to zero, signaling the frame can be shrunk
Current State (needs refactoring)
- Partial Cheney GC exists at
source/quickjs.c:1844-2030:ctx_gc,gc_copy_value,gc_scan_object - 744 calls to JS_DupValue/JS_FreeValue scattered throughout (currently undefined, causing compilation errors)
- Current JSFunction is bloated (has kind, name, union of cfunc/bytecode/bound) - needs simplification
- Current JSVarRef is a separate object - should be eliminated, closures live in frames
- Bump allocator in
js_malloc(line 1495) withheap_base/heap_free/heap_end - Buddy allocator for memory blocks (lines 1727-1837)
- Header offset inconsistency - some structs have header at offset 0, some at offset 8
Implementation Steps
Phase 1: Define No-Op DupValue/FreeValue (To Enable Compilation)
Add these near line 100 in source/quickjs.c:
/* Copying GC - no reference counting needed */
#define JS_DupValue(ctx, v) (v)
#define JS_FreeValue(ctx, v) ((void)0)
#define JS_DupValueRT(rt, v) (v)
#define JS_FreeValueRT(rt, v) ((void)0)
This makes the code compile while keeping existing call sites (they become no-ops).
Phase 2: Standardize Object Headers (offset 0)
Remove JSGCObjectHeader (ref counting remnant) and put objhdr_t at offset 0:
typedef struct JSArray {
objhdr_t hdr; // offset 0
word_t length;
JSValue values[];
} JSArray;
typedef struct JSRecord {
objhdr_t hdr; // offset 0
JSRecord *proto;
word_t length;
slot slots[];
} JSRecord;
typedef struct JSText {
objhdr_t hdr; // offset 0
word_t length; // pretext: length, text: hash
word_t packed[];
} JSText;
typedef struct JSBlob {
objhdr_t hdr; // offset 0
word_t length;
uint8_t bits[];
} JSBlob;
/* Simplified JSFunction per memory.md - 3 words */
typedef struct JSFunction {
objhdr_t hdr; // offset 0, always stone
JSCode *code; // pointer to immutable code object
struct JSFrame *outer; // frame that created this function
} JSFunction;
/* JSFrame per memory.md */
typedef struct JSFrame {
objhdr_t hdr; // offset 0
JSFunction *function; // function being executed
struct JSFrame *caller; // calling frame (NULL = reduced/returned)
word_t ret_addr; // return instruction address
JSValue slots[]; // args, closure vars, locals, temps
} JSFrame;
/* JSCode - always in immutable (stone) memory */
typedef struct JSCode {
objhdr_t hdr; // offset 0, always stone
word_t arity; // max number of inputs
word_t frame_size; // capacity of activation frame
word_t closure_size; // reduced capacity for returned frames
word_t entry_point; // address to begin execution
word_t disruption_point;// address of disruption clause
uint8_t bytecode[]; // actual bytecode
} JSCode;
Phase 3: Complete gc_object_size for All Types
Update gc_object_size (line 1850) to read header at offset 0:
static size_t gc_object_size(void *ptr) {
objhdr_t hdr = *(objhdr_t*)ptr; // Header at offset 0
uint8_t type = objhdr_type(hdr);
uint64_t cap = objhdr_cap56(hdr);
switch (type) {
case OBJ_ARRAY:
return sizeof(JSArray) + cap * sizeof(JSValue);
case OBJ_BLOB:
return sizeof(JSBlob) + (cap + 7) / 8; // cap is bits
case OBJ_TEXT:
return sizeof(JSText) + ((cap + 1) / 2) * sizeof(uint64_t);
case OBJ_RECORD:
return sizeof(JSRecord) + (cap + 1) * sizeof(slot); // cap is mask
case OBJ_FUNCTION:
return sizeof(JSFunction); // 3 words
case OBJ_FRAME:
return sizeof(JSFrame) + cap * sizeof(JSValue); // cap is slot count
case OBJ_CODE:
return 0; // Code is never copied (immutable)
default:
return 64; // Conservative fallback
}
}
Phase 4: Complete gc_scan_object for All Types
Update gc_scan_object (line 1924):
static void gc_scan_object(JSContext *ctx, void *ptr, uint8_t **to_free, uint8_t *to_end) {
objhdr_t hdr = *(objhdr_t*)ptr;
uint8_t type = objhdr_type(hdr);
switch (type) {
case OBJ_ARRAY: {
JSArray *arr = (JSArray*)ptr;
for (uint32_t i = 0; i < arr->length; i++) {
arr->values[i] = gc_copy_value(ctx, arr->values[i], to_free, to_end);
}
break;
}
case OBJ_RECORD: {
JSRecord *rec = (JSRecord*)ptr;
// Copy prototype
if (rec->proto) {
JSValue proto_val = JS_MKPTR(rec->proto);
proto_val = gc_copy_value(ctx, proto_val, to_free, to_end);
rec->proto = (JSRecord*)JS_VALUE_GET_PTR(proto_val);
}
// Copy table entries
uint32_t mask = objhdr_cap56(rec->hdr);
for (uint32_t i = 1; i <= mask; i++) { // Skip slot 0
JSValue k = rec->slots[i].key;
if (!rec_key_is_empty(k) && !rec_key_is_tomb(k)) {
rec->slots[i].key = gc_copy_value(ctx, k, to_free, to_end);
rec->slots[i].value = gc_copy_value(ctx, rec->slots[i].value, to_free, to_end);
}
}
break;
}
case OBJ_FUNCTION: {
JSFunction *func = (JSFunction*)ptr;
// Code is immutable, don't copy - but outer frame needs copying
if (func->outer) {
JSValue outer_val = JS_MKPTR(func->outer);
outer_val = gc_copy_value(ctx, outer_val, to_free, to_end);
func->outer = (JSFrame*)JS_VALUE_GET_PTR(outer_val);
}
break;
}
case OBJ_FRAME: {
JSFrame *frame = (JSFrame*)ptr;
// Copy function pointer
if (frame->function) {
JSValue func_val = JS_MKPTR(frame->function);
func_val = gc_copy_value(ctx, func_val, to_free, to_end);
frame->function = (JSFunction*)JS_VALUE_GET_PTR(func_val);
}
// Copy caller (unless NULL = reduced frame)
if (frame->caller) {
JSValue caller_val = JS_MKPTR(frame->caller);
caller_val = gc_copy_value(ctx, caller_val, to_free, to_end);
frame->caller = (JSFrame*)JS_VALUE_GET_PTR(caller_val);
}
// Copy all slots (args, closure vars, locals, temps)
uint32_t slot_count = objhdr_cap56(frame->hdr);
for (uint32_t i = 0; i < slot_count; i++) {
frame->slots[i] = gc_copy_value(ctx, frame->slots[i], to_free, to_end);
}
break;
}
case OBJ_TEXT:
case OBJ_BLOB:
case OBJ_CODE:
// No internal references to scan
break;
}
}
Phase 5: Fix gc_copy_value Forwarding
Update gc_copy_value (line 1883) for offset 0 headers:
static JSValue gc_copy_value(JSContext *ctx, JSValue v, uint8_t **to_free, uint8_t *to_end) {
if (!JS_IsPtr(v)) return v; // Immediate value
void *ptr = JS_VALUE_GET_PTR(v);
// Stone memory - don't copy (includes Code objects)
objhdr_t hdr = *(objhdr_t*)ptr;
if (objhdr_s(hdr)) return v;
// Check if in current heap
if ((uint8_t*)ptr < ctx->heap_base || (uint8_t*)ptr >= ctx->heap_end)
return v; // External allocation
// Already forwarded?
if (objhdr_type(hdr) == OBJ_FORWARD) {
void *new_ptr = (void*)(uintptr_t)objhdr_cap56(hdr);
return JS_MKPTR(new_ptr);
}
// Copy object to new space
size_t size = gc_object_size(ptr);
void *new_ptr = *to_free;
*to_free += size;
memcpy(new_ptr, ptr, size);
// Leave forwarding pointer in old location
*(objhdr_t*)ptr = objhdr_make((uint64_t)(uintptr_t)new_ptr, OBJ_FORWARD, 0, 0, 0, 0);
return JS_MKPTR(new_ptr);
}
Phase 6: Complete GC Root Tracing
Update ctx_gc (line 1966) to trace all roots including JSGCRef:
static int ctx_gc(JSContext *ctx) {
// ... existing setup code ...
// Copy roots: global object, class prototypes, etc. (existing)
ctx->global_obj = gc_copy_value(ctx, ctx->global_obj, &to_free, to_end);
ctx->global_var_obj = gc_copy_value(ctx, ctx->global_var_obj, &to_free, to_end);
// ... other existing root copying ...
// Copy GC root stack (JS_PUSH_VALUE/JS_POP_VALUE)
for (JSGCRef *ref = ctx->top_gc_ref; ref; ref = ref->prev) {
ref->val = gc_copy_value(ctx, ref->val, &to_free, to_end);
}
// Copy GC root list (JS_AddGCRef/JS_DeleteGCRef)
for (JSGCRef *ref = ctx->last_gc_ref; ref; ref = ref->prev) {
ref->val = gc_copy_value(ctx, ref->val, &to_free, to_end);
}
// Copy current exception
ctx->current_exception = gc_copy_value(ctx, ctx->current_exception, &to_free, to_end);
// Cheney scan (existing)
// ...
}
Phase 7: Trigger GC on Allocation Failure
Update js_malloc (line 1495):
void *js_malloc(JSContext *ctx, size_t size) {
size = (size + 7) & ~7; // Align to 8 bytes
if ((uint8_t*)ctx->heap_free + size > (uint8_t*)ctx->heap_end) {
if (ctx_gc(ctx) < 0) {
JS_ThrowOutOfMemory(ctx);
return NULL;
}
// Retry after GC
if ((uint8_t*)ctx->heap_free + size > (uint8_t*)ctx->heap_end) {
JS_ThrowOutOfMemory(ctx);
return NULL;
}
}
void *ptr = ctx->heap_free;
ctx->heap_free = (uint8_t*)ctx->heap_free + size;
return ptr;
}
Phase 8: Frame Reduction (for closures)
When a function returns, "reduce" its frame to just closure variables:
static void reduce_frame(JSContext *ctx, JSFrame *frame) {
if (frame->caller == NULL) return; // Already reduced
JSCode *code = frame->function->code;
uint32_t closure_size = code->closure_size;
// Shrink capacity to just closure variables
frame->hdr = objhdr_make(closure_size, OBJ_FRAME, 0, 0, 0, 0);
frame->caller = NULL; // Signal: frame is reduced
}
Phase 9: Remove Unused Reference Counting Code
Delete:
gc_decref,gc_decref_childfunctionsgc_scan_incref_child,gc_scan_incref_child2functionsJS_GCPhaseEnum,gc_phasefieldsJSGCObjectHeaderstruct (merge into objhdr_t)ref_countfields from any remaining structsmark_function_children_decreffunction- All
free_*functions that rely on ref counting
Files to Modify
-
source/quickjs.c - Main implementation:
- Add DupValue/FreeValue no-op macros (~line 100)
- Restructure JSArray, JSBlob, JSText, JSRecord (lines 468-499)
- Simplify JSFunction to 3-word struct (line 1205)
- Add JSFrame as heap object (new)
- Restructure JSCode/JSFunctionBytecode (line 1293)
- Fix gc_object_size (line 1850)
- Fix gc_copy_value (line 1883)
- Complete gc_scan_object (line 1924)
- Update ctx_gc for all roots (line 1966)
- Update js_malloc to trigger GC (line 1495)
- Delete ref counting code throughout
-
source/quickjs.h - Public API:
- Remove JSGCObjectHeader
- Update JSValue type checks if needed
- Ensure JS_IsStone works with offset 0 headers
Execution Order
- First: Add DupValue/FreeValue macros (enables compilation)
- Second: Standardize struct layouts (header at offset 0)
- Third: Fix gc_object_size and gc_copy_value
- Fourth: Complete gc_scan_object for all types
- Fifth: Update ctx_gc with complete root tracing
- Sixth: Wire js_malloc to trigger GC
- Seventh: Add frame reduction for closures
- Finally: Remove ref counting dead code
Verification
- Compile test:
makeshould succeed without errors - Basic test: Run simple scripts:
var a = [1, 2, 3] log.console(a[1]) - Stress test: Allocate many objects to trigger GC:
for (var i = 0; i < 100000; i++) { var x = { value: i } } log.console("done") - Closure test: Test functions with closures survive GC:
fn make_counter() { var count = 0 fn inc() { count = count + 1; return count } return inc } var c = make_counter() log.console(c()) // 1 log.console(c()) // 2 - GC stress with closures: Create many closures, trigger GC, verify they still work
Key Design Decisions (Resolved)
- JSCode storage: Lives in stone (immutable) memory, never copied during GC ✓
- Header offset: Standardized to offset 0 for all heap objects ✓
- Closure variables: Live in JSFrame objects; frames are "reduced" when functions return ✓
- JSVarRef: Eliminated - closures reference their outer frame directly ✓