From 071cb82ff5e5d407da96b426d37d56820e420e47 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:38:28 -0500 Subject: [PATCH 01/12] Add managed environment native ABI --- native/managed_environment_glue.c | 337 ++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 native/managed_environment_glue.c diff --git a/native/managed_environment_glue.c b/native/managed_environment_glue.c new file mode 100644 index 00000000..fe3c3e3e --- /dev/null +++ b/native/managed_environment_glue.c @@ -0,0 +1,337 @@ +#include "portapy.h" + +#include +#include +#include +#include + + +typedef struct managed_callback_entry { + uint64_t callable_id; + portapy_environment_callback callback; + void *context; +} managed_callback_entry; + +struct portapy_environment { + portapy_runtime runtime; + managed_callback_entry *callbacks; + size_t callback_count; + size_t callback_capacity; + uint64_t next_callable_id; +}; + + +static portapy_status utf16_to_utf8( + const uint16_t *text, + size_t length, + uint8_t **out_data, + size_t *out_size +) { + if (out_data == NULL || out_size == NULL || (length != 0 && text == NULL)) { + return PORTAPY_INVALID_ARGUMENT; + } + *out_data = NULL; + *out_size = 0; + if (length > (SIZE_MAX - 1) / 3) { + return PORTAPY_INVALID_ARGUMENT; + } + + uint8_t *data = (uint8_t *)malloc(length * 3 + 1); + if (data == NULL) { + return PORTAPY_RUNTIME_ERROR; + } + + size_t source_index = 0; + size_t target_index = 0; + while (source_index < length) { + uint32_t codepoint = text[source_index++]; + if (codepoint >= 0xd800 && codepoint <= 0xdbff) { + if (source_index >= length) { + free(data); + return PORTAPY_INVALID_ARGUMENT; + } + uint32_t low = text[source_index++]; + if (low < 0xdc00 || low > 0xdfff) { + free(data); + return PORTAPY_INVALID_ARGUMENT; + } + codepoint = UINT32_C(0x10000) + + ((codepoint - UINT32_C(0xd800)) << 10) + + (low - UINT32_C(0xdc00)); + } else if (codepoint >= 0xdc00 && codepoint <= 0xdfff) { + free(data); + return PORTAPY_INVALID_ARGUMENT; + } + + if (codepoint <= UINT32_C(0x7f)) { + data[target_index++] = (uint8_t)codepoint; + } else if (codepoint <= UINT32_C(0x7ff)) { + data[target_index++] = (uint8_t)(UINT32_C(0xc0) | (codepoint >> 6)); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | (codepoint & UINT32_C(0x3f))); + } else if (codepoint <= UINT32_C(0xffff)) { + data[target_index++] = (uint8_t)(UINT32_C(0xe0) | (codepoint >> 12)); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | ((codepoint >> 6) & UINT32_C(0x3f))); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | (codepoint & UINT32_C(0x3f))); + } else { + data[target_index++] = (uint8_t)(UINT32_C(0xf0) | (codepoint >> 18)); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | ((codepoint >> 12) & UINT32_C(0x3f))); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | ((codepoint >> 6) & UINT32_C(0x3f))); + data[target_index++] = (uint8_t)(UINT32_C(0x80) | (codepoint & UINT32_C(0x3f))); + } + } + + data[target_index] = 0; + *out_data = data; + *out_size = target_index; + return PORTAPY_OK; +} + + +static managed_callback_entry *find_callback( + portapy_environment *environment, + uint64_t callable_id +) { + if (environment == NULL) { + return NULL; + } + for (size_t index = 0; index < environment->callback_count; ++index) { + if (environment->callbacks[index].callable_id == callable_id) { + return &environment->callbacks[index]; + } + } + return NULL; +} + + +static portapy_status PORTAPY_CALL dispatch_managed_callback( + void *context, + portapy_runtime runtime, + uint64_t callable_id, + const portapy_value *arguments, + size_t argument_count, + portapy_value *out_result +) { + portapy_environment *environment = (portapy_environment *)context; + managed_callback_entry *entry = find_callback(environment, callable_id); + if ( + environment == NULL + || environment->runtime != runtime + || entry == NULL + || entry->callback == NULL + || out_result == NULL + ) { + return PORTAPY_INVALID_ARGUMENT; + } + return entry->callback( + entry->context, + runtime, + arguments, + argument_count, + out_result + ); +} + + +static portapy_status reserve_callback(portapy_environment *environment) { + if (environment->callback_count < environment->callback_capacity) { + return PORTAPY_OK; + } + size_t next_capacity = environment->callback_capacity == 0 + ? 8 + : environment->callback_capacity * 2; + if (next_capacity < environment->callback_capacity) { + return PORTAPY_RUNTIME_ERROR; + } + managed_callback_entry *next = (managed_callback_entry *)realloc( + environment->callbacks, + next_capacity * sizeof(managed_callback_entry) + ); + if (next == NULL) { + return PORTAPY_RUNTIME_ERROR; + } + environment->callbacks = next; + environment->callback_capacity = next_capacity; + return PORTAPY_OK; +} + + +portapy_status PORTAPY_CALL portapy_environment_create( + portapy_environment **out_environment +) { + if (out_environment == NULL) { + return PORTAPY_INVALID_ARGUMENT; + } + *out_environment = NULL; + + portapy_status status = portapy_library_initialize(); + if (status != PORTAPY_OK) { + return status; + } + + portapy_environment *environment = (portapy_environment *)calloc( + 1, + sizeof(portapy_environment) + ); + if (environment == NULL) { + return PORTAPY_RUNTIME_ERROR; + } + environment->next_callable_id = UINT64_C(1); + + portapy_config config = {0}; + config.struct_size = sizeof(config); + config.abi_version = PORTAPY_ABI_VERSION; + status = portapy_runtime_create(&config, &environment->runtime); + if (status != PORTAPY_OK) { + free(environment); + return status; + } + + status = portapy_host_set_call_handler( + environment->runtime, + dispatch_managed_callback, + environment + ); + if (status != PORTAPY_OK) { + portapy_runtime_destroy(environment->runtime); + free(environment); + return status; + } + + *out_environment = environment; + return PORTAPY_OK; +} + + +portapy_status PORTAPY_CALL portapy_environment_destroy( + portapy_environment *environment +) { + if (environment == NULL) { + return PORTAPY_INVALID_ARGUMENT; + } + portapy_status handler_status = portapy_host_set_call_handler( + environment->runtime, + NULL, + NULL + ); + portapy_status destroy_status = portapy_runtime_destroy(environment->runtime); + free(environment->callbacks); + free(environment); + if (destroy_status != PORTAPY_OK) { + return destroy_status; + } + return handler_status; +} + + +portapy_runtime PORTAPY_CALL portapy_environment_get_runtime( + const portapy_environment *environment +) { + return environment == NULL ? PORTAPY_NULL_RUNTIME : environment->runtime; +} + + +portapy_status PORTAPY_CALL portapy_environment_add_callback_utf16( + portapy_environment *environment, + const uint16_t *name, + size_t name_length, + portapy_environment_callback callback, + void *context +) { + if (environment == NULL || callback == NULL || name_length == 0) { + return PORTAPY_INVALID_ARGUMENT; + } + + uint8_t *utf8_name = NULL; + size_t utf8_name_size = 0; + portapy_status status = utf16_to_utf8( + name, + name_length, + &utf8_name, + &utf8_name_size + ); + if (status != PORTAPY_OK) { + return status; + } + + status = reserve_callback(environment); + if (status != PORTAPY_OK) { + free(utf8_name); + return status; + } + + uint64_t callable_id = environment->next_callable_id++; + if (callable_id == 0) { + free(utf8_name); + return PORTAPY_RUNTIME_ERROR; + } + + portapy_value callable = PORTAPY_NULL_VALUE; + status = portapy_value_from_host_callable( + environment->runtime, + callable_id, + &callable + ); + if (status == PORTAPY_OK) { + status = portapy_set_global_utf8( + environment->runtime, + utf8_name, + utf8_name_size, + callable + ); + } + if (callable != PORTAPY_NULL_VALUE) { + portapy_status release_status = portapy_value_release( + environment->runtime, + callable + ); + if (status == PORTAPY_OK && release_status != PORTAPY_OK) { + status = release_status; + } + } + free(utf8_name); + if (status != PORTAPY_OK) { + return status; + } + + managed_callback_entry *entry = &environment->callbacks[ + environment->callback_count++ + ]; + entry->callable_id = callable_id; + entry->callback = callback; + entry->context = context; + return PORTAPY_OK; +} + + +portapy_status PORTAPY_CALL portapy_environment_execute_utf16( + portapy_environment *environment, + const uint16_t *source, + size_t source_length +) { + if (environment == NULL) { + return PORTAPY_INVALID_ARGUMENT; + } + + uint8_t *utf8_source = NULL; + size_t utf8_source_size = 0; + portapy_status status = utf16_to_utf8( + source, + source_length, + &utf8_source, + &utf8_source_size + ); + if (status != PORTAPY_OK) { + return status; + } + + static const uint8_t filename[] = ""; + status = portapy_exec_utf8( + environment->runtime, + utf8_source, + utf8_source_size, + filename, + sizeof(filename) - 1 + ); + free(utf8_source); + return status; +} From 9c07a2575b87cb3a0d72478273d88e64e3a1ad24 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:38:51 -0500 Subject: [PATCH 02/12] Add managed environment public header --- include/portapy_managed.h | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 include/portapy_managed.h diff --git a/include/portapy_managed.h b/include/portapy_managed.h new file mode 100644 index 00000000..107dbc99 --- /dev/null +++ b/include/portapy_managed.h @@ -0,0 +1,46 @@ +#ifndef PORTAPY_MANAGED_H +#define PORTAPY_MANAGED_H + +#include "portapy.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct portapy_environment portapy_environment; + +typedef portapy_status (PORTAPY_CALL *portapy_environment_callback)( + void *context, + portapy_runtime runtime, + const portapy_value *arguments, + size_t argument_count, + portapy_value *out_result +); + +PORTAPY_API portapy_status PORTAPY_CALL portapy_environment_create( + portapy_environment **out_environment +); +PORTAPY_API portapy_status PORTAPY_CALL portapy_environment_destroy( + portapy_environment *environment +); +PORTAPY_API portapy_runtime PORTAPY_CALL portapy_environment_get_runtime( + const portapy_environment *environment +); +PORTAPY_API portapy_status PORTAPY_CALL portapy_environment_add_callback_utf16( + portapy_environment *environment, + const uint16_t *name, + size_t name_length, + portapy_environment_callback callback, + void *context +); +PORTAPY_API portapy_status PORTAPY_CALL portapy_environment_execute_utf16( + portapy_environment *environment, + const uint16_t *source, + size_t source_length +); + +#ifdef __cplusplus +} +#endif + +#endif From df5cfd571b0e8b5e36a4c24bf76d70af1009d95f Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:40:04 -0500 Subject: [PATCH 03/12] Export managed environment ABI --- tools/native_surface.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/native_surface.py b/tools/native_surface.py index 2f2dad65..bab4ee56 100644 --- a/tools/native_surface.py +++ b/tools/native_surface.py @@ -53,6 +53,14 @@ "portapy_global_name_copy_utf8", ) +MANAGED_ENVIRONMENT_GLUE_EXPORTS = ( + "portapy_environment_create", + "portapy_environment_destroy", + "portapy_environment_get_runtime", + "portapy_environment_add_callback_utf16", + "portapy_environment_execute_utf16", +) + TUPLE_GLUE_EXPORTS = ( "portapy_value_from_tuple", "portapy_tuple_get_size", @@ -179,6 +187,7 @@ def public_exports( if host_calls: result += HOST_CALL_GLUE_EXPORTS result += ENVIRONMENT_GLUE_EXPORTS + result += MANAGED_ENVIRONMENT_GLUE_EXPORTS result += TUPLE_GLUE_EXPORTS result += DICT_GLUE_EXPORTS result += LIST_GLUE_EXPORTS From d44727b30d4c6d9fef361b9fecc944a7c97a5609 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:41:07 -0500 Subject: [PATCH 04/12] Compile managed environment ABI with environment glue --- native/environment_glue.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/native/environment_glue.c b/native/environment_glue.c index d7c20cdd..8d0c3529 100644 --- a/native/environment_glue.c +++ b/native/environment_glue.c @@ -123,3 +123,6 @@ portapy_status PORTAPY_CALL portapy_global_name_copy_utf8( } return PORTAPY_OK; } + +#include "portapy_managed.h" +#include "managed_environment_glue.c" From 024cda4dc9d0b81e766f2cfa33b411e825bb13c2 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:41:53 -0500 Subject: [PATCH 05/12] Add direct C# Environment wrapper --- bindings/csharp/PortaPy.cs | 218 +++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 bindings/csharp/PortaPy.cs diff --git a/bindings/csharp/PortaPy.cs b/bindings/csharp/PortaPy.cs new file mode 100644 index 00000000..20f2ca45 --- /dev/null +++ b/bindings/csharp/PortaPy.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; + +namespace PortaPy; + +public enum Status : int +{ + Ok = 0, + InvalidArgument = 1, + CompileError = 2, + RuntimeError = 3, + TypeError = 4, + NotFound = 5, + Closed = 6, + InvalidHandle = 7, + Interrupted = 8, + AbiMismatch = 9, +} + +public sealed class PortaPyException : Exception +{ + public Status Status { get; } + + public PortaPyException(Status status) + : base($"PortaPy failed with status {status} ({(int)status}).") + { + Status = status; + } +} + +public sealed class Environment : IDisposable +{ + private const string LibraryName = "portapy"; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate Status NativeCallback( + IntPtr context, + ulong runtime, + IntPtr arguments, + nuint argumentCount, + out ulong result + ); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern Status portapy_environment_create(out IntPtr environment); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern Status portapy_environment_destroy(IntPtr environment); + + [DllImport( + LibraryName, + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Unicode + )] + private static extern Status portapy_environment_add_callback_utf16( + IntPtr environment, + [MarshalAs(UnmanagedType.LPWStr)] string name, + nuint nameLength, + NativeCallback callback, + IntPtr context + ); + + [DllImport( + LibraryName, + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Unicode + )] + private static extern Status portapy_environment_execute_utf16( + IntPtr environment, + [MarshalAs(UnmanagedType.LPWStr)] string source, + nuint sourceLength + ); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern Status portapy_value_from_none( + ulong runtime, + out ulong value + ); + + private readonly List callbackRoots = new(); + private readonly object callbackLock = new(); + private IntPtr handle; + private ExceptionDispatchInfo? pendingManagedException; + private bool disposed; + + public Environment() + { + ThrowIfError(portapy_environment_create(out handle)); + if (handle == IntPtr.Zero) + { + throw new PortaPyException(Status.RuntimeError); + } + } + + ~Environment() + { + Dispose(false); + } + + public void Add(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + Add(callback.Method.Name, callback); + } + + public void Add(string name, Action callback) + { + ObjectDisposedException.ThrowIf(disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(callback); + + NativeCallback nativeCallback = ( + IntPtr context, + ulong runtime, + IntPtr arguments, + nuint argumentCount, + out ulong result + ) => + { + result = 0; + if (argumentCount != 0) + { + return Status.InvalidArgument; + } + + try + { + callback(); + return portapy_value_from_none(runtime, out result); + } + catch (Exception error) + { + lock (callbackLock) + { + pendingManagedException = ExceptionDispatchInfo.Capture(error); + } + return Status.RuntimeError; + } + }; + + ThrowIfError( + portapy_environment_add_callback_utf16( + handle, + name, + (nuint)name.Length, + nativeCallback, + IntPtr.Zero + ) + ); + + // Native code stores the function pointer, not the managed delegate. + // Root it for at least as long as the environment can call it. + callbackRoots.Add(nativeCallback); + } + + public void Execute(string source) + { + ObjectDisposedException.ThrowIf(disposed, this); + ArgumentNullException.ThrowIfNull(source); + + Status status = portapy_environment_execute_utf16( + handle, + source, + (nuint)source.Length + ); + + ExceptionDispatchInfo? callbackError = null; + lock (callbackLock) + { + if (pendingManagedException is not null) + { + callbackError = pendingManagedException; + pendingManagedException = null; + } + } + callbackError?.Throw(); + ThrowIfError(status); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposed) + { + return; + } + disposed = true; + + IntPtr current = handle; + handle = IntPtr.Zero; + if (current != IntPtr.Zero) + { + Status status = portapy_environment_destroy(current); + if (disposing) + { + ThrowIfError(status); + } + } + + callbackRoots.Clear(); + } + + private static void ThrowIfError(Status status) + { + if (status != Status.Ok) + { + throw new PortaPyException(status); + } + } +} From c8e51d663b9bec7e625e0e8a9af8a56801c0e71a Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:42:23 -0500 Subject: [PATCH 06/12] Add managed environment ABI conformance host --- tests/native_managed_environment_host.c | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/native_managed_environment_host.c diff --git a/tests/native_managed_environment_host.c b/tests/native_managed_environment_host.c new file mode 100644 index 00000000..70cd5ddf --- /dev/null +++ b/tests/native_managed_environment_host.c @@ -0,0 +1,118 @@ +#include "portapy_managed.h" + +#include +#include + +#if defined(_WIN32) +#include +#define LOAD_LIBRARY(path) ((void *)LoadLibraryA(path)) +#define LOAD_SYMBOL(lib, name) ((void *)(uintptr_t)GetProcAddress((HMODULE)(lib), (name))) +#define ABI_CALL __cdecl +#else +#include +#define LOAD_LIBRARY(path) dlopen((path), RTLD_NOW | RTLD_LOCAL) +#define LOAD_SYMBOL(lib, name) dlsym((lib), (name)) +#define ABI_CALL +#endif + +typedef portapy_status (ABI_CALL *environment_create_fn)(portapy_environment **); +typedef portapy_status (ABI_CALL *environment_destroy_fn)(portapy_environment *); +typedef portapy_status (ABI_CALL *environment_add_fn)( + portapy_environment *, + const uint16_t *, + size_t, + portapy_environment_callback, + void * +); +typedef portapy_status (ABI_CALL *environment_execute_fn)( + portapy_environment *, + const uint16_t *, + size_t +); +typedef portapy_status (ABI_CALL *from_none_fn)( + portapy_runtime, + portapy_value * +); + +typedef struct callback_context { + from_none_fn from_none; + int calls; +} callback_context; + +static portapy_status ABI_CALL hello_world( + void *raw_context, + portapy_runtime runtime, + const portapy_value *arguments, + size_t argument_count, + portapy_value *out_result +) { + callback_context *context = (callback_context *)raw_context; + if ( + context == NULL + || arguments != NULL + || argument_count != 0 + || out_result == NULL + ) { + return PORTAPY_INVALID_ARGUMENT; + } + context->calls += 1; + return context->from_none(runtime, out_result); +} + +#define RESOLVE(type, variable, name) \ + type variable = (type)(uintptr_t)LOAD_SYMBOL(library, name); \ + if ((variable) == NULL) { \ + fprintf(stderr, "missing symbol: %s\n", name); \ + return 10; \ + } + +int main(int argc, char **argv) { + if (argc != 2) return 2; + void *library = LOAD_LIBRARY(argv[1]); + if (library == NULL) return 3; + + RESOLVE(environment_create_fn, environment_create, "portapy_environment_create"); + RESOLVE(environment_destroy_fn, environment_destroy, "portapy_environment_destroy"); + RESOLVE(environment_add_fn, environment_add, "portapy_environment_add_callback_utf16"); + RESOLVE(environment_execute_fn, environment_execute, "portapy_environment_execute_utf16"); + RESOLVE(from_none_fn, from_none, "portapy_value_from_none"); + + portapy_environment *environment = NULL; + if (environment_create(&environment) != PORTAPY_OK || environment == NULL) { + return 11; + } + + callback_context context = {from_none, 0}; + const uint16_t name[] = { + 'h', 'e', 'l', 'l', 'o', 'W', 'o', 'r', 'l', 'd' + }; + if ( + environment_add( + environment, + name, + sizeof(name) / sizeof(name[0]), + hello_world, + &context + ) != PORTAPY_OK + ) { + return 12; + } + + const uint16_t source[] = { + 'h', 'e', 'l', 'l', 'o', 'W', 'o', 'r', 'l', 'd', '(', ')', '\n' + }; + if ( + environment_execute( + environment, + source, + sizeof(source) / sizeof(source[0]) + ) != PORTAPY_OK + ) { + return 13; + } + if (context.calls != 1) return 14; + if (environment_destroy(environment) != PORTAPY_OK) return 15; + + puts("managed-environment: ok"); + return 0; +} From 8ac5da68f6ed4692c08adf63dd62568abc0a7fe4 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:42:48 -0500 Subject: [PATCH 07/12] Add C# managed environment smoke project --- .../PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj diff --git a/tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj b/tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj new file mode 100644 index 00000000..f31c7574 --- /dev/null +++ b/tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + enable + enable + latest + + + + + From cb031c5fe303b1815f3aff7d96f4a65597f6584a Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:43:02 -0500 Subject: [PATCH 08/12] Add direct C# environment smoke test --- tests/csharp/PortaPy.ManagedSmoke/Program.cs | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/csharp/PortaPy.ManagedSmoke/Program.cs diff --git a/tests/csharp/PortaPy.ManagedSmoke/Program.cs b/tests/csharp/PortaPy.ManagedSmoke/Program.cs new file mode 100644 index 00000000..9d265c65 --- /dev/null +++ b/tests/csharp/PortaPy.ManagedSmoke/Program.cs @@ -0,0 +1,22 @@ +using PortaPy; + +int calls = 0; + +void helloWorld() +{ + Console.WriteLine("Hello, world!"); + calls += 1; +} + +using (var env = new PortaPy.Environment()) +{ + env.Add(helloWorld); + env.Execute("helloWorld()\n"); +} + +if (calls != 1) +{ + throw new InvalidOperationException($"Expected one callback, observed {calls}."); +} + +Console.WriteLine("csharp-managed-environment: ok"); From e86467c0ae13e30997cd21650d2c1a6e5a0077d4 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:43:39 -0500 Subject: [PATCH 09/12] Validate direct managed environment API --- .github/workflows/native-host-calls.yml | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/native-host-calls.yml b/.github/workflows/native-host-calls.yml index 6491ecc5..43cbd86c 100644 --- a/.github/workflows/native-host-calls.yml +++ b/.github/workflows/native-host-calls.yml @@ -34,6 +34,14 @@ jobs: tests/native_host_call_host.c -ldl -o dist/native_host_call_host ./dist/native_host_call_host ./dist/libportapy.so | tee dist/host-call-output.txt grep -qx 'native-host-calls: ok' dist/host-call-output.txt + - name: Execute managed environment ABI from C + run: | + cc -std=c11 -Wall -Wextra -Werror -Iinclude \ + tests/native_managed_environment_host.c -ldl \ + -o dist/native_managed_environment_host + ./dist/native_managed_environment_host ./dist/libportapy.so \ + | tee dist/managed-environment-output.txt + grep -qx 'managed-environment: ok' dist/managed-environment-output.txt - name: Execute public tuple ABI from C run: | cc -std=c11 -Wall -Wextra -Werror -Iinclude \ @@ -82,6 +90,28 @@ jobs: $output = & dist/native_host_call_host.exe dist/portapy.dll $output | Out-File -Encoding utf8 dist/host-call-output.txt if ($output -ne 'native-host-calls: ok') { throw "unexpected host-call output: $output" } + - name: Execute managed environment ABI from C + shell: powershell + run: | + gcc -std=c11 -Wall -Wextra -Werror -Iinclude ` + tests/native_managed_environment_host.c ` + -o dist/native_managed_environment_host.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $output = & dist/native_managed_environment_host.exe dist/portapy.dll + $output | Out-File -Encoding utf8 dist/managed-environment-output.txt + if ($output -ne 'managed-environment: ok') { throw "unexpected managed environment output: $output" } + - name: Execute direct C# environment API + shell: powershell + run: | + $project = 'tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj' + dotnet build $project -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $outputDirectory = 'tests/csharp/PortaPy.ManagedSmoke/bin/Release/net8.0' + Copy-Item dist/portapy.dll "$outputDirectory/portapy.dll" + $output = & dotnet "$outputDirectory/PortaPy.ManagedSmoke.dll" + $output | Out-File -Encoding utf8 dist/csharp-managed-environment-output.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($output[-1] -ne 'csharp-managed-environment: ok') { throw "unexpected C# output: $output" } - name: Execute public tuple ABI from C shell: powershell run: | From 96b95103247460976bba83d36f7416a610dc2b88 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:45:18 -0500 Subject: [PATCH 10/12] Document direct C# environment usage --- docs/CSHARP.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/CSHARP.md diff --git a/docs/CSHARP.md b/docs/CSHARP.md new file mode 100644 index 00000000..4992b3db --- /dev/null +++ b/docs/CSHARP.md @@ -0,0 +1,38 @@ +# Direct C# hosting + +`portapy.dll` exposes a managed-host ABI directly. No custom C/C++ shim is +required. Include `bindings/csharp/PortaPy.cs` in the C# project and place +`portapy.dll` beside the built application. + +```csharp +using PortaPy; + +void helloWorld() +{ + Console.WriteLine("Hello, world!"); +} + +using var env = new PortaPy.Environment(); +env.Add(helloWorld); +env.Execute("helloWorld()\n"); +``` + +`Environment.Add(Action)` uses the managed method name as the Python global +name. An explicit name is also supported: + +```csharp +env.Add("hello_world", helloWorld); +env.Execute("hello_world()\n"); +``` + +The environment owns the native runtime and callback registry. The managed +wrapper keeps callback delegates alive until disposal and rethrows exceptions +raised by managed callbacks after native execution returns. + +The underlying public exports are: + +- `portapy_environment_create` +- `portapy_environment_destroy` +- `portapy_environment_get_runtime` +- `portapy_environment_add_callback_utf16` +- `portapy_environment_execute_utf16` From a9ad4c562b3fc4883b47756ef40a2dd75288b1fd Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:49:25 -0500 Subject: [PATCH 11/12] Capture managed .NET diagnostics --- .github/workflows/native-host-calls.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/native-host-calls.yml b/.github/workflows/native-host-calls.yml index 43cbd86c..5412fc48 100644 --- a/.github/workflows/native-host-calls.yml +++ b/.github/workflows/native-host-calls.yml @@ -104,13 +104,18 @@ jobs: shell: powershell run: | $project = 'tests/csharp/PortaPy.ManagedSmoke/PortaPy.ManagedSmoke.csproj' - dotnet build $project -c Release - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build $project -c Release *>&1 ` + | Tee-Object -FilePath dist/csharp-managed-build.log + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { exit $buildExit } $outputDirectory = 'tests/csharp/PortaPy.ManagedSmoke/bin/Release/net8.0' Copy-Item dist/portapy.dll "$outputDirectory/portapy.dll" - $output = & dotnet "$outputDirectory/PortaPy.ManagedSmoke.dll" + & dotnet "$outputDirectory/PortaPy.ManagedSmoke.dll" *>&1 ` + | Tee-Object -FilePath dist/csharp-managed-run.log + $runExit = $LASTEXITCODE + if ($runExit -ne 0) { exit $runExit } + $output = Get-Content dist/csharp-managed-run.log $output | Out-File -Encoding utf8 dist/csharp-managed-environment-output.txt - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($output[-1] -ne 'csharp-managed-environment: ok') { throw "unexpected C# output: $output" } - name: Execute public tuple ABI from C shell: powershell From cfa609bad0dedaaa2fc40f6c13796c2668dc0ac6 Mon Sep 17 00:00:00 2001 From: deltathedumb <133447617+deltathedumb@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:50:48 -0500 Subject: [PATCH 12/12] Use exact native symbol spelling in C# binding --- bindings/csharp/PortaPy.cs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bindings/csharp/PortaPy.cs b/bindings/csharp/PortaPy.cs index 20f2ca45..260edf4d 100644 --- a/bindings/csharp/PortaPy.cs +++ b/bindings/csharp/PortaPy.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; @@ -44,16 +43,25 @@ private delegate Status NativeCallback( out ulong result ); - [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + [DllImport( + LibraryName, + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] private static extern Status portapy_environment_create(out IntPtr environment); - [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + [DllImport( + LibraryName, + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] private static extern Status portapy_environment_destroy(IntPtr environment); [DllImport( LibraryName, CallingConvention = CallingConvention.Cdecl, - CharSet = CharSet.Unicode + CharSet = CharSet.Unicode, + ExactSpelling = true )] private static extern Status portapy_environment_add_callback_utf16( IntPtr environment, @@ -66,7 +74,8 @@ IntPtr context [DllImport( LibraryName, CallingConvention = CallingConvention.Cdecl, - CharSet = CharSet.Unicode + CharSet = CharSet.Unicode, + ExactSpelling = true )] private static extern Status portapy_environment_execute_utf16( IntPtr environment, @@ -74,7 +83,11 @@ private static extern Status portapy_environment_execute_utf16( nuint sourceLength ); - [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + [DllImport( + LibraryName, + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] private static extern Status portapy_value_from_none( ulong runtime, out ulong value @@ -151,8 +164,6 @@ out ulong result ) ); - // Native code stores the function pointer, not the managed delegate. - // Root it for at least as long as the environment can call it. callbackRoots.Add(nativeCallback); }