Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions Plugins/PackageToJS/Templates/runtime.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ type ref = number;
type pointer = number;

declare class JSObjectSpace {
private _heapValueById;
private _heapEntryByValue;
private _heapNextKey;
private _slotByValue;
private _values;
private _stateBySlot;
private _freeSlotStack;
constructor();
retain(value: any): number;
retainByRef(ref: ref): number;
release(ref: ref): void;
getObject(ref: ref): any;
retainByRef(reference: ref): number;
release(reference: ref): void;
getObject(reference: ref): any;
private _getValidatedSlotState;
}

/**
Expand Down
110 changes: 78 additions & 32 deletions Plugins/PackageToJS/Templates/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,44 +238,90 @@ function deserializeError(error) {

const globalVariable = globalThis;

const SLOT_BITS = 22;
const SLOT_MASK = (1 << SLOT_BITS) - 1;
const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1;
class JSObjectSpace {
constructor() {
this._heapValueById = new Map();
this._heapValueById.set(1, globalVariable);
this._heapEntryByValue = new Map();
this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 });
// Note: 0 is preserved for invalid references, 1 is preserved for globalThis
this._heapNextKey = 2;
this._slotByValue = new Map();
this._values = [];
this._stateBySlot = [];
this._freeSlotStack = [];
this._values[0] = undefined;
this._values[1] = globalVariable;
this._slotByValue.set(globalVariable, 1);
this._stateBySlot[1] = 1; // gen=0, rc=1
}
retain(value) {
const entry = this._heapEntryByValue.get(value);
if (entry) {
entry.rc++;
return entry.id;
}
const id = this._heapNextKey++;
this._heapValueById.set(id, value);
this._heapEntryByValue.set(value, { id: id, rc: 1 });
return id;
}
retainByRef(ref) {
return this.retain(this.getObject(ref));
}
release(ref) {
const value = this._heapValueById.get(ref);
const entry = this._heapEntryByValue.get(value);
entry.rc--;
if (entry.rc != 0)
const slot = this._slotByValue.get(value);
if (slot !== undefined) {
const state = this._stateBySlot[slot];
const nextState = (state + 1) >>> 0;
if ((nextState & SLOT_MASK) === 0) {
throw new RangeError(`Reference count overflow at slot ${slot}`);
}
this._stateBySlot[slot] = nextState;
return ((nextState & ~SLOT_MASK) | slot) >>> 0;
}
let newSlot;
let state;
if (this._freeSlotStack.length > 0) {
newSlot = this._freeSlotStack.pop();
const gen = this._stateBySlot[newSlot] >>> SLOT_BITS;
state = ((gen << SLOT_BITS) | 1) >>> 0;
}
else {
newSlot = this._values.length;
if (newSlot > SLOT_MASK) {
throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`);
}
state = 1;
}
this._stateBySlot[newSlot] = state;
this._values[newSlot] = value;
this._slotByValue.set(value, newSlot);
return ((state & ~SLOT_MASK) | newSlot) >>> 0;
}
retainByRef(reference) {
const state = this._getValidatedSlotState(reference);
const slot = reference & SLOT_MASK;
const nextState = (state + 1) >>> 0;
if ((nextState & SLOT_MASK) === 0) {
throw new RangeError(`Reference count overflow at slot ${slot}`);
}
this._stateBySlot[slot] = nextState;
return reference;
}
release(reference) {
const state = this._getValidatedSlotState(reference);
const slot = reference & SLOT_MASK;
if ((state & SLOT_MASK) > 1) {
this._stateBySlot[slot] = (state - 1) >>> 0;
return;
this._heapEntryByValue.delete(value);
this._heapValueById.delete(ref);
}
getObject(ref) {
const value = this._heapValueById.get(ref);
if (value === undefined) {
throw new ReferenceError("Attempted to read invalid reference " + ref);
}
return value;
this._slotByValue.delete(this._values[slot]);
this._values[slot] = undefined;
const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK;
this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0;
this._freeSlotStack.push(slot);
}
getObject(reference) {
this._getValidatedSlotState(reference);
return this._values[reference & SLOT_MASK];
}
// Returns the packed state for the slot, after validating the reference.
_getValidatedSlotState(reference) {
const slot = reference & SLOT_MASK;
if (slot === 0)
throw new ReferenceError("Attempted to use invalid reference " + reference);
const state = this._stateBySlot[slot];
if (state === undefined || (state & SLOT_MASK) === 0) {
throw new ReferenceError("Attempted to use invalid reference " + reference);
}
if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) {
throw new ReferenceError("Attempted to use stale reference " + reference);
}
return state;
}
}

Expand Down
126 changes: 90 additions & 36 deletions Runtime/src/object-heap.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,113 @@
import { globalVariable } from "./find-global.js";
import { ref } from "./types.js";

type SwiftRuntimeHeapEntry = {
id: number;
rc: number;
};
const SLOT_BITS = 22;
const SLOT_MASK = (1 << SLOT_BITS) - 1;
const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1;

export class JSObjectSpace {
private _heapValueById: Map<number, any>;
private _heapEntryByValue: Map<any, SwiftRuntimeHeapEntry>;
private _heapNextKey: number;
private _slotByValue: Map<any, number>;
private _values: (any | undefined)[];
private _stateBySlot: number[];
private _freeSlotStack: number[];

constructor() {
this._heapValueById = new Map();
this._heapValueById.set(1, globalVariable);

this._heapEntryByValue = new Map();
this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 });
this._slotByValue = new Map();
this._values = [];
this._stateBySlot = [];
this._freeSlotStack = [];

// Note: 0 is preserved for invalid references, 1 is preserved for globalThis
this._heapNextKey = 2;
this._values[0] = undefined;
this._values[1] = globalVariable;
this._slotByValue.set(globalVariable, 1);
this._stateBySlot[1] = 1; // gen=0, rc=1
}

retain(value: any) {
const entry = this._heapEntryByValue.get(value);
if (entry) {
entry.rc++;
return entry.id;
const slot = this._slotByValue.get(value);
if (slot !== undefined) {
const state = this._stateBySlot[slot]!;
const nextState = (state + 1) >>> 0;
if ((nextState & SLOT_MASK) === 0) {
throw new RangeError(
`Reference count overflow at slot ${slot}`,
);
}
this._stateBySlot[slot] = nextState;
return ((nextState & ~SLOT_MASK) | slot) >>> 0;
}

let newSlot: number;
let state: number;
if (this._freeSlotStack.length > 0) {
newSlot = this._freeSlotStack.pop()!;
const gen = this._stateBySlot[newSlot]! >>> SLOT_BITS;
state = ((gen << SLOT_BITS) | 1) >>> 0;
} else {
newSlot = this._values.length;
if (newSlot > SLOT_MASK) {
throw new RangeError(
`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`,
);
}
state = 1;
}
const id = this._heapNextKey++;
this._heapValueById.set(id, value);
this._heapEntryByValue.set(value, { id: id, rc: 1 });
return id;

this._stateBySlot[newSlot] = state;
this._values[newSlot] = value;
this._slotByValue.set(value, newSlot);
return ((state & ~SLOT_MASK) | newSlot) >>> 0;
}

retainByRef(ref: ref) {
return this.retain(this.getObject(ref));
retainByRef(reference: ref) {
const state = this._getValidatedSlotState(reference);
const slot = reference & SLOT_MASK;
const nextState = (state + 1) >>> 0;
if ((nextState & SLOT_MASK) === 0) {
throw new RangeError(`Reference count overflow at slot ${slot}`);
}
this._stateBySlot[slot] = nextState;
return reference;
}

release(ref: ref) {
const value = this._heapValueById.get(ref);
const entry = this._heapEntryByValue.get(value)!;
entry.rc--;
if (entry.rc != 0) return;
release(reference: ref) {
const state = this._getValidatedSlotState(reference);
const slot = reference & SLOT_MASK;
if ((state & SLOT_MASK) > 1) {
this._stateBySlot[slot] = (state - 1) >>> 0;
return;
}

this._slotByValue.delete(this._values[slot]);
this._values[slot] = undefined;
const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK;
this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0;
this._freeSlotStack.push(slot);
}

this._heapEntryByValue.delete(value);
this._heapValueById.delete(ref);
getObject(reference: ref) {
this._getValidatedSlotState(reference);
return this._values[reference & SLOT_MASK];
}

getObject(ref: ref) {
const value = this._heapValueById.get(ref);
if (value === undefined) {
// Returns the packed state for the slot, after validating the reference.
private _getValidatedSlotState(reference: ref): number {
const slot = reference & SLOT_MASK;
if (slot === 0)
throw new ReferenceError(
"Attempted to use invalid reference " + reference,
);
const state = this._stateBySlot[slot];
if (state === undefined || (state & SLOT_MASK) === 0) {
throw new ReferenceError(
"Attempted to use invalid reference " + reference,
);
}
if (state >>> SLOT_BITS !== reference >>> SLOT_BITS) {
throw new ReferenceError(
"Attempted to read invalid reference " + ref,
"Attempted to use stale reference " + reference,
);
}
return value;
return state;
}
}
46 changes: 16 additions & 30 deletions Tests/JavaScriptKitTests/JSClosureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,62 +92,48 @@ class JSClosureTests: XCTestCase {
throw XCTSkip("Missing --expose-gc flag")
}

// Step 1: Create many JSClosure instances
// Step 1: Create many source closures and keep only JS references alive.
// These closures must remain callable even after heavy finalizer churn.
let obj = JSObject()
var closurePointers: Set<UInt32> = []
let numberOfSourceClosures = 10_000

do {
var closures: [JSClosure] = []
for i in 0..<numberOfSourceClosures {
let closure = JSClosure { _ in .undefined }
let closure = JSClosure { _ in .number(Double(i)) }
obj["c\(i)"] = closure.jsValue
closures.append(closure)
// Store
closurePointers.insert(UInt32(UInt(bitPattern: Unmanaged.passUnretained(closure).toOpaque())))

// To avoid all JSClosures having a common address diffs, randomly allocate a new object.
if Bool.random() {
_ = JSObject()
}
}
}

// Step 2: Create many JSObject to make JSObject.id close to Swift heap object address
let minClosurePointer = closurePointers.min() ?? 0
let maxClosurePointer = closurePointers.max() ?? 0
while true {
let obj = JSObject()
if minClosurePointer == obj.id {
break
}
}

// Step 3: Create JSClosure instances and find the one with JSClosure.id == &closurePointers[x]
// Step 2: Create many temporary objects/closures to stress ID reuse and finalizer paths.
// Under the optimized object heap, IDs are aggressively reused, so this should exercise
// the same misdeallocation surface without relying on monotonic ID growth.
do {
while true {
let c = JSClosure { _ in .undefined }
if closurePointers.contains(c.id) || c.id > maxClosurePointer {
break
let numberOfProbeClosures = 50_000
for i in 0..<numberOfProbeClosures {
let tempClosure = JSClosure { _ in .number(Double(i)) }
if i % 3 == 0 {
let tempObject = JSObject()
tempObject["probe"] = tempClosure.jsValue
}
// To avoid all JSClosures having a common JSObject.id diffs, randomly allocate a new JS object.
if Bool.random() {
if i % 7 == 0 {
_ = JSObject()
}
}
}

// Step 4: Trigger garbage collection to call the finalizer of the conflicting JSClosure instance
// Step 3: Trigger garbage collection to run finalizers for temporary closures.
for _ in 0..<100 {
gc()
// Tick the event loop to allow the garbage collector to run finalizers
// registered by FinalizationRegistry.
try await Task.sleep(for: .milliseconds(0))
}

// Step 5: Verify that the JSClosure instances are still alive and can be called
// Step 4: Verify source closures are still alive and correct.
for i in 0..<numberOfSourceClosures {
_ = obj["c\(i)"].function!()
XCTAssertEqual(obj["c\(i)"].function!(), .number(Double(i)))
}
}
}
Loading