From f4fe424d4154e34d978b4d47e085b8538ceeefbb Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 12:16:52 +0000 Subject: [PATCH] CultCache interop: C# union and nested-bytes store read by Rust read-only, and back (#139) --- .../examples/cultcache_interop.rs | 98 +++++++++++++++++++ packages/cultcache-ts/test/cult-cache.test.ts | 45 ++++++++- tests/GameCult.Caching.InteropPeer/Program.cs | 74 ++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs index 8efb9395..dcafee5f 100644 --- a/packages/cultcache-rs/examples/cultcache_interop.rs +++ b/packages/cultcache-rs/examples/cultcache_interop.rs @@ -1,6 +1,8 @@ use anyhow::Result; use anyhow::anyhow; use cultcache_rs::CultCache; +use cultcache_rs::CultSlots; +use cultcache_rs::CultUnion; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; use serde_json::json; @@ -39,6 +41,100 @@ struct CultCacheInteropDeck { marks: Vec, } +// A union field and nested bytes: the shapes a Bifrost-style store holds beyond plain strings. +#[derive(Clone, Debug, PartialEq, Eq, CultUnion)] +enum InteropArm { + #[cultcache(key = 0)] + Text(InteropTextArm), + #[cultcache(key = 1)] + Pack(InteropPackArm), +} + +#[derive(Clone, Debug, PartialEq, Eq, CultSlots)] +struct InteropTextArm { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, CultSlots)] +struct InteropPackArm { + #[cultcache(key = 0)] + label: String, + #[cultcache(key = 1)] + chunks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "cultcache.interop-shape", schema = "CultCacheInteropShape")] +struct CultCacheInteropShape { + #[cultcache(key = 0)] + document_id: String, + #[cultcache(key = 1)] + arm: InteropArm, + #[cultcache(key = 2)] + raw: serde_bytes::ByteBuf, +} + +fn shapes() -> Vec { + vec![ + CultCacheInteropShape { + document_id: "shape:text".into(), + arm: InteropArm::Text(InteropTextArm { text: "union arm 0".into() }), + raw: (0..=255u8).collect::>().into(), + }, + CultCacheInteropShape { + document_id: "shape:pack".into(), + arm: InteropArm::Pack(InteropPackArm { + label: "union arm 1".into(), + chunks: vec![vec![0xc0, 0xc1, 0x00].into(), vec![].into(), vec![0x92, 0x01, 0x02].into()], + }), + raw: vec![0xde, 0xad, 0xbe, 0xef].into(), + }, + ] +} + +fn write_shapes(file: &str) -> Result<()> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?; + cache.pull_all_backing_stores()?; + for shape in shapes() { + cache.put(&shape.document_id, &shape)?; + } + print_shapes(shapes()) +} + +/// Opens the store through the read-only snapshot door: no lock file, no staging file, no rewrite. +fn read_shapes(file: &str) -> Result<()> { + let envelopes = SingleFileMessagePackBackingStore::new(file).pull_all_read_only_snapshot()?; + let mut found = Vec::new(); + for envelope in envelopes.iter().filter(|envelope| envelope.r#type == CultCacheInteropShape::TYPE) { + found.push(cultcache_rs::decode_payload::(&envelope.payload)?); + } + print_shapes(found) +} + +fn print_shapes(mut found: Vec) -> Result<()> { + let hex = |bytes: &[u8]| bytes.iter().map(|byte| format!("{byte:02X}")).collect::(); + found.sort_by(|a, b| a.document_id.cmp(&b.document_id)); + let rows: Vec<_> = found + .iter() + .map(|shape| { + let arm = match &shape.arm { + InteropArm::Text(text) => json!({ "kind": "text", "text": text.text }), + InteropArm::Pack(pack) => json!({ + "kind": "pack", + "label": pack.label, + "chunks": pack.chunks.iter().map(|chunk| hex(chunk)).collect::>(), + }), + }; + json!({ "documentId": shape.document_id, "arm": arm, "raw": hex(&shape.raw) }) + }) + .collect(); + println!("{}", serde_json::to_string(&rows)?); + Ok(()) +} + fn read_deck(file: &str) -> Result<()> { let mut cache = CultCache::new(); cache.register_entry_type::()?; @@ -70,6 +166,8 @@ fn main() -> Result<()> { "write" => write_note(file, require_arg(&options, "runtime-id")?)?, "read" => read_note(file)?, "read-deck" => read_deck(file)?, + "write-shapes" => write_shapes(file)?, + "read-shapes" => read_shapes(file)?, "hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?, _ => return Err(anyhow!("unknown mode {mode}")), } diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 2636a9d3..4efa6d78 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -33,8 +33,7 @@ const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-r ? resolve(cultLibRoot, "packages", "cultcache-rs") : resolve(cultCacheTsRoot, "..", "cultcache-rs"); const rustInteropBinary = resolve( - cultcacheRsRoot, - "target", + process.env.CARGO_TARGET_DIR ?? resolve(cultcacheRsRoot, "target"), "debug", "examples", process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop", @@ -1273,6 +1272,48 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth } }); +// A Bifrost-shaped store (a union field, nested bytes) written by one of C# and Rust is opened by the other: C# reads through +// its store, Rust through the read-only snapshot door, which takes no lock and writes nothing. Leftover staging files from an +// interrupted write of either runtime sit beside the store; a read neither deletes them nor lets them change what it reads, and +// the store file's bytes are the same after every read. +test("A store written by C# or Rust with a union and nested bytes is read back equal by the other, read-only, past leftover staging files", async () => { + await buildInteropPeers(); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-shapes-")); + const rustRead = (file: string) => runJsonCommand("rust-read-shapes", rustInteropBinary, ["read-shapes", "--file", file], cultcacheRsRoot); + const csharpRead = (file: string) => runJsonCommand("csharp-read-shapes", dotnetCommand, [csharpInteropDll, "read-shapes", "--file", file], cultLibRoot); + const peers = [ + { + writer: "csharp", + write: (file: string) => runJsonCommand("csharp-write-shapes", dotnetCommand, [csharpInteropDll, "write-shapes", "--file", file], cultLibRoot), + read: rustRead, + }, + { + writer: "rust", + write: (file: string) => runJsonCommand("rust-write-shapes", rustInteropBinary, ["write-shapes", "--file", file], cultcacheRsRoot), + read: csharpRead, + }, + ]; + for (const { writer, write, read } of peers) { + const file = join(tempDir, `${writer}.cc`); + const written = await write(file); + assert.equal(written.length, 2); + // The leftovers an interrupted write leaves: C# stages as `...tmp`, Rust as `..tmp`; both hold partial bytes. + const leftovers = [ + join(tempDir, `.${writer}.cc.0123456789abcdef0123456789abcdef.tmp`), + join(tempDir, `${writer}.cc.00000000-0000-4000-8000-000000000000.tmp`), + ]; + for (const leftover of leftovers) { + await writeFile(leftover, Buffer.from([0x93, 0xb2])); + } + const before = await readFile(file); + assert.deepEqual(await read(file), written, `${writer}'s store did not read back equal`); + assert.deepEqual(await readFile(file), before, `reading ${writer}'s store changed its bytes`); + for (const leftover of leftovers) { + assert.ok(await exists(leftover), `reading ${writer}'s store removed ${leftover}`); + } + } +}); + // Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own // note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other // runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every diff --git a/tests/GameCult.Caching.InteropPeer/Program.cs b/tests/GameCult.Caching.InteropPeer/Program.cs index 3c8eb92b..f0f0479e 100644 --- a/tests/GameCult.Caching.InteropPeer/Program.cs +++ b/tests/GameCult.Caching.InteropPeer/Program.cs @@ -51,6 +51,12 @@ static async Task ProgramMainAsync(string[] args) case "edit-in-place": await EditInPlaceAsync(file, RequireArg(options, "kind")); return 0; + case "write-shapes": + await WriteShapesAsync(file); + return 0; + case "read-shapes": + await ReadShapesAsync(file); + return 0; case "hold-lock": HoldLock(file, int.Parse(RequireArg(options, "ms"), CultureInfo.InvariantCulture)); return 0; @@ -96,6 +102,49 @@ static async Task WriteAsync(string file, string runtimeId) WriteJsonLine(note); } +// Records a reader of a Bifrost-shaped store must open: a union field (two arms) and nested bytes (a bin member, a bin list). +static async Task WriteShapesAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + foreach (var shape in InteropShapes()) + await cache.AddAsync(shape, new CultRecordHandle(new CultRecordKey(shape.DocumentId))); + cache.FlushAllBackingStores(); + WriteShapesJsonLine(InteropShapes()); +} + +static async Task ReadShapesAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + WriteShapesJsonLine(cache.AllEntries.OfType()); +} + +static CultCacheInteropShape[] InteropShapes() => +[ + new() { DocumentId = "shape:text", Arm = new InteropTextArm { Text = "union arm 0" }, Raw = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray() }, + new() + { + DocumentId = "shape:pack", + Arm = new InteropPackArm { Label = "union arm 1", Chunks = [new byte[] { 0xc0, 0xc1, 0x00 }, [], [0x92, 0x01, 0x02]] }, + Raw = [0xde, 0xad, 0xbe, 0xef] + } +]; + +// One line, ordered by document id, in the form the Rust peer prints. +static void WriteShapesJsonLine(IEnumerable shapes) => + Console.Out.WriteLine(JsonSerializer.Serialize(shapes.OrderBy(shape => shape.DocumentId, StringComparer.Ordinal).Select(shape => new + { + documentId = shape.DocumentId, + arm = shape.Arm switch + { + InteropTextArm text => (object)new { kind = "text", text = text.Text }, + InteropPackArm pack => new { kind = "pack", label = pack.Label, chunks = pack.Chunks.Select(Convert.ToHexString).ToArray() }, + _ => throw new InvalidOperationException("Unknown arm.") + }, + raw = Convert.ToHexString(shape.Raw) + }))); + static async Task ReadAsync(string file) { var cache = BuildCache(file); @@ -328,3 +377,28 @@ public sealed class CultCacheInteropMark [Key(0)] public string Label { get; set; } = string.Empty; [Key(1)] [CultElementId] public string Id { get; set; } = string.Empty; } + +[CultDocument("cultcache.interop-shape", "cultcache.interop_shape.v1")] +[MessagePackObject] +public sealed class CultCacheInteropShape +{ + [Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty; + [Key(1)] public InteropArm Arm { get; set; } = new InteropTextArm(); + [Key(2)] public byte[] Raw { get; set; } = Array.Empty(); +} + +[Union(0, typeof(InteropTextArm)), Union(1, typeof(InteropPackArm))] +public abstract class InteropArm { } + +[MessagePackObject] +public sealed class InteropTextArm : InteropArm +{ + [Key(0)] public string Text { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class InteropPackArm : InteropArm +{ + [Key(0)] public string Label { get; set; } = string.Empty; + [Key(1)] public List Chunks { get; set; } = new(); +}