Rewrite the SDK for v2.0.0 - #14
Merged
Merged
Conversation
otavio
force-pushed
the
v2-rewrite
branch
3 times, most recently
from
August 28, 2026 16:26
0145473 to
e96fdfc
Compare
The module path becomes github.com/UpdateHub/agent-sdk-go/v2, and the API
is not compatible with what came before it. The rewrite is driven by one
intended consumer: a single-process firmware daemon on a device that has
no way back if its update channel breaks. Three defects are fatal there.
The listener called log.Fatal on every error path, including inside
Handler.Cancel, so a transient socket error took the host down. Every
client method ended in a single-value type assertion on a value that
processRequest sets to nil on error, so an unreachable agent panicked the
caller. And processRequest discarded the HTTP response, so no status code
was ever read.
The first two composed. Probe always sent a body, so an empty custom
server sent {"custom_server": ""}, which agent 2.1.6 answers with a 500 -
and keeps answering with a 500 until it restarts, because a parked agent
never reaches the entry point that resets the address. The 500 body then
failed to decode, so Probe panicked rather than returning.
What the rewrite does:
- Nothing exits the process, and no method panics. Every failure is
returned to the caller, which decides what it means.
- Every call reads the HTTP status code and reports an unexpected one as
a *StatusError carrying the code and the body, so "the agent answered
500" is distinguishable from "the reply did not decode".
- Probe sends no body when no custom server is given, as the agent's own
Rust and Python SDKs do.
- ProbeResponse is a type rather than interface{}, and carries all four
replies: updating, no_update, try_again(N), and busy with the agent's
own state name. Busy is not a failure, and one of the busy state names
is "error", so the two must stay apart.
- LocalInstall, RemoteInstall and AbortDownload report the agent's 406
refusal as a result rather than as an error.
- Calls to one agent are serialised, through a turn held per base URL, so
a second Client cannot defeat it.
- Configuration is a constructor parameter. NewClient takes the base URL,
a timeout and a hold, NewStateChange takes the socket path, and the
UH_LISTENER_TEST environment variable is deleted rather than kept as a
fallback: a fallback leaves a process-global seam for anyone who does
not pass the parameter.
- The listener binds without unlinking, so a path that is already there
fails with EADDRINUSE and the caller decides what that means.
- The listener has a lifetime: Bind, Serve(ctx) and Close replace a
Listen that blocked for ever, and it serves each connection in its own
goroutine rather than one at a time.
- A callback takes a context and returns an error. Errors, and panics,
reach the sink OnError registers; so does a veto the listener could not
write, whatever the callback did with that error. A single connection
never stops the listener, because a listener that stops leaves the
agent blocked.
- TriggerInstalled reports whether the agent's trigger script is there.
Without it the agent never consults the socket, so it installs whatever
the server offers on every poll, unasked. What to do about that is the
consumer's decision, so the package only reports it.
- The package writes nothing to stdout and nothing through log.
- gorequest is gone, with the goproxy and pkg/errors dependencies it
pulled in for two POSTs to localhost. The package needs the standard
library alone.
Why a context bounds the wait and never the request. A call that ended on
its context used to release the turn at once, so the next call could
reach an agent that was still working on the first one, and the port's
own budgets reach that state: a probe was measured blocking 15.569 s
against a 10 s local_install budget. That matters because of how the
agent handles a request. Its machine selects between a sleep, a waker and
await_communication, and await_communication awaits the whole handler.
When the sleep or the waker wins the race, select drops the handler
mid-flight, the reply channel is dropped without a send, and the task
waiting on it reaches unreachable!("Unexpected response: Err(RecvError)")
in states/machine/address.rs. Every request still in flight there widens
that window, and the communication channel is bounded at 10, which is
where the "ten concurrent requests" measurement comes from. So the round
trip now runs to its end on a goroutine that holds the turn, while the
caller returns at its deadline; a call that then gives up waiting for its
turn reports ErrCallOutstanding. An abandoned request is not itself a
hazard for the agent - it drops the reply with responder.send(...).ok()?
and carries on. The hazard is the second request.
The hold is what guarantees the turn comes back. The agent can stay alive
and answer nothing at all, which is exactly what it does after the panic
above, and neither its answer nor the death of its process would then
release the turn. So the caller states how long the client keeps it after
giving up. Long is safer than short, and the value is the caller's,
because only the caller knows when to stop assuming its agent is working.
The module asked for go 1.15, which predates every language feature this
rewrite uses. It now asks for 1.26, the release its intended consumer
builds with, and modernize -fix has run over the tree.
Both files have tests, where the repository had none on either branch.
The four that pin the turn - the serialising, the abandoned call, the
hold and the shared turn - each fail against the behaviour they replace.
The CI jobs drove an OpenAPI mock and UH_LISTENER_TEST through the
example binaries; they run that suite instead, under the race detector,
against the version go.mod declares and against the newest release. The
README states the agent version the release is validated against, 2.1.6,
and lists what v2.0.0 breaks.
Claude-Session: https://claude.ai/code/session_01Rk8Pd5KHDPTSifYYmoxRan
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This rewrites both files of the SDK and releases them as
v2.0.0, at modulepath
github.com/UpdateHub/agent-sdk-go/v2. Please do not tag it yet. Thetag is cut after a consumer proves the integration against a real agent.
The repository has been dormant since 2020-12-09 and its only tags,
1.0.0and1.1.0, lack thevprefix, so the Go tool sees no version on this module atall and every consumer already pins a pseudo-version off a branch. That makes
the first
v-prefixed tag free of history, and it is why this is a rewriterather than a patch series.
Why a rewrite
The intended consumer is a single-process firmware daemon on a device with no
way back if its update channel breaks. Three defects are fatal there.
The listener killed the host.
checkErrcalledlog.Fataland every errorpath used it:
net.ListenandAcceptinsideListen(), the write insideHandler.Cancel(), andos.RemoveinremoveFile. A transient socket errortook down the process.
Every client method panicked on the error path.
processRequestreturnsnilon error, and each method then ran a single-value type assertion on it.A nil interface fails that assertion and panics, on the ordinary path where the
agent is down or the reply does not decode.
The request helper discarded the HTTP response, assigning it to
_, so nostatus code was ever read. A 500 was indistinguishable from a malformed reply.
The first two compose, and the composition is worse than either half.
Probealways sent a body, so an empty argument sent
{"custom_server": ""}. Agent2.1.6 has
Request.custom_serveras a plainStringrather than anOption,so that resolves the server address to
"",Url::parse("")fails, and theagent answers 500 with no probe performed. It is sticky: a parked agent
never reaches the
EntryPointthat resets the address, so every later probe —including a bodyless one — answers 500 until the agent restarts. The 500 body
then failed to decode, so
Probepanicked rather than returning.Confirmed on a device running agent 2.1.6, with its negative control:
What changed
to the caller, which decides what it means. A library that calls
log.Fatalhas decided its consumer's process should die, and that is not its call.
*StatusErrorcarrying the code and the body.Probesends no body when no custom server is given, which is what theagent's own Rust and Python SDKs send.
ProbeResponseis a type, notinterface{}, and carries all four repliesthe agent can send:
updating,no_update,try_again(N)and busy with theagent's own state name. Busy is not a failure — the agent answers it
without reaching the server whenever it is in a state that is not preemptive —
and one of those state names is
error, so the type keeps them apart.LocalInstall,RemoteInstallandAbortDownloadreport the agent's 406refusal as a result rather than as an error.
than per
Client, and a context bounds the wait rather than the request, so acall a caller gave up on keeps its turn. Ten concurrent requests panic a tokio
worker in agent 2.1.6:
thread 'tokio-runtime-worker' panicked at updatehub/src/states/machine/address.rs:102:20: internal error: entered unreachable code: Unexpected response: Err(RecvError). The process survivesand keeps polling on schedule, but
/infoand/probethen time out forever; only
systemctl restart updatehubclears it.NewClienttakes a base URL, atimeout and a hold;
NewStateChangetakes a socket path.UH_LISTENER_TESTisdeleted rather than kept as a fallback, because a fallback leaves the
process-global seam in place for anyone who does not pass the parameter.
carries no deadline of its own. The value is the consumer's; the SDK only
refuses to hard-code "none".
socket before binding, so a second binder won silently — which, for this
socket, means a second process quietly takes over the device's only firmware
veto. It now fails with
EADDRINUSE, whicherrors.Isreports, and thecaller decides whether that is a live binder or a stale path.
Bind,Serve(ctx)andClosereplace aListenthat blocked for ever with a deaderrorreturn, and it serves eachconnection in its own goroutine rather than one at a time.
connection — a failed read, a callback error, a reply that could not be
written — reach the sink
OnErrorregisters, and the listener keeps serving,because a listener that stops leaves the agent blocked on its next transition.
A callback that panics is reported the same way rather than taking down a
process that could not have recovered it.
TriggerInstalledreports whether the agent's trigger script is there.Without the script the agent never consults the socket, so a bound and correct
listener vetoes nothing and the device installs whatever the server offers on
every poll, unasked. Where the script lives is protocol truth, so it goes
here; what to do about its absence is the consumer's, so it stays there.
log.gorequestis gone, and with itgoproxy— a man-in-the-middle proxylibrary — plus
pkg/errorsand their transitive tree, all pulled in for twoPOSTs to localhost. The module now needs the standard library alone, which
also closes the 13 Dependabot advisories open on the default branch.
processRequestdouble decode is gone: it calledEndStructand thenjson.Unmarshalon the same value.modernize -fixhas runover the tree.
Tests
The repository had none on either branch. It now has 44, covering the four
probe replies, the bodyless request, the 500, the 406 refusals, every method
returning rather than crashing with no agent listening, the serialising mutex
under ten concurrent callers, the timeout,
EADDRINUSEwith the live listenerstill serving, the bind that does not unlink, the shutdown, and the callback
error and panic paths, and the four that pin the turn: the serialising, the
abandoned call, the hold, and two clients for one agent. Each of those four
fails against the behaviour it replaces.
go test -race ./...covers 88% ofstatements.
CI is rewritten around them. The old jobs drove an OpenAPI mock through the
example binary and drove the listener example through
UH_LISTENER_TEST, whichno longer exists.
Why the turn outlives the caller
The serialising mutex is not the whole story, and reading the agent says why.
Its machine runs
where
comm_futisawait_communication(), which receives one request andawaits its handler. When the sleep or the waker wins that race,
selectdrops the handler mid-flight, the reply channel is dropped without a send, and
the HTTP task waiting on it reaches
unreachable!("Unexpected response: Err(RecvError)")inmachine/address.rs.The communication channel is
bounded(10), which is where the "ten concurrentrequests" measurement comes from: ten queued requests keep
await_communicationrunning long enough that the race is nearly certain.
So every request still in flight there widens the window. A client cannot close
it — one slow request can lose the race alone — but it decides how wide it
opens. That is why a context bounds the wait and never the request: a caller
that gives up gets its deadline back, while the request keeps the turn, and
ErrCallOutstandingtells the next caller which of the two it is waiting on.The hold is what guarantees the turn comes back. The agent can stay alive
and answer nothing at all — which is exactly what it does after the panic above
— and then neither its answer nor the death of its process would release the
turn. So
NewClienttakes a third argument: how long the client keeps the turnafter its caller gave up. Long is safer than short, and the value belongs to the
caller, because only the caller knows when to stop assuming its agent is still
working.
The turn is held per agent, not per
Client. A second client built for thesame base URL waits for the first one's call rather than arriving beside it, so
an application that ends up with two clients — its own plus a vendored library's
— cannot defeat the guarantee. The key is the base URL as written, so
localhostand
127.0.0.1are two turns; the package documents that.An abandoned request is not itself a hazard for the agent: it drops the reply
with
responder.send(...).ok()?and carries on. The hazard is the secondrequest.
One more thing worth arguing about
A callback that panics is reported rather than fatal. The listener runs a
consumer's callback in a goroutine this package owns, so an unrecovered panic
there kills a process the consumer had no way to protect. That is the same
decision
log.Fatalused to make. It is recovered and handed toOnError, sothe consumer's own supervisor still gets the fault and still decides.
Compatibility
v2.0.0breaks the API. There is no compatibility shim, on purpose: the modulehas no Go-resolvable version today, so nothing can be pinned to the old surface
by version. All six
Clientmethods survive,GetInfoincluded — the panic fixlives in one shared helper, so the marginal cost of the methods a given consumer
does not call is close to zero, and
/inforeports the agent's running version,which is how a consumer asserts which agent it is talking to.
One thing worth stating plainly, so nobody relies on it later: the branch named
v2means the UpdateHub agent's v2 HTTP API, while modulev2means Gosemver. They align today by coincidence. A later breaking Go API change against
the same agent API would force module
/v3and break the alignment.