Four settings read from the environment (endpoint, model, reasoning, scratch dir) pointed straight at getenv() memory instead of a copy, and both setstr() and the cleanup at exit called free() on them. Changing one of them from inside the session therefore freed the environment block: "/endpoint http://x.example" with SOLO_ENDPOINT set died inside the handler, and SOLO_TMPDIR killed the process on the way out without a slash command at all. Reproduced on musl, both are copies now, and the two post-load env re-reads go through setstr() so they stop leaking the value create() already stored. The generic parameter handler typed every number with json_object_new_double, so "/max_tokens 4096" reached the endpoint as 4096.0 and "/seed 3" as 3.0. A number written without a decimal point or an exponent now stays an integer, which is what the OpenAI schema asks for; 0.7, .5 and 1e3 are still floats, and a word is still a string. The deadline for a command was time(0) + SOLO_TIMEOUT, and time() counts whole seconds, so the effective limit was the timeout minus whatever fraction of the second the command happened to start in. Measured with SOLO_TIMEOUT=3, commands died after 2.38s, 2.47s, 2.68s and 3.19s depending on the phase. rp() takes its deadline from clock_gettime(CLOCK_MONOTONIC) now, which is also immune to the clock being stepped underneath it. A SOLO_TIMEOUT below 1 gave rp() a deadline already in the past, so every command failed at once with "still running after 0s", and atoi on anything unparseable lands there too. Below 1 now means no limit. The API read, which passed time(0) + 1e9 to mean "never", passes no deadline at all. Only the child called setpgid(0,0), so a kill(-k, ...) issued before the child reached it found no process group and was dropped, leaving the command running as an orphan. A standalone harness confirms both halves: without the parent claim the signal is lost and the child runs to completion, with it the child dies. The parent claims the group too, which is the belt to the child's braces. hc() wrote the response envelope with one uncounted write(), so a signal arriving mid-write truncated the JSON and the parent reported "request failed" for a request that had in fact succeeded. Both link steps write to <name>.new and rename it. Overwriting a running executable fails with ETXTBSY, which is exactly what happens when the agent rebuilds the binary it is itself running from, and a failed link used to take the working binary with it. make clean now removes the temp names as well. test.sh holds 13 offline tests: no API key and no network, every line handed to the agent starting with / or !, each test in its own directory because the session file lives in the cwd. They cover the four crash paths, integer/float/string typing, a staggered-start sweep for the early timeout, SOLO_TIMEOUT=0 and a negative value, process group cleanup after a timeout, and the tool description quoting the real timeout. make test runs them. The README documents the timeout semantics, the integer typing and the new build step. Verified: 13 passed, 0 failed against the new binary, and 8 of the 13 fail against the previous one, which is what makes them regression tests rather than decoration. Still silent under -Wall -Wextra and under -Wshadow -Wformat=2 -Wcast-align -Wwrite-strings -Wstrict-prototypes -Wmissing-prototypes -Wredundant-decls -Wundef. Built and tested on Alpine gcc 15.2 (musl) with libcurl 8.22.0, json-c 0.19 and readline 8.3.
169 lines
6.7 KiB
Makefile
169 lines
6.7 KiB
Makefile
# Makefile for Solo-C.
|
|
#
|
|
# Written for the POSIX make utility (IEEE Std 1003.1, XCU make) and a POSIX
|
|
# shell (/bin/sh), so it works with any conforming make and no GNU-only
|
|
# features. Deliberately avoided:
|
|
#
|
|
# $(shell ...) not POSIX - pkg-config is run by the recipe shell
|
|
# ?= not POSIX - defaults are plain "=" and are overridden on
|
|
# the command line, e.g. make CC=clang CFLAGS=-O1
|
|
# .PHONY not POSIX - see the FORCE target at the bottom
|
|
# target-specific variables, ifeq, wildcard, -include, ... all non-POSIX
|
|
# $(patsubst ...) not POSIX - so the install directory is resolved by the
|
|
# recipe shell, see INSTALL_DIR below
|
|
#
|
|
# Rule syntax used here: recipe lines start with a TAB, macros are written
|
|
# $(NAME) (POSIX also accepts ${NAME}), and the only automatic macro used is
|
|
# $@, the name of the target. Prerequisite names are written out: $< is POSIX
|
|
# for inference rules only, and "$< is the first prerequisite" in a plain rule
|
|
# is a GNU extension that even BusyBox make lacks.
|
|
|
|
SHELL = /bin/sh
|
|
|
|
# Standard macros. POSIX defaults CC to c99; Solo-C uses the system compiler
|
|
# driver "cc" instead, which is the compiler a platform actually ships
|
|
# (gcc, clang, musl-gcc, ...). Every macro here can be overridden from the
|
|
# command line: make CC=clang, make CFLAGS=-O1, make LDLIBS=-lcurl.
|
|
CC = cc
|
|
CFLAGS = -Wall -Wextra -O2 -std=c99 -march=native
|
|
LDFLAGS =
|
|
LDLIBS =
|
|
LINT = lint
|
|
LINTFLAGS =
|
|
LINTLIBS = -lc
|
|
|
|
# Link-time discovery of the shared dependencies. POSIX make cannot run
|
|
# commands while reading a makefile, so the flags are collected by the shell
|
|
# that runs the recipe instead of by $(shell ...) while parsing.
|
|
PKG_CONFIG = pkg-config
|
|
PKGS = libcurl json-c readline
|
|
# $$(...) survives expansion of the macro and reaches the shell as $(...).
|
|
PKG_CFLAGS = $$($(PKG_CONFIG) --cflags $(PKGS))
|
|
PKG_LIBS = $$($(PKG_CONFIG) --libs $(PKGS))
|
|
|
|
# Fully static binary.
|
|
#
|
|
# The distro libcurl.a cannot be linked statically on Alpine: it pulls in
|
|
# libpsl, libbrotli, libzstd and libnghttp2, whose static archives hold LTO
|
|
# bytecode from GCC 14 that the installed GCC 15 cannot read ("bytecode
|
|
# stream ... generated with LTO version 14.0 instead of the expected 15.1"),
|
|
# so psl_*/Brotli*/ZSTD* never resolve. -fno-lto does not help; the objects
|
|
# are bytecode, not C.
|
|
#
|
|
# static-deps/libcurl.a is curl built without those four libraries, leaving
|
|
# only LTO-free static deps. Regenerate with: make static-deps
|
|
#
|
|
# c-ares is built from source by the same script, for two reasons: a minimal
|
|
# system often has no libcares.a at all (Void's c-ares-devel ships the shared
|
|
# library only), and where an archive does exist it can carry the same LTO
|
|
# bytecode as the distro libcurl.a. Building it also pins the version.
|
|
#
|
|
# Archive order matters: libcurl.a pulls in ares_* symbols, so libcares.a has to
|
|
# come after it on the link line.
|
|
CURL_STATIC = static-deps/libcurl.a
|
|
CARES_STATIC = static-deps/libcares.a
|
|
STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistring \
|
|
-lreadline -lncursesw -ljson-c -lm -ldl -pthread
|
|
|
|
solo: main.c
|
|
$(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@.new main.c $(PKG_LIBS) $(LDLIBS)
|
|
strip $@.new
|
|
mv -f $@.new $@
|
|
|
|
# Writes solo-static, not a file called "static", so $@ is not used here.
|
|
static: main.c
|
|
@for f in $(CURL_STATIC) $(CARES_STATIC); do \
|
|
test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \
|
|
done
|
|
$(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static.new main.c $(STATIC_LIBS) $(LDLIBS)
|
|
strip solo-static.new
|
|
mv -f solo-static.new solo-static
|
|
|
|
static-deps: FORCE
|
|
sh ./mkstaticdeps.sh
|
|
|
|
# Rebuild both archives from scratch, ignoring what is already there.
|
|
static-deps-force: FORCE
|
|
FORCE=1 sh ./mkstaticdeps.sh
|
|
|
|
# Install paths. Every one of these can be set on the command line, so the
|
|
# install target takes any location you name:
|
|
#
|
|
# make install # $(DESTDIR)$(BINDIR) = /usr/local/bin
|
|
# make install PREFIX=$HOME/.local # ~/.local/bin
|
|
# make install BINDIR=/opt/solo/bin # an explicit directory
|
|
# make install DIR=/opt/solo/bin # the same, spelled in one macro
|
|
# make install DIR=~/.local/bin # a leading ~ is expanded, see below
|
|
# make install DIR=/usr/bin INSTALL="install -C"
|
|
# make install DESTDIR=/tmp/pkg DIR=/usr/bin # staged, for packaging
|
|
#
|
|
# DIR wins over BINDIR when both are given; DESTDIR is prepended either way, so
|
|
# "make install DESTDIR=/tmp/pkg DIR=/usr/bin" stages into /tmp/pkg/usr/bin.
|
|
DESTDIR =
|
|
PREFIX = /usr/local
|
|
BINDIR = $(PREFIX)/bin
|
|
DIR =
|
|
INSTALL = install
|
|
|
|
# The installation directory, as a shell snippet that prints it on stdout.
|
|
#
|
|
# POSIX make has no string functions, so "DIR if set, else BINDIR" and the
|
|
# leading ~ are handled by the recipe shell instead of by the makefile. Both
|
|
# install and uninstall use this one snippet, so they always agree on the path.
|
|
# $$(...) survives expansion of the macro and reaches the shell as $(...).
|
|
INSTALL_DIR = d="$(DIR)"; \
|
|
test -n "$$d" || d="$(BINDIR)"; \
|
|
case "$$d" in \
|
|
'~') d="$$HOME";; \
|
|
'~/'*) d="$$HOME/$$(printf '%s' "$$d" | cut -c3-)";; \
|
|
esac; \
|
|
printf '%s%s\n' "$(DESTDIR)" "$$d"
|
|
|
|
# Install solo, and solo-static too when it has been built. Prints the location
|
|
# it used, so a script can see where the binaries went.
|
|
install: solo FORCE
|
|
@dir=$$($(INSTALL_DIR)); \
|
|
echo "installing into $$dir"; \
|
|
$(INSTALL) -d "$$dir"; \
|
|
$(INSTALL) -m 755 solo "$$dir/solo"; \
|
|
if test -f solo-static; then \
|
|
$(INSTALL) -m 755 solo-static "$$dir/solo-static"; \
|
|
fi
|
|
|
|
# Install the static binary only; fails if it has not been built yet.
|
|
install-static: static FORCE
|
|
@dir=$$($(INSTALL_DIR)); \
|
|
echo "installing solo-static into $$dir"; \
|
|
$(INSTALL) -d "$$dir"; \
|
|
$(INSTALL) -m 755 solo-static "$$dir/solo-static"
|
|
|
|
# Prints the installation directory that install would use, and nothing else.
|
|
installdir: FORCE
|
|
@dir=$$($(INSTALL_DIR)); echo "$$dir"
|
|
|
|
# Removes the binaries from the location resolved the same way, so
|
|
# "make install DIR=~/.local/bin" is undone by
|
|
# "make uninstall DIR=~/.local/bin".
|
|
uninstall: FORCE
|
|
@dir=$$($(INSTALL_DIR)); \
|
|
echo "removing from $$dir"; \
|
|
rm -f "$$dir/solo" "$$dir/solo-static"
|
|
|
|
clean: FORCE
|
|
rm -f solo solo-static solo.new solo-static.new
|
|
|
|
# Offline regression tests. No API key, no network: every line sent to the agent
|
|
# starts with / or !, so nothing reaches the model. Pass another binary as $1.
|
|
test: solo FORCE
|
|
sh ./test.sh ./solo
|
|
|
|
# POSIX lint target: runs the lint tool named by the LINT macro.
|
|
lint: main.c FORCE
|
|
$(LINT) $(LINTFLAGS) $(LINTLIBS) main.c
|
|
|
|
# POSIX make has no .PHONY special target. Any target that depends on FORCE is
|
|
# always out of date, because FORCE never creates a file, so its recipe always
|
|
# runs. Keep this target last: the first target in the file is the default goal.
|
|
FORCE:
|
|
@:
|