Living tracker for the "fastest and most complete shell" program. Every item
lands only with: make test green (2878 golden cases), tests/hard 16/16,
tests/scripts diffs clean vs bash --posix, verify_alloc.sh green on both
heaps, and the make conformance baseline gate not regressing (Oils / mksh
pass counts in bench/baseline/). Performance claims come from
make perf / hyperfine on the OPT build only.
-
"$VAR"reaches the expander fast path: −19.5% loop instructions (callgrind, loop_arith: 1,266.9M → 1,019.2M Ir).try_simple_envvar(src/expander/expand_word2.c) gated ont->tt != TT_ENVVAR, so the DOUBLE-QUOTED form fell through every fast path and paidclone_astplus the wholeexpand_word_glob_ctlpipeline — ~2.5k instructions to read one variable, on what is the most common construct in real POSIX scripts. Accepting TT_DQENVVAR alongside TT_ENVVAR is safe because the reparser emits exactly one subtoken for"$v"and both types carry the bare name in start/len. The existing guards are what make it sound, and they must not be relaxed casually:!*vroutes unset/empty to the slow path (keeping"$empty"as ONE empty field rather than vanishing),name_is_plainrejects$@/$*/${a[@]}so aggregates still split, andneeds_split_or_globcatches the rest. What is left is exactly the set where quoted and unquoted expansion are byte-identical. Found by a profile-driven fan-out; the claim was adversarially re-verified before landing. Gates: 3016/3016 golden, plus 12 targeted quoting cases (empty/unset → one field,"$v"with spaces → one field,$vwith spaces → three, custom IFS both ways, quoted glob char,"$@"and its iteration form). - Hot-path predicate ordering: −33% parse instructions (callgrind,
parse50k: 573.5M → 383.5M Ir, 1.50x less work). Two instances of the
same bug — an expensive test placed before the cheap discriminating
one — found by profiling, not reading:
1.
hazard_at(src/infrastructure/rl_multi_line2.c) ranft_strncmp(...,"alias",5)and...,"source",6on every byte of input: ~4M libc strncmp calls on a 2MB script, 17.4% of all instructions retired. Gating each ons[i]=='a'/'s'is exact, not heuristic (strncmp can only match where the first byte does), and cut strncmp 99.8M → 10.0M Ir andbuff_readline92.7M → 35.1M. 2.reparse_dquoteevaluatedgetenv("HELLISH_DBG_DQ")before a one-byte test, once per double-quoted word — 34.6k linearenvironwalks per parse, 5% of parse time to prove a debug flag was unset. Pure operand swap; both operands are side-effect-free. Gates: 3016/3016 golden, 213/213 regress_hellish incl. 6 new hazard cases (words merely CONTAINING alias/source, dot-source, heredoc bodies, backslash-newline,$LINENO),40_batch_semantics.shbyte-identical tobash --posix. Lesson worth keeping:perf(1)is unusable here (perf_event_paranoid=4), but callgrind counts instructions, which are deterministic — so on a noisypowersavebox it is strictly better than wall-clock for finding where the work is. - Batched non-interactive input delivery — parse50k 705ms → ~200ms.
Hazard fallback (alias/source/heredoc/backslash-newline), newline as
top-level list operator, per-range heredoc gathering, failure replay
(rewind + line-exact rerun so pre-error commands still run), exact
$LINENOfrom token offsets. Regression net:tests/scripts/40_batch_semantics.sh. Commit74ac593. - ft_malloc morecore batching (nested submodule): small-bin refills
went from one 4KB mmap per block to 64KB batches; 23.7k → 1.5k mmaps
on parse50k. ft_malloc
045e360, libft bumpb5550ed0. - Parse arena (
src/helpers/parse_arena*.c,incs/parena.h): chunked bump allocator for cycle-lifetime parse objects (AST children buffers viaast_push_child, full_word copies), gated ON only around the main-cycle parse_tokens so eval/source/cmdsub keep heap discipline;parena_freerouting makes every teardown path safe for both arena and heap trees. Also fixed latent raw-xfree bombs in expander_split / assignment_word_to_word / split_envvar2 / process_word_token2. RSS for parse50k capped (~160MB), allocation churn removed. - Plain-word reparse fast path: words with no quote/expansion/glob chars skip the reparse rebuild entirely (~2/3 of real-script words). Gotcha learned: plain subtokens keep split_eligible FALSE (only cmdsub output splits) and full_word NULL.
- Arena + fast path committed (
5bb80e2), gate green (Oils 997 / mksh 184 unchanged). -
./configureCOMPLETES under hellish (ebeb50e) — exit 0, config.status + Makefile generated. Four bugs fixed, each with a repro in tests/regress_hellish: heredoc-body text corrupting the cycle-completeness quote state (tokenize the stripped text), backticks inside dquotes not scanned atomically by the lexer, quote-blind $() paren scans in reparser AND expander (shared sh_skip_quoted), for-without-in iterating live positionals instead of a POSIX entry snapshot (SEGV under config.status's shift).
- Configure-fixes gate GREEN and IMPROVED: Oils 997→998, mksh 184→186.
- word-split/IFS cluster fixed: unquoted
$@/$ * now defer to the splitter (one field per positional, each IFS-split — never joined, so IFS='' keeps positionals separate); trailing IFS separators close the field (IFS=:; ${w}:b splits at the boundary); deferred positionals are recognised by pos_mark() POINTER identity, fixing the v=@; echo "$v" collision; ${@-M}/${@+P} operator forms share the same sentinel path. split_envvar refactored onto one split_value core with uniform lead/trail rules for whitespace and non-whitespace IFS. 8 new regress_hellish cases.
- IFS gate GREEN: Oils 991 → 1007, mksh 184 → 187, baseline
accepted (
bc4d0c9). Session total: +16 Oils, +3 mksh, configure complete, parse 3.4× faster. - bench/KNOWN_ISSUES.md rewritten — configure saga documented as resolved, 64B ASan-configure leak tracked as the open remainder.
- Job control LIVE (
2a7c5c1): background launches register in the job table; jobs/fg/bg/kill %1 work for the first time. Report lifecycle matches bash EMPIRICALLY (7 probes, all MATCH): script-silent notify, jobs report-once-keep-status, wait purges + forgets (re-wait = 127), ring-miss falls back to the table (jobs' own WNOHANG poll used to eat statuses). job_print byte-identical to bash (27-pad + trailing " &" on running bg jobs). 6 regress cases. hard/12 was broken mid-flight by script-mode notices — the INP_RL gate in job_notify is what fixed it. - Prompt v3 — prettier AND faster (committed after
2a7c5c1): truecolor palette + ember gradient fill (COLORTERM-gated, 256 fallback preserved), dim-parents/bold-last cwd, @host over SSH, and the dirty*RESURRECTED from dead code (real git status fork, 3s TTL cache, one subprocess max). repo_locate cwd-keyed root cache: deep-dir render 44us -> 6.5us; repo root 4.4us steady-state. HELLISH_PROMPT_BENCH=1 is the measuring stick. Battery: 2901 golden, 16/16 hard, 40/40 scripts, both heaps.
- Conformance gate for
2a7c5c1+prompt running (expect possible Oilsbackgroundcluster gains). Norminette NOT INSTALLED on this host — norm gate unverified since the audit commits; runmake normwherever norminette exists before the next PR. -
make perf BENCH_LAX=1rerun — first run where the configure dimension can TIME hellish (completion gate now passes). Commit bench/results.md when it lands. - Job polish: bash prints "Terminated"/"Killed" per signal where we print "Done" (interactive-only path, no golden coverage); wait %jobspec; wait -n.
- Prompt v2 committed (
af2aade) + first timed-configure scoreboard (hellish 19.8s vs bash 7.7s / dash 6.8s — completes, 2.6× gap to close, fork-path dominated). - Audit quick wins committed (
daf5a99): $RANDOM (pid^time-seeded PRNG), $SECONDS/$EPOCHSECONDS/$UID/$HOSTNAME/$OSTYPE, times format fix,. file argspositional binding. Gate pending.
- PS1/PS2 user theming — the prompt is configurable from
~/.hellishrc (already sourced at interactive startup): PS1/PS2
with the bash escape set (\u \h \H \w \W $ \n \t \d \e \a \
\j \s \v [ ]) plus live
$NAME/$ {NAME} expansion; the rich built-in prompt is now just the default theme when PS1 is unset. prompt_ps1.c/prompt_ps1b.c. Pty-test gotcha recorded: script(1) uses $SHELL and bash-family shells strip PS1 from the exported env — test with SHELL=/bin/sh. - PROMPT_COMMAND, \D{fmt}, ! # history escapes, promptvars cmdsub — natural PS1 follow-ups, small.
- $'…' ANSI-C quoting (full bash escape set incl \uHHHH/\U/\cX, atomic lexing with live ' escapes, TT_SQWORD decode in the reparser, "$'…'" stays literal). 10/10 bash probes.
- wait %jobspec + multi-arg + bash error semantics — wait %1 used to HANG (atoi("%1")=0 → waitpid on the whole group).
- [[ a < b ]] string comparison (was silently a REDIRECT).
- set -e AND-OR suppression reaches subtrees — f(){ false; }; f || true no longer exits from inside the body. 7/7 probes.
- 12 regression lines added (tests/regress_hellish, 2913 golden total).
- Indexed arrays: arr=(a b c), arr+=(d), arr[i]=v (arith
subscripts, sparse, scalar promotion),
$arr, $ {arr[i]}, ${arr[@]}/${arr[*]} quoted+unquoted with exact field semantics, ${#arr[@]}, ${#arr[i]}, bash-style set-listing, never exported. 16/17 bash probes. Storage = encoded ordinary env value (magic + idx/US/val records) → ZERO env-lifecycle changes; [@] defers via a name-carrying marker registry mirroring pos_mark; splitter has exact analogues of the positional emitters. - Array follow-ups: element-level split/glob in arr=(...) (the one probe divergence), slices ${a[@]:1:2}, ${!a[@]}, unset a[i], read -a, mapfile, declare -a, PIPESTATUS, associative arrays.
- <<< herestrings (7/7 probes, incl 0<<< fd form; pipe backing + unlinked-tmpfile fallback; TT_HERESTRING keeps the heredoc machinery clean; hang root-caused to get_default_src_fd).
- PIPESTATUS array after every pipeline (all stages now waited synchronously like bash) + single-command form.
- unset a[i] (arith subscript; variable survives as empty array).
- read -a NAME / read -p PROMPT (value-taking option parser; -rp combos work).
- Array gate ACCEPTED: Oils 1029 → 1047 (+18, campaign record), mksh 191 → 193.
- FOUND pre-existing:
exitin the LAST pipeline stage runs in-parent and kills the shell (exit 3 | exit 5; echo xnever echoes; bash contains it). Pipeline builtin containment fix.
- [[ =~ ]] with BASH_REMATCH (POSIX ERE; the lexer grew a regex-word mode — the word after =~ is ONE token so parens survive; parked-pointer cell threads state to the evaluator). 7/7 probes incl multi-group captures.
- Pipeline containment fixed: stages never run in-parent
(was zsh-lastpipe style —
true | exit 7killed the shell,echo x | read vleaked v). Inside a forked compound the flag flips back so| while readloops work exactly like bash. - Batch-2 gate accepted: Oils 1047 → 1057, mksh 193 → 195.
- Session running total: Oils 1029 → 1057+, mksh 191 → 195+.
Landed toward flipping hellish as a login shell (all probe-verified vs bash, full battery green each step, both heaps leak-free):
- type -t/-p/-P, wait -n, set rejects unknown flags fatally.
- Array completion: ${a[@]:off:len} slices, ${!a[@]} keys,
${a[-1]} negative index, $ {a[@]/p/r} & ${a[@]#p} element-wise ops, mapfile/ readarray -t, declare/typeset -p/-a, local/declare a=(...) arrays. - shopt -s/-u/-q (nullglob+dotglob wired into the glob path).
- ${v^^}/${v,,}/${v~} case conversion, ${v@Q}/@U/@L/@u/@A transforms.
- printf -v NAME (with bash name validation), [[ -v NAME ]], [[ =~ ]] with BASH_REMATCH.
- Pipeline containment fixed (true|exit no longer kills the shell; echo x|read v no longer leaks) — the one shell-KILLING bug.
- $'...', <<< herestrings, PIPESTATUS, unset a[i], read -a/-p, wait %jobspec, [[ a<b ]], errexit and-or suppression, var-sub-quote.
- BASH_VERSION/BASH_VERSINFO set so scripts take their bash path.
- CONFORMANCE this session: Oils 1029 -> 1060 (+31), mksh 191 -> 201 (+10). PARSE PERF: 53.8ms vs bash 54.3ms (nominally faster, ~13% less CPU), 20MB RSS.
- Known scope-outs (v1, documented, low-frequency):
${a[@]:-word} (array + default op), mixed-quote op-words a "b c" d, element-level split/glob in arr=(...), slice per-element field structure, $ {a[@]^^},${!prefix*}, associative arrays, declare -i arithmetic attribute, $ {@:0} including $0. - REMAINING for full parity (queued, none architectural): associative arrays (declare -A), ${a[@]:-w}, the pull-lexer to decisively beat bash on parse, PROMPT_COMMAND, trap DEBUG/RETURN/ERR, coproc.
- Associative arrays (declare -A): string keys, h[k]=v, h[$var]=v,
${h[k]}, ${h[$k]}, ${!h[@]} keys, ${h[@]} values,
${#h[@]}, unset h[k], declare -p h, for k in "$ {!h[@]}". Attribute in the value encoding (distinct magic byte) — no attribute table. Keys deferred as fields via a "!"-marker. - ${a[i]OP}/${h[k]OP} single-element operators (:- := # % / …) via scratch-var reuse — the ${cnt[$w]:-0} counting idiom works.
- NAME[$var]=v / NAME[$((e))]=v assignments (pre-pass classifies the raw word before reparse splits the subscript) — fixed for indexed AND assoc (was pre-existing).
- a=() empty array literal (is_function_def no longer eats a word ending in "=").
-
${array[x]} inside $ ((...)) (routed through full expand_token). - ${!var} indirection, PROMPT_COMMAND, scalar += append (n+=3, s+=world).
- Scope-outs remaining (v1, low-freq): declare -i integer attribute (use n=$((...))), compound assoc init declare -A c=([k]=v), bare c[x] in arith (use ${c[x]}), a[i]+= element append, ${!prefix*}, printf %(fmt)T, coproc, trap DEBUG/RETURN/ERR, nameref (declare -n).
- declare -i / declare -n (per-var attribute table, env_attr.c;
state->var_attrs, empty ⇒ hot paths short-circuit). -i arith-evaluates
every assignment (n="3*4"→12, n+=5→old+5); -n retargets read
(env_expand_n) and write (apply_var_attrs). Commit
31f2f65. - Compound array init [k]=v (
4756c1e, expand_array_assign2/3.c): declare -A c=([k]=v [k2]=v2) assoc; a=([2]=x foo [7]=y) indexed with arithmetic subscripts + running counter; plain a=(x y z) unchanged. Assoc declare -p prints bash's trailing space before ')'. - trap DEBUG/RETURN/ERR (
60f4583, exec_traps{,2}.c): slots above the signals, trap_depth re-entrancy guard. DEBUG before each simple command + for-iter bind + case; ERR in errexit_check (decoupled from -e); RETURN on function return. Not inherited by functions/subshells (save/blank across calls) — matches bash w/o functrace/errtrace. Deep nested-leak firing NOT matched (rare). - coproc (
10b9cc5, TT_COPROC/AST_COPROC, parse_coproc.c, execute_coproc.c): all 3 forms; NAME[0]/[1] fds (>fd9, CLOEXEC via save_fd) + NAME_PID, backgrounded own-pgid child. Reclassify keeps the token aftercoproc NAMEin cmd position so{opens a compound. - Suite 3012 green, both-heap parity, ASan clean, regress_hellish covers all.
Profiled with strace -c + hyperfine User/System split (perf/ptrace locked). Finding: hellish's parse USER-CPU already beats bash (47 vs 53ms); the whole gap was SYSTEM time (page faults from 4x RSS). Two fixes, both green (3016 tests, both heaps, ASan, Oils 1069 / mksh 210):
- 64KB file-read buffer (libft
b7666008, bumpb477496): script slurp was 1766 read() calls (1KB buffer in vec_append_fd) vs bash's 225; now ~34. Flipped hellish ahead of bash on wall-clock. - Lean 16B deque token (
2989d00): the raw token deque stored full 32B t_tokens, but full_word/arith_cache are NULL there (attached later on AST nodes / at exec). New t_ltoken (16B prefix) + ltok2tok(); 3 deque_init sites use sizeof(t_ltoken), all 87 deque casts routed through t_ltoken (compiler-verified). parse50k RSS 19.9->15.1MB (-24%), system 5.8->4.5ms. - pull-lexer verdict: it's a MEMORY lever (user-CPU already wins), not worth a from-scratch rewrite; ft_lexer/ft_yacc rejected (LALR slower on CPU than the tuned RD; shell grammar hostile to yacc). Remaining RSS over bash: two whole-file copies (input + alias_exp, ~3.4MB) and the full-file token deque still materialized (~4.75MB) — only a pull-lexer eliminates that.
hellish is a drop-in bash replacement for interactive use and the vast majority of real scripts: arrays (indexed + associative), all common parameter expansions, [[ =~ ]], herestrings, PIPESTATUS, job control, process subs, config-themed animated prompt — at bash-parity parse speed (56ms vs 54ms) with 20MB RSS. Oils 1029 -> 1065+, mksh 191 -> 201+ this session. The remaining gaps are the documented low-frequency tail.
-
Job control inert:
job_add(src/job_control/job_table.c:38) has ZERO callers — jobs/fg/bg/kill %1/wait %1 all dead despite complete plumbing (jobspec parser, table, builtins registered). Wire it in the background-launch path (execute_range_background / exe_bg). Also the Oilsbackgroundcluster (3+ cases) depends on this. - Quick potent fixes:
timesprints its literal format string;$RANDOMprng exists in t_shell but never wired to expansion;$SECONDS/$EPOCHSECONDSmissing;$UID/$HOSTNAME/$OSTYPEunset at init;. file argsdoesn't bind $1..$N (bash/ksh/zsh do — Oils builtin-eval-source cluster);$0/$-empty under -c;read -ttimeout rc=1 (bash >128);read -p/-n/-dsilently no-op (worse than erroring);type -a/-t/-Pmissing; README claims adirsbuiltin that does not exist (stale doc). -
$'…'ANSI-C quoting — silent literal passthrough today; everywhere in real scripts. Lexer+expander feature. -
<<<herestring (+|&,;&,;;&,functionkeyword) — parser features, herestring can reuse heredoc backing. -
[[ ]]is plain test: no=~, and</>become REDIRECTIONS (silently wrong). At minimum make</>compare and error on=~. - Arrays (indexed first) — the biggest script-compat wall; also blocks PIPESTATUS/BASH_REMATCH/read -a/mapfile. Large project.
-
declare/typeset+shopt(globstar/extglob/nullglob/autocd gates) — large. - PS1/PROMPT_COMMAND/HISTCONTROL/HISTIGNORE — personalization; the rich prompt should fall back to PS1 when set.
-
set -e; f(){ false; }; f || truestill exits — errexit suppression around function calls (Oils errexit cluster). - Unknown
setflags silently accepted rc=0 (-m -b -H -k -E -T…) — should at least not lie.
- NEXT CLUSTER picked: var-sub-quote (10 cases) — diagnosis in task list: ${x:-"c d"} flattens op-word quoting; needs segment-aware default emission (quoted segments split_eligible=false). After it: builtin-vars export semantics (8), assign/local scoping (7), builtin-meta command -v/-V/-p (6), fatal-errors (5).
- KNOWN ISSUE: one 64-byte realloc_to leak, only visible in a fully ASan-instrumented configure run (suites all leak-clean). Repro: debug build, CONFIG_SHELL=build/bin/hellish on the vendored hello configure, grep conf.log for LeakSanitizer. Chase with ASAN_OPTIONS=fast_unwind_on_malloc=0:malloc_context_size=20.
- Update bench/KNOWN_ISSUES.md (configure section is now history) + let make perf time the configure dimension (completion gate should now include hellish).
Both survived a skeptical reviewer whose brief was to refute them. Neither is
landed: each is medium-risk and needs coverage the current suite cannot give,
because every category in tests/tester runs through hellish -c.
- [~] dlopen readline instead of linking it — ~30% of startup.
HARNESS DONE (
make readline-test), payoff re-measured, surface mapped. The coverage gap that blocked this is CLOSED: tests/readline_paths_test.py drives a real pty over every readline entry point (line read+run, up-arrow recall, command/filename/ambiguous completion, vi<->emacs, emacs keys after the round trip, clean exit). It passes on the unmodified shell, so it is a valid before/after gate. Two traps it encodes: a pty ECHOES input (a literal marker "passes" against a dead shell — assert on an EXPANSION result), and completion output interleaves with prompt redraws (assert by EXECUTION). Re-measured payoff (callgrind, deterministic): a trivial C program costs 137,115 Ir; the same program linked -lreadline costs 430,920 Ir, so loading libreadline+libtinfo is 293,805 Ir — confirming the old 291k figure. hellish-c truestartup is 814,492 Ir (bash 1,208,806 — we already beat bash 33%; dash 268,563), so this is 36% of our startup and would land us at ~521k, roughly 1.9x dash instead of 3.0x. EXACT surface (fromnm -D+readelf -r, not guesswork) — 4 imported functions: readline, add_history, rl_completion_matches, rl_variable_bind; 7 R_X86_64_COPY data relocs: rl_outstream, rl_completion_append_character, rl_attempted_completion_function, rl_instream, rl_editing_mode, rl_point, rl_event_hook; plus rl_line_buffer, rl_readline_state and rl_filename_completion_function referenced in source. 10 files touch them: completion/{completion,complete_files,complete_commands2,complete_variables}.c, editing/{vi_mode,emacs_mode}.c, infrastructure/{rl,history,mascot_anim,mascot_redraw}.c. DESIGN THAT AVOIDS TOUCHING ALL 10 (do it this way): at_rl_dyntable of function+data pointers filled once by a lazy dlopen/dlsym, exposed as ONE accessorrl_dyn(), then a header that redirects the names —#define rl_editing_mode (*rl_dyn()->editing_mode),#define readline(p) rl_dyn_readline(p)etc. Swap#include <readline/readline.h>for that header in the 10 files and NO call site changes. Keep the readline headers included for types/RL_STATE_*; an unreferencedexternemits no relocation. Link becomes -ldl, not -lreadline. Norm: one accessor + two loader statics, not 14 accessors. The pointer cache is a file-static — allowed under the documented "readline is the painful exception" clause in CLAUDE.md; document it. REMAINING: write the shim, swap the includes, flip the link, add the HELLISH_STATIC_READLINE fallback, then gate onmake readline-test+make hist-test+make anim-test+make docker-test(4 distros) and re-measure startup Ir. Original notes below. - (original) dlopen readline instead of linking it — ~30% of startup.
hellish needs 3 shared objects at load time (libreadline, libtinfo,
libc); bash needs 2 (its readline is static), dash needs 1. Relocating
libreadline + libtinfo costs 291,493 Ir of the 829,882 Ir
-c truestartup, and a non-interactive run calls exactly zero readline entry points. Surface is small — 4 function symbols (readline,add_history,rl_completion_matches,rl_variable_bind); the real work is the ~10 R_X86_64_COPY relocations for readline globals (rl_attempted_completion_function,rl_editing_mode, …), each of which becomes a dlsym'd pointer deref, confined tosrc/editing/andsrc/completion/. Measured dead end:-z lazyis NOT the fix (427,753 vs 427,782 Ir — the cost is data relocations, not PLT stubs). Keep aHELLISH_STATIC_READLINEcompile-time fallback for musl/hardened loaders. Coverage gap: needsmake hist-test,make docker-testacross all four distros, and a NEW pty test for tab-completion and vi/emacs switching. Startup compounds at./configure's fork rate, so this is really a configure fix wearing a startup hat. - Stop forking twice per command substitution — ~12% of fork workloads.
x=$(/bin/true)spawns TWO processes where bash and dash spawn one (measured: clone=2000 vs 1000 vs 1000 over 1000 iterations): the subshell child forks again instead of exec'ing in place. Fix is to thread a "this process is a subshell body and this is its final command" bool throught_shell(no new global) fromfork_and_run_inproc/execute_subshelldown toexecute_cmd_bg, and reusecmdsub_fast2.c's eligibility scan for the conservative single-simple-command precondition. Hazard: exec-in-place is irreversible, so$(a; b),$(a && b),$(a &)and non-final pipeline stages must never take it or they silently lose the trailing work; DEBUG/ERR/EXIT traps must still fire. Whole-script diffs intests/scripts/andtests/hard/are what would catch a lost trailing statement.
Not pursued: the configure track proposed a quadratic-parser root cause
that the verifier refuted (0 of 5 findings survived) — do not re-open it
without a fresh profile.
-
word-split/IFS conformance cluster (~30 certified bugs, biggest
cluster). Isolated repros:
-
set -- a "b c"; IFS=""; printf "[%s]" $@→ must give[a][b c](empty IFS must NOT join positionals; hellish joins). -
IFS=:; word=a:; printf "[%s]" ${word}:b→ must give[a][:b](split at expansion/literal boundary; hellish gives[a:b]). Fix lives insrc/expander/ifs_split*.c+expander_split*.c. Related mksh families: IFS-*, single-quotes-in-braces (quoted patterns inside${v#'pat'}).
-
-
Full lexer/reparser fusion (the remaining parse-speed lever, target
bash parity ≤70ms on parse50k): emit typed subtokens during the main
lex instead of the 3-pass lex → reparse_words → reparse_assign_words
pipeline. Profile first with gprof
(
make OPT=1 CFLAGS='... -O2 -pg -g' LDFLAGS='-pg' OBJ_DIR=<scratch>; perf/ptrace are locked on this host). Current profile: reparse ~37%, ft_memcpy (push growth) ~17%, parser proper ~25%. - Teardown-walk elimination (blocked on a clean design): skip free_ast for fully-arena cycle trees. Blockers found: exec-time heap attachments to arena nodes (arith caches, heredoc bodies, expander token rewrites in assignment_word_to_word/process_word_token2) would leak — needs either defer-registries at every attach site (fragile) or arena-ing those attachments via parena_owns(node) checks.
-
Cmdsub
$(/bin/true)fork path — 0.86×/0.57× vs bash/dash; profile the fork+exec+pipe path (posix_spawn? vfork? close-range?). - 3-stage pipeline — 0.92× vs dash; same fork-path work.
-
Remaining Oils consensus divergences (168 at last count) by cluster:
assign/local scoping (~10), builtin-vars export semantics (~7),
builtin-meta
command -v/-V/-p(~6), fatal-errors expansion aborts (5), var-op-strip char-class (~5), sh-usage$0handling (3), xtrace PS4 (5). - mksh check.t divergences (55): alias-3..6, bksl-nl-ign family, exit-err family (errexit interactions), heredoc-quoting-subst.
-
make agnostic-bench— rerun the 8-shell docker matrix once parse lands; publish the scoreboard in bench/results.md. -
Feature-gap audit — agent review: what does hellish lack vs
bash/zsh/ksh for daily-driver use (HISTIGNORE? PROMPT_COMMAND?
arrays?
[[ =~ ]]regex? printf %(fmt)T? job specs %+/%-? RANDOM/ SECONDS? set -o pipefail — check present). File findings here.
Branch perf/pull-lexer (off develop; carries the 8B deque token). Goal: stop
materialising the whole file's token deque up front. Even after the 8-byte
token, a 50k-line parse holds ~310k slots × 8B ≈ 2.5MB resident that a
pull-lexer would cut to one range's worth (a few KB). parse50k would go
~11.9 → ~9.5MB.
THE DECISIVE CONSTRAINT (why this was deferred, read before starting): the deque's whole-file materialisation is COUPLED to the whole-file parse. To lex lazily AND stay CPU-neutral you must ALSO make the parser incremental. A simpler "re-lex + re-parse each range as it accumulates line-by-line" driver works but re-does O(n²) parse work inside every multi-line construct — and parse50k is all multi-line function bodies, so that is a ~7–14× parse-CPU regression, which violates the project's hard "faster than bash" requirement. So a correct pull-lexer is effectively a pull-parser too: a multi-week, load-bearing rewrite. Do NOT ship the simple CPU-regressing version.
Foundation already built on the branch: src/lexer/tokenizer2.c lex_line
— a resumable single-logical-line lexer (stops after a top-level newline,
appends to the deque WITHOUT clearing or pushing TT_END, offsets stay relative
to the whole base). skip_noise/tokenize_step were un-static'd in
tokenizer.c and declared in lexer.h. It compiles clean, suite green, but is
NOT wired (dead until the driver exists). Whole-file tokenizer() unchanged.
The exact seam to convert (verified via architecture map):
- PRIMARY:
update_prompt—src/infrastructure/input.c:92/:97callstokenizer(alias_exp.ctx, tt)over the ENTIRE buffer on the completeness pass; the final NULL-returning call leaves the whole-file deque thattry_parse_tokens→stream_trythen consumes. This is the materialisation. - The streaming driver
stream_try—src/infrastructure/input_stream.c:69already parses one range at a time (parse_tokens_range) but from a PRE-FILLED whole-file deque; it must instead REFILLttper range vialex_linebefore eachparse_tokens_range. - Range boundary is a PARSER decision, not a lexer one: top-level
TT_NEWLINEconsumed as a list operator withparser->streamset —src/parsing/simple_list.c:52-53(stream_more=true). A wholeif…fi/while…done/{…}spanning many newlines is ONE range (compound_list has no stream boundary check), so the lexer alone cannot find a range end without tracking keyword nesting (fragile: command-positionecho donevsdone).
Design that avoids the O(n²) trap (parser-driven continuation):
- Drop the whole-buffer completeness tokenize in
update_promptfor the stream-eligible case (preloaded + ring drained — exactlystream_eligible, input_stream.c:32). Completeness then falls out of per-range parsing hitting EOF, like bash: no up-front whole-file scan. stream_tryloop:lex_linethe next logical line(s) intott(append, keepbase = alias_exp.ctxconstant so offsets stay coherent), runparse_tokens_range. On RES_OK for a top-level range → execute, CLEAR the deque, advance. On RES_GETMOREINPUT with input remaining → the construct spans more lines: append the next line's tokens and RESUME the parse WITHOUT re-lexing/re-parsing from scratch — this is the hard part and needs the parser to (a) distinguish soft-EOF (more tokens coming) from hard-EOF (input exhausted), and (b) not destructively consume until the range is complete, or be re-entrant on the accumulated deque. On hard-EOF mid-construct → syntax error (stream_finishalready prints bash's "unexpected end of file").
Correctness mechanisms that MUST keep working (each verified):
- Completeness/continuation:
tokenizerreturns a continuation prompt on unterminated lexeme;looking_for(lexer.h:33). Per-range must still detect an unterminated quote/construct whose range boundary sits inside it. base+offset coherence (8B token): every slot'soffis relative tobase;ltok2tok(l, base)is called at PARSE time, sobase(==alias_exp.ctx, kept whole and alive) must still point at the buffer when the range parses. Per-range materialisation is fine because base never changes.- Heredocs: KEEP EXCLUDED initially.
stream_eligiblereturns false whenstate->cycle_has_hd(input_stream.c:34);split_heredocs(extract2.c:104) andgather_range_heredocs(execute_simple_list.c:92) assume the whole buffer. Incremental<<body extraction is a separate follow-up. - Failure replay (
try_replay_exactrl_helpers2.c:26): already BYPASSED on the stream path (ranges pre-execute). Keep it disabled while streaming; it rewindsrl.buffcursor bystate->input.lenand assumes one-batch-per-cycle. - $LINENO (
tok_lineno/note_cmd_linenoexec_lineno.c:47/90): maps a token pointer to a line vianl_count(base, tok-base)withbase = alias_exp.ctxandcycle_line0. Keepalias_expWHOLE (don't window it) so the offset→line count stays correct across ranges.
Also note (bonus targets the map surfaced): the whole file sits in rl.buff
(the ring, ~1.8MB) AND is mirrored into state->input (~1.8MB) per batch, on
top of the deque — so there are ~3 whole-file copies. For the stream path
(replay bypassed, ring drained) rl.buff could potentially be freed/shrunk
after the batch is consumed for another ~1.8MB — but the cursor/replay coupling
needs care. This is a smaller, orthogonal lever to the deque one.
Staged steps for the future session (each gated on 3016 tests + verify_alloc both heaps + conformance Oils/mksh unchanged):
- [done]
lex_lineresumable primitive + un-static helpers. - Make
parse_tokens_range/top-levelparse_simple_listre-entrant: accept an already-partially-consumed deque and a "soft-EOF vs hard-EOF" signal, so appending tokens and re-invoking continues rather than restarts. Prove with a unit harness that feeding tokens in chunks yields the SAME AST as one-shot. - Wire
stream_tryto refill vialex_lineper range (non-heredoc only); drop the whole-buffer completeness tokenize inupdate_promptfor stream-eligible cycles. Measure RSS (target ~9.5MB) AND parse50k user-CPU (must NOT regress vs the 8B-token baseline ~53ms — if it does, the driver is re-doing work; fix before landing). - Only then consider heredoc-cycle streaming and the
rl.buffdrain.
Rollback: perf/pull-lexer is isolated; if step 3 regresses CPU, revert to the
8B-token baseline (already merged to develop) — that state beats bash and is the
safe stopping point.
- parse50k 221ms → 72ms (bash 60ms same load — ratio 3.2× → 1.21×),
RSS 158MB → 22MB, in four committed rounds:
1.
35e4f56first-char operator dispatch (parse_op rebuilt a 21-entry table per call, ~60 libft calls each — libft.a has NO LTO objects, nothing ever inlines), 256-byte char-class table for is_special_char/is_word_boundary (1.4M ft_strchr calls), dbracket_toggle call-site guard (527k → ~0 calls), arena-pure teardown skip via parena attach-tracking (2.17M parena_free routes gone; ASan net caught the one impure site — procsub's parse-time strndup, now arena). 2.0002690reparse helpers operate in place (was: copy 104-byte node in + out per subtoken), table word_is_plain, shared arena full_word copy, deque presize for >4KB batches. 3.(streaming)— the big one: fully-buffered batches parse ONE newline-range at a time, execute, parena_reset, repeat. Peak memory = one range. Gated: ring drained + preloaded/EOF source, never interactive, no heredoc cycles (conservative). Replay skipped mid-stream (prefix already ran = what replay emulated). Bonus bash-parity: healthy prefix executes before an unexpected-EOF error. - Second wave — parse50k TIES bash (64.2 vs 63.2ms overlapping sigma; user CPU 55.6 vs 60.7 = hellish computes LESS): libft primitives → compiler builtins (glibc AVX; constant sizes inline), fat-LTO archive for the SAFE=0 tree ONLY (linker plugin disturbs LeakSanitizer on plain links — bisected via alias_posix), ft_strnstr memchr-skipping, t_token 40→32B (type in a byte), t_token_old 24→16B, MADV_HUGEPAGE on 2MB+ ft_malloc zones (inert on this fragmented host, free elsewhere). libft ea7a804a, ft_malloc dbc08f5 — both submodules need pushing.
- REMAINING to clearly BEAT bash (~4-5ms, all page-fault sys time): pull-based lexing (feed the parser tokens on demand instead of materialising the whole ~450k-token deque — bash's yylex model; kills most of the 20MB touched). Smaller: move full_word + arith_cache out of t_token into t_ast_node (deque slots 32→16B, wide but mechanical ripple). ft_printf checked: 1 write per echo, same as bash — not a bottleneck (we win output-heavy dims). Measure ratios under identical load only.
- Loosen streaming gates later: heredoc cycles (cursor logic is order-compatible, unproven), INP_NOTTY before EOF.
| dimension | hellish | bash | dash | standing |
|---|---|---|---|---|
| parse50k | 72ms | ~60ms | ~21ms | 1.21× bash, closing (was 705→221) |
| startup -c true | tied | tied | tied | tied |
| arith loop | wins 2× vs bash | tied dash | winning | |
| string concat | wins ~7× vs bash | tied dash | winning | |
| read loop | fastest | 1.5× slower | 3.5-5.8× slower | winning |
| cmdsub $(true) | fastest | 2.9× slower | 2× slower | winning |
| cmdsub $(/bin/true) | 0.83× bash | 0.67× dash | losing | |
| pipeline 3-stage | 1.09× bash | 0.91× dash | close | |
| configure | 14.1s (was 19.8) | 7.1s | 6.4s | 2× gap, closing |
- Batching semantics pinned by
tests/scripts/40_batch_semantics.sh. - Arena gate: ON only around main-cycle parse_tokens; anything that frees AST/token/full_word/children memory MUST route through parena_free.
- Plain-word fast path: split_eligible stays false; full_word stays NULL.
- Function bodies / eval trees are always heap (clone_ast/deep_clone_ast keep vec_push; the gate is closed during exec_string parses).
- ASan (SAFE=1) is the free-routing oracle; verify_alloc.sh proves both heaps.