feat(terminal): detect suspended PTY process (#37845)

Problem:  Terminal doesn't detect if the PTY process is suspended or
          offer a convenient way for the user to resume the process.
Solution: Detect suspended PTY process on SIGCHLD and show virtual text
          "[Process suspended]" at the bottom-left. Resume the process
          when the user presses a key.
This commit is contained in:
zeertzjq
2026-02-13 21:49:08 +08:00
committed by GitHub
parent 9c5ade9212
commit 6bc0b8ae87
11 changed files with 216 additions and 15 deletions

View File

@@ -382,6 +382,8 @@ TERMINAL
• |nvim_open_term()| can be called with a non-empty buffer. The buffer
contents are piped to the PTY and displayed as terminal output.
• CSI 3 J (the sequence to clear terminal scrollback) is now supported.
• A suspended PTY process is now indicated by "[Process suspended]" at the
bottom-left of the buffer and can be resumed by pressing a key.
TREESITTER

View File

@@ -1144,6 +1144,7 @@ Integer nvim_open_term(Buffer buffer, Dict(open_term) *opts, Error *err)
.height = (uint16_t)curwin->w_view_height,
.write_cb = term_write,
.resize_cb = term_resize,
.resume_cb = term_resume,
.close_cb = term_close,
.force_crlf = GET_BOOL_OR_TRUE(opts, open_term, force_crlf),
};
@@ -1194,6 +1195,10 @@ static void term_resize(uint16_t width, uint16_t height, void *data)
// TODO(bfredl): Lua callback
}
static void term_resume(void *data)
{
}
static void term_close(void *data)
{
Channel *chan = data;

View File

@@ -393,6 +393,7 @@ Channel *channel_job_start(char **argv, const char *exepath, CallbackReader on_s
proc->argv = argv;
proc->exepath = exepath;
proc->cb = channel_proc_exit_cb;
proc->state_cb = channel_proc_state_cb;
proc->events = chan->events;
proc->detach = detach;
proc->cwd = cwd;
@@ -762,6 +763,14 @@ static void channel_proc_exit_cb(Proc *proc, int status, void *data)
channel_decref(chan);
}
static void channel_proc_state_cb(Proc *proc, bool suspended, void *data)
{
Channel *chan = data;
if (chan->term) {
terminal_set_state(chan->term, suspended);
}
}
static void channel_callback_call(Channel *chan, CallbackReader *reader)
{
Callback *cb;
@@ -812,6 +821,7 @@ void channel_terminal_alloc(buf_T *buf, Channel *chan)
.height = chan->stream.pty.height,
.write_cb = term_write,
.resize_cb = term_resize,
.resume_cb = term_resume,
.close_cb = term_close,
.force_crlf = false,
};
@@ -839,6 +849,14 @@ static void term_resize(uint16_t width, uint16_t height, void *data)
pty_proc_resize(&chan->stream.pty, width, height);
}
static void term_resume(void *data)
{
#ifdef UNIX
Channel *chan = data;
pty_proc_resume(&chan->stream.pty);
#endif
}
static inline void term_delayed_free(void **argv)
{
Channel *chan = argv[0];

View File

@@ -1441,6 +1441,17 @@ static void win_update(win_T *wp)
decor_providers_invoke_win(wp);
if (buf->terminal && terminal_suspended(buf->terminal)) {
static VirtTextChunk chunk = { .text = "[Process suspended]", .hl_id = -1 };
static DecorVirtText virt_text = {
.priority = DECOR_PRIORITY_BASE,
.pos = kVPosWinCol,
.data.virt_text = { .items = &chunk, .size = 1 },
};
decor_range_add_virt(&decor_state, buf->b_ml.ml_line_count - 1, 0,
buf->b_ml.ml_line_count - 1, 0, &virt_text, false);
}
FOR_ALL_WINDOWS_IN_TAB(win, curtab) {
if (win->w_buffer == wp->w_buffer && win_redraw_signcols(win)) {
changed_line_abv_curs_win(win);

View File

@@ -152,6 +152,7 @@ typedef enum {
/// OS process
typedef struct proc Proc;
typedef void (*proc_exit_cb)(Proc *proc, int status, void *data);
typedef void (*proc_state_cb)(Proc *proc, bool suspended, void *data);
typedef void (*internal_proc_cb)(Proc *proc);
struct proc {
@@ -169,6 +170,7 @@ struct proc {
RStream out, err;
/// Exit handler. If set, user must call proc_free().
proc_exit_cb cb;
proc_state_cb state_cb;
internal_proc_cb internal_exit_cb, internal_close_cb;
bool closed, detach, overlapped, fwd_err;
MultiQueue *events;

View File

@@ -25,6 +25,7 @@ static inline Proc proc_init(Loop *loop, ProcType type, void *data)
.out = { .s.closed = false, .s.fd = STDOUT_FILENO },
.err = { .s.closed = false, .s.fd = STDERR_FILENO },
.cb = NULL,
.state_cb = NULL,
.closed = false,
.internal_close_cb = NULL,
.internal_exit_cb = NULL,

View File

@@ -239,6 +239,11 @@ void pty_proc_resize(PtyProc *ptyproc, uint16_t width, uint16_t height)
ioctl(ptyproc->tty_fd, TIOCSWINSZ, &ptyproc->winsize);
}
void pty_proc_resume(PtyProc *ptyproc)
{
kill(((Proc *)ptyproc)->pid, SIGCONT);
}
void pty_proc_close(PtyProc *ptyproc)
FUNC_ATTR_NONNULL_ALL
{
@@ -397,13 +402,22 @@ static void chld_handler(uv_signal_t *handle, int signum)
for (size_t i = 0; i < kv_size(loop->children); i++) {
Proc *proc = kv_A(loop->children, i);
do {
pid = waitpid(proc->pid, &stat, WNOHANG);
pid = waitpid(proc->pid, &stat, WNOHANG|WUNTRACED|WCONTINUED);
} while (pid < 0 && errno == EINTR);
if (pid <= 0) {
continue;
}
if (WIFSTOPPED(stat)) {
proc->state_cb(proc, true, proc->data);
continue;
}
if (WIFCONTINUED(stat)) {
proc->state_cb(proc, false, proc->data);
continue;
}
if (WIFEXITED(stat)) {
proc->status = WEXITSTATUS(stat);
} else if (WIFSIGNALED(stat)) {

View File

@@ -174,6 +174,8 @@ struct terminal {
bool in_altscreen;
// program exited
bool closed;
// program suspended
bool suspended;
// when true, the terminal's destruction is already enqueued.
bool destroy;
@@ -672,7 +674,6 @@ void terminal_close(Terminal **termpp, int status)
// only need to call the close callback to clean up the terminal object.
only_destroy = true;
} else {
term->forward_mouse = false;
// flush any pending changes to the buffer
if (!exiting) {
block_autocmds();
@@ -726,7 +727,33 @@ void terminal_close(Terminal **termpp, int status)
}
}
static void terminal_state_change_event(void **argv)
{
handle_T buf_handle = (handle_T)(intptr_t)argv[0];
buf_T *buf = handle_get_buffer(buf_handle);
if (buf && buf->terminal) {
// Don't change the actual terminal content to indicate the suspended state here,
// as unlike the process exit case the change needs to be reversed on resume.
// Instead, the code in win_update() will add a "[Process suspended]" virtual text
// at the botton-left of the buffer.
redraw_buf_line_later(buf, buf->b_ml.ml_line_count, false);
}
}
/// Updates the suspended state of the terminal program.
void terminal_set_state(Terminal *term, bool suspended)
FUNC_ATTR_NONNULL_ALL
{
if (term->suspended != suspended) {
// Trigger a main loop iteration to redraw the buffer.
multiqueue_put(main_loop.events, terminal_state_change_event,
(void *)(intptr_t)term->buf_handle);
}
term->suspended = suspended;
}
void terminal_check_size(Terminal *term)
FUNC_ATTR_NONNULL_ALL
{
if (term->closed) {
return;
@@ -951,9 +978,14 @@ static void terminal_check_cursor(void)
if (topline != curwin->w_topline) {
set_topline(curwin, topline);
}
// Nudge cursor when returning to normal-mode.
int off = is_focused(term) ? 0 : (curwin->w_p_rl ? 1 : -1);
coladvance(curwin, MAX(0, term->cursor.col + off));
if (term->suspended) {
// If the terminal process is suspended, keep cursor at the bottom-left corner.
curwin->w_cursor = (pos_T){ .lnum = curbuf->b_ml.ml_line_count };
} else {
// Nudge cursor when returning to normal-mode.
int off = is_focused(term) ? 0 : (curwin->w_p_rl ? 1 : -1);
coladvance(curwin, MAX(0, term->cursor.col + off));
}
}
static bool terminal_check_focus(TerminalState *const s)
@@ -1129,6 +1161,13 @@ static int terminal_execute(VimState *state, int key)
s->got_bsl = true;
break;
}
if (s->term->suspended) {
s->term->opts.resume_cb(s->term->opts.data);
// XXX: detecting continued process via waitpid() on SIGCHLD doesn't always work
// (e.g. on macOS), so also consider it continued after sending SIGCONT.
terminal_set_state(s->term, false);
break;
}
if (s->term->closed) {
s->close = true;
return 0;
@@ -1393,15 +1432,23 @@ void terminal_get_line_attributes(Terminal *term, win_T *wp, int linenr, int *te
}
Buffer terminal_buf(const Terminal *term)
FUNC_ATTR_NONNULL_ALL
{
return term->buf_handle;
}
bool terminal_running(const Terminal *term)
FUNC_ATTR_NONNULL_ALL
{
return !term->closed;
}
bool terminal_suspended(const Terminal *term)
FUNC_ATTR_NONNULL_ALL
{
return term->suspended;
}
void terminal_notify_theme(Terminal *term, bool dark)
FUNC_ATTR_NONNULL_ALL
{
@@ -2063,7 +2110,8 @@ static bool send_mouse_event(Terminal *term, int c)
}
int offset;
if (term->forward_mouse && mouse_win->w_buffer->terminal == term && row >= 0
if (!term->suspended && !term->closed
&& term->forward_mouse && mouse_win->w_buffer->terminal == term && row >= 0
&& (grid > 1 || row + mouse_win->w_winbar_height < mouse_win->w_height)
&& col >= (offset = win_col_off(mouse_win))
&& (grid > 1 || col < mouse_win->w_width)) {

View File

@@ -9,6 +9,7 @@
typedef void (*terminal_write_cb)(const char *buffer, size_t size, void *data);
typedef void (*terminal_resize_cb)(uint16_t width, uint16_t height, void *data);
typedef void (*terminal_resume_cb)(void *data);
typedef void (*terminal_close_cb)(void *data);
typedef struct {
@@ -16,6 +17,7 @@ typedef struct {
uint16_t width, height;
terminal_write_cb write_cb;
terminal_resize_cb resize_cb;
terminal_resume_cb resume_cb;
terminal_close_cb close_cb;
bool force_crlf;
} TerminalOptions;

View File

@@ -450,6 +450,105 @@ describe(':terminal buffer', function()
]])
assert_alive()
end)
describe('handles suspended PTY process', function()
if skip(is_os('win'), 'N/A for Windows') then
return
end
--- @param external_resume boolean
local function test_term_process_suspend_resume(external_resume)
command('set mousemodel=extend')
local pid = eval('jobpid(&channel)')
vim.uv.kill(pid, 'sigstop')
screen:expect([[
tty ready |
|*4
^[Process suspended] |
{5:-- TERMINAL --} |
]])
command('set laststatus=0 | botright vsplit')
screen:expect([[
tty ready │tty ready |
│ |*4
[Process suspended] │^[Process suspended] |
{5:-- TERMINAL --} |
]])
-- Resize is detected by the process on resume.
if external_resume then
vim.uv.kill(pid, 'sigcont')
else
feed('a')
end
screen:expect([[
tty ready │tty ready |
rows: 6, cols: 25 │rows: 6, cols: 25 |
│^ |
│ |*3
{5:-- TERMINAL --} |
]])
tt.enable_mouse()
tt.feed_data('mouse enabled\n\n\n\n')
screen:expect([[
rows: 6, cols: 25 │rows: 6, cols: 25 |
mouse enabled │mouse enabled |
│ |*3
│^ |
{5:-- TERMINAL --} |
]])
api.nvim_input_mouse('right', 'press', '', 0, 0, 25)
screen:expect({ any = vim.pesc('"!!^') })
api.nvim_input_mouse('right', 'release', '', 0, 0, 25)
screen:expect({ any = vim.pesc('#!!^') })
vim.uv.kill(pid, 'sigstop')
local s1 = [[
rows: 6, cols: 25 │rows: 6, cols: 25 |
mouse enabled │mouse enabled |
│ |*3
[Process suspended] │^[Process suspended] |
{5:-- TERMINAL --} |
]]
screen:expect(s1)
-- Mouse isn't forwarded when process is suspended.
api.nvim_input_mouse('right', 'press', '', 0, 1, 27)
api.nvim_input_mouse('right', 'release', '', 0, 1, 27)
screen:expect([[
rows: 6, cols: 25 │rows: 6, cols: 25 |
mo{108:use enabled} │mo^u{108:se enabled} |
{108: } │{108: } |*3
[Process suspended] │[Process suspended] |
{5:-- VISUAL --} |
]])
feed('<Esc>i')
screen:expect(s1)
if external_resume then
vim.uv.kill(pid, 'sigcont')
else
feed('a')
end
screen:expect([[
rows: 6, cols: 25 │rows: 6, cols: 25 |
mouse enabled │mouse enabled |
│ |*3
#!! │ #!!^ |
{5:-- TERMINAL --} |
]])
-- Mouse is forwarded after process is resumed.
api.nvim_input_mouse('right', 'press', '', 0, 0, 28)
screen:expect({ any = vim.pesc('"$!^') })
api.nvim_input_mouse('right', 'release', '', 0, 0, 28)
screen:expect({ any = vim.pesc('#$!^') })
end
it('resumed by an external signal', function()
skip(is_os('mac'), 'FIXME: does not work on macOS')
test_term_process_suspend_resume(true)
end)
it('resumed by pressing a key', function()
test_term_process_suspend_resume(false)
end)
end)
end)
describe(':terminal buffer', function()

View File

@@ -100,11 +100,11 @@ describe('TUI', function()
]])
else -- resuming works on other platforms
screen:expect([[
^ |
|*5
^[Process suspended] |
{5:-- TERMINAL --} |
]])
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
n.feed('<Space>')
screen:expect(s0)
end
feed_data(':')
@@ -4409,7 +4409,6 @@ describe('TUI client', function()
it('suspend/resume works with multiple clients', function()
t.skip(is_os('win'), 'N/A for Windows')
local server_super, screen_server, screen_client = start_tui_and_remote_client()
local server_super_exec_lua = tt.make_lua_executor(server_super)
local screen_normal = [[
Hello, Worl^d |
@@ -4419,8 +4418,8 @@ describe('TUI client', function()
{5:-- TERMINAL --} |
]]
local screen_suspended = [[
^ |
|*5
^[Process suspended] |
{5:-- TERMINAL --} |
]]
@@ -4433,12 +4432,12 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended })
-- Resume the remote client.
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_suspended, unchanged = true })
-- Resume the embedding client.
server_super_exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
server_super:request('nvim_input', '<Space>')
screen_server:expect({ grid = screen_normal })
screen_client:expect({ grid = screen_normal, unchanged = true })
@@ -4448,7 +4447,7 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended })
-- Resume the remote client.
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_suspended, unchanged = true })
@@ -4458,12 +4457,12 @@ describe('TUI client', function()
screen_server:expect({ grid = screen_suspended, unchanged = true })
-- Resume the embedding client.
server_super_exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
server_super:request('nvim_input', '<Space>')
screen_server:expect({ grid = screen_normal })
screen_client:expect({ grid = screen_suspended, unchanged = true })
-- Resume the remote client.
exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigcont')]])
n.feed('<Space>')
screen_client:expect({ grid = screen_normal })
screen_server:expect({ grid = screen_normal, unchanged = true })