From 6476e75e80cb1830bd24d98ca821a3c2e72c519f Mon Sep 17 00:00:00 2001 From: WGH Date: Fri, 12 Jun 2026 20:23:48 +0300 Subject: [PATCH 1/2] daemon: implement SdNotifyWithFiles This function allows to send an array of additional file descriptors to the service manager. This is mostly used for FDSTORE=1 messages, and BARRIER=1 can be implemented on top of this as well. This corresponds to sd_pid_notify_with_fds in libsystemd, but without pid support. If sd_notify_with_fds existed, that would be the complete equivalent. --- daemon/sdnotify.go | 20 +++++- daemon/sdnotify_other.go | 16 +++++ daemon/sdnotify_unix.go | 27 ++++++++ daemon/sdnotify_unix_test.go | 128 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 daemon/sdnotify_unix_test.go diff --git a/daemon/sdnotify.go b/daemon/sdnotify.go index ba4ae31f..b343e22c 100644 --- a/daemon/sdnotify.go +++ b/daemon/sdnotify.go @@ -54,6 +54,18 @@ const ( // (false, err) - notification supported, but failure happened (e.g. error connecting to NOTIFY_SOCKET or while sending data) // (true, nil) - notification supported, data has been sent func SdNotify(unsetEnvironment bool, state string) (bool, error) { + return SdNotifyWithFiles(unsetEnvironment, state) +} + +// SdNotifyWithFiles is like [SdNotify], but additionally passes the given open +// files to the service manager. It is typically used together with "FDSTORE=1" +// to hand file descriptors over for safe-keeping across a restart. +// +// The files remain owned by the caller; they may be closed as soon as this +// function returns, if necessary. +// +// The return values are the same as for [SdNotify]. +func SdNotifyWithFiles(unsetEnvironment bool, state string, files ...*os.File) (bool, error) { socketAddr := &net.UnixAddr{ Name: os.Getenv("NOTIFY_SOCKET"), Net: "unixgram", @@ -70,14 +82,16 @@ func SdNotify(unsetEnvironment bool, state string) (bool, error) { } } - conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr) - // Error connecting to NOTIFY_SOCKET + // Use an unconnected socket instead of [net.DialUnix] because + // [net.UnixConn.WriteMsgUnix] needs datagram sockets to be + // unconnected. + conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{}) if err != nil { return false, err } defer conn.Close() - if _, err = conn.Write([]byte(state)); err != nil { + if err := writeState(conn, socketAddr, state, files); err != nil { return false, err } return true, nil diff --git a/daemon/sdnotify_other.go b/daemon/sdnotify_other.go index d6309495..ef0e01f1 100644 --- a/daemon/sdnotify_other.go +++ b/daemon/sdnotify_other.go @@ -16,7 +16,23 @@ package daemon +import ( + "errors" + "net" + "os" +) + // SdNotifyMonotonicUsec returns the empty string on unsupported platforms. func SdNotifyMonotonicUsec() string { return "" } + +// writeState sends the notification state. Passing files is not supported on +// this platform, so a non-empty files slice results in an error. +func writeState(conn *net.UnixConn, addr *net.UnixAddr, state string, files []*os.File) error { + if len(files) > 0 { + return errors.New("daemon: passing files over NOTIFY_SOCKET is not supported on this platform") + } + _, err := conn.WriteToUnix([]byte(state), addr) + return err +} diff --git a/daemon/sdnotify_unix.go b/daemon/sdnotify_unix.go index bdbe6b0c..bbed2e71 100644 --- a/daemon/sdnotify_unix.go +++ b/daemon/sdnotify_unix.go @@ -17,11 +17,38 @@ package daemon import ( + "net" + "os" + "runtime" "strconv" "golang.org/x/sys/unix" ) +// writeState sends the notification state, optionally accompanied by the given +// open files as an SCM_RIGHTS ancillary message. +func writeState(conn *net.UnixConn, addr *net.UnixAddr, state string, files []*os.File) error { + if len(files) == 0 { + _, err := conn.WriteToUnix([]byte(state), addr) + return err + } + + fds := make([]int, len(files)) + for i, f := range files { + fds[i] = int(f.Fd()) + } + oob := unix.UnixRights(fds...) + + _, _, err := conn.WriteMsgUnix([]byte(state), oob, addr) + + // File descriptors that reference files are stored inside oob []byte, + // so explicitly mark files alive to prevent GC from closing them + // early. + runtime.KeepAlive(files) + + return err +} + // SdNotifyMonotonicUsec returns a MONOTONIC_USEC=... assignment for the current time // with a trailing newline included. This is typically used with [SdNotifyReloading]. // diff --git a/daemon/sdnotify_unix_test.go b/daemon/sdnotify_unix_test.go new file mode 100644 index 00000000..7ee19a6b --- /dev/null +++ b/daemon/sdnotify_unix_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 +// +// 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. + +//go:build unix + +package daemon + +import ( + "net" + "os" + "testing" + + "golang.org/x/sys/unix" +) + +func recvNotifyFiles(t *testing.T, conn *net.UnixConn) (string, []*os.File) { + t.Helper() + + buf := make([]byte, 4096) + oob := make([]byte, unix.CmsgSpace(1024)) + n, oobn, _, _, err := conn.ReadMsgUnix(buf, oob) + if err != nil { + t.Fatalf("ReadMsgUnix: %v", err) + } + + var files []*os.File + scms, err := unix.ParseSocketControlMessage(oob[:oobn]) + if err != nil { + t.Fatalf("ParseSocketControlMessage: %v", err) + } + for _, scm := range scms { + fds, err := unix.ParseUnixRights(&scm) + if err != nil { + t.Fatalf("ParseUnixRights: %v", err) + } + for _, fd := range fds { + files = append(files, os.NewFile(uintptr(fd), "")) + } + } + return string(buf[:n]), files +} + +func TestSdNotifyWithFiles(t *testing.T) { + const numStoredFiles = 3 + + testDir := t.TempDir() + + notifySocket := testDir + "/notify-socket.sock" + laddr := net.UnixAddr{ + Name: notifySocket, + Net: "unixgram", + } + conn, err := net.ListenUnixgram("unixgram", &laddr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + var storedFiles []*os.File + + for range numStoredFiles { + f, err := os.CreateTemp(testDir, "") + if err != nil { + t.Fatal(err) + } + defer f.Close() + storedFiles = append(storedFiles, f) + } + + t.Setenv("NOTIFY_SOCKET", notifySocket) + + // Store file descriptors in the test service manager + + state := "FDSTORE=1" + + sent, err := SdNotifyWithFiles(false, "FDSTORE=1", storedFiles...) + if err != nil { + t.Fatalf("SdNotifyWithFiles returned error: %v", err) + } + if !sent { + t.Fatal("SdNotifyWithFiles reported the notification was not sent") + } + + // Note that we don't close the original files, we don't have to. + + // And we're now the "service manager". + + receivedState, fds := recvNotifyFiles(t, conn) + + if receivedState != state { + t.Errorf("received state = %q; want %q", receivedState, state) + } + + if len(fds) != len(storedFiles) { + t.Fatalf("received %d file descriptors; want %d", len(fds), len(storedFiles)) + } + + // The received file desctiptors must refer to the same files + for i, f := range fds { + defer f.Close() + + wantStat, err := storedFiles[i].Stat() + if err != nil { + t.Fatal(err) + } + + gotStat, err := f.Stat() + if err != nil { + t.Fatal(err) + } + + if !os.SameFile(gotStat, wantStat) { + // "%#v" for os.fileStat is ugly, but decipherable + t.Errorf("file %d: wrong file descriptor received; got %#v, want %#v", i, gotStat, wantStat) + } + } +} From b7a08d792a0834931016a9c56c7b0f5edd5aa723 Mon Sep 17 00:00:00 2001 From: WGH Date: Fri, 12 Jun 2026 20:46:52 +0300 Subject: [PATCH 2/2] daemon: implement SdNotifyBarrier This function blocks until all notifications sent before it have been processed by the service manager. This is the equivalent of libsystemd's sd_notify_barrier. --- daemon/sdnotify.go | 57 ++++++++++++++++++++++++++++++ daemon/sdnotify_unix_test.go | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/daemon/sdnotify.go b/daemon/sdnotify.go index b343e22c..820b7a4a 100644 --- a/daemon/sdnotify.go +++ b/daemon/sdnotify.go @@ -22,8 +22,11 @@ package daemon import ( + "context" + "io" "net" "os" + "time" ) const ( @@ -45,6 +48,8 @@ const ( SdNotifyWatchdog = "WATCHDOG=1" ) +var aLongTimeAgo = time.Unix(1, 0) + // SdNotify sends a message to the init daemon. It is common to ignore the error. // If `unsetEnvironment` is true, the environment variable `NOTIFY_SOCKET` // will be unconditionally unset. @@ -96,3 +101,55 @@ func SdNotifyWithFiles(unsetEnvironment bool, state string, files ...*os.File) ( } return true, nil } + +// SdNotifyBarrier blocks until all notifications sent before it have been +// processed by the service manager. This is the equivalent of libsystemd's +// sd_notify_barrier(3). +// +// It works by sending a "BARRIER=1" message together with the write end of a +// freshly created pipe. The service manager closes its copy of that descriptor +// once it has handled every preceding notification, at which point our read end +// observes EOF and the barrier is lifted. +// +// It returns one of the following: +// (false, nil) - notification not supported (i.e. NOTIFY_SOCKET is unset) +// (false, err) - notification supported, but failure happened (e.g. error connecting to NOTIFY_SOCKET, while sending data) +// (true, err) - notification supported and sent, but barrier hasn't been reached +// (true, nil) - notification supported and sent, and the barrier has been reached +func SdNotifyBarrier(ctx context.Context, unsetEnvironment bool) (bool, error) { + r, w, err := os.Pipe() + if err != nil { + return false, err + } + defer r.Close() + + // Send the barrier along with the write end of the pipe. We close our copy + // of the write end immediately afterwards (whatever the outcome), so that + // the service manager holds the only remaining reference to it. + sent, err := SdNotifyWithFiles(unsetEnvironment, "BARRIER=1", w) + w.Close() + if err != nil || !sent { + return sent, err + } + + err = waitForClose(ctx, r) + return true, err +} + +func waitForClose(ctx context.Context, r *os.File) error { + // interrupt read on context cancelation + stop := context.AfterFunc(ctx, func() { + // error should never happen: deadlines are supported for pipes + _ = r.SetReadDeadline(aLongTimeAgo) + }) + defer stop() + + _, err := io.Copy(io.Discard, r) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return err + } + return nil +} diff --git a/daemon/sdnotify_unix_test.go b/daemon/sdnotify_unix_test.go index 7ee19a6b..a26b019d 100644 --- a/daemon/sdnotify_unix_test.go +++ b/daemon/sdnotify_unix_test.go @@ -17,9 +17,12 @@ package daemon import ( + "context" + "errors" "net" "os" "testing" + "time" "golang.org/x/sys/unix" ) @@ -126,3 +129,67 @@ func TestSdNotifyWithFiles(t *testing.T) { } } } + +func TestSdNotifyBarrier(t *testing.T) { + tests := []struct { + name string + timeout bool + }{ + {name: "OK", timeout: false}, + {name: "Timeout", timeout: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDir := t.TempDir() + + notifySocket := testDir + "/notify-socket.sock" + laddr := net.UnixAddr{ + Name: notifySocket, + Net: "unixgram", + } + conn, err := net.ListenUnixgram("unixgram", &laddr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + t.Setenv("NOTIFY_SOCKET", notifySocket) + + ctx, cancel := context.WithCancel(t.Context()) + + var t1, t2 time.Time + + go func() { + _, files := recvNotifyFiles(t, conn) + if tt.timeout { + // simulate timeout for the SdNotifyBarrier call + cancel() + <-t.Context().Done() + } else { + t1 = time.Now() + files[0].Close() + } + }() + + sent, err := SdNotifyBarrier(ctx, false) + if !sent { + t.Fatal("SdNotifyBarrier should've sent a message") + } + t2 = time.Now() + + if tt.timeout { + if !errors.Is(err, context.Canceled) { + t.Errorf("unexpected error %v", err) + } + } else { + if err != nil { + t.Errorf("unexpected error %v", err) + } + if t1.After(t2) { + t.Error("SdNotifyWithFiles returned before service manager closed the file") + } + } + }) + } +}