@@ -11,7 +11,13 @@ defmodule Hyper.E2e.ForkTest do
1111 - a VM created from the derived image composes that chain (dm-snapshot over
1212 the published COW store) and boots with the parent's pre-publish state —
1313 including the parent's writes, excluding the child's;
14- - all forked writable volumes are reclaimed on stop (no dm leak).
14+ - all forked writable volumes are reclaimed on stop (no dm leak);
15+ - `Hyper.Vm.fork/1`'s slow-fork fallback: when a real node-admission refusal
16+ stops `fast_fork/1`, `fork/1` publishes exactly one derived image before
17+ re-placing cluster-wide, and the cluster-wide capacity verdict surfaces
18+ unmasked when no other node can take the child;
19+ - `fast_fork/1` and `fork/1` on a dead VM handle are `{:error, :not_found}`,
20+ never misclassified as a capacity refusal.
1521
1622 Runs only under `--only integration` / `--include integration` on a host
1723 provisioned per docs/cookbook/install.md (CI: the `integration` job).
@@ -27,6 +33,17 @@ defmodule Hyper.E2e.ForkTest do
2733 # limits, which shared GHA egress IPs routinely exhaust.
2834 @ image System . get_env ( "HYPER_E2E_IMAGE" , "public.ecr.aws/docker/library/alpine:3.19" )
2935
36+ alias Hyper.Img.Db . { Image , Repo }
37+
38+ # Boot :micro fillers until cluster placement refuses, so the next
39+ # fast_fork must hit a node-admission refusal.
40+ defp saturate ( img_id ) do
41+ case Hyper . create_vm ( % Hyper.Vm.Spec { img_id: img_id , type: :micro } ) do
42+ { :ok , vm } -> [ vm | saturate ( img_id ) ]
43+ { :error , reason } when reason in [ :no_capacity , :exhausted ] -> [ ]
44+ end
45+ end
46+
3047 test "fast_fork isolates writes; published delta composes and boots" do
3148 assert { :ok , img_id } = Hyper.Img.OciLoader . load ( @ image )
3249
@@ -108,4 +125,47 @@ defmodule Hyper.E2e.ForkTest do
108125
109126 assert cousin_sees_childfile != 0 , "child's writes leaked into the published delta"
110127 end
128+
129+ test "fork/1 falls back to publish-and-reschedule when the node refuses admission" do
130+ assert { :ok , img_id } = Hyper.Img.OciLoader . load ( @ image )
131+
132+ assert { :ok , parent } = Hyper . create_vm ( % Hyper.Vm.Spec { img_id: img_id , type: :micro } )
133+ on_exit ( fn -> Hyper.Node . stop_image_vm ( parent ) end )
134+
135+ # Unique disk content so the published delta (and thus the derived image
136+ # id) cannot collide with any image published by an earlier test.
137+ assert { :ok , % { exit_code: 0 } } =
138+ await_exec (
139+ parent ,
140+ [ "/bin/sh" , "-c" , "head -c 32 /dev/urandom > /marker && sync" ] ,
141+ :timer . minutes ( 3 )
142+ )
143+
144+ fillers = saturate ( img_id )
145+ on_exit ( fn -> Enum . each ( fillers , & Hyper.Node . stop_image_vm / 1 ) end )
146+
147+ images_before = Repo . aggregate ( Image , :count )
148+
149+ # Single-node cluster: fast_fork must refuse with a capacity verdict,
150+ # fork/1 must then publish the parent's disk state (the observable slow-
151+ # fork side effect) and re-place cluster-wide — where the same saturated
152+ # node is the only candidate, so the cluster verdict surfaces unmasked.
153+ # fork/1's doc names both cluster-wide verdicts; either proves the point.
154+ assert { :error , reason } = Hyper.Vm . fork ( parent )
155+
156+ assert reason in [ :no_capacity , :exhausted ] ,
157+ "expected the cluster-wide capacity verdict, got #{ inspect ( reason ) } "
158+
159+ assert Repo . aggregate ( Image , :count ) == images_before + 1 ,
160+ "slow fork must publish exactly one derived image before re-placing"
161+ end
162+
163+ test "forking a dead VM handle is :not_found, with no fallback attempted" do
164+ dead = spawn ( fn -> :ok end )
165+ ref = Process . monitor ( dead )
166+ assert_receive { :DOWN , ^ ref , :process , ^ dead , _ }
167+
168+ assert Hyper.Vm . fast_fork ( dead ) == { :error , :not_found }
169+ assert Hyper.Vm . fork ( dead ) == { :error , :not_found }
170+ end
111171end
0 commit comments