Skip to content

Rewrite the SDK for v2.0.0 - #14

Merged
otavio merged 1 commit into
v2from
v2-rewrite
Aug 28, 2026
Merged

otavio merged 1 commit into
v2from
v2-rewrite

Conversation

@otavio

@otavio otavio commented Aug 28, 2026

Copy link
Copy Markdown
Member

This rewrites both files of the SDK and releases them as v2.0.0, at module
path github.com/UpdateHub/agent-sdk-go/v2. Please do not tag it yet. The
tag 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.0 and
1.1.0, lack the v prefix, so the Go tool sees no version on this module at
all 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 rewrite
rather 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. checkErr called log.Fatal and every error
path used it: net.Listen and Accept inside Listen(), the write inside
Handler.Cancel(), and os.Remove in removeFile. A transient socket error
took down the process.

Every client method panicked on the error path. processRequest returns
nil on 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 no
status code was ever read. A 500 was indistinguishable from a malformed reply.

The first two compose, and the composition is worse than either half. Probe
always sent a body, so an empty argument sent {"custom_server": ""}. Agent
2.1.6 has Request.custom_server as a plain String rather than an Option,
so that resolves the server address to "", Url::parse("") fails, and the
agent answers 500 with no probe performed. It is sticky: a parked agent
never reaches the EntryPoint that 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 Probe panicked rather than returning.

Confirmed on a device running agent 2.1.6, with its negative control:

POST /probe  (no body)               -> 200  "no_update"
POST /probe  {"custom_server": ""}   -> 500  Unhandled rejection: Client(UrlParse(RelativeUrlWithoutBase))

What changed

  • Nothing exits the process, and no method panics. Every failure is returned
    to the caller, which decides what it means. A library that calls log.Fatal
    has decided its consumer's process should die, and that is not its call.
  • Every call reads the HTTP status code and reports an unexpected one as a
    *StatusError carrying the code and the body.
  • Probe sends no body when no custom server is given, which is what the
    agent's own Rust and Python SDKs send.
  • ProbeResponse is a type, not interface{}, and carries all four replies
    the agent can send: updating, no_update, try_again(N) and busy with the
    agent'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, 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 rather
    than per Client, and a context bounds the wait rather than the request, so a
    call 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 survives
    and keeps polling on schedule, but /info and /probe then time out for
    ever; only systemctl restart updatehub clears it.
  • Configuration is a constructor parameter. NewClient takes a base URL, a
    timeout and a hold; NewStateChange takes a socket path. UH_LISTENER_TEST is
    deleted rather than kept as a fallback, because a fallback leaves the
    process-global seam in place for anyone who does not pass the parameter.
  • The timeout is settable and mandatory. It bounds any call whose context
    carries no deadline of its own. The value is the consumer's; the SDK only
    refuses to hard-code "none".
  • The listener binds without unlinking. It used to remove any existing
    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, which errors.Is reports, and the
    caller decides whether that is a live binder or a stale path.
  • The listener has a lifetime. Bind, Serve(ctx) and Close replace a
    Listen that blocked for ever with a dead error return, 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 from one
    connection — a failed read, a callback error, a reply that could not be
    written — reach the sink OnError registers, 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.
  • TriggerInstalled reports 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.
  • The package writes nothing to stdout and nothing through log.
  • gorequest is gone, and with it goproxy — a man-in-the-middle proxy
    library — plus pkg/errors and their transitive tree, all pulled in for two
    POSTs to localhost. The module now needs the standard library alone, which
    also closes the 13 Dependabot advisories open on the default branch.
  • The processRequest double decode is gone: it called EndStruct and then
    json.Unmarshal on the same value.
  • The module asks for Go 1.26 rather than 1.15, and modernize -fix has run
    over 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, EADDRINUSE with the live listener
still 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% of
statements.

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, which
no 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

select( select(sleep_fut, waker_fut), comm_fut )   // states/machine/mod.rs

where comm_fut is await_communication(), which receives one request and
awaits its handler. When the sleep or the waker wins that race, select
drops 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)") in machine/address.rs.
The communication channel is bounded(10), which is where the "ten concurrent
requests" measurement comes from: ten queued requests keep await_communication
running 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
ErrCallOutstanding tells 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 NewClient takes a third argument: how long the client keeps the turn
after 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 the
same 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 localhost
and 127.0.0.1 are 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 second
request.

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.Fatal used to make. It is recovered and handed to OnError, so
the consumer's own supervisor still gets the fault and still decides.

Compatibility

v2.0.0 breaks the API. There is no compatibility shim, on purpose: the module
has no Go-resolvable version today, so nothing can be pinned to the old surface
by version. All six Client methods survive, GetInfo included — the panic fix
lives in one shared helper, so the marginal cost of the methods a given consumer
does not call is close to zero, and /info reports 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
v2 means the UpdateHub agent's v2 HTTP API, while module v2 means Go
semver. They align today by coincidence. A later breaking Go API change against
the same agent API would force module /v3 and break the alignment.

@otavio
otavio force-pushed the v2-rewrite branch 3 times, most recently from 0145473 to e96fdfc Compare August 28, 2026 16:26
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
@otavio
otavio merged commit 46c3b42 into v2 Aug 28, 2026
3 checks passed
@otavio
otavio deleted the v2-rewrite branch August 28, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant