Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions daemon/sdnotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@
package daemon

import (
"context"
"io"
"net"
"os"
"time"
)

const (
Expand All @@ -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.
Expand All @@ -54,6 +59,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",
Expand All @@ -70,15 +87,69 @@ 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
}

// 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
}
16 changes: 16 additions & 0 deletions daemon/sdnotify_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions daemon/sdnotify_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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].
//
Expand Down
195 changes: 195 additions & 0 deletions daemon/sdnotify_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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 (
"context"
"errors"
"net"
"os"
"testing"
"time"

"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)
}
}
}

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")
}
}
})
}
}