Skip to content

Commit 0f7099c

Browse files
author
Maruthan G
committed
src: skip tcsetattr on exit when backgrounded
ResetStdio() unconditionally restored saved termios state on every tty stdio fd at exit. When Node is not the foreground process group for that terminal (`node script.js &`, ctrl-Z + bg, or a later pipeline stage taking the foreground), this clobbers the foreground pgrp's termios - breaking shell readline, leaving terminals in raw mode after the foreground pager exits, etc. Guard the tcsetattr call with `tcgetpgrp(fd) == getpgrp()` so we only restore when we are still the foreground pgrp for that terminal. The existing SIGTTOU defensive block is preserved to cover the unavoidable race where the foreground pgrp changes between the check and the call. Fixes: #35536 Refs: #42433 Signed-off-by: Maruthan G <[email protected]>
1 parent 8f348bc commit 0f7099c

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

src/node.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,8 +697,22 @@ void ResetStdio() {
697697
sigset_t sa;
698698
int err;
699699

700+
// Only restore the termios state if this process is (still) the
701+
// foreground process group for the controlling terminal. If we are a
702+
// background job, calling tcsetattr() would either be silently ignored
703+
// (with SIGTTOU blocked, see below) or, worse, clobber the terminal
704+
// state of whichever process is currently in the foreground -- e.g.
705+
// the parent shell's readline.
706+
// See https://github.com/nodejs/node/issues/35536.
707+
pid_t fg_pgrp = tcgetpgrp(fd);
708+
if (fg_pgrp == -1 || fg_pgrp != getpgrp()) continue;
709+
700710
// We might be a background job that doesn't own the TTY so block SIGTTOU
701711
// before making the tcsetattr() call, otherwise that signal suspends us.
712+
// The foreground-pgrp check above handles the common case, but a race
713+
// is still possible if the foreground pgrp changes between tcgetpgrp()
714+
// and tcsetattr(); blocking SIGTTOU keeps us from being suspended in
715+
// that race.
702716
sigemptyset(&sa);
703717
sigaddset(&sa, SIGTTOU);
704718

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use strict';
2+
3+
// Regression test for https://github.com/nodejs/node/issues/35536
4+
//
5+
// When Node.js is run as a background process, its stdio reset on exit must
6+
// not call tcsetattr() on the controlling terminal -- otherwise it clobbers
7+
// the foreground process's termios state (e.g. breaks readline in the parent
8+
// shell, or the pager when piping `node ... | less`).
9+
//
10+
// This test allocates a pty, snapshots termios, then forks/execs Node such
11+
// that Node's process group is *not* the foreground process group of the pty.
12+
// After Node exits, it compares termios. If termios changed, the bug is
13+
// present.
14+
//
15+
// Implemented via a Python helper because Node's standard library does not
16+
// expose pty(7) / tcgetpgrp() / setpgid() directly.
17+
18+
const common = require('../common');
19+
20+
if (common.isWindows) {
21+
common.skip('pty test, POSIX only');
22+
}
23+
24+
const assert = require('assert');
25+
const child_process = require('child_process');
26+
27+
// Probe for python3 (the helper uses the `pty` and `termios` stdlib modules).
28+
const pythonCandidates = ['python3', 'python'];
29+
let python = null;
30+
for (const candidate of pythonCandidates) {
31+
const probe = child_process.spawnSync(candidate, ['-c', 'import pty, termios'], {
32+
stdio: 'ignore',
33+
});
34+
if (probe.status === 0) {
35+
python = candidate;
36+
break;
37+
}
38+
}
39+
if (python === null) {
40+
common.skip('python3 with pty/termios modules not available');
41+
}
42+
43+
const helper = `
44+
import os
45+
import pty
46+
import sys
47+
import termios
48+
49+
node = sys.argv[1]
50+
51+
parent_fd, child_fd = pty.openpty()
52+
53+
# Snapshot termios on the slave side before running node.
54+
before = termios.tcgetattr(child_fd)
55+
56+
pid = os.fork()
57+
if pid == 0:
58+
# Child: become leader of a new session, attach the pty as our
59+
# controlling terminal, then place ourselves in a *new* process group
60+
# that is NOT the foreground process group of the tty. From the tty's
61+
# perspective, this child is "backgrounded".
62+
os.setsid()
63+
os.close(parent_fd)
64+
65+
name = os.ttyname(child_fd)
66+
fd = os.open(name, os.O_RDWR)
67+
os.dup2(fd, child_fd)
68+
os.close(fd)
69+
70+
os.dup2(child_fd, 0)
71+
os.dup2(child_fd, 1)
72+
os.dup2(child_fd, 2)
73+
if child_fd > 2:
74+
os.close(child_fd)
75+
76+
# The session leader's pgrp is currently the tty's foreground pgrp
77+
# (set by the TIOCSCTTY in the open above). Move to a new pgrp so that
78+
# the foreground pgrp != getpgrp(). This is the configuration the bug
79+
# reporter hits with shell job-control 'bg'.
80+
os.setpgid(0, 0)
81+
new_pgrp = os.getpgrp()
82+
fg_pgrp = os.tcgetpgrp(0)
83+
if fg_pgrp == new_pgrp:
84+
# Could not background ourselves; bail loudly so the test fails.
85+
os._exit(99)
86+
87+
# Run node. It should exit cleanly without modifying termios.
88+
os.execvp(node, [node, '-e', '0'])
89+
os._exit(98)
90+
91+
os.close(child_fd)
92+
# Drain the parent_fd so the child isn't blocked on output (it shouldn't
93+
# print anything, but be defensive).
94+
try:
95+
import select
96+
while True:
97+
rfds, _, _ = select.select([parent_fd], [], [], 0.05)
98+
if not rfds:
99+
# Check whether child has exited.
100+
wpid, _ = os.waitpid(pid, os.WNOHANG)
101+
if wpid == pid:
102+
break
103+
continue
104+
try:
105+
data = os.read(parent_fd, 4096)
106+
except OSError:
107+
break
108+
if not data:
109+
break
110+
finally:
111+
pass
112+
113+
# Make sure we have reaped the child.
114+
try:
115+
wpid, status = os.waitpid(pid, 0)
116+
except ChildProcessError:
117+
status = 0
118+
119+
after = termios.tcgetattr(child_fd)
120+
os.close(parent_fd)
121+
122+
if before != after:
123+
sys.stderr.write('termios changed: before=%r after=%r\\n' % (before, after))
124+
sys.exit(1)
125+
126+
sys.exit(0)
127+
`;
128+
129+
const result = child_process.spawnSync(python, ['-c', helper, process.execPath], {
130+
encoding: 'utf8',
131+
stdio: ['ignore', 'pipe', 'pipe'],
132+
});
133+
134+
assert.strictEqual(
135+
result.status, 0,
136+
`helper failed (status=${result.status}, signal=${result.signal})\n` +
137+
`stdout: ${result.stdout}\nstderr: ${result.stderr}`
138+
);

0 commit comments

Comments
 (0)