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
1 change: 1 addition & 0 deletions AGENTS.md
97 changes: 97 additions & 0 deletions CLAUDE.md
Comment thread
spirsch marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

`HOMCC` is a work-from-home friendly `distcc` replacement: it distributes `C`/`C++` compilation (`gcc`/`clang`) to remote servers while guaranteeing the same result as a local build. Its distinguishing feature over `distcc` is aggressive optimization for thin network uplinks: dependencies are compressed and **server-side cached by SHA1 hash**, so a warmed-up cache only re-transmits missing dependencies.

The project ships two executables plus an optional GUI:
- `homcc` — the client (entry point `homcc.client.main:main`)
- `homccd` — the server daemon (entry point `homcc.server.main:main`)
- `homcc-monitor` — a PySide2 GUI that watches client state files (entry point `homcc.monitor.main:main`)

## Commands

All linting/testing runs against `*.py homcc tests` from the repo root.

```sh
# Install dev dependencies (requires liblzo2-dev liblzma-dev apt packages)
python -m pip install -r requirements.txt

# Run all tests with coverage
pytest -v -rfEs --cov=homcc

# Run a single test file / test
pytest -v tests/client/client_test.py
pytest -v tests/client/client_test.py::TestClass::test_name

# Tests that exercise sandboxed compilation (opt-in, need a configured environment)
pytest -v -rfEs --cov=homcc --runschroot=jammy
pytest -v -rfEs --cov=homcc --rundocker=jammy

# Lint + static typing (both run in CI and must pass)
pylint -v --rcfile=.pylintrc *.py homcc tests
mypy --pretty *.py homcc tests

# Format (line length 120, black skips string normalization)
black --check --color --diff --verbose *.py homcc tests
isort --check --color --diff --gitignore --verbose *.py homcc tests

# Build Debian packages (needs stdeb toolchain; run as root)
sudo make homcc # -> target/homcc.deb
sudo make homccd # -> target/homccd.deb
```

CI (`.github/workflows/`) runs three jobs: linters (pylint + mypy), format check (black + isort), and tests (pytest). Match all three locally before pushing.

## Architecture

The codebase is three packages sharing a common protocol layer:

- **`homcc/common/`** — shared by client and server. This is the contract between them; changes here usually require touching both sides.
- **`homcc/client/`** — invoked once per compilation (as `CCACHE_PREFIX=homcc`), selects a remote host, drives the protocol, falls back to local compilation.
- **`homcc/server/`** — long-lived `socketserver`-based daemon that receives requests, resolves dependencies against its cache, and compiles inside a sandbox.
- **`homcc/monitor/`** — read-only GUI; independent of the compile path.

### The wire protocol (`common/messages.py`)

All client/server communication is a stream of length-prefixed `Message` subclasses (JSON header + optional binary payload), serialized via `Message.to_bytes()` / `Message.from_bytes()`. The compilation handshake:

1. Client sends `ArgumentMessage` (compiler args, cwd, target, sandbox profile, compression, and a `{path: sha1sum}` map of all dependencies).
2. Server checks each hash against its `Cache`; for cache misses it sends `DependencyRequestMessage`, and the client replies with `DependencyReplyMessage` (compressed file bytes) until all dependencies are present.
3. Server compiles and returns a `CompilationResultMessage` (object files + stdout/stderr/return code), or a `ConnectionRefusedMessage` if it is at capacity.

When adding a message type, register it in `MessageType` and `Message._parse_message_json`.

### Client compilation flow (`client/compilation.py`)

`compile_remotely` is the orchestrator. Key invariants:
- **Local fallback is the norm, not an error**: on most failures the client compiles locally so builds never break (unless `--no-local-compilation` is set). Preprocessing (`_preprocess`) and final linking (`execute_linking`) always happen locally; only the compile step is distributed.
- **Recursion guard**: because homcc invokes a real compiler that might itself be homcc-wrapped, `main.py` sets the `_HOMCC_SAFEGUARD` env var and children detect it (`RECURSIVE_ERROR_MESSAGE`) to abort.
- **Host selection** (`client/client.py`): `RemoteHostSelector` picks hosts randomly weighted by their `LIMIT`. Concurrency is bounded by **SysV semaphores** (`sysv_ipc`) — `RemoteHostSemaphore`, `LocalHostCompilationSemaphore`, `LocalHostPreprocessingSemaphore`. Debug open semaphores with `ipcs -s`.
- Hosts come from `$HOMCC_HOSTS` or a `hosts` file. Parsing lives in `common/host.py` (`_parse_host`); formats are `HOST[:PORT][/LIMIT][,COMPRESSION]` for TCP and `@HOST[:PORT]` / `USER@HOST[:PORT]` (`[IPv6]` bracketed) for SSH.

### Transports (`client/client.py`, `client/ssh.py`)

The protocol is transport-agnostic: `RemoteCompilationClient` (abstract, in `client/client.py`) holds all message framing/send/receive logic and only defers `_open_connection` to subclasses. `TCPClient` opens a direct connection to `host:port`; `SSHClient` (`client/ssh.py`) connects to `127.0.0.1:<forwarded_port>`. `create_remote_client` in `compilation.py` dispatches on `host.type`. When adding a transport, subclass `RemoteCompilationClient` and set `connection_target`.

SSH is a **tunnel to a running `homccd`**, not a per-job remote process: `SSHTunnel` establishes a multiplexed OpenSSH master (`ControlMaster`/`ControlPersist`) that local-forwards a port to the daemon's loopback port, so all jobs (TCP and SSH alike) hit the same daemon and its global connection limit, and per-job latency is ~a local TCP connect. A per-host `flock` under `$HOMCC_DIR/ssh/` serializes master setup across the many concurrent `homcc` processes a build spawns. `SSHError` subclasses `ConnectionError` so tunnel failures fall through to the next host / local fallback like any lost connection. **No server changes** are needed for SSH.

### `Arguments` (`common/arguments.py`)

The central abstraction for a compiler command line. It parses/normalizes args, distinguishes source files, dependencies, output paths, and compiler capabilities. Both client (to build the request) and server (to reconstruct the command in the sandbox) depend on it, so its behavior must stay symmetric across the wire.

### Server sandboxing (`server/environment.py`, `docker.py`, `schroot.py`)

Each request runs in a fresh `Environment` under a temp dir in `/tmp/`, with client paths remapped into a server-local `mapped_cwd`. Compilation executes through a `ShellEnvironment` strategy: `HostShellEnvironment` (bare), `DockerShellEnvironment` (`--docker-container`), or `SchrootShellEnvironment` (`--schroot-profile`). The `Cache` (`server/cache.py`) is a hash-addressed, LRU-evicted dependency store bounded by `--max-cache-size`.

### State files & monitoring (`common/statefile.py`)

