Wait for the Server to Drain

GolangBeginner
Practice Now

Introduction

The download desk must close without cutting off the person already receiving its field notes. You will replace immediate connection closure with Go's ordinary HTTP shutdown coordination, then keep a real complete download from the original process.

This unit starts independently with the prepared handler and the same two documents. Bring the previous unit's precise PID and accepted-request observations, or equivalent experience, plus handlers, errors, goroutines and channels. Signal delivery, finite contexts, error matching and main's waiting responsibility are explained below. This covers ordinary HTTP, not WebSockets, distributed traffic switching or a production zero-downtime guarantee. Allow about 60 minutes; this is an author estimate.

Make Main Wait for Shutdown

In this step, implement finite shutdown coordination while preserving the prepared HTTP handler.

Start in Downloads and enter the independent workspace:

cd /home/labex/project/download-desk
cat lifecycle.go

Only lifecycle.go is yours to change. Keep the handler in main.go, both documents, go.mod, the prepared observers and their observations unchanged. The function currently captures SIGTERM but calls Close, which interrupts active connections. A signal handler alone does not make a service graceful.

An http.Server owns the listener and active HTTP connections. ListenAndServe blocks while accepting work. Shutdown first closes listeners and existing idle connections, then waits for ordinary active requests to become idle. Starting shutdown causes ListenAndServe to return http.ErrServerClosed immediately, even though accepted handlers can still be running. If main returns at that point, every goroutine disappears and the transfer is cut off. Therefore the main path must wait for Shutdown itself to return.

A context carries a cancellation or deadline budget. context.Background() supplies a starting context. context.WithTimeout(..., 45*time.Second) creates a finite 45-second shutdown budget and a cancel function; defer cancel() releases its associated resources when this function returns, even if work ends sooner. Cancellation is not proof of completion. The public maximum is 60 seconds after the stop request: if an accepted response cannot finish within the chosen budget, report an error and exit nonzero within that maximum. The reference uses 45 seconds; equivalent finite budgets no longer than 60 seconds are allowed. The check includes a finite long response that cannot finish within 60 seconds, so a shutdown using only context.Background() is not sufficient. In that isolated timeout trial, a partial transfer and a reported nonzero server exit are the correct failure outcome, not a successful drain. If the budget expires, Shutdown returns an error; our function returns that error and main exits nonzero. We do not label that case successful draining or silently restart the server.

signal.Notify(stopped, syscall.SIGTERM) asks Go to send that signal on a channel. This channel has buffer 1 because signal delivery must not wait for a receiver; one queued stop request is enough here. signal.Stop(stopped) cancels this channel's registration when the function ends; it is different from stopping the HTTP server. The buffered result channel similarly lets the serving goroutine report its one result while main is doing shutdown work.

The first select waits for either a serving error or a stop signal. That also prevents a bind/start error from being hidden forever behind a signal wait. After a successful shutdown, errors.Is(err, http.ErrServerClosed) recognizes the expected serving result. A different serving error is returned instead of being called success. runServer is called by main, so it returning only after shutdown is what keeps the process alive.

Replace this one file. The quoted here-document delimiter means the shell writes the Go source literally until the final standalone GO; > replaces lifecycle.go only:

cat > lifecycle.go <<'GO'
package main

import (
 "context"
 "errors"
 "net/http"
 "os"
 "os/signal"
 "syscall"
 "time"
)

func runServer(server *http.Server) error {
 stopped := make(chan os.Signal, 1)
 signal.Notify(stopped, syscall.SIGTERM)
 defer signal.Stop(stopped)
 result := make(chan error, 1)
 go func() { result <- server.ListenAndServe() }()
 select {
 case err := <-result:
  return err
 case <-stopped:
 }
 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
 defer cancel()
 if err := server.Shutdown(ctx); err != nil {
  return err
 }
 err := <-result
 if !errors.Is(err, http.ErrServerClosed) {
  return err
 }
 return nil
}
GO

Format the source using gofmt -w; -w writes the formatted file. Build the current package with an explicit executable path so a new terminal does not depend on a hidden PATH setting:

/usr/local/go/bin/gofmt -w lifecycle.go
/usr/local/go/bin/go build -o desk .

