Skip to content
Open
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
8 changes: 5 additions & 3 deletions DEPLOYMENT.md
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,10 +68,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

Expand Down
16 changes: 14 additions & 2 deletions api/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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 {}`
Expand All @@ -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
Expand Down
62 changes: 48 additions & 14 deletions templates/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,36 @@ <h1>Configuration</h1>
</div>

<script nonce="{{ csp_nonce }}">
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btn-save-config')?.addEventListener('click', validateAndSave);
async function postSetWorkspace(path) {
const res = await fetch('/api/set-workspace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
let body = {};
try {
body = await res.json();
} catch (e) {
body = {};
}
if (res.status === 409) {
return {
ok: false,
message: body.error || 'Workspace path cannot be changed in this deployment.',
code: body.code
};
}
if (!res.ok) {
return {
ok: false,
message: body.error || 'Failed to save workspace path.',
code: body.code
};
}
return { ok: true, body };
}

document.addEventListener('DOMContentLoaded', async () => { document.getElementById('btn-save-config')?.addEventListener('click', validateAndSave);

const stored = localStorage.getItem('workspacePath');
if (stored) {
Expand Down Expand Up @@ -55,13 +83,17 @@ <h1>Configuration</h1>
});
const valData = await valRes.json();
if (valData.valid) {
localStorage.setItem('workspacePath', detected);
await fetch('/api/set-workspace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: detected })
});
window.location.href = '/';
const saved = await postSetWorkspace(detected);
if (saved.ok) {
localStorage.setItem('workspacePath', detected);
window.location.href = '/';
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const statusEl = document.getElementById('status-msg');
statusEl.className = 'alert alert-danger';
statusEl.textContent = saved.message;
statusEl.style.display = 'block';
document.getElementById('workspace-path').value = detected;
return;
}
}
Expand All @@ -86,12 +118,14 @@ <h1>Configuration</h1>
const data = await res.json();

if (data.valid) {
const saved = await postSetWorkspace(path);
if (!saved.ok) {
statusEl.className = 'alert alert-danger';
statusEl.textContent = saved.message;
statusEl.style.display = 'block';
return;
}
localStorage.setItem('workspacePath', path);
await fetch('/api/set-workspace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
statusEl.className = 'alert alert-success';
statusEl.textContent = `Found ${data.workspaceCount} workspaces in the specified location`;
statusEl.style.display = 'block';
Expand Down
117 changes: 117 additions & 0 deletions tests/test_set_workspace_multiworker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""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"])
Comment thread
clean6378-max-it marked this conversation as resolved.

from utils.workspace_path import get_workspace_path_override

self.assertIsNone(get_workspace_path_override())

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())

def test_web_concurrency_one_does_not_block_later_multi_worker_signals(self):
from utils.workspace_path import is_multi_worker_process_deployment

with patch.dict(
os.environ,
{
"WEB_CONCURRENCY": "1",
"GUNICORN_WORKERS": "4",
"CURSOR_BROWSER_MULTI_WORKER": "",
},
clear=False,
):
self.assertTrue(is_multi_worker_process_deployment())

with patch.dict(
os.environ,
{
"WEB_CONCURRENCY": "1",
"GUNICORN_CMD_ARGS": "app:create_app --workers 2",
"CURSOR_BROWSER_MULTI_WORKER": "",
},
clear=False,
):
self.assertTrue(is_multi_worker_process_deployment())
34 changes: 34 additions & 0 deletions utils/workspace_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
import sys
import subprocess
import threading
Expand Down Expand Up @@ -40,6 +41,39 @@ 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():
count = int(raw)
if count > 1:
return True
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("~")
Expand Down
Loading