Skip to content

Compile cache (0xFC)

A small binary protocol for storing and retrieving compiler output, used by fastcache-cc. Unlike the memcached and Redis protocols, which move opaque bytes, this one is structured: the server understands that a value contains an object blob plus tagged text regions, and rewrites the paths inside those regions.

Detection

The first byte a client sends selects the protocol. 0xFC selects the compile cache, alongside the memcached and Redis flavors — see Autodetection. There is no dedicated port and no flag to enable it: every fastcached listener serves it.

Framing

All multi-byte integers are unsigned 32-bit big-endian. A field is a length followed by that many bytes.

Every request carries a fixed 7-byte header and then exactly the payload it declares:

Offset Size Field Meaning
0 1 magic Always 0xFC.
1 1 version Protocol version. Current: 14, and the oldest accepted is also 14 — see Versioning.
2 1 op 0x01 STORE, 0x02 FETCH, 0x03 AUTH; the rest are distributed execution and node identity.
3 4 payloadLength Bytes of payload following the header.

Every reply carries a fixed 5-byte header and then exactly the payload it declares — uniformly for every status, including a miss, which carries a zero-length payload rather than no payload at all:

Offset Size Field
0 1 status
1 4 payloadLength

Those two declared lengths are what make the protocol extensible. A receiver that does not recognise an opcode can skip exactly payloadLength bytes, answer with a typed error, and stay in sync — so adding a verb is not a breaking change, and a refusal is a reply rather than a dropped connection. Both sides frame through one shared module, src/FastCache/Protocol/CompileCacheWire.hpp, so the daemon, the launcher, and the test client cannot disagree about the layout.

Statuses

Byte Status Meaning
0x00 Miss FETCH found nothing. Payload empty. A legitimate negative, not an error.
0x01 Ok Command succeeded; payload is the result, if any.
0x02 Error Command refused; payload is [u8 errorCode][message].
0x03 Progress The server is still working on this request. Payload empty. Not an outcome: zero or more precede exactly one of the three above, and only on COMPILE.
0x04 Push One frame of a subscription's stream. Not an outcome either, and only on SUBSCRIBE.

Which statuses an op may be answered with is table data, not convention:

Op Legal statuses
STORE Ok, Error
FETCH Ok, Miss, Error
AUTH Ok, Error
REGISTER Ok, Error
HEARTBEAT Ok, Error
LEASE Ok, Error
RELEASE Ok, Error
COMPILE Ok, Progress, Error

A miss and a refusal being distinct is the point. When both were the byte 0x00, a client the daemon could not serve saw an endlessly cold cache and no diagnostic — the build merely got slower, forever, with nothing to show for it.

Error codes

Byte Name Raised when
0x01 unsupported-version The request version is outside the server's range.
0x02 unknown-opcode The opcode is not one this build knows.
0x03 malformed-frame The fields do not exactly fill the declared payload.
0x04 payload-too-large The declared payload exceeds the session cap.
0x05 malformed-value A STORE payload is not a compile value at all — it does not decode. Narrower than it looks: a value that decodes and names a generation this build does not implement is foreign-value-generation, not this.
0x06 (reserved) Never sent. Was canonicalization-failed, for a failure PathCanon could not produce; removed in issues #59/#69. The number is burnt so it is never reused with a different meaning.
0x07 storage-write-failed The cache engine refused the write.
0x08 unauthenticated A credential is required and has not been accepted on this connection.
0x09 no-worker No registered worker matches the requested toolchain.
0x0a no-capacity Every matching worker is at its slot limit.
0x0b already-in-flight Another client is already compiling this key.
0x0c dispatch-not-permitted This listener does not serve distributed execution.
0x0d unknown-lease The lease token is unknown or has expired.
0x0e fingerprint-mismatch The worker does not serve the toolchain the job names.
0x0f unsupported-codec No codec in common with what the request offered.
0x10 worker-scratch-unavailable The worker could not prepare a scratch directory, or could not write the translation unit into it.
0x11 worker-spawn-failed The worker could not start the compiler. Not "the compiler rejected the code" — that is a successful exchange carrying a non-zero exit code.
0x12 not-leader This node does not lead the cluster. The message carries the leader's endpoint when one is known, and is empty during an election.
0x13 not-a-member The caller is not a member of this cluster, so it may not spend the fleet's capacity. It is still served the cache.
0x14 withdrawn Matching workers have slots free on paper and have withdrawn them: their machines are busy with something other than this fleet, or out of scratch space. Distinct from no-capacity, which means the fleet is full of this build's own work.
0x15 no-cluster This node runs no cluster, so there is nothing to administer. Distinct from not-leader, which names somewhere else to ask.
0x16 invalid-cluster-change The cluster cannot accept that change — a setting nobody has heard of, a member named with no address, a field a verb ignores. The message says which.
0x17 endpoint-busy This endpoint has reached its own concurrent-request cap or in-flight byte budget. A statement about one node's front door, never about the fleet.
0x18 malformed-registration A REGISTER named its toolchain, its endpoint or its version in bytes that are not valid UTF-8. The message says which field. Refused rather than repaired: a fingerprint is matched byte for byte, so a worker admitted under a cleaned-up name would match nothing and never be picked.
0x19 lease-unauthorized The lease token is not one this cluster issued: its signature does not verify under the key the worker's roster holds for the voter it names, that key has been revoked, or it is not a lease token at all. Deliberately one code for all of them — a receiver cannot tell a forgery from a random string, and a client's answer to a revoked signer is the same. Distinct from unknown-lease, which names a lease the scheduler did issue and has since forgotten. Nothing the token claimed is echoed back.
0x1a lease-endpoint-mismatch An authentic lease, presented to a worker it was not issued for. Only ever reported once the signature has verified, so it is a diagnostic rather than a hint: the message names both endpoints, because the common cause is a worker registered under an address clients do not dial, not a replay.
0x1b lease-expired An authentic lease, presented past its expiry and the clock-skew slack. Not a capacity statement — a worker's slots bound what it runs, the expiry bounds how long a captured token is worth replaying.
0x1c worker-toolchain-survey-in-flight The worker is still identifying its toolchains and serves nothing yet. Distinct from fingerprint-mismatch: that one says this worker serves a different toolchain, this one says the same request will succeed shortly. Reachable only by dialling the node directly — a node registers nothing until its survey finishes, so the scheduler never offers it to anyone who asked the fleet.
0x1d request-deadline-exceeded The request was admitted and outran the window this surface allows for answering it. Not endpoint-busy, which says the node is momentarily full and to come back: this one says the work was abandoned on time, and for a compile it is a question about the lease timeout rather than about the worker. Sent only when the server can still reach the client — a peer swept while the connection is parked on the socket gets the close alone.
0x1e foreign-value-generation A STORE whose value is a compile value, well formed, written under a canonicalization generation this build does not implement. Emphatically not malformed-value, which says the bytes are not a compile value at all: this one is the normal, expected answer to a peer of a different generation during a rolling upgrade, and reporting it as malformed tells an operator their cache is damaged when the fleet is merely mixed. Either direction — a producer behind this server answers it exactly as one ahead of it — so the message names both generations rather than a direction, and that is the whole diagnostic.
0x1f cluster-change-in-flight One membership change is already uncommitted, so this one must wait. The one retriable code in the cluster-administration range: the cluster could accept the change and will, shortly. Not invalid-cluster-change, which would send an operator to correct a record that is already correct.
0x20 cluster-change-not-needed The change asked for is already in force, so nothing was recorded — --cluster-admit naming a member the cluster already has. Idempotence rather than a mistake, and a code rather than a success because no entry was appended and there is no index to name.
0x21 worker-compiler-unclassified The worker ran the program its own --toolchain names and cannot tell what it is, so it cannot build a command line for it. Split from worker-spawn-failed, which says the program could not be run at all.
0x22 enrollment-closed This node runs no enrollment window right now. The default answer to an enrollment request — the window is closed unless an operator has just opened it — and counted, since a rise with nobody at a terminal is somebody trying the door.
0x23 enrollment-full The window is open and its pending list is full, so the request was not recorded. A refusal rather than an eviction, so a flood cannot push the real joiner off the list the operator is reading.
0x24 (reserved) Never sent. Was enrollment-already-collected, which made a key hand-over spendable once; an approved enrollment no longer carries a secret (#178). The number is burnt.
0x25 unknown-fleet-selector A fleet read named a section or a range this build does not serve. The message lists the ones it does.
0x26 node-proof-unchallenged A node proof arrived with no challenge outstanding on this connection — never asked for one, or already spent it. The exchange was got wrong, so ask for a challenge; not a statement about the key.
0x27 node-proof-rejected A node proof's signature did not verify under the identity key it presented. One answer for every way that happens, since naming the field would be an oracle, and asked before the roster is consulted.
0x28 roster-expired The worker can check nobody's lease right now: it holds no roster of the cluster's voters, or the one it holds has not been re-certified within its lifetime and the clock-skew slack. A statement about the worker, never about the lease — a fresh grant would get the same answer.
0x29 node-key-unknown A node proof verified, under a key this cluster does not hold for the id it named: a machine nobody admitted, or one presenting a key other than the one admitted under its id. The remedy is an admission, not a new key.
0x2a node-key-revoked A node proof verified under a key the cluster has revoked — the forgotten machine itself. The connection is kept and marked, and every later verb on it is refused as the forgotten machine's, even from a host --fleet-member still names.
0x2b node-identity-required A verb only a machine that proved its identity may send — REGISTER, NODE-ANNOUNCE, HEARTBEAT, WITHDRAW — arrived on a connection that has not. An address admits a client; it no longer admits a machine into the fleet, loopback included.

Every one of these is a refusal the client answers by compiling locally, never by failing. They are distinct codes rather than one "no" because they mean different things to an operator: not-a-member is a policy decision somebody made, no-worker is a fingerprint nobody in the fleet serves, no-capacity is a fleet that is too small, and already-in-flight is none of the three.

The byte values are wire contracts and are never reassigned, which is why the table is not in the order the codes were added: a burnt number stays burnt, and a new code takes the next free byte rather than the one beside its neighbour in meaning. The enumeration and every code's full argument live in CompileCacheWire.hpp's ErrorCode.

STORE

[0xFC][ver][0x01][u32 len]  payload: [key][prefetchGroup][srcRoot][buildTree][value]

The srcRoot and buildTree fields describe the producer's layout. The server uses them to rewrite absolute paths inside the value's text regions into canonical tokens before storing — so what lands in the cache is layout-neutral.

FETCH

[0xFC][ver][0x02][u32 len]  payload: [key]

On a hit the reply payload is the stored value, in its canonical form — the client localizes it to its own layout.

AUTH

[0xFC][ver][0x03][u32 len]  payload: [username][secret]

An empty username asks to be verified against the secret alone — the redis requirepass form, and the usual one. The field is always present so the frame arity does not depend on which credential style a client uses.

Distributed execution

Five more verbs turn the same wire into a scheduler and a worker protocol. None of them is served by fastcached: the scheduler is fastcache-compile-node --serve-scheduler and the worker is the same binary serving compiles, both answering on its one --listen-node.

A scheduling verb arriving at a cache listener is answered dispatch-not-permitted with a message naming where the scheduler went. It is a reply, not a dropped connection, and it keeps its opcode rather than becoming unknown — a client built against an older daemon has to learn why its scheduling stopped, and a close is indistinguishable from a dead host while unknown-opcode would say this daemon is too old when it is in fact too new.

REGISTER carries one fingerprint, so a worker serving several toolchains registers once per toolchain. The scheduler keys a worker on (fingerprint, endpoint), so those are separate entries — but every one of them heartbeats the same machine-wide in-flight count, so the entries fill up together and the pool behaves as one rather than advertising N times the machine.

REGISTER, HEARTBEAT, LEASE and RELEASE go to the scheduler, along with the four cluster-administration verbs (CLUSTER-STATUS 0x08, CLUSTER-SET 0x09, CLUSTER-FORGET 0x0a, CLUSTER-ADMIT 0x0b), which the leader answers and only to a member. COMPILE goes to a worker on the same --listen-node port that carries its cache verbs and, with --serve-scheduler, the scheduler's: #290 merged what were three ports into one 0xFC surface, so the listener is no longer the policy. Which caller is admitted to which verb is a property of the verb, asked of the component that owns it, and never of the port a frame arrived on. A verb this node runs no component for is refused unknown-opcode: that is the honest code, because this endpoint really does not implement it, and a client learns so rather than seeing a dropped connection it cannot tell from a dead host. It is emphatically not dispatch-not-permitted, which says the verb is served somewhere else — that is what fastcached answers a scheduling verb, two paragraphs up. The distinction is the client's: unknown-opcode is the one refusal a launcher steps over before carrying on, and dispatch-not-permitted is one it treats as fatal, so the wrong code here is a worker that never joins and a cache that never hits, behind a green build. A node with no cache tier and no scheduler is the ordinary shape, not a misconfiguration, so this is what a healthy build answers. AUTH is the exception across a node's three verb families — the scheduler's, the compile verbs and the cache tier's, all of them now on that one port — none of which implements it, so each answers unknown-opcode, the one refusal a client steps over before carrying on unauthenticated. fastcached does implement AUTH: it is the only server on this wire that checks a credential, and --requirepass there refuses the gated verbs unauthenticated rather than stepping over anything.

REGISTER   [fingerprint][endpoint][slots][codecs][capacity] -> [workerId]
HEARTBEAT  [workerId][inFlight][load]                       -> Ok
LEASE      [fingerprint][objectKey][codecs]                 -> [endpoint][leaseToken][workerCodecs][leaseLifetime]
COMPILE    [leaseToken][fingerprint][args][source][codecs][sourceName]
                                              -> [exitCode][object][stdout][stderr][correlation]
RELEASE    [leaseToken][objectKey]                          -> Ok

capacity and load are nested records rather than fields of their own, and that is a compatibility property rather than tidiness: the top-level field count of each verb is exact and fixed forever, so a fact added there would make two builds of one fleet unable to speak at all. The nested records are read with the variable-arity split, so a node built before a field existed simply reports nothing for it and a newer node registering with an older leader has it skipped. capacity is what the machine is — cores, memory, node class, reserve, the software version and the cache budgets; load is what it is doing — CPU busy, available memory, free scratch and what its cache holds. inFlight stays outside the nested record, because it is the one number a worker can never fail to have.

sourceName is the client's translation unit as the build system spelled it, directory and all. A compiler records the name of the file it was handed, so an object built under an invented name is gratuitously different from a locally built one — and clang takes that name from the input file path rather than from the #line marker, so a dispatched object recorded the worker's per-job scratch directory until #660. That directory carries a counter, so two dispatches of one translation unit produced two different objects under one cache key.

The worker uses it two ways and they are not the same kind of use. It sanitizes it down to a single safe component to name its scratch file — a string off a socket that becomes a path, so nothing in it may decide a directory — and it puts the value verbatim on the right-hand side of a -fdebug-prefix-map rule whose left-hand side is that scratch file, which is what makes the recorded name the client's. A rule's replacement is a string the compiler records, never a path anything opens. It never decides the language either — the client states that explicitly.

correlation is what ties a reply to the request that asked for it, and it is the one field on this wire that exists only to catch a defect. Everything else here is upstream of the reply — the key covers the inputs, the fingerprint covers the toolchain, the lease covers the authorization — so before it existed a client sent a job and accepted whatever object came back on that connection. Any defect that crossed two jobs therefore produced a build that succeeded with the wrong object under a correct key: silent, stored, and then served to every other machine that fetched that key.

It is a digest of what the worker actually compiled — the preprocessed text it wrote to scratch, the client's own argument slice as the worker decoded it, the fingerprint and the source name — taken inside the worker's runner from the values it was about to spawn with, never recomputed from the decoded request. A digest taken at the wire layer would agree with whatever it was compared against, because at that layer both of two crossed requests are still pristine. The client recomputes the same digest from what it asked for and refuses a reply that does not match, before the object envelope is opened. There is no best-effort match and no fallback to using the object anyway: a mismatch means the translation unit is compiled locally, and the launcher says so unconditionally on stderr rather than only under FASTCACHE_VERBOSE. fastcache-cc --show-stats ranks it as a fall-back reason.

It is integrity against accident, not against a hostile worker: the digest is unkeyed, so a worker that can return a wrong object can return a wrong digest just as easily. It also cannot see a runner that fed the right bytes and read back the wrong object file — there the metadata is honest and only the object is foreign, and the worker's exclusive scratch claim is what closes that.

Two rules carry the weight and neither is configurable. A job goes only to a worker whose fingerprint is byte-identical: an over-strict match costs a local compile, an over-loose one produces a silently wrong object stored under a key other machines fetch, and those errors are not symmetric. And the scheduler picks the worker with the most free slots, ties broken by utilization — not the one running the fewest jobs, which treats every machine as an identical box and sends work to the smallest ones first.

The scheduler also suppresses duplicate work: when many clients miss the same key at once — the ordinary shape of a miss after a header change — only the first is dispatched and the rest compile locally. That check runs before the capacity check, so a second client asking for a key already in flight at a busy fleet is told already-in-flight rather than no-capacity: both are true, but only one of them is something an operator can act on.

A lease has three transitions, and only two of them are automatic

LEASE takes one, RELEASE resolves it, and the scheduler expires whatever is left. The client sends RELEASE on every way its job can end — an object built, a worker that refused it, a worker that could not be reached — because the client is who the lease was issued to and the only party that sees all three.

Expiry is the safety net for a client that died, Ctrl-C on a build being the ordinary case, and is not the ordinary path. It used to be: there was no RELEASE, so every key stayed suppressed for the full lease lifetime — ten minutes — and recompiling the same translation unit inside that window fell back to a local compile. The same applies to a machine leaving the fleet: dropping a worker releases the leases held against it, rather than leaving its keys pinned until each one times out.

RELEASE names the key as well as the token, and that is not redundant. A token is a small integer the scheduler minted, and its counter starts again at one in a scheduler that has just restarted — so a client reporting a job it began before the restart would otherwise resolve whatever lease the new instance had since issued under the same number, freeing a key somebody is building. Naming both makes a release resolve the client's own lease or nothing.

A RELEASE the scheduler cannot match is refused unknown-lease rather than accepted quietly. That is the diagnostic for a job that outlived its lease, which means the fleet's lease timeout is shorter than its slowest translation unit — and there is nowhere else that fact could be observed. The client does nothing about it either way; whether it has an object depends on the worker, which declines to serve one produced past the grant's own expiry (lease-expired), because by then the scheduler has reclaimed the key and may have re-granted it. Either way the invocation falls back to a local compile, and the remedy is the same: raise lease-lifetime.

How long a lease lives travels on the grant, as leaseLifetime, in the clear beside the token. It is a replicated cluster setting (lease-lifetime) rather than a constant each end compiles in, so the number has to reach the client somehow — and the client holds no roster to verify the token's signature against, so reading it out of the token's authenticated claims is not open to it. Handing it a way to read claims without checking them would put exactly the primitive the verify-first rule forbids into a header every binary includes; what the client needs is not a claim about the token but its own budget, from the scheduler that is already telling it which worker to dial.

The worker reads the same number from inside the MAC, where it belongs, because the worker can verify. Both ends therefore bound the same job by the same moment — the grant's expiry — and the scheduler reclaims the key at it. A grant already minted keeps the bound it was issued under, whatever the setting does afterwards.

The moment rather than the number, and the difference is the client's: a lifetime runs end to end from the mint, while the client's compile leg does not start until the LEASE round trip is over, so it subtracts what that round trip cost before using the value. It measures that on its own monotonic clock, from its own LEASE send, and no instant crosses the wire — an instant from another machine would be a comparison against a clock this one has no relationship with. The mint happened somewhere inside that round trip, so charging the whole of it over-states the elapsed and the client gives up a little early rather than a little late. Taking the lifetime whole made it wait past its own grant by roughly a round trip (#1122), which was harmless only because the worker declines an outrun job first.

Bulk fields carry a codec envelope

A preprocessed translation unit and an object file are both large, so those fields travel as [u8 codec][u32 rawLen][bytes] using the same codec ids the cache already documents. The request carries the list of codecs the sender accepts and the reply picks one from it, so the two ends agree with no extra round trip — the same reasoning that keeps AUTH free. rawLen is what lets a receiver reject a declared expansion before decompressing a byte, and nothing in common falls back to Identity rather than refusing, so a build never loses its cache because two peers were compiled with different codec sets.

Control verbs have a lower ceiling

REGISTER, HEARTBEAT, LEASE and RELEASE are capped at 64 KiB rather than the session cap. That listener is meant to be reachable by a whole fleet, and a scheduler that can be made to allocate 256 MiB per frame by anything that authenticated once is a scheduler that stops scheduling. COMPILE is the deliberate exception, since it carries a whole translation unit.

Node identity and sealed frames

A machine that joins the fleet — registers a worker, announces itself, heartbeats, withdraws — proves which machine it is before it sends any of those verbs, and every frame after that proof is sealed (#178). A client asking for a lease or a cache entry proves nothing: it is admitted by its address or its credential exactly as before, and pays no round trip for any of this.

NODE-CHALLENGE 0x18  [nonceC(32)][ephC(32)]
                  -> [serverId][serverKey(32)][nonceS(32)][ephS(32)][signature(64)]
PROVE-NODE     0x19  [nodeId][publicKey(32)][signature(64)]
                  -> Ok, or a refusal -- sealed either way once keys are agreed
  1. The caller opens with a fresh nonce and a fresh X25519 ephemeral key.
  2. The server answers with its node id, its identity key, a nonce and an ephemeral key of its own, and an Ed25519 signature over all of them and the caller's two, under the label fastcache-node-challenge-v2. A caller that holds a roster of the cluster's voters checks it before proving anything, and so learns whether it reached a voter, a machine the cluster has revoked, or something it cannot vouch for.
  3. The caller signs that whole transcript, the server's signature included, plus its own id and key, under fastcache-node-proof-v2, and sends PROVE-NODE. The server checks the signature first (node-proof-rejected), then the cluster's roster: a key it does not hold for that id is node-key-unknown, a key it has revoked is node-key-revoked.

Both ends then derive two session keys — HKDF-SHA256 over the X25519 shared secret, salted with both nonces, bound to both ephemeral keys and both ids — one for each direction. The answer to PROVE-NODE is the first sealed frame, whatever it says: from it on, every frame both ways carries a 32-byte HMAC-SHA256 tag after its payload, over an implicit position, the frame's header and its payload. A frame whose tag does not verify, one that is replayed, one sent under the other direction's key, or one longer than a frame may be closes the connection, unanswered.

The seal is what makes the proof worth having. Without it, a machine on the path could relay a genuine worker's handshake to the scheduler, watch it succeed, and then write a REGISTER of its own into the connection the worker's proof admitted — naming an endpoint of its choosing and being leased the fleet's jobs. It holds neither session key, so it cannot tag that frame.

A challenge is spent whatever the outcome, and asking for a second one abandons the first. A connection proves once: either verb on a connection that has already proved, or that is already sealed, closes it, so a caller refused for its key redials rather than trying again in place. Bytes a caller pipelined behind PROVE-NODE close the connection too — they arrived in the clear, before the seal existed, so nothing can say whose they are.

A worker holds its identity key in --cluster-dir, which it mints on first start; the cluster admits the key through an enrollment window or --cluster-admit-worker=<id>@<key> (0x1d), and forgets it — refusing it from every address — with --cluster-forget. What an operator sees of all of this is on the node's page.

Authentication

When the daemon runs with --requirepass, every verb except AUTH is refused with unauthenticated until an AUTH frame has been accepted on that connection.

This handler was for a long time the only one in the tree that did not check the configured credential: memcached text, memcached binary and RESP all did, so a daemon started with --requirepass gated those three and served the compile cache to anyone who could open a socket.

Which verbs are reachable before a credential is a column of the opcode table (OpDescriptor::preAuth), not a condition written into the handler. A verb added without a thought about it defaults to closed, and the gate reports "not allowed" for an opcode it does not recognise at all.

Why it costs no round trip

Authentication is per-connection state, and the launcher opens a fresh connection per operation — so sending AUTH, awaiting its reply, and then sending the real command would double the round trips of every translation unit in a build. That is exactly the cost the no-handshake decision exists to avoid.

It does not have to be spelled that way. Replies are strictly ordered and one-per-request, so a client pipelines: AUTH and the real command go out in a single write, and the two replies are read in order afterwards. The credential costs a few dozen bytes in a segment that was being sent anyway. fastcache-cc does exactly this, and asserts it — a unit test checks the write count, because the bytes are identical either way and only the call count distinguishes a pipelined credential from one that waited.

A client must still read the AUTH reply. Skipping it on the assumption it succeeded strands a whole frame in the socket, and the next command on that connection reads the previous one's answer.

Refusals and reloads

A failed AUTH is answered and the connection kept, as every other handler here does: a refusal is a reply, not a close. Closing would not slow an attacker down — reconnecting is free — while costing every honest launcher its pipelining.

The policy is resolved once per command from the live auth source, so a SIGHUP that enables requirepass gates connections that are already open, and one that disables it releases them. A connection that has actually verified a credential keeps its access across a secret rotation, as redis does: re-gating on rotation would fail every in-flight build at the moment an operator rotates, which is what makes rotation something nobody dares do.

Against a daemon with no credential configured, an AUTH frame is answered Ok and ignored, so setting FASTCACHE_TOKEN is safe in a mixed fleet. It does not mark the connection as verified, though — nothing was checked — so a later reload that enables auth gates it like any other.

Against a daemon that predates AUTH

The AUTH opcode was added without bumping CurrentVersion, because the framing was built precisely so a receiver can step over a verb it does not know: an older daemon answers unknown-opcode, skips the payload, and serves the pipelined command behind it perfectly well.

A client must therefore treat unknown-opcode on AUTH as "this daemon has no authentication", not as a failed exchange. Returning it as the outcome instead gives a token-configured launcher a permanent 0% hit rate against every not-yet-upgraded daemon, reported as rejected (unknown-opcode) — a regression with a plausible-looking error message and no obvious cause. fastcache-cc falls through to the command's own reply and sets credentialIgnored, which surfaces once per build as a verbose note: the operator asked for authentication and did not get it, and a cache that silently does less than it was told to is worse than one that says so. Every other refusal is about the credential itself and is still reported.

Payload ceilings

AUTH is the one verb reachable before authentication, which makes it the one hole in the gate above: without a bound of its own it would be read with the session's maxPayloadBytes (256 MiB by default), handing an unauthenticated peer exactly the allocation the gate exists to deny. So the opcode table carries a maxPayload column — MaxAuthPayload (4 KiB) for AUTH, and 0 ("the session cap") for STORE and FETCH, which carry object files and are read only after the peer has authenticated. A static_assert requires every preAuth row to declare a non-zero ceiling, so a future pre-auth verb cannot reopen the hole by omission. An over-ceiling frame is drained and answered like any other refusal, and names the verb whose cap it hit rather than the session's.

Versioning

The version byte travels on every request, and the server pins it to the first command's for the life of the connection: a stream that changes version mid-flight is nonsensical rather than merely unsupported, and saying so is cheaper than carrying two decoders.

Rejection policy, per command:

Condition Reply Connection
bad magic none possible closed
version unsupported, or changed mid-connection Error / unsupported-version closed
payload over the session cap Error / payload-too-large closed
unknown opcode Error / unknown-opcode stays open
fields ≠ declared payload Error / malformed-frame stays open

A wrong magic is the only case that still closes without a reply: the peer is not speaking this protocol, so there is no framing in which an answer would be meaningful. Every other refusal is a typed reply, and every one is also reported through the daemon's connection logger, so a rejection is visible to the operator as well as to the client.

An unsupported-version message names the offered version and the supported range (unsupported wire version 13; this server speaks 14..14). A rejection that does not say what would have worked cannot be acted on, and this is the only message an operator with a mismatched install will ever see.

Why there is no handshake

There is no HELLO and no negotiation round trip on the cache path. fastcache-cc opens a fresh connection per operation — manifest fetch, object fetch, object store, manifest store — so a handshake would cost two to four extra round trips per translation unit, on the hot path where this project has already measured serious regressions. Instead the client optimistically sends its current version and learns the server's range from the rejection if it is wrong: zero cost in the common case, one wasted round trip in the case that is already broken.

Because both binaries ship in one package, version skew is an operator error — a mixed install — so the goal here is a loud diagnostic, not automatic interop.

The node handshake is not an exception to this: it negotiates no version and no feature, it establishes WHICH machine is on the other end, and it is paid only by a machine joining the fleet, once per connection, never by a translation unit.

Two independent version axes

The wire version describes the framing; CompileValueVersion, the first byte of a stored blob, describes the value format. They are separate because a stored blob outlives any connection: the wire version is agreed per request, while the blob's version is discovered when it is decoded, however long after it was written. The launcher's cache key additionally carries an objkey-v6 schema tag; bumping it re-keys the cache, so stale entries miss and are rewritten rather than being served under rules they were not written by.

Those two tags move independently, and it matters which one is load-bearing here. Nothing couples a CompileValueVersion bump to an objkey-v* bump — the lock-step this project does enforce is manifest-v* behind objkey-v*, a different pair — so a canonicalization change need not re-key anything, and two generations can therefore meet over one key. Re-keying is an optimisation that makes them meet less often; the refusal below is the protection that does not depend on anybody having remembered to bump a second tag.

CompileValueVersion names the canonicalization spec, not only the byte layout. Canonical text travels nowhere but inside a stored value, and every server on this wire has to rewrite one identically — including servers at different builds, since a fleet is permanently mid-upgrade. So the byte is pinned to the behaviour rather than maintained by hand: a conformance corpus is run through the canonicalizer and its inverse, digested, and matched against the row for the live generation. Change how a path span is found, rewritten or framed and that test fails naming the bump.

A reader that meets a generation it does not implement refuses the value. It cannot canonicalize it, and storing or serving it uncanonicalized would put the producing checkout's absolute paths into a shared cache under a key every machine computes — so a store is declined as foreign-value-generation (0x1e), naming both generations in the message, and a fetch is a miss the launcher reports under its own --show-stats reason rather than as a malformed value. That code is the point rather than a detail: the refusal is what a healthy fleet does midway through a rolling upgrade, and answering it as malformed-value told an operator their cache was damaged — the same conflation the storage layer already avoids on disk, where a store written by another build is UnsupportedFormatVersion and never Corrupt, because the code is what monitoring reads and Corrupt is what makes somebody delete a healthy cache.

Refusing costs the hits of one upgrade window; the alternative costs every consumer that replays those paths into its dependency graph, where no edit in its own checkout can invalidate them.

The value format

A stored value is an object blob plus zero or more text regions, each tagged with the grammar that identifies path spans inside it:

CompileValue {
    objectBlob:   bytes          // the .o / .obj, stored untouched
    textRegions:  [ { grammar, bytes } ]
}

The object blob is opaque and never rewritten. Text regions are the compiler's captured stdout and stderr, and their grammar tells the server where the paths are — /showIncludes notes for MSVC drivers, Makefile depfile syntax for GNU ones. Only recognised path spans are rewritten; every other byte, including diagnostics the grammar does not match, is preserved verbatim.

This asymmetry is the whole design: canonicalize on STORE, serve canonical on FETCH, localize on the client. The server stores exactly one representation of an entry no matter how many differently-rooted machines produce it.

The region count is a claim about bytes, and it is checked against them

textRegions is length-prefixed with a 32-bit count, and a decoder must treat that number as an assertion the frame either backs up or does not — never as a size to allocate from. A region costs five bytes on the wire at the very least (its grammar tag and its length prefix), so a frame declaring more regions than remaining / 5 is refused as malformed-value before anything is reserved.

Unchecked, a nine-byte STORE payload declaring 0xFFFFFFFF regions asked for roughly 172 GB — reachable on the daemon's STORE path, and from a worker's reply to the launcher (#267). Validating the count is necessary and not sufficient: a validated count is still an amplifier whenever the in-memory element is bigger than its wire minimum, so any capacity a decoder reserves is sized from something it owns — the bytes already in hand, or its own configured ceiling — never from the peer's number. The same rule governs the prefetch-group manifest's key list and the launcher's direct-mode manifest.

Prefetch groups

The prefetchGroup field on a STORE groups keys that tend to be needed together — in practice, one build of one project. The server records the mapping, and when a FETCH hits a key belonging to a group it warms the rest of that group into the in-memory tier in the background.

This is automatic and has no CLI flag. It is debounced at two levels: per connection, and per (engine, group) pair. Both are necessary because a launcher opens a fresh connection per translation unit — with only per-connection debouncing, one 60-hit build was measured issuing 27022 prefetches and 13969 disk reads. A group holds at most 100 000 keys.

Prefetch group membership never affects the cache key, so changing FASTCACHE_PREFETCH_GROUP re-groups prefetching without partitioning the cache or invalidating anything.

Operational notes

The per-value cap (--storage-max-value, default 256 MiB) also raises the wire frame-payload cap for this protocol, and the default is already sized for object files in a large codebase. See running a compile cache.