From 282dc22265c2706942436a7992a3e01b3b7c06a4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 13 Jul 2026 21:48:22 +0000 Subject: [PATCH 1/3] test(hyper): table-driven refusals + id/1 unreachable-node degradation --- test/hyper_test.exs | 48 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/test/hyper_test.exs b/test/hyper_test.exs index 633698c0..9be6c4c8 100644 --- a/test/hyper_test.exs +++ b/test/hyper_test.exs @@ -25,15 +25,27 @@ defmodule HyperTest do # owning node's Hyper.Node.exec/3. The final hop into a live guest/relay is # covered by the Hyper.Node.FireVMM.Agent suite and the live E2E. - test "exec/3 with an unregistered vm_id returns {:error, :not_found}" do - # A vm_id shaped like a real one but never registered in the routing table. - assert Hyper.exec("vaaaaaaaaaaaaaaaa", ["/bin/true"]) == {:error, :not_found} - end + # Every cluster entry point that resolves a VM to a node must short-circuit to + # {:error, :not_found} when the target is unresolvable, rather than misroute or + # crash. `:__self__` resolves to this test's pid at runtime: a live pid that was + # never registered as a {vm_id, :supervisor}, so id/1 reverse-resolves to nil. + for {name, {fun, args}} <- [ + {"exec/3 by an unregistered vm_id", {:exec, ["vaaaaaaaaaaaaaaaa", ["/bin/true"]]}}, + {"exec/3 by a non-supervisor pid", {:exec, [:__self__, ["/bin/true"]]}}, + {"usage/1 by a non-supervisor pid", {:usage, [:__self__]}}, + {"fork_vm/1 by an unregistered vm_id", {:fork_vm, ["vaaaaaaaaaaaaaaaa"]}} + ] do + test "#{name} refuses with {:error, :not_found}" do + {fun, args} = unquote(Macro.escape({fun, args})) + + args = + Enum.map(args, fn + :__self__ -> self() + other -> other + end) - test "exec/3 with a pid that is not a VM supervisor returns {:error, :not_found}" do - # self/0 is a live pid but was never registered under {vm_id, :supervisor}, - # so id/1 resolves to nil and exec/3 refuses rather than misrouting. - assert Hyper.exec(self(), ["/bin/true"]) == {:error, :not_found} + assert apply(Hyper, fun, args) == {:error, :not_found} + end end test "exec/3 routes a resolvable VM into the owning node's Hyper.Node.exec/3, by vm_id and by pid" do @@ -55,6 +67,14 @@ defmodule HyperTest do end end + test "id/1 answers nil when the VM's owning node is unreachable" do + # id/1 erpc's to node(pid). When that node can never be reached, the {:erpc, + # :noconnection} transport failure must degrade to nil (the VM died with its + # host, so "unknown" is truthful) rather than crash the caller. + unreachable = fabricate_pid_on(:"ghost@127.0.0.1") + assert Hyper.id(unreachable) == nil + end + # Horde materialises a registration into the local replica asynchronously, so # poll whereis/1 until the entry is visible before routing against it. defp await_route(vm_id, tries \\ 200) @@ -70,4 +90,16 @@ defmodule HyperTest do :ok end end + + # A pid term whose node/1 is `node_name`, built without connecting to it. The + # external NEW_PID_EXT (tag 88) form: node as a SMALL_ATOM_UTF8_EXT (tag 119, + # 8-bit length), then id/serial/creation words. Lets us exercise the + # unreachable-owning-node path with no peer node, epmd, or distribution. + defp fabricate_pid_on(node_name) do + node_bin = :erlang.atom_to_binary(node_name, :utf8) + + :erlang.binary_to_term( + <<131, 88, 119, byte_size(node_bin)::8, node_bin::binary, 1::32, 0::32, 1::32>> + ) + end end From b3e406f0d185c544f2e53e928b2c2dc14ae862b0 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 13 Jul 2026 21:56:02 +0000 Subject: [PATCH 2/3] test(metering): Hyper.usage/1 lifetime + stopped-VM + pid delegation contracts --- test/hyper/metering/usage_test.exs | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/hyper/metering/usage_test.exs b/test/hyper/metering/usage_test.exs index b3213b21..f4b4187e 100644 --- a/test/hyper/metering/usage_test.exs +++ b/test/hyper/metering/usage_test.exs @@ -84,4 +84,49 @@ defmodule Hyper.Metering.UsageTest do cpu_time: Time.zero() }) end + + describe "Hyper.usage/1 across a VM's lifetime" do + test "a stopped-but-metered VM reports its recorded lifetime total" do + vm_id = Hyper.Vm.Id.generate() + record!(vm_id, 0, 60, Time.ms(1_500)) + record!(vm_id, 60, 120, Time.ms(500)) + + # Not registered anywhere: whereis/1 is nil, so the not-yet-flushed + # remainder is zero and usage/1 returns the flushed windows alone. + assert {:ok, total} = Hyper.usage(vm_id) + assert Time.as_ms(total) == 2_000 + end + + test "an entirely unknown VM is :not_found" do + # Never metered and not running anywhere: both halves of the lookup miss. + assert Hyper.usage(Hyper.Vm.Id.generate()) == {:error, :not_found} + end + + test "usage/1 resolves a pid handle to its vm_id and delegates" do + vm_id = Hyper.Vm.Id.generate() + record!(vm_id, 0, 60, Time.ms(750)) + :ok = Hyper.Cluster.Routing.register_self({vm_id, :supervisor}) + await_local(vm_id) + + # id/1 reverse-resolves self() -> vm_id; usage/1 then delegates to the + # vm_id clause. whereis/1 resolves this node, whose meter is absent, so the + # unflushed remainder is zero and the flushed total is returned. + assert {:ok, total} = Hyper.usage(self()) + assert Time.as_ms(total) == 750 + end + end + + # Horde materialises a local registration into its replica asynchronously; poll + # until this node owns the routing entry before routing against it. + defp await_local(vm_id, tries \\ 200) + defp await_local(vm_id, 0), do: flunk("routing entry for #{vm_id} never materialised") + + defp await_local(vm_id, tries) do + if Hyper.whereis(vm_id) == node() do + :ok + else + Process.sleep(5) + await_local(vm_id, tries - 1) + end + end end From c07973d8b1b22172432ab31a3954c2b9030e6fda Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 13 Jul 2026 22:05:20 +0000 Subject: [PATCH 3/3] test(metering): clarify where the absent-meter exit is swallowed --- test/hyper/metering/usage_test.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/hyper/metering/usage_test.exs b/test/hyper/metering/usage_test.exs index f4b4187e..f628fbf1 100644 --- a/test/hyper/metering/usage_test.exs +++ b/test/hyper/metering/usage_test.exs @@ -109,8 +109,9 @@ defmodule Hyper.Metering.UsageTest do await_local(vm_id) # id/1 reverse-resolves self() -> vm_id; usage/1 then delegates to the - # vm_id clause. whereis/1 resolves this node, whose meter is absent, so the - # unflushed remainder is zero and the flushed total is returned. + # vm_id clause. whereis/1 resolves this node, so unflushed_on/2 does erpc + # into Meter.unflushed/1; no meter is registered, so it swallows the + # GenServer :noproc exit and returns zero. The flushed total is returned. assert {:ok, total} = Hyper.usage(self()) assert Time.as_ms(total) == 750 end