Skip to content

Commit 145ee89

Browse files
author
Mark Birger
committed
refine FFI/C# host-await bindings per review
- HostAwaitBuiltin: single-arg (name) ctor; arg_count fixed to 1 - SetHostAwaitResponses: multi-identifier dictionary API - always route CompileFromModules through host-await FFI - dead-check/IIFE cleanup, pub(crate), expanded tests + docs
1 parent b3fd8d6 commit 145ee89

9 files changed

Lines changed: 476 additions & 132 deletions

File tree

bindings/csharp/API.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,11 @@ public sealed class Rvm : IDisposable
408408
public string? GetHostAwaitIdentifier();
409409
public string? GetHostAwaitArgument();
410410

411-
// Run-to-completion-mode host-await pre-loading
412-
public void SetHostAwaitResponses(string identifier, string[] valuesJson);
411+
// Run-to-completion-mode host-await pre-loading. Atomically replaces all
412+
// previously configured responses for every identifier; pass every
413+
// identifier the policy may invoke in a single call.
414+
public void SetHostAwaitResponses(
415+
IReadOnlyDictionary<string, IReadOnlyList<string>> responsesByIdentifier);
413416

414417
public void Dispose();
415418
}
@@ -421,13 +424,16 @@ Declares a function name that the compiler should treat as a host-await call.
421424
When the VM encounters a call to this function, it suspends (suspendable mode)
422425
or consumes a pre-loaded response (run-to-completion mode).
423426

427+
Registered builtins are restricted to exactly one argument at the compiler
428+
level (use object packing to pass multiple values), so the C# struct does not
429+
expose an `argCount` parameter.
430+
424431
```csharp
425432
public readonly struct HostAwaitBuiltin
426433
{
427434
public string Name { get; }
428-
public int ArgCount { get; }
429435

430-
public HostAwaitBuiltin(string name, int argCount);
436+
public HostAwaitBuiltin(string name);
431437
}
432438
```
433439

bindings/csharp/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ allow if {
130130

131131
var modules = new[] { new PolicyModule("demo.rego", Policy) };
132132
var entryPoints = new[] { "data.demo.allow" };
133-
var builtins = new[] { new HostAwaitBuiltin("get_account", 1) };
133+
var builtins = new[] { new HostAwaitBuiltin("get_account") };
134134

135135
using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins);
136136
using var vm = new Rvm();
@@ -168,16 +168,21 @@ greeting := msg if {
168168

169169
var modules = new[] { new PolicyModule("demo.rego", Policy) };
170170
var entryPoints = new[] { "data.demo.greeting" };
171-
var builtins = new[] { new HostAwaitBuiltin("translate", 1) };
171+
var builtins = new[] { new HostAwaitBuiltin("translate") };
172172

173173
using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins);
174174
using var vm = new Rvm();
175175
vm.SetExecutionMode(ExecutionMode.RunToCompletion);
176176
vm.LoadProgram(program);
177177
vm.SetInputJson("""{"lang": "es"}""");
178178

179-
// Queue responses before execution
180-
vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" });
179+
// Queue responses before execution. SetHostAwaitResponses atomically replaces
180+
// ALL prior responses for every identifier — pass every identifier the policy
181+
// may invoke in a single call.
182+
vm.SetHostAwaitResponses(new Dictionary<string, IReadOnlyList<string>>
183+
{
184+
["translate"] = new[] { "\"hola\"" },
185+
});
181186

182187
var result = vm.Execute();
183188
Console.WriteLine($"greeting: {result}"); // "hola"

bindings/csharp/Regorus.Tests/RvmProgramTests.cs

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using Microsoft.VisualStudio.TestTools.UnitTesting;
67

78
namespace Regorus.Tests;
@@ -134,7 +135,7 @@ public void RegisteredHostAwait_Suspendable_SuspendAndResume()
134135
{
135136
var modules = new[] { new PolicyModule("account.rego", GetAccountPolicy) };
136137
var entryPoints = new[] { "data.demo.allow" };
137-
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("get_account", 1) };
138+
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("get_account") };
138139

139140
using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins);
140141
using var vm = new Rvm();
@@ -145,7 +146,9 @@ public void RegisteredHostAwait_Suspendable_SuspendAndResume()
145146
// Execute — should suspend on get_account()
146147
vm.Execute();
147148

148-
// Verify we're suspended due to HostAwait with identifier "get_account"
149+
// Verify we're suspended due to HostAwait with identifier "get_account".
150+
// GetHostAwaitIdentifier returns the JSON-encoded Value, so the identifier
151+
// string itself includes the surrounding JSON quotes.
149152
var identifier = vm.GetHostAwaitIdentifier();
150153
Assert.AreEqual("\"get_account\"", identifier, "expected identifier to be get_account");
151154

