Support Eshell iterative evaluation in the background

This really just generalizes Eshell's previous support for iterative
evaluation of a single current command to a list of multiple commands,
of which at most one can be in the foreground (bug#66066).

* lisp/eshell/esh-cmd.el (eshell-last-async-procs)
(eshell-current-command): Make obsolete in favor of...
(eshell-foreground-command): ... this
(eshell-background-commands): New variable.
(eshell-interactive-process-p): Make obsolete.
(eshell-head-process, eshell-tail-process): Use
'eshell-foreground-command'.
(eshell-cmd-initialize): Initialize new variables.
(eshell-add-command, eshell-remove-command)
(eshell-commands-for-process): New functions.
(eshell-parse-command): Make 'eshell-do-subjob' the outermost call.
(eshell-do-subjob): Call 'eshell-resume-eval' to split this command
off from its parent forms.
(eshell-eval-command): Use 'eshell-add-command'.
(eshell-resume-command): Use 'eshell-commands-for-process'.
(eshell-resume-eval): Take a COMMAND argument.  Return
':eshell-background' form for deferred background commands.
(eshell-do-eval): Remove check for 'eshell-current-subjob-p'.  This is
handled differently now.

* lisp/eshell/eshell.el (eshell-command): Wait for all processes to
exit when running synchronously.

* lisp/eshell/esh-mode.el (eshell-intercept-commands)
(eshell-watch-for-password-prompt):
* lisp/eshell/em-cmpl.el (eshell-complete-parse-arguments):
* lisp/eshell/em-smart.el (eshell-smart-display-move): Use
'eshell-foreground-command'.

* test/lisp/eshell/esh-cmd-tests.el
(esh-cmd-test/background/simple-command)
(esh-cmd-test/background/subcommand): New tests.
(esh-cmd-test/throw): Use 'eshell-foreground-command'.

* test/lisp/eshell/eshell-tests.el (eshell-test/queue-input): Use
'eshell-foreground-command'.

* test/lisp/eshell/em-script-tests.el
(em-script-test/source-script/background): Make the test script more
complex.

* test/lisp/eshell/eshell-tests.el
(eshell-test/eshell-command/pipeline-wait): New test.

* doc/misc/eshell.texi (Bugs and ideas): Remove implemented feature.
This commit is contained in:
Jim Porter 2023-09-23 11:36:11 -07:00
parent 8f2cfe15a7
commit 498d31e9f0
9 changed files with 164 additions and 72 deletions

View file

@ -2568,8 +2568,6 @@ A special associate array, which can take references of the form
@samp{$=[REGEXP]}. It indexes into the directory ring.
@end table
@item Eshell scripts can't execute in the background
@item Support zsh's ``Parameter Expansion'' syntax, i.e., @samp{$@{@var{name}:-@var{val}@}}
@item Create a mode @code{eshell-browse}

View file

@ -343,7 +343,7 @@ to writing a completion function."
(defun eshell-complete-parse-arguments ()
"Parse the command line arguments for `pcomplete-argument'."
(when (and eshell-no-completion-during-jobs
(eshell-interactive-process-p))
eshell-foreground-command)
(eshell--pcomplete-insert-tab))
(let ((end (point-marker))
(begin (save-excursion (beginning-of-line) (point)))

View file

@ -294,7 +294,7 @@ and the end of the buffer are still visible."
((eq this-command 'self-insert-command)
(if (eq last-command-event ? )
(if (and eshell-smart-space-goes-to-end
eshell-current-command)
eshell-foreground-command)
(if (not (pos-visible-in-window-p (point-max)))
(setq this-command 'scroll-up)
(setq this-command 'eshell-smart-goto-end))

View file

@ -263,7 +263,24 @@ command line.")
;;; Internal Variables:
(defvar eshell-current-command nil)
;; These variables have been merged into `eshell-foreground-command'.
;; Outside of this file, the most-common use for them is to check
;; whether they're nil.
(define-obsolete-variable-alias 'eshell-last-async-procs
'eshell-foreground-command "30.1")
(define-obsolete-variable-alias 'eshell-current-command
'eshell-foreground-command "30.1")
(defvar eshell-foreground-command nil
"The currently-running foreground command, if any.
This is a list of the form (FORM PROCESSES). FORM is the Eshell
command form. PROCESSES is a list of processes that deferred the
command.")
(defvar eshell-background-commands nil
"A list of currently-running deferred commands.
Each element is of the form (FORM PROCESSES), as with
`eshell-foreground-command' (which see).")
(defvar eshell-command-name nil)
(defvar eshell-command-arguments nil)
(defvar eshell-in-pipeline-p nil
@ -273,11 +290,6 @@ otherwise t.")
(defvar eshell-in-subcommand-p nil)
(defvar eshell-last-arguments nil)
(defvar eshell-last-command-name nil)
(defvar eshell-last-async-procs nil
"The currently-running foreground process(es).
When executing a pipeline, this is a list of all the pipeline's
processes, with the first usually reading from stdin and last
usually writing to stdout.")
(defvar eshell-allow-commands t
"If non-nil, allow evaluating command forms (including Lisp forms).
@ -294,29 +306,30 @@ also `eshell-complete-parse-arguments'.")
(defsubst eshell-interactive-process-p ()
"Return non-nil if there is a currently running command process."
eshell-last-async-procs)
(declare (obsolete 'eshell-foreground-command "30.1"))
eshell-foreground-command)
(defsubst eshell-head-process ()
"Return the currently running process at the head of any pipeline.
This only returns external (non-Lisp) processes."
(car eshell-last-async-procs))
(caadr eshell-foreground-command))
(defsubst eshell-tail-process ()
"Return the currently running process at the tail of any pipeline.
This only returns external (non-Lisp) processes."
(car (last eshell-last-async-procs)))
(car (last (cadr eshell-foreground-command))))
(define-obsolete-function-alias 'eshell-interactive-process
'eshell-tail-process "29.1")
(defun eshell-cmd-initialize () ;Called from `eshell-mode' via intern-soft!
"Initialize the Eshell command processing module."
(setq-local eshell-current-command nil)
(setq-local eshell-foreground-command nil)
(setq-local eshell-background-commands nil)
(setq-local eshell-command-name nil)
(setq-local eshell-command-arguments nil)
(setq-local eshell-last-arguments nil)
(setq-local eshell-last-command-name nil)
(setq-local eshell-last-async-procs nil)
(add-hook 'eshell-kill-hook #'eshell-resume-command nil t)
(add-hook 'eshell-parse-argument-hook
@ -337,6 +350,47 @@ This only returns external (non-Lisp) processes."
(throw 'pcomplete-completions
(all-completions pcomplete-stub obarray 'boundp)))))
;; Current command management
(defun eshell-add-command (form &optional background)
"Add a command FORM to our list of known commands and return the new entry.
If non-nil, BACKGROUND indicates that this is a command running
in the background. The result is a command entry in the
form (BACKGROUND FORM PROCESSES), where PROCESSES is initially
nil."
(cons (when background 'background)
(if background
(car (push (list form nil) eshell-background-commands))
(cl-assert (null eshell-foreground-command))
(setq eshell-foreground-command (list form nil)))))
(defun eshell-remove-command (command)
"Remove COMMAND from our list of known commands.
COMMAND should be a list of the form (BACKGROUND FORM PROCESSES),
as returned by `eshell-add-command' (which see)."
(let ((background (car command))
(entry (cdr command)))
(if background
(setq eshell-background-commands
(delq entry eshell-background-commands))
(cl-assert (eq eshell-foreground-command entry))
(setq eshell-foreground-command nil))))
(defun eshell-commands-for-process (process)
"Return all commands associated with a PROCESS.
Each element will have the form (BACKGROUND FORM PROCESSES), as
returned by `eshell-add-command' (which see).
Usually, there should only be one element in this list, but it's
theoretically possible to have more than one associated command
for a given process."
(nconc (when (memq process (cadr eshell-foreground-command))
(list (cons nil eshell-foreground-command)))
(seq-keep (lambda (cmd)
(when (memq process (cadr cmd))
(cons 'background cmd)))
eshell-background-commands)))
;; Command parsing
(defsubst eshell--region-p (object)
@ -407,8 +461,6 @@ command hooks should be run before and after the command."
(lambda (cmd)
(let ((sep (pop sep-terms)))
(setq cmd (eshell-parse-pipeline cmd))
(when (equal sep "&")
(setq cmd `(eshell-do-subjob (cons :eshell-background ,cmd))))
(unless eshell-in-pipeline-p
(setq cmd `(eshell-trap-errors ,cmd)))
;; Copy I/O handles so each full statement can manipulate
@ -416,6 +468,8 @@ command hooks should be run before and after the command."
;; command in the list; we won't use the originals again
;; anyway.
(setq cmd `(eshell-with-copied-handles ,cmd ,(not sep)))
(when (equal sep "&")
(setq cmd `(eshell-do-subjob ,cmd)))
cmd))
sub-chains)))
(if toplevel
@ -740,13 +794,13 @@ if none)."
(defmacro eshell-do-subjob (object)
"Evaluate a command OBJECT as a subjob.
We indicate that the process was run in the background by returning it
ensconced in a list."
We indicate that the process was run in the background by
returning it as (:eshell-background . PROCESSES)."
`(let ((eshell-current-subjob-p t)
;; Print subjob messages. This could have been cleared
;; (e.g. by `eshell-source-file', which see).
(eshell-subjob-messages t))
,object))
(eshell-resume-eval (eshell-add-command ',object 'background))))
(defmacro eshell-commands (object &optional silent)
"Place a valid set of handles, and context, around command OBJECT."
@ -980,12 +1034,12 @@ Return the process (or head and tail processes) created by
COMMAND, if any. If COMMAND is a background command, return the
process(es) in a cons cell like:
(:eshell-background . PROCESS)"
(if eshell-current-command
(:eshell-background . PROCESSES)"
(if eshell-foreground-command
(progn
;; We can just stick the new command at the end of the current
;; one, and everything will happen as it should.
(setcdr (last (cdr eshell-current-command))
(setcdr (last (cdar eshell-foreground-command))
(list `(let ((here (and (eobp) (point))))
,(and input
`(insert-and-inherit ,(concat input "\n")))
@ -994,56 +1048,61 @@ process(es) in a cons cell like:
(eshell-do-eval ',command))))
(eshell-debug-command 'form
"enqueued command form for %S\n\n%s"
(or input "<no string>") (eshell-stringify eshell-current-command)))
(or input "<no string>")
(eshell-stringify (car eshell-foreground-command))))
(eshell-debug-command-start input)
(setq eshell-current-command command)
(let* (result
(delim (catch 'eshell-incomplete
(ignore (setq result (eshell-resume-eval))))))
(ignore (setq result (eshell-resume-eval
(eshell-add-command command)))))))
(when delim
(error "Unmatched delimiter: %S" delim))
result)))
(defun eshell-resume-command (proc status)
"Resume the current command when a pipeline ends."
(when (and proc
;; Make sure PROC is one of our foreground processes and
;; that all of those processes are now dead.
(member proc eshell-last-async-procs)
(not (seq-some #'eshell-process-active-p eshell-last-async-procs)))
(if (and ;; Check STATUS to determine whether we want to resume or
;; abort the command.
(stringp status)
(not (string= "stopped" status))
(not (string-match eshell-reset-signals status)))
(eshell-resume-eval)
(setq eshell-last-async-procs nil)
(setq eshell-current-command nil)
(declare-function eshell-reset "esh-mode" (&optional no-hooks))
(eshell-reset))))
"Resume the current command when a pipeline ends.
PROC is the process that invoked this from its sentinel, and
STATUS is its status."
(when proc
(dolist (command (eshell-commands-for-process proc))
(unless (seq-some #'eshell-process-active-p (nth 2 command))
(setf (nth 2 command) nil) ; Clear processes from command.
(if (and ;; Check STATUS to determine whether we want to resume or
;; abort the command.
(stringp status)
(not (string= "stopped" status))
(not (string-match eshell-reset-signals status)))
(eshell-resume-eval command)
(eshell-remove-command command)
(declare-function eshell-reset "esh-mode" (&optional no-hooks))
(eshell-reset))))))
(defun eshell-resume-eval ()
"Destructively evaluate a form which may need to be deferred."
(setq eshell-last-async-procs nil)
(when eshell-current-command
(eshell-condition-case err
(let (retval procs)
(unwind-protect
(progn
(setq procs (catch 'eshell-defer
(ignore (setq retval
(eshell-do-eval
eshell-current-command)))))
(when retval
(cadr retval)))
(setq eshell-last-async-procs procs)
(defun eshell-resume-eval (command)
"Destructively evaluate a COMMAND which may need to be deferred.
COMMAND is a command entry of the form (BACKGROUND FORM
PROCESSES) (see `eshell-add-command').
Return the result of COMMAND's FORM if it wasn't deferred. If
BACKGROUND is non-nil and Eshell defers COMMAND, return a list of
the form (:eshell-background . PROCESSES)."
(eshell-condition-case err
(let (retval procs)
(unwind-protect
(progn
(setq procs
(catch 'eshell-defer
(ignore (setq retval (eshell-do-eval (cadr command))))))
(cond
(retval (cadr retval))
((car command) (cons :eshell-background procs))))
(if procs
(setf (nth 2 command) procs)
;; If we didn't defer this command, clear it out. This
;; applies both when the command has finished normally,
;; and when a signal or thrown value causes us to unwind.
(unless procs
(setq eshell-current-command nil))))
(error
(error (error-message-string err))))))
(eshell-remove-command command))))
(error
(error (error-message-string err)))))
(defmacro eshell-manipulate (form tag &rest body)
"Manipulate a command FORM with BODY, using TAG as a debug identifier."
@ -1272,7 +1331,6 @@ have been replaced by constants."
(setcdr form (cdr new-form)))
(eshell-do-eval form synchronous-p))
(if-let (((memq (car form) eshell-deferrable-commands))
((not eshell-current-subjob-p))
(procs (eshell-make-process-list result)))
(if synchronous-p
(apply #'eshell/wait procs)

View file

@ -453,7 +453,7 @@ and the hook `eshell-exit-hook'."
last-command-event))))
(defun eshell-intercept-commands ()
(when (and (eshell-interactive-process-p)
(when (and eshell-foreground-command
(not (and (integerp last-input-event)
(memq last-input-event '(?\C-x ?\C-c)))))
(let ((possible-events (where-is-internal this-command))
@ -967,7 +967,7 @@ buffer's process if STRING contains a password prompt defined by
`eshell-password-prompt-regexp'.
This function could be in the list `eshell-output-filter-functions'."
(when (eshell-interactive-process-p)
(when eshell-foreground-command
(save-excursion
(let ((case-fold-search t))
(goto-char eshell-last-output-block-begin)

View file

@ -315,9 +315,8 @@ argument), then insert output into the current buffer at point."
;; make the output as attractive as possible, with no
;; extraneous newlines
(when intr
(if (eshell-interactive-process-p)
(eshell-wait-for-process (eshell-tail-process)))
(cl-assert (not (eshell-interactive-process-p)))
(apply #'eshell-wait-for-process (cadr eshell-foreground-command))
(cl-assert (not eshell-foreground-command))
(goto-char (point-max))
(while (and (bolp) (not (bobp)))
(delete-char -1)))

View file

@ -67,14 +67,14 @@
"Test sourcing a script in the background."
(skip-unless (executable-find "echo"))
(ert-with-temp-file temp-file
:text "*echo hi"
:text "*echo hi\nif {[ foo = foo ]} {*echo bye}"
(eshell-with-temp-buffer bufname "old"
(with-temp-eshell
(eshell-match-command-output
(format "source %s > #<%s> &" temp-file bufname)
"\\`\\'")
(eshell-wait-for-subprocess t))
(should (equal (buffer-string) "hi\n")))))
(should (equal (buffer-string) "hi\nbye\n")))))
(ert-deftest em-script-test/source-script/arg-vars ()
"Test sourcing script with $0, $1, ... variables."

View file

@ -103,6 +103,32 @@ bug#59469."
"}")
"value\nexternal\nvalue\n")))
;; Background command invocation
(ert-deftest esh-cmd-test/background/simple-command ()
"Test invocation with a simple background command."
(skip-unless (executable-find "echo"))
(eshell-with-temp-buffer bufname ""
(with-temp-eshell
(eshell-match-command-output
(format "*echo hi > #<%s> &" bufname)
(rx "[echo" (? ".exe") "] " (+ digit) "\n"))
(eshell-wait-for-subprocess t))
(should (equal (buffer-string) "hi\n"))))
(ert-deftest esh-cmd-test/background/subcommand ()
"Test invocation with a background command containing subcommands."
(skip-unless (and (executable-find "echo")
(executable-find "rev")))
(eshell-with-temp-buffer bufname ""
(with-temp-eshell
(eshell-match-command-output
(format "*echo ${*echo hello | rev} > #<%s> &" bufname)
(rx "[echo" (? ".exe") "] " (+ digit) "\n"))
(eshell-wait-for-subprocess t))
(should (equal (buffer-string) "olleh\n"))))
;; Lisp forms
@ -453,8 +479,7 @@ This tests when `eshell-lisp-form-nil-is-failure' is nil."
"echo hi; (throw 'tag 42); echo bye"))
42))
(should (eshell-match-output "\\`hi\n\\'"))
(should-not eshell-current-command)
(should-not eshell-last-async-procs)
(should-not eshell-foreground-command)
;; Make sure we can call another command after throwing.
(eshell-match-command-output "echo again" "\\`again\n")))

View file

@ -58,6 +58,18 @@ This test uses a pipeline for the command."
(eshell-command "*echo hi | *cat" t)
(should (equal (buffer-string) "hi\n"))))))
(ert-deftest eshell-test/eshell-command/pipeline-wait ()
"Check that `eshell-command' waits for all its processes before returning."
(skip-unless (and (executable-find "echo")
(executable-find "sh")
(executable-find "rev")))
(ert-with-temp-directory eshell-directory-name
(let ((eshell-history-file-name nil))
(with-temp-buffer
(eshell-command
"*echo hello | sh -c 'sleep 1; rev' 1>&2 | *echo goodbye" t)
(should (equal (buffer-string) "goodbye\nolleh\n"))))))
(ert-deftest eshell-test/eshell-command/background ()
"Test that `eshell-command' works for background commands."
(skip-unless (executable-find "echo"))
@ -132,7 +144,7 @@ insert the queued one at the next prompt, and finally run it."
(eshell-insert-command "sleep 1; echo slept")
(eshell-insert-command "echo alpha" #'eshell-queue-input)
(let ((start (marker-position (eshell-beginning-of-output))))
(eshell-wait-for (lambda () (not eshell-current-command)))
(eshell-wait-for (lambda () (not eshell-foreground-command)))
(should (string-match "^slept\n.*echo alpha\nalpha\n$"
(buffer-substring-no-properties
start (eshell-end-of-output)))))))