Skip to content

Commit 02936aa

Browse files
fix: return 409 from set-workspace when multiple WSGI workers are in use (#150)
* fix: return 409 from set-workspace when multiple WSGI workers are in use * Address brad's feedback * fix: surface set-workspace errors on auto-detect and tighten worker env detection
1 parent 8e9ee40 commit 02936aa

5 files changed

Lines changed: 218 additions & 19 deletions

File tree

DEPLOYMENT.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ pip install gunicorn # Linux / macOS
1212
# pip install waitress # Windows-friendly alternative (see below)
1313

1414
# Multi-process (recommended): one thread per worker avoids any per-worker state surprises.
15-
gunicorn --factory --bind 127.0.0.1:3000 --workers 2 --threads 1 app:create_app
15+
# WEB_CONCURRENCY (or CURSOR_BROWSER_MULTI_WORKER=1) lets the app detect multi-worker mode so
16+
# POST /api/set-workspace returns 409 instead of a misleading 200 on a single worker.
17+
WEB_CONCURRENCY=2 gunicorn --factory --bind 127.0.0.1:3000 --workers 2 --threads 1 app:create_app
1618

1719
# Single-process, multi-threaded: safe after the #43 lock; useful for lighter deployments.
1820
gunicorn --factory --bind 127.0.0.1:3000 --workers 1 --threads 4 app:create_app
@@ -66,10 +68,10 @@ gunicorn and waitress are **not** runtime dependencies; install them only when d
6668

6769
When gunicorn runs with `--workers 2` (or more), each worker is an independent Python process:
6870

69-
- **`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.
71+
- **`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.
7072
- **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.
7173

72-
For a single-user localhost deployment with multiple workers, set the workspace path via environment variable rather than the Configuration page.
74+
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.
7375

7476
## Path configuration
7577

api/config_api.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
from api.flask_config import api_error, json_response
1717

1818
from utils.path_validation import WorkspacePathError, validate_workspace_path
19-
from utils.workspace_path import set_workspace_path_override
19+
from utils.workspace_path import (
20+
is_multi_worker_process_deployment,
21+
set_workspace_path_override,
22+
)
2023

2124
bp = Blueprint("config_api", __name__)
2225
_logger = logging.getLogger(__name__)
@@ -172,7 +175,8 @@ def set_workspace() -> tuple[Response, int] | Response:
172175
173176
Returns:
174177
``{"success": true, "path": "..."}`` on success. 400 for invalid path or
175-
body; 500 when override storage fails.
178+
body; 409 when multiple WSGI worker processes are in use (override cannot
179+
apply fleet-wide); 500 when override storage fails.
176180
"""
177181
# Reject non-dict JSON bodies (array / string / number / null). Without
178182
# this, get_json returns the value directly, the truthy fallback `or {}`
@@ -194,6 +198,14 @@ def set_workspace() -> tuple[Response, int] | Response:
194198
return api_error("Failed to validate workspace path", "validate_workspace_path_failed", 500)
195199
if message is not None:
196200
return api_error(message, _workspace_path_error_code(message), 400)
201+
if is_multi_worker_process_deployment():
202+
return api_error(
203+
"POST /api/set-workspace only updates this worker process. "
204+
"Set WORKSPACE_PATH or pass --base-dir at startup when using "
205+
"multiple gunicorn workers.",
206+
"set_workspace_multi_worker_unsupported",
207+
409,
208+
)
197209
try:
198210
set_workspace_path_override(canonical)
199211
except Exception: # noqa: BLE001 — keep the response shape structured JSON

templates/config.html

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,36 @@ <h1>Configuration</h1>
1818
</div>
1919

2020
<script nonce="{{ csp_nonce }}">
21-
document.addEventListener('DOMContentLoaded', async () => {
22-
document.getElementById('btn-save-config')?.addEventListener('click', validateAndSave);
21+
async function postSetWorkspace(path) {
22+
const res = await fetch('/api/set-workspace', {
23+
method: 'POST',
24+
headers: { 'Content-Type': 'application/json' },
25+
body: JSON.stringify({ path })
26+
});
27+
let body = {};
28+
try {
29+
body = await res.json();
30+
} catch (e) {
31+
body = {};
32+
}
33+
if (res.status === 409) {
34+
return {
35+
ok: false,
36+
message: body.error || 'Workspace path cannot be changed in this deployment.',
37+
code: body.code
38+
};
39+
}
40+
if (!res.ok) {
41+
return {
42+
ok: false,
43+
message: body.error || 'Failed to save workspace path.',
44+
code: body.code
45+
};
46+
}
47+
return { ok: true, body };
48+
}
49+
50+
document.addEventListener('DOMContentLoaded', async () => { document.getElementById('btn-save-config')?.addEventListener('click', validateAndSave);
2351

2452
const stored = localStorage.getItem('workspacePath');
2553
if (stored) {
@@ -55,13 +83,17 @@ <h1>Configuration</h1>
5583
});
5684
const valData = await valRes.json();
5785
if (valData.valid) {
58-
localStorage.setItem('workspacePath', detected);
59-
await fetch('/api/set-workspace', {
60-
method: 'POST',
61-
headers: { 'Content-Type': 'application/json' },
62-
body: JSON.stringify({ path: detected })
63-
});
64-
window.location.href = '/';
86+
const saved = await postSetWorkspace(detected);
87+
if (saved.ok) {
88+
localStorage.setItem('workspacePath', detected);
89+
window.location.href = '/';
90+
return;
91+
}
92+
const statusEl = document.getElementById('status-msg');
93+
statusEl.className = 'alert alert-danger';
94+
statusEl.textContent = saved.message;
95+
statusEl.style.display = 'block';
96+
document.getElementById('workspace-path').value = detected;
6597
return;
6698
}
6799
}
@@ -86,12 +118,14 @@ <h1>Configuration</h1>
86118
const data = await res.json();
87119

88120
if (data.valid) {
121+
const saved = await postSetWorkspace(path);
122+
if (!saved.ok) {
123+
statusEl.className = 'alert alert-danger';
124+
statusEl.textContent = saved.message;
125+
statusEl.style.display = 'block';
126+
return;
127+
}
89128
localStorage.setItem('workspacePath', path);
90-
await fetch('/api/set-workspace', {
91-
method: 'POST',
92-
headers: { 'Content-Type': 'application/json' },
93-
body: JSON.stringify({ path })
94-
});
95129
statusEl.className = 'alert alert-success';
96130
statusEl.textContent = `Found ${data.workspaceCount} workspaces in the specified location`;
97131
statusEl.style.display = 'block';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""POST /api/set-workspace behavior under multi-worker WSGI deployments."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import tempfile
8+
import unittest
9+
from unittest.mock import patch
10+
11+
from tests.test_workspace_path_validation import _make_cursor_workspace_dir
12+
13+
14+
class TestSetWorkspaceMultiWorker(unittest.TestCase):
15+
def setUp(self):
16+
from flask import Flask
17+
18+
from api.config_api import bp as config_bp
19+
from utils.workspace_path import set_workspace_path_override
20+
21+
self.tmp = tempfile.mkdtemp(prefix="cursor-multiworker-test-")
22+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
23+
self.addCleanup(set_workspace_path_override, None)
24+
25+
app = Flask(__name__)
26+
app.config["TESTING"] = True
27+
app.register_blueprint(config_bp)
28+
self.client = app.test_client()
29+
self.storage = _make_cursor_workspace_dir(self.tmp)
30+
31+
def test_multi_worker_returns_409_with_stable_code(self):
32+
with patch(
33+
"api.config_api.is_multi_worker_process_deployment",
34+
return_value=True,
35+
):
36+
resp = self.client.post(
37+
"/api/set-workspace",
38+
json={"path": self.storage},
39+
)
40+
self.assertEqual(resp.status_code, 409)
41+
body = resp.get_json()
42+
self.assertEqual(body["code"], "set_workspace_multi_worker_unsupported")
43+
self.assertIn("WORKSPACE_PATH", body["error"])
44+
45+
from utils.workspace_path import get_workspace_path_override
46+
47+
self.assertIsNone(get_workspace_path_override())
48+
49+
def test_single_process_still_succeeds_when_not_multi_worker(self):
50+
with patch(
51+
"api.config_api.is_multi_worker_process_deployment",
52+
return_value=False,
53+
):
54+
resp = self.client.post(
55+
"/api/set-workspace",
56+
json={"path": self.storage},
57+
)
58+
self.assertEqual(resp.status_code, 200)
59+
self.assertTrue(resp.get_json()["success"])
60+
61+
62+
class TestMultiWorkerDetection(unittest.TestCase):
63+
def test_explicit_env_flag(self):
64+
from utils.workspace_path import is_multi_worker_process_deployment
65+
66+
with patch.dict(os.environ, {"CURSOR_BROWSER_MULTI_WORKER": "1"}, clear=False):
67+
self.assertTrue(is_multi_worker_process_deployment())
68+
with patch.dict(os.environ, {"CURSOR_BROWSER_MULTI_WORKER": "0"}, clear=False):
69+
self.assertFalse(is_multi_worker_process_deployment())
70+
71+
def test_web_concurrency_gt_one(self):
72+
from utils.workspace_path import is_multi_worker_process_deployment
73+
74+
with patch.dict(
75+
os.environ,
76+
{"WEB_CONCURRENCY": "4", "CURSOR_BROWSER_MULTI_WORKER": ""},
77+
clear=False,
78+
):
79+
self.assertTrue(is_multi_worker_process_deployment())
80+
81+
def test_gunicorn_cmd_args_workers(self):
82+
from utils.workspace_path import is_multi_worker_process_deployment
83+
84+
with patch.dict(
85+
os.environ,
86+
{
87+
"GUNICORN_CMD_ARGS": "app:create_app --bind :5000 --workers 3",
88+
"CURSOR_BROWSER_MULTI_WORKER": "",
89+
},
90+
clear=False,
91+
):
92+
self.assertTrue(is_multi_worker_process_deployment())
93+
94+
def test_web_concurrency_one_does_not_block_later_multi_worker_signals(self):
95+
from utils.workspace_path import is_multi_worker_process_deployment
96+
97+
with patch.dict(
98+
os.environ,
99+
{
100+
"WEB_CONCURRENCY": "1",
101+
"GUNICORN_WORKERS": "4",
102+
"CURSOR_BROWSER_MULTI_WORKER": "",
103+
},
104+
clear=False,
105+
):
106+
self.assertTrue(is_multi_worker_process_deployment())
107+
108+
with patch.dict(
109+
os.environ,
110+
{
111+
"WEB_CONCURRENCY": "1",
112+
"GUNICORN_CMD_ARGS": "app:create_app --workers 2",
113+
"CURSOR_BROWSER_MULTI_WORKER": "",
114+
},
115+
clear=False,
116+
):
117+
self.assertTrue(is_multi_worker_process_deployment())

utils/workspace_path.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import os
6+
import re
67
import sys
78
import subprocess
89
import threading
@@ -40,6 +41,39 @@ def get_workspace_path_override() -> str | None:
4041
return _workspace_path_override
4142

4243

44+
def is_multi_worker_process_deployment() -> bool:
45+
"""Return True when multiple WSGI worker processes may serve requests.
46+
47+
Used by POST /api/set-workspace to fail loud instead of updating only one
48+
process. Single-process and multi-threaded deployments return False.
49+
50+
Operators can force True/False with ``CURSOR_BROWSER_MULTI_WORKER``. Otherwise
51+
``WEB_CONCURRENCY``, ``GUNICORN_WORKERS``, or ``--workers`` / ``-w`` in
52+
``GUNICORN_CMD_ARGS`` are consulted when present.
53+
"""
54+
flag = os.environ.get("CURSOR_BROWSER_MULTI_WORKER", "").strip().lower()
55+
if flag in ("1", "true", "yes"):
56+
return True
57+
if flag in ("0", "false", "no"):
58+
return False
59+
for key in ("WEB_CONCURRENCY", "GUNICORN_WORKERS"):
60+
raw = os.environ.get(key, "").strip()
61+
if raw.isdigit():
62+
count = int(raw)
63+
if count > 1:
64+
return True
65+
cmd_args = os.environ.get("GUNICORN_CMD_ARGS", "")
66+
if cmd_args:
67+
for pattern in (
68+
r"(?:--workers|-w)\s+(\d+)",
69+
r"(?:--workers|-w)=(\d+)",
70+
):
71+
for match in re.finditer(pattern, cmd_args):
72+
if int(match.group(1)) > 1:
73+
return True
74+
return False
75+
76+
4377
def get_default_workspace_path() -> str:
4478
"""Detect the default Cursor workspace storage path based on OS."""
4579
home = os.path.expanduser("~")

0 commit comments

Comments
 (0)