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.
121 lines
5.1 KiB
Bash
Executable File
121 lines
5.1 KiB
Bash
Executable File
#!/bin/sh
|
|
# Offline regression tests for Solo-C. No API key and no network: every line
|
|
# handed to the agent starts with / or !, so nothing is ever sent to the model.
|
|
#
|
|
# sh ./test.sh [binary] # default ./solo
|
|
#
|
|
# Each test runs the agent in its own directory, because the session file
|
|
# (.solo_sess.json) lives in the current directory. Exits non-zero if any test
|
|
# failed, with the reason on stderr.
|
|
set -u
|
|
|
|
SOLO=${1:-./solo}
|
|
case $SOLO in /*) ;; *) SOLO=$PWD/$SOLO ;; esac
|
|
[ -x "$SOLO" ] || { echo "test: no such executable: $SOLO" >&2; exit 1; }
|
|
command -v perl >/dev/null 2>&1 || { echo "test: perl is required" >&2; exit 1; }
|
|
|
|
WORK=$(mktemp -d)
|
|
# Long enough that the environment block lands where the allocator dislikes it,
|
|
# short enough to stay a legal path component.
|
|
LONGDIR=/tmp/$(perl -e 'print "d" x 200')
|
|
trap 'rm -rf "$WORK" "$LONGDIR"' EXIT INT TERM
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
# ok EXIT-CODE NAME [DETAIL] -- prints pass, or the detail on stderr.
|
|
ok() {
|
|
if [ "$1" = 0 ]; then
|
|
PASS=$((PASS + 1))
|
|
printf 'PASS %s\n' "$2"
|
|
else
|
|
FAIL=$((FAIL + 1))
|
|
printf 'FAIL %s: %s\n' "$2" "${3:-}" >&2
|
|
fi
|
|
}
|
|
|
|
# solo_in DIR ENV... < lines
|
|
solo_in() {
|
|
_dir=$1; shift
|
|
mkdir -p "$_dir"
|
|
( cd "$_dir" && env "$@" "$SOLO" )
|
|
}
|
|
|
|
echo "# $SOLO"
|
|
|
|
# --- a setting taken from the environment must not be freed by the agent -------
|
|
# These slots used to point straight at getenv memory, so setstr() called free()
|
|
# on it and the process died inside the slash command that changed it.
|
|
printf '/endpoint http://x.example\n/quit\n' | \
|
|
solo_in "$WORK/envcrash" SOLO_ENDPOINT="http://$(perl -e 'print "a" x 900').example.com/api" \
|
|
>/dev/null 2>&1
|
|
ok $? 'SOLO_ENDPOINT then /endpoint does not crash'
|
|
|
|
printf '/model other/model\n/quit\n' | \
|
|
solo_in "$WORK/envmodel" SOLO_MODEL="vendor/$(perl -e 'print "m" x 500')" >/dev/null 2>&1
|
|
ok $? 'SOLO_MODEL then /model does not crash'
|
|
|
|
printf '/reasoning_effort low\n/quit\n' | \
|
|
solo_in "$WORK/envreason" SOLO_REASON="$(perl -e 'print "r" x 700')" >/dev/null 2>&1
|
|
ok $? 'SOLO_REASON then /reasoning_effort does not crash'
|
|
|
|
# TMP is freed at exit, so this one used to die without a slash command at all.
|
|
printf '/quit\n' | solo_in "$WORK/envtmp" SOLO_TMPDIR="$LONGDIR" >/dev/null 2>&1
|
|
ok $? 'SOLO_TMPDIR then /quit does not crash'
|
|
|
|
# --- an integer parameter must stay an integer in the request ------------------
|
|
# Every number went through json_object_new_double, so /max_tokens 4096 reached
|
|
# the endpoint as 4096.0.
|
|
D=$WORK/params
|
|
printf '/max_tokens 4096\n/seed 3\n/top_p 0.95\n/quit\n' | solo_in "$D" >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/"max_tokens":4096,/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/max_tokens 4096 is sent as an integer'
|
|
perl -0777 -ne 'exit(/"seed":3,/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/seed 3 is sent as an integer'
|
|
perl -0777 -ne 'exit(/"top_p":0\.9/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/top_p 0.95 is sent as a number'
|
|
perl -0777 -ne 'exit(/"foo"/ ? 1 : 0)' "$D/.solo_sess.json"
|
|
ok $? 'a string parameter is still a string'
|
|
|
|
# --- the timeout is the timeout, not one second less --------------------------
|
|
# The old deadline was time(0) + TO, and time() counts whole seconds, so a
|
|
# command that happened to start late in a second was killed up to a second
|
|
# early. Stagger the start and require that no run dies before the limit.
|
|
D=$WORK/timing
|
|
MIN=99
|
|
for frac in 0 0.25 0.5 0.75; do
|
|
out=$( { sleep "$frac"
|
|
printf '!sh -c "date +%%s.%%N; sleep 6"\n!date +%%s.%%N\n/quit\n'; } | \
|
|
solo_in "$D" SOLO_TIMEOUT=3 2>/dev/null )
|
|
a=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | head -1)
|
|
b=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | tail -1)
|
|
d=$(perl -e 'printf "%.2f", $ARGV[1] - $ARGV[0]' "$a" "$b" 2>/dev/null || echo 0)
|
|
MIN=$(perl -e 'printf "%.2f", $ARGV[1] < $ARGV[0] ? $ARGV[1] : $ARGV[0]' "$MIN" "$d")
|
|
done
|
|
[ "$(perl -e 'print $ARGV[0] >= 2.8 ? 1 : 0' "$MIN")" = 1 ]
|
|
ok $? "SOLO_TIMEOUT=3 never fires early" "a run died after ${MIN}s"
|
|
|
|
# --- SOLO_TIMEOUT below 1 means no limit, not "kill instantly" ----------------
|
|
printf '!echo hi\n/quit\n' | solo_in "$WORK/nolimit" SOLO_TIMEOUT=0 2>&1 | grep -q '^hi$'
|
|
ok $? 'SOLO_TIMEOUT=0 runs commands instead of killing them at once'
|
|
printf '!echo hi\n/quit\n' | solo_in "$WORK/neglimit" SOLO_TIMEOUT=-5 2>&1 | grep -q '^hi$'
|
|
ok $? 'a negative SOLO_TIMEOUT is not an instant timeout'
|
|
|
|
# --- a timed out command leaves nothing behind --------------------------------
|
|
# The child calls setpgid(0,0) and the parent kills the group, so the whole
|
|
# group has to be gone once the turn ends.
|
|
D=$WORK/group
|
|
printf '!sh -c "sleep 41 & sleep 41"\n/quit\n' | solo_in "$D" SOLO_TIMEOUT=1 >/dev/null 2>&1
|
|
sleep 1
|
|
LEFT=$(ps -o args= 2>/dev/null | grep -c '^sleep 41$')
|
|
[ "$LEFT" -eq 0 ]
|
|
ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left"
|
|
|
|
# --- the tool description must quote the real timeout -------------------------
|
|
D=$WORK/desc
|
|
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/after 42s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'the bash tool description quotes SOLO_TIMEOUT'
|
|
|
|
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
|
[ "$FAIL" -eq 0 ]
|