Live progress reporting for long-running Incus operations (image pulls, instance lifecycle). The client emits progress events; a renderer turns them into terminal output.
Incus operation
-> operation hook registers op.AddHandler (client)
-> reportProgress reads Metadata "*_progress"
-> Progress{Percent, Text}
-> progressHandler callback (set by the consumer)
-> renderer paints a line (cmd/incus-compose)
Two distinct signals feed a line, and they arrive on different paths:
AddHookAfter to mark a line done. This is what lets
quick actions (start, stop, delete) that never emit progress still report.Register a callback for live operation progress; pass nil to disable. Operations
run in parallel, so the handler may be called concurrently and must be safe for
concurrent use.
gc.SetProgressHandler(func(action client.Action, r client.Resource, _ client.Options, p client.Progress) {
// render p for r
})
The handler is wired in through the operation hooks: when an action runs an Incus
operation, the hook calls op.AddHandler, which on each update reads the operation
Metadata for a key ending in _progress, parses a NN% out of it, and invokes the
handler.
Not every wait is an Incus operation. In-client waits that block without an
operation (e.g. an instance blocking on a dependency's health) call
emitProgress to push a synthetic Progress{Percent: -1, Text: ...} straight to
the handler, so the line shows a spinner with status instead of stalling silently.
It lands on the acting resource's current line (same action key), so the wait and
the operation that follows share one line.
type Progress struct {
Percent int // 0-100, or -1 when the operation reports no percentage
Text string // raw status text from Incus, empty when none
}
Two sources, two shapes:
"rootfs: 42% (3.10MB/s)"): Percent
is set and Text carries the transfer speed."Retrieving OCI image from registry"):
Percent is -1 because the registry download runs as an opaque skopeo subprocess
with no byte or percentage feedback. Render these with a spinner, not a bar.cmd/incus-compose/progress.go is the canonical consumer. startProgress attaches
it and returns a finish func:
finish := startProgress(globalClient, client, os.Stderr)
defer finish(success)
Attaching does three things: registers the renderer as the progress handler,
registers an AddHookAfter to mark lines done, and (in animate mode) reroutes log
output above the live block. The finish func clears the handler, flushes the final
frame, and restores log routing.
Selected once, from whether stderr is a real terminal:
NO_COLOR both degrade to this cleanly.Color is gated on noColor; cursor movement is gated on animate - so the two
concerns degrade independently.
Lines are keyed by action + "/" + IncusName(), so a resource that goes through
several actions (restart = stop then start) gets one line per action. Batches run in
priority order, so images report done before instances.
While a live block is on screen, slog output would be overwritten by the next
repaint. To avoid that, log records are routed through a swapWriter to a
bypassWriter that prints whole lines above the block (erase block, write,
repaint below). Partial lines are buffered until their newline arrives so a torn
write cannot split the block. Plain mode has no in-place block, so it skips this.
Operations run in parallel, so handle, markDone, and the bypass writer all guard
shared state with a single mutex. The spinner ticker takes the same lock before each
repaint.
- \ | /; braille frames would violate the
no-non-ASCII rule and misrender in narrow terminals.