diff --git a/Jint.Tests/Runtime/FunctionNameForMessageTests.cs b/Jint.Tests/Runtime/FunctionNameForMessageTests.cs
new file mode 100644
index 0000000000..2360b74712
--- /dev/null
+++ b/Jint.Tests/Runtime/FunctionNameForMessageTests.cs
@@ -0,0 +1,36 @@
+using Jint.Native.Function;
+
+namespace Jint.Tests.Runtime;
+
+///
+/// exists to render a name inside an error message
+/// without running script — so it must never throw itself. A script function's own name starts as
+/// the shared pending-lazy sentinel, whose value accessors throw by design to make a leak loud; reading
+/// the descriptor's Value before anything materialized the property is exactly such a leak.
+///
+public class FunctionNameForMessageTests
+{
+ [Fact]
+ public void APendingLazyNameFallsBackToTheClrTypeNameInsteadOfThrowing()
+ {
+ using var engine = new Engine();
+
+ // The function's own "name" property exists from birth, but its descriptor is the pending
+ // sentinel until something reads the property — which nothing here does.
+ var function = (Function) engine.Evaluate("(function foo() {})");
+
+ var name = function.GetOwnFunctionNameForMessage();
+
+ Assert.Equal(function.GetType().Name, name);
+ }
+
+ [Fact]
+ public void AMaterializedNameIsQuoted()
+ {
+ using var engine = new Engine();
+
+ var function = (Function) engine.Evaluate("const f = function foo() {}; void f.name; f");
+
+ Assert.Equal("foo", function.GetOwnFunctionNameForMessage());
+ }
+}
diff --git a/Jint/Native/Function/Function.cs b/Jint/Native/Function/Function.cs
index e835394020..172344b5e2 100644
--- a/Jint/Native/Function/Function.cs
+++ b/Jint/Native/Function/Function.cs
@@ -663,13 +663,22 @@ internal string GetOwnFunctionName()
/// toString throws. coerces through TypeConverter.ToString
/// and would let that object hijack the error being built — an extra observable call, and a thrown
/// value replacing the TypeError the caller meant to raise. So only an actual string is quoted
- /// here; anything else falls back to the CLR type's name, which no script can influence. Reading
- /// touches the raw field and never invokes an accessor.
+ /// here; anything else falls back to the CLR type's name, which no script can influence. A descriptor
+ /// carrying is skipped for the same reason: its
+ /// routes through the CustomValue accessor rather than the
+ /// raw field, and one of those accessors — the shared pending-lazy sentinel a script function's name
+ /// starts with — throws by design. An error-message path must never introduce a throw of its own.
///
///
internal string GetOwnFunctionNameForMessage()
{
- return _nameDescriptor?.Value is JsString name ? name.ToString() : GetType().Name;
+ var descriptor = _nameDescriptor;
+ if (descriptor is null || (descriptor.Flags & PropertyFlag.CustomJsValue) != PropertyFlag.None)
+ {
+ return GetType().Name;
+ }
+
+ return descriptor.Value is JsString name ? name.ToString() : GetType().Name;
}
///