fix: Phase 8 launch blockers — #41 #42 #43 #44 - #59
Conversation
A typo'd screen name, a handler that forgot to set _SHELLFRAME_APP_NEXT, or an unmapped event previously sent shellframe_app into an infinite command-not-found loop. The runtime now verifies the _type function and event handler exist (declare -F) and asserts NEXT is non-empty after each handler, exiting 1 with a targeted stderr diagnostic on failure. Closes #41
…ection) %b re-interprets backslash escapes in its argument. All v1 widget rendering now uses %s exclusively — real-byte ANSI constants behave identically, but literal-backslash color overrides (a documented consumer pattern) and any future caller-supplied content can no longer be expanded into terminal escapes. 40 format specifiers converted across confirm, alert, table, and action-list. Regression tests pin the behavior with poisoned literal-backslash color constants in both default draw-row helpers. Closes #42
INT/TERM previously shared the EXIT trap, which restored the terminal but kept running — quitting only via the accidental fd-3-liveness check with the wrong exit code. EXIT now owns cleanup alone; INT/TERM clear the trap set, run cleanup once, and exit 130/143 respectively. Integration test drives a real Ctrl-C through the PTY line discipline. Required a companion ptyunit fix: under an async runner pool, bash's SIGINT-ignored disposition inherits into the fixture and POSIX forbids re-trapping an entry-ignored signal — pty_run.py now restores default dispositions in the forked child before exec. Closes #43
read -t returning <=128 with an empty value means stdin EOF, not a timeout tick; treating them alike made the input loop spin at 100% CPU for as long as /dev/tty stayed open after stdin detached. _shellframe_shell_read_key now sets _SHELLFRAME_KEY_EOF on true EOF; the loop quits via __QUIT__ and shellframe_shell returns 1 so callers can distinguish an EOF shutdown from a clean user exit. Validated read return-code semantics empirically (EOF rc=1 vs timeout rc=142 on bash 5.3) per org IO-validation rules; Docker matrix covers 3.2. Closes #44
Review — verdict: changes requested (posted as a comment: GitHub blocks request-changes from the author's own account)Review of #59 against the four Phase 8 tickets. #41/#42/#43 are substantively right and the test discipline (watchdogged app-runtime cases, real SIGINT through the PTY line discipline) is good. Two findings block, though — the first one regresses the flagship compatibility target. Blocking1. #44's EOF discriminator quits the runtime after 1 idle second on bash 3.2 —
|
| child bash | result |
|---|---|
| 5.3 | idles until the driver timeout (rc 124) — correct |
| 3.2.57 | shell-returned:1 after ~1 s — runtime quit itself |
Second repro: 2.5 s idle then ENTER on the list example — origin/main selects "apple"; this branch prints "No selection." at ~1.1 s. Any shql/gdiff user on default macOS bash who pauses for a second, or resizes the window, is dumped out with rc=1; the toast-expiry tick can never run there.
CI is blind to it — no test idles ≥1 s with stdin attached (pty_run sends keys 0.15 s apart), so both legs pass while the runtime is broken.
Fix shape: trust rc > 128 only when BASH_VERSINFO[0] >= 4; on 3.2 use a discriminator that doesn't depend on rc — EOF returns instantly, a timeout takes ≥1 s, so $SECONDS unchanged across the read (any ≥1 s interval crosses a boundary) or N consecutive instant empty failures. Whichever you pick, add a test that idles >1 s on the 3.2 leg — it's the only thing that would have caught this, and org rules ask for exactly that class of PTY-timing validation.
2. #44 is fixed in only one of the two key-read primitives — src/input.sh:139
shellframe_read_key (used by every v1 widget loop, hence by shellframe_app) has no EOF detection. confirm.sh:241-255 calls _shellframe_confirm_on_key "" → returns 1 (unhandled) → while true continues; table.sh:409-437 and action-list.sh:190-215 continue on the empty key. Verified on the PR tree: exec 3>/dev/null 0</dev/null; ulimit -t 1; shellframe_confirm 'q?' dies with rc=152 (SIGXCPU) after burning 1 s of CPU; same for shellframe_table and shellframe_action_list. A shellframe_app program whose tty detaches still pegs a core — the exact failure #44 was opened for — while PROJECT.md marks #44 closed. The same (3.2-safe) discriminator belongs in shellframe_read_key, with the widget loops breaking on its non-zero return.
Should fix in this PR
-
[Bug] H1: Missing screen/handler functions cause infinite error loop in app runtime #41 guards miss
_render—src/app.sh:128._t_ROOT_typedefined,_t_ROOT_rendertypo'd →command not foundunder the alt screen, widget runs with the previous screen's rows (widget arrays aren't reset at 107-120), rc=0. Silent-misconfiguration-with-success-exit is the class [Bug] H1: Missing screen/handler functions cause infinite error loop in app runtime #41 targets; adeclare -F …_rendercheck mirroring 124-126 closes it. -
_shellframe_app_dieusesexit 1from a sourced library —src/app.sh:87. It's the onlyexitinsrc/; every other error path isprintf … >&2; return 1(cursor.sh:66, scroll.sh:67, selection.sh:70, sheet.sh:50, split.sh:69).bash -c 'source ./shellframe.sh; shellframe_app _bogus ROOT; echo AFTER'never prints AFTER; a user who sources shellframe in their interactive shell per README and typos a screen name loses the session. The PR's own tests had to wrapshellframe_appin a background watchdog because an in-process call would exit the test file.return 1(withreturn 1after each_diecall) meets [Bug] H1: Missing screen/handler functions cause infinite error loop in app runtime #41's "non-zero exit" without breakingdocs/api.md:418's "Returns when…" contract. -
One
%bon caller content survives [Bug] H2: printf '%b' applied to caller-supplied text (escape injection) #42 —src/draw.sh:24shellframe_pad_left.shellframe_pad_left 'C:\new' … | od -cshows a real newline (and the pad width is computed on the raw bytes, so layout drifts);'a\033[31mX'emits a live SGR.git grep '%b' -- src/on this branch returns exactly this line plus its doc comment; the siblingshellframe_str_padat clip.sh:175 already uses%s. Also update theprintf '%b'usage examples at draw.sh:19, docs/api.md:152, docs/hard-won-lessons.md:114, which currently tell customdraw_rowauthors to wrap it in a second%b. -
Trap install clobbers the caller's traps —
src/shell.sh:456-458, 675. Pre-existing, but this PR rewrote exactly these lines and the new INT path additionally clears EXIT beforeexit 130. Verified on 3.2 and 5.3: caller'strap _my_cleanup EXITis gone aftershellframe_shellreturns (trap - EXITdeletes, it doesn't restore). No current consumer installs traps so it's latent, but it's a library-contract bug and this is the moment to addtrap -psave/restore. -
No
*)default for unknown_type—src/app.sh:91._t_ROOT_typeprintingalrt→ no widget runs,_eventis empty, diagnostic readsmissing _t_ROOT_() handler for widget type 'alrt'. Validate_typeagainst the known set for a correct "unknown widget type" message.
Tests / hygiene
-
Watchdog orphans hold the runner's pipe —
tests/unit/test-app.sh:97.( sleep 3; kill -9 "$_pid" ) &inherits stdout;kill "$_wd"kills the subshell, not itssleep, which keeps ptyunit'sout=$(bash "$f" 2>&1)capture open. Measured: 3.36 s wall under the runner vs 0.36 s to a file, 4 leakedsleep 3per run, a worker slot held for 3 s on every CI run.( … ) >/dev/null 2>&1 &drops it to 0.33 s with 22/22 passing — or replace the harness with( ulimit -t 3; "$@" )and no background job. Separately, the capture file is a fixed/tmp/sf-app-bg.out(lines 95/102/103) — two concurrent suite runs on one host race on it; usemktempunder$PTYUNIT_TEST_TMPDIRas test-alert.sh:21 does. -
New contracts undocumented —
shellframe_shellnow returns 1 and sets_SHELLFRAME_SHELL_EOF/_SHELLFRAME_KEY_EOF;shellframe_appcan terminate the process. Neither the shell.sh header nor docs/api.md mentions a return code; app.sh:81 carries a copy-pasted "Map widget return code → event name string" comment above_shellframe_app_die. Also, the two "shell-runtime" integration tests were appended totest-mouse-routing.sh(lines 77, 88), whose header scopes it to mouse routing — atest-shell-runtime.shmatches the one-file-per-concern rule. -
Debug scaffolding left in the fixture —
tests/fixtures/shell-eof.sh:9,12,18,26printf … >&8— nothing opens fd 8 underpty.fork(), so every transcript for the [Bug] M4: Busy-spin at 100% CPU on stdin EOF in shell runtime #44 test carries fourbash: 8: Bad file descriptorerrors; it only passes because the assertion grepsshell-returned:1. Delete orexec 8>&2. Minor: test-app.sh's "empty initial screen name" case exercises "missing ROOT" (app.sh:102${2:-ROOT}maps "" to ROOT), not an empty name.
Not blocking, for the record
- v1 widgets'
trap - INT TERMon teardown (confirm.sh:258 etc.) would strip shellframe_shell's new 130/143 handlers if a widget were ever nested inside a shell session — no consumer does it today. - The EOF quit path bypasses the screen's
_quithook; [Bug] M4: Busy-spin at 100% CPU on stdin EOF in shell runtime #44 asked for "same path asq". - Cross-repo dependency is resolved: ptyunit#53 merged with
96d6f04and v1.6.0 is tagged, so the SIGINT test's driver fix is on main.
Reproductions for 1, 2, 4, 5, 8 were run against this branch's head (ce43364) on macOS /bin/bash 3.2.57 and bash 5.3.15.
🤖 Generated with Claude Code
…iene Review response (shellframe#59): Blockers: - #44 on bash 3.2: read -t returns 1 for BOTH timeout and EOF there (verified /bin/bash 3.2), so the rc discriminator quit the runtime during any idle second. Discrimination is now version-gated: >=4.0 uses rc>128; 3.2 widens the window to 2 s and classifies by elapsed time (instant failure = EOF). Verified: idle-then-navigate survives on /bin/bash 3.2; new unit tests idle past the window on every matrix leg. - v1 widgets now handle stdin EOF too: shellframe_read_key sets SHELLFRAME_KEY_EOF (untimed first read = unambiguous), and the confirm/alert/action-list/table loops plus the editor paste drain exit cleanly instead of spinning. Should-fixes: - app runtime: _render existence guard; explicit default case for an unknown widget type; die() now prints + returns 1 instead of exit(1) from a sourced library. - shellframe_pad_left renders via %s; docs no longer teach double-%b. - shellframe_shell preserves the caller's EXIT/INT/TERM traps: saved at entry, restored verbatim on normal return (contract documented in docs/api.md alongside all return-code tables). Hygiene: - test-app watchdog writes to mktemp with detached stdio (no fixed /tmp race, no orphaned sleeps holding capture pipes). - fixture debug markers removed; run-matrix prefers a sibling ptyunit checkout under /Users (Docker-shared) over the unshared /opt/homebrew path — fixes the long-standing local matrix blocker.
Review response — all blockers and should-fixes addressed (
|
Re-review of
|
before (ce43364) |
now (bcabb48) |
|
|---|---|---|
Idle runtime, no keys, /bin/bash 3.2 |
quit itself: shell-returned:1 at ~1 s |
idles to the driver timeout (rc 124), same as bash 5 |
| 2.5 s idle then a key, 3.2 | quit before the key | key handled, shell-returned:0 |
shellframe_confirm / alert with detached stdin under ulimit -t 1 |
rc 152 (SIGXCPU, spinning) | immediate return (confirm rc 1, alert rc 0) on both bash versions |
The 3.2 discriminator is sound: a full 2 s read -t 2 always crosses ≥2 date +%s boundaries, EOF is delta 0, and 3.2 doesn't interrupt read on SIGWINCH so there's no third case. shellframe_read_key's untimed read makes EOF unambiguous on every version — right call.
Should-fixes — all verified in the tree
_shellframe_app_die returns (no exit left in src/) · _render guarded · *) default for unknown _type · git grep '%b' -- src/ is empty · traps saved with trap -p and eval-restored (I checked $(trap -p) inside command substitution does reflect the parent's traps on 3.2 — the restore works there too) · watchdog uses mktemp with detached stdio · fixture >&8 markers gone.
Suites
- Full suite in an isolated worktree against ptyunit v1.6.0: 1529/1529 across 49 files, rc 0.
- PR CI: both legs green on
bcabb48.
⚠ Pre-merge action (not a code change): bump the Homebrew tap to ptyunit 1.6.0
tests/run.sh prefers a sibling ../ptyunit checkout and only falls back to Homebrew. CI and the ~/lib/fissible workspace get the sibling (v1.6.0), but my first run was in a worktree with no sibling → Homebrew 1.5.4 → the SIGINT integration test hung for the full 10-minute tool timeout (old driver: SIGINT inherited as ignored under the worker pool, so \x03 never lands and the runtime never exits). The tap formula still points at v1.5.4.tar.gz. Anyone running this branch with Homebrew ptyunit will hit that hang, so bump the tap before (or with) the merge.
Leftover nits — fine to defer
_sf_read_t/_sf_t0in_shellframe_shell_read_keyaren'tlocal.- The two shell-runtime integration tests are still in
test-mouse-routing.sh(item 9's file-split suggestion) — cosmetic. - Side note: the run-matrix mount fix (unshared
/opt/homebrew→ sibling under/Users) is a nice catch; worth a line indocs/hard-won-lessons.md.
🤖 Generated with Claude Code
Summary
Closes all four Phase 8 pre-OSS-launch blockers filed from the 2026-08-24 external review:
declare -Fguards on the_typefn and event handler, non-empty_SHELLFRAME_APP_NEXTassertion; exits 1 with a targeted stderr diagnostic instead of looping forever.%bescape injection: all 40%bspecifiers across confirm/alert/table/action-list converted to%s. Behavior-neutral for$'..'real-byte constants; immune to literal-backslash color overrides and any future caller-content through the format.read -trc≤128+empty is EOF (clean quit,shellframe_shellreturns 1), rc>128 stays timeout-tick. Semantics validated empirically per org IO rules.Cross-repo dependency
#43's PTY test exposed a ptyunit framework bug (bash async-worker SIGINT inheritance + unguarded waitpid). Fixed in fissible/ptyunit
96d6f04— see ptyunit PR #53. This PR's CI passes standalone, but the mouse-routing integration tests are only meaningful against the fixed ptyunit; CI sibling-checkout of ptyunit main will pick it up when PR #53 merges.Test plan