commit: f8058e1a817325da080f43ffbd03e7e67c9ab1ce
Author: Michał Górny <mgorny <AT> gentoo <DOT> org>
AuthorDate: Sun Nov 16 19:17:33 2025 +0000
Commit: Michał Górny <mgorny <AT> gentoo <DOT> org>
CommitDate: Sun Nov 16 20:05:31 2025 +0000
URL: https://gitweb.gentoo.org/proj/steve.git/commit/?id=f8058e1a
Start rewriting using CUSE
Signed-off-by: Michał Górny <mgorny <AT> gentoo.org>
README.rst | 100 -------------------------------
meson.build | 6 ++
pyproject.toml | 67 ---------------------
steve.c | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
steve.py | 183 ---------------------------------------------------------
5 files changed, 186 insertions(+), 350 deletions(-)
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 205fd5f..0000000
--- a/README.rst
+++ /dev/null
@@ -1,100 +0,0 @@
-=====
-steve
-=====
-
-Steve is a trivial standalone jobserver compatible with GNU make.
-Start it via::
-
- steve ${path_to_fifo}
-
-Then for GNU make / ninja invocations, set::
-
- MAKEFLAGS="--jobserver-auth=fifo:${path_to_fifo}"
-
-Make sure that your build processes can access the FIFO. When using
-with Portage, change the ownership to ``portage:portage`` and add it
-to ``SANDBOX_WRITE`` if necessary.
-
-Normally steve runs until explicitly terminated via ^c or SIGTERM.
-
-
-Signals
--------
-Steve responds to the following signals:
-
-SIGINT, SIGTERM
- Terminate gracefully.
-
-SIGUSR1
- Print jobserver status.
-
-
-The jobserver protocol and risks
---------------------------------
-Steve implements the variant of GNU make jobserver protocol using
-a named pipe (FIFO). The scheme is trivial and largely stateless --
-one could hardly call it a server, in fact. The idea is that steve
-creates a named pipe and write a character for each permitted job to it,
-the so-called "job tokens". Afterwards, steve just keeps the pipe open,
-while all operations are performed directly by clients.
-
-Clients read job tokens from the pipe to claim them, and write them back
-once the jobs complete. The total job count is effectively controlled
-by exhausting the job tokens written to the pipe -- the read operation
-blocks until a token is writting back, and then a new job can be
-started.
-
-This can be a blessing but it is also a curse. Most importantly,
-clients must reliably return job tokens -- any misbehaving client can
-easily consume all job tokens, and effectively stop all builds from
-proceeding. This can particularly be a case if the client (ninja,
-GNU make) is killed by ``SIGKILL``, as it effectively prevents it
-from running a cleanup routine. If that happens, one can artificially
-add job tokens back, as per `Adjusting the job count at runtime`_.
-
-Another counter-intuitive fact is that restarting steve does not reset
-the tob tokens for existing clients. The old named pipe will be
-replaced by a new one. The existing clients will continue using job
-tokens from the old pipe, while new clients will consume them from
-the new pipe. This can mean that every steve restart will result
-in the total job count growing linearly, and that builds hanged due
-to all jobs being consumed will remain hanged.
-
-
-Control FIFO
-------------
-Optionally, steve can be started with a control/locking FIFO using::
-
- steve -c ${path_to_control} ${path_to_fifo}
-
-Steve then opens the read end of the pipe and waits for a client (such
-as ``emerge``) to open the write end before actually writing any job
-tokens. If multiple processes need to use Steve, they should all hold
-the write end open. Steve reads the FIFO until EOF, then exits
--- effectively waiting for all locked processes to terminate.
-
-This effectively means that the first process needing steve can start
-a shared instance dynamically, and it will continue running until
-the last process exits.
-
-In the future, the control FIFO may be used to pass explicit commands
-to steve.
-
-
-Adjusting the job count at runtime
-----------------------------------
-Due to the simplicity of the jobserver protocol, it is trivially
-possible to adjust the job count at runtime.
-
-To add more jobs to the jobserver, use ``--add-jobs`` with the same
-jobserver FIFO path::
-
- steve -a ${jobs} ${path_to_fifo}
-
-To remove them, use ``--remove-jobs``. This will block until
-all the requested jobs are removed. If the number is larger than total
-number of jobs, it will block forever.
-
-::
-
- steve -d ${jobs} ${path_to_fifo}
diff --git a/meson.build b/meson.build
new file mode 100644
index 0000000..8f9e5da
--- /dev/null
+++ b/meson.build
@@ -0,0 +1,6 @@
+project('steve', 'c')
+
+fuse3 = dependency('fuse3')
+
+executable('steve', ['steve.c'],
+ dependencies: fuse3)
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 7f4e74a..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,67 +0,0 @@
-[build-system]
-requires = ["flit_core >=3.2,<4"]
-build-backend = "flit_core.buildapi"
-
-[project]
-name = "steve"
-authors = [{name = "Michał Górny", email = "[email protected]"}]
-readme = "README.rst"
-dynamic = ["version", "description"]
-license = {text = "GPL-2.0-or-later"}
-requires-python = ">=3.9"
-classifiers = [
- "Development Status :: 4 - Beta",
- "Environment :: Console",
- "Intended Audience :: System Administrators",
- "License :: OSI Approved :: GNU General Public License v2 or later
(GPLv2+)",
- "Operating System :: POSIX",
- "Programming Language :: Python",
- "Topic :: Software Development :: Build Tools",
-]
-
-[project.scripts]
-steve = "steve:main"
-
-[project.urls]
-Homepage = "https://gitweb.gentoo.org/proj/steve.git/"
-
-[tool.flit.sdist]
-include = [
- "COPYING",
-]
-
-[tool.ruff]
-line-length = 80
-
-[tool.ruff.lint]
-extend-select = [
- "E",
- "N",
- "W",
- "I",
- "UP",
- "ANN",
- "B",
- "A",
- "COM",
- "C4",
- "EXE",
- "ISC",
- "PIE",
- "PT",
- "Q",
- "RSE",
- "RET",
- "SLOT",
- "SIM",
- "TCH",
- "ARG",
- "ERA",
- "PGH",
- "FURB",
- "RUF",
-]
-
-[tool.ruff.lint.flake8-copyright]
-min-file-size = 1
-notice-rgx = "\\(c\\) \\d{4}(-\\d{4})?"
diff --git a/steve.c b/steve.c
new file mode 100644
index 0000000..3002016
--- /dev/null
+++ b/steve.c
@@ -0,0 +1,180 @@
+/* Steve, the jobserver
+ * (c) 2025 Michał Górny
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ *
+ * Inspired by CUSE example and nixos-jobserver (draft):
+ *
https://github.com/libfuse/libfuse/blob/f58d4c5b0d56116d8870753f6b9d1620ee082709/example/cuse.c
+ *
https://github.com/RaitoBezarius/nixpkgs/blob/e97220ecf1e8887b949e4e16547bf0334826d076/pkgs/by-name/ni/nixos-jobserver/nixos-jobserver.cpp#L213
+ */
+
+#define FUSE_USE_VERSION 31
+
+#include <cuse_lowlevel.h>
+#include <fuse.h>
+#include <fuse_opt.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+
+static const char *usage =
+"usage: steve [options]\n"
+"\n"
+"options:\n"
+" --help|-h print this help message\n"
+" --jobs=JOBS|-j JOBS jobs to use (default: nproc)\n"
+" --verbose|-v enable verbose logging\n"
+" -d -o debug enable debug output (implies -f)\n"
+" -f foreground operation\n"
+" -s disable multi-threaded operation\n"
+"\n";
+
+struct steve_state {
+ bool verbose;
+ unsigned int jobs;
+ unsigned int tokens;
+};
+
+enum steve_arg_keys {
+ STEVE_HELP,
+ STEVE_VERBOSE,
+};
+
+#define STEVE_OPT(t, p) { t, offsetof(struct steve_state, p), 1 }
+
+static const struct fuse_opt steve_opts[] = {
+ STEVE_OPT("-j %u", jobs),
+ STEVE_OPT("--jobs=%u", jobs),
+ FUSE_OPT_KEY("-v", STEVE_VERBOSE),
+ FUSE_OPT_KEY("--verbose", STEVE_VERBOSE),
+ FUSE_OPT_KEY("-h", STEVE_HELP),
+ FUSE_OPT_KEY("--help", STEVE_HELP),
+ FUSE_OPT_END
+};
+
+static int steve_process_arg(
+ void *userdata, const char *arg, int key, struct fuse_args *outargs)
+{
+ struct steve_state *state = userdata;
+
+ (void) outargs;
+ (void) arg;
+
+ switch (key) {
+ case STEVE_HELP:
+ fprintf(stderr, "%s", usage);
+ return fuse_opt_add_arg(outargs, "-ho");
+ case STEVE_VERBOSE:
+ state->verbose = true;
+ break;
+ default:
+ return 1;
+ }
+
+ return 0;
+}
+
+static void steve_init(void *userdata, struct fuse_conn_info *conn)
+{
+ struct steve_state *state = userdata;
+
+ /* Disable the receiving and processing of FUSE_INTERRUPT requests */
+ conn->no_interrupt = 1;
+
+ state->tokens = state->jobs;
+
+ fprintf(stderr, "steve running on /dev/steve for %d jobs\n",
state->jobs);
+}
+
+static void steve_open(fuse_req_t req, struct fuse_file_info *fi)
+{
+ fuse_reply_open(req, fi);
+}
+
+static void steve_read(
+ fuse_req_t req, size_t size, off_t off, struct fuse_file_info *fi)
+{
+ const struct fuse_ctx *context = fuse_req_ctx(req);
+ struct steve_state *state = fuse_req_userdata(req);
+
+ if (off != 0) {
+ fuse_reply_err(req, EIO);
+ return;
+ }
+ if (size == 0) {
+ fuse_reply_buf(req, "", 0);
+ return;
+ }
+
+ /* no need to support reading more than one token at a time */
+ if (state->tokens > 0) {
+ state->tokens--;
+ if (state->verbose)
+ printf("Giving job token to PID %d, %d left\n",
+ context->pid, state->tokens);
+ fuse_reply_buf(req, "+", 1);
+ return;
+ }
+
+ if (fi->flags & O_NONBLOCK) {
+ fuse_reply_err(req, EAGAIN);
+ return;
+ }
+
+ /* TODO: implement waiting */
+ fuse_reply_err(req, EIO);
+}
+
+static void steve_write(
+ fuse_req_t req, const char *buf, size_t size, off_t off,
+ struct fuse_file_info *fi)
+{
+ const struct fuse_ctx *context = fuse_req_ctx(req);
+ struct steve_state *state = fuse_req_userdata(req);
+
+ if (off != 0) {
+ fuse_reply_err(req, EIO);
+ return;
+ }
+
+ state->tokens += size;
+ if (state->verbose)
+ printf("PID %d returned %zd tokens, %d available now\n",
+ context->pid, size, state->tokens);
+ fuse_reply_write(req, size);
+}
+
+static const struct cuse_lowlevel_ops steve_ops = {
+ .init = steve_init,
+ .open = steve_open,
+ .read = steve_read,
+ .write = steve_write,
+};
+
+int main(int argc, char **argv)
+{
+ struct fuse_args args = FUSE_ARGS_INIT(argc, argv);
+ const char *dev_name = "DEVNAME=steve";
+ const char *dev_info_argv[] = { dev_name };
+ struct cuse_info ci = { 0 };
+ struct steve_state state = { 0 };
+ int ret;
+
+ if (fuse_opt_parse(&args, &state, steve_opts, steve_process_arg)) {
+ fprintf(stderr, "failed to parse option\n");
+ fuse_opt_free_args(&args);
+ return 1;
+ }
+
+ ci.dev_info_argc = 1;
+ ci.dev_info_argv = dev_info_argv;
+ if (state.jobs == 0)
+ state.jobs = sysconf(_SC_NPROCESSORS_ONLN);
+
+ ret = cuse_lowlevel_main(args.argc, args.argv, &ci, &steve_ops, &state);
+ fuse_opt_free_args(&args);
+ return ret;
+}
diff --git a/steve.py b/steve.py
deleted file mode 100755
index 5606009..0000000
--- a/steve.py
+++ /dev/null
@@ -1,183 +0,0 @@
-#!/usr/bin/env python
-# (c) 2025 Michał Górny
-# SPDX-License-Identifier: GPL-2.0-or-later
-
-"""steve -- a simple jobserver for Gentoo"""
-# based on
https://github.com/ninja-build/ninja/blob/master/misc/jobserver_pool.py
-
-from __future__ import annotations
-
-import argparse
-import array
-import contextlib
-import fcntl
-import multiprocessing
-import os
-import select
-import signal
-import termios
-from pathlib import Path
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- import io
- from collections.abc import Generator
- from types import FrameType
-
-
-__version__ = "0.0.1"
-
-
-def positive_int(value: str) -> int:
- ret = int(value)
- if ret <= 0:
- raise argparse.ArgumentTypeError("must be positive")
- return ret
-
-
-def exit_sig_handler(signum: int, _: FrameType | None) -> None:
- print(f"Exiting on {signal.Signals(signum).name}")
- raise SystemExit(0)
-
-
-def print_state(job_file: io.FileIO) -> None:
- data_buf = array.array("i", [0])
- fcntl.ioctl(job_file.fileno(), termios.FIONREAD, data_buf)
- print(f"Available job tokens: {data_buf[0]}")
-
-
[email protected]
-def hold_fifo(path: Path) -> Generator[Path]:
- os.mkfifo(path)
- try:
- yield path
- finally:
- path.unlink()
-
-
[email protected]
-def close_fd(fd: int) -> Generator[int]:
- try:
- yield fd
- finally:
- os.close(fd)
-
-
-def remove_job_tokens(pipe_fd: int, num: int) -> None:
- """Remove num job tokens from pipe pipe_fd, open non-blocking"""
-
- while num > 0:
- try:
- removed = len(os.read(pipe_fd, num))
- except BlockingIOError:
- # block until more data is available
- select.select([pipe_fd], [], [])
- else:
- num -= removed
- print(f"{removed} jobs removed, {num} left")
-
-
[email protected]
-def job_cleanup(pipe_fd: int, expected_jobs: int) -> Generator[None]:
- try:
- yield
- finally:
- print(f"Waiting for {expected_jobs} tokens to be returned")
- remove_job_tokens(pipe_fd, expected_jobs)
-
-
-def main() -> None:
- signal.signal(signal.SIGINT, exit_sig_handler)
- signal.signal(signal.SIGTERM, exit_sig_handler)
- signal.signal(signal.SIGUSR1, signal.SIG_IGN)
-
- argp = argparse.ArgumentParser()
-
- commands = argp.add_mutually_exclusive_group()
- commands.add_argument(
- "-a",
- "--add-jobs",
- type=positive_int,
- help="Add the specified number of jobs to the running jobserver",
- )
- commands.add_argument(
- "-d",
- "--delete-jobs",
- "--remove-jobs",
- type=positive_int,
- help="Remove the specified number of jobs to the running jobserver",
- )
-
- argp.add_argument(
- "-c",
- "--control-fifo",
- type=Path,
- help="Path for the control/lock FIFO",
- )
- argp.add_argument(
- "-j",
- "--jobs",
- type=positive_int,
- help="Number of jobs to allow (default: nproc)",
- )
- argp.add_argument(
- "path",
- type=Path,
- help="Path to create the FIFO at",
- )
- args = argp.parse_args()
-
- if args.add_jobs is not None:
- if args.add_jobs <= 0:
- argp.error("--add-jobs must be larger than 0")
- with args.path.open("wb") as job_file:
- job_file.write(b"." * args.add_jobs)
- print(f"{args.add_jobs} job tokens added to {args.path}")
- return
-
- if args.delete_jobs:
- with close_fd(
- os.open(args.path, os.O_RDONLY | os.O_NONBLOCK),
- ) as job_fd:
- print(f"Removing {args.delete_jobs} job tokens from {args.path}")
- remove_job_tokens(job_fd, args.delete_jobs)
- return
-
- if args.jobs is None:
- try:
- args.jobs = multiprocessing.cpu_count()
- except NotImplementedError:
- argp.error("Cannot determine CPU count, please specify --jobs")
-
- with contextlib.ExitStack() as context_managers:
- context_managers.enter_context(hold_fifo(args.path))
- job_read_fd = os.open(args.path, os.O_RDONLY | os.O_NONBLOCK)
- context_managers.enter_context(close_fd(job_read_fd))
- job_file = args.path.open("wb")
- signal.signal(
- signal.SIGUSR1,
- lambda _sig, _frame: print_state(job_file),
- )
- if args.control_fifo is not None:
- context_managers.enter_context(hold_fifo(args.control_fifo))
- print(f"Waiting for clients to open {args.control_fifo}")
- control_file = args.control_fifo.open("rb")
-
- job_file.write(b"." * args.jobs)
- job_file.flush()
- context_managers.enter_context(job_cleanup(job_read_fd, args.jobs))
-
- print(f"Started for {args.jobs} jobs")
- print(f'MAKEFLAGS="--jobserver-auth=fifo:{args.path}"')
-
- if args.control_fifo is not None:
- print("Running until the last client closes the control FIFO")
- control_file.read()
- print("Control FIFO released, exiting")
- else:
- while True:
- signal.pause()
-
-
-if __name__ == "__main__":
- main()