Skip to content

Commit 59a22d8

Browse files
Jiaqi-LvJiaqi Lvshaneahmed
authored
🔨 Fix TileServer issues on macOS (#976)
## Overview This PR addresses **issue #975** and introduces two major improvements: --- ### 1. Improved Index Usage Warning in `SQLiteStore` **Problem:** The previous logic for detecting index usage relied only on the first row of `EXPLAIN QUERY PLAN`. On macOS, this often corresponds to the `rtree` virtual table, so the check missed actual indexes and always warned. **Solution:** - Added a new helper method `_warn_if_query_not_using_index` that inspects **all rows** of the query plan. - The method now looks for any row containing `USING INDEX` or `USING COVERING INDEX` (macOS phrasing). - If no index is detected, a warning is logged; otherwise, it remains silent. **Impact:** More accurate performance warnings and better developer guidance for adding indexes. --- ### 2. Dynamic Port Allocation for Tile Server in Bokeh Tests **Problem:** macOS runs multiple daemons on well-known ports (e.g., `5000`). Tests hardcoded to `http://127.0.0.1:5000/...` often collided with other services, causing `HTML/403` responses and subsequent `JSONDecodeError` or `KeyError`. **Solution:** - Introduced dynamic port binding for the tile server: - At test session start, a free loopback port is reserved and exported as `TIATOOLBOX_TILESERVER_PORT`. - Both the Flask tile server and Bokeh client read this environment variable, ensuring consistent communication. - Updated all relevant tests (`test_app_bokeh.py`, `test_json_config_bokeh.py`, `test_server_bokeh.py`) and CLI launcher to use the dynamic port. - Added proxy bypass (`trust_env=False`) for local requests to avoid interference from system proxies. **Impact:** Eliminates port conflicts, stabilizes UI tests, and improves cross-platform reliability. --- ### Additional Changes - Updated Bokeh app hooks and main logic to respect dynamic port configuration. - Minor refactoring for clarity and maintainability. --- ✅ **Benefits:** - More robust query performance checks. - Reliable test execution across macOS and CI environments. - Cleaner integration between CLI, Bokeh app, and test harness. --- **Closes:** #975 --------- Co-authored-by: Jiaqi Lv <[email protected]> Co-authored-by: Shan E Ahmed Raza <[email protected]>
1 parent aa6c8ab commit 59a22d8

File tree

8 files changed

+152
-39
lines changed

8 files changed

+152
-39
lines changed

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import os
66
import shutil
7+
import socket
78
import time
9+
from contextlib import closing
810
from pathlib import Path
911
from typing import TYPE_CHECKING
1012

@@ -16,6 +18,13 @@
1618
from tiatoolbox.data import _fetch_remote_sample
1719
from tiatoolbox.utils.env_detection import has_gpu, running_on_ci
1820

21+
# Reserve a free port for tileserver tests
22+
_TILES_ENV = "TIATOOLBOX_TILESERVER_PORT"
23+
if _TILES_ENV not in os.environ:
24+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
25+
sock.bind(("127.0.0.1", 0))
26+
os.environ[_TILES_ENV] = str(sock.getsockname()[1])
27+
1928
if TYPE_CHECKING:
2029
from collections.abc import Callable
2130

tests/test_app_bokeh.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import re
1010
import time
1111
from pathlib import Path
12+
from types import SimpleNamespace
1213
from typing import TYPE_CHECKING
1314

1415
import bokeh.models as bkmodels
@@ -25,7 +26,7 @@
2526
from scipy.ndimage import label
2627

2728
from tiatoolbox.data import _fetch_remote_sample
28-
from tiatoolbox.visualization.bokeh_app import main
29+
from tiatoolbox.visualization.bokeh_app import app_hooks, main
2930
from tiatoolbox.visualization.tileserver import TileServer
3031
from tiatoolbox.visualization.ui_utils import get_level_by_extent
3132

@@ -41,6 +42,13 @@
4142
GRIDLINES = 2
4243

4344

45+
class _DummySessionContext:
46+
"""Simple shim matching the subset of Bokeh's SessionContext we use."""
47+
48+
def __init__(self: _DummySessionContext, user: str) -> None:
49+
self.request = SimpleNamespace(arguments={"user": user})
50+
51+
4452
# helper functions and fixtures
4553
def get_tile(layer: str, x: float, y: float, z: float, *, show: bool) -> np.ndarray:
4654
"""Get a tile from the server.
@@ -77,7 +85,9 @@ def get_renderer_prop(prop: str) -> json:
7785
The property to get.
7886
7987
"""
80-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/renderer/{prop}")
88+
resp = main.UI["s"].get(
89+
f"http://{main.host2}:{main.port}/tileserver/renderer/{prop}",
90+
)
8191
return resp.json()
8292

8393

@@ -144,7 +154,7 @@ def run_app() -> None:
144154
layers={},
145155
)
146156
CORS(app, send_wildcard=True)
147-
app.run(host="127.0.0.1", threaded=True)
157+
app.run(host="127.0.0.1", port=int(main.port), threaded=True)
148158

