diff --git a/CHANGELOG.md b/CHANGELOG.md index b127ec13..c3c6611f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 099ff7ae..209ccf3c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/contrib/user-local-install/files/.local/share/applications/codex-desktop.desktop b/contrib/user-local-install/files/.local/share/applications/codex-desktop.desktop index 19d5c3c3..9a2dea3a 100644 --- a/contrib/user-local-install/files/.local/share/applications/codex-desktop.desktop +++ b/contrib/user-local-install/files/.local/share/applications/codex-desktop.desktop @@ -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 diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 7af81c14..8da24645 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1247,7 +1247,7 @@ 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() { @@ -1255,6 +1255,13 @@ needs_cold_start() { } 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 diff --git a/launcher/webview-server.py b/launcher/webview-server.py index 925a77fc..c5dc128a 100644 --- a/launcher/webview-server.py +++ b/launcher/webview-server.py @@ -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": diff --git a/packaging/linux/codex-desktop.desktop b/packaging/linux/codex-desktop.desktop index bce60717..f1cadb4c 100644 --- a/packaging/linux/codex-desktop.desktop +++ b/packaging/linux/codex-desktop.desktop @@ -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 diff --git a/scripts/lib/package-common.sh b/scripts/lib/package-common.sh index cb01e6df..d1fdf08e 100755 --- a/scripts/lib/package-common.sh +++ b/scripts/lib/package-common.sh @@ -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 diff --git a/tests/multi-instance-dev-e2e.sh b/tests/multi-instance-dev-e2e.sh new file mode 100755 index 00000000..734e13a9 --- /dev/null +++ b/tests/multi-instance-dev-e2e.sh @@ -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 <&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 ]] diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index d35a6871..ec9bca74 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -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" @@ -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: