What went wrong?
On Linux with the AppImage install, every app launched by ao start leaves the AppImage runtime process (~/.ao/agent-orchestrator.AppImage --installed-via=npm-bootstrap) burning 100% of one CPU core for as long as the app runs, and after the app quits too.
Root cause: ao start starts the app with stdout and stderr closed. backend/internal/cli/process.go (at a34094e):
type processStartConfig struct {
...
Stdout *os.File
Stderr *os.File
}
func startProcess(cfg processStartConfig) error {
cmd := exec.Command(cfg.Path, cfg.Args...)
cmd.Env = cfg.Env
cmd.Stdout = cfg.Stdout
cmd.Stderr = cfg.Stderr
...
openApp leaves Stdout/Stderr unset. Assigning a nil *os.File to the io.Writer fields gives a non-nil interface holding a nil pointer, which os/exec passes as "no file". The child therefore starts with fd 1 and fd 2 closed. Stdin is untouched, so it correctly gets /dev/null. A minimal Go 1.27.1 program with the same assignment shows fd0=open fd1=CLOSED fd2=CLOSED in the child.
Why that costs a core: the AppImage type2 runtime (here AppImage/type2-runtime@dd6cebe) creates its keepalive pipe with pipe(), so with 1 and 2 free the pipe lands on fd 1/2. When the FUSE side daemonizes, it redirects 0-2 to /dev/null, which silently replaces the pipe's write end. write_pipe_thread then loops write() "until we block", and writes to /dev/null never block, so it spins in the kernel. It also never sees the EPIPE that should end it when the app exits.
Observed on the running app:
- The runtime process has fds
0,1,2 -> /dev/null, the AppImage and /dev/fuse, and no pipe.
- One thread spends about 75% of its time in the kernel, with 0 voluntary context switches.
- The Electron main process has
fd 2 -> /tmp/.mount_agent-*/AppRun: stderr was closed at start, and a later open() reused the number.
Minimal repro without AO:
A=~/.ao/agent-orchestrator.AppImage
setsid sh -c "exec $A --appimage-mount" </dev/null >/dev/null 2>&1 & # busiest runtime thread: 0% CPU
setsid sh -c "exec 0<&- 1>&- 2>&-; exec $A --appimage-mount" & # busiest runtime thread: 100% CPU
Also affected:
- A second-instance
ao start that only focuses the existing window also leaves a spinning runtime behind.
- Killing the spinning runtime unmounts
/tmp/.mount_agent-*. Running daemon, pty-host and chat-host processes execute from that mount, so they break. The only clean way out is restarting AO.
- The
.deb install isn't affected, since it has no FUSE runtime.
Suggested fix (one place, covers every caller): only set the writers when a file was given, so an unset stream falls back to os/exec's default, /dev/null:
if cfg.Stdout != nil {
cmd.Stdout = cfg.Stdout
}
if cfg.Stderr != nil {
cmd.Stderr = cfg.Stderr
}
Workaround: launch the AppImage yourself with /dev/null stdio instead of ao start:
setsid ~/.ao/agent-orchestrator.AppImage --installed-via=npm-bootstrap </dev/null >/dev/null 2>&1 &
Environment: AO 0.13.3 AppImage at ~/.ao/agent-orchestrator.AppImage, Ubuntu 24.04 (Linux 7.0), XFCE, libfuse2t64 2.9.9.
What went wrong?
On Linux with the AppImage install, every app launched by
ao startleaves the AppImage runtime process (~/.ao/agent-orchestrator.AppImage --installed-via=npm-bootstrap) burning 100% of one CPU core for as long as the app runs, and after the app quits too.Root cause:
ao startstarts the app with stdout and stderr closed.backend/internal/cli/process.go(at a34094e):openAppleavesStdout/Stderrunset. Assigning a nil*os.Fileto theio.Writerfields gives a non-nil interface holding a nil pointer, whichos/execpasses as "no file". The child therefore starts with fd 1 and fd 2 closed. Stdin is untouched, so it correctly gets/dev/null. A minimal Go 1.27.1 program with the same assignment showsfd0=open fd1=CLOSED fd2=CLOSEDin the child.Why that costs a core: the AppImage type2 runtime (here
AppImage/type2-runtime@dd6cebe) creates its keepalive pipe withpipe(), so with 1 and 2 free the pipe lands on fd 1/2. When the FUSE side daemonizes, it redirects 0-2 to/dev/null, which silently replaces the pipe's write end.write_pipe_threadthen loopswrite()"until we block", and writes to/dev/nullnever block, so it spins in the kernel. It also never sees theEPIPEthat should end it when the app exits.Observed on the running app:
0,1,2 -> /dev/null, the AppImage and/dev/fuse, and no pipe.fd 2 -> /tmp/.mount_agent-*/AppRun: stderr was closed at start, and a lateropen()reused the number.Minimal repro without AO:
Also affected:
ao startthat only focuses the existing window also leaves a spinning runtime behind./tmp/.mount_agent-*. Running daemon, pty-host and chat-host processes execute from that mount, so they break. The only clean way out is restarting AO..debinstall isn't affected, since it has no FUSE runtime.Suggested fix (one place, covers every caller): only set the writers when a file was given, so an unset stream falls back to
os/exec's default,/dev/null:Workaround: launch the AppImage yourself with
/dev/nullstdio instead ofao start:Environment: AO 0.13.3 AppImage at
~/.ao/agent-orchestrator.AppImage, Ubuntu 24.04 (Linux 7.0), XFCE,libfuse2t642.9.9.