From f3903dd34b4d00535442d97f63154b33a0acd1d3 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 27 May 2026 09:17:00 +0200 Subject: [PATCH 01/25] [bgen] Propagate nullability information for generic type arguments. Fixes #16860. When a property has a generic type like Action, the C# compiler emits a [NullableAttribute(byte[])] where each byte encodes the nullability for the outer type and each generic argument in depth-first order. Previously, bgen only looked at the first byte (the outer type) and ignored nullability for generic type arguments. This change: - Adds GetNullabilityBytes() to AttributeManager to extract the full byte array - Adds a FormatType overload in TypeManager that processes the byte array to annotate each generic type argument with ? where appropriate - Uses the new method when rendering property type declarations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/AttributeManager.cs | 32 +++++ src/bgen/Generator.cs | 6 +- src/bgen/TypeManager.cs | 122 +++++++++++++++++++ tests/bgen/BGenTests.cs | 20 +++ tests/bgen/tests/generic-type-nullability.cs | 26 ++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/bgen/tests/generic-type-nullability.cs diff --git a/src/bgen/AttributeManager.cs b/src/bgen/AttributeManager.cs index 3ceccdd0c76e..f980afca2182 100644 --- a/src/bgen/AttributeManager.cs +++ b/src/bgen/AttributeManager.cs @@ -689,4 +689,36 @@ public bool IsNullable (ICustomAttributeProvider? provider) return false; } + + // Returns the full nullability byte array from a [NullableAttribute], or null if not present. + // The byte array encodes nullability for the type and all its generic type arguments in + // depth-first traversal order: + // 0 = oblivious, 1 = not nullable, 2 = nullable + // For example, Action would have bytes [1, 2, 2]. + public byte []? GetNullabilityBytes (ICustomAttributeProvider? provider) + { + var attributes = GetAttributes (provider); + if (attributes is null) + return null; + + foreach (var attrib in attributes) { + var attribType = attrib.GetAttributeType (); + if (attribType.Name == "NullableAttribute") { + if (attrib.ConstructorArguments.Count == 1) { + var argType = attrib.ConstructorArguments [0].ArgumentType; + if (argType.Namespace == "System" && argType.Name == "Byte") + return new [] { (byte) attrib.ConstructorArguments [0].Value! }; + if (argType.IsArray && argType.GetElementType ()?.Namespace == "System" && argType.GetElementType ()?.Name == "Byte") { + var valueCollection = (ReadOnlyCollection) attrib.ConstructorArguments [0].Value!; + var result = new byte [valueCollection.Count]; + for (int i = 0; i < valueCollection.Count; i++) + result [i] = (byte) valueCollection [i].Value!; + return result; + } + } + } + } + + return null; + } } diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index f5559c942d26..e3cbb5001604 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -4029,10 +4029,11 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields print_generated_code (); PrintPropertyAttributes (pi, minfo); PrintAttributes (pi, preserve: true, advice: true); + var wrapNullabilityBytes = AttributeManager.GetNullabilityBytes (pi); print ("{0} {1}{2}{3} {4} {{", mod, minfo.GetModifiers (), - TypeManager.FormatType (pi.DeclaringType, pi.PropertyType), + TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes), nullable ? "?" : String.Empty, pi.Name.GetSafeParamName ()); indent++; @@ -4115,7 +4116,8 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields // it remains nullable only if the BindAs type can be null (i.e. a reference type) nullable = !bindAsAttrib.Type.IsValueType && AttributeManager.IsNullable (pi); } else { - propertyTypeName = TypeManager.FormatType (minfo.type, pi.PropertyType); + var nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + propertyTypeName = TypeManager.FormatType (minfo.type, pi.PropertyType, nullabilityBytes); } print ("{0} {1}{2}{3} {4} {{", diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index 541d5d436944..88d0c6ea51c8 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -257,6 +257,43 @@ public string FormatType (Type? usedIn, Type? type) return FormatTypeUsedIn (usedIn?.Namespace, type); } + public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) + { + if (type is null) + throw new BindingException (1065, true); + if (nullabilityBytes is null || nullabilityBytes.Length <= 1) + return FormatTypeUsedIn (usedIn?.Namespace, type); + + // The byte array encodes nullability for the type tree in depth-first order. + // Byte 0 is for the outer type itself (which the caller handles separately via + // [NullAllowed] / IsNullable), so we skip it. The rest are for generic arguments. + var targs = type.GetGenericArguments (); + if (targs.Length == 0) + return FormatTypeUsedIn (usedIn?.Namespace, type); + + // Render the outer type name using the standard method (without nullability) + // and then render generic arguments with nullability from the byte array + int index = 1; // skip byte[0] (the outer type) + var formattedArgs = new string [targs.Length]; + for (int i = 0; i < targs.Length; i++) + formattedArgs [i] = FormatTypeUsedIn (usedIn?.Namespace, targs [i], nullabilityBytes, ref index); + + // Get the outer type name without generic args + var usedInNamespace = usedIn?.Namespace; + string tname; + var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) + tname = type.Name; + else + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + + return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">"; + } + public string FormatType (Type? usedIn, string? @namespace, string name) { string tname; @@ -344,6 +381,91 @@ public string FormatTypeUsedIn (string? usedInNamespace, Type? type) return tname; } + // Overload that consumes nullability bytes in depth-first order. + // The byte array from [NullableAttribute] encodes nullability for each type position: + // 0 = oblivious, 1 = not nullable, 2 = nullable + // The index tracks our position as we traverse the type tree depth-first. + string FormatTypeUsedIn (string? usedInNamespace, Type? type, byte [] nullabilityBytes, ref int index) + { + if (type is null) + throw new BindingException (1065, true); + + // Consume one byte for the current type + byte currentByte = index < nullabilityBytes.Length ? nullabilityBytes [index] : (byte) 0; + index++; + + // For value types, the byte is consumed but doesn't affect the output + bool isCurrentNullable = !type.IsValueType && currentByte == 2; + + // Simple types that don't have generic arguments + if (type == TypeCache.System_Void) + return "void"; + if (type == TypeCache.System_String) + return "string" + (isCurrentNullable ? "?" : ""); + + // Value types are never annotated with ? from the byte array + // (Nullable is handled separately via GetUnderlyingNullableType) + if (type == TypeCache.System_SByte) + return "sbyte"; + if (type == TypeCache.System_Int32) + return "int"; + if (type == TypeCache.System_Int16) + return "short"; + if (type == TypeCache.System_Int64) + return "long"; + if (type == TypeCache.System_Byte) + return "byte"; + if (type == TypeCache.System_UInt16) + return "ushort"; + if (type == TypeCache.System_UInt32) + return "uint"; + if (type == TypeCache.System_UInt64) + return "ulong"; + if (type == TypeCache.System_Float) + return "float"; + if (type == TypeCache.System_Double) + return "double"; + if (type == TypeCache.System_Boolean) + return "bool"; + if (type == TypeCache.System_nfloat) + return "nfloat"; + if (type == TypeCache.System_nint) + return "nint"; + if (type == TypeCache.System_nuint) + return "nuint"; + if (type == TypeCache.System_Char) + return "char"; + + if (type.IsArray) { + return FormatTypeUsedIn (usedInNamespace, type.GetElementType (), nullabilityBytes, ref index) + "[" + new string (',', type.GetArrayRank () - 1) + "]"; + } + + string tname; + var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) + tname = type.Name; + else + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + + var targs = type.GetGenericArguments (); + if (targs.Length > 0) { + var isNullableValueType = GetUnderlyingNullableType (type) is not null; + if (isNullableValueType) + return FormatTypeUsedIn (usedInNamespace, targs [0], nullabilityBytes, ref index) + "?"; + + var formattedArgs = new string [targs.Length]; + for (int i = 0; i < targs.Length; i++) + formattedArgs [i] = FormatTypeUsedIn (usedInNamespace, targs [i], nullabilityBytes, ref index); + return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">"; + } + + return tname + (isCurrentNullable ? "?" : ""); + } + public string RenderType (Type t, ICustomAttributeProvider? provider = null) { var nullable = string.Empty; diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index 37de37291334..f9edb7359fb9 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1576,6 +1576,26 @@ public void DelegatesWithNullableReturnType (Profile profile) Assert.That (delegateCallback.MethodReturnType.CustomAttributes.Any (v => v.AttributeType.Name == "NullableAttribute"), "Nullable return type"); } + [Test] + [TestCase (Profile.iOS)] + public void GenericTypeNullability (Profile profile) + { + Configuration.IgnoreIfIgnoredPlatform (profile.AsPlatform ()); + var bgen = BuildFile (profile, "generic-type-nullability.cs"); + bgen.AssertNoWarnings (); + + // Find the generated source file and check the property signatures + var generatedFile = Path.Combine (bgen.TmpDirectory!, "NS", "Widget.g.cs"); + Assert.That (File.Exists (generatedFile), Is.True, "Generated file exists"); + var contents = File.ReadAllText (generatedFile); + + // Verify nullable generic type arguments are properly annotated + Assert.That (contents, Does.Contain ("Action?"), "AuthenticateHandler should have nullable generic args"); + Assert.That (contents, Does.Contain ("Action?"), "CompletionHandler should have nullable generic args"); + // Non-nullable generic args should NOT have ? + Assert.That (contents, Does.Contain ("Action?"), "NonNullableHandler should NOT have nullable generic args"); + } + [Test] [TestCase (Profile.iOS)] public void DelegatesWithPointerTypes (Profile profile) diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs new file mode 100644 index 000000000000..f3c41702806e --- /dev/null +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -0,0 +1,26 @@ +using System; + +using Foundation; +using ObjCRuntime; +#if IOS +using UIKit; +#endif + +#nullable enable + +namespace NS { + [BaseType (typeof (NSObject))] + interface Widget { + [Export ("authenticateHandler")] + [NullAllowed] + Action AuthenticateHandler { get; set; } + + [Export ("completionHandler")] + [NullAllowed] + Action CompletionHandler { get; set; } + + [Export ("nonNullableHandler")] + [NullAllowed] + Action NonNullableHandler { get; set; } + } +} From 1a89643f457dd9552384bd99425acefac728b7be Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 27 May 2026 11:25:20 +0200 Subject: [PATCH 02/25] [bgen] Fix brace style, add complex test cases, fix value type byte consumption. - Use braces for all if/else/for blocks in new code. - Add complex test samples with many generic args, value types, and alternating nullability patterns. - Fix bug where value types incorrectly consumed nullability bytes. - Only apply nullability for void-returning delegates (Action<>) in the non-wrap path to avoid Func<> covariance issues with trampolines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/AttributeManager.cs | 9 +- src/bgen/Generator.cs | 14 +- src/bgen/TypeManager.cs | 183 +++++++++++++------ tests/bgen/BGenTests.cs | 24 ++- tests/bgen/tests/generic-type-nullability.cs | 38 ++++ 5 files changed, 203 insertions(+), 65 deletions(-) diff --git a/src/bgen/AttributeManager.cs b/src/bgen/AttributeManager.cs index f980afca2182..8da1edf54dae 100644 --- a/src/bgen/AttributeManager.cs +++ b/src/bgen/AttributeManager.cs @@ -698,21 +698,24 @@ public bool IsNullable (ICustomAttributeProvider? provider) public byte []? GetNullabilityBytes (ICustomAttributeProvider? provider) { var attributes = GetAttributes (provider); - if (attributes is null) + if (attributes is null) { return null; + } foreach (var attrib in attributes) { var attribType = attrib.GetAttributeType (); if (attribType.Name == "NullableAttribute") { if (attrib.ConstructorArguments.Count == 1) { var argType = attrib.ConstructorArguments [0].ArgumentType; - if (argType.Namespace == "System" && argType.Name == "Byte") + if (argType.Namespace == "System" && argType.Name == "Byte") { return new [] { (byte) attrib.ConstructorArguments [0].Value! }; + } if (argType.IsArray && argType.GetElementType ()?.Namespace == "System" && argType.GetElementType ()?.Name == "Byte") { var valueCollection = (ReadOnlyCollection) attrib.ConstructorArguments [0].Value!; var result = new byte [valueCollection.Count]; - for (int i = 0; i < valueCollection.Count; i++) + for (int i = 0; i < valueCollection.Count; i++) { result [i] = (byte) valueCollection [i].Value!; + } return result; } } diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index e3cbb5001604..9abeb82769c1 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -2935,8 +2935,9 @@ public void MakeSignatureFromParameterInfo (bool comma, StringBuilder sb, Member } else if (pi.Position == 0 && mi is MethodInfo minfo) { // only need to check for setter, since we wouldn't get here for a getter. var propertyInfo = GetProperty (minfo, getter: false, setter: true); - if (AttributeManager.IsNullable (propertyInfo)) + if (AttributeManager.IsNullable (propertyInfo)) { sb.Append ('?'); + } } } } @@ -4116,7 +4117,16 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields // it remains nullable only if the BindAs type can be null (i.e. a reference type) nullable = !bindAsAttrib.Type.IsValueType && AttributeManager.IsNullable (pi); } else { - var nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + // Only apply nullability bytes for delegate types whose generic type parameters + // are all contravariant (Action<> variants). Func<> types have a covariant TResult + // which creates a type mismatch with the trampoline's CreateNullableBlock signature. + byte []? nullabilityBytes = null; + if (pi.PropertyType.IsSubclassOf (TypeCache.System_Delegate)) { + var invokeMethod = pi.PropertyType.GetMethod ("Invoke"); + if (invokeMethod is not null && invokeMethod.ReturnType == TypeCache.System_Void) { + nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + } + } propertyTypeName = TypeManager.FormatType (minfo.type, pi.PropertyType, nullabilityBytes); } diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index 88d0c6ea51c8..0fbef8653c0b 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -259,37 +259,42 @@ public string FormatType (Type? usedIn, Type? type) public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) { - if (type is null) + if (type is null) { throw new BindingException (1065, true); - if (nullabilityBytes is null || nullabilityBytes.Length <= 1) + } + if (nullabilityBytes is null || nullabilityBytes.Length <= 1) { return FormatTypeUsedIn (usedIn?.Namespace, type); + } // The byte array encodes nullability for the type tree in depth-first order. // Byte 0 is for the outer type itself (which the caller handles separately via // [NullAllowed] / IsNullable), so we skip it. The rest are for generic arguments. var targs = type.GetGenericArguments (); - if (targs.Length == 0) + if (targs.Length == 0) { return FormatTypeUsedIn (usedIn?.Namespace, type); + } // Render the outer type name using the standard method (without nullability) // and then render generic arguments with nullability from the byte array int index = 1; // skip byte[0] (the outer type) var formattedArgs = new string [targs.Length]; - for (int i = 0; i < targs.Length; i++) + for (int i = 0; i < targs.Length; i++) { formattedArgs [i] = FormatTypeUsedIn (usedIn?.Namespace, targs [i], nullabilityBytes, ref index); + } // Get the outer type name without generic args var usedInNamespace = usedIn?.Namespace; string tname; var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; - if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; - else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || - string.IsNullOrEmpty (type.FullName)) + string.IsNullOrEmpty (type.FullName)) { tname = type.Name; - else + } else { tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">"; } @@ -384,83 +389,143 @@ public string FormatTypeUsedIn (string? usedInNamespace, Type? type) // Overload that consumes nullability bytes in depth-first order. // The byte array from [NullableAttribute] encodes nullability for each type position: // 0 = oblivious, 1 = not nullable, 2 = nullable + // Value types do NOT consume bytes from the array (their nullability is structural). // The index tracks our position as we traverse the type tree depth-first. string FormatTypeUsedIn (string? usedInNamespace, Type? type, byte [] nullabilityBytes, ref int index) { - if (type is null) + if (type is null) { throw new BindingException (1065, true); + } + + // Value types don't have bytes in the NullableAttribute array. + // Their nullability is determined structurally (by being Nullable). + if (type.IsValueType) { + if (type == TypeCache.System_SByte) { + return "sbyte"; + } + if (type == TypeCache.System_Int32) { + return "int"; + } + if (type == TypeCache.System_Int16) { + return "short"; + } + if (type == TypeCache.System_Int64) { + return "long"; + } + if (type == TypeCache.System_Byte) { + return "byte"; + } + if (type == TypeCache.System_UInt16) { + return "ushort"; + } + if (type == TypeCache.System_UInt32) { + return "uint"; + } + if (type == TypeCache.System_UInt64) { + return "ulong"; + } + if (type == TypeCache.System_Float) { + return "float"; + } + if (type == TypeCache.System_Double) { + return "double"; + } + if (type == TypeCache.System_Boolean) { + return "bool"; + } + if (type == TypeCache.System_nfloat) { + return "nfloat"; + } + if (type == TypeCache.System_nint) { + return "nint"; + } + if (type == TypeCache.System_nuint) { + return "nuint"; + } + if (type == TypeCache.System_Char) { + return "char"; + } + + // Generic value types (e.g. Nullable, KeyValuePair) + var vtargs = type.GetGenericArguments (); + if (vtargs.Length > 0) { + var isNullableValueType = GetUnderlyingNullableType (type) is not null; + if (isNullableValueType) { + return FormatTypeUsedIn (usedInNamespace, vtargs [0], nullabilityBytes, ref index) + "?"; + } - // Consume one byte for the current type + // Non-nullable generic value type: recurse into its args + var formattedVtArgs = new string [vtargs.Length]; + for (int i = 0; i < vtargs.Length; i++) { + formattedVtArgs [i] = FormatTypeUsedIn (usedInNamespace, vtargs [i], nullabilityBytes, ref index); + } + + string vtname; + var vtParentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + vtname = $"global::{type.Namespace}.{vtParentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + vtname = type.Name; + } else { + vtname = $"global::{type.Namespace}.{vtParentClass}{type.Name}"; + } + return vtname.RemoveArity () + "<" + string.Join (", ", formattedVtArgs) + ">"; + } + + // Non-generic value type: use the standard name + string vname; + var vParentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + vname = $"global::{type.Namespace}.{vParentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + vname = type.Name; + } else { + vname = $"global::{type.Namespace}.{vParentClass}{type.Name}"; + } + return vname; + } + + // Reference types consume one byte from the array byte currentByte = index < nullabilityBytes.Length ? nullabilityBytes [index] : (byte) 0; index++; - // For value types, the byte is consumed but doesn't affect the output - bool isCurrentNullable = !type.IsValueType && currentByte == 2; + bool isCurrentNullable = currentByte == 2; - // Simple types that don't have generic arguments - if (type == TypeCache.System_Void) + // Simple reference types + if (type == TypeCache.System_Void) { return "void"; - if (type == TypeCache.System_String) + } + if (type == TypeCache.System_String) { return "string" + (isCurrentNullable ? "?" : ""); - - // Value types are never annotated with ? from the byte array - // (Nullable is handled separately via GetUnderlyingNullableType) - if (type == TypeCache.System_SByte) - return "sbyte"; - if (type == TypeCache.System_Int32) - return "int"; - if (type == TypeCache.System_Int16) - return "short"; - if (type == TypeCache.System_Int64) - return "long"; - if (type == TypeCache.System_Byte) - return "byte"; - if (type == TypeCache.System_UInt16) - return "ushort"; - if (type == TypeCache.System_UInt32) - return "uint"; - if (type == TypeCache.System_UInt64) - return "ulong"; - if (type == TypeCache.System_Float) - return "float"; - if (type == TypeCache.System_Double) - return "double"; - if (type == TypeCache.System_Boolean) - return "bool"; - if (type == TypeCache.System_nfloat) - return "nfloat"; - if (type == TypeCache.System_nint) - return "nint"; - if (type == TypeCache.System_nuint) - return "nuint"; - if (type == TypeCache.System_Char) - return "char"; + } if (type.IsArray) { - return FormatTypeUsedIn (usedInNamespace, type.GetElementType (), nullabilityBytes, ref index) + "[" + new string (',', type.GetArrayRank () - 1) + "]"; + return FormatTypeUsedIn (usedInNamespace, type.GetElementType (), nullabilityBytes, ref index) + "[" + new string (',', type.GetArrayRank () - 1) + "]" + (isCurrentNullable ? "?" : ""); } string tname; var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; - if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; - else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || - string.IsNullOrEmpty (type.FullName)) + string.IsNullOrEmpty (type.FullName)) { tname = type.Name; - else + } else { tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } var targs = type.GetGenericArguments (); if (targs.Length > 0) { - var isNullableValueType = GetUnderlyingNullableType (type) is not null; - if (isNullableValueType) - return FormatTypeUsedIn (usedInNamespace, targs [0], nullabilityBytes, ref index) + "?"; - var formattedArgs = new string [targs.Length]; - for (int i = 0; i < targs.Length; i++) + for (int i = 0; i < targs.Length; i++) { formattedArgs [i] = FormatTypeUsedIn (usedInNamespace, targs [i], nullabilityBytes, ref index); - return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">"; + } + return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">" + (isCurrentNullable ? "?" : ""); } return tname + (isCurrentNullable ? "?" : ""); diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index f9edb7359fb9..fac0d8442816 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1589,11 +1589,33 @@ public void GenericTypeNullability (Profile profile) Assert.That (File.Exists (generatedFile), Is.True, "Generated file exists"); var contents = File.ReadAllText (generatedFile); - // Verify nullable generic type arguments are properly annotated + // Basic: two nullable generic args Assert.That (contents, Does.Contain ("Action?"), "AuthenticateHandler should have nullable generic args"); + // Three nullable generic args Assert.That (contents, Does.Contain ("Action?"), "CompletionHandler should have nullable generic args"); // Non-nullable generic args should NOT have ? Assert.That (contents, Does.Contain ("Action?"), "NonNullableHandler should NOT have nullable generic args"); + + // Value type between nullable reference types (int should never get ?) + Assert.That (contents, Does.Contain ("Action?"), "WithValueType should not annotate value types"); + + // Four nullable reference type args + Assert.That (contents, Does.Contain ("Action?"), "ManyNullableArgs should handle 4 nullable args"); + + // Mixed: first and last non-nullable, middle nullable + Assert.That (contents, Does.Contain ("Action?"), "MixedMiddleNullable should only annotate the middle arg"); + + // Multiple value types (int, bool should never get ?) + Assert.That (contents, Does.Contain ("Action?"), "MultipleValueTypes should not annotate any value types"); + + // Alternating nullable/non-nullable pattern + Assert.That (contents, Does.Contain ("Action?"), "AlternatingNullability should preserve alternating pattern"); + + // All non-nullable (5 reference type args, none should get ?) + Assert.That (contents, Does.Contain ("Action?"), "AllNonNullable should not annotate any args"); + + // Value type at the end + Assert.That (contents, Does.Contain ("Action?"), "ValueTypeAtEnd should not annotate trailing value type"); } [Test] diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs index f3c41702806e..c98bda13b833 100644 --- a/tests/bgen/tests/generic-type-nullability.cs +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -11,16 +11,54 @@ namespace NS { [BaseType (typeof (NSObject))] interface Widget { + // Basic: two nullable generic args [Export ("authenticateHandler")] [NullAllowed] Action AuthenticateHandler { get; set; } + // Three nullable generic args [Export ("completionHandler")] [NullAllowed] Action CompletionHandler { get; set; } + // Non-nullable generic args (should NOT get ?) [Export ("nonNullableHandler")] [NullAllowed] Action NonNullableHandler { get; set; } + + // Value type argument between nullable reference types + [Export ("withValueType")] + [NullAllowed] + Action WithValueType { get; set; } + + // Four nullable reference type args + [Export ("manyNullableArgs")] + [NullAllowed] + Action ManyNullableArgs { get; set; } + + // Mixed: first and last non-nullable, middle nullable + [Export ("mixedMiddleNullable")] + [NullAllowed] + Action MixedMiddleNullable { get; set; } + + // Multiple value types interleaved with nullable reference types + [Export ("multipleValueTypes")] + [NullAllowed] + Action MultipleValueTypes { get; set; } + + // Five args with alternating nullable/non-nullable + [Export ("alternatingNullability")] + [NullAllowed] + Action AlternatingNullability { get; set; } + + // All non-nullable reference types (5 args) + [Export ("allNonNullable")] + [NullAllowed] + Action AllNonNullable { get; set; } + + // Value type at the end + [Export ("valueTypeAtEnd")] + [NullAllowed] + Action ValueTypeAtEnd { get; set; } } } From bce4d137bd09aef7f488ab5845393796ddd4146c Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 27 May 2026 14:55:47 +0200 Subject: [PATCH 03/25] [bgen] Remove null-forgiving operator usage, use pattern matching instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/AttributeManager.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/bgen/AttributeManager.cs b/src/bgen/AttributeManager.cs index 8da1edf54dae..0aa3836a0405 100644 --- a/src/bgen/AttributeManager.cs +++ b/src/bgen/AttributeManager.cs @@ -708,15 +708,20 @@ public bool IsNullable (ICustomAttributeProvider? provider) if (attrib.ConstructorArguments.Count == 1) { var argType = attrib.ConstructorArguments [0].ArgumentType; if (argType.Namespace == "System" && argType.Name == "Byte") { - return new [] { (byte) attrib.ConstructorArguments [0].Value! }; + if (attrib.ConstructorArguments [0].Value is byte b) { + return new [] { b }; + } } if (argType.IsArray && argType.GetElementType ()?.Namespace == "System" && argType.GetElementType ()?.Name == "Byte") { - var valueCollection = (ReadOnlyCollection) attrib.ConstructorArguments [0].Value!; - var result = new byte [valueCollection.Count]; - for (int i = 0; i < valueCollection.Count; i++) { - result [i] = (byte) valueCollection [i].Value!; + if (attrib.ConstructorArguments [0].Value is ReadOnlyCollection valueCollection) { + var result = new byte [valueCollection.Count]; + for (int i = 0; i < valueCollection.Count; i++) { + if (valueCollection [i].Value is byte val) { + result [i] = val; + } + } + return result; } - return result; } } } From c85a3d963b908eac1f6c7ba5943f5ad344da271a Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Fri, 5 Jun 2026 08:04:01 +0200 Subject: [PATCH 04/25] [Foundation] Fix nullability for NSUrlSessionTaskDelegate.WillPerformHttpRedirection. --- src/Foundation/NSUrlSessionHandler.cs | 6 +++--- src/foundation.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index e34081682faa..31e3a1bfab92 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -1036,17 +1036,17 @@ void WillCacheResponseImpl (NSUrlSession session, NSUrlSessionDataTask dataTask, } [Preserve (Conditional = true)] - public override void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler) + public override void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler) { if (!sessionHandler.AllowAutoRedirect) { - completionHandler (null!); + completionHandler (null); return; } var inflight = GetInflightData (task); if (inflight is null) { - completionHandler (null!); + completionHandler (null); return; } diff --git a/src/foundation.cs b/src/foundation.cs index 8ceba04a14c1..d0f7cca6d5f7 100644 --- a/src/foundation.cs +++ b/src/foundation.cs @@ -11096,7 +11096,7 @@ partial interface NSUrlSessionTaskDelegate { /// To be added. /// To be added. [Export ("URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:")] - void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler); + void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler); /// To be added. /// To be added. From c698bc843f73e71832e2dd2df3f34a22417e2574 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Fri, 5 Jun 2026 10:29:34 +0200 Subject: [PATCH 05/25] Fix build --- src/foundation.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/foundation.cs b/src/foundation.cs index d0f7cca6d5f7..dd1ea68f6bb8 100644 --- a/src/foundation.cs +++ b/src/foundation.cs @@ -11096,7 +11096,9 @@ partial interface NSUrlSessionTaskDelegate { /// To be added. /// To be added. [Export ("URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:")] +#nullable enable void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler); +#nullable disable /// To be added. /// To be added. From c8cc48a447dc8a8a2ef499519923e74560328109 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 09:23:37 +0200 Subject: [PATCH 06/25] [bgen] Propagate nullability for generic type arguments in method parameters and async wrappers. Apply nullability byte propagation to method parameter signatures in MakeSignatureFromParameterInfo, matching the existing property fix. Fix IsNSErrorNullable in AsyncMethodInfo to read nullability from the outer method parameter's NullableAttribute bytes instead of the delegate Invoke parameter (which doesn't carry nullability info). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 12 +++++++++++- src/bgen/Models/AsyncMethodInfo.cs | 25 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index 8ac84e706685..81e7a53ee60d 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -2927,7 +2927,17 @@ public void MakeSignatureFromParameterInfo (bool comma, StringBuilder sb, Member if (!bt.IsValueType && AttributeManager.IsNullable (pi)) sb.Append ('?'); } else { - sb.Append (TypeManager.FormatType (declaringType, parType)); + // Only apply nullability bytes for delegate types whose generic type parameters + // are all contravariant (Action<> variants). Func<> types have a covariant TResult + // which creates a type mismatch with the trampoline's CreateNullableBlock signature. + byte []? nullabilityBytes = null; + if (parType.IsSubclassOf (TypeCache.System_Delegate)) { + var invokeMethod = parType.GetMethod ("Invoke"); + if (invokeMethod is not null && invokeMethod.ReturnType == TypeCache.System_Void) { + nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + } + } + sb.Append (TypeManager.FormatType (declaringType, parType, nullabilityBytes)); // some `IntPtr` are decorated with `[NullAttribute]` if (!parType.IsValueType) { if (AttributeManager.IsNullable (pi)) { diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index d57a30c18937..d468559952f4 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -28,7 +28,30 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, var lastParam = cbParams.LastOrDefault (); if (lastParam is not null && lastParam.ParameterType.Name == "NSError") { HasNSError = true; - IsNSErrorNullable = generator.AttributeManager.IsNullable (lastParam); + // The nullability info for generic type arguments is encoded in the NullableAttribute + // on the outer method parameter (the one with the Action<...> type), not on the + // delegate's Invoke method parameters. Check the nullability bytes to determine if + // the NSError type argument is nullable. + var outerParam = mi.GetParameters ().Last (); + var nullabilityBytes = generator.AttributeManager.GetNullabilityBytes (outerParam); + if (nullabilityBytes is not null && nullabilityBytes.Length > 1) { + // Walk the type arguments depth-first to find the byte index for the last param. + // Value types don't consume bytes, reference types do. + // byte[0] is for the Action<> itself, then each reference type arg consumes one byte. + int byteIndex = 1; // start after the outer type byte + var genericArgs = lastType.GetGenericArguments (); + for (int i = 0; i < genericArgs.Length; i++) { + if (!genericArgs [i].IsValueType) { + if (i == genericArgs.Length - 1) { + // This is the last generic argument (NSError) + IsNSErrorNullable = byteIndex < nullabilityBytes.Length && nullabilityBytes [byteIndex] == 2; + } + byteIndex++; + } + } + } else { + IsNSErrorNullable = generator.AttributeManager.IsNullable (lastParam); + } cbParams = cbParams.DropLast (); } From d6e663470c30e127e324ffec60e911afadea590e Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 11:56:10 +0200 Subject: [PATCH 07/25] [bgen] Add unit tests for nullability propagation in method parameters and async wrappers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/bgen/BGenTests.cs | 16 ++++++++++++++ tests/bgen/tests/generic-type-nullability.cs | 22 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index 6b338d31c434..33095bd6a8e2 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1674,6 +1674,22 @@ public void GenericTypeNullability (Profile profile) // Value type at the end Assert.That (contents, Does.Contain ("Action?"), "ValueTypeAtEnd should not annotate trailing value type"); + + // === Method parameter assertions === + + // Method with nullable Action parameter + Assert.That (contents, Does.Contain ("Action"), "DoSomething should have nullable generic arg in method parameter"); + + // Method with mixed nullable/non-nullable Action parameter + Assert.That (contents, Does.Contain ("Action"), "DoSomethingElse should have mixed nullability in method parameter"); + + // Async method: completion handler with nullable NSError should generate Tuple + Assert.That (contents, Does.Contain ("Action"), "ConfirmAcquired should have nullable NSError in method parameter"); + Assert.That (contents, Does.Contain ("Tuple"), "ConfirmAcquired async should generate Tuple with nullable NSError"); + + // Async method: completion handler with non-nullable NSError should generate Tuple + Assert.That (contents, Does.Contain ("Action"), "ConfirmAcquiredNonNull should have non-nullable NSError in method parameter"); + Assert.That (contents, Does.Contain ("Tuple"), "ConfirmAcquiredNonNull async should generate Tuple with non-nullable NSError"); } [Test] diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs index c98bda13b833..75c2b1696838 100644 --- a/tests/bgen/tests/generic-type-nullability.cs +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -11,6 +11,8 @@ namespace NS { [BaseType (typeof (NSObject))] interface Widget { + // === Properties === + // Basic: two nullable generic args [Export ("authenticateHandler")] [NullAllowed] @@ -60,5 +62,25 @@ interface Widget { [Export ("valueTypeAtEnd")] [NullAllowed] Action ValueTypeAtEnd { get; set; } + + // === Methods with nullable generic type arguments === + + // Method with nullable Action parameter + [Export ("doSomething:completionHandler:")] + void DoSomething (NSObject obj, Action completionHandler); + + // Method with mixed nullable/non-nullable Action parameter + [Export ("doSomethingElse:completionHandler:")] + void DoSomethingElse (NSObject obj, Action completionHandler); + + // Async method with nullable NSError in completion handler + [Async] + [Export ("confirmAcquired:completionHandler:")] + void ConfirmAcquired (NSObject obj, Action completionHandler); + + // Async method with non-nullable NSError in completion handler + [Async] + [Export ("confirmAcquiredNonNull:completionHandler:")] + void ConfirmAcquiredNonNull (NSObject obj, Action completionHandler); } } From 7096dedff181134c702710a978f420c633d0c068 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 18:17:10 +0200 Subject: [PATCH 08/25] [api-tools] Fix API diff rendering issues - MultiplexedFormatter.Diff: replace LESSERTHANREPLACEMENT/GREATERTHANREPLACEMENT placeholders with formatter-specific values before rendering. - Skip NullableAttribute and NullableContextAttribute in API info output since nullability is already rendered via '?' annotations on type names. - Fix IsNullabilitySuffix to recognize '%' as a separator (for placeholder text). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/api-tools/mono-api-html/ApiChange.cs | 5 +++-- tools/api-tools/mono-api-html/MultiplexedFormatter.cs | 6 +++++- tools/api-tools/mono-api-info/mono-api-info.cs | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tools/api-tools/mono-api-html/ApiChange.cs b/tools/api-tools/mono-api-html/ApiChange.cs index 18cc90285b5c..2b48be6428dd 100644 --- a/tools/api-tools/mono-api-html/ApiChange.cs +++ b/tools/api-tools/mono-api-html/ApiChange.cs @@ -110,11 +110,12 @@ static bool IsNullabilitySuffix (string text, int index) { // A '?' is a nullability suffix if it's at the end, or before a type separator. // The input type names are already formatted (via GetTypeName + Formatter), so generic - // brackets appear as HTML entities (< / >). A '?' before '&' catches the > case. + // brackets appear as HTML entities (< / >) or placeholders (%GREATERTHANREPLACEMENT%). + // A '?' before '&' catches >, and '%' catches %GREATERTHANREPLACEMENT%. if (index + 1 >= text.Length) return true; char next = text [index + 1]; - return next == ']' || next == ',' || next == '>' || next == '&' || next == ' '; + return next == ']' || next == ',' || next == '>' || next == '&' || next == '%' || next == ' '; } } diff --git a/tools/api-tools/mono-api-html/MultiplexedFormatter.cs b/tools/api-tools/mono-api-html/MultiplexedFormatter.cs index d9327619c222..cc8556c93424 100644 --- a/tools/api-tools/mono-api-html/MultiplexedFormatter.cs +++ b/tools/api-tools/mono-api-html/MultiplexedFormatter.cs @@ -190,8 +190,12 @@ public override void DiffRemoval (TextChunk chunk, string text) public override void Diff (ApiChange apichange) { - foreach (var formatter in formatters) + foreach (var formatter in formatters) { + var sb = apichange.Member.GetStringBuilder (formatter); + sb.Replace (LesserThan, formatter.LesserThan); + sb.Replace (GreaterThan, formatter.GreaterThan); formatter.Diff (apichange); + } } public override void PushOutput () diff --git a/tools/api-tools/mono-api-info/mono-api-info.cs b/tools/api-tools/mono-api-info/mono-api-info.cs index 1b22201ef597..182bca69129d 100644 --- a/tools/api-tools/mono-api-info/mono-api-info.cs +++ b/tools/api-tools/mono-api-info/mono-api-info.cs @@ -1413,6 +1413,10 @@ bool SkipAttribute (CustomAttribute attribute) switch (attribute.AttributeType.FullName) { case "System.Runtime.CompilerServices.NativeIntegerAttribute": return false; + case "System.Runtime.CompilerServices.NullableAttribute": + case "System.Runtime.CompilerServices.NullableContextAttribute": + // Nullability is already rendered via '?' annotations on type names. + return true; } if (!state.TypeHelper.IsPublic (attribute)) From 4778797c6ccff8963e741d86ff877b4325ff1818 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Fri, 19 Jun 2026 13:03:21 +0200 Subject: [PATCH 09/25] [api-tools] Render full generic type argument nullability in API info. Replace top-level-only nullability rendering with a recursive depth-first traversal that reads the full NullableAttribute byte array and applies '?' at every generic type argument position. This fixes two issues: - void return types incorrectly showing as 'void?' when NullableContext context defaults to nullable (void is not a reference type). - Generic type argument nullability not being rendered (e.g., Action was shown as Action, losing the inner '?'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-tools/mono-api-info/mono-api-info.cs | 163 ++++++++++++------ 1 file changed, 113 insertions(+), 50 deletions(-) diff --git a/tools/api-tools/mono-api-info/mono-api-info.cs b/tools/api-tools/mono-api-info/mono-api-info.cs index 182bca69129d..cb2db03d8087 100644 --- a/tools/api-tools/mono-api-info/mono-api-info.cs +++ b/tools/api-tools/mono-api-info/mono-api-info.cs @@ -1014,8 +1014,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); FieldDefinition field = (FieldDefinition) memberDefinition; - var fieldTypeName = Utils.CleanupTypeName (field.FieldType); - fieldTypeName = NullabilityHelper.AppendNullabilityToTypeName (fieldTypeName, field.FieldType, field, field.DeclaringType); + var fieldTypeName = NullabilityHelper.FormatTypeNameWithFullNullability (field.FieldType, field, field.DeclaringType); AddAttribute ("fieldtype", fieldTypeName); if (field.IsLiteral) { @@ -1085,8 +1084,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); PropertyDefinition prop = (PropertyDefinition) memberDefinition; - var ptypeName = Utils.CleanupTypeName (prop.PropertyType); - ptypeName = NullabilityHelper.AppendNullabilityToTypeName (ptypeName, prop.PropertyType, prop, prop.DeclaringType); + var ptypeName = NullabilityHelper.FormatTypeNameWithFullNullability (prop.PropertyType, prop, prop.DeclaringType); AddAttribute ("ptype", ptypeName); bool haveParameters; @@ -1153,8 +1151,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); EventDefinition evt = (EventDefinition) memberDefinition; - var evtTypeName = Utils.CleanupTypeName (evt.EventType); - evtTypeName = NullabilityHelper.AppendNullabilityToTypeName (evtTypeName, evt.EventType, evt, evt.DeclaringType); + var evtTypeName = NullabilityHelper.FormatTypeNameWithFullNullability (evt.EventType, evt, evt.DeclaringType); AddAttribute ("eventtype", evtTypeName); } @@ -1222,10 +1219,9 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) // base method can come from another assembly. AddAttribute ("is-override", "true"); } - string rettype = Utils.CleanupTypeName (mbase.MethodReturnType.ReturnType); + string rettype = NullabilityHelper.FormatTypeNameWithFullNullability (mbase.MethodReturnType.ReturnType, mbase.MethodReturnType, mbase); if (rettype != "System.Void" || !mbase.IsConstructor) { - rettype = NullabilityHelper.AppendNullabilityToTypeName (rettype, mbase.MethodReturnType.ReturnType, mbase.MethodReturnType, mbase); - AddAttribute ("returntype", (rettype)); + AddAttribute ("returntype", rettype); } // // if (mbase.MethodReturnType.HasCustomAttributes) @@ -1310,7 +1306,7 @@ public override void DoOutput () pt = brt.ElementType; } - AddAttribute ("type", NullabilityHelper.AppendNullabilityToTypeName (Utils.CleanupTypeName (pt), pt, parameter, parameter.Method as ICustomAttributeProvider)); + AddAttribute ("type", NullabilityHelper.FormatTypeNameWithFullNullability (pt, parameter, parameter.Method as ICustomAttributeProvider)); if (parameter.IsOptional) { AddAttribute ("optional", "true"); @@ -1472,25 +1468,6 @@ static class NullabilityHelper { const string NullableAttributeName = "System.Runtime.CompilerServices.NullableAttribute"; const string NullableContextAttributeName = "System.Runtime.CompilerServices.NullableContextAttribute"; - // Returns the nullability flag for the top-level type: - // 0 = oblivious, 1 = not-null, 2 = nullable - public static byte GetTopLevelNullability (ICustomAttributeProvider provider, ICustomAttributeProvider? context) - { - // Check for NullableAttribute directly on the member/parameter/return type - var flag = GetNullableFlagFromProvider (provider); - if (flag.HasValue) - return flag.Value; - - // Fall back to NullableContextAttribute on the containing method/type - if (context is not null) { - var contextFlag = GetNullableContextFlag (context); - if (contextFlag.HasValue) - return contextFlag.Value; - } - - return 0; // oblivious - } - // Gets the NullableContextAttribute flag from a method or type public static byte? GetNullableContextFlag (ICustomAttributeProvider provider) { @@ -1543,7 +1520,9 @@ public static byte GetTopLevelNullability (ICustomAttributeProvider provider, IC return null; } - static byte? GetNullableFlagFromProvider (ICustomAttributeProvider provider) + // Gets the full NullableAttribute byte array from a provider. + // Returns null if no NullableAttribute is present. + static byte []? GetNullabilityBytesFromProvider (ICustomAttributeProvider provider) { if (!provider.HasCustomAttributes) return null; @@ -1556,39 +1535,123 @@ public static byte GetTopLevelNullability (ICustomAttributeProvider provider, IC var arg = attr.ConstructorArguments [0]; if (arg.Value is byte b) - return b; - if (arg.Value is CustomAttributeArgument [] arr && arr.Length > 0 && arr [0].Value is byte b2) - return b2; + return new byte [] { b }; + if (arg.Value is CustomAttributeArgument [] arr) { + var result = new byte [arr.Length]; + for (int i = 0; i < arr.Length; i++) { + if (arr [i].Value is byte val) { + result [i] = val; + } + } + return result; + } } return null; } - public static bool IsNullableReferenceType (TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + // Gets the full nullability byte array for a provider, falling back to NullableContext. + static byte []? GetFullNullabilityBytes (ICustomAttributeProvider provider, ICustomAttributeProvider? context) { - if (type is null) - return false; + var bytes = GetNullabilityBytesFromProvider (provider); + if (bytes is not null) + return bytes; - // Value types use Nullable for nullability, not annotations - if (type.IsValueType) - return false; + if (context is not null) { + var contextFlag = GetNullableContextFlag (context); + if (contextFlag.HasValue) + return new byte [] { contextFlag.Value }; + } - // ByReference types (ref/out parameters): check the element type - if (type.IsByReference) { - var elementType = ((ByReferenceType) type).ElementType; - if (elementType.IsValueType) - return false; + return null; + } + + // Formats a type name with full nullability annotations, including generic type arguments. + // This reads the NullableAttribute byte array and applies '?' at every position in + // the type tree (depth-first), not just the top-level type. + public static string FormatTypeNameWithFullNullability (TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + { + var bytes = GetFullNullabilityBytes (provider, context); + if (bytes is null) + return Utils.CleanupTypeName (type); + + int index = 0; + return FormatTypeWithNullabilityBytes (type, bytes, ref index); + } + + // Recursively formats a type name, consuming nullability bytes in depth-first order. + // Value types don't consume bytes. Reference types consume one byte each. + // A single-byte array means uniform nullability for all positions. + static string FormatTypeWithNullabilityBytes (TypeReference type, byte [] bytes, ref int index) + { + // Void is never nullable + if (type.FullName == "System.Void") + return "System.Void"; + + // Value types don't consume bytes from the NullableAttribute array. + // Their nullability is structural (Nullable). + if (type.IsValueType) { + if (type is GenericInstanceType valueGenType) { + // Handle Nullable + if (valueGenType.ElementType.FullName == "System.Nullable`1") { + return FormatTypeWithNullabilityBytes (valueGenType.GenericArguments [0], bytes, ref index) + "?"; + } + // Other generic value types: recurse into args + var sb = new StringBuilder (); + sb.Append (RemoveArityAndClean (valueGenType.ElementType.FullName)); + sb.Append ('['); + for (int i = 0; i < valueGenType.GenericArguments.Count; i++) { + if (i > 0) + sb.Append (", "); + sb.Append (FormatTypeWithNullabilityBytes (valueGenType.GenericArguments [i], bytes, ref index)); + } + sb.Append (']'); + return sb.ToString (); + } + return Utils.CleanupTypeName (type); + } + + // Reference types consume one byte + byte currentByte; + if (bytes.Length == 1) { + // Single byte: uniform nullability for all positions + currentByte = bytes [0]; + } else { + currentByte = index < bytes.Length ? bytes [index] : (byte) 0; + index++; + } + bool isNullable = currentByte == 2; + + if (type is ArrayType arrType) { + string ranks = new string (',', arrType.Rank - 1); + return FormatTypeWithNullabilityBytes (arrType.ElementType, bytes, ref index) + "[" + ranks + "]" + (isNullable ? "?" : ""); + } + + if (type is GenericInstanceType genType) { + var sb = new StringBuilder (); + sb.Append (RemoveArityAndClean (genType.ElementType.FullName)); + sb.Append ('['); + for (int i = 0; i < genType.GenericArguments.Count; i++) { + if (i > 0) + sb.Append (", "); + sb.Append (FormatTypeWithNullabilityBytes (genType.GenericArguments [i], bytes, ref index)); + } + sb.Append (']'); + if (isNullable) + sb.Append ('?'); + return sb.ToString (); } - var flag = GetTopLevelNullability (provider, context); - return flag == 2; + return Utils.CleanupTypeName (type) + (isNullable ? "?" : ""); } - public static string AppendNullabilityToTypeName (string typeName, TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + // Removes generic arity suffix (e.g., `1) and converts / to + for nested types. + static string RemoveArityAndClean (string name) { - if (IsNullableReferenceType (type, provider, context)) - return typeName + "?"; - return typeName; + int backtick = name.IndexOf ('`'); + if (backtick >= 0) + name = name.Substring (0, backtick); + return name.Replace ('/', '+'); } } From f583af5ef58873017cb2c3a8dc94dd3e491234c4 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 23 Jun 2026 18:01:10 +0200 Subject: [PATCH 10/25] [api-tools] Fix reversed old/new in markdown API diff output. The MarkdownFormatter had the old and new text swapped in two places: - DiffModification wrapped 'old' with addition markers and 'new' with removal markers (backwards compared to HtmlFormatter). - The Diff method assigned the wrong Clean() arguments to the - and + lines, causing the old line to show added content and vice versa. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/api-tools/mono-api-html/MarkdownFormatter.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/api-tools/mono-api-html/MarkdownFormatter.cs b/tools/api-tools/mono-api-html/MarkdownFormatter.cs index 6f93beed2d14..125c9e1322e5 100644 --- a/tools/api-tools/mono-api-html/MarkdownFormatter.cs +++ b/tools/api-tools/mono-api-html/MarkdownFormatter.cs @@ -207,9 +207,9 @@ public override void DiffAddition (TextChunk chunk, string text) public override void DiffModification (TextChunk chunk, string old, string @new) { if (old is not null && old.Length > 0) - DiffAddition (chunk, old); + DiffRemoval (chunk, old); if (@new is not null && @new.Length > 0) - DiffRemoval (chunk, @new); + DiffAddition (chunk, @new); } public override void DiffRemoval (TextChunk chunk, string text) @@ -223,9 +223,9 @@ public override void DiffRemoval (TextChunk chunk, string text) public override void Diff (ApiChange apichange) { foreach (var line in apichange.Member.GetStringBuilder (this).ToString ().Split (new [] { Environment.NewLine }, 0)) { - if (line.Contains ("+++")) { - output.WriteLine ("-{0}", Clean (line, "+++", "---")); - output.WriteLine ("+{0}", Clean (line, "---", "+++")); + if (line.Contains ("+++") || line.Contains ("---")) { + output.WriteLine ("-{0}", Clean (line, "---", "+++")); + output.WriteLine ("+{0}", Clean (line, "+++", "---")); } else { output.WriteLine (" {0}", line); } From 84feb12ffaa079119b83cf9ee9486f08a500ccf7 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 24 Jun 2026 17:14:01 +0200 Subject: [PATCH 11/25] [bgen] Handle single-byte NullableAttribute and fix depth-first byte counting - TypeManager.FormatType: Handle single-byte (uniform) NullableAttribute where all positions share the same nullability value. Previously bailed out early when Length <= 1. - TypeManager.FormatTypeUsedIn: Don't increment index when reading from single-byte arrays (the single byte applies to all positions). - AsyncMethodInfo: Add CountNullabilityBytes helper for proper depth-first traversal of type trees. Previously assumed one byte per parameter, which is incorrect for arrays and nested generics. - Add test case: FetchItems with Action to validate depth-first byte counting with array types before NSError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Models/AsyncMethodInfo.cs | 52 +++++++++++++++----- src/bgen/TypeManager.cs | 18 +++++-- tests/bgen/BGenTests.cs | 5 ++ tests/bgen/tests/generic-type-nullability.cs | 6 +++ 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index d468559952f4..5cf512d1ec33 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -34,20 +34,22 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, // the NSError type argument is nullable. var outerParam = mi.GetParameters ().Last (); var nullabilityBytes = generator.AttributeManager.GetNullabilityBytes (outerParam); - if (nullabilityBytes is not null && nullabilityBytes.Length > 1) { - // Walk the type arguments depth-first to find the byte index for the last param. - // Value types don't consume bytes, reference types do. - // byte[0] is for the Action<> itself, then each reference type arg consumes one byte. - int byteIndex = 1; // start after the outer type byte + if (nullabilityBytes is not null && nullabilityBytes.Length == 1) { + // Single-byte (uniform) form: the same byte applies to all positions + IsNSErrorNullable = nullabilityBytes [0] == 2; + } else if (nullabilityBytes is not null && nullabilityBytes.Length > 1) { + // Multi-byte form: walk the type arguments depth-first to find the byte + // index for the last param (NSError). byte[0] is for the Action<> itself. + int byteIndex = 1; var genericArgs = lastType.GetGenericArguments (); for (int i = 0; i < genericArgs.Length; i++) { - if (!genericArgs [i].IsValueType) { - if (i == genericArgs.Length - 1) { - // This is the last generic argument (NSError) - IsNSErrorNullable = byteIndex < nullabilityBytes.Length && nullabilityBytes [byteIndex] == 2; - } - byteIndex++; + if (i == genericArgs.Length - 1) { + // This is the last generic argument (NSError), which is a reference type + IsNSErrorNullable = byteIndex < nullabilityBytes.Length && nullabilityBytes [byteIndex] == 2; + break; } + // Advance the byte index past this argument's subtree + byteIndex += CountNullabilityBytes (genericArgs [i]); } } else { IsNSErrorNullable = generator.AttributeManager.IsNullable (lastParam); @@ -78,4 +80,32 @@ public string GetUniqueParamName (string suggestion) } } + // Counts how many nullability bytes a type subtree consumes in depth-first order. + // Value types consume 0 bytes (unless they are generic and contain reference type args). + // Reference types consume 1 byte for themselves, plus bytes for their generic args/element types. + static int CountNullabilityBytes (Type type) + { + if (type.IsValueType) { + // Value types don't consume a byte themselves, but their generic args might + var vtargs = type.GetGenericArguments (); + int count = 0; + foreach (var arg in vtargs) + count += CountNullabilityBytes (arg); + return count; + } + + // Reference types consume 1 byte for themselves + int bytes = 1; + + if (type.IsArray) { + bytes += CountNullabilityBytes (type.GetElementType ()!); + } else { + var targs = type.GetGenericArguments (); + foreach (var arg in targs) + bytes += CountNullabilityBytes (arg); + } + + return bytes; + } + } diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index 0fbef8653c0b..19124b5c24f0 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -262,13 +262,14 @@ public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) if (type is null) { throw new BindingException (1065, true); } - if (nullabilityBytes is null || nullabilityBytes.Length <= 1) { + if (nullabilityBytes is null || nullabilityBytes.Length == 0) { return FormatTypeUsedIn (usedIn?.Namespace, type); } // The byte array encodes nullability for the type tree in depth-first order. // Byte 0 is for the outer type itself (which the caller handles separately via // [NullAllowed] / IsNullable), so we skip it. The rest are for generic arguments. + // A single-byte array means uniform nullability for all positions. var targs = type.GetGenericArguments (); if (targs.Length == 0) { return FormatTypeUsedIn (usedIn?.Namespace, type); @@ -276,7 +277,7 @@ public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) // Render the outer type name using the standard method (without nullability) // and then render generic arguments with nullability from the byte array - int index = 1; // skip byte[0] (the outer type) + int index = nullabilityBytes.Length == 1 ? 0 : 1; // for single-byte (uniform), don't skip var formattedArgs = new string [targs.Length]; for (int i = 0; i < targs.Length; i++) { formattedArgs [i] = FormatTypeUsedIn (usedIn?.Namespace, targs [i], nullabilityBytes, ref index); @@ -489,9 +490,16 @@ string FormatTypeUsedIn (string? usedInNamespace, Type? type, byte [] nullabilit return vname; } - // Reference types consume one byte from the array - byte currentByte = index < nullabilityBytes.Length ? nullabilityBytes [index] : (byte) 0; - index++; + // Reference types consume one byte from the array. + // For single-byte (uniform) arrays, the same byte applies to all positions + // without incrementing the index. + byte currentByte; + if (nullabilityBytes.Length == 1) { + currentByte = nullabilityBytes [0]; + } else { + currentByte = index < nullabilityBytes.Length ? nullabilityBytes [index] : (byte) 0; + index++; + } bool isCurrentNullable = currentByte == 2; diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index 33095bd6a8e2..7f36553a4d5b 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1690,6 +1690,11 @@ public void GenericTypeNullability (Profile profile) // Async method: completion handler with non-nullable NSError should generate Tuple Assert.That (contents, Does.Contain ("Action"), "ConfirmAcquiredNonNull should have non-nullable NSError in method parameter"); Assert.That (contents, Does.Contain ("Tuple"), "ConfirmAcquiredNonNull async should generate Tuple with non-nullable NSError"); + + // Async method with array arg before NSError (depth-first byte counting) + Assert.That (contents, Does.Contain ("Action"), "FetchItems should have nullable array and NSError"); + // When NSError is nullable, async uses Task with error→exception; the result type is the non-error arg + Assert.That (contents, Does.Contain ("Task"), "FetchItems async should return Task (nullable NSError triggers error handling)"); } [Test] diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs index 75c2b1696838..13199e05fb83 100644 --- a/tests/bgen/tests/generic-type-nullability.cs +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -82,5 +82,11 @@ interface Widget { [Async] [Export ("confirmAcquiredNonNull:completionHandler:")] void ConfirmAcquiredNonNull (NSObject obj, Action completionHandler); + + // Async method with array arg before NSError (tests depth-first byte counting: + // the array type consumes 2 bytes — one for the array itself and one for the element type) + [Async] + [Export ("fetchItems:completionHandler:")] + void FetchItems (NSObject obj, Action completionHandler); } } From 7661c4caf26940bf2e8b9b9d2411b9225f58282c Mon Sep 17 00:00:00 2001 From: GitHub Actions Autoformatter Date: Wed, 24 Jun 2026 15:52:38 +0000 Subject: [PATCH 12/25] Auto-format source code --- tests/bgen/tests/generic-type-nullability.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs index 13199e05fb83..b49894e9b2f9 100644 --- a/tests/bgen/tests/generic-type-nullability.cs +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -87,6 +87,6 @@ interface Widget { // the array type consumes 2 bytes — one for the array itself and one for the element type) [Async] [Export ("fetchItems:completionHandler:")] - void FetchItems (NSObject obj, Action completionHandler); + void FetchItems (NSObject obj, Action completionHandler); } } From 0404b111892d563e7ef649c9e9cdd9751155aa37 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 24 Jun 2026 18:02:00 +0200 Subject: [PATCH 13/25] Address review comments: array nullability in FormatType and markdown diff empty lines - TypeManager.FormatType: Handle array types with nullability bytes instead of bailing out when GetGenericArguments() is empty. Arrays have no generic arguments but still have element type nullability encoded in the byte array. - MarkdownFormatter.Diff: Only emit removal/addition lines when there is actual content. Pure additions no longer produce an extra empty removal line (and vice versa). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/TypeManager.cs | 13 +++++++++---- tools/api-tools/mono-api-html/MarkdownFormatter.cs | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index 19124b5c24f0..b381ff76f854 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -271,13 +271,18 @@ public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) // [NullAllowed] / IsNullable), so we skip it. The rest are for generic arguments. // A single-byte array means uniform nullability for all positions. var targs = type.GetGenericArguments (); - if (targs.Length == 0) { + if (targs.Length == 0 && !type.IsArray) { return FormatTypeUsedIn (usedIn?.Namespace, type); } - // Render the outer type name using the standard method (without nullability) - // and then render generic arguments with nullability from the byte array - int index = nullabilityBytes.Length == 1 ? 0 : 1; // for single-byte (uniform), don't skip + // Start index: for single-byte (uniform), don't skip byte 0 since it applies everywhere. + // For multi-byte, skip byte 0 (the outer type, handled by the caller via [NullAllowed]). + int index = nullabilityBytes.Length == 1 ? 0 : 1; + + // For array types, delegate to FormatTypeUsedIn which handles arrays with nullability + if (type.IsArray) { + return FormatTypeUsedIn (usedIn?.Namespace, type, nullabilityBytes, ref index); + } var formattedArgs = new string [targs.Length]; for (int i = 0; i < targs.Length; i++) { formattedArgs [i] = FormatTypeUsedIn (usedIn?.Namespace, targs [i], nullabilityBytes, ref index); diff --git a/tools/api-tools/mono-api-html/MarkdownFormatter.cs b/tools/api-tools/mono-api-html/MarkdownFormatter.cs index 125c9e1322e5..e755471194d7 100644 --- a/tools/api-tools/mono-api-html/MarkdownFormatter.cs +++ b/tools/api-tools/mono-api-html/MarkdownFormatter.cs @@ -224,8 +224,12 @@ public override void Diff (ApiChange apichange) { foreach (var line in apichange.Member.GetStringBuilder (this).ToString ().Split (new [] { Environment.NewLine }, 0)) { if (line.Contains ("+++") || line.Contains ("---")) { - output.WriteLine ("-{0}", Clean (line, "---", "+++")); - output.WriteLine ("+{0}", Clean (line, "+++", "---")); + var removed = Clean (line, "---", "+++"); + var added = Clean (line, "+++", "---"); + if (!string.IsNullOrWhiteSpace (removed)) + output.WriteLine ("-{0}", removed); + if (!string.IsNullOrWhiteSpace (added)) + output.WriteLine ("+{0}", added); } else { output.WriteLine (" {0}", line); } From 58e95150280af9d541a68595a2884bd32587a1e0 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 24 Jun 2026 18:21:56 +0200 Subject: [PATCH 14/25] [api-tools] Preserve generic arity and handle % in nullability stripping - mono-api-info: Rename RemoveArityAndClean to CleanForNullability and stop stripping the generic arity suffix (`N). mono-api-html's GetElementTypeName uses the backtick to detect and format generics, so removing it would break type rendering in API diffs. - Helpers.StripNullability: Add '%' as a valid boundary character for stripping '?' annotations. MultiplexedFormatter uses %...% placeholders for < and >, so '?' before '%GREATERTHANREPLACEMENT%' needs to be recognized as a nullability suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/api-tools/mono-api-html/Helpers.cs | 5 +++-- tools/api-tools/mono-api-info/mono-api-info.cs | 13 ++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/api-tools/mono-api-html/Helpers.cs b/tools/api-tools/mono-api-html/Helpers.cs index 9a98857a0826..d6b786d725e6 100644 --- a/tools/api-tools/mono-api-html/Helpers.cs +++ b/tools/api-tools/mono-api-html/Helpers.cs @@ -262,11 +262,12 @@ public static FieldAttributes GetFieldAttributes (this XElement element) if (type is null) return null; // Remove all '?' that appear before ']', at end of string, before ',', - // before '>' or '&' (HTML entities like >), or before ' ' (before param name) + // before '>' or '&' (HTML entities like >), before ' ' (before param name), + // or before '%' (placeholder boundaries like %GREATERTHANREPLACEMENT%) var sb = new StringBuilder (type.Length); for (int i = 0; i < type.Length; i++) { if (type [i] == '?') { - if (i + 1 >= type.Length || type [i + 1] == ']' || type [i + 1] == ',' || type [i + 1] == '>' || type [i + 1] == '&' || type [i + 1] == ' ') + if (i + 1 >= type.Length || type [i + 1] == ']' || type [i + 1] == ',' || type [i + 1] == '>' || type [i + 1] == '&' || type [i + 1] == ' ' || type [i + 1] == '%') continue; } sb.Append (type [i]); diff --git a/tools/api-tools/mono-api-info/mono-api-info.cs b/tools/api-tools/mono-api-info/mono-api-info.cs index cb2db03d8087..7a3583b11b50 100644 --- a/tools/api-tools/mono-api-info/mono-api-info.cs +++ b/tools/api-tools/mono-api-info/mono-api-info.cs @@ -1598,7 +1598,7 @@ static string FormatTypeWithNullabilityBytes (TypeReference type, byte [] bytes, } // Other generic value types: recurse into args var sb = new StringBuilder (); - sb.Append (RemoveArityAndClean (valueGenType.ElementType.FullName)); + sb.Append (CleanForNullability (valueGenType.ElementType.FullName)); sb.Append ('['); for (int i = 0; i < valueGenType.GenericArguments.Count; i++) { if (i > 0) @@ -1629,7 +1629,7 @@ static string FormatTypeWithNullabilityBytes (TypeReference type, byte [] bytes, if (type is GenericInstanceType genType) { var sb = new StringBuilder (); - sb.Append (RemoveArityAndClean (genType.ElementType.FullName)); + sb.Append (CleanForNullability (genType.ElementType.FullName)); sb.Append ('['); for (int i = 0; i < genType.GenericArguments.Count; i++) { if (i > 0) @@ -1645,12 +1645,11 @@ static string FormatTypeWithNullabilityBytes (TypeReference type, byte [] bytes, return Utils.CleanupTypeName (type) + (isNullable ? "?" : ""); } - // Removes generic arity suffix (e.g., `1) and converts / to + for nested types. - static string RemoveArityAndClean (string name) + // Converts / to + for nested types (same as CleanupTypeName but without <> replacement, + // since we build the generic argument list ourselves with nullability annotations). + // The generic arity suffix (e.g., `2) is preserved because mono-api-html uses it to detect generics. + static string CleanForNullability (string name) { - int backtick = name.IndexOf ('`'); - if (backtick >= 0) - name = name.Substring (0, backtick); return name.Replace ('/', '+'); } } From d507026b75ddcd070630d2721d84f9060fbc8a73 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 24 Jun 2026 19:12:09 +0200 Subject: [PATCH 15/25] [bgen] Fix array byte consumption in FormatType and remove null-forgiving operator - TypeManager.FormatType: For array types, format the element type directly instead of delegating to FormatTypeUsedIn on the array itself. The array's own nullability byte is already consumed by the caller (via [NullAllowed]), so passing the whole array type would double-consume that byte. - AsyncMethodInfo.CountNullabilityBytes: Replace null-forgiving operator on GetElementType() with a null check to maintain nullability flow analysis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Models/AsyncMethodInfo.cs | 4 +++- src/bgen/TypeManager.cs | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index 5cf512d1ec33..f1dee75a8855 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -98,7 +98,9 @@ static int CountNullabilityBytes (Type type) int bytes = 1; if (type.IsArray) { - bytes += CountNullabilityBytes (type.GetElementType ()!); + var elementType = type.GetElementType (); + if (elementType is not null) + bytes += CountNullabilityBytes (elementType); } else { var targs = type.GetGenericArguments (); foreach (var arg in targs) diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index b381ff76f854..c7dacf27d8b9 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -279,9 +279,12 @@ public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) // For multi-byte, skip byte 0 (the outer type, handled by the caller via [NullAllowed]). int index = nullabilityBytes.Length == 1 ? 0 : 1; - // For array types, delegate to FormatTypeUsedIn which handles arrays with nullability + // For array types, format the element type with nullability. The array's own + // nullability (byte 0) is handled by the caller via [NullAllowed], so we only + // need to format the element type starting at index (which is past byte 0). if (type.IsArray) { - return FormatTypeUsedIn (usedIn?.Namespace, type, nullabilityBytes, ref index); + var elementFormatted = FormatTypeUsedIn (usedIn?.Namespace, type.GetElementType (), nullabilityBytes, ref index); + return elementFormatted + "[" + new string (',', type.GetArrayRank () - 1) + "]"; } var formattedArgs = new string [targs.Length]; for (int i = 0; i < targs.Length; i++) { From e72526990bf3f912fd58dad3691c80f1158342e7 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 11:14:44 +0200 Subject: [PATCH 16/25] [bgen] Only use NullableAttribute bytes for generic delegates in AsyncMethodInfo For non-generic delegates (e.g. ACAccountStoreSaveCompletionHandler), the NullableAttribute on the outer parameter describes the delegate instance's nullability, not the Invoke method parameters. Only read nullability bytes when the delegate type has generic arguments (Action<...> variants). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Models/AsyncMethodInfo.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index f1dee75a8855..25cecd420f90 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -32,8 +32,12 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, // on the outer method parameter (the one with the Action<...> type), not on the // delegate's Invoke method parameters. Check the nullability bytes to determine if // the NSError type argument is nullable. + // This only applies to generic delegate types (Action<...>). For non-generic delegates, + // the NullableAttribute on the outer parameter describes the delegate instance, not + // the Invoke parameters. var outerParam = mi.GetParameters ().Last (); - var nullabilityBytes = generator.AttributeManager.GetNullabilityBytes (outerParam); + var genericArgs = lastType.GetGenericArguments (); + var nullabilityBytes = genericArgs.Length > 0 ? generator.AttributeManager.GetNullabilityBytes (outerParam) : null; if (nullabilityBytes is not null && nullabilityBytes.Length == 1) { // Single-byte (uniform) form: the same byte applies to all positions IsNSErrorNullable = nullabilityBytes [0] == 2; @@ -41,7 +45,6 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, // Multi-byte form: walk the type arguments depth-first to find the byte // index for the last param (NSError). byte[0] is for the Action<> itself. int byteIndex = 1; - var genericArgs = lastType.GetGenericArguments (); for (int i = 0; i < genericArgs.Length; i++) { if (i == genericArgs.Length - 1) { // This is the last generic argument (NSError), which is a reference type From 3edbdac167c45cbc5c5dc0d9b9593807a6d81841 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 14:04:44 +0200 Subject: [PATCH 17/25] Remove unreachable System.Void check in FormatTypeUsedIn System.Void is a value type and already returns early from the IsValueType branch, making the explicit check dead code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/TypeManager.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index c7dacf27d8b9..6ace113956ad 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -512,9 +512,6 @@ string FormatTypeUsedIn (string? usedInNamespace, Type? type, byte [] nullabilit bool isCurrentNullable = currentByte == 2; // Simple reference types - if (type == TypeCache.System_Void) { - return "void"; - } if (type == TypeCache.System_String) { return "string" + (isCurrentNullable ? "?" : ""); } From 2a5fb532290386c9b9b562976e0ed7cd9ecd6aa9 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 14:24:09 +0200 Subject: [PATCH 18/25] Use nullability bytes consistently in wrap-property getter Pass wrapNullabilityBytes to FormatType calls in the getter body so that cast/construction expressions match the declared property type when it has nullable generic arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index 81e7a53ee60d..37ea28183a74 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -4055,14 +4055,14 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields if (TypeManager.IsDictionaryContainerType (pi.PropertyType)) { print ("var src = {0} is not null ? new NSMutableDictionary ({0}) : null;", wrap); - print ("return src is null ? null! : new {0}(src);", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType)); + print ("return src is null ? null! : new {0}(src);", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes)); } else { if (TypeManager.IsArrayOfWrappedType (pi.PropertyType)) - print ("return NSArray.FromArray<{0}>({1} as NSArray){2};", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType.GetElementType ()), wrap, nullable ? "" : "!"); + print ("return NSArray.FromArray<{0}>({1} as NSArray){2};", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType.GetElementType (), wrapNullabilityBytes), wrap, nullable ? "" : "!"); else if (pi.PropertyType.IsValueType) - print ("return ({0}) ({1});", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType), wrap); + print ("return ({0}) ({1});", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes), wrap); else - print ("return ({0} as {1})!;", wrap, TypeManager.FormatType (pi.DeclaringType, pi.PropertyType)); + print ("return ({0} as {1})!;", wrap, TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes)); } indent--; print ("}"); From f57600889ddb6256bf45b9540d4746a22687216a Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 14:45:48 +0200 Subject: [PATCH 19/25] Add comment explaining DiffModification fix Clarify that the original code had old/new reversed (additions were rendered as removals and vice versa in markdown output). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/api-tools/mono-api-html/MarkdownFormatter.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/api-tools/mono-api-html/MarkdownFormatter.cs b/tools/api-tools/mono-api-html/MarkdownFormatter.cs index e755471194d7..ece3b3727e5a 100644 --- a/tools/api-tools/mono-api-html/MarkdownFormatter.cs +++ b/tools/api-tools/mono-api-html/MarkdownFormatter.cs @@ -206,6 +206,8 @@ public override void DiffAddition (TextChunk chunk, string text) public override void DiffModification (TextChunk chunk, string old, string @new) { + // The 'old' text is what's being removed, and 'new' is what's being added. + // (The original code had these reversed.) if (old is not null && old.Length > 0) DiffRemoval (chunk, old); if (@new is not null && @new.Length > 0) From 2512b809e424a504f84cf0cfda01d414dd0fc6f6 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 14:59:44 +0200 Subject: [PATCH 20/25] Don't pass nullability bytes for array element type in NSArray.FromArray The element type for wrapped arrays is a simple non-generic type (e.g. NSObject), so FormatType ignores the bytes anyway. Passing them is misleading and would produce incorrect byte indexing if the element type were ever generic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index 37ea28183a74..06c2b81fc15b 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -4058,7 +4058,7 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields print ("return src is null ? null! : new {0}(src);", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes)); } else { if (TypeManager.IsArrayOfWrappedType (pi.PropertyType)) - print ("return NSArray.FromArray<{0}>({1} as NSArray){2};", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType.GetElementType (), wrapNullabilityBytes), wrap, nullable ? "" : "!"); + print ("return NSArray.FromArray<{0}>({1} as NSArray){2};", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType.GetElementType ()), wrap, nullable ? "" : "!"); else if (pi.PropertyType.IsValueType) print ("return ({0}) ({1});", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes), wrap); else From 660de9c8b3539f2bedf6fbec8fc631c23ff81ce1 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 18:12:27 +0200 Subject: [PATCH 21/25] Improve DiffModification comment to explain the original bug Clarify exactly what the original code did wrong (called DiffAddition for 'old' and DiffRemoval for 'new'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/api-tools/mono-api-html/MarkdownFormatter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/api-tools/mono-api-html/MarkdownFormatter.cs b/tools/api-tools/mono-api-html/MarkdownFormatter.cs index ece3b3727e5a..ab2d7d6a9ac9 100644 --- a/tools/api-tools/mono-api-html/MarkdownFormatter.cs +++ b/tools/api-tools/mono-api-html/MarkdownFormatter.cs @@ -206,8 +206,9 @@ public override void DiffAddition (TextChunk chunk, string text) public override void DiffModification (TextChunk chunk, string old, string @new) { - // The 'old' text is what's being removed, and 'new' is what's being added. - // (The original code had these reversed.) + // The 'old' text is what's being removed (wrap in ---), and 'new' is + // what's being added (wrap in +++). The original code incorrectly called + // DiffAddition for 'old' and DiffRemoval for 'new'. if (old is not null && old.Length > 0) DiffRemoval (chunk, old); if (@new is not null && @new.Length > 0) From 5b6276fd71c5ce0e9ae0fd23fff94bd65ee71f49 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 19:09:24 +0200 Subject: [PATCH 22/25] Propagate nullability to async wrapper return types For [Async] methods with completion handlers like Action, the generated async wrapper now returns Task instead of Task. Store the nullability byte slice for each non-NSError completion parameter in AsyncMethodInfo.CompletionParamNullabilityBytes, and use it in GetAsyncTaskType to format the return type with the correct nullability annotation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 18 ++++++++-- src/bgen/Models/AsyncMethodInfo.cs | 35 ++++++++++++++++++-- tests/bgen/BGenTests.cs | 14 ++++++-- tests/bgen/tests/generic-type-nullability.cs | 20 +++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index 06c2b81fc15b..c5132b4336e2 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -4302,8 +4302,22 @@ string GetReturnType (AsyncMethodInfo minfo) HashSet? reported1077; string GetAsyncTaskType (AsyncMethodInfo minfo) { - if (minfo.IsSingleArgAsync) - return TypeManager.FormatType (minfo.type, minfo.AsyncCompletionParams [0].ParameterType); + if (minfo.IsSingleArgAsync) { + var paramType = minfo.AsyncCompletionParams [0].ParameterType; + var paramBytes = minfo.CompletionParamNullabilityBytes? [0]; + if (paramBytes is not null && !paramType.IsValueType) { + // Format with nullability for inner generic args + var formatted = TypeManager.FormatType (minfo.type, paramType, paramBytes); + // Check byte 0 of the slice for the param's own nullability + bool isParamNullable = paramBytes.Length == 1 + ? paramBytes [0] == 2 + : (paramBytes.Length > 0 && paramBytes [0] == 2); + if (isParamNullable) + formatted += "?"; + return formatted; + } + return TypeManager.FormatType (minfo.type, paramType); + } var attr = AttributeManager.GetOneCustomAttribute (minfo.mi); if (attr.ResultTypeName is not null) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index 25cecd420f90..13572da0cd49 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -12,6 +12,11 @@ class AsyncMethodInfo : MemberInformation { public bool IsVoidAsync { get; } public bool IsSingleArgAsync { get; } public MethodInfo MethodInfo { get; } + // Nullability bytes for each non-NSError completion parameter's type subtree. + // Each entry is a byte slice starting at that parameter's position in the + // delegate's NullableAttribute array (byte 0 = the param's own nullability). + // Null if nullability info is unavailable. + public byte []?[]? CompletionParamNullabilityBytes { get; } public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, MethodInfo mi, Type? categoryExtensionType, bool isExtensionMethod) : base (generator, gather, mi, type, categoryExtensionType, false, isExtensionMethod) @@ -26,6 +31,10 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, AsyncCompletionParams = cbParams; var lastParam = cbParams.LastOrDefault (); + var outerParam = mi.GetParameters ().Last (); + var genericArgs = lastType.GetGenericArguments (); + var nullabilityBytes = genericArgs.Length > 0 ? generator.AttributeManager.GetNullabilityBytes (outerParam) : null; + if (lastParam is not null && lastParam.ParameterType.Name == "NSError") { HasNSError = true; // The nullability info for generic type arguments is encoded in the NullableAttribute @@ -35,9 +44,6 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, // This only applies to generic delegate types (Action<...>). For non-generic delegates, // the NullableAttribute on the outer parameter describes the delegate instance, not // the Invoke parameters. - var outerParam = mi.GetParameters ().Last (); - var genericArgs = lastType.GetGenericArguments (); - var nullabilityBytes = genericArgs.Length > 0 ? generator.AttributeManager.GetNullabilityBytes (outerParam) : null; if (nullabilityBytes is not null && nullabilityBytes.Length == 1) { // Single-byte (uniform) form: the same byte applies to all positions IsNSErrorNullable = nullabilityBytes [0] == 2; @@ -62,6 +68,29 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, IsVoidAsync = cbParams.Length == 0; IsSingleArgAsync = cbParams.Length == 1; + + // Compute nullability byte slices for each non-NSError completion param + if (nullabilityBytes is not null && genericArgs.Length > 0) { + var nonErrorArgCount = HasNSError ? genericArgs.Length - 1 : genericArgs.Length; + if (nonErrorArgCount > 0) { + CompletionParamNullabilityBytes = new byte [nonErrorArgCount][]; + if (nullabilityBytes.Length == 1) { + // Single-byte (uniform): every param gets the same byte + for (int i = 0; i < nonErrorArgCount; i++) + CompletionParamNullabilityBytes [i] = nullabilityBytes; + } else { + // Multi-byte: extract slices for each generic arg + int byteIndex = 1; // skip byte 0 (the Action<> itself) + for (int i = 0; i < nonErrorArgCount; i++) { + int paramByteCount = CountNullabilityBytes (genericArgs [i]); + var slice = new byte [paramByteCount]; + Array.Copy (nullabilityBytes, byteIndex, slice, 0, paramByteCount); + CompletionParamNullabilityBytes [i] = slice; + byteIndex += paramByteCount; + } + } + } + } } public string GetUniqueParamName (string suggestion) diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index 7f36553a4d5b..7ae378daa32a 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1693,8 +1693,18 @@ public void GenericTypeNullability (Profile profile) // Async method with array arg before NSError (depth-first byte counting) Assert.That (contents, Does.Contain ("Action"), "FetchItems should have nullable array and NSError"); - // When NSError is nullable, async uses Task with error→exception; the result type is the non-error arg - Assert.That (contents, Does.Contain ("Task"), "FetchItems async should return Task (nullable NSError triggers error handling)"); + // When NSError is nullable, async uses Task with error→exception; the result type preserves nullability + Assert.That (contents, Does.Contain ("Task"), "FetchItems async should return Task (array nullability preserved)"); + + // Async method with nullable result type + Assert.That (contents, Does.Contain ("Task"), "LoadData async should return Task"); + // Async method with non-nullable result type + Assert.That (contents, Does.Match (@"Task\s"), "LoadDataNonNull async should return Task"); + + // Async method with nullable array result type + Assert.That (contents, Does.Contain ("Task"), "LoadItems async should return Task"); + // Async method with non-nullable array result type + Assert.That (contents, Does.Match (@"Task\s"), "LoadItemsNonNull async should return Task"); } [Test] diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs index b49894e9b2f9..7e626563e194 100644 --- a/tests/bgen/tests/generic-type-nullability.cs +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -88,5 +88,25 @@ interface Widget { [Async] [Export ("fetchItems:completionHandler:")] void FetchItems (NSObject obj, Action completionHandler); + + // Async method with nullable result type (single arg, NSError triggers exception pattern) + [Async] + [Export ("loadData:completionHandler:")] + void LoadData (NSObject obj, Action completionHandler); + + // Async method with non-nullable result type + [Async] + [Export ("loadDataNonNull:completionHandler:")] + void LoadDataNonNull (NSObject obj, Action completionHandler); + + // Async method with nullable array result type + [Async] + [Export ("loadItems:completionHandler:")] + void LoadItems (NSObject obj, Action completionHandler); + + // Async method with non-nullable array result type + [Async] + [Export ("loadItemsNonNull:completionHandler:")] + void LoadItemsNonNull (NSObject obj, Action completionHandler); } } From db05a77bcb8f5c95f093bcf00597ad08520828a8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Autoformatter Date: Thu, 25 Jun 2026 17:19:02 +0000 Subject: [PATCH 23/25] Auto-format source code --- src/bgen/Models/AsyncMethodInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index 13572da0cd49..5b08bc54bede 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -16,7 +16,7 @@ class AsyncMethodInfo : MemberInformation { // Each entry is a byte slice starting at that parameter's position in the // delegate's NullableAttribute array (byte 0 = the param's own nullability). // Null if nullability info is unavailable. - public byte []?[]? CompletionParamNullabilityBytes { get; } + public byte []? []? CompletionParamNullabilityBytes { get; } public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, MethodInfo mi, Type? categoryExtensionType, bool isExtensionMethod) : base (generator, gather, mi, type, categoryExtensionType, false, isExtensionMethod) @@ -73,7 +73,7 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, if (nullabilityBytes is not null && genericArgs.Length > 0) { var nonErrorArgCount = HasNSError ? genericArgs.Length - 1 : genericArgs.Length; if (nonErrorArgCount > 0) { - CompletionParamNullabilityBytes = new byte [nonErrorArgCount][]; + CompletionParamNullabilityBytes = new byte [nonErrorArgCount] []; if (nullabilityBytes.Length == 1) { // Single-byte (uniform): every param gets the same byte for (int i = 0; i < nonErrorArgCount; i++) From b0da7e8205ab4ba0683b9bb4f78441775bbd0ad6 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 19:26:00 +0200 Subject: [PATCH 24/25] Add bounds check for nullability byte slicing; simplify null check Add a bounds check before Array.Copy to gracefully handle malformed or truncated NullableAttribute arrays. Simplify the isParamNullable check since both branches were equivalent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 5 +---- src/bgen/Models/AsyncMethodInfo.cs | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index c5132b4336e2..566c94f8973e 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -4309,10 +4309,7 @@ string GetAsyncTaskType (AsyncMethodInfo minfo) // Format with nullability for inner generic args var formatted = TypeManager.FormatType (minfo.type, paramType, paramBytes); // Check byte 0 of the slice for the param's own nullability - bool isParamNullable = paramBytes.Length == 1 - ? paramBytes [0] == 2 - : (paramBytes.Length > 0 && paramBytes [0] == 2); - if (isParamNullable) + if (paramBytes [0] == 2) formatted += "?"; return formatted; } diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index 5b08bc54bede..8552f874f8e4 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -83,6 +83,8 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, int byteIndex = 1; // skip byte 0 (the Action<> itself) for (int i = 0; i < nonErrorArgCount; i++) { int paramByteCount = CountNullabilityBytes (genericArgs [i]); + if (byteIndex + paramByteCount > nullabilityBytes.Length) + break; // malformed or truncated attribute, skip remaining var slice = new byte [paramByteCount]; Array.Copy (nullabilityBytes, byteIndex, slice, 0, paramByteCount); CompletionParamNullabilityBytes [i] = slice; From 0839cabaa4a8b8a33853083010fc27b03793e6e8 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 25 Jun 2026 19:53:24 +0200 Subject: [PATCH 25/25] Fix comments to describe the actual void-return-type check The comments said 'contravariant generic type parameters' but the code checks whether Invoke returns void. Updated to match reality. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bgen/Generator.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index 566c94f8973e..c3b8ca924f9f 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -2927,9 +2927,9 @@ public void MakeSignatureFromParameterInfo (bool comma, StringBuilder sb, Member if (!bt.IsValueType && AttributeManager.IsNullable (pi)) sb.Append ('?'); } else { - // Only apply nullability bytes for delegate types whose generic type parameters - // are all contravariant (Action<> variants). Func<> types have a covariant TResult - // which creates a type mismatch with the trampoline's CreateNullableBlock signature. + // Only apply nullability bytes for void-returning delegate types (Action<> variants). + // Func<> types have a covariant TResult which creates a type mismatch with the + // trampoline's CreateNullableBlock signature. byte []? nullabilityBytes = null; if (parType.IsSubclassOf (TypeCache.System_Delegate)) { var invokeMethod = parType.GetMethod ("Invoke"); @@ -4127,9 +4127,9 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields // it remains nullable only if the BindAs type can be null (i.e. a reference type) nullable = !bindAsAttrib.Type.IsValueType && AttributeManager.IsNullable (pi); } else { - // Only apply nullability bytes for delegate types whose generic type parameters - // are all contravariant (Action<> variants). Func<> types have a covariant TResult - // which creates a type mismatch with the trampoline's CreateNullableBlock signature. + // Only apply nullability bytes for void-returning delegate types (Action<> variants). + // Func<> types have a covariant TResult which creates a type mismatch with the + // trampoline's CreateNullableBlock signature. byte []? nullabilityBytes = null; if (pi.PropertyType.IsSubclassOf (TypeCache.System_Delegate)) { var invokeMethod = pi.PropertyType.GetMethod ("Invoke");