Subject: bug#79079: 31.0.50; Piped command output is sometimes lost in Eshell

From: Jim Porter <[email-masked]>
Date: Sat, 16 Aug 2025 16:54:57 -0700

On 8/16/2025 12:21 PM, Eli Zaretskii wrote:
>> Date: Sat, 16 Aug 2025 10:19:21 -0700
>> Cc: [email-masked], [email-masked], [email-masked]
>> From: Jim Porter <[email-masked]>
>>
>> It doesn't look like Emacs has the ability to close a file descriptor
>> from Lisp. I could add a C function to do this from Lisp, but I don't
>> know if it would be useful outside of this one test.
> 
> There's nothing wrong in having a function for the benefit of running
> tests.  If suitably documented, it's okay, I think.

Ok, now done. I've added a new function called 'internal-close-stream'. 
Hopefully the documentation is clear enough (it's a pretty simple function).

>> The tests aren't especially useful on Windows, but they should pass
>> (assuming the pipe test does), since on Windows we always use a pipe,
>> even if the user requests a PTY.
> 
> What would be the purpose of running the same tests several times?

Just one fewer conditional in the tests, but that's not a big deal. I've 
change this to run only the "pipe" case under MS-Windows.

From 9c9bc027ca63afe4f86a74cf486ede1a97e84784 Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Fri, 15 Aug 2025 13:44:03 -0700
Subject: [PATCH] Allow child processes to continue after EPIPE

This ensures that if the child process closed its stdin and Emacs tries
to write to it, the process can still do any remaining work and exit
normally.  In practice, this can occur with commands like "head(1)"
(bug#79079).

* src/fileio.c (file_for_stream): New function, extracted from...
(Fset_binary_mode): ... here.
(Finternal_close_stream): New function.

* src/process.c (send_process): When encountering EPIPE, only close the
fd for the pipe to the child process's stdin.

* lisp/eshell/esh-io.el (eshell-output-object-to-target): Don't check
for process liveness anymore.

* test/src/process-tests.el (process-tests/broken-pipe): New function.
(process-tests/broken-pipe/pipe, process-tests/broken-pipe/pty)
(process-tests/broken-pipe/pipe-stdin)
(process-tests/broken-pipe/pty-stdin): New tests.

* etc/NEWS: Announce this change.
---
 etc/NEWS                  |  7 +++++
 lisp/eshell/esh-io.el     | 14 ++++------
 src/fileio.c              | 42 ++++++++++++++++++++++--------
 src/process.c             |  6 ++---
 test/src/process-tests.el | 54 +++++++++++++++++++++++++++++++++++++++
 5 files changed, 99 insertions(+), 24 deletions(-)

diff --git a/etc/NEWS b/etc/NEWS
index 5b3be259196..5cd43872649 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -2735,6 +2735,13 @@ See the new argument in 'make-thread'.  The default value allows the
 thread's current buffer to be killed by another thread.  This does not
 apply to the main thread's buffer.
 
+---
+** Emacs no longer kills child processes after EPIPE.
+Previously, Emacs would immediately kill a child process and set its
+exit status to 256 if sending input to that process returned EPIPE.
+Now when this happens, Emacs closes the file descriptor to write to the
+child process, but allows it to continue execution as normal.
+
 
 * Lisp Changes in Emacs 31.1
 
diff --git a/lisp/eshell/esh-io.el b/lisp/eshell/esh-io.el
index def51b0c0cb..d063ad42eea 100644
--- a/lisp/eshell/esh-io.el
+++ b/lisp/eshell/esh-io.el
@@ -744,15 +744,11 @@ eshell-output-object-to-target
   (condition-case err
       (process-send-string target object)
     (error
-     ;; If `process-send-string' raises an error and the process has
-     ;; finished, treat it as a broken pipe.  Otherwise, just re-raise
-     ;; the signal.  NOTE: When running Emacs in batch mode
-     ;; (e.g. during regression tests), Emacs can abort due to SIGPIPE
-     ;; here.  Maybe `process-send-string' should handle SIGPIPE even
-     ;; in batch mode (bug#66186).
-     (if (process-live-p target)
-         (signal (car err) (cdr err))
-       (signal 'eshell-pipe-broken (list target)))))
+     ;; NOTE: When running Emacs in batch mode (e.g. during regression
+     ;; tests), Emacs can abort due to SIGPIPE here.  Maybe
+     ;; `process-send-string' should handle SIGPIPE even in batch mode
+     ;; (bug#66186).
+     (signal 'eshell-pipe-broken (list target))))
   object)
 
 (cl-defmethod eshell-output-object-to-target (object
diff --git a/src/fileio.c b/src/fileio.c
index 5cb3a267807..47c4dc4f3b0 100644
--- a/src/fileio.c
+++ b/src/fileio.c
@@ -6570,6 +6570,20 @@ DEFUN ("next-read-file-uses-dialog-p", Fnext_read_file_uses_dialog_p,
 }
 
 
+static FILE *
+file_for_stream(Lisp_Object stream)
+{
+  CHECK_SYMBOL (stream);
+  if (EQ (stream, Qstdin))
+    return stdin;
+  else if (EQ (stream, Qstdout))
+    return stdout;
+  else if (EQ (stream, Qstderr))
+    return stderr;
+  else
+    xsignal2 (Qerror, build_string ("unsupported stream"), stream);
+}
+
 DEFUN ("set-binary-mode", Fset_binary_mode, Sset_binary_mode, 2, 2, 0,
        doc: /* Switch STREAM to binary I/O mode or text I/O mode.
 STREAM can be one of the symbols `stdin', `stdout', or `stderr'.
@@ -6591,25 +6605,30 @@ DEFUN ("set-binary-mode", Fset_binary_mode, Sset_binary_mode, 2, 2, 0,
 effect except for flushing STREAM's data.  */)
   (Lisp_Object stream, Lisp_Object mode)
 {
-  FILE *fp = NULL;
+  FILE *fp = file_for_stream (stream);
   int binmode;
 
-  CHECK_SYMBOL (stream);
-  if (EQ (stream, Qstdin))
-    fp = stdin;
-  else if (EQ (stream, Qstdout))
-    fp = stdout;
-  else if (EQ (stream, Qstderr))
-    fp = stderr;
-  else
-    xsignal2 (Qerror, build_string ("unsupported stream"), stream);
-
   binmode = NILP (mode) ? O_TEXT : O_BINARY;
   if (fp != stdin)
     fflush (fp);
 
   return (set_binary_mode (fileno (fp), binmode) == O_BINARY) ? Qt : Qnil;
 }
+
+DEFUN ("internal-close-stream", Finternal_close_stream,
+       Sinternal_close_stream, 1, 1, 0,
+       doc: /* Close the file associated with STREAM.
+STREAM can be one of the symbols `stdin', `stdout', or `stderr'.
+
+This function is primarily intended for testing process machinery within
+Emacs.  */)
+  (Lisp_Object stream)
+{
+  FILE *fp = file_for_stream (stream);
+  fclose (fp);
+  return Qnil;
+}
+
 
 #ifndef DOS_NT
 
@@ -7045,6 +7064,7 @@ do (file-exists-p FILENAME) and FILENAME is handled by HANDLER, then
   defsubr (&Snext_read_file_uses_dialog_p);
 
   defsubr (&Sset_binary_mode);
+  defsubr (&Sinternal_close_stream);
 
 #ifndef DOS_NT
   defsubr (&Sfile_system_info);
diff --git a/src/process.c b/src/process.c
index e61ec425f7e..a77fda41d1e 100644
--- a/src/process.c
+++ b/src/process.c
@@ -6873,10 +6873,8 @@ send_process (Lisp_Object proc, const char *buf, ptrdiff_t len,
 		}
 	      else if (errno == EPIPE)
 		{
-		  p->raw_status_new = 0;
-		  pset_status (p, list2 (Qexit, make_fixnum (256)));
-		  p->tick = ++process_tick;
-		  deactivate_process (proc);
+		  close_process_fd (&p->open_fd[WRITE_TO_SUBPROCESS]);
+		  p->outfd = -1;
 		  error ("Process %s no longer connected to pipe; closed it",
 			 SDATA (p->name));
 		}
diff --git a/test/src/process-tests.el b/test/src/process-tests.el
index 0693bbe8924..63e6c138447 100644
--- a/test/src/process-tests.el
+++ b/test/src/process-tests.el
@@ -1044,6 +1044,60 @@ process-sentinel-interrupt-event
       ;; ...and the change description should be "interrupt".
       (should (equal '("interrupt\n") events)))))
 
+(defun process-tests/broken-pipe (connection-type)
+  "Test handling of broken pipes; see bug#79079.
+This test runs a shell script that reads a line of text and closes
+stdin.  We send two lines of text to the script; the second should
+signal an error indicating that the pipe has been closed.  The script
+should also run to completion, printing out the line of text it read."
+  (with-temp-buffer
+    (let ((saw-error nil)
+          (proc (make-process
+                 :name "test" :buffer (current-buffer)
+                 :command `(,(expand-file-name invocation-name
+                                               invocation-directory)
+                            "-Q" "--batch" "--eval"
+                            ,(prin1-to-string
+                              '(let ((line (read-string "")))
+                                 (internal-close-stream 'stdin)
+                                 (sit-for 1)
+                                 (message "%s" line))))
+                 :connection-type 'pipe)))
+      (process-send-string proc "hello\n")
+      (sit-for 0.1)
+      (condition-case err
+          (process-send-string proc "extra\n")
+        (error
+         (setq saw-error t)
+         (should (equal (error-message-string err)
+                        (concat "Process test no longer connected to pipe; "
+                                "closed it")))))
+      (unless saw-error
+        (ert-fail "Expected error from `process-send-string'"))
+      ;; Wait for the process to finish, and check results.
+      (while (eq (process-status proc) 'run)
+        (sit-for 0.1))
+      (should (equal (buffer-string) "hello\n\nProcess test finished\n"))
+      (should (eq (process-status proc) 'exit))
+      (should (eq (process-exit-status proc) 0)))))
+
+;; These tests only works when running Emacs interactively, since we
+;; don't catch SIGPIPE in batch mode.
+(when (not noninteractive)
+  (ert-deftest process-tests/broken-pipe/pipe ()
+    (process-tests/broken-pipe 'pipe))
+
+  ;; Emacs doesn't support PTYs on MS-Windows.
+  (unless (memq system-type '(ms-dos windows-nt))
+    (ert-deftest process-tests/broken-pipe/pty ()
+      (process-tests/broken-pipe 'pty))
+
+    (ert-deftest process-tests/broken-pipe/pipe-stdin ()
+      (process-tests/broken-pipe '(pipe . pty)))
+
+    (ert-deftest process-tests/broken-pipe/pty-stdin ()
+      (process-tests/broken-pipe '(pty . pipe)))))
+
 (ert-deftest process-num-processors ()
   "Sanity checks for num-processors."
   (should (equal (num-processors) (num-processors)))
-- 
2.25.1