Marge Bot pushed to branch wip/marge_bot_batch_merge_job at Glasgow Haskell Compiler / GHC
Commits:
-
cbfbecb6
by Kazu Yamamoto at 2026-10-06T15:16:47-04:00
-
f20e7d94
by Simon Jakobi at 2026-10-06T15:16:48-04:00
6 changed files:
- + changelog.d/24450
- changelog.d/fix-parallel-gc-selector-race-27477
- rts/RtsStartup.c
- + testsuite/tests/rts/T24450.hs
- + testsuite/tests/rts/T24450.stderr
- testsuite/tests/rts/all.T
Changes:
| 1 | +section: rts
|
|
| 2 | +synopsis:
|
|
| 3 | + Fix a hang when a program is started with a standard file descriptor closed
|
|
| 4 | +description:
|
|
| 5 | + A process started with stdout or stderr closed -- ``prog >&-`` from a shell
|
|
| 6 | + does exactly that -- left that descriptor number free for the runtime to
|
|
| 7 | + take for its own use. The ``Handle`` for the stream, built later when the
|
|
| 8 | + program first wrote to it, then took over a descriptor belonging to the
|
|
| 9 | + runtime: the program wrote its output into the runtime's own plumbing and
|
|
| 10 | + hung, with no error reported anywhere. The runtime now opens ``/dev/null``
|
|
| 11 | + onto whichever of the descriptors 0, 1 and 2 are closed when it starts, so
|
|
| 12 | + such a stream is connected to ``/dev/null`` instead.
|
|
| 13 | +mrs: !16710
|
|
| 14 | +issues: #24450 |
| 1 | 1 | section: rts
|
| 2 | 2 | synopsis: Fix a parallel GC race on weakly-ordered architectures (AArch64) that could crash or silently corrupt the heap
|
| 3 | 3 | description:
|
| 4 | - On weakly-ordered architectures such as AArch64 programs using
|
|
| 4 | + On weakly-ordered architectures such as AArch64, programs
|
|
| 5 | 5 | could crash during parallel garbage collection (segmentation fault
|
| 6 | 6 | or ``internal error: eval_thunk_selector: strange selectee <n>``) or
|
| 7 | 7 | continue running with silently corrupted heap data, due to a missing
|
| ... | ... | @@ -69,6 +69,10 @@ |
| 69 | 69 | #if defined(HAVE_LOCALE_H)
|
| 70 | 70 | #include <locale.h>
|
| 71 | 71 | #endif
|
| 72 | +#if !defined(mingw32_HOST_OS)
|
|
| 73 | +#include <errno.h>
|
|
| 74 | +#include <fcntl.h>
|
|
| 75 | +#endif
|
|
| 72 | 76 | |
| 73 | 77 | // Count of how many outstanding hs_init()s there have been.
|
| 74 | 78 | static StgWord hs_init_count = 0;
|
| ... | ... | @@ -81,6 +85,10 @@ static int64_t __codePage = -1; |
| 81 | 85 | |
| 82 | 86 | static void flushStdHandles(void);
|
| 83 | 87 | |
| 88 | +#if !defined(mingw32_HOST_OS)
|
|
| 89 | +static void sanitiseStandardFds(void);
|
|
| 90 | +#endif
|
|
| 91 | + |
|
| 84 | 92 | /* -----------------------------------------------------------------------------
|
| 85 | 93 | Initialise floating point unit on x86 (currently disabled; See Note
|
| 86 | 94 | [x86 Floating point precision] in compiler/GHC/CmmToAsm/X86/Instr.hs)
|
| ... | ... | @@ -238,6 +246,82 @@ hs_init_with_rtsopts(int *argc, char **argv[]) |
| 238 | 246 | |
| 239 | 247 | void init_ghc_hs_iface(void);
|
| 240 | 248 | |
| 249 | +#if !defined(mingw32_HOST_OS)
|
|
| 250 | +/* See Note [Standard file descriptors must be open]. */
|
|
| 251 | +static void sanitiseStandardFds(void)
|
|
| 252 | +{
|
|
| 253 | + // The access mode each descriptor would have had, had the process been
|
|
| 254 | + // started with it open.
|
|
| 255 | + static const int access_mode[3] = { O_RDONLY, O_WRONLY, O_WRONLY };
|
|
| 256 | + |
|
| 257 | + for (int fd = 0; fd <= 2; fd++) {
|
|
| 258 | + if (fcntl(fd, F_GETFD) != -1 || errno != EBADF) {
|
|
| 259 | + continue; // already open
|
|
| 260 | + }
|
|
| 261 | + |
|
| 262 | + int nullfd;
|
|
| 263 | + do {
|
|
| 264 | + nullfd = open("/dev/null", access_mode[fd]);
|
|
| 265 | + } while (nullfd == -1 && errno == EINTR);
|
|
| 266 | + |
|
| 267 | + if (nullfd == -1) {
|
|
| 268 | + // No /dev/null to be had. There is nothing better to do than
|
|
| 269 | + // carry on; the hazard the Note describes is then still there,
|
|
| 270 | + // but refusing to start would be worse.
|
|
| 271 | + return;
|
|
| 272 | + }
|
|
| 273 | + if (nullfd != fd) {
|
|
| 274 | + // open() hands out the lowest free descriptor, so this cannot
|
|
| 275 | + // happen unless something in this process is opening descriptors
|
|
| 276 | + // concurrently. Do not shuffle its descriptor around.
|
|
| 277 | + close(nullfd);
|
|
| 278 | + return;
|
|
| 279 | + }
|
|
| 280 | + }
|
|
| 281 | +}
|
|
| 282 | + |
|
| 283 | +/*
|
|
| 284 | + * Note [Standard file descriptors must be open]
|
|
| 285 | + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
| 286 | + * A process may be started with any of file descriptors 0, 1 and 2 closed --
|
|
| 287 | + * from a shell, `prog >&-` does exactly that. Nothing then reserves those
|
|
| 288 | + * numbers, and open(), pipe() and socket() hand out the lowest free
|
|
| 289 | + * descriptor, so the next descriptor the runtime creates for itself takes one
|
|
| 290 | + * of them: the ticker's pipe, or a capability's IO manager control pipe.
|
|
| 291 | + *
|
|
| 292 | + * Nothing warns anybody. 'GHC.Internal.IO.StdHandles.stdout' is a CAF, so it
|
|
| 293 | + * is built the first time the program writes to stdout, which is long after
|
|
| 294 | + * that has happened, and it builds a 'Handle' over descriptor 1 -- which now
|
|
| 295 | + * belongs to the runtime. The program then writes its output into the
|
|
| 296 | + * runtime's own plumbing, and at exit closes it.
|
|
| 297 | + *
|
|
| 298 | + * What this looks like from the outside is a hang, because the descriptor the
|
|
| 299 | + * Handle has taken over is the read end of a pipe. It is a perfectly good
|
|
| 300 | + * descriptor, so nothing reports an error: poll() for POLLOUT on it simply
|
|
| 301 | + * never says yes, epoll_ctl() accepts a registration for it, and the thread
|
|
| 302 | + * waiting to write blocks for ever. See #24450, where this is `prog >&-`
|
|
| 303 | + * hanging after main has finished.
|
|
| 304 | + *
|
|
| 305 | + * Closing those descriptors is legal, so the runtime cannot refuse them; and
|
|
| 306 | + * moving only the descriptors the runtime opens would not help, since the
|
|
| 307 | + * first file the program itself opens would land on the free number instead.
|
|
| 308 | + * The fix is to leave no free number: open /dev/null onto whichever of 0, 1
|
|
| 309 | + * and 2 are closed, before the runtime creates any descriptor of its own.
|
|
| 310 | + * Other runtimes do the same, and for the same reason.
|
|
| 311 | + *
|
|
| 312 | + * A program run this way therefore finds stdout connected to /dev/null rather
|
|
| 313 | + * than failing with EBADF on the first write. That is the price of the fix,
|
|
| 314 | + * and it is the behaviour such a program would get from most other language
|
|
| 315 | + * runtimes.
|
|
| 316 | + *
|
|
| 317 | + * Each substitute is opened for the access the descriptor would normally have:
|
|
| 318 | + * read-only for 0, write-only for 1 and 2. Opening all three for both would
|
|
| 319 | + * make the substitute something a real stdin or stdout is not, and writing to
|
|
| 320 | + * 'stdin' or reading from 'stdout' would then quietly succeed instead of
|
|
| 321 | + * failing with EBADF.
|
|
| 322 | + */
|
|
| 323 | +#endif
|
|
| 324 | + |
|
| 241 | 325 | void
|
| 242 | 326 | hs_init_ghc(int *argc, char **argv[], RtsConfig rts_config)
|
| 243 | 327 | {
|
| ... | ... | @@ -252,6 +336,12 @@ hs_init_ghc(int *argc, char **argv[], RtsConfig rts_config) |
| 252 | 336 | stg_exit(1);
|
| 253 | 337 | }
|
| 254 | 338 | |
| 339 | +#if !defined(mingw32_HOST_OS)
|
|
| 340 | + /* Before anything here opens a file descriptor of its own.
|
|
| 341 | + See Note [Standard file descriptors must be open]. */
|
|
| 342 | + sanitiseStandardFds();
|
|
| 343 | +#endif
|
|
| 344 | + |
|
| 255 | 345 | #if defined(wasm32_HOST_ARCH)
|
| 256 | 346 | char *pwd = getenv("PWD");
|
| 257 | 347 | if (pwd != NULL) {
|
| 1 | +{-# LANGUAGE ScopedTypeVariables #-}
|
|
| 2 | +-- A process may be started with stdout closed -- `prog >&-` from a shell does
|
|
| 3 | +-- exactly that. Nothing then reserves descriptor 1, so the next descriptor
|
|
| 4 | +-- the runtime opens for itself takes it, and the 'Handle' for stdout, built
|
|
| 5 | +-- later when the program first writes, takes over a descriptor belonging to
|
|
| 6 | +-- the runtime. The write then goes into the runtime's own plumbing, and what
|
|
| 7 | +-- this looks like from the outside is a hang. See #24450 and
|
|
| 8 | +-- Note [Standard file descriptors must be open] in rts/RtsStartup.c.
|
|
| 9 | +--
|
|
| 10 | +-- This test is run with its stdin and stdout closed, so what it has to say
|
|
| 11 | +-- goes to stderr. The four writes are the reproducer from the ticket and must
|
|
| 12 | +-- simply finish. The lines after them are what tell the two outcomes apart:
|
|
| 13 | +-- the descriptors the runtime opens for itself are pipes, so finding a pipe on
|
|
| 14 | +-- descriptor 0 or 1 means it was handed to the runtime.
|
|
| 15 | +module Main (main) where
|
|
| 16 | + |
|
| 17 | +import Control.Exception (IOException, try)
|
|
| 18 | +import System.IO
|
|
| 19 | +import System.Posix.Files (getFdStatus, isCharacterDevice, isNamedPipe)
|
|
| 20 | +import System.Posix.Types (Fd (..))
|
|
| 21 | + |
|
| 22 | +main :: IO ()
|
|
| 23 | +main = do
|
|
| 24 | + hPutChar stderr 'A' -- unbuffered
|
|
| 25 | + hPutChar stdout 'X' -- buffered; this is the one that used to hang
|
|
| 26 | + hPutChar stderr 'Z' -- unbuffered
|
|
| 27 | + hPutChar stderr '\n' -- unbuffered
|
|
| 28 | + mapM_ (\fd -> hPutStrLn stderr =<< describeFd fd) [0, 1]
|
|
| 29 | + |
|
| 30 | +describeFd :: Fd -> IO String
|
|
| 31 | +describeFd fd = do
|
|
| 32 | + r <- try (getFdStatus fd)
|
|
| 33 | + return $ "fd " ++ show fd ++ ": " ++ case r of
|
|
| 34 | + Left (_ :: IOException) -> "closed"
|
|
| 35 | + Right st
|
|
| 36 | + -- What the fix arranges: /dev/null, opened onto the free number
|
|
| 37 | + -- before the runtime could take it.
|
|
| 38 | + | isCharacterDevice st -> "character device"
|
|
| 39 | + -- What #24450 is: the runtime's ticker or control pipe.
|
|
| 40 | + | isNamedPipe st -> "pipe"
|
|
| 41 | + | otherwise -> "something else" |
| 1 | +AZ
|
|
| 2 | +fd 0: character device
|
|
| 3 | +fd 1: character device |
| ... | ... | @@ -408,6 +408,18 @@ test('T10590', [ ignore_stderr |
| 408 | 408 | , req_process
|
| 409 | 409 | ], compile_and_run, [''])
|
| 410 | 410 | |
| 411 | +# Run with stdin and stdout closed -- that is what leaves those descriptors
|
|
| 412 | +# free for the runtime to take, which is the whole of #24450. What the test
|
|
| 413 | +# has to say therefore goes to stderr and is checked against T24450.stderr.
|
|
| 414 | +test('T24450',
|
|
| 415 | + [ when(opsys('mingw32'), skip) # POSIX only
|
|
| 416 | + , js_skip # JS backend has its own runtime
|
|
| 417 | + # The wrapper below would close the stdout of the wasm runtime rather
|
|
| 418 | + # than that of the program running under it, which tests nothing.
|
|
| 419 | + , when(arch('wasm32'), skip)
|
|
| 420 | + , cmd_wrapper(lambda c: c + ' <&- >&-')
|
|
| 421 | + ], compile_and_run, [''])
|
|
| 422 | + |
|
| 411 | 423 | # 20000 was easily enough to trigger the bug with 7.10
|
| 412 | 424 | test('T10904', [ extra_run_opts('20000'), req_c ],
|
| 413 | 425 | compile_and_run, ['T10904lib.c'])
|