fix(runtime): make Copy to RAM switch live backing
This commit is contained in:
@@ -155,41 +155,33 @@ func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) (ret
|
||||
log("Already running from RAM — installation media can be safely disconnected.")
|
||||
return nil
|
||||
}
|
||||
originalStatus := state.LiveBootSource
|
||||
|
||||
squashfsFiles, sourceAvailable := ensureLiveMediumAvailable(log)
|
||||
|
||||
dstDir := installToRAMDir
|
||||
loopsSwitched := false
|
||||
defer func() {
|
||||
if retErr == nil || loopsSwitched {
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(dstDir)
|
||||
log("Removed incomplete RAM copy.")
|
||||
}()
|
||||
|
||||
// If the source medium is unavailable, check whether a previous run already
|
||||
// produced a complete copy in RAM. If so, skip the copy phase and proceed
|
||||
// directly to the loop-rebind / bind-mount steps.
|
||||
// A worker or virtual-CD disconnect may have interrupted the task after a
|
||||
// complete squashfs copy was fsynced. That copy is useful, but it is not
|
||||
// success by itself: the active loop device still has to accept it as its
|
||||
// backing file and the live-medium mount must move to tmpfs below.
|
||||
if !sourceAvailable {
|
||||
copiedFiles, _ := filepath.Glob(filepath.Join(dstDir, "*.squashfs"))
|
||||
if len(copiedFiles) > 0 {
|
||||
log("Source medium not available, but a previous RAM copy was found — resuming from existing copy.")
|
||||
// Proceed to rebind with the already-copied files.
|
||||
for _, dst := range copiedFiles {
|
||||
base := filepath.Base(dst)
|
||||
// Re-associate the loop device that was originally backed by the
|
||||
// source file (now gone); find it by the old source path pattern.
|
||||
srcGuess := "/run/live/medium/live/" + base
|
||||
loopDev, lerr := findLoopForFile(srcGuess)
|
||||
if lerr != nil {
|
||||
log(fmt.Sprintf("Loop device for %s not found (%v) — skipping re-association.", base, lerr))
|
||||
continue
|
||||
}
|
||||
if rerr := reassociateLoopDevice(loopDev, dst); rerr != nil {
|
||||
log(fmt.Sprintf("Warning: could not re-associate %s → %s: %v", loopDev, dst, rerr))
|
||||
} else {
|
||||
log(fmt.Sprintf("Loop device %s now backed by RAM copy.", loopDev))
|
||||
}
|
||||
}
|
||||
goto bindMedium
|
||||
}
|
||||
if len(copiedFiles) == 0 {
|
||||
return fmt.Errorf("no squashfs files found in /run/live/medium/live/ and no prior RAM copy in %s — reconnect the installation medium and retry (or run bee-remount-medium as root)", dstDir)
|
||||
}
|
||||
log("Source medium not available, but a previous RAM copy was found — verifying and resuming it.")
|
||||
squashfsFiles = copiedFiles
|
||||
}
|
||||
|
||||
{
|
||||
if sourceAvailable {
|
||||
free := freeMemBytes()
|
||||
var needed int64
|
||||
for _, sf := range squashfsFiles {
|
||||
@@ -204,7 +196,6 @@ func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) (ret
|
||||
return fmt.Errorf("insufficient RAM: need %s, available %s",
|
||||
humanBytes(needed+headroom), humanBytes(free))
|
||||
}
|
||||
}
|
||||
|
||||
if state.CopyPresent {
|
||||
log("Removing stale partial RAM copy before retry...")
|
||||
@@ -213,13 +204,6 @@ func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) (ret
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("create tmpfs dir: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if retErr == nil {
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(dstDir)
|
||||
log("Removed incomplete RAM copy.")
|
||||
}()
|
||||
|
||||
for _, sf := range squashfsFiles {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -232,44 +216,58 @@ func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) (ret
|
||||
return fmt.Errorf("copy %s: %v", base, err)
|
||||
}
|
||||
log(fmt.Sprintf("Copied %s.", base))
|
||||
|
||||
loopDev, err := findLoopForFile(sf)
|
||||
if err != nil {
|
||||
log(fmt.Sprintf("Loop device for %s not found (%v) — skipping re-association.", base, err))
|
||||
continue
|
||||
}
|
||||
if err := reassociateLoopDevice(loopDev, dst); err != nil {
|
||||
log(fmt.Sprintf("Warning: could not re-associate %s → %s: %v", loopDev, dst, err))
|
||||
} else {
|
||||
log(fmt.Sprintf("Loop device %s now backed by RAM copy.", loopDev))
|
||||
}
|
||||
}
|
||||
|
||||
bindMedium:
|
||||
log("Copying remaining medium files...")
|
||||
if err := cpDir(ctx, "/run/live/medium", dstDir, log); err != nil {
|
||||
log(fmt.Sprintf("Warning: partial copy: %v", err))
|
||||
log(fmt.Sprintf("Warning: partial metadata copy: %v", err))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mediumRebound := false
|
||||
switchedCount := 0
|
||||
for _, sf := range squashfsFiles {
|
||||
base := filepath.Base(sf)
|
||||
dst := filepath.Join(dstDir, base)
|
||||
oldBacking := sf
|
||||
if !sourceAvailable {
|
||||
oldBacking = filepath.Join("/run/live/medium/live", base)
|
||||
}
|
||||
loopDev, err := findLoopForFile(oldBacking)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find active loop device for %s: %v", base, err)
|
||||
}
|
||||
if err := reassociateLoopDevice(loopDev, dst); err != nil {
|
||||
return fmt.Errorf("switch %s to RAM copy %s: %v", loopDev, dst, err)
|
||||
}
|
||||
loopsSwitched = true
|
||||
newLoop, err := findLoopForFile(dst)
|
||||
if err != nil || newLoop != loopDev {
|
||||
return fmt.Errorf("verify %s RAM backing: active backing file did not change to %s", loopDev, dst)
|
||||
}
|
||||
switchedCount++
|
||||
log(fmt.Sprintf("Loop device %s now backed by RAM copy.", loopDev))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(mountSource("/run/live/medium")) != "" {
|
||||
log("Unmounting original live medium...")
|
||||
if err := umountLiveMedium(); err != nil {
|
||||
return fmt.Errorf("unmount original /run/live/medium: %v", err)
|
||||
}
|
||||
}
|
||||
if err := bindMount(dstDir, "/run/live/medium"); err != nil {
|
||||
log(fmt.Sprintf("Warning: rebind /run/live/medium → %s failed: %v", dstDir, err))
|
||||
} else {
|
||||
mediumRebound = true
|
||||
return fmt.Errorf("bind RAM copy on /run/live/medium: %v", err)
|
||||
}
|
||||
|
||||
log("Verifying live medium now served from RAM...")
|
||||
status := s.LiveBootSource()
|
||||
if err := verifyInstallToRAMStatus(status, dstDir, mediumRebound, log); err != nil {
|
||||
if err := verifyInstallToRAMStatus(status, switchedCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if status.InRAM {
|
||||
log(fmt.Sprintf("Verification passed: live medium now served from %s.", describeLiveBootSource(status)))
|
||||
}
|
||||
detachInstallMedium(status, log)
|
||||
log(fmt.Sprintf("Verification passed: %d loop device(s) and live medium now served from RAM.", switchedCount))
|
||||
detachInstallMedium(originalStatus, log)
|
||||
log("Done. Squashfs files are in RAM. Installation media has been detached when possible.")
|
||||
return nil
|
||||
}
|
||||
@@ -324,12 +322,6 @@ func detachInstallMedium(status LiveBootSource, log func(string)) {
|
||||
}
|
||||
|
||||
log("Detaching original installation medium...")
|
||||
if err := umountLiveMedium(); err != nil {
|
||||
log(fmt.Sprintf("Warning: could not unmount /run/live/medium: %v", err))
|
||||
} else {
|
||||
log("Unmounted /run/live/medium.")
|
||||
}
|
||||
|
||||
device := strings.TrimSpace(status.Device)
|
||||
if device == "" {
|
||||
device = strings.TrimSpace(status.Source)
|
||||
@@ -346,27 +338,14 @@ func detachInstallMedium(status LiveBootSource, log func(string)) {
|
||||
log(fmt.Sprintf("Ejected %s.", device))
|
||||
}
|
||||
|
||||
func verifyInstallToRAMStatus(status LiveBootSource, dstDir string, mediumRebound bool, log func(string)) error {
|
||||
if status.InRAM {
|
||||
return nil
|
||||
func verifyInstallToRAMStatus(status LiveBootSource, switchedLoops int) error {
|
||||
if switchedLoops <= 0 {
|
||||
return fmt.Errorf("install to RAM verification failed: no active squashfs loop device was switched to RAM")
|
||||
}
|
||||
|
||||
// The live medium mount was not redirected to RAM. This is expected when
|
||||
// booting from an ISO/CD-ROM: the squashfs loop device has a non-zero
|
||||
// offset and LOOP_CHANGE_FD cannot be used; the bind mount also fails
|
||||
// because the CD-ROM mount is in use. Check whether files were at least
|
||||
// copied to the tmpfs directory — that is sufficient for safe disconnection
|
||||
// once the kernel has paged in all actively-used data.
|
||||
files, _ := filepath.Glob(filepath.Join(dstDir, "*.squashfs"))
|
||||
if len(files) > 0 {
|
||||
if !mediumRebound {
|
||||
log(fmt.Sprintf("Note: squashfs copied to RAM (%s) but /run/live/medium still shows the original source.", dstDir))
|
||||
log("This is normal for CD-ROM boots. For a fully transparent RAM boot, add 'toram' to the kernel parameters.")
|
||||
if !status.InRAM {
|
||||
return fmt.Errorf("install to RAM verification failed: live medium still mounted from %s", describeLiveBootSource(status))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("install to RAM verification failed: live medium still mounted from %s and no squashfs found in %s", describeLiveBootSource(status), dstDir)
|
||||
}
|
||||
|
||||
func describeLiveBootSource(status LiveBootSource) string {
|
||||
@@ -519,8 +498,5 @@ func reassociateLoopDevice(loopDev, newFile string) error {
|
||||
if off := loopDeviceOffset(loopDev); off > 0 {
|
||||
return fmt.Errorf("loop device has non-zero offset (%d bytes, typical for ISO/CD-ROM) — LOOP_CHANGE_FD not supported; use 'toram' kernel parameter for RAM boot", off)
|
||||
}
|
||||
if err := exec.Command("losetup", "--replace", loopDev, newFile).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
return loopChangeFD(loopDev, newFile)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const ioctlLoopChangeFD = 0x4C08
|
||||
// LOOP_CHANGE_FD from <linux/loop.h>. 0x4C08 is LOOP_SET_DIRECT_IO and was
|
||||
// the reason every runtime Copy to RAM attempt ended in EINVAL.
|
||||
const ioctlLoopChangeFD = 0x4C06
|
||||
|
||||
func loopChangeFD(loopDev, newFile string) error {
|
||||
lf, err := os.OpenFile(loopDev, os.O_RDWR, 0)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build linux
|
||||
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoopChangeFDIOCTLMatchesLinuxABI(t *testing.T) {
|
||||
const linuxLoopChangeFD = 0x4C06
|
||||
if ioctlLoopChangeFD != linuxLoopChangeFD {
|
||||
t.Fatalf("ioctlLoopChangeFD=%#x want LOOP_CHANGE_FD %#x", ioctlLoopChangeFD, linuxLoopChangeFD)
|
||||
}
|
||||
}
|
||||
@@ -36,19 +36,21 @@ func TestInferLiveBootKind(t *testing.T) {
|
||||
func TestVerifyInstallToRAMStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dstDir := t.TempDir()
|
||||
|
||||
if err := verifyInstallToRAMStatus(LiveBootSource{InRAM: true, Kind: "ram", Source: "tmpfs"}, dstDir, false, nil); err != nil {
|
||||
if err := verifyInstallToRAMStatus(LiveBootSource{InRAM: true, Kind: "ram", Source: "tmpfs"}, 1); err != nil {
|
||||
t.Fatalf("expected success for RAM-backed status, got %v", err)
|
||||
}
|
||||
|
||||
err := verifyInstallToRAMStatus(LiveBootSource{InRAM: false, Kind: "usb", Device: "/dev/sdb1"}, dstDir, false, nil)
|
||||
err := verifyInstallToRAMStatus(LiveBootSource{InRAM: false, Kind: "usb", Device: "/dev/sdb1"}, 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure when media is still on USB")
|
||||
}
|
||||
if got := err.Error(); got != "install to RAM verification failed: live medium still mounted from USB (/dev/sdb1) and no squashfs found in "+dstDir {
|
||||
if got := err.Error(); got != "install to RAM verification failed: live medium still mounted from USB (/dev/sdb1)" {
|
||||
t.Fatalf("error=%q", got)
|
||||
}
|
||||
|
||||
if err := verifyInstallToRAMStatus(LiveBootSource{InRAM: true, Kind: "ram", Source: "tmpfs"}, 0); err == nil {
|
||||
t.Fatal("expected verification failure when no loop device was switched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeLiveBootSource(t *testing.T) {
|
||||
@@ -214,10 +216,9 @@ func TestDetachInstallMedium(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
var umountCalled bool
|
||||
var ejected string
|
||||
umountLiveMedium = func() error {
|
||||
umountCalled = true
|
||||
t.Fatal("detachInstallMedium must not unmount the RAM bind mount")
|
||||
return nil
|
||||
}
|
||||
ejectDevice = func(device string) error {
|
||||
@@ -226,13 +227,10 @@ func TestDetachInstallMedium(t *testing.T) {
|
||||
}
|
||||
var logs []string
|
||||
detachInstallMedium(LiveBootSource{Kind: "cdrom", Device: "/dev/sr1"}, func(msg string) { logs = append(logs, msg) })
|
||||
if !umountCalled {
|
||||
t.Fatal("expected umountLiveMedium to be called")
|
||||
}
|
||||
if ejected != "/dev/sr1" {
|
||||
t.Fatalf("ejected=%q want /dev/sr1", ejected)
|
||||
}
|
||||
if len(logs) < 3 {
|
||||
if len(logs) < 2 {
|
||||
t.Fatalf("logs=%v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,7 +138,7 @@ All SAT run endpoints enqueue an async task. Response: `{"task_id": "..."}`.
|
||||
| Method | Path | Description |
|
||||
|--------|------------------------------|---------------------------------------------------|
|
||||
| GET | `/api/system/ram-status` | toram boot state and ISO copy status |
|
||||
| POST | `/api/system/install-to-ram` | Copy ISO to RAM (background task) |
|
||||
| POST | `/api/system/install-to-ram` | Copy ISO to RAM and verify active loop backing |
|
||||
| GET | `/api/system/time` | Current host epoch, local wall-clock text, and timezone |
|
||||
| POST | `/api/system/time-sync` | Set host time and timezone from the browser |
|
||||
| POST | `/api/system/reboot` | Reboot the host |
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Runtime Copy to RAM must switch the active loop backing file
|
||||
|
||||
**Date:** 2026-09-03
|
||||
**Status:** active
|
||||
|
||||
## Evidence
|
||||
|
||||
On `bee@172.16.41.97`, the runtime `Copy to RAM` task showed two contradictory
|
||||
results:
|
||||
|
||||
- with the BMC virtual CD connected, the 2.7 GB squashfs copy reached 100%,
|
||||
loop reassociation logged `invalid argument`, and the worker failed;
|
||||
- without the ISO connected, a later task found the stale file in
|
||||
`/dev/shm/bee-live`, logged the same reassociation error, but returned
|
||||
success.
|
||||
|
||||
After the reported success, `/sys/block/loop0/loop/backing_file` still named
|
||||
`/run/live/medium/live/filesystem-*.squashfs`, `/run/live/medium` was backed by
|
||||
`/dev/sr0`, and disconnecting virtual media produced repeated `Medium not
|
||||
present` and I/O errors. The system was not running independently from RAM.
|
||||
|
||||
The Linux ABI defines `LOOP_CHANGE_FD` as `0x4C06` and
|
||||
`LOOP_SET_DIRECT_IO` as `0x4C08`. The application had named `0x4C08` as
|
||||
`ioctlLoopChangeFD`, so every fallback reassociation called the wrong ioctl
|
||||
and received `EINVAL`.
|
||||
|
||||
## Decision
|
||||
|
||||
- Use the correct `LOOP_CHANGE_FD` request number, `0x4C06`, and protect it
|
||||
with a Linux-specific regression test.
|
||||
- Copy all source data before changing active loop devices.
|
||||
- Treat a missing loop device, failed reassociation, or failed post-change
|
||||
backing-file lookup as a fatal task error. A copied file alone is never
|
||||
proof that the running system uses it.
|
||||
- After all squashfs loops point at their files in `/dev/shm/bee-live`,
|
||||
unmount the original live-medium mount and bind the RAM directory at
|
||||
`/run/live/medium`.
|
||||
- Success requires both at least one switched loop and a tmpfs-backed
|
||||
`/run/live/medium`.
|
||||
- Eject the original device without unmounting the new RAM bind mount.
|
||||
- Preserve a complete RAM copy after any failure that occurs after loop
|
||||
reassociation begins, so retry never deletes a file backing a live loop.
|
||||
|
||||
## Consequences
|
||||
|
||||
The button can resume a complete copy left by an interrupted worker even if
|
||||
the virtual CD has since disappeared, but it reports success only if the
|
||||
kernel actually accepts that file as the live loop backing. Partial or stale
|
||||
copies can no longer produce a false `done` result. Boot-time `toram` remains
|
||||
a separate initramfs path and is not evidence that the runtime button worked.
|
||||
@@ -17,3 +17,4 @@ One file per decision, named `YYYY-MM-DD-short-topic.md`.
|
||||
| 2026-08-31 | "Run All" SAT planning happens on the backend, not the browser | active |
|
||||
| 2026-08-31 | Support bundle uses private staging and unique atomic output | active |
|
||||
| 2026-09-03 | PCIe link verdict comes only from the existing real-traffic GPU bandwidth SAT | active |
|
||||
| 2026-09-03 | Runtime Copy to RAM succeeds only after active loop devices move to tmpfs | active |
|
||||
|
||||
Reference in New Issue
Block a user