ambevar-dotfiles/.emacs.d/lisp/package-eshell-detach.el

322 lines
15 KiB
EmacsLisp
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

;;; eshell-detach.el
;; Instead of sending the command prompt to Eshell, send it to a bash shell run in a dtach session.
;; dtach allows the user to disconnect (quit Eshell or even Emacs) while the command keeps going.
;; Stderr and stdout can be both displayed on screen and redirected to a file thanks to the `tee' program.
;;
;; dtach allows the command line processor (that is, Bash) to run in the background.
;; Bash allows for:
;; - using `tee' to separate stdout/stderr, output both in files and on screen;
;; - executing pipe lines (e.g. grep foo | sed ... | cut | wc). dtach cannot do that alone.
;;
;; Bash is necessary here. If we want to run Eshell within dtach, we would need
;; to run Emacs in --batch mode:
;;
;; emacs --batch --eval '(progn (eshell) (insert "echo hello") (eshell-send-input))'
;;
;; Issues: Eshell mixes stdout and stderr. (--batch sends to stderr.) How do we redirect the output to the real stdout/stderr?
;; TODO: Support Eshell nonetheless?
;; TODO: Move eshell to a lower level of abstraction. This package should be called "detach.el".
;; TODO: Remove bash / tee / dtach dependencies? I don't think dtach can be removed.
;;; See if `make-process' is the way to go: it supports stderr/stdout separation and stop/cont.
;;; Re-use `eshell-gather-process-output'? Re-implement?
;; TODO: Add list of regexp matches for which detach should automatically be used.
(defvar eshell-detach-program "dtach"
"The `dtach' program.")
(defvar eshell-detach-redraw-method nil
"If nil, use the default value.
Value must be a string.
See dtach(1) for possible values.")
(defvar eshell-detach-shell "bash"
"Shell to run the command in.
Should be bash-compatible.
The end command will be
\"`eshell-detach-shell' -c { { <command>; } > >(tee stdout) } 2> >(tee stderr) | tee stdout+stderr\"")
;; TODO: Set the detach character? No need when `C-c C-c` suffices.
;; (defvar eshell-detach-detach-character "^\\"
;; "Charcter to press to detach dtach, i.e. leave the process run in the background.
;; The character syntax follows terminal notations, not Emacs.")
;;
;; (defvar eshell-detach-detach-character-binding "C-\\"
;; "The Emacs binding matching `eshell-detach-detach-character'.")
(defvar eshell-detach-socket-ext ".socket"
"The file name extension for the socket fo `eshell-detach-program'.")
(defvar eshell-detach-stdout-ext ".stdout"
"If non-nil and a string, stdout will also be saved to file named after the socket with this extension appened.
The 'tee' program is required.")
(defvar eshell-detach-stderr-ext ".stderr"
"If non-nil and a string, stderr will also be saved to file named after the socket with this extension appened.
The 'tee' program is required.")
(defvar eshell-detach-stdout+stderr-ext ".stdout+stderr"
"If non-nil and a string, stdout and stderr will also be saved to file named after the socket with this extension appened.
The 'tee' program is required.")
;; TODO: Turn into a defcustom, and offer `user-emacs-directory' as well.
(defvar eshell-detach-directory (if server-socket-dir server-socket-dir temporary-file-directory)
"The directory where to store the dtach socket and the logs.")
(defvar eshell-detach-file-pattern-function 'eshell-detach-default-file-pattern
"Function that takes the commandline as argument and returns
the name of all the dtach-related files (output and socket).")
(defun eshell-detach-default-file-pattern (commandline)
"Create a pattern made of the alphanumerical translation of the commandline.
Characters that don't fit are replaced with '_'.
An ISO date string is appended.
Suitable for `eshell-detach-file-pattern-function'."
(format "-%s-%s-"
(replace-regexp-in-string "[^A-Za-z0-9=-]" "_" commandline)
(format-time-string "%F-%R:%S")))
;; `eshell-named-command-hook' is not the way to go as it won't take pipelines. What about
;; `eshell-rewrite-command-hook'?
(defun eshell-detach-rewrite-input (input)
"Rewrite INPUT so that it is ready for detaching."
;; Since sockets get killed on termination, there won't be any leftover if
;; there is no log. Thus it is cleaner to _not_ create a sub-directory.
;; `tee' creates log files even if nothing is output. We cleanup on exit by
;; deleting 0-byte files.
(let* (
;; TODO: temp-file should not exist for dtach to start? That forces us
;; to use make-temp-file which is vulnerable to race condition.
;; TODO: Read `input' safely. E.g. from a file.
(socket (make-temp-name
(expand-file-name
(concat "dtach" (funcall eshell-detach-file-pattern-function input))
eshell-detach-directory)))
(stdout (and eshell-detach-stdout-ext (concat socket eshell-detach-stdout-ext)))
(stderr (and eshell-detach-stderr-ext (concat socket eshell-detach-stderr-ext)))
(stdout+stderr (and eshell-detach-stdout+stderr-ext (concat socket eshell-detach-stdout+stderr-ext)))
(socket (concat socket eshell-detach-socket-ext))
;; The following bash command was inspired by
;; https://stackoverflow.com/questions/21465297/tee-stdout-and-stderr-to-separate-files-while-retaining-them-on-their-respective.
;;
;; { { echo stdout; echo stderr >&2; } > >(tee stdout.txt ); } 2> >(tee stderr.txt ) | tee stdout+stderr.txt
(commandline (format "{ { %s; }%s }%s %s; for i in %s %s %s; do [ ! -s \"$i\" ] && rm -- \"$i\"; done"
input
;; TODO: Move `tee' to a defvar.
(if stdout (format " > >(tee %s );" stdout) "")
(if stderr (format " 2> >(tee %s )" stderr) "")
(if stdout+stderr (format " | tee %s" stdout+stderr) "")
(shell-quote-argument (or stdout ""))
(shell-quote-argument (or stderr ""))
(shell-quote-argument (or stdout+stderr "")))))
(format "%s -c %s -z %s -c %s" eshell-detach-program socket eshell-detach-shell (shell-quote-argument commandline))))
(defun eshell-detach--list-sockets ()
"List sockets of `eshell-detach-program'."
(file-expand-wildcards (concat
(expand-file-name "dtach-"
eshell-detach-directory)
"*" eshell-detach-socket-ext)))
(defun eshell-detach-attach ()
"Attach to a running session of `eshell-detach-program'."
(interactive)
(let ((socket (completing-read "Attach to session: " (eshell-detach--list-sockets) nil t)))
(when socket
(when (or (eshell-interactive-process)
(/= (point) eshell-last-output-end))
(eshell-interrupt-process))
(goto-char (point-max))
;; TODO: Redraw method?
(insert eshell-detach-program " -a " (shell-quote-argument socket))
(eshell-send-input))))
;;; This is almost an exact copy of `eshell-send-input'.
(defun eshell-detach-send-input (&optional use-region queue-p no-newline)
"Send the input received to Eshell for parsing and processing.
After `eshell-last-output-end', sends all text from that marker to
point as input. Before that marker, calls `eshell-get-old-input' to
retrieve old input, copies it to the end of the buffer, and sends it.
If USE-REGION is non-nil, the current region (between point and mark)
will be used as input.
If QUEUE-P is non-nil, input will be queued until the next prompt,
rather than sent to the currently active process. If no process, the
input is processed immediately.
If NO-NEWLINE is non-nil, the input is sent without an implied final
newline."
(interactive "P")
;; Note that the input string does not include its terminal newline.
(let ((proc-running-p (and (eshell-interactive-process)
(not queue-p)))
(inhibit-point-motion-hooks t)
(inhibit-modification-hooks t))
(unless (and proc-running-p
(not (eq (process-status
(eshell-interactive-process))
'run)))
(if (or proc-running-p
(>= (point) eshell-last-output-end))
(goto-char (point-max))
(let ((copy (eshell-get-old-input use-region)))
(goto-char eshell-last-output-end)
(insert-and-inherit copy)))
(unless (or no-newline
(and eshell-send-direct-to-subprocesses
proc-running-p))
(insert-before-markers-and-inherit ?\n))
(if proc-running-p
(progn
(eshell-update-markers eshell-last-output-end)
(if (or eshell-send-direct-to-subprocesses
(= eshell-last-input-start eshell-last-input-end))
(unless no-newline
(process-send-string (eshell-interactive-process) "\n"))
(process-send-region (eshell-interactive-process)
eshell-last-input-start
eshell-last-input-end)))
(if (= eshell-last-output-end (point))
(run-hooks 'eshell-post-command-hook)
(let (input)
(eshell-condition-case err
(progn
(setq input (buffer-substring-no-properties
eshell-last-output-end (1- (point))))
(run-hook-with-args 'eshell-expand-input-functions
eshell-last-output-end (1- (point)))
(let ((cmd
;; TODO: This is the modification. Report upstream the
;; lack of flexibility.
;; (eshell-parse-command-input
;; eshell-last-output-end (1- (point)))))
(eshell-parse-command
(eshell-detach-rewrite-input input) nil t)))
(when cmd
(eshell-update-markers eshell-last-output-end)
(setq input (buffer-substring-no-properties
eshell-last-input-start
(1- eshell-last-input-end)))
(run-hooks 'eshell-input-filter-functions)
(and (catch 'eshell-terminal
(ignore
(if (eshell-invoke-directly cmd)
(eval cmd)
(eshell-eval-command cmd input))))
(eshell-life-is-too-much)))))
(quit
(eshell-reset t)
(run-hooks 'eshell-post-command-hook)
(signal 'quit nil))
(error
(eshell-reset t)
(eshell-interactive-print
(concat (error-message-string err) "\n"))
(run-hooks 'eshell-post-command-hook)
(insert-and-inherit input)))))))))
;; TODO: Pause/resume on Eshell.
;; Bash is one way:
;; (local-set-key (kbd "C-z") 'self-insert-command)
;; Pressing self-inserted "C-z RET" works.
;; This only works for interactive shells, not with `bash -c'.
;;; esh-proc.el has `eshell-stop-process' but this does not seem to work. Maybe it
;;; does not propagate properly.
;; TODO: Order by deepest child first so that we kill in order? Not sure it matters.
(defun eshell-detach-children ()
"Return the list of recursive children of dtach except the dtach daemon."
;; The process graph when a dtach session is first created is as follows:
;;
;; dtach (client)
;; - dtach (daemon)
;; - bash (tee of stdout+stderr)
;; - bash (process)
;; - bash (tee of stdout)
;; - bash (tee of stderr)
;;
;; When attached to, dtach (the client) is no longer the parent of the daemon.
;; We want to send a signal to "bash (process)". We cannot predict how many
;; processes the command will start so we need to send signals to all children.
(let* ((dtach-client (process-id (eshell-interactive-process)))
dtach-daemon
result
pids
ppids)
(if (or (null dtach-client) (not (string= (alist-get 'comm (process-attributes dtach-client)) "dtach")))
(message "Current interactive process is not dtach")
(setq pids (list-system-processes)
ppids (mapcar (lambda (p) (cons (alist-get 'ppid (process-attributes p)) p)) pids))
;; Query dtach daemon.
;; Could be the child of the client.
(setq dtach-daemon (alist-get dtach-client ppids))
(when (null dtach-daemon)
;; If we are attaching to a socket, the client daemon is forked and thus
;; it is not a child of the client.
;; WARNING: Brittle trick ahead: We find the daemon by finding the
;; "dtach" process which 3 argument is the same socket as the client.
(let ((pids pids) ; Save `pids'.
(dtach-client-socket (nth 2 (split-string (alist-get 'args (process-attributes dtach-client))))))
(while (and pids (null dtach-daemon))
(let ((attr (process-attributes (car pids))))
(when (and (/= (car pids) dtach-client)
(string= (alist-get 'comm attr) "dtach")
;; Socket is the third substring.
(string= (nth 2 (split-string (alist-get 'args attr))) dtach-client-socket))
(setq dtach-daemon (car pids))))
(setq pids (cdr pids)))))
;; Query children.
(if (null dtach-daemon)
(message "Cannot find associated dtach daemon")
;; The first (and only) child of dtach is the main bash session.
(push (alist-get dtach-daemon ppids) result)
;; Add all recursive children of the main bash session to `result'.
(let ((l 0))
(while (/= l (length ppids))
(setq l (length ppids))
(let (newppids)
(while ppids
(when (member (caar ppids) result)
(push (cdar ppids) result))
(pop ppids))
(setq ppids newppids))))))
result))
(defun eshell-detach-stop ()
"If `eshell-interactive-process' is dtach, pause all children processes.
The processes can be resumed with `eshell-detach-continue'."
(interactive)
;; The TSTP is not the right signal since we are not "at terminal".
(let ((children (eshell-detach-children)))
(when children
(eshell-detach-signal 'STOP children))))
(defun eshell-detach-continue ()
"If `eshell-interactive-process' is dtach, resume all children processes.
The processes can be pause with `eshell-detach-stop'."
(interactive)
(let ((children (eshell-detach-children)))
(when children
(eshell-detach-signal 'CONT children))))
;; Inpsired by `helm-top-sh'.
(defun eshell-detach-signal (sig pids)
"Run `kill' shell command with signal SIG on PIDs.
PIDs is a list of numbers."
(let ((pids (mapcar 'number-to-string pids)))
(message "kill -%s %s exited with status %s"
sig (mapconcat 'identity pids " ")
(apply #'call-process
"kill" nil nil nil (format "-%s" sig) pids))))
(provide 'package-eshell-detach)