You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fory.apache.org by ch...@apache.org on 2026/10/10 02:32:52 UTC

(fory) branch main updated: feat(go): Enforce registration freeze after first root serialize/deserialize operation (#4161)

This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new d7ebfa787 feat(go): Enforce registration freeze after first root serialize/deserialize operation (#4161)
d7ebfa787 is described below

commit d7ebfa7878b188d9e88216137363399ab5afbe36
Author: Swatantra Yadav <11...@users.noreply.github.com>
AuthorDate: Sat Oct 10 08:02:47 2026 +0530

    feat(go): Enforce registration freeze after first root serialize/deserialize operation (#4161)
    
    ## Why?
    
    In Go, `Fory` previously allowed registering structs, enums, unions, and
    extensions after root serialization or deserialization had already
    begun. This violated Fory's registration lifecycle design invariant,
    invalidated precomputed type descriptors and resolver state, and
    introduced subtle race conditions in shared/concurrent usage.
    
    ## What does this PR do?
    
    Enforces permanent registration freeze on `Fory` instances upon the
    first root serialize or deserialize operation:
    
    1. **Export Sentinel Error**:
    - Defines `var ErrRegistrationFrozen = errors.New("fory: registration is
    frozen after serialization or deserialization has started")`.
    2. **State & Methods**:
    - Keeps `frozen bool` state in `TypeResolver`. `Fory.Freeze()` and
    `Fory.IsFrozen()` delegate to the resolver so both registration paths
    share the same permanent freeze.
    3. **Registration Guards**:
    - Guards all 8 public registration methods (`RegisterStruct`,
    `RegisterUnion`, `RegisterEnum`, `RegisterExtension`, and their `ByName`
    variants) in `Fory` and `TypeResolver` to reject post-freeze calls
    immediately before any reflection or map mutation occurs.
    4. **Root Operation Hooks**:
    - Automatically triggers `Freeze()` at the entry points of all root
    serialization and deserialization APIs (`Serialize`, `SerializeTo`,
    `Marshal`, `SerializeWithCallback`, `Serialize[T]`, `Deserialize`,
    `DeserializeFrom`, `Unmarshal`, `DeserializeWithCallbackBuffers`,
    `Deserialize[T]`, `DeserializeFromStream`, and `DeserializeFromReader`).
    5. **ThreadSafe Wrapper**:
    - Standardizes `threadsafe.Fory` to return `fory.ErrRegistrationFrozen`.
    6. **Tests**:
    - Adds comprehensive unit tests (`TestRegistrationFreeze`,
    `TestIssue4118Reproducer`, and
    `TestIssue4118FailedRootOperationFreezes`) verifying:
         - Pre-operation registrations succeed.
    - All 8 registration methods return `ErrRegistrationFrozen` after root
    serialize or deserialize operations.
    - Failed serialize/deserialize attempts still permanently freeze
    registration.
    - Rejected late registrations leave internal resolver maps and type
    tables completely unmutated.
         - Generic and streaming APIs properly trigger freeze.
    
    ## Related issues
    
    Closes #4118
    
    ## AI Contribution Checklist
    
    - [x] Substantial AI assistance was used in this PR: `no`
    
    ## Does this PR introduce any user-facing change?
    
    - [x] Does this PR introduce any public API change? Exports
    `ErrRegistrationFrozen` and methods `Freeze()`, `IsFrozen()`.
    - [ ] Does this PR introduce any binary protocol compatibility change?
    No.
    
    ## Benchmark
    
    All existing Go tests and benchmarks run cleanly without regressions.
    
    ---------
    
    Signed-off-by: Swatantra Yadav <ma...@gmail.com>
    Co-authored-by: chaokunyang <sh...@gmail.com>
---
 go/fory/fory.go            |  49 ++++++++
 go/fory/fory_test.go       | 270 +++++++++++++++++++++++++++++++++++++++++----
 go/fory/fory_typed_test.go |  11 +-
 go/fory/stream.go          |   2 +
 go/fory/threadsafe/fory.go |   2 +-
 go/fory/type_resolver.go   |  40 +++++++
 6 files changed, 347 insertions(+), 27 deletions(-)

diff --git a/go/fory/fory.go b/go/fory/fory.go
index c8975721b..bc5157ecd 100644
--- a/go/fory/fory.go
+++ b/go/fory/fory.go
@@ -33,6 +33,10 @@ import (
 // ErrNoSerializer indicates no serializer is registered for a type
 var ErrNoSerializer = errors.New("fory: no serializer registered for type")
 
+// ErrRegistrationFrozen indicates an attempt to register types or serializers
+// after serialization or deserialization has already started.
+var ErrRegistrationFrozen = errors.New("fory: registration is frozen after serialization or deserialization has started")
+
 // Public named registration accepts one dotted name; resolver primitives receive
 // the split wire metadata components because named TypeDefs store them separately.
 func splitRegisteredName(name string) (string, string, error) {
@@ -271,6 +275,17 @@ func (f *Fory) MetaContext() *MetaContext {
 	return f.metaContext
 }
 
+// Freeze permanently freezes registration on this Fory instance.
+// Root serialization and deserialization operations freeze registration automatically.
+func (f *Fory) Freeze() {
+	f.typeResolver.Freeze()
+}
+
+// IsFrozen reports whether registration on this Fory instance is frozen.
+func (f *Fory) IsFrozen() bool {
+	return f.typeResolver.IsFrozen()
+}
+
 // GetTypeResolver returns the type resolver for this Fory instance
 func (f *Fory) GetTypeResolver() *TypeResolver {
 	return f.typeResolver
@@ -302,6 +317,9 @@ func validateUserTypeID(typeID uint32) error {
 //
 //go:noinline
 func (f *Fory) RegisterStruct(type_ any, typeID uint32) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	if err := validateUserTypeID(typeID); err != nil {
 		return err
 	}
@@ -335,6 +353,9 @@ func (f *Fory) RegisterStruct(type_ any, typeID uint32) error {
 //
 //go:noinline
 func (f *Fory) RegisterUnion(type_ any, typeID uint32, serializer Serializer) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	if serializer == nil {
 		return fmt.Errorf("RegisterUnion requires a non-nil serializer")
 	}
@@ -363,6 +384,9 @@ func (f *Fory) RegisterUnion(type_ any, typeID uint32, serializer Serializer) er
 //
 //go:noinline
 func (f *Fory) RegisterUnionByName(type_ any, name string, serializer Serializer) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	if serializer == nil {
 		return fmt.Errorf("RegisterUnionByName requires a non-nil serializer")
 	}
@@ -392,6 +416,9 @@ func (f *Fory) RegisterUnionByName(type_ any, name string, serializer Serializer
 //
 //go:noinline
 func (f *Fory) RegisterStructByName(type_ any, name string) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	var t reflect.Type
 	if rt, ok := type_.(reflect.Type); ok {
 		t = rt
@@ -419,6 +446,9 @@ func (f *Fory) RegisterStructByName(type_ any, name string) error {
 //
 //go:noinline
 func (f *Fory) RegisterEnum(type_ any, typeID uint32) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	if err := validateUserTypeID(typeID); err != nil {
 		return err
 	}
@@ -451,6 +481,9 @@ func (f *Fory) RegisterEnum(type_ any, typeID uint32) error {
 //
 //go:noinline
 func (f *Fory) RegisterEnumByName(type_ any, name string) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	var t reflect.Type
 	if rt, ok := type_.(reflect.Type); ok {
 		t = rt
@@ -483,6 +516,9 @@ func (f *Fory) RegisterEnumByName(type_ any, name string) error {
 //
 //go:noinline
 func (f *Fory) RegisterExtension(type_ any, typeID uint32, serializer ExtensionSerializer) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	if err := validateUserTypeID(typeID); err != nil {
 		return err
 	}
@@ -522,6 +558,9 @@ func (f *Fory) RegisterExtension(type_ any, typeID uint32, serializer ExtensionS
 //
 //go:noinline
 func (f *Fory) RegisterExtensionByName(type_ any, name string, serializer ExtensionSerializer) error {
+	if f.IsFrozen() {
+		return ErrRegistrationFrozen
+	}
 	var t reflect.Type
 	if rt, ok := type_.(reflect.Type); ok {
 		t = rt
@@ -563,6 +602,7 @@ func (f *Fory) Reset() {
 //
 // For thread-safe usage, use threadsafe.Fory which copies the data internally.
 func (f *Fory) Serialize(value any) ([]byte, error) {
+	f.Freeze()
 	defer f.resetWriteState()
 	if !validateRootDecimal(f.writeCtx.Err(), value) {
 		return nil, f.writeCtx.TakeError()
@@ -598,6 +638,7 @@ func (f *Fory) rootRefMode() RefMode {
 // Deserialize deserializes data directly into the provided target value.
 // The target must be a pointer to the value to deserialize into.
 func (f *Fory) Deserialize(data []byte, v any) error {
+	f.Freeze()
 	defer f.resetReadState()
 	f.readCtx.SetData(data)
 	target := reflect.ValueOf(v).Elem()
@@ -637,6 +678,7 @@ func (f *Fory) resetWriteState() {
 // This is useful when you need to write multiple serialized values to the same buffer.
 // Returns error if serialization fails.
 func (f *Fory) SerializeTo(buf *ByteBuffer, value any) error {
+	f.Freeze()
 	defer f.resetWriteState()
 	if !validateRootDecimal(f.writeCtx.Err(), value) {
 		return f.writeCtx.TakeError()
@@ -690,6 +732,7 @@ func (f *Fory) SerializeTo(buf *ByteBuffer, value any) error {
 // The buffer's reader index is advanced as data is read.
 // This is useful when reading multiple serialized values from the same buffer.
 func (f *Fory) DeserializeFrom(buf *ByteBuffer, v any) error {
+	f.Freeze()
 	// Reset contexts for each independent serialized object
 	defer f.resetReadState()
 
@@ -730,11 +773,13 @@ func (f *Fory) DeserializeFrom(buf *ByteBuffer, v any) error {
 //
 // For thread-safe usage, use threadsafe.Fory which copies the data internally.
 func (f *Fory) Marshal(v any) ([]byte, error) {
+	f.Freeze()
 	return f.Serialize(v)
 }
 
 // Unmarshal deserializes bytes into the provided value.
 func (f *Fory) Unmarshal(data []byte, v any) error {
+	f.Freeze()
 	return f.Deserialize(data, v)
 }
 
@@ -743,6 +788,7 @@ func (f *Fory) Unmarshal(data []byte, v any) error {
 // If callback is provided, it will be called for each BufferObject during serialization.
 // Return true from callback to write in-band, false for out-of-band.
 func (f *Fory) SerializeWithCallback(buffer *ByteBuffer, v any, callback func(BufferObject) bool) error {
+	f.Freeze()
 	buf := f.writeCtx.buffer
 	defer func() {
 		// Reset internal state but NOT the buffer - caller manages buffer state
@@ -786,6 +832,7 @@ func (f *Fory) SerializeWithCallback(buffer *ByteBuffer, v any, callback func(Bu
 // DeserializeWithCallbackBuffers deserializes from buffer into the provided value (for streaming/cross-language use).
 // The third parameter is optional external buffers for out-of-band data (can be nil).
 func (f *Fory) DeserializeWithCallbackBuffers(buffer *ByteBuffer, v any, buffers []*ByteBuffer) error {
+	f.Freeze()
 	// Use the caller buffer only for this root; later stream roots reuse the
 	// original internal buffer.
 	origBuffer := f.readCtx.buffer
@@ -919,6 +966,7 @@ func readHeaderSlow(ctx *ReadContext, bitmap byte) {
 //
 // For thread-safe usage, use threadsafe.Serialize which copies the data internally.
 func Serialize[T any](f *Fory, value T) ([]byte, error) {
+	f.Freeze()
 	defer f.resetWriteState()
 	v := any(value)
 	if !validateRootDecimal(f.writeCtx.Err(), v) {
@@ -1075,6 +1123,7 @@ func Serialize[T any](f *Fory, value T) ([]byte, error) {
 // For structs, it reads directly into the struct fields.
 // Note: Fory instance is NOT thread-safe. Use ThreadSafeFory for concurrent use.
 func Deserialize[T any](f *Fory, data []byte, target *T) error {
+	f.Freeze()
 	// Generic roots share the same reusable read and metadata owners as the
 	// method API, so both entry and every exit must start from a root-clean state.
 	f.resetReadState()
diff --git a/go/fory/fory_test.go b/go/fory/fory_test.go
index 950ec5c1a..4556fe694 100644
--- a/go/fory/fory_test.go
+++ b/go/fory/fory_test.go
@@ -274,17 +274,17 @@ func TestSerializeStructSimple(t *testing.T) {
 		type A struct {
 			F1 []string
 		}
+		type SimpleB struct {
+			F1 []string
+			F2 map[string]int32
+		}
 		require.Nil(t, fory.RegisterStructByName(A{}, "example.A"))
+		require.Nil(t, fory.RegisterStructByName(SimpleB{}, "example.SimpleB"))
 		serde(t, fory, A{})
 		serde(t, fory, &A{})
 		serde(t, fory, A{F1: []string{"str1", "", "str2"}})
 		serde(t, fory, &A{F1: []string{"str1", "", "str2"}})
 
-		type SimpleB struct {
-			F1 []string
-			F2 map[string]int32
-		}
-		require.Nil(t, fory.RegisterStructByName(SimpleB{}, "example.SimpleB"))
 		serde(t, fory, SimpleB{})
 		serde(t, fory, SimpleB{
 			F1: []string{"str1", "", "str2"},
@@ -410,24 +410,24 @@ func newFoo() Foo {
 func TestSerializeStruct(t *testing.T) {
 	for _, referenceTracking := range []bool{false, true} {
 		fory := NewFory(WithXlang(true), WithCompatible(false), WithRefTracking(referenceTracking))
+		type A struct {
+			F1 Bar
+			F2 any
+		}
 		require.Nil(t, fory.RegisterStructByName(Bar{}, "example.Bar"))
+		require.Nil(t, fory.RegisterStructByName(A{}, "example.A"))
+		require.Nil(t, fory.RegisterStructByName(Foo{}, "example.Foo"))
 		serde(t, fory, &Bar{})
 		bar := Bar{F1: 1, F2: "str"}
 		serde(t, fory, bar)
 		serde(t, fory, &bar)
 
-		type A struct {
-			F1 Bar
-			F2 any
-		}
-		require.Nil(t, fory.RegisterStructByName(A{}, "example.A"))
 		serde(t, fory, A{})
 		serde(t, fory, &A{})
 		// Use int64 for any fields since xlang deserializes integers to int64
 		serde(t, fory, A{F1: Bar{F1: 1, F2: "str"}, F2: int64(-1)})
 		serde(t, fory, &A{F1: Bar{F1: 1, F2: "str"}, F2: int64(-1)})
 
-		require.Nil(t, fory.RegisterStructByName(Foo{}, "example.Foo"))
 		foo := newFoo()
 		serde(t, fory, foo)
 		serde(t, fory, &foo)
@@ -436,11 +436,17 @@ func TestSerializeStruct(t *testing.T) {
 
 func TestSerializeCircularReference(t *testing.T) {
 	fory := NewFory(WithXlang(true), WithCompatible(false), WithRefTracking(true))
+	type A struct {
+		A1 *A
+	}
+	type CircularRefB struct {
+		F1 string
+		F2 *CircularRefB
+		F3 *CircularRefB
+	}
+	require.Nil(t, fory.RegisterStructByName(A{}, "example.A"))
+	require.Nil(t, fory.RegisterStructByName(CircularRefB{}, "example.CircularRefB"))
 	{
-		type A struct {
-			A1 *A
-		}
-		require.Nil(t, fory.RegisterStructByName(A{}, "example.A"))
 		// If use `A{}` instead of `&A{}` and pass `a` instead of `&a`, there will be serialization data duplication
 		// and can't be deserialized by other languages too.
 		// TODO(chaokunyang) If pass by value(have a copy) and there are some inner value reference, return a readable
@@ -455,12 +461,6 @@ func TestSerializeCircularReference(t *testing.T) {
 		require.Same(t, a1, a1.A1)
 	}
 	{
-		type CircularRefB struct {
-			F1 string
-			F2 *CircularRefB
-			F3 *CircularRefB
-		}
-		require.Nil(t, fory.RegisterStructByName(CircularRefB{}, "example.CircularRefB"))
 		b := &CircularRefB{F1: "str"}
 		b.F2 = b
 		b.F3 = b
@@ -910,3 +910,231 @@ func convertRecursively(newVal, tmplVal reflect.Value) (reflect.Value, error) {
 			fmt.Errorf("cannot convert %s to %s", newVal.Type(), tmplVal.Type())
 	}
 }
+
+type freezeDummyExtSerializer struct{}
+
+func (freezeDummyExtSerializer) WriteData(ctx *WriteContext, value reflect.Value) {}
+func (freezeDummyExtSerializer) ReadData(ctx *ReadContext, value reflect.Value)   {}
+
+type freezeDummyUnionSerializer struct{}
+
+func (freezeDummyUnionSerializer) GetType() reflect.Type                            { return reflect.TypeOf(struct{}{}) }
+func (freezeDummyUnionSerializer) WriteData(ctx *WriteContext, value reflect.Value) {}
+func (freezeDummyUnionSerializer) Write(ctx *WriteContext, refMode RefMode, writeType bool, hasGenerics bool, value reflect.Value) {
+}
+func (freezeDummyUnionSerializer) ReadData(ctx *ReadContext, value reflect.Value) {}
+func (freezeDummyUnionSerializer) Read(ctx *ReadContext, refMode RefMode, readType bool, hasGenerics bool, value reflect.Value) {
+}
+func (freezeDummyUnionSerializer) ReadWithTypeInfo(ctx *ReadContext, refMode RefMode, typeInfo *TypeInfo, value reflect.Value) {
+}
+
+func assertAllRegistrationsFrozen(t *testing.T, f *Fory) {
+	t.Helper()
+	require.True(t, f.IsFrozen(), "Fory should be frozen")
+	require.True(t, f.GetTypeResolver().IsFrozen(), "TypeResolver should be frozen")
+
+	type lateType struct{ Val int32 }
+	type lateEnum int32
+
+	err := f.RegisterStruct(lateType{}, 9901)
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterStructByName(lateType{}, "test.LateStruct")
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterEnum(lateEnum(1), 9902)
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterEnumByName(lateEnum(1), "test.LateEnum")
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterUnion(lateType{}, 9903, freezeDummyUnionSerializer{})
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterUnionByName(lateType{}, "test.LateUnion", freezeDummyUnionSerializer{})
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterExtension(lateType{}, 9904, freezeDummyExtSerializer{})
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	err = f.RegisterExtensionByName(lateType{}, "test.LateExt", freezeDummyExtSerializer{})
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+	tr := f.GetTypeResolver()
+	require.ErrorIs(t, tr.RegisterStruct(reflect.TypeOf(lateType{}), STRUCT, 9905), ErrRegistrationFrozen)
+	require.ErrorIs(t, tr.RegisterEnum(reflect.TypeOf(lateEnum(1)), 9906), ErrRegistrationFrozen)
+	require.ErrorIs(t, tr.RegisterUnion(reflect.TypeOf(lateType{}), 9907, freezeDummyUnionSerializer{}), ErrRegistrationFrozen)
+	require.ErrorIs(t, tr.RegisterExtension(reflect.TypeOf(lateType{}), 9908, freezeDummyExtSerializer{}), ErrRegistrationFrozen)
+}
+
+func TestRegistrationFreeze(t *testing.T) {
+	type PreStruct struct{ Value int32 }
+	type LateStruct struct{ Name string }
+
+	t.Run("PreOperationRegistrationAllowed", func(t *testing.T) {
+		f := New()
+		require.False(t, f.IsFrozen())
+		require.False(t, f.GetTypeResolver().IsFrozen())
+		type OtherStruct struct{ Name string }
+		require.NoError(t, f.RegisterStruct(PreStruct{}, 100))
+		require.NoError(t, f.RegisterStructByName(OtherStruct{}, "test.OtherStruct"))
+	})
+
+	t.Run("FreezeAfterSerialize", func(t *testing.T) {
+		f := New()
+		require.NoError(t, f.RegisterStruct(PreStruct{}, 100))
+		_, err := f.Serialize(&PreStruct{Value: 42})
+		require.NoError(t, err)
+		assertAllRegistrationsFrozen(t, f)
+	})
+
+	t.Run("FreezeAfterFailedSerialize", func(t *testing.T) {
+		f := New()
+		require.NoError(t, f.RegisterStruct(PreStruct{}, 100))
+		// Passing struct value directly is disallowed and returns an error
+		_, err := f.Serialize(PreStruct{Value: 42})
+		require.Error(t, err)
+		assertAllRegistrationsFrozen(t, f)
+	})
+
+	t.Run("FreezeAfterDeserialize", func(t *testing.T) {
+		f := New()
+		require.NoError(t, f.RegisterStruct(PreStruct{}, 100))
+		bytes, err := f.Serialize(&PreStruct{Value: 42})
+		require.NoError(t, err)
+
+		reader := New()
+		require.NoError(t, reader.RegisterStruct(PreStruct{}, 100))
+		var target PreStruct
+		require.NoError(t, reader.Deserialize(bytes, &target))
+		assertAllRegistrationsFrozen(t, reader)
+	})
+
+	t.Run("FreezeAfterFailedDeserialize", func(t *testing.T) {
+		reader := New()
+		var target PreStruct
+		// Corrupt data fails deserialization
+		err := reader.Deserialize([]byte{0xff, 0xff}, &target)
+		require.Error(t, err)
+		assertAllRegistrationsFrozen(t, reader)
+	})
+
+	t.Run("FreezeAfterGenericSerializeAndDeserialize", func(t *testing.T) {
+		writer := New()
+		val := int32(123)
+		bytes, err := Serialize(writer, val)
+		require.NoError(t, err)
+		assertAllRegistrationsFrozen(t, writer)
+
+		reader := New()
+		var result int32
+		require.NoError(t, Deserialize(reader, bytes, &result))
+		assertAllRegistrationsFrozen(t, reader)
+	})
+
+	t.Run("FreezeAfterSerializeToAndDeserializeFrom", func(t *testing.T) {
+		writer := New()
+		buf := NewByteBuffer(nil)
+		val := int32(456)
+		require.NoError(t, writer.SerializeTo(buf, val))
+		assertAllRegistrationsFrozen(t, writer)
+
+		reader := New()
+		var target int32
+		require.NoError(t, reader.DeserializeFrom(buf, &target))
+		assertAllRegistrationsFrozen(t, reader)
+	})
+
+	t.Run("FreezeAfterStreamDeserialize", func(t *testing.T) {
+		data, err := New().Serialize(int32(789))
+		require.NoError(t, err)
+
+		f1 := New()
+		is := NewInputStream(bytes.NewReader(data))
+		var val1 int32
+		require.NoError(t, f1.DeserializeFromStream(is, &val1))
+		assertAllRegistrationsFrozen(t, f1)
+
+		f2 := New()
+		var val2 int32
+		require.NoError(t, f2.DeserializeFromReader(bytes.NewReader(data), &val2))
+		assertAllRegistrationsFrozen(t, f2)
+	})
+
+	t.Run("ExplicitFreezeAndResetDoesNotUnfreeze", func(t *testing.T) {
+		f := New()
+		require.False(t, f.IsFrozen())
+		f.Freeze()
+		require.True(t, f.IsFrozen())
+		assertAllRegistrationsFrozen(t, f)
+
+		// Reset should clear serialization state but keep registration permanently frozen
+		f.Reset()
+		require.True(t, f.IsFrozen())
+		assertAllRegistrationsFrozen(t, f)
+	})
+
+	t.Run("ResolverStateImmutabilityOnRejectedRegistration", func(t *testing.T) {
+		f := New()
+		_, err := f.Serialize(int32(1))
+		require.NoError(t, err)
+
+		tr := f.GetTypeResolver()
+		lateID := uint32(9999)
+		err = f.RegisterStruct(LateStruct{}, lateID)
+		require.ErrorIs(t, err, ErrRegistrationFrozen)
+
+		// Confirm internal maps and caches did not receive late registrations
+		_, hasID := tr.userTypeIdToTypeInfo[lateID]
+		require.False(t, hasID, "rejected userTypeId must not be present in userTypeIdToTypeInfo")
+
+		_, hasType := tr.typesInfo[reflect.TypeOf(LateStruct{})]
+		require.False(t, hasType, "rejected type must not be present in typesInfo")
+
+		_, hasPtrType := tr.typesInfo[reflect.TypeOf(&LateStruct{})]
+		require.False(t, hasPtrType, "rejected ptr type must not be present in typesInfo")
+	})
+}
+
+func TestIssue4118Reproducer(t *testing.T) {
+	type Item struct {
+		Count int32
+	}
+	type Late struct {
+		Name string
+	}
+
+	f := New()
+
+	require.NoError(t, f.RegisterStruct(Item{}, 100))
+
+	// First root operation.
+	item := Item{Count: 1}
+	_, err := f.Serialize(&item)
+	require.NoError(t, err)
+
+	// Registration after the first root operation must fail.
+	err = f.RegisterStruct(Late{}, 101)
+	require.Error(t, err)
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+}
+
+func TestIssue4118FailedRootOperationFreezes(t *testing.T) {
+	type Item struct {
+		Count int32
+	}
+	type Late struct {
+		Name string
+	}
+
+	f := New()
+
+	// Direct struct serialization without pointer fails.
+	_, err := f.Serialize(Item{Count: 1})
+	require.Error(t, err)
+
+	// Registration after the failed root operation must still fail.
+	err = f.RegisterStruct(Late{}, 101)
+	require.Error(t, err)
+	require.ErrorIs(t, err, ErrRegistrationFrozen)
+}
diff --git a/go/fory/fory_typed_test.go b/go/fory/fory_typed_test.go
index 3a7da012a..5e8764f38 100644
--- a/go/fory/fory_typed_test.go
+++ b/go/fory/fory_typed_test.go
@@ -173,9 +173,8 @@ func TestDeserializeByteSliceAcceptsUint8ArrayRootType(t *testing.T) {
 // TestSerializeGenericComplex tests Serialize[T]/DeserializeWithCallbackBuffers[T] with complex types.
 // Struct wrappers must be registered explicitly before fast serializer use.
 func TestSerializeGenericComplex(t *testing.T) {
-	f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
-
 	t.Run("Struct", func(t *testing.T) {
+		f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
 		type TestStruct struct {
 			Name  string
 			Value int32
@@ -195,6 +194,7 @@ func TestSerializeGenericComplex(t *testing.T) {
 	})
 
 	t.Run("Slice", func(t *testing.T) {
+		f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
 		// Note: *[]T is not supported, use wrapper struct instead
 		type SliceWrapper struct {
 			Items []int32
@@ -211,6 +211,7 @@ func TestSerializeGenericComplex(t *testing.T) {
 	})
 
 	t.Run("Map", func(t *testing.T) {
+		f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
 		// Note: *map[K]V is not supported, use wrapper struct instead
 		type MapWrapper struct {
 			Items map[string]int32
@@ -229,10 +230,9 @@ func TestSerializeGenericComplex(t *testing.T) {
 
 // TestSerializeDeserializeRoundTrip tests that serialized data can be correctly deserialized.
 func TestSerializeDeserializeRoundTrip(t *testing.T) {
-	f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
-
 	// Test that SerializeWithCallback[T] uses pointer-based fast path when available
 	t.Run("TypedSerializerPath", func(t *testing.T) {
+		f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
 		// Int32 has a registered fast path
 		original := int32(999)
 		data, err := Serialize(f, &original)
@@ -246,12 +246,13 @@ func TestSerializeDeserializeRoundTrip(t *testing.T) {
 	})
 
 	t.Run("FastSerializerFallbackPath", func(t *testing.T) {
+		f := NewFory(WithXlang(false), WithRefTracking(true), WithCompatible(false))
 		// Custom struct uses the fast serializer fallback path.
 		type CustomStruct struct {
 			ID   int64
 			Name string
 		}
-		f.RegisterStructByName(CustomStruct{}, "test.CustomStruct")
+		require.NoError(t, f.RegisterStructByName(CustomStruct{}, "test.CustomStruct"))
 
 		original := CustomStruct{ID: 123, Name: "test"}
 		data, err := Serialize(f, &original)
diff --git a/go/fory/stream.go b/go/fory/stream.go
index 018f79746..64852fd39 100644
--- a/go/fory/stream.go
+++ b/go/fory/stream.go
@@ -96,6 +96,7 @@ func (is *InputStream) Shrink() {
 // DeserializeFromStream reads the next object from the stream into the provided value.
 // It preserves the stream buffer while clearing root-scoped read metadata between calls.
 func (f *Fory) DeserializeFromStream(is *InputStream, v any) error {
+	f.Freeze()
 	origBuffer := f.readCtx.buffer
 	f.readCtx.buffer = is.buffer
 	target := reflect.ValueOf(v).Elem()
@@ -124,6 +125,7 @@ func (f *Fory) DeserializeFromStream(is *InputStream, v any) error {
 // each call, discarding any prefetched data and type metadata.
 // For sequential multi-object reads on the same stream, use NewInputStream instead.
 func (f *Fory) DeserializeFromReader(r io.Reader, v any) error {
+	f.Freeze()
 	defer f.resetReadState()
 	// Always reset to enforce stateless semantics.
 	f.readCtx.buffer.ResetWithReader(r, 0)
diff --git a/go/fory/threadsafe/fory.go b/go/fory/threadsafe/fory.go
index 8593d4b1d..7bc739439 100644
--- a/go/fory/threadsafe/fory.go
+++ b/go/fory/threadsafe/fory.go
@@ -128,7 +128,7 @@ func (f *Fory) registerCallback(registration func(*fory.Fory) error) error {
 	f.registrationMu.Lock()
 	defer f.registrationMu.Unlock()
 	if f.started.Load() {
-		return fmt.Errorf("types must be registered before the first serialization or deserialization")
+		return fory.ErrRegistrationFrozen
 	}
 	// Registration cannot accumulate on arbitrary pool.Get results: even
 	// serialized callers can borrow different entries, and GC can discard them.
diff --git a/go/fory/type_resolver.go b/go/fory/type_resolver.go
index e3396b215..3a7fed41e 100644
--- a/go/fory/type_resolver.go
+++ b/go/fory/type_resolver.go
@@ -157,6 +157,9 @@ type TypeResolver struct {
 	isXlang             bool
 	metaStringResolver  *MetaStringResolver
 	requireRegistration bool
+	// Keep the permanent freeze here so Fory and direct resolver registration
+	// share one state, independent of root-operation resets.
+	frozen bool
 
 	// String mappings
 	metaStrToStr     map[string]string
@@ -276,6 +279,16 @@ func (r *TypeResolver) IsXlang() bool {
 	return r.isXlang
 }
 
+// Freeze permanently freezes registration on this TypeResolver.
+func (r *TypeResolver) Freeze() {
+	r.frozen = true
+}
+
+// IsFrozen reports whether registration on this TypeResolver is frozen.
+func (r *TypeResolver) IsFrozen() bool {
+	return r.frozen
+}
+
 func (r *TypeResolver) getTypeInfoByType(type_ reflect.Type) *TypeInfo {
 	if type_ == nil {
 		return nil
@@ -444,6 +457,9 @@ func (r *TypeResolver) initialize() {
 }
 
 func (r *TypeResolver) registerSerializer(type_ reflect.Type, typeId TypeId, s Serializer) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if prev, ok := r.typeToSerializers[type_]; ok {
 		return fmt.Errorf("type %s already has a serializer %s registered", type_, prev)
 	}
@@ -496,6 +512,9 @@ func validateOptionalFields(type_ reflect.Type) error {
 
 // RegisterStruct registers a type with a numeric user type ID for cross-language serialization.
 func (r *TypeResolver) RegisterStruct(type_ reflect.Type, typeID TypeId, userTypeID uint32) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	// Check if already registered
 	if info, ok := r.userTypeIdToTypeInfo[userTypeID]; ok {
 		if info.Type == type_ {
@@ -556,6 +575,9 @@ func (r *TypeResolver) RegisterStruct(type_ reflect.Type, typeID TypeId, userTyp
 
 // RegisterUnion registers a union type with a numeric user type ID for cross-language serialization.
 func (r *TypeResolver) RegisterUnion(type_ reflect.Type, userTypeID uint32, serializer Serializer) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if serializer == nil {
 		return fmt.Errorf("RegisterUnion requires a non-nil serializer")
 	}
@@ -591,6 +613,9 @@ func (r *TypeResolver) RegisterUnion(type_ reflect.Type, userTypeID uint32, seri
 
 // RegisterEnum registers an enum type (numeric type in Go) with a user type ID.
 func (r *TypeResolver) RegisterEnum(type_ reflect.Type, userTypeID uint32) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	// Check if already registered
 	if info, ok := r.userTypeIdToTypeInfo[userTypeID]; ok {
 		return fmt.Errorf("type %s with id %d has been registered", info.Type, userTypeID)
@@ -630,6 +655,9 @@ func (r *TypeResolver) RegisterEnum(type_ reflect.Type, userTypeID uint32) error
 }
 
 func (r *TypeResolver) registerEnumByName(type_ reflect.Type, namespace, typeName string) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	// Check if already registered
 	if prev, ok := r.typeToSerializers[type_]; ok {
 		return fmt.Errorf("type %s already has a serializer %s registered", type_, prev)
@@ -668,6 +696,9 @@ func (r *TypeResolver) registerEnumByName(type_ reflect.Type, namespace, typeNam
 }
 
 func (r *TypeResolver) registerStructByName(type_ reflect.Type, namespace, typeName string) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if prev, ok := r.typeToSerializers[type_]; ok {
 		return fmt.Errorf("type %s already has a serializer %s registered", type_, prev)
 	}
@@ -710,6 +741,9 @@ func (r *TypeResolver) registerUnionByName(
 	typeName string,
 	serializer Serializer,
 ) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if serializer == nil {
 		return fmt.Errorf("RegisterUnionByName requires a non-nil serializer")
 	}
@@ -749,6 +783,9 @@ func (r *TypeResolver) registerExtensionByName(
 	typeName string,
 	userSerializer ExtensionSerializer,
 ) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if userSerializer == nil {
 		return fmt.Errorf("serializer cannot be nil for extension type %s", type_)
 	}
@@ -791,6 +828,9 @@ func (r *TypeResolver) RegisterExtension(
 	userTypeID uint32,
 	userSerializer ExtensionSerializer,
 ) error {
+	if r.frozen {
+		return ErrRegistrationFrozen
+	}
 	if userTypeID > maxUserTypeID {
 		return fmt.Errorf("typeID must be in range [0, 0xfffffffe], got %d", userTypeID)
 	}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org