Skip to content

xtool dev build dies with a fatal SwiftNIO error when stdout is /dev/null #268

Open

Description

@keveduardo

What happens

xtool dev build with stdout redirected to /dev/null dies immediately, before doing any work, with a fatal error rather than a message:

NIOPosix/BaseSocketChannel.swift:1018: Fatal error: 'try!' expression unexpectedly raised an error: epoll_ctl(epfd:op:fd:event:): Operation not permitted) (errno: 1)
Current stack trace:
0   ... <unavailable> + 11213777
...

Reproduction

xtool dev build >/dev/null 2>&1     # crashes, every time
xtool dev build 2>&1 | cat          # fine — builds normally
xtool dev build > build.log 2>&1    # fine
xtool dev build                     # fine in a terminal
script -qec 'xtool dev build' /dev/null   # fine (a pty)

So it is specifically /dev/null, not redirection in general: pipes, regular files and ptys all work.

Cause, from strace

dup(0)                                                          = 33
dup(1)                                                          = 34
epoll_ctl(9, EPOLL_CTL_ADD, 33, {events=EPOLLERR|EPOLLHUP, ...}) = 0
epoll_ctl(9, EPOLL_CTL_ADD, 34, {events=EPOLLERR|EPOLLHUP, ...}) = -1 EPERM (Operation not permitted)

dev duplicates stdio and registers both descriptors with NIO's epoll. /dev/null's file_operations has no poll method, so epoll_ctl answers EPERM for it — and the try! in BaseSocketChannel.swift:1018 converts that into a fatal error. The duplicate of fd 0 registers fine; the duplicate of fd 1 is the one that fails.

Environment

  • xtool 1.20.0, xtool-x86_64.AppImage (also reproduces on 1.19.2)
  • Ubuntu 24.04.4 LTS, x86_64, kernel 6.8
  • Swift 6.4 release toolchain via swiftly
  • No seccomp filter, AppArmor unconfined — verified with /proc/self/status

Impact

>/dev/null is ordinary in scripts and CI ("run it, I only want the exit code"), and the failure is a crash with a stack trace rather than a diagnosis. Handling the registration failure, or not registering a non-pollable descriptor, would be enough — I'm happy to test a patch.

Activity

  1. kabiroberai commented on Sep 22, 2026

    @kabiroberai
    Member

    Seems like this happens when xtool tries to ask for input with Console.prompt. It's the "Path to Xcode.xip:" prompt when the SDK hasn't yet been installed. Still worth fixing but it seems like it's only in specific cases.

    Does this happen for you even after you complete SDK setup?

  2. mvanhorn commented on Oct 7, 2026

    @mvanhorn
    Contributor

    Console.prompt always duplicated standard input and output into NIOPipeBootstrap, which registers those descriptors with epoll. Linux epoll_ctl returns EPERM for /dev/null, and SwiftNIO turns that failure into a fatal error. isatty and poll still leave /dev/null on the NIO path: other non-ttys already succeed there, and poll reports /dev/null as ready.

    prompt takes explicit input and output descriptors and uses the NIO bootstrap only when isEpollRegisterable returns true for both.

  3. HadesX23 commented on Oct 15, 2026

    @HadesX23

    Additional trigger: xtool new with no project name

    Hit the same crash from a different entry point while setting up xtool, which suggests this is not specific to the .xip path prompt — any Console.prompt call can reach it.

    $ xtool new                 # no project name
    NIOPosix/BaseSocketChannel.swift:1018: Fatal error: 'try!' expression unexpectedly raised an error: epoll_ctl(epfd:op:fd:event:): Operation not permitted) (errno: 1)
    

    → SIGILL (exit 132), core dumped. Same failure mode as reported here: the trap is on the NIO event-loop thread, in a try! around epoll_ctl returning EPERM.

    Isolated by varying only stdio, always with no project name:

    stdin stdout result
    /dev/null pipe SIGILL, exit 132 (11/11 runs)
    /dev/null regular file Error: Operation unsupported, exit 1
    empty regular file pipe Error: Operation unsupported, exit 1
    tty (pty) tty Error: I/O on closed channel, exit 1

    For contrast, supplying a name under the exact crashing condition succeeds:

    $ xtool new Foo             # stdin=/dev/null, stdout=pipe
    rc=0 — Finished generating project Foo.
    

    So supplying a name makes the crash unreachable, and with no name the crash needs /dev/null on stdin specifically — a regular file or a pty on stdin is handled cleanly.

    That is consistent with the mechanism described above: the descriptor duplicated into the NIO bootstrap fails EPOLL_CTL_ADD with EPERM. Worth noting that EPERM here is not a /dev/null speciality — regular files and non-tty character devices fail the same way when registered directly; /dev/null is simply the descriptor that reaches this path in practice.

    The Darwin SDK was already installed when this happened, so the failure is not tied to the "SDK not yet installed" state — it is about the stdio descriptors.

    Ruled out on my side: OOM (6 GB free, 26 GB swap free), seccomp (Seccomp: 0, Seccomp_filters: 0), container (systemd-detect-virt: none), and cwd/filesystem.

    Environment: xtool 1.22.2 (AppImage), Omarchy 4.0.4 (Arch-based), x86_64, kernel 7.2.5.

    The change in #304 goes through the shared Console.prompt, so it should cover this path as well — noting it here in case it is useful for the fix's scope.

  4. mvanhorn commented on Oct 17, 2026

    @mvanhorn
    Contributor

    Thanks, this makes the scope clear: this is a general Console.prompt/stdio issue, not an SDK-installation edge case. The /dev/null stdin plus pipe stdout case points to the NIO epoll-registration path, while supplying a project name simply avoids prompting. Since #304 goes through shared Console.prompt, it should cover xtool new too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions