fix Ctrl-C swallowing the next line

readline only redraws the prompt on Ctrl-C: the half-typed text
stays in its buffer and the interrupt flag outlives the prompt,
so the line typed next was silently discarded. Register a signal
event hook that empties the line, the way bash does, and clear
the flag around readline instead of eating the line: a Ctrl-C
during a command still aborts the turn, and one during a
multi-line task still abandons it. Two pty-based regression
tests cover the idle-prompt and mid-line cases.
This commit is contained in:
Luxferre
2026-10-04 21:32:50 +03:00
parent 141c6cc414
commit fdd389e452
3 changed files with 84 additions and 6 deletions
+6 -5
View File
@@ -416,12 +416,13 @@ where you are when you press it:
| -------------------- | ------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------- |
| Running a command | The process group is killed, exit 130 is reported, the turn ends. | | Running a command | The process group is killed, exit 130 is reported, the turn ends. |
| Waiting on the API | The request is abandoned and the turn ends. | | Waiting on the API | The request is abandoned and the turn ends. |
| Part way through a line | The half-typed line is thrown away. | | Part way through a line | The half-typed line is cleared; the next line runs normally. |
| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. | | Mid way through a multi-line task | The whole task is abandoned. |
| Sitting at the prompt | Solo survives and stays usable; the next line runs normally. |
That last case is the one wart: press Ctrl-C once at an idle prompt and your The half-typed line is cleared by Solo itself: readline only redraws the
next command vanishes without a word. Press it twice and the second one goes prompt on Ctrl-C and leaves the text in its buffer, so Solo registers a
through, so it is self-correcting in practice. signal hook that empties the line, the way bash does.
## Example session ## Example session
+13 -1
View File
@@ -42,6 +42,16 @@ static int GN(json_object *o, const char *k, int d) { json_object *v = G(o, k);
static volatile sig_atomic_t IN; static volatile sig_atomic_t IN;
static void onint(int s) { (void)s; IN = 1; } static void onint(int s) { (void)s; IN = 1; }
/* readline calls this from its input loop, in a safe context, after it has
dealt with a signal caught while it was reading a line. A Ctrl-C only
redraws the prompt: the half-typed text stays in the buffer, so clear it
here, the way bash does. The flag is left set on purpose; the prompt loop
clears it, so a Ctrl-C during a command still aborts the turn, and one
during a multi-line task still abandons it. */
static int ci_hook(void) {
if(IN) { rl_replace_line("", 1); rl_redisplay(); }
return 0;
}
static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, *SELF, SH[256] = "/bin/sh"; static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, *SELF, SH[256] = "/bin/sh";
static int TO = 120, QUIT, CLR = 1; static int TO = 120, QUIT, CLR = 1;
@@ -402,6 +412,7 @@ int main(int argc, char **argv) {
if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash"; if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash";
snprintf(SH, sizeof SH, "%s", e); snprintf(SH, sizeof SH, "%s", e);
signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN); signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN);
rl_signal_event_hook = ci_hook;
create(); load(); create(); load();
if((e = getenv("SOLO_MODEL")) && *e) { setstr(&MOD, e); A(SSN, "model", S(e)); } if((e = getenv("SOLO_MODEL")) && *e) { setstr(&MOD, e); A(SSN, "model", S(e)); }
if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); } if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); }
@@ -416,8 +427,9 @@ int main(int argc, char **argv) {
free(task); free(task);
while(!QUIT) { while(!QUIT) {
snprintf(pb, sizeof pb, "\001%s\002> \001%s\002", CLR ? BLD : "", CLR ? OF : ""); snprintf(pb, sizeof pb, "\001%s\002> \001%s\002", CLR ? BLD : "", CLR ? OF : "");
IN = 0; /* a Ctrl-C during a command must not reach the line about to be typed */
if(!(ln = readline(pb))) { puts(""); break; } if(!(ln = readline(pb))) { puts(""); break; }
if(IN) { IN = 0; free(ln); continue; } IN = 0; /* a Ctrl-C during the line only cleared that line, not this turn */
ac = ln; ac = ln;
for(;;) { for(;;) {
size_t l = strlen(ac); size_t l = strlen(ac);
+65
View File
@@ -119,6 +119,71 @@ LEFT=$(ps -o args= 2>/dev/null | grep -c '^sleep 41$')
[ "$LEFT" -eq 0 ] [ "$LEFT" -eq 0 ]
ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left" ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left"
# --- Ctrl-C must not eat the next line --------------------------------
# readline only redraws the prompt on Ctrl-C: the half-typed text
# stays in its buffer and the interrupt flag outlives the prompt.
# Both used to swallow the next command silently. This needs a real
# tty, so the agent runs on a pty driven by a small helper.
D=$WORK/ctrlc
cat >"$WORK/ptyrun.c" <<'PTY_EOF'
/* Drive PROG on a pty from a script on stdin. A line "INT" sends
SIGINT, a line "RAW text" writes text without a newline, and any
other line is written with a newline appended. A short pause
follows every step. Everything PROG prints is relayed to stdout. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <pty.h>
int main(int argc, char **argv) {
int m, st; pid_t k; char buf[4096], line[4096]; struct timeval tv; fd_set fs;
if(argc < 2) { fprintf(stderr, "usage: ptyrun PROG [ARG...] < script\n"); return 2; }
k = forkpty(&m, 0, 0, 0);
if(k < 0) { perror("forkpty"); return 2; }
if(!k) { execvp(argv[1], argv + 1); _exit(127); }
fcntl(m, F_SETFL, O_NONBLOCK);
while(fgets(line, sizeof line, stdin)) {
size_t l = strlen(line);
while(l && (line[l - 1] == '\n' || line[l - 1] == '\r')) line[--l] = 0;
usleep(400000);
if(!strcmp(line, "INT")) { kill(k, SIGINT); continue; }
if(!strncmp(line, "RAW ", 4)) { if(write(m, line + 4, strlen(line + 4)) < 0) break; continue; }
if(write(m, line, strlen(line)) < 0 || write(m, "\n", 1) < 0) break;
}
for(;;) {
FD_ZERO(&fs); FD_SET(m, &fs); tv.tv_sec = 10; tv.tv_usec = 0;
if(select(m + 1, &fs, 0, 0, &tv) <= 0) break;
st = read(m, buf, sizeof buf);
if(st <= 0) break;
if(write(1, buf, st) < 0) break;
}
kill(k, SIGKILL);
waitpid(k, &st, 0);
return 0;
}
PTY_EOF
# forkpty lives in libc since glibc 2.34; older systems need -lutil.
cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" 2>/dev/null \
|| cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" -lutil 2>/dev/null
[ -x "$WORK/ptyrun" ] || { echo "test: the pty helper did not build" >&2; exit 1; }
mkdir -p "$D"
( cd "$D" && printf 'INT\n!echo PTY_CI\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
>"$WORK/ctrlc.out" 2>&1
grep -q -- '--- \$ echo PTY_CI' "$WORK/ctrlc.out"
ok $? 'a command typed after Ctrl-C at the prompt still runs'
( cd "$D" && printf 'RAW echo partial\nINT\n!echo PTY_FRESH\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
>"$WORK/ctrlc2.out" 2>&1
grep -q -- '--- \$ echo PTY_FRESH' "$WORK/ctrlc2.out"
ok $? 'a command typed after Ctrl-C mid-line still runs'
if grep -q 'partialPTY_FRESH\|partial!echo' "$WORK/ctrlc2.out"; then rc=1; else rc=0; fi
ok "$rc" 'Ctrl-C mid-line clears the half-typed text'
# --- the tool description must quote the real timeout ------------------------- # --- the tool description must quote the real timeout -------------------------
D=$WORK/desc D=$WORK/desc
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1 printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1