123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- ;;; 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)
|