Why keychain calls are bounded by your context¶
Every method on the shipped OS-keychain backend takes a context.Context and
respects its deadline — but not by cancelling the underlying call, because there is
no underlying call to cancel. It respects the deadline by returning to you and
leaving the call behind. This page explains why the module is built that way, what
it costs, and what it therefore asks of you.
Available() being true does not mean the keychain works¶
The trap this design exists to close is a small gap between two words.
KeychainAvailable() reports whether a keychain-capable Backend is registered. On
a Linux host running GNOME Keyring, that is true: the Secret Service is present on
the bus and the backend can see it. What it does not tell you is whether the
collection is unlocked. A locked keyring is present, reachable, and completely
unable to hand you a secret until somebody types a password into an unlock prompt.
On a headless host — a server, an SSH session, a CI runner, a Linux desktop before the user's first unlock — nobody can type into that prompt. There is no display for it to appear on. The call does not fail; it waits.
So the sequence a naive consumer follows is: ask whether a keychain is available,
get a truthful yes, make the call, and never come back. Available was not lying.
The gap between "present" and "usable" is simply unbounded, and nothing in the
platform APIs closes it.
Why the platform gives no way to cancel¶
The three stores this module wraps are all synchronous and none of them exposes cancellation:
- Linux — the Secret Service over D-Bus, through go-keyring and godbus. A request against a locked collection blocks pending an unlock prompt.
- macOS — Keychain Services, which may put up its own GUI unlock dialog.
- Windows — Credential Manager, via wincred.
None of these takes a deadline, and none returns a handle you can abort. A Go wrapper cannot invent cancellation the C API does not have. Whatever "honouring the context" means here, it cannot mean stopping the work.
Bounding a call you cannot cancel¶
What it means instead is that the caller stops waiting.
Each backend call runs on its own goroutine. The method waits on that goroutine's
result and on ctx.Done(), and returns whichever arrives first. If the context
wins, the method returns the context's error and the goroutine keeps running until
the platform call finally answers — when the prompt is dismissed, the keyring is
unlocked, or the daemon exits.
That is the whole mechanism, and it is shared: Probe and the keychain backend both
route through the same internal helper rather than each growing their own copy, so
the two paths cannot drift apart and start disagreeing about what a deadline means.
The context is also checked before the call starts, not only in the wait. An already-cancelled caller should not reach the keychain at all, and without that check a cancelled context racing a fast call would return different answers on different runs for identical input.
The cost: cancellation returns control, it does not stop work¶
This is the part worth being blunt about, because the pleasant version of it is not true.
An abandoned goroutine is still a running goroutine. It holds one call's working set
until the platform call returns, and against a keyring nobody will ever unlock, that
is forever. A Store you gave up on can still commit afterwards — your deadline
expired, but the write did not stop, and it may land in the keychain minutes later.
The leak is bounded in the shape it usually occurs: a setup flow probes once, so at most one goroutine per attempt is stranded. It is not bounded if you retry in a tight loop against a blocked keychain — do that and you accumulate goroutines, one per attempt.
Every part of that is a worse outcome than a call that returns promptly, and a better outcome than the alternative, which is that the caller is the thing that never returns. A tool that hangs on startup is not diagnosable by the person running it; a tool that gives up after five seconds and falls through to the next credential source is.
Why there is no default timeout¶
The obvious follow-up is to have the backend impose its own deadline so a caller
using context.Background() is protected too. The module deliberately does not,
because no single value is defensible.
A timeout short enough to protect a headless server — a few seconds — is far too short for the case the prompt exists to serve: a human noticing a dialog, finding their password and typing it. A timeout long enough for that is long enough to look like a hang on a server. Picking one trades a hang on servers for a spurious failure on desktops, and there is no reason to think that bargain is better; it is just differently wrong, and imposed on every consumer rather than chosen by them.
What the module does instead is make a deadline effective wherever a caller sets
one, publish a suggested value as
KeychainOpTimeout, and say
plainly that callers on a startup or setup path should set one. The tolerance for
"too slow" is knowledge that lives in the tool, not in the library — a desktop
photo app and an unattended CI job do not want the same number.
The consequence you have to live with: a context with no deadline is not
protected. Passing context.Background() to a keychain call gets you exactly the
behaviour that existed before any of this — an indefinite wait — because there is
nothing for the bounding mechanism to bound.
What this asks of a Backend implementer¶
If you write your own Backend, the contract is that a cancelled context aborts the
call. For a remote store with a real HTTP client, honour it properly: pass the
context down and let the transport cancel the request. Abandoning the goroutine is
the fallback for APIs that leave no other option, not the pattern to copy.
Whichever way you do it, return the context's error rather than a store-specific one, so callers can tell "we gave up" apart from "the store said no".
What is deliberately still open¶
Refusing to start a call against a collection the backend can already see is locked
would be better than bounding one that was never going to succeed — it would make
Available() correct rather than merely truthful. It is not done here. go-keyring
does not expose lock state, so it needs a direct bus query on Linux plus separate
equivalents for macOS and Windows: a platform change rather than a contract fix, and
worth doing on its own rather than smuggled into this one.
Until then, Probe is the check that closes the gap, because it answers the
question by trying.
Related¶
- Probe — the live round-trip, and what it leaves behind on a timeout
- KeychainOpTimeout — the suggested deadline
- What credentials does not do
- Test the keychain on a headless host