149159

150160
@pytest.fixture(scope="module")
@@ -376,13 +386,17 @@ def test_type_cmap_select(doc: Document) -> None:
376386

377387
# remove the type cmap
378388
cmap_select.value = []
379-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
389+
resp = main.UI["s"].get(
390+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
391+
)
380392
assert resp.json()["score_prop"] == "None"
381393

382394
# check callback works regardless of order
383395
cmap_select.value = ["0"]
384396
cmap_select.value = ["0", "prob"]
385-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap")
397+
resp = main.UI["s"].get(
398+
f"http://{main.host2}:{main.port}/tileserver/secondary_cmap"
399+
)
386400
assert resp.json()["score_prop"] == "prob"
387401

388402

@@ -753,24 +767,24 @@ def test_cmap_select(doc: Document) -> None:
753767
main.UI["cprop_input"].value = ["prob"]
754768
# set to jet
755769
cmap_select.value = "jet"
756-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
770+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
757771
assert resp.json() == "jet"
758772
# set to dict
759773
cmap_select.value = "dict"
760-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
774+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
761775
assert isinstance(resp.json(), dict)
762776

763777
main.UI["cprop_input"].value = ["type"]
764778
# should now be the type mapping
765-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
779+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
766780
for key in main.UI["vstate"].mapper:
767781
assert str(key) in resp.json()
768782
assert np.all(
769783
np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]),
770784
)
771785
# set the cmap to "coolwarm"
772786
cmap_select.value = "coolwarm"
773-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
787+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
774788
# as cprop is type (categorical), it should have had no effect
775789
for key in main.UI["vstate"].mapper:
776790
assert str(key) in resp.json()
@@ -779,7 +793,7 @@ def test_cmap_select(doc: Document) -> None:
779793
)
780794

781795
main.UI["cprop_input"].value = ["prob"]
782-
resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap")
796+
resp = main.UI["s"].get(f"http://{main.host2}:{main.port}/tileserver/cmap")
783797
# should be coolwarm as that is the last cmap we set, and prob is continuous
784798
assert resp.json() == "coolwarm"
785799

@@ -825,3 +839,51 @@ def test_clearing_doc(doc: Document) -> None:
825839
"""Test that the doc can be cleared."""
826840
doc.clear()
827841
assert len(doc.roots) == 0
842+
843+
844+
def test_app_hooks_session_destroyed(monkeypatch: pytest.MonkeyPatch) -> None:
845+
"""Hook should call reset endpoint and exit."""
846+
recorded: dict[str, object] = {}
847+
848+
def fake_get(url: str, *, timeout: int) -> None:
849+
"""Fake requests.get to record parameters."""
850+
recorded["url"] = url
851+
recorded["timeout"] = timeout
852+
853+
monkeypatch.setattr(app_hooks, "PORT", "6150")
854+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
855+
exited = False
856+
857+
def fake_exit() -> None:
858+
"""Fake sys.exit to record call."""
859+
nonlocal exited
860+
exited = True
861+
862+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
863+
app_hooks.on_session_destroyed(_DummySessionContext("user-1"))
864+
assert recorded["url"] == "http://127.0.0.1:6150/tileserver/reset/user-1"
865+
assert recorded["timeout"] == 5
866+
assert exited
867+
868+
869+
def test_app_hooks_session_destroyed_suppresses_timeout(
870+
monkeypatch: pytest.MonkeyPatch,
871+
) -> None:
872+
"""ReadTimeout should be suppressed and exit still called."""
873+
874+
def fake_get(*_: object, **__: object) -> None:
875+
"""Fake requests.get to raise ReadTimeout."""
876+
raise app_hooks.requests.exceptions.ReadTimeout # type: ignore[attr-defined]
877+
878+
monkeypatch.setattr(app_hooks, "PORT", "6160")
879+
monkeypatch.setattr(app_hooks.requests, "get", fake_get)
880+
exited = False
881+
882+
def fake_exit() -> None:
883+
"""Fake sys.exit to record call."""
884+
nonlocal exited
885+
exited = True
886+
887+
monkeypatch.setattr(app_hooks, "sys", SimpleNamespace(exit=fake_exit))
888+
app_hooks.on_session_destroyed(_DummySessionContext("user-2"))
889+
assert exited

