Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- Multi-instance launches (`--new-instance` / `CODEX_MULTI_LAUNCH=1`) now skip warm-start and Electron second-instance handoff in the launcher, so an already-running default instance cannot hijack an explicit new-instance request.
- Packaged `.desktop` files now expose an **Open New Instance** action (right-click the launcher icon) that passes the multi-launch flags expected by the patched Electron bootstrap lock bypass.
- The embedded webview HTTP server now installs `PR_SET_PDEATHSIG(SIGTERM)` so orphaned servers cannot permanently block webview ports after a crashed or killed launcher.
- The Chrome native-messaging host now evicts stale browser clients when a newer Codex browser client connects, preventing old Node REPL sessions from repeatedly reattaching CDP and driving extension service-worker CPU.
- The bundled Chrome plugin is now auto-installed during app startup, matching Browser Use, so the plugin page no longer falls back to an install button after restart when the Linux native host is already staged.
- Nix builds, installer apps, and dev shells now use modern `7zz`, and the installer dependency check accepts `7zz` without requiring a separate legacy `7z` binary.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ By default, second launches reuse the running app through the Linux warm-start h
./codex-app/start.sh --new-instance
```

Packaged installs also expose **Open New Instance** in the `.desktop` entry actions menu (right-click the app icon in your launcher).

The launcher picks the first free webview port from a bounded range, then uses per-port pid files, launch socket, log, and Electron user-data dir. This keeps Electron's single-instance lock scoped to that new instance while leaving normal launches unchanged. The default range allows up to five instances.

Configure the range or make every launch use this mode with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ MimeType=x-scheme-handler/codex;x-scheme-handler/codex-browser-sidebar;
Keywords=codex;openai;ai;coding;
StartupNotify=true
StartupWMClass=codex-desktop
X-GNOME-WMClass=codex-desktop
Icon=codex-desktop
Actions=NewInstance;

[Desktop Action NewInstance]
Name=Open New Instance
Exec=env CODEX_MULTI_LAUNCH=1 @HOME@/.local/bin/codex-desktop --new-instance
9 changes: 8 additions & 1 deletion launcher/start.sh.template
Original file line number Diff line number Diff line change
Expand Up @@ -1247,14 +1247,21 @@ running_app_is_active() {
}

using_second_instance_handoff() {
[ "$WARM_START" -eq 0 ] && running_app_is_active
[ "$MULTI_LAUNCH_ACTIVE" -eq 0 ] && [ "$WARM_START" -eq 0 ] && running_app_is_active
}

needs_cold_start() {
[ "$WARM_START" -eq 0 ] && ! using_second_instance_handoff
}

detect_warm_start() {
if [ "$MULTI_LAUNCH_ACTIVE" -eq 1 ]; then
WARM_START=0
RUNNING_APP_PID=""
echo "Multi-launch active; skipping warm-start and second-instance handoff"
return 0
fi

if RUNNING_APP_PID="$(find_running_app_pid)" || { [ -S "$LAUNCH_ACTION_SOCKET" ] && RUNNING_APP_PID="$(discover_running_app_pid)"; }; then
echo "$RUNNING_APP_PID" > "$APP_PID_FILE"
if ! linux_setting_enabled "codex-linux-warm-start-enabled" 1; then
Expand Down
28 changes: 28 additions & 0 deletions launcher/webview-server.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
#!/usr/bin/env python3
import ctypes
import ctypes.util
import functools
import http.server
import os
import signal
import sys


def _install_parent_death_signal():
# Ensure the kernel terminates this process if the launcher (parent) exits
# without invoking its cleanup trap (SIGKILL, OOM, crash). Without this,
# the HTTP server can outlive the launcher and block its webview port,
# which is fatal for multi-instance launches pinned to a single port.
if sys.platform != "linux":
return
libc_name = ctypes.util.find_library("c") or "libc.so.6"
try:
libc = ctypes.CDLL(libc_name, use_errno=True)
except OSError:
return
PR_SET_PDEATHSIG = 1
if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) != 0:
return
# The parent may have died between fork() and prctl(); in that case the
# death signal never fires. Bail out now so the port is freed promptly.
if os.getppid() == 1:
os._exit(0)


_install_parent_death_signal()


port = int(sys.argv[1])
bind = "127.0.0.1"
if len(sys.argv) >= 4 and sys.argv[2] == "--bind":
Expand Down
10 changes: 7 additions & 3 deletions packaging/linux/codex-desktop.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ Type=Application
Categories=Development;
MimeType=x-scheme-handler/codex;x-scheme-handler/codex-browser-sidebar;
StartupNotify=true
StartupWMClass=Codex
X-GNOME-WMClass=Codex
Actions=CheckForUpdates;InstallReadyUpdate;
StartupWMClass=codex-desktop
X-GNOME-WMClass=codex-desktop
Actions=NewInstance;CheckForUpdates;InstallReadyUpdate;

[Desktop Action NewInstance]
Name=Open New Instance
Exec=env BAMF_DESKTOP_FILE_HINT=/usr/share/applications/codex-desktop.desktop CHROME_DESKTOP=codex-desktop.desktop CODEX_MULTI_LAUNCH=1 /usr/bin/codex-desktop --new-instance

[Desktop Action CheckForUpdates]
Name=Check for Updates
Expand Down
12 changes: 11 additions & 1 deletion scripts/lib/package-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,22 @@ render_desktop_entry() {
mv "$rendered_target" "$target"
else
awk '
BEGIN { actions_rewritten = 0 }
/^\[Desktop Action CheckForUpdates\]$/ { skip = 1; next }
/^\[Desktop Action InstallReadyUpdate\]$/ { skip = 1; next }
/^\[/ { skip = 0 }
skip { next }
/^Actions=/ { next }
/^Actions=/ {
print "Actions=NewInstance;"
actions_rewritten = 1
next
}
{ print }
END {
if (actions_rewritten == 0) {
print "Actions=NewInstance;"
}
}
' "$rendered_target" > "$target"
rm -f "$rendered_target"
fi
Expand Down
144 changes: 144 additions & 0 deletions tests/multi-instance-dev-e2e.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Isolated multi-instance launcher E2E for the side-by-side dev app identity.
# Never touches production codex-app. Requires an explicit dev app directory.
set -euo pipefail

REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PROD_APP="${CODEX_PROD_APP:-$HOME/.local/opt/codex-desktop-linux/codex-app}"
DEV_APP="${CODEX_DEV_APP:-${CODEX_DEV_APP_DIR:-$HOME/.local/opt/codex-desktop-linux/codex-dev-app}}"
PASS=0
FAIL=0

pass() { echo "[PASS] $*"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $*"; FAIL=$((FAIL + 1)); }

usage() {
cat <<EOF
Usage: CODEX_DEV_APP=/path/to/codex-dev-app $0

Runs an isolated multi-instance launcher test using a temp copy of the dev app.
Production codex-app is never modified or stopped.

Optional:
CODEX_DEV_APP Side-by-side dev install (default: ~/.local/opt/.../codex-dev-app)
CODEX_PROD_APP Production path used only for a non-interference check
EOF
}

if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi

if [[ ! -d "$DEV_APP" || ! -x "$DEV_APP/start.sh" ]]; then
echo "Dev app not found: $DEV_APP" >&2
echo "Build one with: make build-dev-app (or set CODEX_DEV_APP)" >&2
exit 2
fi

if [[ "$DEV_APP" == "$PROD_APP" ]]; then
echo "Refusing to run: CODEX_DEV_APP must differ from production codex-app" >&2
exit 2
fi

TEST_ROOT="$(mktemp -d /tmp/codex-dev-multi-e2e.XXXXXX)"
TEST_APP="$TEST_ROOT/codex-dev-app"
cleanup() {
pkill -f "$TEST_APP/" 2>/dev/null || true
rm -rf "$TEST_ROOT"
}
trap cleanup EXIT

echo "=== Multi-instance dev E2E (isolated copy) ==="
echo "Source dev app: $DEV_APP"
echo "Temp test app: $TEST_APP"
echo "Production app: $PROD_APP (read-only check only)"

cp -a "$DEV_APP" "$TEST_APP"
mv "$TEST_APP/electron" "$TEST_APP/electron.real"
cat > "$TEST_APP/electron" << 'MOCK'
#!/usr/bin/env bash
printf 'mock-electron pid=%s port=%s multi=%s\n' "$$" "${CODEX_LINUX_WEBVIEW_PORT:-}" "${CODEX_LINUX_MULTI_LAUNCH:-}" \
>> "${MOCK_ELECTRON_LOG:-/tmp/mock-electron.log}"
exec sleep 600
MOCK
chmod +x "$TEST_APP/electron"

cat > "$TEST_APP/start.sh" << PRE
#!/bin/bash
set -euo pipefail
CODEX_LINUX_APP_ID=codex-desktop-dev
CODEX_LINUX_APP_DISPLAY_NAME=Codex\ CUA\ Lab
CODEX_LINUX_WEBVIEW_PORT=\${CODEX_WEBVIEW_PORT:-5176}
PRE
cat "$REPO_DIR/launcher/start.sh.template" >> "$TEST_APP/start.sh"
chmod +x "$TEST_APP/start.sh"
cp "$REPO_DIR/launcher/webview-server.py" "$TEST_APP/.codex-linux/webview-server.py"

export XDG_STATE_HOME="$TEST_ROOT/state"
export XDG_CACHE_HOME="$TEST_ROOT/cache"
export XDG_RUNTIME_DIR="$TEST_ROOT/runtime"
export XDG_CONFIG_HOME="$TEST_ROOT/config"
export MOCK_ELECTRON_LOG="$TEST_ROOT/mock-electron.log"
export CODEX_CLI_PATH="${CODEX_CLI_PATH:-$(command -v codex || true)}"
mkdir -p "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME"
chmod 700 "$XDG_RUNTIME_DIR"

# pdeathsig
PDEATHSIG_PORT="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')"
python3 "$TEST_APP/.codex-linux/webview-server.py" "$PDEATHSIG_PORT" --bind 127.0.0.1 &
PDPID=$!
sleep 0.5
kill -9 "$PDPID"
sleep 1
if ( exec 3<>/dev/tcp/127.0.0.1/"$PDEATHSIG_PORT" ) 2>/dev/null; then
fail "pdeathsig: port $PDEATHSIG_PORT still held"
else
pass "pdeathsig releases port after parent death"
fi

# Instance 1
"$TEST_APP/start.sh" --disable-gpu &
L1=$!
sleep 8
if grep -q 'port=5176' "$TEST_ROOT/mock-electron.log" 2>/dev/null; then
pass "instance 1 launched on default dev port 5176"
else
fail "instance 1 missing on port 5176"
tail -20 "$TEST_ROOT/cache/codex-desktop-dev/launcher.log" 2>/dev/null || true
fi

# Instance 2 (--new-instance)
"$TEST_APP/start.sh" --new-instance --disable-gpu &
L2=$!
sleep 10

if grep -rq "Multi-launch active; skipping warm-start" "$TEST_ROOT/cache/codex-desktop-dev/" 2>/dev/null; then
pass "instance 2 skipped warm-start handoff"
else
fail "instance 2 did not skip warm-start"
fi

if grep -q 'multi=1' "$TEST_ROOT/mock-electron.log" 2>/dev/null; then
pass "instance 2 exported CODEX_LINUX_MULTI_LAUNCH=1 to Electron"
else
fail "instance 2 missing CODEX_LINUX_MULTI_LAUNCH export"
fi

if grep -qE 'port=517[7-9]|port=5180' "$TEST_ROOT/mock-electron.log" 2>/dev/null; then
pass "instance 2 allocated a secondary webview port"
else
fail "instance 2 did not allocate a secondary port"
cat "$TEST_ROOT/mock-electron.log" 2>/dev/null || true
fi

MOCK_COUNT="$(grep -c 'mock-electron' "$TEST_ROOT/mock-electron.log" 2>/dev/null || echo 0)"
if [[ "$MOCK_COUNT" -ge 2 ]]; then
pass "two independent launcher starts completed ($MOCK_COUNT)"
else
fail "expected two mock electron launches, got $MOCK_COUNT"
fi

echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" -eq 0 ]]
8 changes: 8 additions & 0 deletions tests/scripts_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,10 @@ test_launcher_template_sanity() {
assert_file_exists "$REPO_DIR/launcher/webview-server.py"
assert_contains "$REPO_DIR/launcher/webview-server.py" "Cache-Control"
assert_contains "$REPO_DIR/launcher/webview-server.py" "If-Modified-Since"
assert_contains "$REPO_DIR/launcher/webview-server.py" "PR_SET_PDEATHSIG"
assert_contains "$REPO_DIR/packaging/linux/codex-desktop.desktop" "Actions=NewInstance;"
assert_contains "$REPO_DIR/packaging/linux/codex-desktop.desktop" "CODEX_MULTI_LAUNCH=1"
assert_contains "$REPO_DIR/packaging/linux/codex-desktop.desktop" "--new-instance"
assert_contains "$REPO_DIR/install.sh" "webview-server.py"
assert_contains "$REPO_DIR/launcher/start.sh.template" 'python3 "$SCRIPT_DIR/.codex-linux/webview-server.py" "$CODEX_LINUX_WEBVIEW_PORT" --bind 127.0.0.1'
assert_contains "$REPO_DIR/launcher/start.sh.template" "WEBVIEW_PID_FILE"
Expand Down Expand Up @@ -1530,6 +1534,10 @@ if 'send_warm_start_launch_action "${LAUNCHER_ARGS[@]}"' not in source:
raise SystemExit("warm-start handoff must not receive launcher-only multi-launch flags")
if 'launch_electron "${LAUNCHER_ARGS[@]}"' not in source:
raise SystemExit("Electron launch must receive sanitized launcher args")
if '"$MULTI_LAUNCH_ACTIVE" -eq 1' not in detect_body or 'skipping warm-start and second-instance handoff' not in detect_body:
raise SystemExit("detect_warm_start must bypass warm-start when multi-launch is active")
if 'MULTI_LAUNCH_ACTIVE" -eq 0 ] && [ "$WARM_START" -eq 0 ] && running_app_is_active' not in source:
raise SystemExit("using_second_instance_handoff must ignore live apps during multi-launch")
if 'RUNNING_APP_PID="$(find_running_app_pid)"' not in detect_body:
raise SystemExit("detect_warm_start must record a pid-file running app even when warm start is disabled")
if '[ -S "$LAUNCH_ACTION_SOCKET" ] && RUNNING_APP_PID="$(discover_running_app_pid)"' not in detect_body:
Expand Down
Loading