diff --git a/test/hyper/metering/usage_test.exs b/test/hyper/metering/usage_test.exs index b3213b21..f628fbf1 100644 --- a/test/hyper/metering/usage_test.exs +++ b/test/hyper/metering/usage_test.exs @@ -84,4 +84,50 @@ 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, 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 + 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 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