A syntax error names a file and line. Compare the complete block and replace only lifecycle.go if needed; do not reset the VM. The check also builds your unchanged function in isolated temporary fixtures and varies real download durations and completion order. It checks the long-response failure branch as well as normal success, so allow about a minute for a check to finish. It does not signal your live desk or modify your source.

Keep the Download and Close the Listener

In this step, keep an accepted real download while refusing new connections and waiting for the original server.

Switch to Server, enter the workspace and start a new observed attempt. This terminal stays occupied until the original process ends:

cd /home/labex/project/download-desk
python3 desk.py serve drained

Wait for its actual ready message, then switch to Downloads. Read this entire step, including the expected probe failure and recovery, before starting the following finite slow transfer. Then run the blocks in sequence; do not use the transfer window to learn a new concept. Start a new finite slow transfer in the background, just as in the previous unit. The 300ms between 32-byte writes gives ordinary reading time; it is not an unbounded wait for a signal.

python3 desk.py download drained completed north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained completed

The observer reads the real accepted request before you stop anything. Obtain and inspect the original built process identity, then send SIGTERM from this terminal:

server_pid=$(python3 desk.py pid drained)
python3 desk.py identity drained
kill -TERM "$server_pid"

While that accepted download is still in progress, test a new connection. The prepared probe makes bounded real connection attempts for up to two seconds, retaining every native outcome until a refusal appears. This allows Go to handle the delivered signal before the first failed connection is observed; the probe does not assume kill synchronously closes the listener. The next command is expected to return 7, with a connection-refused message:

python3 desk.py probe drained

This is a refusal to create a new connection, not an HTTP 403 or a damaged accepted transfer. The original download should continue. Wait for its background job and compare its saved actual bytes with the original:

wait "$download_job"
cmp documents/north-pier.txt downloads/completed.txt
cat downloads/completed.txt

Expect native curl 0, no cmp output, and the complete final chapter. Switch to Server: it should now report Original server native exit: 0. The server may finish sending before the client process finishes reading or flushing its file; shutdown does not wait for a client-side application acknowledgement. These are separate observations, not a required order between two terminal messages.

Return to Downloads. Retain lifecycle.go, the complete download, and the native observations. The check reads this live result and tests the same source on isolated random ports with an existing idle connection and accepted requests in different orders. Direct Close, returning from main while Shutdown is still running, keeping the listener open until work finishes, or sleeping for a guessed interval do not meet this contract.

If the download has already completed before accepted or the stop action, preserve its file and choose a new label on the still-running attempt. If the server has already exited, explicitly start a new attempt name in Server and use that name for a new slow output and the identity, accepted and probe commands. A refused probe taken only after the process exited does not show early listener closure; retry the observed active workflow with new names. Never overwrite the successful earlier file or reset observation history.

For a complete retry after the previous attempt has exited, use Server:

python3 desk.py serve drained-retry

After the ready message, use Downloads. Read all blocks first. These new names preserve the earlier files and observations:

python3 desk.py download drained-retry completed-retry north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained-retry completed-retry
server_pid=$(python3 desk.py pid drained-retry)
python3 desk.py identity drained-retry
kill -TERM "$server_pid"

The following probe is expected to return 7:

python3 desk.py probe drained-retry
wait "$download_job"
cmp documents/north-pier.txt downloads/completed-retry.txt

Expect curl 0, equal complete bytes, and original native exit0 in Server. If the earlier server is still running, finish reading before starting a fresh slow output on that attempt; use a new label in both download and accepted, and retain that attempt name for identity and probe. If an earlier probe already exists, preserve it, explicitly stop your own old attempt and let it exit before the complete new-attempt retry above.

A genuine startup error appears in the server's existing stderr and returns nonzero; correct a documented source/build mistake or wait for your own previous attempt to exit before using 8080 again. Do not kill a same-named platform process. A shutdown deadline error means the finite budget did not establish completion; read it honestly rather than substituting a replacement process or copying an earlier document.

Summary

You coordinated the listener, accepted HTTP handlers, a finite shutdown context, and main. The original process stopped accepting connections while finishing its existing work. Exact final bytes and native outcomes made the result observable.