diff --git a/extension-host/include/sqlextensionhostcallbacks.h b/extension-host/include/sqlextensionhostcallbacks.h new file mode 100644 index 00000000..d0fbf7a1 --- /dev/null +++ b/extension-host/include/sqlextensionhostcallbacks.h @@ -0,0 +1,79 @@ +//********************************************************************* +// Copyright (c) Microsoft Corporation. +// +// Host callbacks protocol between the host and extensions. +// Optional API surface introduced alongside v3 of the Extension API. +// +// @File: sqlextensionhostcallbacks.h +// +//********************************************************************* + +#ifndef __SQLEXTENSIONHOSTCALLBACKS +#define __SQLEXTENSIONHOSTCALLBACKS + +#include "sqlexternallanguage.h" +#include "sqlextensiontracelevel.h" + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +// Callback function provided by the host for logging XEvents from Extension. +// +typedef void (*PFunc_ExtensionLogXEvent)( + const SQLCHAR *ExtensionName, + SQLULEN ExtensionNameLength, + SQLGUID SessionId, + SQLUSMALLINT TaskId, + SQLUSMALLINT TraceLevel, + SQLINTEGER ErrorCode, + const SQLCHAR *Message, + SQLULEN MessageLength + ); + +// Host callbacks structure passed from host to Extension. +// +#define SQLEXTENSION_HOST_CALLBACKS_VERSION_1 1 +#define SQLEXTENSION_HOST_CALLBACKS_MIN_SUPPORTED_VERSION SQLEXTENSION_HOST_CALLBACKS_VERSION_1 + +typedef struct _SQLEXTENSION_HOST_CALLBACKS +{ + // Highest SQLEXTENSION_HOST_CALLBACKS_VERSION_* the host populates. + // Extension must validate before reading version-gated fields. + // + SQLUSMALLINT Version; + + // Explicit padding so SizeInBytes is naturally 4-byte aligned regardless + // of compiler packing settings. Must be zero. + // + SQLUSMALLINT Reserved0; + + // sizeof(SQLEXTENSION_HOST_CALLBACKS) as the host saw it at build time. + // Extension must validate this is greater or equal the size of every field it intends + // to read. Lets a newer Extension safely run against an older host that supplied a smaller struct. + // + SQLUINTEGER SizeInBytes; + + // Version 1 callbacks. + // + PFunc_ExtensionLogXEvent LogXEvent; + + // Reserved for future expansion. Zero-initialized by the host. Extension must not read or call these. + // + void *Reserved1; + void *Reserved2; +} SQLEXTENSION_HOST_CALLBACKS; + +// Optional API (v3+) +// Receives host callback functions from the host. +// +SQLEXTENSION_INTERFACE +SQLRETURN SetHostCallbacks( + SQLEXTENSION_HOST_CALLBACKS *Callbacks + ); + +#ifdef __cplusplus +} /* End of extern "C" { */ +#endif/* __cplusplus */ + +#endif/* __SQLEXTENSIONHOSTCALLBACKS */ diff --git a/extension-host/include/sqlextensiontracelevel.h b/extension-host/include/sqlextensiontracelevel.h new file mode 100644 index 00000000..0afdcd01 --- /dev/null +++ b/extension-host/include/sqlextensiontracelevel.h @@ -0,0 +1,26 @@ +//********************************************************************* +// Copyright (c) Microsoft Corporation. +// +// ExtensionTraceLevel enum shared between the host and extensions. +// +// @File: sqlextensiontracelevel.h +// +//********************************************************************* + +#ifndef __SQLEXTENSIONTRACELEVEL +#define __SQLEXTENSIONTRACELEVEL + +// Trace levels for events logged via the LogXEvent host callback. +// Lowest numeric value is the most severe, matching the Windows ETW +// TRACE_LEVEL_* convention. +// +enum ExtensionTraceLevel +{ + Extension_Critical = 1, + Extension_Error = 2, + Extension_Warning = 3, + Extension_Information = 4, + Extension_Verbose = 5 +}; + +#endif /* __SQLEXTENSIONTRACELEVEL */ diff --git a/extension-host/include/sqlexternallanguage.h b/extension-host/include/sqlexternallanguage.h index 0709e39b..57f3df24 100644 --- a/extension-host/include/sqlexternallanguage.h +++ b/extension-host/include/sqlexternallanguage.h @@ -28,7 +28,7 @@ extern "C" { #endif /* __cplusplus */ -#define EXTERNAL_LANGUAGE_EXTENSION_API 2 +#define EXTERNAL_LANGUAGE_EXTENSION_API 3 SQLEXTENSION_INTERFACE SQLUSMALLINT GetInterfaceVersion(); diff --git a/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h b/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h index 8a1a3412..15872d79 100644 --- a/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h +++ b/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h @@ -24,6 +24,7 @@ #include #include "sqlexternallanguage.h" #include "sqlexternallibrary.h" +#include "sqlextensionhostcallbacks.h" #include "DotnetEnvironment.h" // Returns the string with the address of the first character and length @@ -142,6 +143,17 @@ SQLEXTENSION_INTERFACE SQLRETURN CleanupSession(SQLGUID sessionId, SQLUSMALLINT // SQLEXTENSION_INTERFACE SQLRETURN Cleanup(); -// Dotnet environment pointer +// Receives host callback function pointers from the host. +// Optional API, supported since v3 of the Extension API. // -static DotnetEnvironment* g_dotnet_runtime = nullptr; \ No newline at end of file +SQLEXTENSION_INTERFACE SQLRETURN SetHostCallbacks( + SQLEXTENSION_HOST_CALLBACKS *hostCallbacks); + +// Dotnet environment pointer. Defined in nativecsharpextension.cpp. +// +extern DotnetEnvironment* g_dotnet_runtime; + +// Host callbacks pointer provided by the host via SetHostCallbacks. +// Defined in nativecsharpextension.cpp. +// +extern SQLEXTENSION_HOST_CALLBACKS* g_hostCallbacks; \ No newline at end of file diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs index 0f3be1f3..6ddf73be 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs @@ -601,6 +601,105 @@ public static short GetOutputParam( }); } + /// + /// Delegate type matching the host's LogXEvent callback signature. + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void LogXEventCallbackDelegate( + byte *extensionName, + ulong extensionNameLength, + Guid sessionId, + ushort taskId, + ushort traceLevel, + int errorCode, + byte *message, + ulong messageLength); + + /// + /// Managed representation of the SQLEXTENSION_HOST_CALLBACKS structure. + /// Must match the native struct layout exactly. + /// + [StructLayout(LayoutKind.Sequential)] + public struct SqlExtensionHostCallbacks + { + public ushort Version; + public IntPtr LogXEvent; + } + + /// + /// Minimal SQLEXTENSION_HOST_CALLBACKS version this extension understands. + /// If host callbacks version is greater than this, extension will still parse and read + /// known host callbacks and ignore unknown fields, allowing forward compatibility. + /// + /// + private const ushort MinSupportedHostCallbacksVersion = 1; + + /// + /// This delegate declares the delegate type of SetHostCallbacks. + /// + public delegate short SetHostCallbacksDelegate( + SqlExtensionHostCallbacks *hostCallbacks); + + /// + /// This method implements SetHostCallbacks API. + /// Receives a pointer to the host callbacks structure, reads the callback + /// function pointers during this call, and stores any needed managed + /// delegates so managed code can call back into the host. + /// + /// + /// Pointer to the SQLEXTENSION_HOST_CALLBACKS structure provided by the host. + /// + /// + /// SQL_SUCCESS(0), SQL_ERROR(-1) + /// + public static short SetHostCallbacks( + SqlExtensionHostCallbacks *hostCallbacks) + { + Logging.Trace("CSharpExtension::SetHostCallbacks"); + return ExceptionUtils.WrapError(() => + { + if (hostCallbacks == null) + { + throw new ArgumentNullException(nameof(hostCallbacks)); + } + + // Validate the struct version before reading any version-gated fields. + // + if (hostCallbacks->Version < MinSupportedHostCallbacksVersion) + { + Logging.Error( + "CSharpExtension::SetHostCallbacks: unsupported host callbacks version: " + + hostCallbacks->Version); + + throw new NotSupportedException( + "Unsupported SQLEXTENSION_HOST_CALLBACKS version: " + + hostCallbacks->Version); + } + + if (hostCallbacks->LogXEvent != IntPtr.Zero) + { + var logXEvent = Marshal.GetDelegateForFunctionPointer( + hostCallbacks->LogXEvent); + Logging.SetLogXEventCallback(logXEvent); + + Logging.LogXEvent( + extensionName: null, + Guid.Empty, + taskId: 0, + traceLevel: Logging.TraceLevel.Information, + errorCode: 0, + "CSharp extension loaded, host callbacks registered (version " + hostCallbacks->Version + ")"); + } + else + { + // Host opted out of XEvent logging. + // Clear any previously stored delegate. + // + Logging.SetLogXEventCallback(null); + } + }); + } + /// /// This delegate declares the delegate type of CleanupSession. /// diff --git a/language-extensions/dotnet-core-CSharp/src/managed/utils/Logging.cs b/language-extensions/dotnet-core-CSharp/src/managed/utils/Logging.cs index bdc36b9f..4dc95996 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/utils/Logging.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/utils/Logging.cs @@ -30,6 +30,13 @@ class Logging /// private const int StdErr = 2; + /// + /// Host-provided LogXEvent callback, set via SetHostCallbacks. + /// Marked volatile so concurrent readers in LogXEvent always + /// observe the latest write from SetLogXEventCallback. + /// + private static volatile CSharpExtension.LogXEventCallbackDelegate _logXEventCallback; + /// /// Static constructor to initialize the custom text writers for stdout and stderr. /// This ensures that any Console.WriteLine or Console.Error.WriteLine calls @@ -74,6 +81,116 @@ public static void Error(string message) } } + /// + /// Stores the host-provided LogXEvent callback for later use. + /// Called from CSharpExtension.SetHostCallbacks. + /// + /// The delegate wrapping the host's LogXEvent function pointer. + public static void SetLogXEventCallback(CSharpExtension.LogXEventCallbackDelegate callback) + { + _logXEventCallback = callback; + } + + /// + /// Returns true if the host has provided a LogXEvent callback. + /// + public static bool HasLogXEventCallback => _logXEventCallback != null; + + /// + /// Trace levels for events logged via the host's LogXEvent callback. + /// Lowest numeric value is the most severe, matching Windows ETW TRACE_LEVEL_* convention. + /// + public enum TraceLevel : ushort + { + Critical = 1, + Error = 2, + Warning = 3, + Information = 4, + Verbose = 5, + } + + /// + /// Default name of the Extension to be used for XEvent logging when + /// the caller does not supply one. + /// + private const string DefaultExtensionName = "CSharp"; + + /// + /// Logs a message through the host's XEvent infrastructure. + /// If no host callback is registered, this is a no-op. + /// + /// Extension name. + /// Session GUID. + /// Task identifier. + /// Trace severity. + /// Error code for non-informational logs. + /// The message to log. + public static unsafe void LogXEvent( + string extensionName, + Guid sessionId, + ushort taskId, + TraceLevel traceLevel, + int errorCode, + string message) + { + // Snapshot the callback once so a concurrent cleanup between the null-check and the invocation + // cannot turn this into a NullReferenceException. + // + CSharpExtension.LogXEventCallbackDelegate callback = _logXEventCallback; + if (callback == null) + { + return; + } + + // Ensure message is not null to avoid issues during UTF-8 encoding. + string safeMessage = message ?? string.Empty; + + // Convert the message to a UTF-8 byte array for native interop. + byte[] utf8MessageBytes = Encoding.UTF8.GetBytes(safeMessage); + + // Capture the real byte length. + ulong messageLen = (ulong)utf8MessageBytes.Length; + + // As `fixed` on a zero-length array yields a null pointer, + // validate that the byte array is not empty. + // + if (utf8MessageBytes.Length == 0) + { + utf8MessageBytes = new byte[] { 0 }; + } + + // Use the caller-supplied Extension name when non-empty, + // otherwise fall back to the default value. + // + string safeExtensionName = string.IsNullOrEmpty(extensionName) + ? DefaultExtensionName + : extensionName; + byte[] utf8ExtNameBytes = Encoding.UTF8.GetBytes(safeExtensionName); + ulong extNameLen = (ulong)utf8ExtNameBytes.Length; + + // Call the host's LogXEvent callback with the prepared parameters. + try + { + fixed (byte* pExtName = utf8ExtNameBytes) + fixed (byte* pMessage = utf8MessageBytes) + { + callback( + pExtName, + extNameLen, + sessionId, + taskId, + (ushort)traceLevel, + errorCode, + pMessage, + messageLen); + } + } + catch (Exception ex) + { + Debug.WriteLine($"LogXEvent host callback threw: {ex}"); + } + } + /// /// Custom TextWriter that writes directly to the standard output/error streams /// using native system calls. This is required because the standard Console diff --git a/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp b/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp index b70831d7..c501234d 100644 --- a/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp +++ b/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp @@ -13,6 +13,18 @@ #define nameof(x) #x +// Single definitions for the globals declared (extern) in the header. +// +DotnetEnvironment* g_dotnet_runtime = nullptr; +SQLEXTENSION_HOST_CALLBACKS* g_hostCallbacks = nullptr; + +// g_hostCallbacks points at internal copy of the host's callbacks +// struct g_hostCallbacksCopy rather than the caller-owned memory, so the +// pointer can never dangle if the host passed a stack-allocated struct to +// SetHostCallbacks. +// +static SQLEXTENSION_HOST_CALLBACKS g_hostCallbacksCopy = {}; + //-------------------------------------------------------------------------------------------------- // Name: UTF8PtrToStr // @@ -338,6 +350,73 @@ SQLRETURN CleanupSession(SQLGUID sessionId, SQLUSMALLINT taskId) SQLRETURN Cleanup() { LOG("nativecsharpextension::Cleanup"); + + // Clear the managed-side callbacks delegates before tearing down the + // runtime so any thread that races into callbacks during shutdown + // cannot invoke a host function pointer whose backing implementation is + // about to be freed. + // + if (g_dotnet_runtime != nullptr) + { + SQLEXTENSION_HOST_CALLBACKS nullCallbacks = {}; + nullCallbacks.Version = SQLEXTENSION_HOST_CALLBACKS_VERSION_1; + nullCallbacks.LogXEvent = nullptr; + + g_dotnet_runtime->call_managed_method( + nameof(SetHostCallbacks), + &nullCallbacks); + } + + g_hostCallbacks = nullptr; delete g_dotnet_runtime; + g_dotnet_runtime = nullptr; return SQL_SUCCESS; +} + +//-------------------------------------------------------------------------------------------------- +// Name: SetHostCallbacks +// +// Description: +// Receives a pointer to the host callbacks structure. +// Stores the pointer natively and forwards to managed code so the +// managed layer can call back into the host. +// +// Returns: +// SQL_SUCCESS on success, else SQL_ERROR +// +SQLRETURN SetHostCallbacks( + SQLEXTENSION_HOST_CALLBACKS *hostCallbacks +) +{ + LOG("nativecsharpextension::SetHostCallbacks"); + + if (hostCallbacks == nullptr) + { + LOG_ERROR("SetHostCallbacks called with null pointer"); + return SQL_ERROR; + } + + if (g_dotnet_runtime == nullptr) + { + LOG_ERROR("SetHostCallbacks called before Init() or after Cleanup()"); + return SQL_ERROR; + } + + // Validate the struct version before reading any version-gated fields. + // + if (hostCallbacks->Version < SQLEXTENSION_HOST_CALLBACKS_MIN_SUPPORTED_VERSION) + { + LOG_ERROR("SetHostCallbacks called with unsupported host callbacks version"); + return SQL_ERROR; + } + + // Take a shallow copy of the caller's struct so g_hostCallbacks cannot + // dangle if the host passed a stack-allocated SQLEXTENSION_HOST_CALLBACKS. + // + g_hostCallbacksCopy = *hostCallbacks; + g_hostCallbacks = &g_hostCallbacksCopy; + + return g_dotnet_runtime->call_managed_method( + nameof(SetHostCallbacks), + hostCallbacks); } \ No newline at end of file diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpSetHostCallbacksTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpSetHostCallbacksTests.cpp new file mode 100644 index 00000000..c2968d51 --- /dev/null +++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpSetHostCallbacksTests.cpp @@ -0,0 +1,231 @@ +//********************************************************************* +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// @File: CSharpSetHostCallbacksTests.cpp +// +// Purpose: +// Tests the optional SetHostCallbacks API exported by the .NET Core C# +// extension. Verifies symbol resolution, null-pointer handling, and that +// a non-null LogXEvent callback supplied via SQLEXTENSION_HOST_CALLBACKS +// is invoked by managed code. +// +//********************************************************************* +#include "CSharpExtensionApiTests.h" + +#include +#include +#include + +using namespace std; + +namespace ExtensionApiTest +{ + typedef SQLRETURN FN_setHostCallbacks(SQLEXTENSION_HOST_CALLBACKS *); + + namespace + { + // Captured invocation of the host LogXEvent callback. + // + struct CapturedLogEvent + { + string extensionName; + SQLUSMALLINT traceLevel; + SQLINTEGER errorCode; + string message; + }; + + // File-scope storage for events captured by TestLogXEventCallback. + // Cleared at the start of each test that uses it. + // + static vector g_capturedLogEvents; + + // Test stand-in for host's LogXEvent implementation. Records the + // invocation so the test can assert on its contents. + // + extern "C" void TestLogXEventCallback( + const SQLCHAR *extensionName, + SQLULEN extensionNameLength, + SQLGUID sessionId, + SQLUSMALLINT taskId, + SQLUSMALLINT traceLevel, + SQLINTEGER errorCode, + const SQLCHAR *message, + SQLULEN messageLength) + { + CapturedLogEvent ev; + if (extensionName != nullptr && extensionNameLength > 0) + { + ev.extensionName.assign( + reinterpret_cast(extensionName), + static_cast(extensionNameLength)); + } + ev.traceLevel = traceLevel; + ev.errorCode = errorCode; + if (message != nullptr && messageLength > 0) + { + ev.message.assign( + reinterpret_cast(message), + static_cast(messageLength)); + } + g_capturedLogEvents.push_back(std::move(ev)); + } + } + +#define RESOLVE_SET_HOST_CALLBACKS() \ + reinterpret_cast( \ + GetProcAddress(sm_libHandle, "SetHostCallbacks")) + + //---------------------------------------------------------------------------------------------- + // Name: SetHostCallbacks_SymbolIsExported + // + // Description: + // Verifies the optional SetHostCallbacks entry point is exported from + // nativecsharpextension so host can discover it via GetProcAddress. + // + TEST_F(CSharpExtensionApiTests, SetHostCallbacks_SymbolIsExported) + { + FN_setHostCallbacks *fn = RESOLVE_SET_HOST_CALLBACKS(); + EXPECT_NE(fn, nullptr) + << "SetHostCallbacks must be exported by the extension binary"; + } + + //---------------------------------------------------------------------------------------------- + // Name: SetHostCallbacks_NullPointerReturnsError + // + // Description: + // Passing a null SQLEXTENSION_HOST_CALLBACKS* must be rejected with + // SQL_ERROR rather than crashing the host. + // + TEST_F(CSharpExtensionApiTests, SetHostCallbacks_NullPointerReturnsError) + { + FN_setHostCallbacks *fn = RESOLVE_SET_HOST_CALLBACKS(); + ASSERT_NE(fn, nullptr); + + SQLRETURN rc = fn(nullptr); + EXPECT_EQ(rc, SQL_ERROR); + } + + //---------------------------------------------------------------------------------------------- + // Name: SetHostCallbacks_RegistersAndForwardsLogXEvent + // + // Description: + // Provides a non-null LogXEvent callback and verifies that managed + // SetHostCallbacks registers it and forwards an invocation back through + // it (the managed implementation emits an "extension loaded" XEvent + // immediately after registration), proving end-to-end callback wiring. + // + TEST_F(CSharpExtensionApiTests, SetHostCallbacks_RegistersAndForwardsLogXEvent) + { + FN_setHostCallbacks *fn = RESOLVE_SET_HOST_CALLBACKS(); + ASSERT_NE(fn, nullptr); + + g_capturedLogEvents.clear(); + + SQLEXTENSION_HOST_CALLBACKS hostCallbacks{}; + hostCallbacks.Version = SQLEXTENSION_HOST_CALLBACKS_VERSION_1; + hostCallbacks.LogXEvent = &TestLogXEventCallback; + + SQLRETURN rc = fn(&hostCallbacks); + EXPECT_EQ(rc, SQL_SUCCESS); + + ASSERT_FALSE(g_capturedLogEvents.empty()) + << "Managed SetHostCallbacks should invoke the host LogXEvent " + "callback at least once after registration"; + + const CapturedLogEvent &ev = g_capturedLogEvents.front(); + EXPECT_EQ(ev.extensionName, string("CSharp")); + EXPECT_EQ(ev.traceLevel, static_cast(Extension_Information)); + EXPECT_EQ(ev.errorCode, 0); + EXPECT_NE(ev.message.find("CSharp extension loaded"), string::npos) + << "Unexpected message: " << ev.message; + } + + //---------------------------------------------------------------------------------------------- + // Name: SetHostCallbacks_NullLogXEventIsAccepted + // + // Description: + // A callbacks struct with a null LogXEvent slot is valid (host opted out + // of logging). SetHostCallbacks must succeed and must not invoke any + // callback. + // + TEST_F(CSharpExtensionApiTests, SetHostCallbacks_NullLogXEventIsAccepted) + { + FN_setHostCallbacks *fn = RESOLVE_SET_HOST_CALLBACKS(); + ASSERT_NE(fn, nullptr); + + g_capturedLogEvents.clear(); + + SQLEXTENSION_HOST_CALLBACKS hostCallbacks{}; + hostCallbacks.Version = SQLEXTENSION_HOST_CALLBACKS_VERSION_1; + hostCallbacks.LogXEvent = nullptr; + + SQLRETURN rc = fn(&hostCallbacks); + EXPECT_EQ(rc, SQL_SUCCESS); + EXPECT_TRUE(g_capturedLogEvents.empty()); + } + + //---------------------------------------------------------------------------------------------- + // Name: SetHostCallbacks_FutureVersionIsAccepted + // + // Description: + // A host that advertises a newer SQLEXTENSION_HOST_CALLBACKS version than + // the Extension was built against must still be accepted, preserving forward compatibility. + // The Extension is expected to read only the fields it and ignore the rest. + // + TEST_F(CSharpExtensionApiTests, SetHostCallbacks_FutureVersionIsAccepted) + { + FN_setHostCallbacks *fn = RESOLVE_SET_HOST_CALLBACKS(); + ASSERT_NE(fn, nullptr); + + g_capturedLogEvents.clear(); + + // Hypothetical "vNext" host callbacks struct. + // + typedef void (*PFunc_FutureCallback)(void); + struct FutureHostCallbacks + { + SQLUSMALLINT Version; + SQLUSMALLINT Reserved0; + SQLUINTEGER SizeInBytes; + PFunc_ExtensionLogXEvent LogXEvent; + void *Reserved1; + void *Reserved2; + PFunc_FutureCallback FutureCallback; + }; + + // Dummy v2 host callback. + // + static bool s_futureCallbackInvoked = false; + s_futureCallbackInvoked = false; + struct DummyFutureCallback + { + static void Invoke() { s_futureCallbackInvoked = true; } + }; + + // Populate rest of the fields in a host callbacks struct before invoking `SetHostCallbacks`. + // + FutureHostCallbacks hostCallbacks{}; + hostCallbacks.Version = SQLEXTENSION_HOST_CALLBACKS_VERSION_1 + 1; + hostCallbacks.SizeInBytes = sizeof(FutureHostCallbacks); + hostCallbacks.LogXEvent = &TestLogXEventCallback; + hostCallbacks.FutureCallback = &DummyFutureCallback::Invoke; + + SQLRETURN rc = fn(reinterpret_cast(&hostCallbacks)); + EXPECT_EQ(rc, SQL_SUCCESS) + << "Extension must accept newer host callback versions for " + "forward compatibility"; + + ASSERT_FALSE(g_capturedLogEvents.empty()) + << "Extension should still consume the known v1 LogXEvent slot " + "when host advertises a newer version"; + + const CapturedLogEvent &ev = g_capturedLogEvents.front(); + EXPECT_EQ(ev.extensionName, string("CSharp")); + EXPECT_EQ(ev.traceLevel, static_cast(Extension_Information)); + + EXPECT_FALSE(s_futureCallbackInvoked) + << "Extension built against v1 must not invoke unknown future " + "callbacks advertised by a newer host"; + } +}