@@ -174,7 +177,7 @@ public void RegisteredHostAwait_RunToCompletion_WithPreloadedResponses()
174177
{
175178
var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) };
176179
var entryPoints = new[] { "data.demo.greeting" };
177-
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate", 1) };
180+
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") };
178181

179182
using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins);
180183
using var vm = new Rvm();
@@ -183,11 +186,161 @@ public void RegisteredHostAwait_RunToCompletion_WithPreloadedResponses()
183186
vm.SetInputJson("{\"lang\": \"es\"}");
184187

185188
// Pre-load a response for translate
186-
vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" });
189+
vm.SetHostAwaitResponses(new Dictionary<string, IReadOnlyList<string>>
190+
{
191+
["translate"] = new[] { "\"hola\"" },
192+
});
187193

188194
// Execute — translate returns "hola"
189195
var result = vm.Execute();
190196
Assert.AreEqual("\"hola\"", result, "expected greeting=hola");
191197
}
192198

199+
[TestMethod]
200+
public void RegisteredHostAwait_CompileRejectsEmptyOrWhitespaceName()
201+
{
202+
var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") };
203+
var entryPoints = new[] { "data.demo.allow" };
204+
205+
foreach (var badName in new[] { "", " ", "\t" })
206+
{
207+
var builtins = new[] { new HostAwaitBuiltin(badName) };
208+
Assert.ThrowsException<InvalidOperationException>(
209+
() => Program.CompileFromModules("{}", modules, entryPoints, builtins),
210+
$"expected compilation to reject empty/whitespace name '{badName}'");
211+
}
212+
}
213+
214+
[TestMethod]
215+
public void RegisteredHostAwait_CompileRejectsDuplicateRegistration()
216+
{
217+
var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") };
218+
var entryPoints = new[] { "data.demo.allow" };
219+
var builtins = new[]
220+
{
221+
new HostAwaitBuiltin("translate"),
222+
new HostAwaitBuiltin("translate"),
223+
};
224+
225+
Assert.ThrowsException<InvalidOperationException>(
226+
() => Program.CompileFromModules("{}", modules, entryPoints, builtins),
227+
"expected compilation to reject duplicate registration");
228+
}
229+
230+
[TestMethod]
231+
public void RegisteredHostAwait_CompileRejectsReservedName()
232+
{
233+
var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") };
234+
var entryPoints = new[] { "data.demo.allow" };
235+
var builtins = new[] { new HostAwaitBuiltin("__builtin_host_await") };
236+
237+
Assert.ThrowsException<InvalidOperationException>(
238+
() => Program.CompileFromModules("{}", modules, entryPoints, builtins),
239+
"expected compilation to reject reserved __builtin_host_await identifier");
240+
}
241+
242+
[TestMethod]
243+
public void RegisteredHostAwait_GetAccessorsReturnNullWhenVmIsNotSuspended()
244+
{
245+
var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) };
246+
var entryPoints = new[] { "data.demo.greeting" };
247+
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") };
248+
249+
using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins);
250+
using var vm = new Rvm();
251+
vm.SetExecutionMode(ExecutionMode.RunToCompletion);
252+
vm.LoadProgram(program);
253+
vm.SetInputJson("{\"lang\": \"es\"}");
254+
vm.SetHostAwaitResponses(new Dictionary<string, IReadOnlyList<string>>
255+
{
256+
["translate"] = new[] { "\"hola\"" },
257+
});
258+
vm.Execute();
259+
260+
// After run-to-completion completes successfully, the VM is no longer suspended.
261+
Assert.IsNull(vm.GetHostAwaitArgument(), "expected null argument when VM is not suspended");
262+
Assert.IsNull(vm.GetHostAwaitIdentifier(), "expected null identifier when VM is not suspended");
263+
}
264+
265+
private const string TranslateNoDefaultPolicy = """
266+
package demo
267+
import rego.v1
268+
269+
# No default — if translate() can't produce a value, the entry point
270+
# evaluation propagates the error to the caller.
271+
result := translate(input.lang)
272+
""";
273+
274+
[TestMethod]
275+
public void RegisteredHostAwait_RunToCompletion_FailsWhenResponseQueueExhausted()
276+
{
277+
var modules = new[] { new PolicyModule("translate.rego", TranslateNoDefaultPolicy) };
278+
var entryPoints = new[] { "data.demo.result" };
279+
var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") };
280+
281+
using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins);
282+
using var vm = new Rvm();
283+
vm.SetExecutionMode(ExecutionMode.RunToCompletion);
284+
vm.LoadProgram(program);
285+
vm.SetInputJson("{\"lang\": \"es\"}");
286+
287+
// No responses pre-loaded — translate has nothing to return.
288+
// Document the actual behavior: in run-to-completion mode the
289+
// missing-response error fails the rule body silently rather than
290+
// surfacing as an exception, so Execute() returns the literal
291+
// string `"<undefined>"` for an entry point that produced no value.
292+
// Asserting the exact return value locks this contract so any
293+
// future change (e.g. propagating an exception) shows up as a
294+
// test failure that has to be explicitly re-acknowledged.
295+
var actual = vm.Execute();
296+
Assert.AreEqual(
297+
"\"<undefined>\"",
298+
actual,
299+
"expected `\"<undefined>\"` when the response queue is exhausted");
300+
}
301+
302+
private const string MultiAwaitPolicy = """
303+
package demo
304+
import rego.v1
305+
306+
default greeting := "unknown"
307+
308+
greeting := combined if {
309+
hello := translate(input.lang)
310+
user := lookup_user({"id": input.user_id})
311+
combined := sprintf("%s %s", [hello, user.name])
312+
}
313+
""";
314+
315+
[TestMethod]
316+
public void RegisteredHostAwait_RunToCompletion_MultipleIdentifiersInSingleCall()
317+
{
318+
var modules = new[] { new PolicyModule("multi.rego", MultiAwaitPolicy) };
319+
var entryPoints = new[] { "data.demo.greeting" };
320+
var hostAwaitBuiltins = new[]
321+
{
322+
new HostAwaitBuiltin("translate"),
323+
new HostAwaitBuiltin("lookup_user"),
324+
};
325+
326+
using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins);
327+
using var vm = new Rvm();
328+
vm.SetExecutionMode(ExecutionMode.RunToCompletion);
329+
vm.LoadProgram(program);
330+
vm.SetInputJson("{\"lang\": \"es\", \"user_id\": \"u1\"}");
331+
332+
// Pre-load responses for BOTH identifiers in a single call.
333+
// The new IReadOnlyDictionary API atomically replaces ALL prior
334+
// responses, so this single call must carry every identifier the
335+
// policy may invoke during this run.
336+
vm.SetHostAwaitResponses(new Dictionary<string, IReadOnlyList<string>>
337+
{
338+
["translate"] = new[] { "\"hola\"" },
339+
["lookup_user"] = new[] { "{\"name\": \"Alice\"}" },
340+
});
341+
342+
var result = vm.Execute();
343+
Assert.AreEqual("\"hola Alice\"", result, "expected combined greeting from both responses");
344+
}
345+
193346
}

