Skip to content
Merged
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
39 changes: 39 additions & 0 deletions builtin_array.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,40 @@ func (r *Runtime) arrayproto_pop(call FunctionCall) Value {
}
}

// pushToStringStack checks for circular references and pushes an object onto the toString stack.
// Returns true if the object is already in the stack (circular reference detected), false otherwise.
// If false is returned, the caller must ensure the object is popped from the stack when done.
func (r *Runtime) pushToStringStack(o *Object) bool {
// Check for circular reference in the toString stack
for _, obj := range r.toStringStack {
if o == obj {
// Circular reference detected
return true
}
}

// Push this object onto the stack
r.toStringStack = append(r.toStringStack, o)
return false
}

// popFromStringStack removes an object from the toString stack.
func (r *Runtime) popFromStringStack() {
// Set the last element to nil to allow GC to collect it
r.toStringStack[len(r.toStringStack)-1] = nil
r.toStringStack = r.toStringStack[:len(r.toStringStack)-1]
}

func (r *Runtime) arrayproto_join(call FunctionCall) Value {
o := call.This.ToObject(r)

if r.pushToStringStack(o) {
// Circular reference detected, return empty string to avoid infinite recursion
// This matches the behavior of mainstream JavaScript engines (V8, SpiderMonkey)
return stringEmpty
}
defer r.popFromStringStack()

l := int(toLength(o.self.getStr("length", nil)))
var sep String
if s := call.Argument(0); s != _undefined {
Expand Down Expand Up @@ -249,6 +281,13 @@ func (r *Runtime) writeItemLocaleString(item Value, buf *StringBuilder) {

func (r *Runtime) arrayproto_toLocaleString(call FunctionCall) Value {
array := call.This.ToObject(r)

if r.pushToStringStack(array) {
// Circular reference detected, return empty string to avoid infinite recursion
return stringEmpty
}
defer r.popFromStringStack()

var buf StringBuilder
if a := r.checkStdArrayObj(array); a != nil {
for i, item := range a.values {
Expand Down
123 changes: 123 additions & 0 deletions builtin_array_circular_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package goja

import (
"testing"
)

func TestArrayCircularReferenceToString(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
var str = String(T);
// Circular reference should be replaced with empty string
str === "1,2,3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,";
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceNumericOperation(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
try {
var x = T % 2; // This should not crash
true;
} catch (e) {
false;
}
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceJoin(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
var str = T.join(',');
// Circular reference should be replaced with empty string
str === "1,2,3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,";
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceConcat(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
var str = '' + T; // String concatenation
// Circular reference should be replaced with empty string
str === "1,2,3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,";
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceToLocaleString(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
var str = T.toLocaleString();
// Circular reference should be replaced with empty string
str === "1,2,3,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,";
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayMultipleCircularReferences(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T;
T[76] = T;
T[80] = T;
var str = String(T);
// Should handle multiple circular references - all should be empty strings
str.split(',').length === 81;
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayNestedCircularReference(t *testing.T) {
const SCRIPT = `
var A = [1, 2];
var B = [3, 4];
A[2] = B;
B[2] = A; // Mutual circular reference
var str = String(A);
// A contains B which contains A - circular refs should be empty
str === "1,2,3,4,";
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceAccessOK(t *testing.T) {
const SCRIPT = `
// These operations should still work fine
var T = [1, 2, 3];
T[42] = T;

// Accessing circular reference is OK
var same = T[42] === T;

// Accessing elements through circular reference is OK
var first = T[42][0];

// Deep nesting is OK
var deep = T[42][42][42][42][42][0];

same && first === 1 && deep === 1;
`
testScript(SCRIPT, valueTrue, t)
}

func TestArrayCircularReferenceComparison(t *testing.T) {
const SCRIPT = `
var T = [1, 2, 3];
T[42] = T; // Create circular reference
try {
var result = T == 5; // Comparison should not crash
true;
} catch (e) {
false;
}
`
testScript(SCRIPT, valueTrue, t)
}
4 changes: 4 additions & 0 deletions runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ type Runtime struct {

promiseRejectionTracker PromiseRejectionTracker
asyncContextTracker AsyncContextTracker

// Stack for tracking objects currently being converted to string
// to detect and handle circular references
toStringStack []*Object
}

type StackFrame struct {
Expand Down
Loading