Each in-flight client compilation writes a binary `StateFile` (phases: STARTUP → CONNECT → PREPROCESSING → COMPILE, adapted from distcc's format for tooling compatibility) into `HOMCC_STATE_DIR`. The monitor GUI uses `watchdog` to observe this directory. This is purely observational and decoupled from the compile path.

## Configuration & packaging notes

- `setup.py` is **generated** at package-build time by `make` copying `setup_client.py` or `setup_server.py` over it — do not hand-edit `setup.py`; edit the `setup_client.py` / `setup_server.py` sources. Version is read from `homcc/{client,server}/__init__.py` `__version__`.
- Config resolution order and CLI/env/file precedence live in the `parsing.py` modules (`client/parsing.py`, `server/parsing.py`, `common/parsing.py`). Client and server each have a config dataclass (`ClientConfig`, `ServerConfig`).
- Compression (`common/compression.py`) supports `lzo` (python-lzo, needs `liblzo2-dev`) and `lzma`; default is none.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati
- `HOST` format:
- `HOST`: TCP connection to specified `HOST` with default port `3126`
- `HOST:PORT`: TCP connection to specified `HOST` with specified `PORT`
- `@HOST` / `USER@HOST` format:
- Connect to `HOST` through an SSH tunnel instead of a plain TCP connection, optionally authenticating as `USER`
- `homcc` establishes a multiplexed SSH master connection to `HOST` and local-forwards a port to the `homccd` running there; the daemon only needs to listen on the remote loopback interface (e.g. `--listen=127.0.0.1`). Subsequent compilations reuse the master connection (OpenSSH `ControlMaster`/`ControlPersist`), so the per-compilation cost stays close to a plain TCP connection
- The daemon's port on the remote defaults to `3126` and can be overridden with `@HOST:PORT` (use `@[IPv6]:PORT` for IPv6 addresses)
- Authentication relies on your existing SSH setup (keys/agent); `homcc` does not manage SSH credentials itself
- `HOST/LIMIT` format:
- Define any of the above `HOST` formats with an additional `LIMIT` parameter that specifies the maximum connection limit to the corresponding `HOST`
- It is advised to always specify your `LIMIT`s as they will otherwise default to 2 and only enable minor levels of concurrency
Expand All @@ -93,13 +98,17 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati
remotehost/12
192.168.0.1:3126/21
[FC00::1]:3126/42,lzo
@buildhost/24,lzo
user@buildhost:3126/24
</pre></sub></td>
<td><sub><pre>
# Comment
"localhost" host with default limit of 2
Named "remotehost" TCP host with limit of 12 at default port 3126
IPv4 "192.168.0.1" TCP host at port 3126 with limit of 21
IPv6 "FC00::1" TCP host at port 3126 with limit of 42 and lzo compression
"buildhost" via SSH tunnel with limit of 24 and lzo compression
"buildhost" via SSH tunnel as user "user", daemon port 3126, limit of 24
</pre></sub></td>
</tr>
</table>
Expand Down Expand Up @@ -158,6 +167,9 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati
HOMCC_LOG_LEVEL
HOMCC_VERBOSE
HOMCC_NO_LOCAL_COMPILATION
HOMCC_SSH_EXECUTABLE
HOMCC_SSH_CONTROL_PERSIST
HOMCC_SSH_OPTIONS

# homccd
HOMCCD_LIMIT
Expand All @@ -179,6 +191,9 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati
log_level=DEBUG
verbose=True
no_local_compilation=True
ssh_executable=ssh
ssh_control_persist=600
ssh_options=-o BatchMode=yes

[homccd]
limit=64
Expand All @@ -199,6 +214,9 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati
Detail level for log messages: {DEBUG, INFO, WARNING, ERROR, CRITICAL}
Enable verbosity mode which implies detailed and colored logging
Enforce that even on recoverable failures no local compilation is executed
Executable used to establish SSH tunnels for '@HOST'/'USER@HOST' hosts
Seconds an idle multiplexed SSH master connection is kept alive for reuse
Additional options passed to the SSH executable, e.g. '-o' flags

# Server configuration
Maximum limit of concurrent compilations
Expand All @@ -213,11 +231,11 @@ Additionally, `HOMCC` provides sandboxed compiler execution for remote compilati

## Deployment hints
Things to keep in mind when deploying `homccd`:
- `homcc` currently does not support any transport encryption such as TLS, so source files would get transmitted over the internet in plain text if not using a VPN.
- `homcc` does not support built-in transport encryption such as TLS: plain TCP hosts transmit source files unencrypted, so a VPN is required over untrusted networks. Alternatively, use an SSH host (`@HOST`/`USER@HOST`) to tunnel the connection through an encrypted, authenticated SSH channel to a `homccd` bound to the remote loopback interface.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this now?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left it — it's not just about TLS, the other two bullets (no per-client connection limit, no restriction on docker/chroot selection) still hold regardless of transport. Reworded it instead so it doesn't imply VPN is the only mitigation now that SSH tunneling is an option too: 9102cd4

- `homccd` does not limit simultaneous connections of a single client. A malicious client could therefore block the service by always opening up connections until no server slots are available any more.
- `homccd` does not limit access to docker containers or chroot environments. A client can choose any docker container or chroot environment available on the server to execute the compilation in.

:exclamation: The key takeaway of the previous points is to **not expose** `homccd` publicly. You should make sure only internal users (e.g. developers) have access to the service, for example through using a VPN.
:exclamation: The key takeaway of the previous points is to **not expose** `homccd` publicly. Make sure only internal users (e.g. developers) have access to the service, for example through a VPN or by only accepting SSH-tunneled connections.

## Development

Expand Down
71 changes: 48 additions & 23 deletions homcc/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
import types
from abc import ABC, abstractmethod
from pathlib import Path
from typing import ClassVar, Dict, Iterator, List, Optional
from typing import ClassVar, Dict, Iterator, List, Optional, Tuple

import sysv_ipc

from homcc.common.arguments import Arguments
from homcc.common.compression import Compression
from homcc.common.constants import TCP_BUFFER_SIZE
from homcc.common.errors import (
ClientParsingError,
Expand Down Expand Up @@ -264,17 +265,25 @@ def __init__(self, host: Host, expected_preprocessing_time: float = DEFAULT_EXPE
super().__init__(host, expected_preprocessing_time)


class TCPClient:
"""Wrapper class to exchange homcc protocol messages via TCP"""
class RemoteCompilationClient(ABC):
"""
Transport-agnostic base class to exchange homcc protocol messages with a remote server.

def __init__(self, host: Host, timeout: float, state: StateFile):
connection_type: ConnectionType = host.type
All protocol logic (message framing, sending arguments and dependencies, receiving replies) lives here and operates
on an asyncio stream reader/writer pair. Concrete subclasses only have to establish the transport-specific
connection by implementing `_open_connection`, e.g. a plain TCP connection (`TCPClient`) or a connection tunneled
through SSH (`SSHClient`).
"""

if connection_type != ConnectionType.TCP:
raise ValueError(f"TCPClient cannot be initialized with {connection_type}!")
host: str
"""Name of the remote host, used for logging and host name resolution errors."""
connection_target: str
"""Human-readable connection target for logging and error messages, e.g. 'host:port' or 'user@host'."""
compression: Compression
"""Compression used for data transfer."""

self.host: str = host.name
self.port: int = host.port
def __init__(self, host: Host, timeout: float, state: StateFile):
self.host = host.name
self.compression = host.compression

self.timeout: float = timeout
Expand All @@ -285,24 +294,25 @@ def __init__(self, host: Host, timeout: float, state: StateFile):

state.set_connect()

async def __aenter__(self) -> TCPClient:
"""connect to specified server at host:port"""
logger.debug("Connecting to '%s:%i'.", self.host, self.port)
@abstractmethod
async def _open_connection(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Establish the transport-specific asyncio connection to the remote server."""

async def __aenter__(self) -> RemoteCompilationClient:
"""connect to the specified server"""
logger.debug("Connecting to '%s'.", self.connection_target)
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(host=self.host, port=self.port, limit=TCP_BUFFER_SIZE),
timeout=self.timeout,
)
self._reader, self._writer = await asyncio.wait_for(self._open_connection(), timeout=self.timeout)
except asyncio.TimeoutError as error:
logger.warning("Connection establishment to '%s:%s' timed out.", self.host, self.port)
logger.warning("Connection establishment to '%s' timed out.", self.connection_target)
raise error from None
except socket.gaierror as error:
raise FailedHostNameResolutionError(f"Host {self.host} could not be resolved.") from error
return self

async def __aexit__(self, *_):
"""disconnect from server and close client socket"""
logger.debug("Disconnecting from '%s:%i'.", self.host, self.port)
logger.debug("Disconnecting from '%s'.", self.connection_target)
self._writer.close()

try:
Expand All @@ -313,7 +323,7 @@ async def __aexit__(self, *_):

async def _send(self, message: Message):
"""send a message to homcc server"""
logger.debug("Sending %s to '%s:%i':\n%s", message.message_type, self.host, self.port, message.get_json_str())
logger.debug("Sending %s to '%s':\n%s", message.message_type, self.connection_target, message.get_json_str())
self._writer.write(message.to_bytes())
await self._writer.drain()

Expand Down Expand Up @@ -350,7 +360,7 @@ async def send_argument_message(
error,
)
raise HostRefusedConnectionError(
f"Host {self.host}:{self.port} closed the connection, probably due to "
f"Host {self.connection_target} closed the connection, probably due to "
"reaching the compilation limit."
) from error

Expand Down Expand Up @@ -390,10 +400,25 @@ async def receive(self) -> Message:
raise ClientParsingError("Received data could not be parsed to a message!")

logger.debug(
"Received %s message from '%s:%i':\n%s",
"Received %s message from '%s':\n%s",
parsed_message.message_type,
self.host,
self.port,
self.connection_target,
parsed_message.get_json_str(),
)
return parsed_message


class TCPClient(RemoteCompilationClient):
"""Client to exchange homcc protocol messages with a remote server via a direct TCP connection."""

def __init__(self, host: Host, timeout: float, state: StateFile):
if host.type != ConnectionType.TCP:
raise ValueError(f"TCPClient cannot be initialized with {host.type}!")

super().__init__(host, timeout, state)

self.port: int = host.port
self.connection_target = f"{host.name}:{host.port}"

async def _open_connection(self) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]:
return await asyncio.open_connection(host=self.host, port=self.port, limit=TCP_BUFFER_SIZE)
Loading
Loading