bindings/csharp/Regorus/Compiler.cs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,30 +42,28 @@ public PolicyModule(string id, string content)
4242
/// rather than regular function calls.
4343
/// </summary>
4444
/// <remarks>
45-
/// Host-await builtins are only supported via the <c>CompileFromModules</c> path.
46-
/// The <c>CompileFromEngine</c> path does not support host-await registration.
45+
/// Registered builtins are restricted to exactly one argument at the compiler
46+
/// level (use object packing to pass multiple values). The argument count is
47+
/// therefore not exposed here.
48+
///
49+
/// Host-await builtins are not yet supported via the <c>CompileFromEngine</c>
50+
/// path; only <c>CompileFromModules</c> accepts them today.
4751
/// </remarks>
4852
public readonly struct HostAwaitBuiltin
4953
{
5054
/// <summary>
51-
/// Gets the function name.
55+
/// Gets the function name to register as host-awaitable.
5256
/// </summary>
5357
public string Name { get; }
5458

55-
/// <summary>
56-
/// Gets the expected argument count.
57-
/// </summary>
58-
public int ArgCount { get; }
59-
6059
/// <summary>
6160
/// Initializes a new instance of the HostAwaitBuiltin struct.
6261
/// </summary>
6362
/// <param name="name">The function name to register as host-awaitable.</param>
64-
/// <param name="argCount">The expected number of arguments.</param>
65-
public HostAwaitBuiltin(string name, int argCount)
63+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
64+
public HostAwaitBuiltin(string name)
6665
{
67-
Name = name;
68-
ArgCount = argCount;
66+
Name = name ?? throw new ArgumentNullException(nameof(name));
6967
}
7068
}
7169

0 commit comments

Comments
 (0)