@@ -10,6 +10,7 @@ import (
1010 "os"
1111 "path/filepath"
1212 "strings"
13+ "syscall"
1314 "testing"
1415 "time"
1516
@@ -554,13 +555,15 @@ func TestFirecrackerSnapshotFeature(t *testing.T) {
554555
555556// TestFirecrackerForkFromTemplate exercises the full template-driven fork
556557// path under the state-machine design: a firecracker source goes Running →
557- // Standby, the first fork implicitly promotes it to Template, and the fork:
558+ // Standby → Template (explicit promote), then a fork:
558559//
559560// (a) reaches Running,
560- // (b) has its mem-file as a symlink into the template's snapshot dir,
561- // (c) bumps the template's ForkCount to 1,
561+ // (b) has its mem-file hardlinked to the source's snapshot mem-file
562+ // (the fan-out optimisation),
563+ // (c) is counted as a live fork of the template,
562564// (d) registers with the per-template uffd page server,
563- // (e) on delete, decrements the ForkCount and detaches from uffd.
565+ // (e) on delete, the fork count drops back to 0 and the fork detaches
566+ // from uffd.
564567func TestFirecrackerForkFromTemplate (t * testing.T ) {
565568 t .Parallel ()
566569 requireFirecrackerIntegrationPrereqs (t )
@@ -600,7 +603,11 @@ func TestFirecrackerForkFromTemplate(t *testing.T) {
600603 require .Equal (t , StateStandby , source .State )
601604 require .True (t , source .HasSnapshot )
602605
603- // Forking from the standby source implicitly promotes it to Template.
606+ // Promote to Template explicitly — only Template sources get fan-out.
607+ source , err = mgr .PromoteToTemplate (ctx , sourceID )
608+ require .NoError (t , err )
609+ require .Equal (t , StateTemplate , source .State )
610+
604611 forked , err := mgr .ForkInstance (ctx , sourceID , ForkInstanceRequest {
605612 Name : "fc-tpl-fork" ,
606613 TargetState : StateRunning ,
@@ -617,21 +624,33 @@ func TestFirecrackerForkFromTemplate(t *testing.T) {
617624 }
618625 })
619626
620- // (b) The fork's mem-file must be a symlink into the source's snapshot.
621- forkMemPath := filepath .Join (p .InstanceSnapshotLatest (forkID ), templateSharedMemFileName )
622- info , err := os .Lstat (forkMemPath )
623- require .NoError (t , err , "fork mem-file should exist at snapshot-latest/memory" )
624- assert .Equal (t , os .ModeSymlink , info .Mode ()& os .ModeSymlink , "fork mem-file should be a symlink, not a copy" )
625- target , err := os .Readlink (forkMemPath )
626- require .NoError (t , err )
627- expectedTarget := filepath .Join (p .InstanceSnapshotLatest (sourceID ), templateSharedMemFileName )
628- assert .Equal (t , expectedTarget , target , "fork mem-file symlink should point at the template's mem-file" )
629-
630- // (c) The source instance is now a Template with ForkCount=1.
627+ // (b) The fork's mem-file must share the source's inode (hardlink), not
628+ // be a copy. We can't compare paths because the link is by inode; we
629+ // compare st_ino + st_dev between the two instances' mem-files.
630+ //
631+ // Firecracker retains the post-restore snapshot dir as snapshot-base
632+ // (see restoreRetainedSnapshotBase), so after the Standby -> Running
633+ // transition the hardlink lives under snapshot-base/, not snapshot-latest/.
634+ // Hardlinks survive the rename because they bind to the inode.
635+ forkMemPath := filepath .Join (p .InstanceSnapshotBase (forkID ), templateSharedMemFileName )
636+ srcMemPath := filepath .Join (p .InstanceSnapshotLatest (sourceID ), templateSharedMemFileName )
637+ forkInfo , err := os .Stat (forkMemPath )
638+ require .NoError (t , err , "fork mem-file should exist at snapshot-base/memory after restore" )
639+ assert .True (t , forkInfo .Mode ().IsRegular (), "fork mem-file should be a regular file (hardlink), not a symlink" )
640+ srcInfo , err := os .Stat (srcMemPath )
641+ require .NoError (t , err )
642+ forkSys := forkInfo .Sys ().(* syscall.Stat_t )
643+ srcSys := srcInfo .Sys ().(* syscall.Stat_t )
644+ assert .Equal (t , srcSys .Ino , forkSys .Ino , "fork mem-file should share the source's inode (hardlink, not copy)" )
645+ assert .Equal (t , srcSys .Dev , forkSys .Dev , "fork mem-file should be on the same filesystem as source" )
646+
647+ // (c) The source instance is a Template with exactly one live fork.
631648 sourceMeta , err := mgr .loadMetadata (sourceID )
632649 require .NoError (t , err )
633- assert .True (t , sourceMeta .StoredMetadata .IsTemplate , "source should be promoted to Template on first fork" )
634- assert .Equal (t , 1 , sourceMeta .StoredMetadata .ForkCount , "template fork refcount should be 1 after one fork" )
650+ assert .True (t , sourceMeta .StoredMetadata .IsTemplate , "source should be a Template" )
651+ forks , err := mgr .countTemplateForks (sourceID )
652+ require .NoError (t , err )
653+ assert .Equal (t , 1 , forks , "template fork count should be 1 after one fork" )
635654
636655 // (d) The per-template uffd page server should be tracking this fork.
637656 require .NotNil (t , mgr .uffd )
@@ -641,8 +660,8 @@ func TestFirecrackerForkFromTemplate(t *testing.T) {
641660 require .NoError (t , mgr .DeleteInstance (ctx , forkID ))
642661 deletedFork = true
643662
644- sourceMetaAfter , err := mgr .loadMetadata (sourceID )
663+ forksAfter , err := mgr .countTemplateForks (sourceID )
645664 require .NoError (t , err )
646- assert .Equal (t , 0 , sourceMetaAfter . StoredMetadata . ForkCount , "template fork refcount should drop back to 0" )
665+ assert .Equal (t , 0 , forksAfter , "template fork count should drop back to 0" )
647666 assert .False (t , mgr .uffd .hasFork (sourceID , forkID ), "uffd tracker should no longer track the deleted fork" )
648667}
0 commit comments