Subject: bug#61501: closed (Re: bug#61501: 30.0.50; [PATCH] Support unloading Eshell)

From: [email-masked] (GNU bug Tracking System)
Date: Thu, 16 Feb 2023 01:35:02 +0000

Your bug report

#61501: 30.0.50; [PATCH] Support unloading Eshell

which was filed against the emacs package, has been closed.

The explanation is attached below, along with your original report.
If you require more details, please reply to [email-masked].

-- 
61501: https://debbugs.gnu.org/cgi/bugreport.cgi?bug=61501
GNU Bug Tracking System
Contact [email-masked] with problems

Subject: Re: bug#61501: 30.0.50; [PATCH] Support unloading Eshell
From: Jim Porter <[email-masked]>
To: Eli Zaretskii <[email-masked]>
Cc: [email-masked]
Date: Wed, 15 Feb 2023 17:34:14 -0800
List-Id: <debbugs-submit.debbugs.gnu.org>

On 2/15/2023 10:06 AM, Eli Zaretskii wrote:
> Perfect, thanks.

Thanks. Merged to master as 8051be9ac2. Closing this bug now.

Subject: 30.0.50; [PATCH] Support unloading Eshell
From: Jim Porter <[email-masked]>
To: [email-masked]
Date: Mon, 13 Feb 2023 19:52:13 -0800
List-Id: <debbugs-submit.debbugs.gnu.org>

Currently, it's not possible to call "(unload-feature 'eshell)" after 
using Eshell. You'll just get an error that some of Eshell's extension 
modules depend on eshell.elc. This is a relatively minor issue, but I'm 
hoping to make some improvements to how extension modules get loaded, so 
this is a prelude to that.

In addition to making it *possible* to unload Eshell, I also fixed an 
issue where the unload hooks weren't named correctly. They were of the 
form 'eshell-hist-unload-hook', but the file's name is em-hist.el. I 
moved these to 'em-hist-unload-function' and similar ('-function' 
because the '-hook' version is obsolete, as I understand it).

From 522f31164da4186af9d15b5608c721ca377ba3c0 Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Sun, 12 Feb 2023 23:25:40 -0800
Subject: [PATCH 1/2] ; Don't require 'eshell' in other Eshell files

This isn't necessary and just makes unloading Eshell harder.

* lisp/eshell/em-banner.el:
* lisp/eshell/em-basic.el:
* lisp/eshell/em-cmpl.el:
* lisp/eshell/em-glob.el:
* lisp/eshell/em-prompt.el:
* lisp/eshell/em-rebind.el:
* lisp/eshell/em-smart.el:
* lisp/eshell/em-term.el:
* lisp/eshell/em-tramp.el:
* lisp/eshell/em-xtra.el:
Stop requiring 'eshell', and instead require specific subcomponents.

* lisp/eshell/em-hist.el: Stop requiring 'eshell' and 'em-pred'
(extension modules shouldn't require each other so they can be
independent).
(eshell-hist-parse-modifier): Ensure this can only be called when
'em-pred' is in use, and declare the relevant function.

* lisp/eshell/eshell.el (eshell-non-interactive-p): Move from here...
* lisp/eshell/esh-mode.el (eshell-non-interactive-p): ... to here.
---
 lisp/eshell/em-banner.el | 1 -
 lisp/eshell/em-basic.el  | 5 +++--
 lisp/eshell/em-cmpl.el   | 4 +---
 lisp/eshell/em-glob.el   | 3 ++-
 lisp/eshell/em-hist.el   | 4 ++--
 lisp/eshell/em-prompt.el | 2 --
 lisp/eshell/em-rebind.el | 1 -
 lisp/eshell/em-smart.el  | 1 -
 lisp/eshell/em-term.el   | 1 -
 lisp/eshell/em-tramp.el  | 3 +--
 lisp/eshell/em-xtra.el   | 2 --
 lisp/eshell/esh-mode.el  | 5 +++++
 lisp/eshell/eshell.el    | 5 +----
 13 files changed, 15 insertions(+), 22 deletions(-)

