Skip to content

Bound the prompt hook's cost, and add a kill switch - #248

Merged
rongxin-liu merged 2 commits into
help50from
help50-safety
Sep 29, 2026
Merged

rongxin-liu merged 2 commits into
help50from
help50-safety

Conversation

@rongxin-liu

@rongxin-liu rongxin-liu commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Pre-rollout safety pass. The question was: can help50 hang a terminal, and what turns it off?

Findings, measured in the image

Scenario Prompt hook Recoverable?
Normal command 7-15 ms n/a
_find over a 60,000-file workspace 52 ms n/a
Helper that blocks forever stuck until Ctrl-C Ctrl-C only
Failed command that printed 35 MB 2.4 s, linear in output size waits it out

Only the last two needed changes. Everything else on the hang question checked out: the interactive-tty guard keeps non-interactive shells away from script; a program that prints forever until Ctrl-C exits 130, which the hook skips.

Changes

Bounded read. _help50 read the whole typescript into a variable ($(cat $HELP50)) before any cap, so a program that printed a lot and then crashed made the next prompt wait in proportion: 35 MB, 2.4 s; 350 MB would have been ~24 s, which a student experiences as a hang. Now it reads at most the first 64 KB (where the command line is echoed) and the last 1 MB (where the error is), with a marker between. Same 35 MB: 0.1 s, and constant in output size.

Helper timeout. Each helper now runs under timeout -k 1 5. Today's helpers can't block, but the framework accepts helpers in any language; this makes "a helper hangs the prompt" impossible rather than Ctrl-C-recoverable. One trade-off, noted in the code: timeout runs the helper in its own process group, so Ctrl-C at the terminal no longer reaches a stuck helper the way it did before; the prompt instead returns when the timeout fires, at most ~6 s per helper. --foreground would restore Ctrl-C but would stop timeout from killing the helper's children, so an orphaned child holding stdout open could stall the prompt indefinitely, which is the hang this change removes.

Kill switch. HELP50_DISABLED in the environment disables help50 at login; help50 is-enabled reports disabled (HELP50_DISABLED=1; unset it to re-enable). Values that read as false (0, false, no, off, any case) count as unset, so setting the secret to 0 turns help50 back on just as deleting it would, rather than silently keeping everyone disabled. Codespaces supports organization-wide Codespaces secrets (that is how CS50_TOKEN arrives), so setting HELP50_DISABLED=1 at the org turns help50 off for every student at their next codespace start, with no image rebuild, and deleting the secret (or setting it to 0) turns it back on. Set as a user-level Codespaces secret, it is a persistent personal opt-out. (An export in ~/.bashrc is too late: /etc/profile.d/cli.sh checks is-enabled before ~/.bashrc is read; help50 disable is the in-codespace opt-out.) Until now the only fleet-level option was revert, rebuild, and wait for students to rebuild.

Escape hatches after this PR

  • Any shell: a stuck helper is cut off by timeout within ~6 s; Ctrl-C is not needed, and no longer reaches the helper (see above).
  • Current shell: help50 stop.
  • This codespace's new shells: help50 disable.
  • Everyone, without a rebuild: org Codespaces secret HELP50_DISABLED=1; delete it or set it to 0 to turn help50 back on.
  • Staff: the Sysadmins terminal profile (root) never starts help50.

Testing

tests/smoke.sh gains three checks: the hook completes in under 1 s on a 35 MB typescript and still delivers the final error line; a helper that sleeps 60 s is cut off within 15 s; HELP50_DISABLED=1 is reported by is-enabled (with the value and how to clear it) and, in an interactive login shell under a pty, leaves help50 status at stopped (with the default case as a positive control). The kill switch check also covers the false-y values (0, false, FALSE, no, off, empty) reporting enabled, and that help50 disable's lock file is still honored when the environment does not disable. Full suite passes in CI on both amd64 and arm64 (the 35 MB hook check measured 137 ms on amd64; the hung helper was cut off at 5 s).

- Read the typescript bounded (first 64K + last 1M) instead of the whole file
  into a variable, so the prompt after a failed command no longer scales with
  how much it printed: 35 MB took 2.4 s, now 0.1 s, and 350 MB would have
  taken 24 s.
- Run each helper under timeout (5 s, then SIGKILL), so a slow or stuck helper
  cannot stall the prompt; today's helpers can't block, but the framework
  accepts helpers in any language.
- HELP50_DISABLED in the environment disables help50 at login and is reported
  by help50 is-enabled/status. Set as an organization-wide Codespaces secret,
  it turns help50 off for everyone at their next login without rebuilding an
  image; set by one user, it's a persistent personal opt-out.

Smoke tests cover all three.
@rongxin-liu rongxin-liu self-assigned this Sep 29, 2026
- HELP50_DISABLED=0 (or false, no, off, case-insensitively) now counts as
  unset, so that an admin who sets the org secret to 0 to turn help50 back
  on gets what they asked for, rather than every student staying disabled
  with no error. The is-enabled message now shows the value and says to
  unset it. Smoke tests cover the false-y values and that the lock file is
  still honored when the environment doesn't disable.
- Note in the prompt hook that timeout runs each helper in its own process
  group, so ctl-c no longer reaches a stuck helper; the timeout itself is
  the bound. --foreground would restore ctl-c but stop timeout from killing
  the helper's children, which would give back the hang this is meant to
  remove.
@rongxin-liu
rongxin-liu merged commit 2e88c1b into help50 Sep 29, 2026
3 checks passed
@rongxin-liu
rongxin-liu deleted the help50-safety branch September 29, 2026 22:09
rongxin-liu added a commit that referenced this pull request Sep 30, 2026
Reimplements help50 in Bash, running locally and automatically per login
shell, without a server. Usage is inspired by systemctl:

- help50 start/stop/status/enable/disable/is-enabled control a session
  that logs the shell's I/O via script to /tmp/help50.$PPID
- help50 COMMAND [ARGS...] runs COMMAND as though typed directly, with
  the same exit status
- HELP50_DISABLED in the environment is a kill switch, so that as a
  Codespaces secret help50 can be turned off fleet-wide without a rebuild

/etc/profile.d/help50.sh installs a PROMPT_COMMAND hook that, after a
failed command, strips the typescript of ANSI/control characters and
terminal echo, bounds the read (first 64K + last 1M) and the output
(first 64 + last 1,024 lines), and passes it to each executable helper in
/opt/cs50/lib/help50/ under a 5-second timeout. Helper output is shown via
_helpful; otherwise _helpless receives the output and command line (a
no-op here, overridden in cs50/codespace to relay to the CS50 Duck).

Also adds /opt/cs50/lib/cli helper functions (_alert, _ansi, _find,
_fold, _sure) used by the make, sqlite3, http-server, and valgrind
wrappers; helpers for bash, cd, clang, make, and python; tests/smoke.sh
(make smoke), run in CI against each architecture's build before pushing
to Docker Hub; and installs bsdextrautils, colorized-logs, file, expect,
and fzf, dropping the Python help50 package.

Squashed from 99 commits, including #244, #245, #246, #247, and #248.

Co-authored-by: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant