diff --git a/.gitignore b/.gitignore index dd9921b54..64c4ec8c2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ pelican.yaml oidc-client-id oidc-client-secret MaxMindKey +e2e_fed_tests.test diff --git a/daemon/launch_unix.go b/daemon/launch_unix.go index 6818e8507..78fd3c40c 100644 --- a/daemon/launch_unix.go +++ b/daemon/launch_unix.go @@ -271,12 +271,17 @@ func LaunchDaemons(ctx context.Context, launchers []Launcher, egrp *errgroup.Gro log.Infof("Daemon %q with pid %d was killed", daemons[chosen].name, daemons[chosen].pid) } if waitResult := context.Cause(daemons[chosen].ctx); waitResult != nil { + metricName := strings.SplitN(launchers[chosen].Name(), ".", 2)[0] + if IsExpectedRestart() { + metrics.SetComponentHealthStatus(metrics.HealthStatusComponent(metricName), metrics.StatusShuttingDown, "XRootD restart in progress") + log.Infof("Daemon %q exited during expected restart: %v", daemons[chosen].name, waitResult) + return nil + } if !daemons[chosen].expiry.IsZero() { return nil } else if errors.Is(waitResult, context.Canceled) { return nil } - metricName := strings.SplitN(launchers[chosen].Name(), ".", 2)[0] metrics.SetComponentHealthStatus(metrics.HealthStatusComponent(metricName), metrics.StatusCritical, launchers[chosen].Name()+" process failed unexpectedly") err = errors.Wrapf(waitResult, "%s process failed unexpectedly", launchers[chosen].Name()) diff --git a/daemon/restart_flag.go b/daemon/restart_flag.go new file mode 100644 index 000000000..53c118bbd --- /dev/null +++ b/daemon/restart_flag.go @@ -0,0 +1,34 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +package daemon + +import "sync/atomic" + +var expectedRestart atomic.Bool + +// SetExpectedRestart marks whether a XRootD restart is currently in progress. +func SetExpectedRestart(inProgress bool) { + expectedRestart.Store(inProgress) +} + +// IsExpectedRestart reports whether daemon shutdowns should be treated as +// intentional because a restart is underway. +func IsExpectedRestart() bool { + return expectedRestart.Load() +} diff --git a/e2e_fed_tests/restart_test.go b/e2e_fed_tests/restart_test.go new file mode 100644 index 000000000..86372aad9 --- /dev/null +++ b/e2e_fed_tests/restart_test.go @@ -0,0 +1,220 @@ +//go:build !windows + +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +package fed_tests + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pelicanplatform/pelican/client" + "github.com/pelicanplatform/pelican/fed_test_utils" + "github.com/pelicanplatform/pelican/metrics" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/test_utils" + "github.com/pelicanplatform/pelican/xrootd" +) + +func waitForComponentStatus(t *testing.T, component metrics.HealthStatusComponent, desired metrics.HealthStatusEnum, timeout time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + status, err := metrics.GetComponentStatus(component) + if err != nil { + return false + } + return status == desired.String() + }, timeout, 100*time.Millisecond, "component %s did not reach status %s", component, desired) +} + +func waitForComponentStatusMatch(t *testing.T, component metrics.HealthStatusComponent, desired []metrics.HealthStatusEnum, timeout time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + status, err := metrics.GetComponentStatus(component) + if err != nil { + return false + } + for _, target := range desired { + if status == target.String() { + return true + } + } + return false + }, timeout, 100*time.Millisecond, "component %s did not reach expected statuses", component) +} + +func waitForComponentStatusNotOK(t *testing.T, component metrics.HealthStatusComponent, timeout time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + status, err := metrics.GetComponentStatus(component) + if err != nil { + return false + } + return status != metrics.StatusOK.String() + }, timeout, 50*time.Millisecond, "component %s never left OK state", component) +} + +// TestXRootDRestart tests that XRootD can be restarted and continues to function +func TestXRootDRestart(t *testing.T) { + t.Cleanup(test_utils.SetupTestLogging(t)) + server_utils.ResetTestState() + defer server_utils.ResetTestState() + + // Create a federation with origin and cache + ft := fed_test_utils.NewFedTest(t, bothPubNamespaces) + + // Create a test file to upload + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.txt") + testContent := "Hello from Pelican restart test" + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0644)) + + // Upload the file before restart + destUrl := fmt.Sprintf("pelican://%s:%d/first/namespace/restart/test.txt", param.Server_Hostname.GetString(), param.Server_WebPort.GetInt()) + transferDetailsUpload, err := client.DoPut(ft.Ctx, testFile, destUrl, false, client.WithTokenLocation(ft.Token)) + require.NoError(t, err) + require.NotEmpty(t, transferDetailsUpload) + assert.Greater(t, transferDetailsUpload[0].TransferredBytes, int64(0)) + + // Download the file to verify it works before restart + downloadFile := filepath.Join(tempDir, "download_before.txt") + transferDetailsDownload, err := client.DoGet(ft.Ctx, destUrl, downloadFile, false, client.WithTokenLocation(ft.Token)) + require.NoError(t, err) + require.NotEmpty(t, transferDetailsDownload) + assert.Greater(t, transferDetailsDownload[0].TransferredBytes, int64(0)) + + // Verify content + downloadedContent, err := os.ReadFile(downloadFile) + require.NoError(t, err) + assert.Equal(t, testContent, string(downloadedContent)) + + // Get the origin server from the fed test (would need to expose this or get it another way) + // For now, we'll test the restart mechanism directly via RestartXrootd + + // Restart the XRootD processes + oldPids := ft.Pids + require.NotEmpty(t, oldPids, "No PIDs found for XRootD processes") + + waitForComponentStatus(t, metrics.OriginCache_XRootD, metrics.StatusOK, 10*time.Second) + + restartDone := make(chan struct{}) + var newPids []int + var restartErr error + + go func() { + newPids, restartErr = xrootd.RestartXrootd(ft.Ctx, oldPids) + close(restartDone) + }() + + waitForComponentStatusNotOK(t, metrics.OriginCache_XRootD, 5*time.Second) + waitForComponentStatusMatch(t, metrics.OriginCache_XRootD, []metrics.HealthStatusEnum{metrics.StatusShuttingDown, metrics.StatusCritical}, 5*time.Second) + + <-restartDone + require.NoError(t, restartErr) + require.NotEmpty(t, newPids) + require.NotEqual(t, oldPids, newPids, "PIDs should be different after restart") + + // Update the PIDs in the fed test + ft.Pids = newPids + + waitForComponentStatus(t, metrics.OriginCache_XRootD, metrics.StatusOK, 10*time.Second) + + // Try to download the file again after restart + downloadFileAfter := filepath.Join(tempDir, "download_after.txt") + transferDetailsAfter, err := client.DoGet(ft.Ctx, destUrl, downloadFileAfter, false, client.WithTokenLocation(ft.Token)) + require.NoError(t, err) + require.NotEmpty(t, transferDetailsAfter) + assert.Greater(t, transferDetailsAfter[0].TransferredBytes, int64(0)) + + // Verify content after restart + downloadedContentAfter, err := os.ReadFile(downloadFileAfter) + require.NoError(t, err) + assert.Equal(t, testContent, string(downloadedContentAfter)) + + // Verify old PIDs are no longer running + for _, pid := range oldPids { + process, err := os.FindProcess(pid) + if err == nil { + // Try to signal the process - should fail if it's dead + err = process.Signal(syscall.Signal(0)) + assert.Error(t, err, "Old PID %d should not be running after restart", pid) + } + } + + // Verify new PIDs are running + for _, pid := range newPids { + process, err := os.FindProcess(pid) + require.NoError(t, err) + err = process.Signal(syscall.Signal(0)) + require.NoError(t, err, "New PID %d should be running after restart", pid) + } +} + +// TestXRootDRestartConcurrent tests that concurrent restart attempts are properly serialized +func TestXRootDRestartConcurrent(t *testing.T) { + t.Cleanup(test_utils.SetupTestLogging(t)) + server_utils.ResetTestState() + defer server_utils.ResetTestState() + + // Create a federation + ft := fed_test_utils.NewFedTest(t, bothPubNamespaces) + + oldPids := ft.Pids + require.NotEmpty(t, oldPids, "No PIDs found for XRootD processes") + + // Try two concurrent restarts + done := make(chan error, 2) + + go func() { + _, err := xrootd.RestartXrootd(ft.Ctx, oldPids) + done <- err + }() + + // Small delay to let first restart acquire the lock + time.Sleep(10 * time.Millisecond) + + go func() { + _, err := xrootd.RestartXrootd(ft.Ctx, oldPids) + done <- err + }() + + // Collect results + err1 := <-done + err2 := <-done + + // One should succeed, one should fail with "already in progress" + if err1 == nil { + require.Error(t, err2) + assert.Contains(t, err2.Error(), "already in progress") + } else if err2 == nil { + require.Error(t, err1) + assert.Contains(t, err1.Error(), "already in progress") + } else { + t.Fatal("Both restart attempts failed, at least one should have succeeded") + } +} diff --git a/launchers/cache_serve.go b/launchers/cache_serve.go index 2c49dbc7a..14239e043 100644 --- a/launchers/cache_serve.go +++ b/launchers/cache_serve.go @@ -135,7 +135,9 @@ func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, m } log.Info("Launching cache") - launchers, err := xrootd.ConfigureLaunchers(false, configPath, false, true) + useCMSD := false + privileged := false + launchers, err := xrootd.ConfigureLaunchers(privileged, configPath, useCMSD, true) if err != nil { return nil, err } @@ -162,6 +164,9 @@ func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, m log.Infoln("Cache startup complete on port", port) } + // Store restart information before launching + xrootd.StoreRestartInfo(launchers, egrp, portStartCallback, true, useCMSD, privileged) + pids, err := xrootd.LaunchDaemons(ctx, launchers, egrp, portStartCallback) if err != nil { return nil, err diff --git a/launchers/origin_serve.go b/launchers/origin_serve.go index 438cbb365..de5514b21 100644 --- a/launchers/origin_serve.go +++ b/launchers/origin_serve.go @@ -120,7 +120,8 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, } privileged := param.Origin_Multiuser.GetBool() - launchers, err := xrootd.ConfigureLaunchers(privileged, configPath, param.Origin_EnableCmsd.GetBool(), false) + useCMSD := param.Origin_EnableCmsd.GetBool() + launchers, err := xrootd.ConfigureLaunchers(privileged, configPath, useCMSD, false) if err != nil { return nil, err } @@ -147,6 +148,9 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, log.Infoln("Origin startup complete on port", port) } + // Store restart information before launching + xrootd.StoreRestartInfo(launchers, egrp, portStartCallback, false, useCMSD, privileged) + pids, err := xrootd.LaunchDaemons(ctx, launchers, egrp, portStartCallback) if err != nil { return nil, err diff --git a/xrootd/restart.go b/xrootd/restart.go new file mode 100644 index 000000000..a57b55b74 --- /dev/null +++ b/xrootd/restart.go @@ -0,0 +1,213 @@ +//go:build !windows + +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +package xrootd + +import ( + "context" + "os" + "sync" + "syscall" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/daemon" + "github.com/pelicanplatform/pelican/metrics" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" +) + +type restartInfo struct { + launchers []daemon.Launcher + egrp *errgroup.Group + callback func(int) + isCache bool + useCMSD bool + privileged bool +} + +var ( + // restartMutex ensures only one restart operation happens at a time + restartMutex sync.Mutex + + // Store launcher information for restart; one entry per server role (origin/cache) + restartInfos []restartInfo +) + +// StoreRestartInfo stores the information needed for restarting XRootD +// This should be called during initial launch +func StoreRestartInfo(launchers []daemon.Launcher, egrp *errgroup.Group, callback func(int), cache bool, cmsd bool, priv bool) { + info := restartInfo{ + launchers: launchers, + egrp: egrp, + callback: callback, + isCache: cache, + useCMSD: cmsd, + privileged: priv, + } + + // Replace any existing entry for the same server role; otherwise append. + replaced := false + for idx := range restartInfos { + if restartInfos[idx].isCache == cache { + restartInfos[idx] = info + replaced = true + break + } + } + + if !replaced { + restartInfos = append(restartInfos, info) + } +} + +// RestartXrootd gracefully restarts the XRootD server processes +// This function is thread-safe and will prevent concurrent restart attempts +func RestartXrootd(ctx context.Context, oldPids []int) (newPids []int, err error) { + // Acquire the restart mutex to prevent concurrent restarts + if !restartMutex.TryLock() { + return nil, errors.New("XRootD restart already in progress") + } + defer restartMutex.Unlock() + defer daemon.SetExpectedRestart(false) + + log.Info("Beginning XRootD restart sequence") + + daemon.SetExpectedRestart(true) + metrics.SetComponentHealthStatus(metrics.OriginCache_XRootD, metrics.StatusShuttingDown, "XRootD restart in progress") + hasCMSD := false + for _, info := range restartInfos { + if info.useCMSD { + hasCMSD = true + break + } + } + if hasCMSD { + metrics.SetComponentHealthStatus(metrics.OriginCache_CMSD, metrics.StatusShuttingDown, "CMSD restart in progress") + } + + if len(restartInfos) == 0 { + return nil, errors.New("restart requested before storing launcher information") + } + + storedInfos := make([]restartInfo, len(restartInfos)) + copy(storedInfos, restartInfos) + + // Step 1: Gracefully shutdown existing XRootD processes + log.Debug("Sending SIGTERM to existing XRootD processes") + for _, pid := range oldPids { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.WithError(err).Warnf("Failed to send SIGTERM to PID %d", pid) + } + } + + // Wait for graceful shutdown with timeout + shutdownTimeout := param.Xrootd_ShutdownTimeout.GetDuration() + shutdownDeadline := time.Now().Add(shutdownTimeout) + for time.Now().Before(shutdownDeadline) { + allDead := true + for _, pid := range oldPids { + process, err := os.FindProcess(pid) + if err == nil && process != nil { + if err := process.Signal(syscall.Signal(0)); err == nil { + allDead = false + break + } + } + } + if allDead { + break + } + time.Sleep(50 * time.Millisecond) + } + + // Force kill any remaining processes + for _, pid := range oldPids { + process, err := os.FindProcess(pid) + if err == nil && process != nil { + if err := process.Signal(syscall.Signal(0)); err == nil { + log.Warnf("Force killing PID %d that did not respond to SIGTERM", pid) + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + log.WithError(err).Errorf("Failed to send SIGKILL to PID %d", pid) + } + } + } + } + + // Step 2: Reconfigure XRootD runtime directory + log.Debug("Reconfiguring XRootD runtime directory") + metrics.SetComponentHealthStatus(metrics.OriginCache_XRootD, metrics.StatusCritical, "XRootD stopped during restart") + + newPids = make([]int, 0, len(oldPids)) + updatedInfos := make([]restartInfo, 0, len(storedInfos)) + + for _, info := range storedInfos { + configPath, cfgErr := ConfigXrootd(ctx, !info.isCache) + if cfgErr != nil { + return nil, errors.Wrap(cfgErr, "Failed to reconfigure XRootD") + } + + if info.useCMSD { + metrics.SetComponentHealthStatus(metrics.OriginCache_CMSD, metrics.StatusCritical, "CMSD stopped during restart") + } + + log.Debug("Configuring new XRootD launchers") + newLaunchers, cfgLaunchErr := ConfigureLaunchers(info.privileged, configPath, info.useCMSD, info.isCache) + if cfgLaunchErr != nil { + return nil, errors.Wrap(cfgLaunchErr, "Failed to configure XRootD launchers") + } + + log.Info("Launching new XRootD daemons") + pids, launchErr := LaunchDaemons(ctx, newLaunchers, info.egrp, info.callback) + if launchErr != nil { + return nil, errors.Wrap(launchErr, "Failed to launch XRootD daemons") + } + + info.launchers = newLaunchers + updatedInfos = append(updatedInfos, info) + newPids = append(newPids, pids...) + + if info.useCMSD { + metrics.SetComponentHealthStatus(metrics.OriginCache_CMSD, metrics.StatusOK, "CMSD restart complete") + } + } + + restartInfos = updatedInfos + + metrics.SetComponentHealthStatus(metrics.OriginCache_XRootD, metrics.StatusOK, "XRootD restart complete") + + log.Infof("XRootD restart complete with new PIDs: %v", newPids) + return newPids, nil +} + +// RestartServer is a helper function that restarts XRootD and updates the server's PIDs +// This avoids circular dependencies by being in the xrootd package +func RestartServer(ctx context.Context, server server_structs.XRootDServer) error { + oldPids := server.GetPids() + newPids, err := RestartXrootd(ctx, oldPids) + if err != nil { + return err + } + server.SetPids(newPids) + return nil +} diff --git a/xrootd/restart_test.go b/xrootd/restart_test.go new file mode 100644 index 000000000..ead0c096e --- /dev/null +++ b/xrootd/restart_test.go @@ -0,0 +1,108 @@ +//go:build !windows + +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +package xrootd + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/daemon" +) + +// TestStoreRestartInfo tests that restart info is stored correctly +func TestStoreRestartInfo(t *testing.T) { + restartInfos = nil + t.Cleanup(func() { restartInfos = nil }) + + var launchers []daemon.Launcher + egrp := &errgroup.Group{} + callback := func(port int) {} + + StoreRestartInfo(launchers, egrp, callback, true, false, true) + StoreRestartInfo(launchers, egrp, callback, false, true, false) + + require.Len(t, restartInfos, 2) + + var cacheInfo, originInfo *restartInfo + for idx := range restartInfos { + if restartInfos[idx].isCache { + cacheInfo = &restartInfos[idx] + } else { + originInfo = &restartInfos[idx] + } + } + + require.NotNil(t, cacheInfo) + require.NotNil(t, originInfo) + + assert.True(t, cacheInfo.isCache) + assert.False(t, cacheInfo.useCMSD) + assert.True(t, cacheInfo.privileged) + + assert.False(t, originInfo.isCache) + assert.True(t, originInfo.useCMSD) + assert.False(t, originInfo.privileged) + assert.NotNil(t, originInfo.egrp) + assert.NotNil(t, originInfo.callback) +} + +func TestStoreRestartInfoReplacesByRole(t *testing.T) { + restartInfos = nil + t.Cleanup(func() { restartInfos = nil }) + + var launchers []daemon.Launcher + egrp := &errgroup.Group{} + + StoreRestartInfo(launchers, egrp, func(int) {}, true, false, false) + require.Len(t, restartInfos, 1) + + StoreRestartInfo(launchers, egrp, func(int) {}, true, true, true) + + require.Len(t, restartInfos, 1) + assert.True(t, restartInfos[0].useCMSD) + assert.True(t, restartInfos[0].privileged) +} + +// TestRestartXrootd_NoProcesses tests restart with no running processes +func TestRestartXrootd_NoProcesses(t *testing.T) { + // This is a minimal test that just checks the restart flow doesn't panic + // when there are no processes to kill + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var launchers []daemon.Launcher + egrp := &errgroup.Group{} + callback := func(int) {} + StoreRestartInfo(launchers, egrp, callback, false, false, false) + + // Try to restart with empty PID list - should fail since there's no xrootd config + _, err := RestartXrootd(ctx, []int{}) + + // We expect this to fail because there's no config set up + // The important thing is it doesn't panic + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to reconfigure XRootD") +} diff --git a/xrootd/restart_windows.go b/xrootd/restart_windows.go new file mode 100644 index 000000000..e456a1305 --- /dev/null +++ b/xrootd/restart_windows.go @@ -0,0 +1,47 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * Licensed under the Apache License, Version 2.0 (the "License"); you + * may not use this file except in compliance with the License. You may + * obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ***************************************************************/ + +package xrootd + +import ( + "context" + + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/daemon" + "github.com/pelicanplatform/pelican/server_structs" +) + +// StoreRestartInfo stores the information needed for restarting XRootD +// Windows stub - restart not implemented on Windows +func StoreRestartInfo(launchers []daemon.Launcher, egrp *errgroup.Group, callback func(int), cache bool, cmsd bool, priv bool) { + // No-op on Windows +} + +// RestartXrootd gracefully restarts the XRootD server processes +// Windows stub - restart not implemented on Windows +func RestartXrootd(ctx context.Context, oldPids []int) (newPids []int, err error) { + return nil, errors.New("XRootD restart is not supported on Windows") +} + +// RestartServer is a helper function that restarts XRootD and updates the server's PIDs +// Windows stub - restart not implemented on Windows +func RestartServer(ctx context.Context, server server_structs.XRootDServer) error { + return errors.New("XRootD restart is not supported on Windows") +}