Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 12 additions & 2 deletions src/shell/ShellSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...)
Expand All @@ -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:]]/ }"
}

Expand Down
4 changes: 3 additions & 1 deletion tests/sessionLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions tests/sessionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
Expand Down
Loading