util: report back child errors to parent and throw #312

pull ViniciusCestarii wants to merge 2 commits into bitcoin-core:master from ViniciusCestarii:report-spawn-exec-errors changing 2 files +158 −11
  1. ViniciusCestarii commented at 6:32 PM on July 20, 2026: contributor

    Report execvp() and close() failures to the parent via a socket and throw from SpawnProcess.

    Previously the parent didn't have a way of knowing whether the child process successfully execute or not without polling for it or waiting for it.

    Now SpawnProcess waits for the exec() to happen before returning and before throwing it also reaps the child, preventing zombie process.

    Also make post-fork child not rely on on perror(), which is not async-signal-safe.

    This addresses the TODO on commit 69652f0edfa10bf8e43c9513d70193bae8ab35fc

  2. DrahtBot commented at 6:32 PM on July 20, 2026: none

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    ACK xyzconstant
    Stale ACK ryanofsky

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #231 (Add windows support by ryanofsky)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. in src/mp/util.cpp:154 in dee4383be1


    ryanofsky commented at 7:46 PM on July 20, 2026:

    In commit "util: report back child execvp error to parent and throw" (dee4383be1a6991adaf3da55d99e71cf853b6a09)

    Could be good to extend the PR to cover this error as well and translate it to an exception in the parent. Might be good to introduce a struct like:

      struct ChildError {
          int which;  // e.g. 1=close, 2=fcntl, 3=execvp
          int err;    // errno
      };
    

    and write that to the pipe, then have the parent throw

    throw std::system_error(error.err, std::system_category(), name_for(error.which))
    

    xyzconstant commented at 4:52 AM on July 21, 2026:

    If this close() call fails in the parent, it throws while the error_fds pair is still open, leaking those descriptors.

    Both ends of error_fds should be closed in the parent before throwing, and the original errno should be saved in a local variable so it isn’t overwritten by subsequent close() calls.

         if (close(fds[pid ? 0 : 1]) != 0) {
    +        const int error = errno;
             if (pid) {
                 (void)close(fds[1]);
    -            throw std::system_error(errno, std::system_category(), "close");
    +            (void)close(error_fds[0]);
    +            (void)close(error_fds[1]);
    +            throw std::system_error(error, std::system_category(), "close");
             }
    

    xyzconstant commented at 5:13 AM on July 21, 2026:

    There are also more descriptor leaks here that were already present. Since this PR adds an additional pair it doubles the issue. It might be a good idea to close all four of them while we're at it.

         if (pid == -1) {
    +        const int error = errno;
    +        (void)close(fds[0]);
    +        (void)close(fds[1]);
    +        (void)close(error_fds[0]);
    +        (void)close(error_fds[1]);
    -        throw std::system_error(errno, std::system_category(), "fork");
    +        throw std::system_error(error, std::system_category(), "fork");
         }
    

    ViniciusCestarii commented at 1:04 PM on July 21, 2026:

    Nice catch! Fixed this on d4177062b3f4e7113aab9d40d994eabe711cb371


    ViniciusCestarii commented at 1:06 PM on July 21, 2026:

    Nice catch! Fixed on d4177062b3f4e7113aab9d40d994eabe711cb371


    ViniciusCestarii commented at 1:35 PM on July 21, 2026:

    I agree, I have added ChildError to cover execv and close errors on d4177062b3f4e7113aab9d40d994eabe711cb371 and then added a helper function to read the error on a761acaa0897d8e5326996d58315d3d50e0f387c and renamed it to SpawnError to cover the parent error on reading from socket.

  4. ryanofsky approved
  5. ryanofsky commented at 7:56 PM on July 20, 2026: collaborator

    Concept ACK dee4383be1a6991adaf3da55d99e71cf853b6a09. I plan to review more and left a suggestion below.

    I think PR description might be underselling the change, because it describes the main advantage as avoiding a perror call that is not signal safe (so could hang). But I think a bigger improvement is translating the execvp failure in the child into an exception in the parent. Previously the parent didn't have a way of knowing whether the child process successfully execute or not without polling for it or waiting for it.

  6. ViniciusCestarii commented at 8:45 PM on July 20, 2026: contributor

    Thanks for reviewing @ryanofsky! I updated the PR description to sell this PR more and I will address your comment.

  7. in src/mp/util.cpp:180 in dee4383be1 outdated
     182 | +
     183 | +    // Close the parent's copy of the child's write end.
     184 | +    if (close(error_fds[0]) != 0) {
     185 | +        (void)close(fds[1]);
     186 | +        throw std::system_error(errno, std::system_category(), "close");
     187 | +    }
    


    xyzconstant commented at 5:04 AM on July 21, 2026:

    error_fds[1] is leaking here too, close it (plus the same stale-errno fix as above).

         if (close(error_fds[0]) != 0) {
    +        const int error = errno;
    +        (void)close(error_fds[1]);
             (void)close(fds[1]);
    -        throw std::system_error(errno, std::system_category(), "close");
    +        throw std::system_error(error, std::system_category(), "close");
         }
    

    ViniciusCestarii commented at 1:05 PM on July 21, 2026:

    Nice! Fixed on d4177062b3f4e7113aab9d40d994eabe711cb371

  8. in src/mp/util.cpp:197 in dee4383be1
     199 | +    if (readResult > 0 && error) {
     200 | +        (void)close(fds[1]);
     201 | +        // Wait it here to avoid leaking a zombie process and then throw.
     202 | +        (void)::waitpid(pid, nullptr, 0);
     203 | +        throw std::system_error(error, std::system_category(), "execvp");
     204 | +    }
    


    xyzconstant commented at 5:31 AM on July 21, 2026:

    It might be worth extracting this into a small helper, e.g.

    //! Read the child's exec result from the error socket, then close it.
    //! Returns 0 on success, otherwise an errno.
    int ReadChildErrno(int fd);
    

    So SpawnProcess has a single close/waitpid/throw site instead of repeating cleanup in each error branch.


    ViniciusCestarii commented at 1:31 PM on July 21, 2026:

    Yes I agree that it is better to extract this. I have added a function to extract it on a761acaa0897d8e5326996d58315d3d50e0f387c (note that I updated the code to use SpawnError)

  9. xyzconstant commented at 5:57 AM on July 21, 2026: contributor

    Concept ACK dee4383be1a6991adaf3da55d99e71cf853b6a09

    Left some comments about descriptor leaks on the error paths. Planning to review more once you update on ryanofsky's feedback.

  10. Sjors commented at 8:28 AM on July 21, 2026: member

    Note that this change happens very close to the code touched by #311, but does seem orthogonal at first glance.

  11. ViniciusCestarii force-pushed on Jul 21, 2026
  12. ViniciusCestarii renamed this:
    util: report back child execvp error to parent and throw
    util: report back child errors to parent and throw
    on Jul 21, 2026
  13. ViniciusCestarii commented at 1:50 PM on July 21, 2026: contributor

    Thanks @xyzconstant for reviewing! I have force-pushed d4177062b3f4e7113aab9d40d994eabe711cb371 and pushed a761acaa0897d8e5326996d58315d3d50e0f387c and updated PR title and description.

    I have addresed:

    • @xyzconstant comment about leaking fds and throwing wrong errno and @ryanofsky comment for reporting a struct ChildError to cover execv and close errors on d4177062b3f4e7113aab9d40d994eabe711cb371.
    • @xyzconstant comment that this PR could benefit with a function for reading the error from child so I added ReadSpawnResult on a761acaa0897d8e5326996d58315d3d50e0f387c and also renamed ChildError to SpawnError to cover the parent error on reading from socket.

    I believe this now conflicts with #311 but it should be a simple rebase for whichever PR merges second.

    I also noticed that there are several throws after fork that could benefit from reaping the child to prevent zombie process, but I believe this could be a follow up.

  14. in src/mp/util.cpp:120 in d4177062b3
     116 | @@ -117,9 +117,30 @@ std::string LogEscape(const kj::StringTree& string, size_t max_size)
     117 |      return result;
     118 |  }
     119 |  
     120 | +enum class ChildErrorOp
    


    ryanofsky commented at 11:28 AM on July 30, 2026:

    In commit "util: report back child error to parent and throw" (d4177062b3f4e7113aab9d40d994eabe711cb371)

    It would be good to move this to anonymous namespace above below MakeArgv and MaxFd so functions here don't get exported as symbols and so this code can be skipped in the same #ifndef WIN32 on windows in #231


    ViniciusCestarii commented at 3:14 PM on July 30, 2026:

    Nice, done on eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca.

  15. in src/mp/util.cpp:142 in a761acaa08
     143 | +    case SpawnErrorOp::READ: return "read";
     144 |      }
     145 |      return "unknown";
     146 |  }
     147 |  
     148 | +SpawnError ReadSpawnResult(int fd)
    


    ryanofsky commented at 11:32 AM on July 30, 2026:

    In commit "util: extract ReadSpawnResult and report read() failures as SpawnError" (a761acaa0897d8e5326996d58315d3d50e0f387c)

    Changes here looks good, but seems like this commit should be squashed in the first commit.


    ryanofsky commented at 12:02 PM on July 30, 2026:

    In commit "util: extract ReadSpawnResult and report read() failures as SpawnError" (a761acaa0897d8e5326996d58315d3d50e0f387c)

    Might want to handle short reads here and also short/interrupted writes. Claude suggests:

    // Read the child's error report. Returns nullopt on success, or a SpawnError to
    // throw on failure. Success is a clean EOF: read() returns 0 with nothing
    // buffered because the child's write end was closed by a successful exec (via
    // FD_CLOEXEC). A fully-read struct is the failure the child reported. A read()
    // error, or an EOF partway through the struct (the child died mid-report), is
    // surfaced as a SpawnErrorOp::READ failure rather than being mistaken for
    // success. A single read() is not guaranteed to return all sizeof(SpawnError)
    // bytes (the socket is SOCK_STREAM, which has no message boundaries) and may be
    // interrupted by a signal, so loop until the whole struct is read.
    std::optional<SpawnError> ReadSpawnResult(int fd)
    {
        SpawnError error{};
        char* buf = reinterpret_cast<char*>(&error);
        size_t remaining = sizeof(error);
        while (remaining > 0) {
            const ssize_t n = ::read(fd, buf, remaining);
            if (n < 0) {
                if (errno == EINTR) continue;
                return SpawnError{.which = SpawnErrorOp::READ, .err = errno};
            }
            if (n == 0) {
                if (remaining == sizeof(error)) return std::nullopt; // clean EOF: success
                return SpawnError{.which = SpawnErrorOp::READ, .err = EPROTO}; // torn report
            }
            buf += n;
            remaining -= static_cast<size_t>(n);
        }
        return error;
    }
    
    // Write the whole SpawnError to fd, retrying short writes and EINTR so the
    // parent never sees a torn struct. Runs in the post-fork child, so it must stay
    // async-signal-safe: it only calls write() and does not allocate or throw. This
    // is best-effort -- if the write cannot complete there is nothing useful the
    // child can do, so it stops and lets the caller _exit().
    void WriteSpawnError(int fd, const SpawnError& error)
    {
        const char* buf = reinterpret_cast<const char*>(&error);
        size_t remaining = sizeof(error);
        while (remaining > 0) {
            const ssize_t n = ::write(fd, buf, remaining);
            if (n < 0) {
                if (errno == EINTR) continue;
                break;
            }
            buf += n;
            remaining -= static_cast<size_t>(n);
        }
        if (remaining > 0) {
            // The parent's read end is gone (e.g. the parent exited before the
            // child could report), so the structured error can't be delivered.
            // Leave a breadcrumb on stderr and exit. The exit code is irrelevant
            // here since no live parent remains to wait on it.
            ChildFail("SpawnProcess(child): failed and could not report error to parent\n");
        }
    }
    

    Note this uses ChildFail from #311 (which will probably be merged first so should be available)


    ViniciusCestarii commented at 3:13 PM on July 30, 2026:

    This is nicer, added on eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca.


    ViniciusCestarii commented at 3:14 PM on July 30, 2026:

    Squashed on eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca.

  16. in src/mp/util.cpp:143 in d4177062b3 outdated
     138 | +}
     139 | +
     140 |  std::tuple<ProcessId, SocketId> SpawnProcess(SpawnConnectInfoToArgsFn&& connect_info_to_args)
     141 |  {
     142 |      auto fds{SocketPair()};
     143 | +    auto error_fds{SocketPair()};
    


    ryanofsky commented at 12:07 PM on July 30, 2026:

    In commit "util: report back child error to parent and throw" (d4177062b3f4e7113aab9d40d994eabe711cb371)

    I think current approach is good and probably best to use a socketpair instead of a pipe, but might be worth a comment that this is only used for child to send errors to the parent so a one-way pipe would be sufficient


    ViniciusCestarii commented at 3:12 PM on July 30, 2026:

    Nice, done on eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca.

  17. ryanofsky approved
  18. ryanofsky commented at 12:44 PM on July 30, 2026: collaborator

    Code review ACK a761acaa0897d8e5326996d58315d3d50e0f387c. Nice changes that should substantially improve error reporting.

    I left some suggestions below but they are not important, this should already be an improvement over current code. Also I think #311 will be merged first and this will need to be rebased, but the two pr's should complement each other and not conflict very much.

    Would note that even with #311 there is a still a race condition since CLOEXEC flag is not applied right away and even with this PR there are still errors not handled like connect_info_to_args or KJ_SYSCALL syscall throwing which could cause leaks, so more improvements could be made as followups.

    The posix code is definitely getting more complicated as a result of these error handling improvements (especially compared to the windows code) but it seems like there might not be a way to avoid this with current Bitcoin core code which is not applying CLOEXEC flags, so file descriptors will leak into child processes. This prevents using posix_spawn instead of fork/exec.

  19. DrahtBot requested review from xyzconstant on Jul 30, 2026
  20. ViniciusCestarii force-pushed on Jul 30, 2026
  21. ViniciusCestarii commented at 3:10 PM on July 30, 2026: contributor

    Thanks again for the reviews! Forced-push eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca squashing into a single commit and basing these changes on #311. I also addressed @ryanofsky comments.

  22. in src/mp/util.cpp:296 in eecda07ea4 outdated
     297 | +
     298 | +    const std::optional<SpawnError> error{ReadSpawnResult(error_fds[1])};
     299 | +    (void)close(error_fds[1]);
     300 | +    if (error) {
     301 | +        (void)close(fds[1]);
     302 | +        throw std::system_error(error->err, std::system_category(), SpawnErrorName(error->which));
    


    ryanofsky commented at 4:48 PM on August 3, 2026:

    In commit "util: report back child error to parent and throw" (eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca)

    In the places there this function is throwing after calling fork() and it returns a child pid (lines 248, 289, and 296) it looks like this code will leak the pid and leave behind a zombie process. I think it would make sense to call waitpid these places, with the pid and maybe with NOHANG.


    ViniciusCestarii commented at 7:59 PM on August 3, 2026:

    Good catch. Fixed it in a separate commit: 3f05b11624ce02e35b52e7451b2c7092b9b48a7c

    I went with SIGKILL followed by a blocking wait rather than NOHANG because at some places the child may still be alive and racing toward exec, so NOHANG would usually return 0 and leave the zombie behind. After a SIGKILL the wait is guaranteed to reap and returns immediately.

  23. in src/mp/util.cpp:108 in eecda07ea4 outdated
     103 | +// FD_CLOEXEC). A fully-read struct is the failure the child reported. A read()
     104 | +// error, or an EOF partway through the struct (the child died mid-report), is
     105 | +// surfaced as a SpawnErrorOp::READ failure rather than being mistaken for
     106 | +// success. A single read() is not guaranteed to return all sizeof(SpawnError)
     107 | +// bytes (the socket is SOCK_STREAM, which has no message boundaries) and may be
     108 | +// interrupted by a signal, so loop until the whole struct is read.
    


    ryanofsky commented at 5:04 PM on August 3, 2026:

    In commit "util: report back child error to parent and throw" (eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca)

    Would be good to note that an EOF can also happen if the child is killed before running exec, and this will return success in that case. There isn't really a good way for this function to avoid this but callers should see the failure when they call WaitProcess


    ViniciusCestarii commented at 7:59 PM on August 3, 2026:

    Nice catch. I added a note in 4a56c1837a781fbd859c4a3ef91fdd9e213b42a7

  24. ryanofsky approved
  25. ryanofsky commented at 5:08 PM on August 3, 2026: collaborator

    Code review ACK eecda07ea4ae8f7bbb592bdbf00a19d9c43458ca. Left minor suggests below but this looks good as-is.

    Might want to note in commit messages or PR description that this does change SpawnProcess behavior a little bit beyond error reporting since it now waits for the exec() to happen before returning instead of just the fork() so it might be a little slower (not that it should matter in practice)

  26. util: report back child error to parent and throw 4a56c1837a
  27. ViniciusCestarii force-pushed on Aug 3, 2026
  28. util: kill and reap child on SpawnProcess error 3f05b11624
  29. ViniciusCestarii force-pushed on Aug 3, 2026
  30. ViniciusCestarii commented at 8:07 PM on August 3, 2026: contributor

    Thanks for the review! Forced push 3f05b11624ce02e35b52e7451b2c7092b9b48a7c addressing #312 (review) adding a note on function ReadSpawnResult and #312 (review) by adding the function KillAndReapChild and calling it before throwing.

    Updated PR description with: SpawnProcess waits for the exec() to happen before returning and before throwing it also reaps the child, preventing zombie process.

  31. xyzconstant commented at 6:03 PM on August 5, 2026: contributor

    LGTM. ACK 3f05b11624ce02e35b52e7451b2c7092b9b48a7c

  32. DrahtBot requested review from ryanofsky on Aug 5, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin-core/libmultiprocess. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-05 20:30 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me