Files
mqtt-5-learning/lessons/02-go-connection.md
T
2026-10-08 13:45:52 +03:30

12 KiB

Section 2 — Understand a Go MQTT 5 connection

Prerequisite: Section 1's broker and backend credentials work. This section contains a complete small program. Type it in stages, compile after each meaningful change, and explain the code before running the failure exercises.

Version baseline: Paho v0.23.0, verified from upstream release tags on 2026-10-07. It requires Go 1.24 or newer; Go 1.25 is compatible. The matching local reference is references/paho.golang-v0.23.0/ (commit 1aa03969c5932464359b3e4ed752d9ac61abccc9).

Working directory and file placement

Run every shell command in this lesson from the learning project root:

cd /home/ryan/Media/Code/Code/robot-market/mqtt-5-learning

Paths such as cmd/backend/main.go are relative to this directory. Markdown files under lessons/ explain the work; create the application files at the paths shown, not inside lessons/ or references/. Code blocks labeled Go, TOML, YAML, or conf are file contents; shell blocks are terminal commands. Create parent directories before saving files.

1. Build the connection program

Create the directory first:

mkdir -p cmd/backend

Create cmd/backend/main.go and paste the complete program below into it. If the file already exists, replace its contents rather than appending a second program:

package main

import (
	"context"
	"fmt"
	"log"
	"net/url"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/eclipse/paho.golang/autopaho"
	"github.com/eclipse/paho.golang/paho"
)

func main() {
	if err := run(); err != nil {
		log.Print(err)
		os.Exit(1)
	}
}

func run() error {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	user := os.Getenv("TEST_MQTT5_MQTT_USERNAME")
	password := os.Getenv("TEST_MQTT5_MQTT_PASSWORD")
	if user == "" || password == "" {
		return fmt.Errorf("set the lab username and password environment variables")
	}
	broker, err := url.Parse("mqtt://127.0.0.1:18883")
	if err != nil {
		return fmt.Errorf("parse broker URL: %w", err)
	}

	cfg := autopaho.ClientConfig{
		ServerUrls:                    []*url.URL{broker},
		KeepAlive:                     20,
		CleanStartOnInitialConnection: true,
		SessionExpiryInterval:         0,
		ConnectUsername:               user,
		ConnectPassword:               []byte(password),
		OnConnectionUp: func(_ *autopaho.ConnectionManager, ack *paho.Connack) {
			log.Printf("connected: session_present=%t reason=0x%02x", ack.SessionPresent, ack.ReasonCode)
		},
		OnConnectionDown: func() bool {
			log.Print("connection down; AutoPaho will retry")
			return true
		},
		OnConnectError: func(err error) {
			// Keep errors free of credentials; never log cfg or broker userinfo.
			log.Printf("connect attempt failed: %v", err)
		},
		ClientConfig: paho.ClientConfig{
			ClientID:      "lab-backend",
			OnClientError: func(err error) { log.Printf("client error: %v", err) },
			OnServerDisconnect: func(packet *paho.Disconnect) {
				log.Printf("server disconnect: reason=0x%02x", packet.ReasonCode)
			},
		},
	}

	manager, err := autopaho.NewConnection(ctx, cfg)
	if err != nil {
		return fmt.Errorf("create connection manager: %w", err)
	}
	defer func() {
		shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := manager.Disconnect(shutdown); err != nil {
			log.Printf("shutdown did not finish within deadline: %v", err)
		}
	}()

	startup, cancel := context.WithTimeout(ctx, 10*time.Second)
	err = manager.AwaitConnection(startup)
	cancel()
	if err != nil {
		return fmt.Errorf("initial connection: %w", err)
	}
	log.Print("connection ready; press Ctrl+C to stop")
	<-ctx.Done()
	return nil
}

2. Understand the connection lifecycle

Before reading the full program, predict what evidence you need at each stage:

Go process → AutoPaho retry loop → TCP connection → CONNECT → CONNACK
                                                        → subscriptions (Lesson 4)

AutoPaho owns reconnect attempts and creates the underlying Paho client for a connection. Paho implements MQTT packets and protocol exchanges. Your application owns readiness, authorization policy, command state, and business retries. A running goroutine or open TCP socket is too early to declare MQTT ready: the broker can still reject CONNECT.

Setting or call MQTT meaning and reason for this choice
ServerUrls Where to dial; the lab host endpoint is different from the Compose service endpoint
ClientID Broker session identity; two simultaneous processes with this ID compete for one connection
ConnectUsername, ConnectPassword CONNECT authentication inputs; independent of Client ID and payload identity
KeepAlive: 20 Maximum idle interval between client control packets, in seconds; Paho handles PINGREQ/PINGRESP when needed
CleanStartOnInitialConnection: true Discard a previous broker session on the manager's first successful connection
SessionExpiryInterval: 0 Do not retain this broker session after the connection closes
NewConnection Start connection management; a nil error does not establish a successful CONNACK
AwaitConnection Wait for connection availability; it does not wait for SUBACK or a device heartbeat
OnConnectionUp Observe a successful connection, including reconnection; inspect CONNACK evidence here
OnConnectError Observe failed attempts, including refused connections or unsuccessful CONNACK
OnConnectionDown Decide whether reconnect attempts should continue; returning false ends that retry lifecycle
OnServerDisconnect Inspect a DISCONNECT actually sent by the broker; a severed cable cannot send this packet

