package-eshell-detach.el 14 KB

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