Restart a Go server under systemd without dropping requests
Contents
A .socket unit keeps the port open across restarts, the Go server takes that socket from systemd and drains running requests on SIGTERM, and KillMode=mixed leaves the processes those requests wait on alone, so systemctl --user restart refuses no connection and cuts no stream that ends within the drain budget (2 min here).
- Tested on
- GEEKOM AE8 mini PC, Fedora Linux 44 (kernel 7.2.10), systemd 259 (259.9-1.fc44) user manager with linger on, Go 1.26.0, curl 8.18.0, iproute2 6.17.0 (2026-10-25). The demo service is
tsvdemo-drainon 127.0.0.1:18490 (18491 after step 10). - Not verified
- system (root) units; IPv6 and Unix-socket
ListenStream=addresses (more than oneListenStream=the server refuses, Failure modes);Accept=yes; TLS and HTTP/2; WebSockets and other hijacked connections; a start at boot from the enabled units (I used the runtime unit directory andenable --runtime, no reboot);FreeBind=for a LAN address. - Needs
- a working
systemctl --user; Go (1.26.0 here);curl;ss; ports 18490 and 18491 free on 127.0.0.1. - Time
- 20 min.
- Risk
- low for the demo: two units on 127.0.0.1 in a unit directory that a reboot empties. On your own service, a change of the socket unit refuses or resets new connections for as long as the drain runs (step 10), and a
TimeoutStopSec=below the drain aborts the stop. Every change here is undone in Undo / rollback.
Before you start#
- Versions and linger:
$ systemctl --version | head -1; go version; loginctl show-user "$USER" -p Linger systemd 259 (259.9-1.fc44) go version go1.26.0 linux/amd64 Linger=yes - The user manager's default stop timeout. It is 45 s here; the systemd 259 man page says 90 s. Fedora lowered it to 45 s in Fedora 38 (the "Shorter Shutdown Timer" change), and
/usr/lib/systemd/user.conflists#DefaultTimeoutStopSec=45s. A drain longer than this default needs its ownTimeoutStopSec=(step 4):$ systemctl --user show -p DefaultTimeoutStopUSec DefaultTimeoutStopUSec=45s - Nothing listens on the demo ports, 18490 and 18491 (header line only):
$ ss -ltn '( sport = :18490 or sport = :18491 )' State Recv-Q Send-Q Local Address:Port Peer Address:Port - Make a directory for the demo and run every command below from it.
UNITDIRis whereinstall-units.sh(step 5) puts the units. I used the runtime unit directory, which systemd reads like~/.config/systemd/userand which lives on tmpfs (/run/user/<UID>), so a reboot removes the trial. For a permanent install leaveUNITDIRunset: the script then writes to~/.config/systemd/user. Export it again in every new shell: without itinstall-units.shinstalls to~/.config/systemd/user, and the drop-in and Undo commands below lose their directory.mkdir tsvdemo-drain && cd tsvdemo-drain export UNITDIR=$XDG_RUNTIME_DIR/systemd/user - To apply this to your own service later, copy its unit file first (
cp ~/.config/systemd/user/<APP>.service ~/<APP>.service.orig, where<APP>is your unit's name). The demo itself replaces nothing.
Steps#
Write the server. Save it as
main.go.listenerFromtakes the socket systemd passes (fd 3,LISTEN_PID= this process,LISTEN_FDS=1) or binds-listenitself;mainunsets the fourLISTEN_*variables before any child process starts; the first SIGTERM or SIGINT starts the drain and a second one exits at once;serveUntilwaits forShutdownand callsClosewhen the drain budget runs out;/stream?n=Nsends N events one second apart, and&wait=Sfirst runssleep Sas a child process.// tsvdemo-drain: an SSE demo that takes its listening socket from systemd and drains on stop. package main import ( "context" "errors" "flag" "fmt" "log" "net" "net/http" "os" "os/exec" "os/signal" "strconv" "syscall" "time" ) func main() { listen := flag.String("listen", "127.0.0.1:18490", "address to bind when not socket-activated") drain := flag.Duration("drain", 2*time.Minute, "on stop, how long running requests may finish") flag.Parse() // The listener first, and the LISTEN_* variables gone, before any child process starts. ln, inherited, err := listenerFrom(*listen) if err != nil { log.Fatal(err) } for _, k := range []string{"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "LISTEN_PIDFDID"} { os.Unsetenv(k) } // First SIGTERM/SIGINT: drain. A second one: exit at once. ctx, cancel := context.WithCancel(context.Background()) sigs := make(chan os.Signal, 1) signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) go func() { <-sigs cancel() <-sigs log.Print("shutdown: second signal, exiting now") os.Exit(1) }() mux := http.NewServeMux() mux.HandleFunc("GET /stream", stream) srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} log.Printf("pid %d serving on %s (inherited=%t)", os.Getpid(), ln.Addr(), inherited) if err := serveUntil(ctx, srv, ln, *drain); err != nil { log.Fatal(err) } log.Print("exit") } // listenerFrom returns the socket systemd passed (fd 3, LISTEN_PID = this process, // LISTEN_FDS = 1) or, without socket activation, binds addr itself. func listenerFrom(addr string) (net.Listener, bool, error) { if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) { ln, err := net.Listen("tcp", addr) return ln, false, err } if n := os.Getenv("LISTEN_FDS"); n != "1" { return nil, false, fmt.Errorf("systemd passed %q sockets, want 1 (one ListenStream= line)", n) } f := os.NewFile(3, "systemd-socket") ln, err := net.FileListener(f) // a dup; close fd 3 itself so children don't inherit it f.Close() return ln, true, err } // serveUntil serves on ln until ctx ends, lets running requests finish for up to drain, then // closes what is left. It returns only after that: Serve returns ErrServerClosed as soon as // Shutdown starts, and a main that returned then would cut every running request. func serveUntil(ctx context.Context, srv *http.Server, ln net.Listener, drain time.Duration) error { done := make(chan struct{}) go func() { defer close(done) <-ctx.Done() log.Printf("shutdown: draining for up to %s", drain) sctx, stop := context.WithTimeout(context.Background(), drain) defer stop() if err := srv.Shutdown(sctx); err != nil { log.Printf("shutdown: drain time over (%v), closing", err) srv.Close() return } log.Print("shutdown: all requests finished") }() err := srv.Serve(ln) if errors.Is(err, http.ErrServerClosed) { <-done return nil } return err } // stream sends n events, one per second. With wait=S it first runs `sleep S` as a child // process: a stand-in for an ssh call or a helper the request waits on. func stream(w http.ResponseWriter, r *http.Request) { n, _ := strconv.Atoi(r.URL.Query().Get("n")) if s := r.URL.Query().Get("wait"); s != "" { if err := exec.CommandContext(r.Context(), "sleep", s).Run(); err != nil { log.Printf("stream: child failed: %v", err) http.Error(w, "child failed: "+err.Error(), http.StatusBadGateway) return } } w.Header().Set("Content-Type", "text/event-stream") rc := http.NewResponseController(w) for i := 1; i <= n; i++ { fmt.Fprintf(w, "data: pid=%d event=%d/%d\n\n", os.Getpid(), i, n) if err := rc.Flush(); err != nil { log.Printf("stream: client gone after %d/%d events", i, n) return } if i < n { select { case <-r.Context().Done(): log.Printf("stream: cut after %d/%d events", i, n) return case <-time.After(time.Second): } } } log.Printf("stream: %d events sent", n) }Create the module and build it:
$ go mod init tsvdemo-drain go: creating new go.mod: module tsvdemo-drain go: to add module requirements and sums: go mod tidy $ go vet ./... && go build -o tsvdemo-drain . && ./tsvdemo-drain -h Usage of ./tsvdemo-drain: -drain duration on stop, how long running requests may finish (default 2m0s) -listen string address to bind when not socket-activated (default "127.0.0.1:18490")Expected: no output from
go vet, then the two flags.Check the fallback without systemd. Started by hand, the server binds the address itself; SIGINT drains and exits 0:
$ ./tsvdemo-drain & sleep 0.5; curl -sN 'http://127.0.0.1:18490/stream?n=3'; kill -INT $!; wait $!; echo "exit status $?" 2026/10/25 14:20:56 pid 2187190 serving on 127.0.0.1:18490 (inherited=false) data: pid=2187190 event=1/3 data: pid=2187190 event=2/3 data: pid=2187190 event=3/3 2026/10/25 14:20:59 stream: 3 events sent 2026/10/25 14:20:59 shutdown: draining for up to 2m0s 2026/10/25 14:20:59 shutdown: all requests finished 2026/10/25 14:20:59 exit exit status 0Expected:
inherited=false, three events,shutdown: all requests finished,exit status 0. The log lines go to stderr, so they interleave with curl's output. Pasted into an interactive bash, the same line also prints the job lines[1] <PID>before the first log line and[1]+ Done…./tsvdemo-drainbeforeexit status 0.Write the socket unit. Save it as
tsvdemo-drain.socket:[Unit] Description=tsvdemo-drain socket: held across restarts, so connections wait while the old process drains [Socket] ListenStream=127.0.0.1:18490 [Install] WantedBy=sockets.target- One
ListenStream=line: the server refuses anyLISTEN_FDSother than1(Failure modes). - An IP literal with a port:
systemd-analyzerefuses Go's:18490andlocalhost:18490forms (Failure modes). Accept=stays at its default,no: the listening socket itself goes to one service process.
- One
Write the service unit. Save it as
tsvdemo-drain.service;install-units.shreplaces@DEMO@with the demo directory.[Unit] Description=tsvdemo-drain: SSE demo that drains running requests on stop # Wants, not Requires: if the socket cannot bind, the service tries to bind the address # itself and Restart= retries. Wants=tsvdemo-drain.socket After=tsvdemo-drain.socket [Service] ExecStart=@DEMO@/tsvdemo-drain -listen 127.0.0.1:18490 -drain 2m Restart=on-failure RestartSec=5 # SIGTERM to the main process only: children it waits on keep running during the drain. # Whatever is left in the cgroup gets SIGKILL after the main process exits. KillMode=mixed # Above -drain: when the stop times out, systemd ends the drain. TimeoutStopSec=150 [Install] WantedBy=default.targetWants=andAfter=the socket: starting the service starts the socket first. WithRequires=, a socket that cannot bind fails the service as a dependency and nothing retries; withWants=the server tries to bind by itself,Restart=on-failureretries every 5 s, and the first retry after the port is free gets the socket again (Failure modes).KillMode=mixed: the default,control-group, sends SIGTERM to every process in the unit when the stop begins, so a child process a request waits on dies at once (step 8).TimeoutStopSec=150, above-drain 2m: when the stop timeout runs out, systemd ends the drain (Failure modes).-listenrepeats the socket's address. The server uses it only without the socket; change both together.
Write the install script. Save it as
install-units.sh. It checks both units withsystemd-analyzebefore it stops anything. A new or changed socket file meansstopof socket and service in one call, thenstartof both: with the service stopped alone, a client that connects during the drain starts it again through the socket, and the socket then refuses to restart while its service runs (Failure modes). An unchanged socket meansstopof the service only, while new connections queue on the open socket, thenstartof both.#!/bin/bash # Install or update the tsvdemo-drain units and (re)start them without refusing connections. set -euo pipefail cd "$(dirname "$0")" UNITDIR=${UNITDIR:-$HOME/.config/systemd/user} mkdir -p "$UNITDIR" build sed "s|@DEMO@|$PWD|" tsvdemo-drain.service > build/tsvdemo-drain.service cp tsvdemo-drain.socket build/ systemd-analyze --user verify build/tsvdemo-drain.socket build/tsvdemo-drain.service if cmp -s build/tsvdemo-drain.socket "$UNITDIR/tsvdemo-drain.socket"; then moved=; else moved=1; fi install -m644 build/tsvdemo-drain.socket build/tsvdemo-drain.service "$UNITDIR/" systemctl --user daemon-reload if [ -n "$moved" ]; then # New or changed socket: stop both in one call, so no client re-activates the service in between. echo "socket file changed: stopping socket and service, then starting both" systemctl --user stop tsvdemo-drain.socket tsvdemo-drain.service systemctl --user start tsvdemo-drain.socket tsvdemo-drain.service else # Same socket: stop the service behind it (new connections queue while it drains), start both. echo "socket file unchanged: restarting the service behind the open socket" systemctl --user stop tsvdemo-drain.service systemctl --user start tsvdemo-drain.socket tsvdemo-drain.service fiMake it executable:
$ chmod +x install-units.shInstall, enable and start the units. For a permanent install, leave out
--runtime.$ echo "UNITDIR=$UNITDIR"; ./install-units.sh UNITDIR=/run/user/1000/systemd/user socket file changed: stopping socket and service, then starting both $ systemctl --user enable --runtime tsvdemo-drain.socket tsvdemo-drain.service Created symlink '/run/user/1000/systemd/user/sockets.target.wants/tsvdemo-drain.socket' → '/run/user/1000/systemd/user/tsvdemo-drain.socket'. Created symlink '/run/user/1000/systemd/user/default.target.wants/tsvdemo-drain.service' → '/run/user/1000/systemd/user/tsvdemo-drain.service'.Check that the service got the socket:
$ systemctl --user is-active tsvdemo-drain.socket tsvdemo-drain.service; ss -ltnp 'sport = :18490' active active State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 127.0.0.1:18490 0.0.0.0:* users:(("tsvdemo-drain",pid=2187490,fd=5)) $ journalctl --user -u tsvdemo-drain.service -g serving -n 1 -o cat; tr "\0" "\n" < /proc/$(systemctl --user show -p MainPID --value tsvdemo-drain.service)/environ | grep ^LISTEN_ 2026/10/25 14:21:00 pid 2187490 serving on 127.0.0.1:18490 (inherited=true) LISTEN_PID=2187490 LISTEN_PIDFDID=14783253 LISTEN_FDS=1 LISTEN_FDNAMES=tsvdemo-drain.socketExpected: both units
active; oneLISTENline on 127.0.0.1:18490;inherited=truein the log./proc/<PID>/environshows the environment systemd set at exec, includingLISTEN_PIDFDID, which systemd 259 adds; the server'sos.Unsetenvdoesn't change that file, it changes what children get (step 8). If the log saysinherited=false, see Failure modes.Restart while a stream runs. Save
restart-test.sh. Stream A asks for 15 events; the restart starts after 3.5 s; request B is made 2 s into the drain;ssshows the listening socket 1 s later. A second argument replaces the restart command (step 10).#!/bin/bash # Restart tsvdemo-drain while stream A (15 events) runs; request B is made during the drain. # usage: restart-test.sh [PORT [COMMAND...]]; COMMAND defaults to systemctl --user restart. P=${1:-18490} U=http://127.0.0.1:$P shift [ $# -gt 0 ] || set -- systemctl --user restart tsvdemo-drain.service ts() { date +%T.%3N; } stamp() { while IFS= read -r l; do [ -n "$l" ] && echo "$(ts) $1 $l"; done; } { curl -sSN "$U/stream?n=15" -w 'http=%{http_code}\n' 2>&1; echo "curl rc=$?"; } | stamp A & sleep 3.5 echo "$(ts) restart begins" ("$@"; echo "$(ts) restart returned rc=$?") & sleep 2 echo "$(ts) B starts" { curl -sSN "$U/stream?n=1" -w 'http=%{http_code} connect=%{time_connect}s total=%{time_total}s\n' 2>&1; echo "curl rc=$?"; } | stamp B & sleep 1 ss -Hltnp "sport = :$P" | stamp ss wait$ chmod +x restart-test.sh $ ./restart-test.sh 14:21:00.774 A data: pid=2187490 event=1/15 14:21:01.775 A data: pid=2187490 event=2/15 14:21:02.776 A data: pid=2187490 event=3/15 14:21:03.777 A data: pid=2187490 event=4/15 14:21:04.271 restart begins 14:21:04.778 A data: pid=2187490 event=5/15 14:21:05.778 A data: pid=2187490 event=6/15 14:21:06.274 B starts 14:21:06.778 A data: pid=2187490 event=7/15 14:21:07.317 ss LISTEN 1 4096 127.0.0.1:18490 0.0.0.0:* 14:21:07.780 A data: pid=2187490 event=8/15 14:21:08.780 A data: pid=2187490 event=9/15 14:21:09.781 A data: pid=2187490 event=10/15 14:21:10.781 A data: pid=2187490 event=11/15 14:21:11.783 A data: pid=2187490 event=12/15 14:21:12.784 A data: pid=2187490 event=13/15 14:21:13.785 A data: pid=2187490 event=14/15 14:21:14.785 A data: pid=2187490 event=15/15 14:21:14.787 A http=200 14:21:14.789 A curl rc=0 14:21:15.293 restart returned rc=0 14:21:15.306 B data: pid=2188843 event=1/1 14:21:15.307 B http=200 connect=0.000108s total=9.024878s 14:21:15.307 B curl rc=0Expected: A gets 15 of 15 events from the old PID and
curl rc=0. B's connect completes in 0.11 ms, before the new process exists, and B gets its event from the new PID once the drain ends (9.0 s later here). During the drain the old process has already closed its listener (Shutdowncloses the listeners first), yetssshows the port listening, withRecv-Qat 1 while B waits and no process listed: systemd keeps its own copy of the socket.systemctl restartreturns when the drain is over (11.0 s here). The journal for the same restart:$ journalctl --user -u tsvdemo-drain.service --since -30s -o short-precise Oct 25 14:21:00.455060 host systemd[1397]: Started tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop. Oct 25 14:21:00.466518 host tsvdemo-drain[2187490]: 2026/10/25 14:21:00 pid 2187490 serving on 127.0.0.1:18490 (inherited=true) Oct 25 14:21:04.276149 host tsvdemo-drain[2187490]: 2026/10/25 14:21:04 shutdown: draining for up to 2m0s Oct 25 14:21:04.276225 host systemd[1397]: Stopping tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop... Oct 25 14:21:14.784301 host tsvdemo-drain[2187490]: 2026/10/25 14:21:14 stream: 15 events sent Oct 25 14:21:15.288520 host tsvdemo-drain[2187490]: 2026/10/25 14:21:15 shutdown: all requests finished Oct 25 14:21:15.288520 host tsvdemo-drain[2187490]: 2026/10/25 14:21:15 exit Oct 25 14:21:15.289782 host systemd[1397]: Stopped tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop. Oct 25 14:21:15.291662 host systemd[1397]: Started tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop. Oct 25 14:21:15.304665 host tsvdemo-drain[2188843]: 2026/10/25 14:21:15 pid 2188843 serving on 127.0.0.1:18490 (inherited=true) Oct 25 14:21:15.305812 host tsvdemo-drain[2188843]: 2026/10/25 14:21:15 stream: 1 events sentRestart while a request waits on a child process. Save
child-test.sh. Request A runssleep 10as a child of the server, then streams 3 events; the restart starts 2 s in. The script also prints the child's file descriptors and how manyLISTEN_*variables it inherited.#!/bin/bash # Restart tsvdemo-drain while request A waits on a child process (sleep 10) before it streams 3 events. U=http://127.0.0.1:${1:-18490} ts() { date +%T.%3N; } stamp() { while IFS= read -r l; do [ -n "$l" ] && echo "$(ts) $1 $l"; done; } { curl -sSN "$U/stream?n=3&wait=10" -w 'http=%{http_code}\n' 2>&1; echo "curl rc=$?"; } | stamp A & sleep 2 main=$(systemctl --user show -p MainPID --value tsvdemo-drain.service) child=$(pgrep -P "$main" -x sleep) echo "$(ts) child: pid $child, fds: $(ls /proc/$child/fd | tr '\n' ' ')LISTEN_ variables: $(tr '\0' '\n' < /proc/$child/environ | grep -c '^LISTEN_')" echo "$(ts) restart begins" systemctl --user restart tsvdemo-drain.service echo "$(ts) restart returned rc=$?" wait$ chmod +x child-test.sh $ ./child-test.sh 14:21:17.873 child: pid 2188987, fds: 0 1 2 LISTEN_ variables: 0 14:21:17.878 restart begins 14:21:25.841 A data: pid=2188843 event=1/3 14:21:26.842 A data: pid=2188843 event=2/3 14:21:27.843 A data: pid=2188843 event=3/3 14:21:27.845 A http=200 14:21:27.846 A curl rc=0 14:21:27.988 restart returned rc=0Expected: the child has only fds 0, 1 and 2 and no
LISTEN_*variable; it keeps running through the drain, A gets 3 of 3 events, and the restart returns after them (10.1 s here).GotchaThe default KillMode kills the child the request waits on
With
KillMode=control-group, the SIGTERM of the stop reaches the child too: the request fails with 502 within 6 ms of the restart, and the drain has nothing left to wait for. The same test with a drop-in, then the drop-in removed:$ mkdir -p "$UNITDIR/tsvdemo-drain.service.d" && printf "[Service]\nKillMode=control-group\n" > "$UNITDIR/tsvdemo-drain.service.d/killmode.conf" && systemctl --user daemon-reload && systemctl --user show -p KillMode tsvdemo-drain.service KillMode=control-group $ ./child-test.sh 14:21:30.796 child: pid 2189923, fds: 0 1 2 LISTEN_ variables: 0 14:21:30.801 restart begins 14:21:30.807 A child failed: signal: terminated 14:21:30.808 A http=502 14:21:30.809 A curl rc=0 14:21:30.811 restart returned rc=0 $ rm "$UNITDIR/tsvdemo-drain.service.d/killmode.conf" && rmdir "$UNITDIR/tsvdemo-drain.service.d" && systemctl --user daemon-reload && systemctl --user show -p KillMode tsvdemo-drain.service KillMode=mixedEnd a drain early (optional). A second SIGTERM to the main process makes the server exit at once; a restart then goes on with the new process. The command is
systemctl --user kill --kill-whom=main --signal=SIGTERM tsvdemo-drain.service. Saveabort-test.shto see it during a 60-event stream:#!/bin/bash # Restart tsvdemo-drain during a 60-event stream, then end the drain early with a second SIGTERM. U=http://127.0.0.1:${1:-18490} ts() { date +%T.%3N; } stamp() { while IFS= read -r l; do [ -n "$l" ] && echo "$(ts) $1 $l"; done; } { curl -sSN "$U/stream?n=60" -w 'http=%{http_code}\n' 2>&1; echo "curl rc=$?"; } | stamp A & sleep 2.5 echo "$(ts) restart begins" (systemctl --user restart tsvdemo-drain.service; echo "$(ts) restart returned rc=$?") & sleep 3 echo "$(ts) second SIGTERM to the main process" systemctl --user kill --kill-whom=main --signal=SIGTERM tsvdemo-drain.service wait$ chmod +x abort-test.sh $ ./abort-test.sh 14:21:31.564 A data: pid=2190293 event=1/60 14:21:32.565 A data: pid=2190293 event=2/60 14:21:33.566 A data: pid=2190293 event=3/60 14:21:34.059 restart begins 14:21:34.566 A data: pid=2190293 event=4/60 14:21:35.566 A data: pid=2190293 event=5/60 14:21:36.567 A data: pid=2190293 event=6/60 14:21:37.062 second SIGTERM to the main process 14:21:37.069 A curl: (18) transfer closed with outstanding read data remaining 14:21:37.070 A http=200 14:21:37.071 A curl rc=18 14:21:37.072 restart returned rc=0Expected: A is cut (
curl: (18)) 3.0 s into the drain, and the restart returns 10 ms after the second signal. The journal records exit status 1 asFailed with result 'exit-code', then starts the new process on the socket:$ journalctl --user -u tsvdemo-drain.service --since -15s -o short-precise […] Oct 25 14:21:34.065330 host systemd[1397]: Stopping tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop... Oct 25 14:21:34.065848 host tsvdemo-drain[2190293]: 2026/10/25 14:21:34 shutdown: draining for up to 2m0s Oct 25 14:21:37.067622 host systemd[1397]: tsvdemo-drain.service: Sent signal SIGTERM to main process 2190293 (tsvdemo-drain) on client request. Oct 25 14:21:37.068169 host tsvdemo-drain[2190293]: 2026/10/25 14:21:37 shutdown: second signal, exiting now Oct 25 14:21:37.068962 host systemd[1397]: tsvdemo-drain.service: Main process exited, code=exited, status=1/FAILURE Oct 25 14:21:37.069055 host systemd[1397]: tsvdemo-drain.service: Failed with result 'exit-code'. Oct 25 14:21:37.069645 host systemd[1397]: Stopped tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop. Oct 25 14:21:37.071006 host systemd[1397]: Started tsvdemo-drain.service - tsvdemo-drain: SSE demo that drains running requests on stop. Oct 25 14:21:37.088652 host tsvdemo-drain[2190806]: 2026/10/25 14:21:37 pid 2190806 serving on 127.0.0.1:18490 (inherited=true)Apply later changes with
install-units.sh. After an edit to the service file only (here itsDescription=), the socket file is unchanged: the script stops the service behind the open socket and then starts both, so a request made meanwhile queues as in step 7:$ sed -i 's/^Description=tsvdemo-drain:/Description=tsvdemo-drain (edited):/' tsvdemo-drain.service && ./restart-test.sh 18490 ./install-units.sh 14:21:37.604 A data: pid=2190806 event=1/15 14:21:38.605 A data: pid=2190806 event=2/15 14:21:39.605 A data: pid=2190806 event=3/15 14:21:40.606 A data: pid=2190806 event=4/15 14:21:41.098 restart begins socket file unchanged: restarting the service behind the open socket 14:21:41.606 A data: pid=2190806 event=5/15 14:21:42.608 A data: pid=2190806 event=6/15 14:21:43.101 B starts 14:21:43.609 A data: pid=2190806 event=7/15 14:21:44.149 ss LISTEN 1 4096 127.0.0.1:18490 0.0.0.0:* 14:21:44.610 A data: pid=2190806 event=8/15 14:21:45.610 A data: pid=2190806 event=9/15 14:21:46.610 A data: pid=2190806 event=10/15 14:21:47.611 A data: pid=2190806 event=11/15 14:21:48.613 A data: pid=2190806 event=12/15 14:21:49.614 A data: pid=2190806 event=13/15 14:21:50.614 A data: pid=2190806 event=14/15 14:21:51.615 A data: pid=2190806 event=15/15 14:21:51.616 A http=200 14:21:51.617 A curl rc=0 Stopping 'tsvdemo-drain.service', but its triggering units are still active: tsvdemo-drain.socket 14:21:51.727 restart returned rc=0 14:21:51.737 B data: pid=2192112 event=1/1 14:21:51.738 B http=200 connect=0.000108s total=8.627194s 14:21:51.739 B curl rc=0Expected:
socket file unchanged: …, systemd's warning that the socket is still active (that is the point here), B queued and answered by the new PID.After an edit to the socket file (here the address, in both files), the script stops socket and service in one call before it starts both:
$ sed -i 's/127.0.0.1:18490/127.0.0.1:18491/' tsvdemo-drain.socket tsvdemo-drain.service && ./install-units.sh socket file changed: stopping socket and service, then starting both $ ss -ltnp '( sport = :18490 or sport = :18491 )'; journalctl --user -u tsvdemo-drain.service -g serving -n 1 -o cat State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 127.0.0.1:18491 0.0.0.0:* users:(("tsvdemo-drain",pid=2192375,fd=5)) 2026/10/25 14:21:52 pid 2192375 serving on 127.0.0.1:18491 (inherited=true)Expected:
socket file changed: …, oneLISTENline on 18491, andinherited=trueon 18491.NoteA socket change refuses or resets new connections until the drain ends
In a separate run of the same move (18490 to 18491) with a stream running, two requests made during the drain, one to each port, both failed at once with
curl: (7) … Could not connect to server, while the stream on the old port finished 8 of 8. With the address unchanged and another line of the socket file edited, a request made during the drain connected, waited, and gotcurl: (56) Recv failure: Connection reset by peerwhen the socket closed after the service. A plain restart (steps 7–9) has neither problem.
Verify#
The restart test on the final address passes: A complete, B connected at once and served by the new PID, one connection queued during the drain.
$ ./restart-test.sh 18491
14:21:52.937 A data: pid=2192375 event=1/15
14:21:53.937 A data: pid=2192375 event=2/15
14:21:54.939 A data: pid=2192375 event=3/15
14:21:55.940 A data: pid=2192375 event=4/15
14:21:56.432 restart begins
14:21:56.941 A data: pid=2192375 event=5/15
14:21:57.941 A data: pid=2192375 event=6/15
14:21:58.435 B starts
14:21:58.941 A data: pid=2192375 event=7/15
14:21:59.479 ss LISTEN 1 4096 127.0.0.1:18491 0.0.0.0:*
14:21:59.942 A data: pid=2192375 event=8/15
14:22:00.943 A data: pid=2192375 event=9/15
14:22:01.943 A data: pid=2192375 event=10/15
14:22:02.943 A data: pid=2192375 event=11/15
14:22:03.945 A data: pid=2192375 event=12/15
14:22:04.945 A data: pid=2192375 event=13/15
14:22:05.947 A data: pid=2192375 event=14/15
14:22:06.947 A data: pid=2192375 event=15/15
14:22:06.948 A http=200
14:22:06.949 A curl rc=0
14:22:07.041 restart returned rc=0
14:22:07.060 B data: pid=2193535 event=1/1
14:22:07.061 B http=200 connect=0.000109s total=8.615904s
14:22:07.062 B curl rc=0
Undo / rollback#
- Drop socket activation and keep the service (the path back for your own service): stop and remove the socket unit, remove the
Wants=/After=lines, reload, restart. The server binds the address itself again:What that gives back: a request during a restart is refused ($ systemctl --user disable --runtime --now tsvdemo-drain.socket && rm "$UNITDIR/tsvdemo-drain.socket" && sed -i "/tsvdemo-drain.socket/d" "$UNITDIR/tsvdemo-drain.service" && systemctl --user daemon-reload && systemctl --user restart tsvdemo-drain.service && sleep 1 && journalctl --user -u tsvdemo-drain.service -g serving -n 1 -o cat Removed '/run/user/1000/systemd/user/sockets.target.wants/tsvdemo-drain.socket'. 2026/10/25 14:22:09 pid 2193892 serving on 127.0.0.1:18491 (inherited=false)curl: (7)), andssprints nothing during the drain because nothing listens:$ ./restart-test.sh 18491 14:22:10.190 A data: pid=2193892 event=1/15 14:22:11.190 A data: pid=2193892 event=2/15 14:22:12.191 A data: pid=2193892 event=3/15 14:22:13.193 A data: pid=2193892 event=4/15 14:22:13.684 restart begins 14:22:14.194 A data: pid=2193892 event=5/15 14:22:15.194 A data: pid=2193892 event=6/15 14:22:15.688 B starts 14:22:15.697 B curl: (7) Failed to connect to 127.0.0.1 port 18491 after 0 ms: Could not connect to server 14:22:15.699 B http=000 connect=0.000000s total=0.000153s 14:22:15.700 B curl rc=7 14:22:16.195 A data: pid=2193892 event=7/15 14:22:17.196 A data: pid=2193892 event=8/15 14:22:18.196 A data: pid=2193892 event=9/15 14:22:19.197 A data: pid=2193892 event=10/15 14:22:20.198 A data: pid=2193892 event=11/15 14:22:21.199 A data: pid=2193892 event=12/15 14:22:22.201 A data: pid=2193892 event=13/15 14:22:23.201 A data: pid=2193892 event=14/15 14:22:24.202 A data: pid=2193892 event=15/15 14:22:24.203 A http=200 14:22:24.204 A curl rc=0 14:22:24.260 restart returned rc=0 - Return your own service to its earlier unit file: copy
~/<APP>.service.origback to~/.config/systemd/user/<APP>.service, remove its.socketunit as in the first bullet, runsystemctl --user daemon-reloadand restart the service.[UNVERIFIED: not exercised; the demo had no earlier unit file] - Remove the demo completely, then delete the demo directory:
$ systemctl --user disable --runtime --now tsvdemo-drain.service && rm "$UNITDIR/tsvdemo-drain.service" && systemctl --user daemon-reload && systemctl --user list-units --all "tsvdemo-*" Removed '/run/user/1000/systemd/user/default.target.wants/tsvdemo-drain.service'. UNIT LOAD ACTIVE SUB DESCRIPTION 0 loaded units listed. To show all installed unit files use 'systemctl list-unit-files'. - For a permanent install, the same commands without
--runtime, withUNITDIR=~/.config/systemd/user.[UNVERIFIED: rollback not exercised outside the runtime unit directory]
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
curl: (18) transfer closed with outstanding read data remaining as soon as a stop begins; the log shows shutdown: draining for up to 2m0s and exit in the same second | main returns when Serve returns ErrServerClosed, while Shutdown still drains in its goroutine | Wait for Shutdown before returning (serveUntil, step 1) |
child failed: signal: terminated, HTTP 502, as soon as a restart begins | KillMode=control-group (the default) sends SIGTERM to every process in the unit | KillMode=mixed (step 4) |
State 'stop-sigterm' timed out. Aborting., Killing process 1972107 (tsvdemo-drain) with signal SIGABRT., a Go SIGABRT: abort goroutine dump, Failed with result 'timeout' | TimeoutStopSec= below the drain (a 5 s drop-in in my run). Fedora 38 and later ship /usr/lib/systemd/user/service.d/10-timeout-abort.conf with TimeoutStopFailureMode=abort, so the timeout sends SIGABRT before any SIGKILL; systemd.service(5) gives terminate as the default | TimeoutStopSec= above -drain (step 4) |
shutdown: drain time over (context deadline exceeded), closing and stream: cut after 9/15 events, then curl: (18) | The request ran longer than -drain (5 s in my run) | Working as designed; raise -drain and TimeoutStopSec= together if such requests must finish |
Stopping 'tsvdemo-drain.service', but its triggering units are still active: tsvdemo-drain.socket; the next request makes the service active again | The socket still listens and starts the service on the next connection | Stop both: systemctl --user stop tsvdemo-drain.socket tsvdemo-drain.service |
tsvdemo-drain.socket: Unit configuration changed while unit was running, and no socket file descriptors are open. Unit not functional until restarted.; after a service restart the log says inherited=false | ListenStream= changed and daemon-reload ran while the socket was active (the file was copied by hand, so install-units.sh then sees no change); is-active still says active | systemctl --user stop tsvdemo-drain.socket tsvdemo-drain.service, then systemctl --user start tsvdemo-drain.socket tsvdemo-drain.service |
Job failed. See "journalctl -xe" for details. from systemctl --user restart tsvdemo-drain.socket; journal: Socket service tsvdemo-drain.service already active, refusing. | The socket unit cannot start while its service runs | Same as the row above |
systemd passed "2" sockets, want 1 (one ListenStream= line), the service exits 1 and is-active says activating (waiting for Restart=) | Two ListenStream= lines (in my run the second came from a drop-in) | One ListenStream= in the socket unit and its drop-ins |
Failed to parse address value in ':18490', ignoring: Invalid argument and Unit has no Listen setting (ListenStream=, ListenDatagram=, ListenFIFO=, ...). Refusing. (the same for localhost:18490) | systemd reads an IP literal with a port, a bare port and other forms listed in systemd.socket(5), not Go's :port or a host name | ListenStream=127.0.0.1:18490; install-units.sh runs systemd-analyze --user verify before it stops anything |
With Requires=: A dependency job for tsvdemo-drain.service failed. See 'journalctl -xe' for details.; journal: Failed to create listening socket (127.0.0.1:18490): Address already in use, Dependency failed for tsvdemo-drain.service; still down after the port is free | Requires= fails the service when the socket fails; no service process ran, so Restart= never fires | Wants= (step 4): the server's own bind fails (listen tcp 127.0.0.1:18490: bind: address already in use), Restart= retries every 5 s, and the first retry after the port is free gets the socket |
curl: (7) Failed to connect to 127.0.0.1 port 18491 after 0 ms: Could not connect to server during a restart | No socket unit: the old process closes its listener when the drain starts, and the new one binds after the drain | The socket unit (steps 3–6) |
Field notes#
- 2026-10-25 13:38–14:22, GEEKOM AE8, Fedora Linux 44, kernel 7.2.10, systemd 259 (259.9-1.fc44), Go 1.26.0, curl 8.18.0, iproute2 6.17.0: the demo above. Units in
$XDG_RUNTIME_DIR/systemd/user, enabled with--runtime; the demo directory sat in my writing workspace, its files saved with mode 644; ports 127.0.0.1:18490–18493. Every block above comes from one run in step order (14:20:56–14:22:25), and so do the 502 andcurl: (7)rows of Failure modes; the other rows and the note in step 10 come from two more runs (13:41–13:42 and 13:46–13:47).- I ran the step sequence four times. In the first pass
curl -shid curl's error texts, and the Undo test started its stream right aftersystemctl restarthad returned: the self-binding server was not listening yet and the stream gotcurl rc=7. A server that binds by itself refuses connections right after its start too, not only during a drain. The second pass still had a unit comment that promised SIGKILL at the stop timeout (wrong on Fedora, see below), and the unchanged-socket branch ofinstall-units.shhad never run; the third pass fixed both. The first three copied the files with their executable bits, so achmodthat named the test scripts before they existed went unnoticed; the fourth saved everything with mode 644, as from this page, and ran thechmodlines of steps 5 and 7–9. - My first port holder for the
Wants=/Requires=cases, a Python socket withoutSO_REUSEADDR, failed to bind 18490 at all (Address already in use), most likely because connections from earlier tests were in TIME_WAIT on that port (I did not capture them). WithSO_REUSEADDRset it held the port and the socket unit failed as intended. - The stop timeout case ended with SIGABRT, not the SIGKILL systemd.service(5) describes for the default
TimeoutStopFailureMode=terminate. Theabortcomes from Fedora's drop-in/usr/lib/systemd/user/service.d/10-timeout-abort.conf(package systemd-259.9-1.fc44), part of the Fedora 38 "Shorter Shutdown Timer" change, which also set the 45 s default stop timeout. The Go runtime answered SIGABRT with a 221-line goroutine dump in the journal and exit status 2. ss -ltnpnever listed the systemd user manager as a holder of the socket, only the server process: as my user, reading the links in/proc/<manager PID>/fd/fails withPermission denied(154 of 154), sosscannot attribute the manager's copy. During a drain the port showed asLISTENwith a queued connection and no process.- After a
ListenStream=change copied in by hand plusdaemon-reload,systemctl is-activestill saidactivefor a socket without file descriptors. Only the server'sinherited=falselog line showed it. - My first
install-units.shhandled an unchanged socket file withstartof the socket and thenrestartof the service. In the state from the previous point,startof the socket failed with "already active, refusing" andset -eended the script. The published version stops the service first, as the Makefile of the service this comes from does. go mod initwith Go 1.26.0 wrotego 1.25.0into go.mod.- At 14:28 the same day a system upgrade installed systemd 259.10-1.fc44, after all of the runs above; nothing here was re-run on 259.10.
- The job lines in step 2's Expected come from a separate paste of that line into an interactive bash on a pseudo-terminal driven by a script (14:12); everything else in step 2 is from the step-order run.
- I ran the step sequence four times. In the first pass
- 2026-10-25 00:45–02:03, same box, systemd 259 (259.9-1.fc44, installed 2026-09-21), a Go LLM routing proxy (the service this manual comes from; the Go version is not in my notes of these runs), throwaway units on 127.0.0.1:18471: five rounds of tests of the change that introduced the socket unit and the drain. The default KillMode sent SIGTERM to the ssh call of an engine switch when a stop began, and the client got an error after 4 s; with
KillMode=mixedthe switch finished and the client got its full stream (62 s). AListenStream=change with only adaemon-reloadleft the socket without file descriptors, and the proxy silently bound the port itself. A stopped service came back on the next client poll through the socket.Requires=turned a socket bind failure into a service that stayed down;Wants=letRestart=recover. The previous release's 5 s shutdown grace had never run, becausemainreturned as soon asListenAndServereturnedErrServerClosed.
Notes#
- The story of how the proxy got here: Shutdown grace that never ran. For llama-server units, KillMode and a SIGTERM to the main process only: Terminating immediately took 90 seconds.
- Requests that arrive during a restart wait for the whole drain (B waited 9.0 s in step 7). A client whose timeout is shorter than the drain gives up. How many connections can wait is the socket's
Backlog=; systemd.socket(5) gives its default as 4294967295, capped bynet.core.somaxconn(4096 here). - net/http's
Shutdown"does not attempt to close nor wait for hijacked connections such as WebSockets", soserveUntildoes not drain them. Not tested here. - systemd 259 also sets
LISTEN_PIDFDID, and sd_listen_fds(3) checks it against the process's pidfd;listenerFromchecks onlyLISTEN_PID. - fd 3 arrives without close-on-exec: a probe under
systemd-socket-activate, which passes sockets the same way, readFD_CLOEXECunset.net.FileListenerworks on a close-on-exec duplicate (poll.DupCloseOnExecin net/file_unix.go), so closing fd 3 keeps the socket out of children (step 8: fds 0, 1, 2 only). github.com/coreos/go-systemd/v22/activation(v22.7.0) does the descriptor part; at v22.7.0 itsFiles(true)unsetsLISTEN_PID,LISTEN_FDSandLISTEN_FDNAMESbut notLISTEN_PIDFDID. I read its source and did not use it.- For a socket on a specific LAN address, systemd.socket(5) recommends
FreeBind=true("for robustness reasons"), so the socket binds before the address is configured. Not tested here.
Sources#
- net/http server.go at go1.26.0 —
Server.Shutdown("immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return"),Serve,Close, hijacked connections. Same text on release-branch.go1.26. - net/file.go at go1.26.0 —
FileListenerreturns a copy; closing one does not affect the other. - net/file_unix.go at go1.26.0 — the copy is made with
poll.DupCloseOnExec. - os/exec exec.go, release-branch.go1.26 —
CommandContextkills the process when the context is done. - systemd.kill(5), systemd 259 —
KillMode=: control-group (default) vs mixed. - systemd.socket(5), systemd 259 —
ListenStream=address forms,Accept=no,Backlog=,FreeBind=, no implicit dependency from socket to service. - sd_listen_fds(3), systemd 259 — fd 3,
LISTEN_PID/LISTEN_FDS,LISTEN_PIDFDIDsince 259, the variables to unset. - systemd.service(5), systemd 259 —
TimeoutStopSec=,TimeoutStopFailureMode=,Restart=. - systemd.unit(5), systemd 259 —
Wants=vsRequires=. - systemd-system.conf(5), systemd 259 —
DefaultTimeoutStopSec=(90 s in the man page). - Fedora change: Shorter Shutdown Timer — Fedora 38: unit stop timeout from 2 minutes to 45 seconds, SIGABRT for a core dump before SIGKILL; the source of
10-timeout-abort.conf. - go-systemd activation/files_unix.go at v22.7.0 —
Files(unsetEnv).