brintos

brintos / linux public Read only

0
0

wasm32: tty job control broken — tcsetpgrp is inert and fork places every child in a fresh pgrp, so forked processes reading the controlling tty get EIO #7

Closed dmitry opened this issue · 3 comments
D dmitry commented

Summary

On a wasm32 machine (glibc distro dmitry/linux-6.12-glibc-bash-coreutils, browser runtime), tty job control is broken in two ways that combine to make every forked process input-blind on the controlling terminal:

  1. tcsetpgrp() is inert. The tty's foreground pgrp never changes — tpgid stays 1 (pid 1's group) no matter who calls tcsetpgrp() on the controlling tty. Verified from /proc/*/stat field 8 across multiple runs.
  2. fork() places the child in a fresh process group equal to its pid, even when userspace makes no setpgid() call at all (bash with job control off, set +m). The child also inherits its comm from the kthread donor task — it shows up as (swapper/0) — so the __wasm32_alloc_process_task recycling path appears to initialize task fields from the donor instead of the parent.

Combined effect: child pgrp ≠ tty foreground pgrp (which is pinned at 1 forever), so any forked child that reads from the controlling tty fails the tty job-control check and gets -EIO. Only pid 1 itself (or an exec from pid 1, i.e. no fork) can read the terminal.

Reproduction (in-browser terminal of the machine above)

a2$ echo flags=$-
flags=hiBHs            # job control OFF — bash makes no setpgid calls
a2$ cat -v
cat: -: Input/output error
a2$ cat /proc/1/stat
1 (init) D 0 1 1 34816 1 256 ...          # pgrp=1 session=1 tty=pts/0 tpgid=1
a2$ bash -c "cat /proc/self/stat"
35 (swapper/0) R 1 35 1 34816 1 0 ...     # pid=35 pgrp=35 (!) session=1 tpgid=1

The forked child (pid 35) sits in pgrp 35 while the tty's foreground pgrp is 1 → its tty reads return EIO. Note comm=(swapper/0) on a plain fork of bash.

Kernel log line seen at each fork: __wasm32_alloc_process_task(pid_sentinel=0, ppid=1) -> ... pid=35.

Impact

Interactive full-screen apps (mc, editors) and anything reading stdin (cat, dd) are dead when launched normally from the shell. Midnight Commander additionally hangs at startup because its subshell-helper fork never comes up.

Workarounds in use

  • /.bashrc with set +m (helps on consoles where the pgrp survives fork — our fra2 host harness; NOT sufficient in the browser, where fork itself reassigns the pgrp).
  • Run tty apps without forking: TERM=ansi exec /usr/bin/mc -u (mc replaces pid 1; -u disables the subshell fork). Verified working in the browser.

Expected

  • fork() must leave the child in the parent's process group (POSIX), and comm must be inherited from the parent.
  • tcsetpgrp() on the controlling tty must update the foreground pgrp so shells can hand the terminal to job pgrps.
jdbrinton jdbrinton commented

New data point from tonight's linux-in-the-browser#91 fix (glibc fbcon now gets a controlling tty via setsid -c, mirroring the musl #28 wiring): with job control active on the glibc image, a foreground fork+exec from the tty1 shell fails at cpu=1 only — the forked bash child exits 1 before exec, silently (dmesg: wasm32: HWJS-A-4 EXIT with PRESERVED parked syscall ... -> draining to done). Background-job + fg work at cpu=1 and cpu=2, and the musl image with identical setsid wiring passes the same cpu=1 shape — so this is a glibc-specific interaction between the job-control child path (setpgid/tcsetpgrp between fork and exec — this ticket's semantics) and the glibc INLINE_SYSCALL −517 decode gap on syscalls outside the HWJS-C-1-routed blocking paths. Documented in distros/linux-6.12-glibc-bash-coreutils/tools/fbcon-bashrc (RESIDUAL block) and reproducible via the new tools/run-fbcon-jobcontrol-gate.mjs with MAXCPU=1; the live machine spec (cpu=2) is unaffected. — agent for joel, 2026-07-14

jdbrinton jdbrinton commented

Root-cause triage (agent for joel, 2026-07-15). Three distinct mechanisms, plus one part of the report that current source can't reproduce:

1. comm=(swapper/0) — confirmed, two halves. __wasm32_alloc_process_task builds the child by memcpy from init_task as template (arch/wasm32/kernel/task_alloc.c:586) and never copies the parent's comm; and the in-place exec loader wasm32_user_exec_load() (binfmt_wasm.c:819 ff) deliberately skips the bprm machinery, so begin_new_exec()'s __set_task_comm never runs for user execs. Only boot's kernel_execve("/init") gets a comm — that's why pid 1 is (init) and every fork+exec child is (swapper/0). Fix: strscpy comm from the resolved parent in the alloc path + __set_task_comm(kbasename(path)) at exec-load success.

2. "fork puts child in fresh pgrp" — NOT reproducible from current source. task_alloc.c:858-867 already attaches the child to task_pgrp(parent)/task_session(parent) (and your repro shows session=1 inherited correctly — both come from adjacent lines of the same code). pgrp==pid requires a setpgid(0,0)-shaped call: either bash had job control on at fork time (POSIX-correct; the bug is then that tcsetpgrp never follows — see 3), or the machine ran a build predating the inheritance work. The alloc dmesg line prints pid/ppid but not pgid/sid/comm (task_alloc.c:872-880) — adding those gives the witness to discriminate. (Real hardening gap found: on alloc_pid failure the child silently keeps init_struct_pid PGID/SID.)

3. tcsetpgrp inert + cat: EIO — three concrete producers, all confirmed in-source:

  • (a) Host ioctl routing gate: hardwarejs routes ioctl kernel-side only for fd >= 3 || kernelRoutedLowFds.has(fd) (worker.mjs:4918-4947); unrouted ioctls hit a stub that returns -ENOTTY for everything. On the interactive console pid 1's fds 0-2 are installed kernel-side (init.c:744-764) so the JS-side set is never seeded and fork children inherit it empty → tcsetpgrp(0,…)/tcgetpgrp(0) fail -ENOTTY for the whole tree; tpgid pinned at 1. (bash partially escapes via its high-fd shell_tty dup.) Fix: route ioctl unconditionally when a per-process task exists (the fd<3 exclusion's original motivation is obsolete — every process now has real kernel files on 0-2, and ioctl on /dev/null returns -ENOTTY natively anyway).
  • (b) glibc −517 decode gap (the cpu=1 residual from my 07-14 comment): tcsetpgrp is __ioctl(TIOCSPGRP) and neither ioctl.c nor setpgid is in the wasm32 Makefile's -D__WASM32_NOCANCEL_PARK list, so a parked -ERESTARTRESCHED leaks to userspace as a failure between fork and exec. Fix: add CFLAGS-ioctl.c to the park list + a C setpgid override routed through __wasm32_syscall_resched.
  • (c) Orphaned-pgrp misclassification when the shell IS pid 1: will_become_orphaned_pgrp() skips members whose real_parent is global init (kernel/exit.c:335), so every job pgrp forked by a pid-1 shell is "orphaned" and background tty reads take the -EIO branch of __tty_check_change instead of SIGTTIN — the direct producer of cat: -: Input/output error. Preferred fix is compositional, not a kernel patch: give the interactive pts shell its own session via setsid -c exactly like the fbcon wiring from linux-in-the-browser#91, so the shell isn't pid 1.

Validation: run-fbcon-jobcontrol-gate.mjs MAXCPU=1/2, glibc-dualcon-gate.test.ts (the cpu=1 foreground fork+exec arm is the shape fix (b) must flip; cpu=2 must not regress), musl twins for parity, then the ticket repros (bash -c 'cat /proc/self/stat'(cat) pgrp 1; tpgid follows tcsetpgrp; mc launches normally). Queued for tonight's ticket run.

jdbrinton jdbrinton commented

Fixed across all four layers and deployed (agent for joel, 2026-07-15). Everything from the triage comment landed, plus one bug the triage missed that only surfaced once the first three fixes let the child get far enough:

  • comm — kernel 03af59d6862e: fork copies the parent's comm (the init_task-template memcpy never did), and the in-place exec loader now runs __set_task_comm(kbasename(path)) like upstream's begin_new_exec. In-guest witness: bash -c 'cat /proc/self/stat'35 (cat) R 1 35 1 34816 — comm is (cat), not (swapper/0). The alloc dmesg line also prints pgid/sid/comm now, so this class of bug is diagnosable from the log.
  • tcsetpgrp inert — hardwarejs 7612eb4: ioctl routes kernel-side whenever a real per-process task exists; the fd<3 stub that returned blanket -ENOTTY (and pinned tpgid at 1 for the whole console tree) is gone.
  • −517 leak (cpu=1 residual) — glibc 19255c818a: misc/ioctl.c (where tcsetpgrp/tcgetpgrp actually inline) + a new C setpgid.c decode the park protocol.
  • NEW: −512 signal-restart leak — glibc d1abbc1387. With the above in place, bash's give_terminal_to reached a real kernel TIOCSPGRP from a not-yet-foreground pgrp; __tty_check_change judged the stale-empty kernel-side signal mask (SIGTTOU "unblocked"), latched a pending SIGTTOU and returned -ERESTARTSYS, which glibc decoded for nothing — the child died pre-exec with every subsequent syscall failing −512. The resched loop now decodes −512/−513/−514 (reconcile pending signals, park to yield the lane, re-issue) and −516→EINTR — musl parity, and faithful upstream restart semantics. The underlying mask divergence is filed as #9 with the fix shape.
  • fork pgrp claim — could not be reproduced from current source and the new witness confirms: PGID/SID attach to the parent's pgrp/session at every fork. What you saw was bash's own job-control setpgid(child,child) (POSIX-correct) with tcsetpgrp never following — i.e., the bugs above.

Validation (all against the deployed bits): glibc dualcon gate GREEN all 3 arms — the cpu=1 foreground fork+exec that failed for three distinct reasons across this ticket's history now completes in 300ms; glibc fbcon-jobcontrol gate GREEN at MAXCPU=1 and 2 (fg works, no −517/−512 leaks asserted); musl twins unregressed; your repro shapes: cat -v reads the tty, tpgid follows tcsetpgrp, comm correct. Deployed to all three machines (effective on relaunch); mc launched normally is worth a retest on your side.

Residuals, deliberately not blocking: (1) #9 — mirror the user-side signal mask into current->blocked so the kernel stops taking the wrong __tty_check_change branch (today it self-corrects via restart); (2) a pid-1 interactive shell's job pgrps are still classified orphaned (is_global_init skip in will_become_orphaned_pgrp), so background tty reads get EIO instead of SIGTTIN-stop — the compositional fix is giving the pts shell its own session via setsid -c like the fbcon wiring (litb#91), a distro-side change worth its own small ticket if the shape bothers anyone in practice. Closing; reopen if a repro survives a machine relaunch.

jdbrinton closed this as completed