A walkthrough of alex0ptr/once, a Go CLI that caches stdout in a background daemon over a Unix socket, with HMAC cache keys and wall-clock expiry.
· 8 min read

How once Caches Command Output in Go: Unix Sockets, HMAC Keys, and Wall-Clock Expiry


Close your laptop lid for two hours. Open it. On systems where the monotonic clock pauses during sleep, a cache based only on monotonic elapsed time can still consider a 30-minute entry valid.

alex0ptr/once accounts for that case. It’s a small CLI that runs a command, caches its stdout, and serves later invocations from a per-user background daemon. Its implementation combines HTTP over a Unix domain socket, cache keys derived with HMAC-SHA256, and wall-clock comparisons for expiry.

The motivating use case in the package comment in main.go is reading a secret from 1Password. Hitting the vault on every shell prompt is slow, while deliberately persisting the value to a file creates a different risk. once keeps cache entries in the daemon’s process memory, although that memory is not locked against swapping.

HTTP over a Unix socket instead of a custom protocol

The daemon in daemon.go doesn’t invent a wire format. It listens on a Unix socket and serves a plain net/http server over it:

ln, err := net.Listen("unix", sock)
if err != nil {
    log.Printf("listen: %v", err)
    return 1
}
_ = os.Chmod(sock, 0o600)
defer os.Remove(sock)

s := newStore(idleGrace)
srv := &http.Server{Handler: s.routes(), ReadHeaderTimeout: 5 * time.Second}
go func() {
    if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
        log.Printf("serve: %v", err)
        s.shutdown()
    }
}()

http.Server.Serve accepts any net.Listener, so the server can retain HTTP routing, status codes, and shutdown behavior while replacing TCP with a Unix socket. The routes use Go 1.22’s method-and-pattern syntax in ServeMux:

mux.HandleFunc("GET /v1/entries/{key}", s.handleGet)
mux.HandleFunc("PUT /v1/entries/{key}", s.handlePut)
mux.HandleFunc("GET /v1/status", s.handleStatus)
mux.HandleFunc("POST /v1/clear", s.handleClear)

Access control comes from the filesystem. runtimeDir verifies ownership and sets the containing directory to 0o700; the daemon also attempts to chmod the socket to 0o600. Linux enforces pathname-socket permissions, but POSIX does not specify that behavior and some Unix systems ignore socket-file permissions, so the private directory is an important part of the boundary. Processes running as the same user are trusted by design and can talk to the daemon; there is no token, TLS, or application-level authentication.

On the client side (client.go), the base URL is the fake host http://once. The hostname never gets resolved because the client’s transport dials the socket path directly. This is the standard trick for talking HTTP to a Unix socket in Go. You keep the full net/http client API, timeouts and contexts included, and swap only the dialer:

// The general pattern: a normal http.Client with a custom DialContext.
client := &http.Client{
    Transport: &http.Transport{
        DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
            var d net.Dialer
            return d.DialContext(ctx, "unix", socketPath)
        },
    },
}
resp, err := client.Get("http://once/v1/status")

If you want more background on configuring transports safely, we covered that in reusing http.Transport in Go.

Length-prefixed HMAC cache keys

key.go builds the cache key as HMAC-SHA256(tenant, dir ‖ argv). The construction detail that matters is length prefixing:

func cacheKey(tenant, dir string, argv []string) string {
	m := hmac.New(sha256.New, []byte(tenant))
	field := func(s string) {
		var n [8]byte
		binary.BigEndian.PutUint64(n[:], uint64(len(s)))
		m.Write(n[:])
		m.Write([]byte(s))
	}
	field("once/v1")
	field(dir)
	for _, a := range argv {
		field(a)
	}
	return hex.EncodeToString(m.Sum(nil))
}

Concatenate the arguments without boundaries and ["ab", "c"] produces the same byte string as ["a", "bc"]. Writing an 8-byte big-endian length before each field makes the input encoding unambiguous. The initial field("once/v1") is a domain separator, keeping this version’s encoded inputs distinct from another format that uses a different prefix. The final HMAC still has the usual cryptographic, rather than mathematical, collision guarantee.

The source is explicit about what the tenant key is and isn’t. It namespaces cache contexts and makes keys opaque to a process that does not know the tenant value, but it is not treated as a secret and authenticates nothing. The security boundary is the private runtime directory and Unix-user account.

validKey is the matching input check on the daemon side: 64 characters, valid hex, nothing else accepted. Cheap, and it means the key going into the map is always a bounded, well-formed string.

Wall clock vs monotonic clock

Here’s the lid-closing problem from the top of the post.

A time.Time in Go can carry both wall-clock and monotonic readings. Comparisons and subtraction use the monotonic readings when both operands have one. The Go documentation notes that on some systems the monotonic clock stops while the machine sleeps. On those systems, a cache that promises wall-clock expiry in 30 minutes can otherwise remain valid after a longer suspend.

once handles this by stripping the monotonic reading from entry expiries and timer deadlines derived from time.Now:

// expiry returns the earlier of now+ttl and until (if set).
func expiry(now time.Time, ttl time.Duration, until time.Time) time.Time {
	exp := now.Add(ttl).Round(0) // wall clock only
	if !until.IsZero() && until.Before(exp) {
		exp = until
	}
	return exp
}

