Engineering Article

Fixing Unix Socket Path Failures in Cloud Mac CI

Fixing Unix Socket Path Failures in Cloud Mac CI

The CI logs show that the service process has started, yet the client repeatedly reports connect failed or No such file or directory, and retries make no difference. If permissions and process status both look correct, the real failure may be hidden in the working directory: the tool may be creating its Unix socket under a deeply nested repository path, job ID, and temporary directory, causing the final absolute path to exceed the length macOS accepts.

This issue commonly affects parallel test workers, Ruby tools, Node.js services, build helpers, and local interprocess communication. The error message may not explicitly mention that the path is too long. Instead of adding more retries, determine where the socket is actually created and measure the path length in UTF-8 bytes.

Confirm That the Failure Comes from the Socket Path

Check the processes, socket files, and open Unix connections at the same time. Do not rely solely on the startup script’s exit code: a successful parent process does not mean that the child responsible for listening has completed its bind operation.

pgrep -fl 'xcodebuild|swift|ruby|node'
find "${TMPDIR:-/private/tmp}" -type s -print 2>/dev/null
lsof -U -n -P | grep "$USER"

If the logs include a .sock path, inspect its parent directory directly. A missing file has three possible explanations: the listening process has not created it yet, it was removed too early after creation, or bind() failed while validating the path. Try reproducing the same operation with a short path. If the short path works while the original one fails, that result is usually more informative than repeatedly changing permissions.

macOS applies Unix socket path limits in bytes, not in the number of characters displayed on screen. Directories containing Chinese characters or combining characters, as well as long job identifiers, can increase the byte count much faster.

Measure the Absolute Path in UTF-8 Bytes

Do not draw conclusions from ${#path} alone, because it usually returns the number of characters. The following script first expands the absolute path and then calculates its byte length using the filesystem encoding:

candidate="$PWD/.ci/runtime/worker/session/control.sock"

python3 - "$candidate" <<'PY'
import os
import sys

path = os.path.abspath(sys.argv[1])
encoded = os.fsencode(path)
print(f"bytes={len(encoded)}")
print(f"path={path}")
PY

Darwin provides limited space in sockaddr_un.sun_path, so directory layouts should not be designed close to the limit. A practical approach is to use 90 bytes as an internal warning threshold, leaving room for tools to append process IDs, random suffixes, and subdirectories. This number is a safety threshold, not an official limit shared by every tool; some frameworks append another filename to the supplied path.

Also record every component of the failing path: the workspace root, repository name, pipeline job name, parallel shard name, and the tool’s own runtime directory. Shortening only the final control.sock component rarely saves enough space.

Create a Short Root Directory for Each Job

The reliable solution is to run each job directly under a short root directory rather than placing a symbolic link around the existing deep directory. Give every job its own directory, set its permissions to 700, and explicitly direct temporary files, caches, and build output to that location.

job_root="$(mktemp -d /private/tmp/vmown-ci.XXXXXX)"
chmod 700 "$job_root"

export TMPDIR="$job_root/tmp"
export XDG_CACHE_HOME="$job_root/cache"
export DERIVED_DATA="$job_root/dd"

mkdir -p "$TMPDIR" "$XDG_CACHE_HOME" "$DERIVED_DATA"

cleanup() {
  rm -rf -- "$job_root"
}

trap cleanup EXIT HUP INT TERM

For Xcode builds, pass an explicit DerivedData path. For SwiftPM, use a dedicated scratch directory. This both shortens paths and prevents parallel jobs from competing for the same build database.

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -derivedDataPath "$DERIVED_DATA" \
  build

swift build --scratch-path "$job_root/spm"

The repository itself should also be checked out to a short path such as $job_root/src. Moving only TMPDIR may not help if commands still run from a deeply nested original directory, because some tools generate sockets from the project’s absolute path.

Avoid Symbolic Link and Shared Directory Traps

Symbolic links are useful for quick validation, but they should not be the only fix. Some programs call realpath and ultimately use the long path behind the link. Others store the real path in cache keys, leaving the same job with both long and short directory variants.

A shared /private/tmp/ci directory is also unsuitable. Parallel jobs may create sockets with the same name, while a socket file left behind by an earlier job may cause a new job to incorrectly conclude that the service is ready. A safer rule is “one root directory per job,” with short, predictable socket filenames.

Do not use broad wildcards during cleanup, and do not remove an entire shared prefix under /private/tmp. Store the directory returned by mktemp in a variable owned by the current process, then have the same job remove that exact directory through trap. If a job is terminated forcibly and cannot clean up, a separate cleanup task can process directories based on ownership and modification time, but it must first verify that no active processes are using them.

Add a Preflight Guard to the Pipeline

A one-time fix is not enough. The issue can return when repository names, branch names, or job templates become longer. Before starting test services, check every candidate socket path and fail immediately if it exceeds the internal warning threshold.

check_socket_path() {
  python3 - "$1" <<'PY'
import os
import sys

limit = 90
path = os.path.abspath(sys.argv[1])
size = len(os.fsencode(path))

if size > limit:
    print(f"socket path exceeds guard: {size} bytes")
    raise SystemExit(1)

print(f"socket path accepted: {size} bytes")
PY
}

check_socket_path "$TMPDIR/test-worker.sock"

Validation should cover at least four points: paths remain below the warning threshold; parallel jobs use different root directories; directories are cleaned up after failures and interruptions; and the paths shown in tool logs match the expected locations. Finally, run two jobs concurrently and confirm that they do not reuse socket names, share caches unintentionally, or delete each other’s directories.

The goal of path management is not to put every file in a temporary directory. It is to give components that require interprocess communication a short, private root path with a clearly defined lifecycle. Once this constraint is in place, many apparently random connection failures become reproducible configuration errors that the pipeline can block in advance.

Frequently asked questions

Why does shortening only the socket filename not fix the error?

The kernel evaluates the UTF-8 byte length of the complete absolute path. Repository folders, job identifiers, temporary roots, and generated subdirectories all count toward the limit.

Can a symbolic link permanently solve a long socket path?

Not reliably. Some tools resolve the link and create the socket under the longer real path. Creating the workspace and temporary directories directly under a short root is safer.

Should parallel CI jobs share one short temporary directory?

No. Give each job a private directory created with mktemp, restrict it to mode 700, and remove it on normal exit or termination to prevent collisions and data leakage.

Dedicated Cloud Mac

Choose a dedicated physical machine for development, builds, and remote desktop access

Compare three Apple Silicon configurations and choose the node, rental term, and storage add-ons at checkout.

Choose a rental plan