package e2e import ( "fmt" "os" "os/exec" "path/filepath" "testing" ) // TestLifecycleRace proves that a new process joining an existing namespace // can have that namespace unpinned if an exiting process incorrectly // thinks it's the last one out. func TestLifecycleRace(t *testing.T) { binaryPath, err := GetBinaryPath() if err != nil { t.Skipf("Skipping test: %v", err) } tmpRuntimeDir := t.TempDir() profile := "race-test" pidsDir := filepath.Join(tmpRuntimeDir, "profiles", profile, "pids") // Setup a valid profile config to ensure tunneling starts tmpConfigDir := t.TempDir() profilesDir := filepath.Join(tmpConfigDir, "wg-wrap", "profiles") if err := os.MkdirAll(profilesDir, 0755); err != nil { t.Fatal(err) } profileConfPath := filepath.Join(profilesDir, profile+".conf") conf := `[Interface] Address = 10.0.0.2/24 PrivateKey = 0000000000000000000000000000000000000000000000000000000000000000 DNS = 1.1.1.1 [Peer] PublicKey = 0000000000000000000000000000000000000000000000000000000000000000 AllowedIPs = 0.0.0.0/0 Endpoint = 1.1.1.1:51820 ` if err := os.WriteFile(profileConfPath, []byte(conf), 0644); err != nil { t.Fatal(err) } // We use a a long-running sleep for Process A to keep the namespace active. // Process A is the "Victim". cmdA := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "10") cmdA.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), ) if err := cmdA.Start(); err != nil { t.Fatalf("Failed to start Process A: %v", err) } defer func() { _ = cmdA.Process.Kill() }() // Wait for Process A to establish the namespace and register PID waitForPids(t, pidsDir, 1) // Process B is the "Saboteur". It will join and then exit. // We will loop this to increase the chance of hitting the race window. for i := 0; i < 5; i++ { cmdB := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "0.1") cmdB.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), ) if err := cmdB.Start(); err != nil { t.Fatalf("Failed to start Process B (iteration %d): %v", i, err) } // Wait for Process B to register waitForPids(t, pidsDir, 2) // Let Process B exit. The defer block in ExecuteCommand will: // 1. Unregister Process B // 2. Check IsLastProcess() -> might return true if Process A's PID is stale or miscounted // 3. UnpinNamespace() if err := cmdB.Wait(); err != nil { t.Fatalf("Process B failed: %v", err) } // After B exits, Process A should still be the only remaining process. // We check if the namespace pin file was accidentally deleted by B. nsPath := filepath.Join(tmpRuntimeDir, "profiles", profile+".ns") if _, err := os.Stat(nsPath); os.IsNotExist(err) { t.Errorf("BUG: Namespace pin file was deleted by exiting Process B, despite Process A still running!") return } // Wait for the PID count to drop back to 1 waitForPids(t, pidsDir, 1) } }