From cf74a746322e5e2834afe734a4db687dcdb35105 Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 22 Jul 2026 03:52:02 +0800 Subject: [PATCH 1/3] fix: return 409 from set-workspace when multiple WSGI workers are in use --- DEPLOYMENT.md | 4 +- api/config_api.py | 16 ++++- tests/test_set_workspace_multiworker.py | 88 +++++++++++++++++++++++++ utils/workspace_path.py | 32 +++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/test_set_workspace_multiworker.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index b10e708..48348c4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -66,10 +66,10 @@ gunicorn and waitress are **not** runtime dependencies; install them only when d When gunicorn runs with `--workers 2` (or more), each worker is an independent Python process: -- **`POST /api/set-workspace`** only updates the worker that handled that request. Other workers keep their previous override (usually `None`). Prefer setting `WORKSPACE_PATH` or passing `--base-dir` at process start so every worker sees the same path. +- **`POST /api/set-workspace`** cannot update every worker from a single request (each process has its own override). The API returns **HTTP 409** with code `set_workspace_multi_worker_unsupported` instead of success when multiple workers are detected (`WEB_CONCURRENCY`, `GUNICORN_WORKERS`, gunicorn `--workers` in `GUNICORN_CMD_ARGS`, or `CURSOR_BROWSER_MULTI_WORKER=1`). Set `WORKSPACE_PATH` or pass `--base-dir` at process start so every worker sees the same path. - **Exclusion rules** (`EXCLUSION_RULES` in app config) are loaded once at worker startup from `--exclude-rules` or the default file. Changing the rules file requires restarting workers. -For a single-user localhost deployment with multiple workers, set the workspace path via environment variable rather than the Configuration page. +For a single-user localhost deployment with multiple workers, set the workspace path via environment variable or `--base-dir` at startup; do not rely on the Configuration page `set-workspace` call. ## Path configuration diff --git a/api/config_api.py b/api/config_api.py index 9b5631f..b09a758 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -16,7 +16,10 @@ from api.flask_config import api_error, json_response from utils.path_validation import WorkspacePathError, validate_workspace_path -from utils.workspace_path import set_workspace_path_override +from utils.workspace_path import ( + is_multi_worker_process_deployment, + set_workspace_path_override, +) bp = Blueprint("config_api", __name__) _logger = logging.getLogger(__name__) @@ -172,7 +175,8 @@ def set_workspace() -> tuple[Response, int] | Response: Returns: ``{"success": true, "path": "..."}`` on success. 400 for invalid path or - body; 500 when override storage fails. + body; 409 when multiple WSGI worker processes are in use (override cannot + apply fleet-wide); 500 when override storage fails. """ # Reject non-dict JSON bodies (array / string / number / null). Without # this, get_json returns the value directly, the truthy fallback `or {}` @@ -194,6 +198,14 @@ def set_workspace() -> tuple[Response, int] | Response: return api_error("Failed to validate workspace path", "validate_workspace_path_failed", 500) if message is not None: return api_error(message, _workspace_path_error_code(message), 400) + if is_multi_worker_process_deployment(): + return api_error( + "POST /api/set-workspace only updates this worker process. " + "Set WORKSPACE_PATH or pass --base-dir at startup when using " + "multiple gunicorn workers.", + "set_workspace_multi_worker_unsupported", + 409, + ) try: set_workspace_path_override(canonical) except Exception: # noqa: BLE001 — keep the response shape structured JSON diff --git a/tests/test_set_workspace_multiworker.py b/tests/test_set_workspace_multiworker.py new file mode 100644 index 0000000..78f4493 --- /dev/null +++ b/tests/test_set_workspace_multiworker.py @@ -0,0 +1,88 @@ +"""POST /api/set-workspace behavior under multi-worker WSGI deployments.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch + +from tests.test_workspace_path_validation import _make_cursor_workspace_dir + + +class TestSetWorkspaceMultiWorker(unittest.TestCase): + def setUp(self): + from flask import Flask + + from api.config_api import bp as config_bp + from utils.workspace_path import set_workspace_path_override + + self.tmp = tempfile.mkdtemp(prefix="cursor-multiworker-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(set_workspace_path_override, None) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(config_bp) + self.client = app.test_client() + self.storage = _make_cursor_workspace_dir(self.tmp) + + def test_multi_worker_returns_409_with_stable_code(self): + with patch( + "api.config_api.is_multi_worker_process_deployment", + return_value=True, + ): + resp = self.client.post( + "/api/set-workspace", + json={"path": self.storage}, + ) + self.assertEqual(resp.status_code, 409) + body = resp.get_json() + self.assertEqual(body["code"], "set_workspace_multi_worker_unsupported") + self.assertIn("WORKSPACE_PATH", body["error"]) + + def test_single_process_still_succeeds_when_not_multi_worker(self): + with patch( + "api.config_api.is_multi_worker_process_deployment", + return_value=False, + ): + resp = self.client.post( + "/api/set-workspace", + json={"path": self.storage}, + ) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json()["success"]) + + +class TestMultiWorkerDetection(unittest.TestCase): + def test_explicit_env_flag(self): + from utils.workspace_path import is_multi_worker_process_deployment + + with patch.dict(os.environ, {"CURSOR_BROWSER_MULTI_WORKER": "1"}, clear=False): + self.assertTrue(is_multi_worker_process_deployment()) + with patch.dict(os.environ, {"CURSOR_BROWSER_MULTI_WORKER": "0"}, clear=False): + self.assertFalse(is_multi_worker_process_deployment()) + + def test_web_concurrency_gt_one(self): + from utils.workspace_path import is_multi_worker_process_deployment + + with patch.dict( + os.environ, + {"WEB_CONCURRENCY": "4", "CURSOR_BROWSER_MULTI_WORKER": ""}, + clear=False, + ): + self.assertTrue(is_multi_worker_process_deployment()) + + def test_gunicorn_cmd_args_workers(self): + from utils.workspace_path import is_multi_worker_process_deployment + + with patch.dict( + os.environ, + { + "GUNICORN_CMD_ARGS": "app:create_app --bind :5000 --workers 3", + "CURSOR_BROWSER_MULTI_WORKER": "", + }, + clear=False, + ): + self.assertTrue(is_multi_worker_process_deployment()) diff --git a/utils/workspace_path.py b/utils/workspace_path.py index 5d9b364..d7f2de8 100644 --- a/utils/workspace_path.py +++ b/utils/workspace_path.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import sys import subprocess import threading @@ -40,6 +41,37 @@ def get_workspace_path_override() -> str | None: return _workspace_path_override +def is_multi_worker_process_deployment() -> bool: + """Return True when multiple WSGI worker processes may serve requests. + + Used by POST /api/set-workspace to fail loud instead of updating only one + process. Single-process and multi-threaded deployments return False. + + Operators can force True/False with ``CURSOR_BROWSER_MULTI_WORKER``. Otherwise + ``WEB_CONCURRENCY``, ``GUNICORN_WORKERS``, or ``--workers`` / ``-w`` in + ``GUNICORN_CMD_ARGS`` are consulted when present. + """ + flag = os.environ.get("CURSOR_BROWSER_MULTI_WORKER", "").strip().lower() + if flag in ("1", "true", "yes"): + return True + if flag in ("0", "false", "no"): + return False + for key in ("WEB_CONCURRENCY", "GUNICORN_WORKERS"): + raw = os.environ.get(key, "").strip() + if raw.isdigit(): + return int(raw) > 1 + cmd_args = os.environ.get("GUNICORN_CMD_ARGS", "") + if cmd_args: + for pattern in ( + r"(?:--workers|-w)\s+(\d+)", + r"(?:--workers|-w)=(\d+)", + ): + for match in re.finditer(pattern, cmd_args): + if int(match.group(1)) > 1: + return True + return False + + def get_default_workspace_path() -> str: """Detect the default Cursor workspace storage path based on OS.""" home = os.path.expanduser("~") From a426f6f5a6734b3ae080a862eda7d9b5a463979b Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 22 Jul 2026 05:26:27 +0800 Subject: [PATCH 2/3] Address brad's feedback --- DEPLOYMENT.md | 4 +- templates/config.html | 58 ++++++++++++++++++------- tests/test_set_workspace_multiworker.py | 4 ++ 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 48348c4..192e8a3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -12,7 +12,9 @@ pip install gunicorn # Linux / macOS # pip install waitress # Windows-friendly alternative (see below) # Multi-process (recommended): one thread per worker avoids any per-worker state surprises. -gunicorn --factory --bind 127.0.0.1:3000 --workers 2 --threads 1 app:create_app +# WEB_CONCURRENCY (or CURSOR_BROWSER_MULTI_WORKER=1) lets the app detect multi-worker mode so +# POST /api/set-workspace returns 409 instead of a misleading 200 on a single worker. +WEB_CONCURRENCY=2 gunicorn --factory --bind 127.0.0.1:3000 --workers 2 --threads 1 app:create_app # Single-process, multi-threaded: safe after the #43 lock; useful for lighter deployments. gunicorn --factory --bind 127.0.0.1:3000 --workers 1 --threads 4 app:create_app diff --git a/templates/config.html b/templates/config.html index 3236ad4..7ccbf27 100644 --- a/templates/config.html +++ b/templates/config.html @@ -18,8 +18,36 @@

Configuration