diff --git a/CHANGELOG.md b/CHANGELOG.md index 312ca88..de9f4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. Detached sessions are never ended to make room. ### Fixed +- After Ctrl+Z, `jobs` could list a stray `suspended (tty output)` job. NMSh's own prompt hook ran `stty` as a job, and it could be stopped when it ran before zsh had taken the terminal back. The hooks now change terminal modes outside job control. - The session service no longer crashes when a window resize races a shell's exit. node-pty could throw `EBADF` for a PTY that had just closed, which inside nmshd would have ended every live session. Other resize failures are now reported to that window instead of ending the service. - A launch notice (for example, sessions archived while no window was attached) is no longer erased when the window reattaches to a live session. diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index ee43888..a7e58f7 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -71,6 +71,16 @@ export TERM=\$nmsh_orig_term export NMSH_ACTIVE=1 unsetopt zle prompt_cr prompt_sp +# Hooks run right after a job stops or ends, sometimes before zsh has taken the +# terminal back. As an ordinary job, stty could then be stopped by SIGTTOU and +# left in the user's job table ("suspended (tty output) stty -echo"). Run it +# outside job control and immune to SIGTTOU so the mode change is simply applied. +function nmsh_tty_echo { + setopt localoptions localtraps nomonitor + trap '' TTOU + stty \$1 2>/dev/null +} + function nmsh_precmd { local nmsh_status=$? # Reblank every cycle: a plugin's own precmd (starship, a prompt theme, ...) @@ -84,12 +94,12 @@ function nmsh_precmd { # still blanks last. Their prompt-spacing options must not return either. precmd_functions=(\${precmd_functions:#nmsh_precmd} nmsh_precmd) unsetopt prompt_cr prompt_sp - stty -echo 2>/dev/null + nmsh_tty_echo -echo printf '\\e]777;nmsh;${token};%d;%s\\a' "\$nmsh_status" "\$PWD" } function nmsh_preexec { - stty echo 2>/dev/null + nmsh_tty_echo echo printf '\\e]777;nmsh;${token};exec;%s\\a' "\${1//[[:cntrl:]]/ }" } diff --git a/tests/sessionLifecycle.test.ts b/tests/sessionLifecycle.test.ts index 9ea91af..48d4907 100644 --- a/tests/sessionLifecycle.test.ts +++ b/tests/sessionLifecycle.test.ts @@ -152,13 +152,15 @@ test('reattach into a fullscreen app resumes passthrough and makes it repaint at writeFileSync(fixture, [ `printf '\\e[?1049h'`, `trap 'printf "\\e[H\\e[2JREDRAW %s\\n" "$(stty size)"' WINCH`, + `printf 'TRAP-READY\\n'`, `while [ ! -f ${stop} ]; do sleep 0.05; done`, `printf '\\e[?1049l'`, ].join('\n')); const first = sandbox.launch(); await first.waitFor(/❯/); first.pty.write(`zsh ${fixture}\r`); - await until(async () => (await sandbox.sessions())[0]?.running !== undefined, 15000, 'fixture running'); + // Detach only once the fixture's WINCH trap is installed, or the reattach resize can arrive before it. + await first.waitFor(/TRAP-READY/); const {id} = (await sandbox.sessions())[0]!; first.pty.kill('SIGKILL'); await waitState(sandbox, 'detached'); diff --git a/tests/sessionService.test.ts b/tests/sessionService.test.ts index 76c21db..7802437 100644 --- a/tests/sessionService.test.ts +++ b/tests/sessionService.test.ts @@ -195,7 +195,11 @@ test('handshake, version mismatch, framing, containment, registry, and disconnec assert.equal(service.registry.length, 0); assert.equal(existsSync(service.socketPath), false); } finally { + // Shells ended by close() still write their spool afterwards; wait so the directory stays removed. + const pids = service.registry.map(session => session.pid); await service.close(); + await until(() => pids.every(pid => { try { process.kill(pid, 0); return false; } catch { return true; } })); + await new Promise(resolve => setTimeout(resolve, 50)); rmSync(runtimeDir, {recursive: true, force: true}); rmSync(home, {recursive: true, force: true}); }