Reference: PR #675 - Server Refactor by Jack Urbanek
PR #675 completed the structural split of the monolithic server.py (~2,000 lines) into modular files under py/visdom/server/ and py/visdom/utils/. That work is merged and done.
However, the PR explicitly outlined follow-up work in TODO comments (socket_handlers.py:41-48, web_handlers.py:54-59) that was never completed. The maintainer wants this remaining work picked up. This plan covers everything that's left, prioritized by impact and risk.
py/visdom/
├── server/
│ ├── __main__.py (Entry point)
│ ├── app.py (Application orchestration)
│ ├── build.py (Asset management)
│ ├── defaults.py (Configuration)
│ ├── run_server.py (Server startup)
│ └── handlers/
│ ├── base_handlers.py (Base handler classes)
│ ├── socket_handlers.py (WebSocket handlers)
│ └── web_handlers.py (HTTP handlers)
└── utils/
├── server_utils.py (Server utilities)
└── shared_utils.py (Shared utilities)
Effort: Small | Risk: Low | PRs: 1 | Dependencies: None
These are actual bugs and resource leaks that can cause data loss or event loop stalls in production.
| # | Issue | File | Lines | Fix |
|---|---|---|---|---|
| 1a | Resource leak in loadfile() |
py/visdom/__init__.py |
137-143 | File opened without context manager; if assert fires, handle leaks. Rewrite with with open() |
| 1b | Bare except: clause |
py/visdom/__init__.py |
2647 | Catches SystemExit/KeyboardInterrupt. Change to except ImportError: |
| 1c | Thread lifecycle bug | py/visdom/__init__.py |
648-651 | setup_polling() thread missing daemon=True (compare with setup_socket() at 729-733 which has it). Prevents clean process exit |
| 1d | Thread-unsafe global | py/visdom/utils/shared_utils.py |
20 | _seen_warnings = set() accessed from multiple threads without lock |
| 1e | Broad exception swallowing | py/visdom/server/handlers/base_handlers.py |
35, 57 | Narrow except Exception to specific exception types |
Effort: Small-Medium | Risk: Low | PRs: 1 | Dependencies: None (parallel with Phase 1)
The same 6-line initialize(self, app) is copy-pasted across 13 handlers in web_handlers.py:
PostHandler(line 61),ExistsHandler(line 90),UpdateHandler(line 115),DeleteEnvHandler(line 423),EnvStateHandler(line 471),ForkEnvHandler(line 491),CompareHandler,DataHandler,SaveHandler, etc.
Fix: Move into BaseHandler in base_handlers.py. Handlers needing extras (IndexHandler needs user_credential, wrap_socket, base_url) call super().initialize(app) then add their own.
Identical environment deletion code exists in:
web_handlers.pyDeleteEnvHandler.wrap_func(lines 432-460)socket_handlers.pyon_messagedelete_env branch (lines 127-155)
Fix: Extract to delete_env_files(env_path, eid) in server_utils.py.
Two implementations:
shared_utils.py:36—str(uuid.uuid4())(good)__init__.py:107— time-based hex (weak, not unique under rapid calls)
Fix: Use UUID version everywhere.
Effort: Large | Risk: Medium | PRs: 2-3 | Dependencies: Phase 2
This is the primary TODO from PR #675: "move the logic that actually parses environments and layouts to new classes in the data_model folder"
| File | Purpose |
|---|---|
environment.py |
Environment class wrapping {"jsons": {}, "reload": {}} dict. Methods: get_window(), set_window(), remove_window(), list_windows(), get_reload() |
state_manager.py |
StateManager class wrapping the top-level state dict. Methods: get_env(), create_env(), delete_env(), list_envs(), fork_env(), serialize(), serialize_all(). Absorbs functions from server_utils.py (serialize_env, serialize_all, load_env, gather_envs, compare_envs) |
window.py |
Typed window structures (dataclasses/TypedDicts) replacing raw dicts built in server_utils.py:window() (lines 202-258) |
- Replace
handler.state[eid]["jsons"][win]patterns inweb_handlers.pyandsocket_handlers.pywithstate_manager.get_env(eid).get_window(win) - Move
register_window()andwindow()fromserver_utils.pyinto data model - Move
LazyEnvData(server_utils.py:84-118) intodata_model/environment.py
py/visdom/server/
├── data_model/
│ ├── __init__.py
│ ├── environment.py (Environment class + LazyEnvData)
│ ├── state_manager.py (StateManager - centralized state ops)
│ └── window.py (Typed window dataclasses)
├── handlers/
│ ├── base_handlers.py (Shared initialize logic)
│ ├── socket_handlers.py (Uses StateManager, not raw dicts)
│ └── web_handlers.py (Uses StateManager, not raw dicts)
└── ...
Effort: Medium-Large | Risk: Medium | PRs: 2 | Dependencies: Phase 3
Currently only ONE place uses async I/O (socket_handlers.py:123 for serialize_all). All others block the Tornado event loop:
| File | Lines | Blocking Operation |
|---|---|---|
server_utils.py |
72 | Cookie file write |
server_utils.py |
121-155 | serialize_env() — JSON file write |
server_utils.py |
273-303 | compare_envs() — env file read |
app.py |
141 | Layout save (file write) |
app.py |
155 | Layout load (file read) |
app.py |
183 | State load (JSON file reads) |
app.py |
238, 242 | CSS file reads |
With Phase 3's StateManager, all file I/O concentrates in one place — easy to wrap.
Tornado 6+ supports native coroutines. Convert all handler get()/post()/on_message() to async def. Update check_auth decorator in server_utils.py:49.
collections.abc.Sequencefallback in 3 files (__init__.py:19-24,server_utils.py:30-35,web_handlers.py:26-31)- Python 2 assert at
__init__.py:51 - PyTorch < 0.4 deprecation warnings (
__init__.py:407-410) - Lua Torch deprecation notice (
web_handlers.py:76-81)
Mixed %, .format(), and f-strings throughout. Standardize on f-strings.
Effort: Large | Risk: Medium | PRs: 3-4 | Dependencies: Phase 1 | Can parallel with Phases 3-4
The 2,712-line __init__.py monolith needs splitting:
| PR | New File | Content | Lines Moved |
|---|---|---|---|
| 5a | py/visdom/client/utils.py |
Utility functions: get_rand_id, isstr, isnum, nan2none, loadfile, _axisformat, _opts2layout, etc. |
~350 lines (106-450) |
| 5b | py/visdom/client/core.py |
Visdom class: init, session, connection, event handlers, save/fork/close |
~520 lines (452-975) |
| 5c | py/visdom/client/plotting.py |
All visualization methods: text, image, scatter, line, heatmap, bar, etc. |
~1,700 lines (977-2712) |
| 5d | Update __init__.py |
Re-exports only, preserving backward compatibility | imports only |
Effort: Medium | Risk: Low | PRs: 2 | Dependencies: Phases 3, 4
From TODO at socket_handlers.py:47-48: "standardize the code between the client-server and visdom-server socket edges"
Create py/visdom/server/protocol.py with typed message definitions (dataclasses):
- Server->Client:
AliveMessage,WindowMessage,WindowUpdateMessage,LayoutUpdateMessage - Client->Server:
CloseCommand,SaveCommand,DeleteEnvCommand,ForwardToVisCommand
The TODO at socket_handlers.py:238 notes inconsistent message JSON formatting between paths. Ensure consistent encoding across all communication channels.
| Issue | Related Phase | How Addressed |
|---|---|---|
| #989 (async race condition in env switching) | Phases 3 + 4 | StateManager with proper locking + async I/O |
| #1324 (refactor image rendering logic) | Phases 3 + 5 | Typed window models + plotting extraction |
| #1310 (multi-tenant architecture) | Phase 3 | StateManager provides isolation foundation |
| TODO Location | Phase |
|---|---|
socket_handlers.py:41 — data model |
Phase 3 |
socket_handlers.py:43 — base handler init |
Phase 2 |
socket_handlers.py:45 — abstract app refs |
Phase 3 |
socket_handlers.py:47 — standardize protocol |
Phase 6 |
web_handlers.py:54-59 — same 4 TODOs |
Phases 2, 3, 6 |
web_handlers.py:147 — embeddings update flow |
Phase 3 |
web_handlers.py:166 — python client function |
Phase 5 |
web_handlers.py:478 — env state handler |
Phase 3 |
__init__.py:602 — merge setup_polling/setup_socket |
Phase 5 |
__init__.py:779 — investigate/deprecate send |
Phase 5 |
server_utils.py:56 — shared method call |
Phase 2 |
base_handlers.py:80 — error.html page |
Phase 2 |
socket_handlers.py:238 — message format |
Phase 6 |
Phase 1 (bugs) ──────────────────────────→ Phase 5 (client split) ──→
Phase 2 (dedup) ──→ Phase 3 (data model) ──→ Phase 4 (async) ──→ Phase 6 (protocol)
Phases 1 & 2 can start immediately in parallel. Phase 5 can proceed independently after Phase 1. Phases 3 -> 4 -> 6 are sequential.
Estimated total: 10-13 PRs across all phases.
For each phase:
- Run existing tests:
python -m pytest(config inpyproject.toml; suite lives inpy/tests/) - Run Cypress E2E:
npm run test(server on port 8098) - Manual smoke test:
python -m visdom.server -port 8098then runexample/demo.py - Verify no regressions in visual regression screenshots
- Run linting:
black pyandnpm run lint
| File | Lines | Touched By |
|---|---|---|
py/visdom/__init__.py |
2,712 | Phases 1, 5 |
py/visdom/server/handlers/web_handlers.py |
708 | Phases 2, 3, 4 |
py/visdom/server/handlers/socket_handlers.py |
409 | Phases 2, 3, 4, 6 |
py/visdom/server/handlers/base_handlers.py |
84 | Phase 2 |
py/visdom/utils/server_utils.py |
568 | Phases 2, 3, 4 |
py/visdom/server/app.py |
248 | Phases 3, 4 |
py/visdom/utils/shared_utils.py |
62 | Phase 1 |