tests/test_json_config_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -14,6 +15,8 @@
1415
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1516
from tiatoolbox.data import _fetch_remote_sample
1617

18+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
19+
1720
if TYPE_CHECKING:
1821
from pathlib import Path
1922

@@ -65,7 +68,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
6568
yield session
6669
session.close()
6770
with suppress(requests.exceptions.ConnectionError):
68-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
71+
requests.post(
72+
f"http://localhost:{PORT}/tileserver/shutdown",
73+
timeout=2,
74+
)
6975

7076

7177
def test_slides_available(bk_session: ClientSession) -> None:

tests/test_server_bokeh.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import time
67
from contextlib import suppress
78
from threading import Thread
@@ -16,6 +17,8 @@
1617
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
1718
from tiatoolbox.data import _fetch_remote_sample
1819

20+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
21+
1922
if TYPE_CHECKING:
2023
from pathlib import Path
2124

@@ -72,7 +75,10 @@ def bk_session(data_path: dict[str, Path]) -> ClientSession:
7275
yield session
7376
session.close()
7477
with suppress(requests.exceptions.ConnectionError):
75-
requests.post("http://localhost:5000/tileserver/shutdown", timeout=2)
78+
requests.post(
79+
f"http://localhost:{PORT}/tileserver/shutdown",
80+
timeout=2,
81+
)
7682

7783

7884
def test_slides_available(bk_session: ClientSession) -> None:

tiatoolbox/annotation/storage.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,33 @@ def _initialize_query_string_parameters(
28612861

28622862
return query_string, query_parameters
28632863

2864+
@staticmethod
2865+
def _warn_if_query_not_using_index(
2866+
cur: sqlite3.Cursor,
2867+
query_string: str,
2868+
query_parameters: dict[str, object],
2869+
) -> None:
2870+
"""Log a warning when SQLite does not use an index."""
2871+
query_plan = cur.execute(
2872+
"EXPLAIN QUERY PLAN " + query_string,
2873+
query_parameters,
2874+
).fetchall()
2875+
uses_index = False
2876+
for plan in query_plan:
2877+
detail = str(plan[-1]).upper()
2878+
# macOS SQLite may report "USING COVERING INDEX".
2879+
if "AUTOMATIC" not in detail and (
2880+
"USING INDEX" in detail or "USING COVERING INDEX" in detail
2881+
):
2882+
uses_index = True
2883+
break
2884+
if not uses_index:
2885+
logger.warning(
2886+
"Query is not using an index. "
2887+
"Consider adding an index to improve performance.",
2888+
stacklevel=2,
2889+
)
2890+
28642891
def _query(
28652892
self: SQLiteStore,
28662893
columns: str,
@@ -2970,16 +2997,7 @@ def _query(
29702997

29712998
# Warn if the query is not using an index
29722999
if index_warning:
2973-
query_plan = cur.execute(
2974-
"EXPLAIN QUERY PLAN " + query_string,
2975-
query_parameters,
2976-
).fetchone()
2977-
if "USING INDEX" not in query_plan[-1]:
2978-
logger.warning(
2979-
"Query is not using an index. "
2980-
"Consider adding an index to improve performance.",
2981-
stacklevel=2,
2982-
)
3000+
self._warn_if_query_not_using_index(cur, query_string, query_parameters)
29833001
# if area column exists, sort annotations by area
29843002
if "area" in self.table_columns:
29853003
query_string += "\nORDER BY area DESC"

tiatoolbox/cli/visualize.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def run_app() -> None:
2626
layers={},
2727
)
2828
CORS(app, send_wildcard=True)
29-
app.run(host="127.0.0.1", threaded=True)
29+
port = int(os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000"))
30+
app.run(host="127.0.0.1", port=port, threaded=True)
3031

3132
proc = Thread(target=run_app, daemon=True)
3233
proc.start()
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""Hooks to be executed upon specific events in bokeh app."""
22

3+
import os
34
import sys
45
from contextlib import suppress
56

67
import requests
78
from bokeh.application.application import SessionContext
89

10+
PORT = os.environ.get("TIATOOLBOX_TILESERVER_PORT", "5000")
11+
912

1013
def on_session_destroyed(session_context: SessionContext) -> None:
1114
"""Hook to be executed when a session is destroyed."""
1215
user = session_context.request.arguments["user"]
1316
with suppress(requests.exceptions.ReadTimeout):
14-
requests.get(f"http://127.0.0.1:5000/tileserver/reset/{user}", timeout=5)
17+
requests.get(
18+
f"http://127.0.0.1:{PORT}/tileserver/reset/{user}",
19+
timeout=5,
20+
)
1521
sys.exit()

0 commit comments

Comments
 (0)