Keep Alive checks the MQTT connection, not the health of the vending application. With an effective Keep Alive of 20 seconds, the broker's protocol inactivity limit is 30 seconds without a client control packet; an already-detected closed socket can be handled earlier. A CONNACK Server Keep Alive can override the requested value. Your three-second heartbeat in Lesson 4 carries application evidence and has a separate purpose.

There are three different contexts here. The signal context controls the program lifetime. startup limits the initial wait to ten seconds. shutdown is a fresh context because the signal context is already canceled during shutdown. The manager receives the long-lived context, not the startup deadline; passing the startup context to NewConnection would terminate your service when that deadline expires.

CleanStartOnInitialConnection: true and SessionExpiryInterval: 0 choose a disposable early-lab session. This is convenient for first experiments and intentionally offers no offline broker session after disconnect. Later lessons change these settings and test recovery. Do not copy them as a durable production policy.

Paho v0.23.0's Disconnect(ctx) cancels the manager and waits for manager shutdown with the provided deadline. Its Done() documentation notes that underlying MQTT cleanup may still be completing; neither API is proof that application data was persisted or device work was finished.

3. Run it

In zsh, read your backend password without echoing it:

export TEST_MQTT5_MQTT_USERNAME=backend
read -rs 'TEST_MQTT5_MQTT_PASSWORD?Backend lab password: '
export TEST_MQTT5_MQTT_PASSWORD
go run ./cmd/backend

export NAME=value puts a variable into this terminal's environment so child processes such as go run inherit it. The read line uses zsh syntax: -r preserves backslashes, -s hides typed characters, and NAME?prompt selects the variable and prompt. Enter the password you created in Lesson 1; this does not create a broker user. The second export exposes that newly read variable to the Go process. In bash the prompt syntax differs; do not copy this zsh-specific line unchanged.

go run ./cmd/backend compiles the package and runs it in the foreground. ./ means relative to the project root. Ctrl+C sends an interrupt to the foreground program; signal.NotifyContext turns it into context cancellation, allowing your cleanup code to run. After the process exits, unset TEST_MQTT5_MQTT_PASSWORD removes the credential from this shell's environment; it does not rotate the broker credential.

Environment variables avoid password literals in this command but are not a secrets vault. Use only lab credentials. Expect connected and connection ready; callback log order can vary. Press Ctrl+C and verify exit within the shutdown deadline.

Run syntax checks after creating the file:

gofmt -w cmd/backend/main.go
go build ./cmd/backend
go vet ./cmd/backend

gofmt -w rewrites the named source file using Go formatting. go build checks compilation and linking; go vet checks suspicious code patterns without running the application. A successful vet run is normally quiet.

go build emits a binary in the current directory; remove or ignore it as appropriate. A build verifies syntax/types, not connection behavior.

4. Observe the protocol and failures

CONNECT carries your client ID, authentication fields, keepalive, and session properties. A successful CONNACK is connection evidence. Bad credentials produce an unsuccessful connection attempt; the rejected CONNACK is handled by the connection-error path rather than your success callback.

  1. Stop the broker before running the program. Expect retry diagnostics and initial-wait failure near ten seconds, followed by cleanup.
  2. Start the broker; run the program; stop and restart the broker. Expect connection-down and connection-up events. You have not restored subscriptions yet because this program has none.
  3. Supply a wrong password. Explain why retrying cannot repair an incorrect credential.
  4. Run two copies using the same client ID. Observe connection takeover/reconnect behavior, then stop both. Unique IDs are necessary for concurrently running clients.
  5. Press Ctrl+C while the broker is unavailable. Cancellation should interrupt the wait; do not wait for the full startup deadline.

Cautions and troubleshooting

  • AutoPaho handles connection management; it does not invent topic authorization, business retries, or durable command storage.
  • Do not call log.Fatal from callbacks. It terminates the process without running deferred cleanup.
  • Callbacks should be short. Never add an indefinite channel send, database operation, or sleep to OnConnectionDown.
  • Keep client IDs stable when you later want session resumption, but distinct across different simultaneously running clients.
  • Logging entire config structs or URL userinfo can reveal passwords. These examples keep credentials separate from the URL.
  • A connection-up event is not subscription readiness, and neither is device readiness.

Completion: Explain every field in cfg, distinguish the three contexts, and demonstrate successful startup, failed startup, reconnect, and cancellation.

Questions to answer before Section 3

  1. Why can NewConnection return before a usable broker connection exists?
  2. What would happen if you passed startup to NewConnection?
  3. Why does shutdown use context.Background() as its parent?
  4. What does returning false from OnConnectionDown change?
Check your reasoning after answering

NewConnection starts a background lifecycle; CONNACK is later evidence. A startup context passed as the manager lifetime would stop the manager when that context expires or is canceled. Shutdown needs a fresh bounded context because the signal context is already canceled. Returning false from OnConnectionDown stops further automatic reconnect attempts for that manager; it does not repair the connection or preserve application state.

Sources

Next: Section 3.