Round(0) is the documented idiom from the time package docs for dropping the monotonic reading. The entry struct documents expires as wall-clock-only, and store.get also calls now.Round(0).Before(e.expires). Go falls back to wall time if either operand lacks a monotonic reading, so stripping the stored expiry is sufficient for the comparison; rounding now makes the intended semantics explicit on both sides.

Timers can have the same suspend behavior, so the store caps each scheduled wait:

const maxTimerWait = 30 * time.Second // timers stall during system sleep; recheck at least this often

func (s *store) wait() time.Duration {
	return min(time.Until(s.next), maxTimerWait)
}

While the process is running normally, the timer rechecks the wall clock at intervals of no more than 30 seconds. If a timer is delayed by sleep, the callback checks wall time after the machine wakes and sweeps expired entries instead of trusting only the paused monotonic duration. If you’re fuzzy on the time package generally, our guide to Go’s time package covers the basics.

There’s also a concurrency detail in newStore that’s easy to read past:

// Hold the lock while assigning the timer, so the callback (which locks
// before using s.timer) is guaranteed to see the assignment.
s.mu.Lock()
s.timer = time.AfterFunc(s.wait(), s.sweep)
s.mu.Unlock()

time.AfterFunc runs its callback on another goroutine. Taking the mutex around the assignment means a callback that acquires the same mutex cannot observe s.timer before the assignment completes. The current grace period is 30 seconds, but the synchronization keeps the constructor correct even if that duration changes.

Single-instance enforcement with flock

Multiple shells may try to start the daemon at once. daemon.go resolves the race with an advisory file lock rather than a PID file:

lock, err := os.OpenFile(filepath.Join(dir, "daemon.lock"), os.O_CREATE|os.O_RDONLY, 0o600)
if err != nil {
    log.Printf("lock file: %v", err)
    return 1
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
    return 0 // another daemon is already running
}

LOCK_NB makes the attempt non-blocking, so a daemon that loses the lock race exits quietly with status 0 instead of hanging. The kernel releases the lock when the descriptor closes, including when the process is killed, so there is no PID file to go stale. One caveat in this implementation is that it treats every Flock error as evidence that another daemon is running; production code that needs diagnostics should distinguish EWOULDBLOCK from other failures. Only after acquiring the lock does the daemon remove a possibly stale socket file and listen.

Signal handling that doesn’t break Ctrl-C

The execute function in client.go runs the user’s command with stdin and stderr inherited and stdout split through an io.MultiWriter into both the terminal and a capped buffer. Interactive prompts, like a 1Password biometric unlock, keep working.

The signal handling is deliberately asymmetric:

sigs := make(chan os.Signal, 4)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(sigs)

SIGINT and SIGQUIT are received by once but not forwarded. The child inherits the same process group, so an interactive terminal sends Ctrl-C or Ctrl-\ to both processes; forwarding again would duplicate the signal. Handling them keeps the wrapper alive long enough to report the child’s exit status. SIGTERM and SIGHUP are not the foreground-terminal keystroke signals, so the wrapper explicitly relays those when it receives them.

Exit codes follow shell convention: 127 when starting the command reports that it was not found, 126 for other start failures, and 128 + signal when the child was killed by a signal. That last case comes from syscall.WaitStatus:

if ee, ok := errors.AsType[*exec.ExitError](err); ok {
    if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
        return 128 + int(ws.Signal()), nil, false
    }
    return ee.ExitCode(), nil, false
}

Note errors.AsType, the generic form of errors.As added in Go 1.26. It returns the typed value and a bool instead of writing through a pointer, which saves you declaring a target variable first.

Keeping cached values out of core files

coredump_linux.go and coredump_other.go are a two-file build-tag split. Both set RLIMIT_CORE to zero so a crash can’t dump cached secrets to disk. The Linux file adds a prctl call:

if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, 0, 0); errno != 0 {
    log.Printf("disable core dumps: prctl: %v", errno)
}

PR_SET_DUMPABLE with 0 suppresses core dumps on Linux. It also prevents ptrace attachment unless the caller passes the kernel’s access checks with CAP_SYS_PTRACE, reducing exposure to other ordinary processes running under the same user.

The fallback file uses //go:build !linux rather than a _darwin suffix, but that does not by itself make every non-Linux target buildable: the code still depends on the target’s syscall API, and the project documents support for macOS and Linux. disableCoreDumps is called at the top of runDaemon, before the socket is opened.

The store also calls wipe on entry data when an entry is replaced or expired. Clearing the live byte slice shortens the period in which that backing array holds the value, but it is only best effort: earlier copies, allocator behavior, swap, and other process memory are outside this helper’s control.

What’s worth borrowing

The pieces are all small: a Unix socket in a private directory, net/http so the project does not need a custom protocol, flock for single-instance coordination, length-prefixed HMAC input encoding, and wall-clock-only timestamps for expiry. No framework, no dependency tree.

The useful lesson is to choose clock semantics deliberately. Monotonic time is normally the right choice for measuring elapsed time because wall-clock adjustments do not disturb it. If a deadline must expire according to civil time across system sleep, store or compare a wall-clock-only value and test the suspend behavior on the platforms you support.