diff --git a/lisp/eshell/em-banner.el b/lisp/eshell/em-banner.el
index 8bc497bdeb3..2bee50b80a4 100644
--- a/lisp/eshell/em-banner.el
+++ b/lisp/eshell/em-banner.el
@@ -43,7 +43,6 @@
 
 (require 'esh-util)
 (require 'esh-mode)
-(require 'eshell)
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-basic.el b/lisp/eshell/em-basic.el
index bfff3bdf56e..016afe811b2 100644
--- a/lisp/eshell/em-basic.el
+++ b/lisp/eshell/em-basic.el
@@ -53,9 +53,10 @@
 
 ;;; Code:
 
-(require 'esh-util)
-(require 'eshell)
+(require 'esh-cmd)
+(require 'esh-io)
 (require 'esh-opt)
+(require 'esh-util)
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-cmpl.el b/lisp/eshell/em-cmpl.el
index 2439f1ed804..af8ac4278f1 100644
--- a/lisp/eshell/em-cmpl.el
+++ b/lisp/eshell/em-cmpl.el
@@ -74,9 +74,7 @@
 (require 'esh-util)
 (require 'em-dirs)
 
-(eval-when-compile
-  (require 'cl-lib)
-  (require 'eshell))
+(eval-when-compile (require 'cl-lib))
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-glob.el b/lisp/eshell/em-glob.el
index 716f5c32b87..c7360fb246e 100644
--- a/lisp/eshell/em-glob.el
+++ b/lisp/eshell/em-glob.el
@@ -49,8 +49,9 @@
 
 ;;; Code:
 
+(require 'esh-arg)
+(require 'esh-module)
 (require 'esh-util)
-(eval-when-compile (require 'eshell))
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-hist.el b/lisp/eshell/em-hist.el
index 6e0e471d910..4796df1604e 100644
--- a/lisp/eshell/em-hist.el
+++ b/lisp/eshell/em-hist.el
@@ -59,8 +59,6 @@
 (require 'ring)
 (require 'esh-opt)
 (require 'esh-mode)
-(require 'em-pred)
-(require 'eshell)
 
 ;;;###autoload
 (progn
@@ -769,6 +767,8 @@ eshell-hist-parse-word-designator
 
 (defun eshell-hist-parse-modifier (hist reference)
   "Parse a history modifier beginning for HIST in REFERENCE."
+  (cl-assert (eshell-using-module 'em-pred))
+  (declare-function eshell-parse-modifiers "em-pred" ())
   (let ((here (point)))
     (insert reference)
     (prog1
diff --git a/lisp/eshell/em-prompt.el b/lisp/eshell/em-prompt.el
index b3a0fadf618..9f9e58e83d7 100644
--- a/lisp/eshell/em-prompt.el
+++ b/lisp/eshell/em-prompt.el
@@ -27,8 +27,6 @@
 ;;; Code:
 
 (require 'esh-mode)
-(eval-when-compile (require 'eshell))
-
 (require 'text-property-search)
 
 ;;;###autoload
diff --git a/lisp/eshell/em-rebind.el b/lisp/eshell/em-rebind.el
index f147d432300..75a2848a9d5 100644
--- a/lisp/eshell/em-rebind.el
+++ b/lisp/eshell/em-rebind.el
@@ -24,7 +24,6 @@
 ;;; Code:
 
 (require 'esh-mode)
-(eval-when-compile (require 'eshell))
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-smart.el b/lisp/eshell/em-smart.el
index ca04c429785..154ff760212 100644
--- a/lisp/eshell/em-smart.el
+++ b/lisp/eshell/em-smart.el
@@ -69,7 +69,6 @@
 ;;; Code:
 
 (require 'esh-mode)
-(eval-when-compile (require 'eshell))
 
 ;;;###autoload
 (progn
diff --git a/lisp/eshell/em-term.el b/lisp/eshell/em-term.el
index a4d777e4a0d..ab26da857b7 100644
--- a/lisp/eshell/em-term.el
+++ b/lisp/eshell/em-term.el
@@ -34,7 +34,6 @@
 (require 'cl-lib)
 (require 'esh-util)
 (require 'esh-ext)
-(eval-when-compile (require 'eshell))
 (require 'term)
 
 ;;;###autoload
diff --git a/lisp/eshell/em-tramp.el b/lisp/eshell/em-tramp.el
index 13dd62e1617..94eb9797033 100644
--- a/lisp/eshell/em-tramp.el
+++ b/lisp/eshell/em-tramp.el
@@ -29,8 +29,7 @@
 (require 'esh-cmd)
 
 (eval-when-compile
-  (require 'esh-mode)
-  (require 'eshell))
+  (require 'esh-mode))
 
 (require 'tramp)
 
diff --git a/lisp/eshell/em-xtra.el b/lisp/eshell/em-xtra.el
index defaa7b2887..45c3ea3c0fc 100644
--- a/lisp/eshell/em-xtra.el
+++ b/lisp/eshell/em-xtra.el
@@ -25,8 +25,6 @@
 
 (require 'cl-lib)
 (require 'esh-util)
-(eval-when-compile
-  (require 'eshell))
 
 ;; There are no items in this custom group, but eshell modules (ab)use
 ;; custom groups.
diff --git a/lisp/eshell/esh-mode.el b/lisp/eshell/esh-mode.el
index 1b8f5ff8018..e0af3579edf 100644
--- a/lisp/eshell/esh-mode.el
+++ b/lisp/eshell/esh-mode.el
@@ -200,6 +200,11 @@ eshell-directory-name
 (defvar eshell-first-time-p t
   "A variable which is non-nil the first time Eshell is loaded.")
 
+(defvar eshell-non-interactive-p nil
+  "A variable which is non-nil when Eshell is not running interactively.
+Modules should use this variable so that they don't clutter
+non-interactive sessions, such as when using `eshell-command'.")
+
 ;; Internal Variables:
 
 ;; these are only set to nil initially for the sake of the
diff --git a/lisp/eshell/eshell.el b/lisp/eshell/eshell.el
index 7a7ece5cb7c..0bfc0413cbf 100644
--- a/lisp/eshell/eshell.el
+++ b/lisp/eshell/eshell.el
@@ -267,10 +267,7 @@ eshell-command-mode
 (define-obsolete-function-alias 'eshell-return-exits-minibuffer
   #'eshell-command-mode "28.1")
 
-(defvar eshell-non-interactive-p nil
-  "A variable which is non-nil when Eshell is not running interactively.
-Modules should use this variable so that they don't clutter
-non-interactive sessions, such as when using `eshell-command'.")
+(defvar eshell-non-interactive-p)       ; Defined in esh-mode.el.
 
 (declare-function eshell-add-input-to-history "em-hist" (input))
 
-- 
2.25.1

From aa1260aac01b3e798a1a1a6a609a18b841d30c53 Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Sun, 12 Feb 2023 23:25:59 -0800
Subject: [PATCH 2/2] Allow unloading Eshell

* lisp/eshell/em-extpipe.el (eshell-extpipe):
* lisp/eshell/esh-opt.el (eshell-opt): New groups.  Eshell uses these
to identify modules to unload.

* lisp/eshell/em-hist.el (eshell-hist-unload-hook):
* lisp/eshell/em-ls.el (eshell-ls-unload-hook):
* lisp/eshell/em-smart.el (eshell-smart-unload-hook):
* lisp/eshell/eshell.el (eshell-unload-hook): Make obsolete and move
to...

* lisp/eshell/em-smart.el (em-smart-unload-function):
* lisp/eshell/em-hist.el (em-hist-unload-function):
* lisp/eshell/em-ls.el (eshell-ls-unload-function):
* lisp/eshell/eshell.el (eshell-unload-function): ... these.

* lisp/eshell/esh-mode.el (eshell-mode-unload-hook):
* lisp/eshell/esh-module.el (eshell-module-unload-hook): Make
obsolete.

* lisp/eshell/em-ls (eshell-ls-enable-in-dired,
eshell-ls-disable-in-dired): New functions...
(eshell-ls-use-in-dired): ... use them.

* lisp/eshell/esh-module.el (eshell-module--feature-name,
eshell-unload-modules): New functions.
(eshell-unload-extension-modules): Use 'eshell-unload-modules'.

* lisp/eshell/eshell.el (eshell-unload-all-modules): Remove.

* test/lisp/eshell/eshell-tests-unload.el: New file.

* doc/misc/eshell.texi (Bugs and ideas): Remove item about unloading
Eshell not working.
---
 doc/misc/eshell.texi                    |  2 -
 lisp/eshell/em-extpipe.el               | 15 ++++
 lisp/eshell/em-hist.el                  |  4 +
 lisp/eshell/em-ls.el                    | 31 ++++----
 lisp/eshell/em-smart.el                 |  4 +
 lisp/eshell/esh-mode.el                 |  1 +
 lisp/eshell/esh-module.el               | 32 ++++++--
 lisp/eshell/esh-opt.el                  |  5 ++
 lisp/eshell/eshell.el                   | 33 +++------
 test/lisp/eshell/eshell-tests-unload.el | 99 +++++++++++++++++++++++++
 10 files changed, 179 insertions(+), 47 deletions(-)
 create mode 100644 test/lisp/eshell/eshell-tests-unload.el

diff --git a/doc/misc/eshell.texi b/doc/misc/eshell.texi
index e51e2cf799b..1c33c04f647 100644
--- a/doc/misc/eshell.texi
+++ b/doc/misc/eshell.texi
@@ -2189,8 +2189,6 @@ Bugs and ideas
 cause it to track the bottom of the output; but backspace no longer
 scrolls back.
 
-@item It's not possible to fully @code{unload-feature} Eshell
-
 @item Menu support was removed, but never put back
 
 @item If an interactive process is currently running, @kbd{M-!} doesn't work
diff --git a/lisp/eshell/em-extpipe.el b/lisp/eshell/em-extpipe.el
index 9078c44ed9f..5c9a0a85934 100644
--- a/lisp/eshell/em-extpipe.el
+++ b/lisp/eshell/em-extpipe.el
@@ -36,6 +36,21 @@
 
 (eval-when-compile (require 'files-x))
 
+;;;###autoload
+(progn
+(defgroup eshell-extpipe nil
+  "Native shell pipelines.
+
+This module lets you construct pipelines that use your operating
+system's shell instead of Eshell's own pipelining support.  This
+is especially relevant when executing commands on a remote
+machine using Eshell's Tramp integration: using the remote
+shell's pipelining avoids copying the data which will flow
+through the pipeline to local Emacs buffers and then right back
+again."
+  :tag "External pipelines"
+  :group 'eshell-module))
+
 ;;; Functions:
 
 (defun eshell-extpipe-initialize () ;Called from `eshell-mode' via intern-soft!
diff --git a/lisp/eshell/em-hist.el b/lisp/eshell/em-hist.el
index 4796df1604e..2c199ec160f 100644
--- a/lisp/eshell/em-hist.el
+++ b/lisp/eshell/em-hist.el
@@ -80,6 +80,7 @@ eshell-hist-unload-hook
      (remove-hook 'kill-emacs-hook 'eshell-save-some-history)))
   "A hook that gets run when `eshell-hist' is unloaded."
   :type 'hook)
+(make-obsolete-variable 'eshell-hist-unload-hook nil "30.1")
 
 (defcustom eshell-history-file-name
   (expand-file-name "history" eshell-directory-name)
@@ -1037,6 +1038,9 @@ eshell-isearch-return
   (isearch-done)
   (eshell-send-input))
 
+(defun em-hist-unload-function ()
+  (remove-hook 'kill-emacs-hook 'eshell-save-some-history))
+
 (provide 'em-hist)
 
 ;; Local Variables:
diff --git a/lisp/eshell/em-ls.el b/lisp/eshell/em-ls.el
index 7e2a7578ef9..56c5f262789 100644
--- a/lisp/eshell/em-ls.el
+++ b/lisp/eshell/em-ls.el
@@ -62,24 +62,27 @@ eshell-ls-dired-initial-args
 This is useful for enabling human-readable format (-h), for example."
   :type '(repeat :tag "Arguments" string))
 
+(defun eshell-ls-enable-in-dired ()
+  "Use `eshell-ls' to read directories in Dired."
+  (require 'dired)
+  (advice-add 'insert-directory :around #'eshell-ls--insert-directory)
+  (advice-add 'dired :around #'eshell-ls--dired))
+
+(defun eshell-ls-disable-in-dired ()
+  "Stop using `eshell-ls' to read directories in Dired."
+  (advice-remove 'insert-directory #'eshell-ls--insert-directory)
+  (advice-remove 'dired #'eshell-ls--dired))
+
 (defcustom eshell-ls-use-in-dired nil
   "If non-nil, use `eshell-ls' to read directories in Dired.
 Changing this without using customize has no effect."
   :set (lambda (symbol value)
-	 (cond (value
-                (require 'dired)
-                (advice-add 'insert-directory :around
-                            #'eshell-ls--insert-directory)
-                (advice-add 'dired :around #'eshell-ls--dired))
-               (t
-                (advice-remove 'insert-directory
-                               #'eshell-ls--insert-directory)
-                (advice-remove 'dired #'eshell-ls--dired)))
+         (if value
+             (eshell-ls-enable-in-dired)
+           (eshell-ls-disable-in-dired))
          (set symbol value))
   :type 'boolean
   :require 'em-ls)
-(add-hook 'eshell-ls-unload-hook #'eshell-ls-unload-function)
-
 
 (defcustom eshell-ls-default-blocksize 1024
   "The default blocksize to use when display file sizes with -s."
@@ -954,10 +957,8 @@ eshell-ls-decorated-name
 				 (car file)))))
   (car file))
 
-(defun eshell-ls-unload-function ()
-  (advice-remove 'insert-directory #'eshell-ls--insert-directory)
-  (advice-remove 'dired #'eshell-ls--dired)
-  nil)
+(defun em-ls-unload-function ()
+  (eshell-ls-disable-in-dired))
 
 (provide 'em-ls)
 
diff --git a/lisp/eshell/em-smart.el b/lisp/eshell/em-smart.el
index 154ff760212..d8b7fadc2c2 100644
--- a/lisp/eshell/em-smart.el
+++ b/lisp/eshell/em-smart.el
@@ -99,6 +99,7 @@ eshell-smart-unload-hook
   "A hook that gets run when `eshell-smart' is unloaded."
   :type 'hook
   :group 'eshell-smart)
+(make-obsolete-variable 'eshell-smart-unload-hook nil "30.1")
 
 (defcustom eshell-review-quick-commands nil
   "If t, always review commands.
@@ -321,6 +322,9 @@ eshell-smart-display-move
     (if clear
 	(remove-hook 'pre-command-hook 'eshell-smart-display-move t))))
 
+(defun em-smart-unload-hook ()
+  (remove-hook 'window-configuration-change-hook #'eshell-refresh-windows))
+
 (provide 'em-smart)
 
 ;; Local Variables:
diff --git a/lisp/eshell/esh-mode.el b/lisp/eshell/esh-mode.el
index e0af3579edf..b3cde472713 100644
--- a/lisp/eshell/esh-mode.el
+++ b/lisp/eshell/esh-mode.el
@@ -79,6 +79,7 @@ eshell-mode
 (defcustom eshell-mode-unload-hook nil
   "A hook that gets run when `eshell-mode' is unloaded."
   :type 'hook)
+(make-obsolete-variable 'eshell-mode-unload-hook nil "30.1")
 
 (defcustom eshell-mode-hook nil
   "A hook that gets run when `eshell-mode' is entered."
diff --git a/lisp/eshell/esh-module.el b/lisp/eshell/esh-module.el
index 651e793ad98..7fc74d38796 100644
--- a/lisp/eshell/esh-module.el
+++ b/lisp/eshell/esh-module.el
@@ -47,6 +47,7 @@ eshell-module-unload-hook
   "A hook run when `eshell-module' is unloaded."
   :type 'hook
   :group 'eshell-module)
+(make-obsolete-variable 'eshell-module-unload-hook nil "30.1")
 
 (defcustom eshell-modules-list
   '(eshell-alias
@@ -85,20 +86,37 @@ eshell-modules-list
 
 ;;; Code:
 
+(defsubst eshell-module--feature-name (module &optional kind)
+  "Get the feature name for the specified Eshell MODULE."
+  (let ((module-name (symbol-name module))
+        (prefix (cond ((eq kind 'core) "esh-")
+                      ((memq kind '(extension nil)) "em-")
+                      (t (error "unknown module kind %s" kind)))))
+    (if (string-match "^eshell-\\(.*\\)" module-name)
+	(concat prefix (match-string 1 module-name))
+      (error "Invalid Eshell module name: %s" module))))
+
 (defsubst eshell-using-module (module)
   "Return non-nil if a certain Eshell MODULE is in use.
 The MODULE should be a symbol corresponding to that module's
 customization group.  Example: `eshell-cmpl' for that module."
   (memq module eshell-modules-list))
 
+(defun eshell-unload-modules (modules &optional kind)
+  "Try to unload the specified Eshell MODULES."
+  (dolist (module modules)
+    (let ((module-feature (intern (eshell-module--feature-name module kind))))
+      (when (featurep module-feature)
+	(message "Unloading %s..." (symbol-name module))
+        (condition-case-unless-debug _
+            (progn
+              (unload-feature module-feature)
+              (message "Unloading %s...done" (symbol-name module)))
+          (error (message "Unloading %s...failed" (symbol-name module))))))))
+
 (defun eshell-unload-extension-modules ()
-  "Unload any memory resident extension modules."
-  (dolist (module (eshell-subgroups 'eshell-module))
-    (if (featurep module)
-	(ignore-errors
-	  (message "Unloading %s..." (symbol-name module))
-	  (unload-feature module)
-	  (message "Unloading %s...done" (symbol-name module))))))
+  "Try to unload all currently-loaded Eshell extension modules."
+  (eshell-unload-modules (eshell-subgroups 'eshell-module)))
 
 (provide 'esh-module)
 ;;; esh-module.el ends here
diff --git a/lisp/eshell/esh-opt.el b/lisp/eshell/esh-opt.el
index 9253f9a4a7d..09c19767a19 100644
--- a/lisp/eshell/esh-opt.el
+++ b/lisp/eshell/esh-opt.el
@@ -29,6 +29,11 @@
 ;; defined in esh-util.
 (require 'esh-util)
 
+(defgroup eshell-opt nil
+  "Functions for argument parsing in Eshell commands."
+  :tag "Option parsing"
+  :group 'eshell)
+
 (defmacro eshell-eval-using-options (name macro-args options &rest body-forms)
   "Process NAME's MACRO-ARGS using a set of command line OPTIONS.
 After doing so, stores settings in local symbols as declared by OPTIONS;
diff --git a/lisp/eshell/eshell.el b/lisp/eshell/eshell.el
index 0bfc0413cbf..7d2c0335db2 100644
--- a/lisp/eshell/eshell.el
+++ b/lisp/eshell/eshell.el
@@ -199,10 +199,11 @@ eshell-load-hook
   :type 'hook
   :group 'eshell)
 
-(defcustom eshell-unload-hook '(eshell-unload-all-modules)
+(defcustom eshell-unload-hook nil
   "A hook run when Eshell is unloaded from memory."
   :type 'hook
   :group 'eshell)
+(make-obsolete-variable 'eshell-unload-hook nil "30.1")
 
 (defcustom eshell-buffer-name "*eshell*"
   "The basename used for Eshell buffers.
@@ -370,28 +371,14 @@ eshell-command-result
 	      (set status-var eshell-last-command-status))
 	  (cadr result))))))
 
-;;; Code:
-
-(defun eshell-unload-all-modules ()
-  "Unload all modules that were loaded by Eshell, if possible.
-If the user has require'd in any of the modules, or customized a
-variable with a :require tag (such as `eshell-prefer-to-shell'), it
-will be impossible to unload Eshell completely without restarting
-Emacs."
-  ;; if the user set `eshell-prefer-to-shell' to t, but never loaded
-  ;; Eshell, then `eshell-subgroups' will be unbound
-  (when (fboundp 'eshell-subgroups)
-    (dolist (module (eshell-subgroups 'eshell))
-      ;; this really only unloads as many modules as possible,
-      ;; since other `require' references (such as by customizing
-      ;; `eshell-prefer-to-shell' to a non-nil value) might make it
-      ;; impossible to unload Eshell completely
-      (if (featurep module)
-	  (ignore-errors
-	    (message "Unloading %s..." (symbol-name module))
-	    (unload-feature module)
-	    (message "Unloading %s...done" (symbol-name module)))))
-    (message "Unloading eshell...done")))
+(defun eshell-unload-function ()
+  (eshell-unload-extension-modules)
+  ;; Wait to unload core modules until after `eshell' has finished
+  ;; unloading.  `eshell' depends on several of them, so they can't be
+  ;; unloaded immediately.
+  (run-at-time 0 nil #'eshell-unload-modules
+               (reverse (eshell-subgroups 'eshell)) 'core)
+  nil)
 
 (run-hooks 'eshell-load-hook)
 
diff --git a/test/lisp/eshell/eshell-tests-unload.el b/test/lisp/eshell/eshell-tests-unload.el
new file mode 100644
index 00000000000..cdd58efef18
--- /dev/null
+++ b/test/lisp/eshell/eshell-tests-unload.el
@@ -0,0 +1,99 @@
+;;; eshell-tests-unload.el --- test unloading Eshell  -*- lexical-binding:t -*-
+
+;; Copyright (C) 2023 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for unloading Eshell.
+
+;;; Code:
+
+(require 'ert)
+(require 'ert-x)
+
+;; In order to test unloading Eshell, don't require any of its files
+;; at the top level.  This means we need to explicitly declare some of
+;; the variables and functions we'll use.
+(defvar eshell-directory-name)
+(defvar eshell-history-file-name)
+(defvar eshell-last-dir-ring-file-name)
+(defvar eshell-modules-list)
+
+(declare-function eshell-module--feature-name "esh-module"
+                  (module &optional kind))
+(declare-function eshell-subgroups "esh-util" (groupsym))
+
+(defvar max-unload-time 5
+  "The maximum amount of time to wait to unload Eshell modules, in seconds.
+See `unload-eshell'.")
+
+(defun load-eshell ()
+  "Load Eshell by calling the `eshell' function and immediately closing it."
+  (save-current-buffer
+    (ert-with-temp-directory eshell-directory-name
+      (let* (;; We want no history file, so prevent Eshell from falling
+             ;; back on $HISTFILE.
+             (process-environment (cons "HISTFILE" process-environment))
+             (eshell-history-file-name nil)
+             (eshell-last-dir-ring-file-name nil)
+             (eshell-buffer (eshell t)))
+        (let (kill-buffer-query-functions)
+          (kill-buffer eshell-buffer))))))
+
+(defun unload-eshell ()
+  "Unload Eshell, waiting until the core modules are unloaded as well."
+  (let ((debug-on-error t)
+        (inhibit-message t))
+    (unload-feature 'eshell)
+    ;; We unload core modules are unloaded from a timer, since they
+    ;; need to wait until after `eshell' itself is unloaded.  Wait for
+    ;; this to finish.
+    (let ((start (current-time)))
+      (while (featurep 'esh-arg)
+        (when (> (float-time (time-since start))
+                 max-unload-time)
+          (error "timed out waiting to unload Eshell modules"))
+        (sit-for 0.1)))))
+
+;;; Tests:
+
+(ert-deftest eshell-test-unload/default ()
+  "Test unloading Eshell with the default list of extension modules."
+  (load-eshell)
+  (unload-eshell))
+
+(ert-deftest eshell-test-unload/no-modules ()
+  "Test unloading Eshell with no extension modules."
+  (require 'esh-module)
+  (let (eshell-modules-list)
+    (load-eshell))
+  (dolist (module (eshell-subgroups 'eshell-module))
+    (should-not (featurep (intern (eshell-module--feature-name module)))))
+  (unload-eshell))
+
+(ert-deftest eshell-test-unload/all-modules ()
+  "Test unloading Eshell with every extension module."
+  (require 'esh-module)
+  (let ((eshell-modules-list (eshell-subgroups 'eshell-module)))
+    (load-eshell))
+  (dolist (module (eshell-subgroups 'eshell-module))
+    (should (featurep (intern (eshell-module--feature-name module)))))
+  (unload-eshell))
+
+(provide 'eshell-tests-unload)
+;;; eshell-tests-unload.el ends here
-- 
2.25.1