From 54e616da42cb4a73eab30490ec7b49ddf56a0358 Mon Sep 17 00:00:00 2001 From: Cameron Daniel Date: Tue, 7 Jul 2026 12:25:56 +1000 Subject: [PATCH] fix: create socket dir before spawning session daemon The launcher writes the detached daemon's log file into the per-user socket directory (/tmp/bench-/) before spawning the daemon, but that directory was only created later by the daemon itself when binding its socket. On a clean /tmp (macOS reaps entries after ~3 days), opening the log failed and the session never started: error: spawning session daemon: open /tmp/bench-/bench-*.log: no such file or directory ensureSession now calls api.EnsureSocketDir before opening the log, reusing the same 0700 + symlink hardening the daemon applies when it binds. The call is idempotent with the daemon's own, so the daemon path is unchanged. ensurePrivateSocketDir is exported as EnsureSocketDir for reuse from the cli package. Note: EnsureSocketDir is a no-op for custom --socket/BENCH_SOCKET paths outside the private dir, so a custom socket in a nonexistent directory would still fail the same way; left as-is since that path is user-chosen and net.Listen would surface a clear error there anyway. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++ internal/api/client.go | 7 ++- internal/api/server.go | 2 +- internal/api/socketdir_test.go | 89 ++++++++++++++++++++++++++++++++++ internal/cli/cli.go | 6 +++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 internal/api/socketdir_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bced680..79a0536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Starting a session no longer fails with `spawning session daemon: open + /tmp/bench-/...log: no such file or directory` when the per-user socket + directory is missing (e.g. after the OS reaps `/tmp`). The launcher now + ensures the directory exists before writing the daemon log, instead of + relying on the not-yet-started daemon to create it. + ## [0.6.9] - 2026-06-15 ### Fixed diff --git a/internal/api/client.go b/internal/api/client.go index e8461a8..145ea48 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -291,7 +291,12 @@ func privateSocketDir() (string, error) { return filepath.Join(os.TempDir(), name), nil } -func ensurePrivateSocketDir(sockPath string) error { +// EnsureSocketDir makes sure the private per-user socket directory that holds +// sockPath exists with hardened permissions (0700, not a symlink). It is a +// no-op when sockPath lives outside that directory (e.g. a custom --socket or +// BENCH_SOCKET path). Safe to call repeatedly; both the launcher (before +// writing the daemon log) and the daemon (before binding) invoke it. +func EnsureSocketDir(sockPath string) error { dir, err := privateSocketDir() if err != nil { return err diff --git a/internal/api/server.go b/internal/api/server.go index a1528c0..e35a3de 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,7 +83,7 @@ func (s *Server) ShutdownRequested() <-chan struct{} { // Start begins listening and accepting connections. func (s *Server) Start() error { - if err := ensurePrivateSocketDir(s.sockPath); err != nil { + if err := EnsureSocketDir(s.sockPath); err != nil { return err } diff --git a/internal/api/socketdir_test.go b/internal/api/socketdir_test.go new file mode 100644 index 0000000..e97665e --- /dev/null +++ b/internal/api/socketdir_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "os" + "path/filepath" + "testing" +) + +// EnsureSocketDir must create the private per-user socket directory when it is +// missing, because the launcher writes the daemon log there before the daemon +// itself binds its socket. macOS reaps /tmp entries, so a directory from a +// prior session may be gone by the next launch. +func TestEnsureSocketDirCreatesMissingDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + + dir, err := privateSocketDir() + if err != nil { + t.Fatalf("privateSocketDir: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected %s to be absent before the call, stat err = %v", dir, err) + } + + sockPath := filepath.Join(dir, "bench-abc.sock") + if err := EnsureSocketDir(sockPath); err != nil { + t.Fatalf("EnsureSocketDir: %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat after create: %v", err) + } + if !info.IsDir() { + t.Fatalf("%s is not a directory", dir) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("directory perms = %o, want 700", perm) + } + + // Idempotent: a second call on an existing dir must succeed. + if err := EnsureSocketDir(sockPath); err != nil { + t.Fatalf("second EnsureSocketDir: %v", err) + } +} + +// A socket path outside the managed private directory (e.g. a custom --socket +// or BENCH_SOCKET) is left untouched — EnsureSocketDir must not create the +// private dir in that case. +func TestEnsureSocketDirNoOpOutsidePrivateDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + + dir, err := privateSocketDir() + if err != nil { + t.Fatalf("privateSocketDir: %v", err) + } + + elsewhere := filepath.Join(t.TempDir(), "custom.sock") + if err := EnsureSocketDir(elsewhere); err != nil { + t.Fatalf("EnsureSocketDir: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("private dir %s should not have been created, stat err = %v", dir, err) + } +} + +// A symlink where the private directory is expected is rejected rather than +// followed, to avoid binding the socket through an attacker-controlled link. +func TestEnsureSocketDirRejectsSymlink(t *testing.T) { + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + + dir, err := privateSocketDir() + if err != nil { + t.Fatalf("privateSocketDir: %v", err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if err := os.Symlink(target, dir); err != nil { + t.Fatalf("symlink: %v", err) + } + + if err := EnsureSocketDir(filepath.Join(dir, "bench-abc.sock")); err == nil { + t.Fatalf("expected error for symlinked socket directory, got nil") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 21f1469..2a747b4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -478,6 +478,12 @@ func ensureSession(configPath, socketPath string, noWatch bool, profiles []strin } wargs = append(wargs, subset...) + // The daemon creates this directory when it binds its socket, but we write + // its log file there first — ensure it exists (macOS reaps /tmp entries, so + // a dir from a prior session may be gone). + if err := api.EnsureSocketDir(sockPath); err != nil { + return nil, false, fmt.Errorf("preparing session directory: %w", err) + } logPath := strings.TrimSuffix(sockPath, ".sock") + ".log" if err := spawnDaemon(self, wargs, logPath); err != nil { return nil, false, fmt.Errorf("spawning session daemon: %w", err)