Skip to content

Commit 44ba84f

Browse files
authored
Merge pull request #180 from Univers42/fix/rl-saved-line-leak
fix(editor): free the two undo lists readline 8.1 drops on the floor
2 parents 985047b + 79fa12d commit 44ba84f

7 files changed

Lines changed: 172 additions & 2 deletions

File tree

‎incs/history.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ void hist_rehome(t_shell *state);
5454
keeps on an edited entry (history_rl.c). Never call the raw ones. */
5555
void hist_rl_drop(int idx);
5656
void hist_rl_clear(void);
57+
void hist_rl_reclaim_saved(void);
5758
void init_history(t_shell *state);
5859
void free_hist(t_shell *state);
5960
void parse_history_file(t_shell *state);

‎src/infrastructure/history_rl.c‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,31 @@ void hist_rl_drop(int idx)
6161
undo_list_free((UNDO_LIST *)free_history_entry(e));
6262
}
6363

64+
/* Before each read: free the undo list readline parked with its saved
65+
** copy of the typed line.
66+
**
67+
** Walking the history from a typed line (previous-history: Ctrl-P, or Up
68+
** with prefix search off) makes readline save that line, with its undo
69+
** list as the entry's data, so Down can bring it back. When the line ends
70+
** elsewhere -- Enter or ^C on a recalled entry -- the saved copy outlives
71+
** the read, and the next readline() frees it without the undo list:
72+
** readline 8.1 (Ubuntu 22.04's) leaked the typed text's undo records once
73+
** per such line, which ASan reported at exit.
74+
** rl_maybe_unsave_line() is the public way to take that list back into
75+
** rl_undo_list, and rl_free_undo_list() frees it as readline does, also
76+
** dropping any history entry still pointing at it. Unsave rings the bell
77+
** when nothing is saved, so the save before it makes sure something is.
78+
** Both rewrite rl_line_buffer, which is harmless here: the read that
79+
** follows starts from an empty line. */
80+
void hist_rl_reclaim_saved(void)
81+
{
82+
if (!rl_line_buffer)
83+
return ;
84+
rl_maybe_save_line();
85+
rl_maybe_unsave_line();
86+
rl_free_undo_list();
87+
}
88+
6489
/* clear_history(), freeing the undo lists it cannot. */
6590
void hist_rl_clear(void)
6691
{

‎src/infrastructure/rl_private.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ int rl_shell_leave(t_shell *state, t_rl_bracket *b);
165165
void rl_prerow_arm(t_string *rows);
166166
FILE *rl_out_open(void);
167167
int rl_getc_hook(FILE *stream);
168+
int rl_nsearch_reclaim(int c);
168169
int rl_idle_timeout(void);
169170
int rl_idle_fd(void);
170171
void rl_idle_event(int r);

‎src/platform/posix/rl_getc.c‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ int rl_getc_hook(FILE *stream)
128128
{
129129
rl_take_signals();
130130
if (rl_should_abort())
131-
return (rl_abort_value());
131+
return (rl_nsearch_reclaim(rl_abort_value()));
132132
rl_block(&old);
133133
r = -1;
134134
if (!rl_pending_signal() && !rl_should_abort())
@@ -141,5 +141,5 @@ int rl_getc_hook(FILE *stream)
141141
}
142142
if (r == '\r' || r == '\n')
143143
rl_suggest_unpaint();
144-
return (r);
144+
return (rl_nsearch_reclaim(r));
145145
}

‎src/platform/posix/rl_inproc.c‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ int rl_read_inproc(t_shell *state, char *prompt)
5858
char *row;
5959
char *line;
6060

61+
hist_rl_reclaim_saved();
6162
row = rl_editor_enter(state, prompt);
6263
line = readline(row);
6364
rl_editor_exit(state);

‎src/platform/posix/rl_nsearch.c‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* ************************************************************************** */
2+
/* */
3+
/* ::: :::::::: */
4+
/* rl_nsearch.c :+: :+: :+: */
5+
/* +:+ +:+ +:+ */
6+
/* By: dlesieur <dlesieur@student.42.fr> +#+ +:+ +#+ */
7+
/* +#+#+#+#+#+ +#+ */
8+
/* Created: 2026/10/18 03:40:00 by dlesieur #+# #+# */
9+
/* Updated: 2026/10/18 03:40:00 by dlesieur ### ########.fr */
10+
/* */
11+
/* ************************************************************************** */
12+
13+
#include "rl_private.h"
14+
15+
/* The byte (or EOF / READERR) the reader is about to hand readline, after
16+
** freeing the search string's undo list when that byte ends a
17+
** non-incremental search (Alt-N / Alt-P, stock) without searching.
18+
**
19+
** The search reads its string into the line buffer with an undo list of
20+
** its own: it saves the line, sets rl_undo_list to NULL and inserts. On
21+
** Enter it frees that list before searching; on abort it puts the saved
22+
** line back, overwriting rl_undo_list, and readline 8.1 (Ubuntu 22.04's)
23+
** loses the list -- a leak per aborted search, reported by ASan at exit.
24+
** Freeing it here, before readline sees the key, leaves nothing to lose,
25+
** and a readline that frees it itself finds NULL.
26+
**
27+
** Caveat: the abort keys are readline 8.1's (search.c,
28+
** _rl_nsearch_dispatch): ^G, ^C, Rubout or ^H on an empty string, and a
29+
** failed read. A key a later readline adds to that list leaks again; a
30+
** key it drops loses only the undo history of the search string. */
31+
int rl_nsearch_reclaim(int c)
32+
{
33+
if (!RL_ISSTATE(RL_STATE_NSEARCH))
34+
return (c);
35+
if (c == '\a' || c == 3 || c < 0
36+
|| ((c == '\b' || c == 127) && rl_point == 0))
37+
rl_free_undo_list();
38+
return (c);
39+
}

