Skip to content

Fix: zellij process accumulation caused by uncached interactive shell binary lookups#399

Open
NeckBeardPrince wants to merge 3 commits intosteipete:mainfrom
NeckBeardPrince:fix/zellij-process-accumulation
Open

Fix: zellij process accumulation caused by uncached interactive shell binary lookups#399
NeckBeardPrince wants to merge 3 commits intosteipete:mainfrom
NeckBeardPrince:fix/zellij-process-accumulation

Conversation

@NeckBeardPrince
Copy link

@NeckBeardPrince NeckBeardPrince commented Feb 18, 2026

Disclaimer by a real human

This was 100% done by Claude. I've never touched Swift in my life, but I love this app and wanted to try and fix it so that I can use it without 180+ zellij processes starting. I did test this build, and it's running locally without any issue.

Changes

This PR contains two independent fixes found during local testing.


Fix 1: zellij process accumulation (main fix)

Problem

Users with zellij configured to auto-start in their shell init files (e.g. ~/.config/fish/config.fish, ~/.zshrc) see hundreds of zellij server processes accumulate under CodexBar in Activity Monitor. Reports of 180+ processes, each consuming 30–74 MB of RAM.

Root Cause

BinaryLocator.resolveBinary() is called on every refresh cycle for each provider binary (claude, codex, gemini, auggie). Steps 4 and 4b of the resolution chain spawn interactive login shells using -l -i -c flags to locate binaries not found on the standard PATH:

// 4) Interactive login shell lookup
if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), ...

// 4b) Alias fallback (login shell)
if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), ...

When the user's shell init file contains something like:

# ~/.config/fish/config.fish
if status is-interactive
    if not set -q ZELLIJ
        zellij
    end
end

...each shell spawn triggers a new zellij server daemon. These daemons persist after the shell exits. With a 5-minute refresh interval and 4 binaries, that's up to 8 new zellij processes every 5 minutes — indefinitely.

Note: LoginShellPathCapturer.capture() has the same spawn pattern but is already protected by LoginShellPathCache.captureOnce() and runs exactly once at startup. Steps 4/4b had no equivalent caching.

Fix

Added BinaryResolutionCache — a thread-safe singleton that mirrors the LoginShellPathCache pattern — to cache the result of shell-based binary lookups keyed by binary name.

  • Cache hit (path found): returns the cached path immediately, no shell spawn.
  • Cache hit (nil): binary was previously confirmed not found; skips shell spawns entirely.
  • Cache miss: runs commandV then aliasResolver once, stores the result (including nil as a "not found" sentinel), returns if resolved.

The two separate shell invocations in steps 4 and 4b are now consolidated into a single cached block, so neither is ever repeated across refresh cycles.

let cached = BinaryResolutionCache.shared.cachedResult(for: name)
if let cached {
    if let path = cached.path, fileManager.isExecutableFile(atPath: path) {
        return path
    }
    // Cached nil = not found; skip shell spawns
} else {
    var resolved: String? = nil
    if let shellHit = commandV(...) { resolved = shellHit }
    else if let aliasHit = aliasResolver(...) { resolved = aliasHit }
    BinaryResolutionCache.shared.store(path: resolved, for: name)
    if let resolved { return resolved }
}

Trade-off

The cache persists for the app session. If a user installs a new binary while CodexBar is running, a cached "not found" entry will prevent detection until restart. This is an acceptable trade-off — the prior behavior (re-running an interactive shell every 5 minutes) was the bug.


Fix 2: @main conflict with top-level await in CodexBarClaudeWebProbe

Problem

Building with Xcode produced:

'main' attribute cannot be used in a module that contains top-level code

Sources/CodexBarClaudeWebProbe/main.swift used @main with static func main() async while also containing a top-level await call — Swift does not allow both in the same module.

Fix

Removed @main, renamed main() to run(), and kept the explicit top-level await CodexBarClaudeWebProbe.run() call. Behaviour is identical.


What Is Not Changed

  • LoginShellPathCapturer.capture() — still runs once at startup, unchanged.
  • All provider probe logic, PTY sessions, subprocess runners — untouched.
  • Refresh cycle timing — unchanged.
  • Test surface — existing tests inject mock commandV/aliasResolver closures via parameters and bypass BinaryResolutionCache entirely; no test changes required.

Testing

  • Built successfully with Xcode after both fixes were applied.
  • Launched CodexBar with zellij configured to auto-start in ~/.config/fish/config.fish.
  • Monitored Activity Monitor over multiple refresh cycles — zellij process count remained stable at 1 (the user's own session) rather than climbing continuously.

Closes #228

Each refresh cycle, resolveBinary() fell through to interactive login
shell spawns (-l -i -c) for any binary not found on the standard PATH.
Users with zellij configured to auto-start in their shell init files
(~/.config/fish/config.fish, ~/.zshrc, etc.) saw a new zellij server
daemon created on every spawn — resulting in 180+ persistent processes
accumulating under CodexBar in Activity Monitor, each consuming 30–74 MB
of RAM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09bd4e7f9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

NeckBeardPrince and others added 2 commits February 18, 2026 14:54
Swift does not allow @main in a file that also contains top-level
executable code. Removing @main and replacing static func main() with
static func run(), called explicitly at the top level, resolves the
build error:

  'main' attribute cannot be used in a module that contains top-level code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two issues raised in code review:

1. Cache bypassed for non-default closures (P1)
   The cache was keyed only by binary name, so a call with custom
   commandV/aliasResolver closures (e.g. in tests) could receive a
   result cached by a prior call with different lookup logic. Added
   useShellCache: Bool to resolveBinary — the four public resolve*
   functions pass true (default closures); any caller with custom
   closures passes false and bypasses the cache entirely.

2. Stale cached path not retried (P2)
   A cached path that is no longer executable (CLI uninstalled/moved)
   caused step 4 to return nil permanently for the session with no
   retry. Now, when a cached path fails the isExecutableFile check,
   the entry is invalidated and the shell lookups run again.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Creating over a hundred zellij sessions!

1 participant

Comments