Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<uid>/...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
Expand Down
7 changes: 6 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
89 changes: 89 additions & 0 deletions internal/api/socketdir_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down