‎tests/rl_undo_leak_test.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""No undo list leaks out of readline's history walk or its aborted search.
3+
4+
Two places where readline 8.1 (Ubuntu 22.04's) drops an undo list it owns,
5+
each once per line, reported by LeakSanitizer when the shell exits:
6+
7+
saved line type, then walk the history with previous-history (Ctrl-P, or
8+
Up with prefix search off) and end the line on the recalled
9+
entry: Enter, ^C or ^G. The next read freed readline's saved
10+
copy of the typed line, but not its undo list.
11+
Fixed by hist_rl_reclaim_saved() (history_rl.c).
12+
search Alt-P / Alt-N (stock non-incremental search), type a string,
13+
abort with ^G or Rubout on an empty string: readline restored
14+
the line over the search string's undo list.
15+
Fixed by rl_nsearch_reclaim() (rl_nsearch.c).
16+
17+
Each case is a fresh shell (in-process reader: the forked one discards its
18+
heap with the child) run with detect_leaks=1; it must exit without a
19+
report. The `works/` cases check the keys still do their job. On a binary
20+
built without ASan nothing can be measured: SKIP, not pass.
21+
"""
22+
import os
23+
import sys
24+
import time
25+
26+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
27+
"helpers"))
28+
from ptyexpect import Tty # noqa: E402
29+
30+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31+
SHELL = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else
32+
os.path.join(ROOT, "build", "bin", "hellish"))
33+
FAILS = []
34+
UP, DOWN = "\x1b[A", "\x1b[B"
35+
ALT_P, ALT_N = "\x1bp", "\x1bn"
36+
HISTORY = ["echo alpha", "echo beta"]
37+
38+
39+
def check(name, ok, detail=""):
40+
print(("ok " if ok else "FAIL ") + name)
41+
if not ok:
42+
FAILS.append(name)
43+
print(" " + detail)
44+
45+
46+
def session(name, steps, want=None):
47+
"""A shell with HISTORY run, then each key group of `steps` sent and
48+
given time to land, then exit. No leak report allowed; `want`, when
49+
given, must be printed by the line the keys ran."""
50+
t = Tty(SHELL, rc="PS1='P$ '\nHISTFILE=/dev/null\n",
51+
env={"ASAN_OPTIONS": "detect_leaks=1",
52+
"HELLISH_HISTORY_PREFIX": "0"})
53+
ok = t.expect(b"P$ ")
54+
for c in HISTORY:
55+
t.send(c + "\r")
56+
ok = ok and t.expect(b"P$ ")
57+
mark = len(t.out)
58+
for keys in steps:
59+
t.send(keys)
60+
t._read(0.25)
61+
if want is not None:
62+
ok = ok and t.expect(b"\n" + want.encode() + b"\r\n")
63+
t.send("\x03")
64+
ok = ok and t.expect(b"P$ ")
65+
t.send("exit\r")
66+
# LeakSanitizer reports as the shell exits: read until the pty closes,
67+
# so the report is in t.out before anything is decided.
68+
end = time.time() + 10
69+
while t._read(0.2) and time.time() < end:
70+
pass
71+
st = t.close()
72+
leak = b"LeakSanitizer" in t.out
73+
check(name, ok and not leak and os.WIFEXITED(st),
74+
repr(t.since(mark)[-400:]))
75+
76+
77+
def main():
78+
with open(SHELL, "rb") as f:
79+
if b"AddressSanitizer" not in f.read():
80+
print("SKIP %s has no ASan: leaks cannot be seen" % SHELL)
81+
return 0
82+
session("saved/up-then-enter", ["echo", UP, "\r"])
83+
session("saved/up-then-ctrl-c", ["echo", UP, "\x03"])
84+
session("saved/up-then-ctrl-g", ["ec", UP, "\x07"])
85+
session("saved/ctrl-p-then-enter", ["echo", "\x10", "\r"])
86+
session("saved/ctrl-p-walk-back-and-forth",
87+
["echo", "\x10", "\x10", "\x0e", "\x10", "\r"])
88+
session("search/alt-p-abort-ctrl-g", [ALT_P, "ech", "\x07"])
89+
session("search/alt-n-abort-ctrl-g", [ALT_N, "ech", "\x07"])
90+
session("search/alt-p-rubout-past-empty", [ALT_P, "e", "\x7f", "\x7f"])
91+
session("search/typed-line-then-abort", ["ls", ALT_P, "ech", "\x07"])
92+
session("search/typed-then-alt-p-enter", ["ech", ALT_P, "\r"])
93+
session("works/up-down-brings-the-typed-line-back",
94+
["echo typed", UP, DOWN, "\r"], want="typed")
95+
session("works/alt-p-search-runs-the-match",
96+
[ALT_P, "alph", "\r", "\r"], want="alpha")
97+
session("works/aborted-search-restores-the-line",
98+
["echo kept", ALT_P, "x", "\x07", "\r"], want="kept")
99+
print("%d failed" % len(FAILS))
100+
return 1 if FAILS else 0
101+
102+
103+
sys.exit(main())

0 commit comments

Comments
 (0)