package-eshell-detach.el 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. ;;; eshell-detach.el
  2. ;; Instead of sending the command prompt to Eshell, send it to a bash shell run in a dtach session.
  3. ;; dtach allows the user to disconnect (quit Eshell or even Emacs) while the command keeps going.
  4. ;; Stderr and stdout can be both displayed on screen and redirected to a file thanks to the `tee' program.
  5. ;;
  6. ;; dtach allows the command line processor (that is, Bash) to run in the background.
  7. ;; Bash allows for:
  8. ;; - using `tee' to separate stdout/stderr, output both in files and on screen;
  9. ;; - executing pipe lines (e.g. grep foo | sed ... | cut | wc). dtach cannot do that alone.
  10. ;;
  11. ;; Bash is necessary here. If we want to run Eshell within dtach, we would need
  12. ;; to run Emacs in --batch mode:
  13. ;;
  14. ;; emacs --batch --eval '(progn (eshell) (insert "echo hello") (eshell-send-input))'
  15. ;;
  16. ;; Issues: Eshell mixes stdout and stderr. (--batch sends to stderr.) How do we redirect the output to the real stdout/stderr?
  17. ;; TODO: Support Eshell nonetheless?
  18. ;; TODO: Move eshell to a lower level of abstraction. This package should be called "detach.el".
  19. ;; TODO: Remove bash / tee / dtach dependencies? I don't think dtach can be removed.
  20. ;;; See if `make-process' is the way to go: it supports stderr/stdout separation and stop/cont.
  21. ;;; Re-use `eshell-gather-process-output'? Re-implement?
  22. ;; TODO: Add list of regexp matches for which detach should automatically be used.
  23. (defvar eshell-detach-program "dtach"
  24. "The `dtach' program.")
  25. (defvar eshell-detach-redraw-method nil
  26. "If nil, use the default value.
  27. Value must be a string.
  28. See dtach(1) for possible values.")
  29. (defvar eshell-detach-shell "bash"
  30. "Shell to run the command in.
  31. Should be bash-compatible.
  32. The end command will be
  33. \"`eshell-detach-shell' -c { { <command>; } > >(tee stdout) } 2> >(tee stderr) | tee stdout+stderr\"")
  34. ;; TODO: Set the detach character? No need when `C-c C-c` suffices.
  35. ;; (defvar eshell-detach-detach-character "^\\"
  36. ;; "Charcter to press to detach dtach, i.e. leave the process run in the background.
  37. ;; The character syntax follows terminal notations, not Emacs.")
  38. ;;
  39. ;; (defvar eshell-detach-detach-character-binding "C-\\"
  40. ;; "The Emacs binding matching `eshell-detach-detach-character'.")
  41. (defvar eshell-detach-socket-ext ".socket"
  42. "The file name extension for the socket fo `eshell-detach-program'.")
  43. (defvar eshell-detach-stdout-ext ".stdout"
  44. "If non-nil and a string, stdout will also be saved to file named after the socket with this extension appened.
  45. The 'tee' program is required.")
  46. (defvar eshell-detach-stderr-ext ".stderr"
  47. "If non-nil and a string, stderr will also be saved to file named after the socket with this extension appened.
  48. The 'tee' program is required.")
  49. (defvar eshell-detach-stdout+stderr-ext ".stdout+stderr"
  50. "If non-nil and a string, stdout and stderr will also be saved to file named after the socket with this extension appened.
  51. The 'tee' program is required.")
  52. ;; TODO: Turn into a defcustom, and offer `user-emacs-directory' as well.
  53. (defvar eshell-detach-directory (if server-socket-dir server-socket-dir temporary-file-directory)
  54. "The directory where to store the dtach socket and the logs.")
  55. (defvar eshell-detach-file-pattern-function 'eshell-detach-default-file-pattern
  56. "Function that takes the commandline as argument and returns
  57. the name of all the dtach-related files (output and socket).")
  58. (defun eshell-detach-default-file-pattern (commandline)
  59. "Create a pattern made of the alphanumerical translation of the commandline.
  60. Characters that don't fit are replaced with '_'.
  61. An ISO date string is appended.
  62. Suitable for `eshell-detach-file-pattern-function'."
  63. (format "-%s-%s-"
  64. (replace-regexp-in-string "[^A-Za-z0-9=-]" "_" commandline)
  65. (format-time-string "%F-%R:%S")))
  66. ;; `eshell-named-command-hook' is not the way to go as it won't take pipelines. What about
  67. ;; `eshell-rewrite-command-hook'?
  68. (defun eshell-detach-rewrite-input (input)
  69. "Rewrite INPUT so that it is ready for detaching."
  70. ;; Since sockets get killed on termination, there won't be any leftover if
  71. ;; there is no log. Thus it is cleaner to _not_ create a sub-directory.
  72. ;; `tee' creates log files even if nothing is output. We cleanup on exit by
  73. ;; deleting 0-byte files.
  74. (let* (
  75. ;; TODO: temp-file should not exist for dtach to start? That forces us
  76. ;; to use make-temp-file which is vulnerable to race condition.
  77. ;; TODO: Read `input' safely. E.g. from a file.
  78. (socket (make-temp-name
  79. (expand-file-name
  80. (concat "dtach" (funcall eshell-detach-file-pattern-function input))
  81. eshell-detach-directory)))
  82. (stdout (and eshell-detach-stdout-ext (concat socket eshell-detach-stdout-ext)))
  83. (stderr (and eshell-detach-stderr-ext (concat socket eshell-detach-stderr-ext)))
  84. (stdout+stderr (and eshell-detach-stdout+stderr-ext (concat socket eshell-detach-stdout+stderr-ext)))
  85. (socket (concat socket eshell-detach-socket-ext))
  86. ;; The following bash command was inspired by
  87. ;; https://stackoverflow.com/questions/21465297/tee-stdout-and-stderr-to-separate-files-while-retaining-them-on-their-respective.
  88. ;;
  89. ;; { { echo stdout; echo stderr >&2; } > >(tee stdout.txt ); } 2> >(tee stderr.txt ) | tee stdout+stderr.txt
  90. (commandline (format "{ { %s; }%s }%s %s; for i in %s %s %s; do [ ! -s \"$i\" ] && rm -- \"$i\"; done"
  91. input
  92. ;; TODO: Move `tee' to a defvar.
  93. (if stdout (format " > >(tee %s );" stdout) "")
  94. (if stderr (format " 2> >(tee %s )" stderr) "")
  95. (if stdout+stderr (format " | tee %s" stdout+stderr) "")
  96. (shell-quote-argument (or stdout ""))
  97. (shell-quote-argument (or stderr ""))
  98. (shell-quote-argument (or stdout+stderr "")))))
  99. (format "%s -c %s -z %s -c %s" eshell-detach-program socket eshell-detach-shell (shell-quote-argument commandline))))
  100. (defun eshell-detach--list-sockets ()
  101. "List sockets of `eshell-detach-program'."
  102. (file-expand-wildcards (concat
  103. (expand-file-name "dtach-"
  104. eshell-detach-directory)
  105. "*" eshell-detach-socket-ext)))
  106. (defun eshell-detach-attach ()
  107. "Attach to a running session of `eshell-detach-program'."
  108. (interactive)
  109. (let ((socket (completing-read "Attach to session: " (eshell-detach--list-sockets) nil t)))
  110. (when socket
  111. (when (or (eshell-interactive-process)
  112. (/= (point) eshell-last-output-end))
  113. (eshell-interrupt-process))
  114. (goto-char (point-max))
  115. ;; TODO: Redraw method?
  116. (insert eshell-detach-program " -a " (shell-quote-argument socket))
  117. (eshell-send-input))))
  118. ;;; This is almost an exact copy of `eshell-send-input'.
  119. (defun eshell-detach-send-input (&optional use-region queue-p no-newline)
  120. "Send the input received to Eshell for parsing and processing.
  121. After `eshell-last-output-end', sends all text from that marker to
  122. point as input. Before that marker, calls `eshell-get-old-input' to
  123. retrieve old input, copies it to the end of the buffer, and sends it.
  124. If USE-REGION is non-nil, the current region (between point and mark)
  125. will be used as input.
  126. If QUEUE-P is non-nil, input will be queued until the next prompt,
  127. rather than sent to the currently active process. If no process, the
  128. input is processed immediately.
  129. If NO-NEWLINE is non-nil, the input is sent without an implied final
  130. newline."
  131. (interactive "P")
  132. ;; Note that the input string does not include its terminal newline.
  133. (let ((proc-running-p (and (eshell-interactive-process)
  134. (not queue-p)))
  135. (inhibit-point-motion-hooks t)
  136. (inhibit-modification-hooks t))
  137. (unless (and proc-running-p
  138. (not (eq (process-status
  139. (eshell-interactive-process))
  140. 'run)))
  141. (if (or proc-running-p
  142. (>= (point) eshell-last-output-end))
  143. (goto-char (point-max))
  144. (let ((copy (eshell-get-old-input use-region)))
  145. (goto-char eshell-last-output-end)
  146. (insert-and-inherit copy)))
  147. (unless (or no-newline
  148. (and eshell-send-direct-to-subprocesses
  149. proc-running-p))
  150. (insert-before-markers-and-inherit ?\n))
  151. (if proc-running-p
  152. (progn
  153. (eshell-update-markers eshell-last-output-end)
  154. (if (or eshell-send-direct-to-subprocesses
  155. (= eshell-last-input-start eshell-last-input-end))
  156. (unless no-newline
  157. (process-send-string (eshell-interactive-process) "\n"))
  158. (process-send-region (eshell-interactive-process)
  159. eshell-last-input-start
  160. eshell-last-input-end)))
  161. (if (= eshell-last-output-end (point))
  162. (run-hooks 'eshell-post-command-hook)
  163. (let (input)
  164. (eshell-condition-case err
  165. (progn
  166. (setq input (buffer-substring-no-properties
  167. eshell-last-output-end (1- (point))))
  168. (run-hook-with-args 'eshell-expand-input-functions
  169. eshell-last-output-end (1- (point)))
  170. (let ((cmd
  171. ;; TODO: This is the modification. Report upstream the
  172. ;; lack of flexibility.
  173. ;; (eshell-parse-command-input
  174. ;; eshell-last-output-end (1- (point)))))
  175. (eshell-parse-command
  176. (eshell-detach-rewrite-input input) nil t)))
  177. (when cmd
  178. (eshell-update-markers eshell-last-output-end)
  179. (setq input (buffer-substring-no-properties
  180. eshell-last-input-start
  181. (1- eshell-last-input-end)))
  182. (run-hooks 'eshell-input-filter-functions)
  183. (and (catch 'eshell-terminal
  184. (ignore
  185. (if (eshell-invoke-directly cmd)
  186. (eval cmd)
  187. (eshell-eval-command cmd input))))
  188. (eshell-life-is-too-much)))))
  189. (quit
  190. (eshell-reset t)
  191. (run-hooks 'eshell-post-command-hook)
  192. (signal 'quit nil))
  193. (error
  194. (eshell-reset t)
  195. (eshell-interactive-print
  196. (concat (error-message-string err) "\n"))
  197. (run-hooks 'eshell-post-command-hook)
  198. (insert-and-inherit input)))))))))
  199. ;; TODO: Pause/resume on Eshell.
  200. ;; Bash is one way:
  201. ;; (local-set-key (kbd "C-z") 'self-insert-command)
  202. ;; Pressing self-inserted "C-z RET" works.
  203. ;; This only works for interactive shells, not with `bash -c'.
  204. ;;; esh-proc.el has `eshell-stop-process' but this does not seem to work. Maybe it
  205. ;;; does not propagate properly.
  206. ;; TODO: Order by deepest child first so that we kill in order? Not sure it matters.
  207. (defun eshell-detach-children ()
  208. "Return the list of recursive children of dtach except the dtach daemon."
  209. ;; The process graph when a dtach session is first created is as follows:
  210. ;;
  211. ;; dtach (client)
  212. ;; - dtach (daemon)
  213. ;; - bash (tee of stdout+stderr)
  214. ;; - bash (process)
  215. ;; - bash (tee of stdout)
  216. ;; - bash (tee of stderr)
  217. ;;
  218. ;; When attached to, dtach (the client) is no longer the parent of the daemon.
  219. ;; We want to send a signal to "bash (process)". We cannot predict how many
  220. ;; processes the command will start so we need to send signals to all children.
  221. (let* ((dtach-client (process-id (eshell-interactive-process)))
  222. dtach-daemon
  223. result
  224. pids
  225. ppids)
  226. (if (or (null dtach-client) (not (string= (alist-get 'comm (process-attributes dtach-client)) "dtach")))
  227. (message "Current interactive process is not dtach")
  228. (setq pids (list-system-processes)
  229. ppids (mapcar (lambda (p) (cons (alist-get 'ppid (process-attributes p)) p)) pids))
  230. ;; Query dtach daemon.
  231. ;; Could be the child of the client.
  232. (setq dtach-daemon (alist-get dtach-client ppids))
  233. (when (null dtach-daemon)
  234. ;; If we are attaching to a socket, the client daemon is forked and thus
  235. ;; it is not a child of the client.
  236. ;; WARNING: Brittle trick ahead: We find the daemon by finding the
  237. ;; "dtach" process which 3 argument is the same socket as the client.
  238. (let ((pids pids) ; Save `pids'.
  239. (dtach-client-socket (nth 2 (split-string (alist-get 'args (process-attributes dtach-client))))))
  240. (while (and pids (null dtach-daemon))
  241. (let ((attr (process-attributes (car pids))))
  242. (when (and (/= (car pids) dtach-client)
  243. (string= (alist-get 'comm attr) "dtach")
  244. ;; Socket is the third substring.
  245. (string= (nth 2 (split-string (alist-get 'args attr))) dtach-client-socket))
  246. (setq dtach-daemon (car pids))))
  247. (setq pids (cdr pids)))))
  248. ;; Query children.
  249. (if (null dtach-daemon)
  250. (message "Cannot find associated dtach daemon")
  251. ;; The first (and only) child of dtach is the main bash session.
  252. (push (alist-get dtach-daemon ppids) result)
  253. ;; Add all recursive children of the main bash session to `result'.
  254. (let ((l 0))
  255. (while (/= l (length ppids))
  256. (setq l (length ppids))
  257. (let (newppids)
  258. (while ppids
  259. (when (member (caar ppids) result)
  260. (push (cdar ppids) result))
  261. (pop ppids))
  262. (setq ppids newppids))))))
  263. result))
  264. (defun eshell-detach-stop ()
  265. "If `eshell-interactive-process' is dtach, pause all children processes.
  266. The processes can be resumed with `eshell-detach-continue'."
  267. (interactive)
  268. ;; The TSTP is not the right signal since we are not "at terminal".
  269. (let ((children (eshell-detach-children)))
  270. (when children
  271. (eshell-detach-signal 'STOP children))))
  272. (defun eshell-detach-continue ()
  273. "If `eshell-interactive-process' is dtach, resume all children processes.
  274. The processes can be pause with `eshell-detach-stop'."
  275. (interactive)
  276. (let ((children (eshell-detach-children)))
  277. (when children
  278. (eshell-detach-signal 'CONT children))))
  279. ;; Inpsired by `helm-top-sh'.
  280. (defun eshell-detach-signal (sig pids)
  281. "Run `kill' shell command with signal SIG on PIDs.
  282. PIDs is a list of numbers."
  283. (let ((pids (mapcar 'number-to-string pids)))
  284. (message "kill -%s %s exited with status %s"
  285. sig (mapconcat 'identity pids " ")
  286. (apply #'call-process
  287. "kill" nil nil nil (format "-%s" sig) pids))))
  288. (provide 'package-eshell-detach)