From 12017e52865c8f5830410c267e8a468adfa5d8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B6Rei?= <212411920+arcjet-rei@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:31:34 -0400 Subject: [PATCH] fix: lift a record into its qualified Go type When two interfaces declare a record with the same name, gravity declares each Go type with its interface's name (HostJob, OtherJob), but RecordLift constructed the bare name (Job{...}), so the bindings failed to compile with "undefined: Job". RecordLift now names the type with qualified_type_name, as VariantLift and EnumLift already do. The regressions example gains two interfaces that each declare `record job`, and the guest passes one to each host. Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/func.rs | 5 +- cmd/gravity/src/codegen/imports.rs | 46 ++++++++++++ cmd/gravity/tests/cmd/regressions.stdout | 95 ++++++++++++++++++++++++ examples/regressions/regressions_test.go | 27 +++++++ examples/regressions/src/lib.rs | 8 +- examples/regressions/wit/regressions.wit | 23 ++++++ 6 files changed, 201 insertions(+), 3 deletions(-) diff --git a/cmd/gravity/src/codegen/func.rs b/cmd/gravity/src/codegen/func.rs index 06ad37b..c789f0e 100644 --- a/cmd/gravity/src/codegen/func.rs +++ b/cmd/gravity/src/codegen/func.rs @@ -849,7 +849,8 @@ impl Bindgen for Func<'_> { results.push(Operand::SingleValue(var.into())) } } - Instruction::RecordLift { record, name, .. } => { + Instruction::RecordLift { record, ty, .. } => { + let name = crate::qualified_type_name(*ty, resolve); let tmp = self.tmp(); let value = &format!("value{tmp}"); let fields = record @@ -860,7 +861,7 @@ impl Bindgen for Func<'_> { quote_in! {self.body => $['\r'] - $value := $(GoIdentifier::public(*name)){ + $value := $(GoIdentifier::public(&name)){ $(for (name, op) in fields join ($['\r']) => $name: $op,) } }; diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index 61fb3a7..628a190 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -1015,4 +1015,50 @@ mod tests { fn test_result_as_function_result_still_resolves() { resolve_export_result("result"); } + + /// When two interfaces declare a record with the same name, both Go types + /// carry their interface's name, and the host function that lifts the + /// record must construct the qualified type. + #[test] + fn test_host_function_lifts_record_by_its_qualified_name() { + let fixture = Fixture::parse( + "package test:fixture; + interface host { + record job { a: u32 } + ping: func(j: job) -> u32; + } + interface other { + record job { b: u32 } + } + world test-world { + import host; + import other; + }", + ); + let ping = fixture.function("host", "ping"); + let method = InterfaceMethod { + name: "ping".to_string(), + go_method_name: GoIdentifier::public("Ping"), + parameters: vec![Parameter { + name: GoIdentifier::private("j"), + go_type: GoType::UserDefined("HostJob".to_string()), + wit_type: ping.params[0].ty, + }], + return_type: Some(WitReturn { + go_type: GoType::Uint32, + wit_type: Type::U32, + }), + wit_function: ping.clone(), + }; + + let code_str = host_function(&fixture, &method); + assert!( + code_str.contains(":= HostJob{"), + "expected the lift to construct HostJob:\n{code_str}" + ); + assert!( + !code_str.contains(":= Job{"), + "the lift constructs the unqualified Job:\n{code_str}" + ); + } } diff --git a/cmd/gravity/tests/cmd/regressions.stdout b/cmd/gravity/tests/cmd/regressions.stdout index 4893f1b..06d6cb5 100644 --- a/cmd/gravity/tests/cmd/regressions.stdout +++ b/cmd/gravity/tests/cmd/regressions.stdout @@ -99,6 +99,28 @@ type IRegressionsIpSource interface { ) *string } +type IRegressionsScheduler interface { + Submit( + ctx context.Context, + j SchedulerJob, + ) uint32 +} + +type SchedulerJob struct { + Id uint32 +} + +type IRegressionsArchiver interface { + Store( + ctx context.Context, + j ArchiverJob, + ) uint32 +} + +type ArchiverJob struct { + Name string +} + type RegressionsFactory struct { runtime wazero.Runtime module wazero.CompiledModule @@ -112,9 +134,36 @@ func NewRegressionsFactory( emailChecker IRegressionsEmailChecker, botVerifier IRegressionsBotVerifier, ipSource IRegressionsIpSource, + scheduler IRegressionsScheduler, + archiver IRegressionsArchiver, ) (*RegressionsFactory, error) { wazeroRuntime := wazero.NewRuntime(ctx) + _, err7 := wazeroRuntime.NewHostModuleBuilder("gravity:regressions/archiver"). + NewFunctionBuilder(). + WithFunc(func( + ctx context.Context, + mod api.Module, + arg0 uint32, + arg1 uint32, + ) uint32{ + buf0, ok0 := mod.Memory().Read(arg0, arg1) + if !ok0 { + panic(errors.New("failed to read bytes from memory")) + } + str0 := string(buf0) + value1 := ArchiverJob{ + Name: str0, + } + value2 := archiver.Store(ctx, value1) + result3 := uint32(value2) + return result3 + }). + Export("store"). + Instantiate(ctx) + if err7 != nil { + return nil, err7 + } _, err4 := wazeroRuntime.NewHostModuleBuilder("gravity:regressions/bot-verifier"). NewFunctionBuilder(). WithFunc(func( @@ -305,6 +354,26 @@ func NewRegressionsFactory( if err1 != nil { return nil, err1 } + _, err6 := wazeroRuntime.NewHostModuleBuilder("gravity:regressions/scheduler"). + NewFunctionBuilder(). + WithFunc(func( + ctx context.Context, + mod api.Module, + arg0 uint32, + ) uint32{ + result0 := uint32(arg0) + value1 := SchedulerJob{ + Id: result0, + } + value2 := scheduler.Submit(ctx, value1) + result3 := uint32(value2) + return result3 + }). + Export("submit"). + Instantiate(ctx) + if err6 != nil { + return nil, err6 + } // Compiling the module takes a LONG time, so we want to do it once and hold // onto it with the Runtime @@ -541,3 +610,29 @@ func (i *RegressionsInstance) RunIpLookup( return str4 } +func (i *RegressionsInstance) RunJobs( + ctx context.Context, + id uint32, + name string, +) uint32 { + arg0 := id + arg1 := name + result0 := uint32(arg0) + memory1 := i.module.Memory() + realloc1 := i.module.ExportedFunction("cabi_realloc") + ptr1, len1, err1 := writeString(ctx, arg1, memory1, realloc1) + // The return type doesn't contain an error so we panic if one is encountered + if err1 != nil { + panic(err1) + } + raw2, err2 := i.module.ExportedFunction("run-jobs").Call(ctx, uint64(result0), uint64(ptr1), uint64(len1)) + // The return type doesn't contain an error so we panic if one is encountered + if err2 != nil { + panic(err2) + } + + results2 := raw2[0] + result3 := uint32(results2) + return result3 +} + diff --git a/examples/regressions/regressions_test.go b/examples/regressions/regressions_test.go index b6b660e..49cd5e5 100644 --- a/examples/regressions/regressions_test.go +++ b/examples/regressions/regressions_test.go @@ -96,6 +96,20 @@ func (IpSource) Lookup(_ context.Context, ip string) *string { return nil } +// Scheduler and Archiver — regression 6 (cross-interface record collision). +// Each receives its own interface's `job` type. +type Scheduler struct{} + +func (Scheduler) Submit(_ context.Context, j SchedulerJob) uint32 { + return j.Id +} + +type Archiver struct{} + +func (Archiver) Store(_ context.Context, j ArchiverJob) uint32 { + return uint32(len(j.Name)) +} + func newInstance(t *testing.T) *RegressionsInstance { t.Helper() fac, err := NewRegressionsFactory( @@ -106,6 +120,8 @@ func newInstance(t *testing.T) *RegressionsInstance { EmailChecker{}, BotVerifier{}, IpSource{}, + Scheduler{}, + Archiver{}, ) if err != nil { t.Fatal(err) @@ -260,3 +276,14 @@ func TestImportCallbackOptionString(t *testing.T) { t.Errorf("RunIpLookup(\"0.0.0.0\") = %q, want \"absent\"", got) } } + +// TestCrossInterfaceRecordCollision tests regression 6: two imported +// interfaces both declare `record job`. The guest passes one to each host, +// and each host reads a field only its own interface's record has. +func TestCrossInterfaceRecordCollision(t *testing.T) { + ins := newInstance(t) + + if got := ins.RunJobs(t.Context(), 40, "ab"); got != 42 { + t.Errorf("RunJobs(40, \"ab\") = %d, want 42", got) + } +} diff --git a/examples/regressions/src/lib.rs b/examples/regressions/src/lib.rs index b884c91..4daae2c 100644 --- a/examples/regressions/src/lib.rs +++ b/examples/regressions/src/lib.rs @@ -1,4 +1,6 @@ -use gravity::regressions::{bot_verifier, checker, email_checker, ip_source, pinger, processor}; +use gravity::regressions::{ + archiver, bot_verifier, checker, email_checker, ip_source, pinger, processor, scheduler, +}; wit_bindgen::generate!({ world: "regressions", @@ -48,4 +50,8 @@ impl Guest for RegressionsWorld { fn run_ip_lookup(ip: String) -> String { ip_source::lookup(&ip).unwrap_or_else(|| "absent".to_string()) } + + fn run_jobs(id: u32, name: String) -> u32 { + scheduler::submit(scheduler::Job { id }) + archiver::store(&archiver::Job { name }) + } } diff --git a/examples/regressions/wit/regressions.wit b/examples/regressions/wit/regressions.wit index e40ddb0..2a516e7 100644 --- a/examples/regressions/wit/regressions.wit +++ b/examples/regressions/wit/regressions.wit @@ -54,6 +54,23 @@ interface ip-source { lookup: func(ip: string) -> option; } +// Regression 6: two interfaces in the same world both define a record named +// `job`, so each Go type carries its interface's name. The host functions +// lift each record into its own interface's type. +interface scheduler { + record job { + id: u32, + } + submit: func(j: job) -> u32; +} + +interface archiver { + record job { + name: string, + } + store: func(j: job) -> u32; +} + world regressions { import checker; import processor; @@ -61,6 +78,8 @@ world regressions { import email-checker; import bot-verifier; import ip-source; + import scheduler; + import archiver; export check-enabled: func(key: string) -> bool; export check-status: func(key: string) -> u32; @@ -76,4 +95,8 @@ world regressions { // Round-trip the option callback. Returns "absent" when the // host returned `none`. export run-ip-lookup: func(ip: string) -> string; + + // Passes one `job` record to each interface and returns the sum of what + // the two hosts answered. + export run-jobs: func(id: u32, name: string) -> u32; }