mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-14 03:37:55 -04:00
8dd18f078f
# Description of Changes Rust added procedure-scoped HTTP via the `procedure_http_request` ABI (see Rust PR #3684) to C# host bindings. What changed: 1) Fix for BSATN wire format * Updated C# BSATN wire types for HTTP (`BSATN.Runtime/HttpWireTypes.cs`) to match exactly the Rust stable types: * `spacetimedb_lib::http::Request` fields and order: `method`, `headers`, `timeout`, `uri`, `version` * `spacetimedb_lib::http::Response` fields and order: `headers`, `version`, `code` * Header pairs are (`name: string, value: bytes`); no additional metadata is encoded. * Added explicit “do not reorder/extend” note in the C# wire type file since the host BSATN-decodes these bytes directly and may trap on mismatch. 2) C# runtime HTTP client implementation * Implemented `ProcedureContext.Http` support (`Runtime/Http.cs`) that: * BSATN-serializes `HttpRequestWire` + sends body bytes via `procedure_http_request` * On success, BSATN-decodes `HttpResponseWire` and returns `(response, body)` as a typed `HttpResponse` * On `HTTP_ERROR`, decodes the error payload as a BSATN `string` * On `WOULD_BLOCK_TRANSACTION`, returns a stable error message (host rejects blocking HTTP while a mut tx is open) * Clamps timeouts to 500ms max (matching host behavior documented on the Rust side) 3) ABI wiring / import plumbing * Added the `procedure_http_request` import to the C# wasm shim (`Runtime/bindings.c`) under the `spacetime_10.3` import module. * Verified the generated C# P/Invoke signature matches the host ABI (request ptr/len, body ptr/len, out `[BytesSource; 2]`). 4) Procedure entrypoint contract (trap vs errno) * Updated/clarified the module procedure trampoline behavior (`Runtime/Internal/Module.call_procedure`): it must either return `Errno.OK` or trap (log + rethrow). This mirrors the host’s expectations and avoids “unexpected errno values from guest entrypoints” behavior. Behavioral notes vs Rust * Rust bindings may `expect(...)`/panic on “should never happen” serialization failures; C# runtime explicitly avoids throwing across the procedure boundary for the HTTP client path and instead returns `Result.Err` for unexpected failures. This prevents whole-module traps from user-space HTTP usage while still surfacing rich errors. * The host remains authoritative on timeout enforcement; C# now mirrors the effective host clamp (500ms) to keep behavior aligned. # API and ABI breaking changes No new host ABI introduced, and does not change the syscall signature. * This is not an ABI “break” in the host, but it is a guest-side wire compatibility correction: older C# guests built with the incorrect wire types could cause the host to trap during BSATN decode. After this PR, C# guests are compatible with the host’s expected layout. **Adds API**: `ProcedureContextBase.Http` is available for procedures No existing public types/method signatures are removed * A notable difference from Rust's panic behavior is that Rust code sometimes `expect(...)`s and may panic/trap on internal “should not happen” cases. On the other hand, C#'s `HttpClient.Send` explicitly converts unexpected failures into `Result.Err` to avoid trapping the module. This is a behavioral difference, but not an API break. # Expected complexity level and risk 2 - Mostly just creating equivalents to the Rust version. # Testing C# regression tests updated to cover: - [X] Successful request path (`ReadMySchemaViaHttp`) - [X] Invalid request path (`InvalidHttpRequest`) returning error without trapping Testing performed: - [X] Full regression suite run against local node (see chat log); no wasm traps, all suites reported `Success`. --------- Signed-off-by: Ryan <r.ekhoff@clockworklabs.io>
401 lines
12 KiB
C#
401 lines
12 KiB
C#
namespace SpacetimeDB.Internal;
|
|
|
|
using System.Runtime.InteropServices;
|
|
using System.Runtime.InteropServices.Marshalling;
|
|
|
|
// This type is outside of the hidden `FFI` class because for now we need to do some public
|
|
// forwarding in the codegen for `__describe_module__` and `__call_reducer__` exports which both
|
|
// use this type.
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct BytesSource(uint Handle)
|
|
{
|
|
public static readonly BytesSource INVALID = new(0);
|
|
}
|
|
|
|
// This type is outside of the hidden `FFI` class because for now we need to do some public
|
|
// forwarding in the codegen for `__describe_module__` and `__call_reducer__` exports which both
|
|
// use this type.
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct BytesSink(uint Handle) { }
|
|
|
|
public enum Errno : short
|
|
{
|
|
EXHAUSTED = -1,
|
|
OK = 0,
|
|
HOST_CALL_FAILURE = 1,
|
|
NOT_IN_TRANSACTION = 2,
|
|
BSATN_DECODE_ERROR = 3,
|
|
NO_SUCH_TABLE = 4,
|
|
NO_SUCH_INDEX = 5,
|
|
NO_SUCH_ITER = 6,
|
|
NO_SUCH_CONSOLE_TIMER = 7,
|
|
NO_SUCH_BYTES = 8,
|
|
NO_SPACE = 9,
|
|
BUFFER_TOO_SMALL = 11,
|
|
UNIQUE_ALREADY_EXISTS = 12,
|
|
SCHEDULE_AT_DELAY_TOO_LONG = 13,
|
|
INDEX_NOT_UNIQUE = 14,
|
|
NO_SUCH_ROW = 15,
|
|
AUTO_INC_OVERFLOW = 16,
|
|
WOULD_BLOCK_TRANSACTION = 17,
|
|
TRANSACTION_NOT_ANONYMOUS = 18,
|
|
TRANSACTION_IS_READ_ONLY = 19,
|
|
TRANSACTION_IS_MUT = 20,
|
|
HTTP_ERROR = 21,
|
|
}
|
|
|
|
#pragma warning disable IDE1006 // Naming Styles - Not applicable to FFI stuff.
|
|
internal static partial class FFI
|
|
{
|
|
// For now this must match the name of the `.c` file (`bindings.c`).
|
|
// In the future C# will allow to specify Wasm import namespace in
|
|
// `LibraryImport` directly.
|
|
const string StdbNamespace10_0 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.0"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_1 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.1"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_2 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.2"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_3 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.3"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_4 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.4"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
[NativeMarshalling(typeof(Marshaller))]
|
|
public struct CheckedStatus
|
|
{
|
|
// This custom marshaller takes care of checking the status code
|
|
// returned from the host and throwing an exception if it's not 0.
|
|
// The only reason it doesn't return `void` is because the C# compiler
|
|
// doesn't treat `void` as a real type and doesn't allow it to be returned
|
|
// from custom marshallers, so we resort to an empty struct instead.
|
|
[CustomMarshaller(
|
|
typeof(CheckedStatus),
|
|
MarshalMode.ManagedToUnmanagedOut,
|
|
typeof(Marshaller)
|
|
)]
|
|
internal static class Marshaller
|
|
{
|
|
public static CheckedStatus ConvertToManaged(Errno status)
|
|
{
|
|
ErrnoHelpers.ThrowIfError(status);
|
|
return default;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class ErrnoHelpers
|
|
{
|
|
public static void ThrowIfError(Errno status)
|
|
{
|
|
if (status == Errno.OK)
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw ToException(status);
|
|
}
|
|
|
|
public static Exception ToException(Errno status) =>
|
|
status switch
|
|
{
|
|
Errno.NOT_IN_TRANSACTION => new NotInTransactionException(),
|
|
Errno.BSATN_DECODE_ERROR => new BsatnDecodeException(),
|
|
Errno.NO_SUCH_TABLE => new NoSuchTableException(),
|
|
Errno.NO_SUCH_INDEX => new NoSuchIndexException(),
|
|
Errno.NO_SUCH_ITER => new NoSuchIterException(),
|
|
Errno.NO_SUCH_CONSOLE_TIMER => new NoSuchLogStopwatch(),
|
|
Errno.NO_SUCH_BYTES => new NoSuchBytesException(),
|
|
Errno.NO_SPACE => new NoSpaceException(),
|
|
Errno.BUFFER_TOO_SMALL => new BufferTooSmallException(),
|
|
Errno.UNIQUE_ALREADY_EXISTS => new UniqueConstraintViolationException(),
|
|
Errno.INDEX_NOT_UNIQUE => new IndexNotUniqueException(),
|
|
Errno.NO_SUCH_ROW => new NoSuchRowException(),
|
|
Errno.AUTO_INC_OVERFLOW => new AutoIncOverflowException(),
|
|
Errno.WOULD_BLOCK_TRANSACTION => new TransactionWouldBlockException(),
|
|
Errno.TRANSACTION_NOT_ANONYMOUS => new TransactionNotAnonymousException(),
|
|
Errno.TRANSACTION_IS_READ_ONLY => new TransactionIsReadOnlyException(),
|
|
Errno.TRANSACTION_IS_MUT => new TransactionIsMutableException(),
|
|
Errno.HTTP_ERROR => new HttpException(),
|
|
_ => new UnknownException(status),
|
|
};
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct TableId
|
|
{
|
|
private readonly uint table_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct IndexId
|
|
{
|
|
private readonly uint index_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct ColId(ushort col_id)
|
|
{
|
|
private readonly ushort col_id = col_id;
|
|
|
|
public static explicit operator ushort(ColId col_id) => col_id.col_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct IndexType
|
|
{
|
|
private readonly byte index_type;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct RowIter(uint Handle)
|
|
{
|
|
public static readonly RowIter INVALID = new(0);
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus table_id_from_name(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
out TableId out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus index_id_from_name(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
out IndexId out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_table_row_count(TableId table_id, out ulong out_);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_table_scan_bsatn(
|
|
TableId table_id,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_4)]
|
|
public static partial CheckedStatus datastore_index_scan_point_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> point,
|
|
uint point_len,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_4)]
|
|
public static partial CheckedStatus datastore_delete_by_index_scan_point_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> point,
|
|
uint point_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_index_scan_range_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> prefix,
|
|
uint prefix_len,
|
|
ColId prefix_elems,
|
|
ReadOnlySpan<byte> rstart,
|
|
uint rstart_len,
|
|
ReadOnlySpan<byte> rend,
|
|
uint rend_len,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial Errno row_iter_bsatn_advance(
|
|
RowIter iter_handle,
|
|
[MarshalUsing(CountElementName = nameof(buffer_len))] [Out] byte[] buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus row_iter_bsatn_close(RowIter iter_handle);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_insert_bsatn(
|
|
TableId table_id,
|
|
Span<byte> row,
|
|
ref uint row_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_update_bsatn(
|
|
TableId table_id,
|
|
IndexId index_id,
|
|
Span<byte> row,
|
|
ref uint row_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_delete_by_index_scan_range_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> prefix,
|
|
uint prefix_len,
|
|
ColId prefix_elems,
|
|
ReadOnlySpan<byte> rstart,
|
|
uint rstart_len,
|
|
ReadOnlySpan<byte> rend,
|
|
uint rend_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_delete_all_by_eq_bsatn(
|
|
TableId table_id,
|
|
[In] byte[] relation,
|
|
uint relation_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial Errno bytes_source_read(
|
|
BytesSource source,
|
|
Span<byte> buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus bytes_sink_write(
|
|
BytesSink sink,
|
|
ReadOnlySpan<byte> buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
public enum LogLevel : byte
|
|
{
|
|
Error = 0,
|
|
Warn = 1,
|
|
Info = 2,
|
|
Debug = 3,
|
|
Trace = 4,
|
|
Panic = 5,
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial void console_log(
|
|
LogLevel level,
|
|
[In] byte[] target,
|
|
uint target_len,
|
|
[In] byte[] filename,
|
|
uint filename_len,
|
|
uint line_number,
|
|
[In] byte[] message,
|
|
uint message_len
|
|
);
|
|
|
|
[NativeMarshalling(typeof(ConsoleTimerIdMarshaller))]
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct ConsoleTimerId
|
|
{
|
|
private readonly uint timer_id;
|
|
|
|
private ConsoleTimerId(uint id)
|
|
{
|
|
timer_id = id;
|
|
}
|
|
|
|
//LayoutKind.Sequential is apparently not enough for this struct to be returnable in PInvoke, so we need a custom marshaller unfortunately
|
|
[CustomMarshaller(
|
|
typeof(ConsoleTimerId),
|
|
MarshalMode.Default,
|
|
typeof(ConsoleTimerIdMarshaller)
|
|
)]
|
|
internal static class ConsoleTimerIdMarshaller
|
|
{
|
|
public static ConsoleTimerId ConvertToManaged(uint id) => new ConsoleTimerId(id);
|
|
|
|
public static uint ConvertToUnmanaged(ConsoleTimerId id) => id.timer_id;
|
|
}
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial ConsoleTimerId console_timer_start([In] byte[] name, uint name_len);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus console_timer_end(ConsoleTimerId stopwatch_id);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial void volatile_nonatomic_schedule_immediate(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
[In] byte[] args,
|
|
uint args_len
|
|
);
|
|
|
|
// Note #1: our Identity type has the same layout as a fixed-size 32-byte little-endian buffer,
|
|
// so instead of working around C#'s lack of fixed-size arrays, we just accept the pointer to
|
|
// the Identity itself. In this regard it's different from Rust declaration, but is still
|
|
// functionally the same.
|
|
// Note #2: we can't use `LibraryImport` here due to https://github.com/dotnet/runtime/issues/98616
|
|
// which prevents source-generated PInvokes from working with types from other assemblies, and
|
|
// `Identity` lives in another assembly (`BSATN.Runtime`). Luckily, `DllImport` is enough here.
|
|
#pragma warning disable SYSLIB1054 // Suppress "Use 'LibraryImportAttribute' instead of 'DllImportAttribute'" warning.
|
|
[DllImport(StdbNamespace10_0)]
|
|
public static extern void identity(out Identity dest);
|
|
#pragma warning restore SYSLIB1054
|
|
|
|
[DllImport(StdbNamespace10_1)]
|
|
public static extern Errno bytes_source_remaining_length(BytesSource source, ref uint len);
|
|
|
|
[DllImport(StdbNamespace10_2)]
|
|
public static extern Errno get_jwt(ref ConnectionId connectionId, out BytesSource source);
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_start_mut_tx")]
|
|
public static partial Errno procedure_start_mut_tx(out long micros);
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_commit_mut_tx")]
|
|
public static partial Errno procedure_commit_mut_tx();
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_abort_mut_tx")]
|
|
public static partial Errno procedure_abort_mut_tx();
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct BytesSourcePair
|
|
{
|
|
public readonly BytesSource A;
|
|
public readonly BytesSource B;
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_http_request")]
|
|
public static partial Errno procedure_http_request(
|
|
ReadOnlySpan<byte> request,
|
|
uint request_len,
|
|
ReadOnlySpan<byte> body,
|
|
uint body_len,
|
|
out BytesSourcePair out_
|
|
);
|
|
}
|