{"record":{"id":"92d8f2ec69028cdd","repo":"grafana/k6","slug":"cyclic-reference-to-an-object-found","errorCode":null,"errorMessage":"cyclic reference to an object found","messagePattern":"cyclic reference to an object found","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/js/modules/k6/grpc/client.go","lineNumber":447,"sourceCode":"\n\t\treturn v, nil\n\t}\n\n\tobj := v.ToObject(runtime)\n\n\t// If it's not a real object (e.g., a string, bool, or regular number primitive),\n\t// after checking for special floats, we can just return it.\n\t// We use obj.ClassName() to distinguish real objects from primitive wrappers.\n\tclassName := obj.ClassName()\n\tif className != \"Object\" && className != \"Array\" {\n\t\treturn v, nil\n\t}\n\n\t// Similar to what's done when marshaling into JSON,\n\t// detect and prevent infinite recursion due to circular object references.\n\tfor _, vis := range stack {\n\t\tif obj.SameAs(vis) {\n\t\t\treturn nil, errors.New(\"cyclic reference to an object found\")\n\t\t}\n\t}\n\tstack = append(stack, obj)\n\n\t// It's a real object or array, so we need to deep-copy and normalize it.\n\tvar newObj *sobek.Object\n\tif className == \"Array\" {\n\t\tnewObj = runtime.NewArray()\n\t} else {\n\t\tnewObj = runtime.NewObject()\n\t}\n\n\tfor _, key := range obj.Keys() {\n\t\tval := obj.Get(key)\n\t\tnormalizedVal, err := normalizeNumberStrings(val, runtime, stack)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}","sourceCodeStart":429,"sourceCodeEnd":465,"githubUrl":"https://github.com/grafana/k6/blob/93accf6570dcd306ca5e99cc44c393ee3797761b/internal/js/modules/k6/grpc/client.go#L429-L465","documentation":"Before marshaling the request, k6 deep-copies and normalizes the sobek object graph (normalizeNumberStrings) to handle int64/uint64 values. While walking objects and arrays it keeps a stack of visited objects and rejects any node that appears again, because a cyclic graph cannot be serialized.","triggerScenarios":"client.invoke('/pkg.Svc/Method', a) where a.self = a; mutual references such as a.b = b and b.a = a (including via arrays); payloads assembled from data structures that carry parent/back pointers.","commonSituations":"Building nested request payloads from ORM-like or tree structures that store parent links; caching/interning objects that reference each other; reusing a response object (deserialized with back-references) as the next request.","solutions":["Construct the payload inline or via a builder that never sets back-references","Strip parent/back links before invoking: delete node.parent","For one-off sanitization, deep-clone with a replacer that skips already-seen objects (see validationCode)"],"exampleFix":"// before\nconst req = { id: 1 };\nreq.self = req; // cycle\nclient.invoke('/pkg.Svc/Method', req);\n\n// after\nconst req = { id: 1 };\nclient.invoke('/pkg.Svc/Method', req);","handlingStrategy":"validation","validationCode":"function assertAcyclic(value) {\n  const seen = new Set();\n  (function walk(v) {\n    if (v === null || typeof v !== 'object') return;\n    if (seen.has(v)) throw new Error('cyclic reference detected in gRPC request payload');\n    seen.add(v);\n    for (const k of Object.keys(v)) walk(v[k]);\n  })(value);\n  return value;\n}\n\nclient.invoke(method, assertAcyclic(payload));","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Build request payloads fresh and flat per invoke; never reuse graphs with back/parent pointers","Run assertAcyclic (or JSON.stringify in dev) on payloads assembled from cached structures","Strip bookkeeping fields (parent, _root, ctx) from ORM/tree data before sending"],"tags":["grpc","serialization","cyclic-reference","invoke"],"backgroundTag":null,"analyzedSha":"93accf6570dcd306ca5e99cc44c393ee3797761b","analyzedAt":"2026-08-15T21:23:27.118Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}