simview 6.2.3__tar.gz → 6.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simview-6.2.3/simview.egg-info → simview-6.3.0}/PKG-INFO +1 -1
- {simview-6.2.3 → simview-6.3.0}/pyproject.toml +1 -1
- {simview-6.2.3 → simview-6.3.0}/simview/__main__.py +15 -1
- {simview-6.2.3 → simview-6.3.0}/simview/info.py +11 -5
- {simview-6.2.3 → simview-6.3.0}/simview/server.py +181 -12
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/SimView.js +24 -4
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/TerrainProfile.js +13 -3
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/viewState.js +21 -0
- {simview-6.2.3 → simview-6.3.0}/simview/view.py +7 -5
- {simview-6.2.3 → simview-6.3.0/simview.egg-info}/PKG-INFO +1 -1
- {simview-6.2.3 → simview-6.3.0}/tests/test_cli.py +4 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_live.py +61 -1
- {simview-6.2.3 → simview-6.3.0}/tests/test_server.py +43 -1
- {simview-6.2.3 → simview-6.3.0}/tests/test_show.py +20 -0
- {simview-6.2.3 → simview-6.3.0}/LICENSE +0 -0
- {simview-6.2.3 → simview-6.3.0}/MANIFEST.in +0 -0
- {simview-6.2.3 → simview-6.3.0}/README.md +0 -0
- {simview-6.2.3 → simview-6.3.0}/setup.cfg +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/__init__.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/columnar.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/diff.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/launcher.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/live.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/merge.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/model.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/py.typed +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/remote.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/render.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/scene.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/state.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/css/controls.css +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/AnimationController.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/BatchManager.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/InteractionController.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/Scene.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/StateStore.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/WindowedField.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/config.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/main.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Body.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Polyline.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/StaticObject.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Terrain.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/colormap.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/similarity.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/utils.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/BatchLegend.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/BodyStateWindow.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/Controls.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/ErrorMetrics.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/Legend.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/PlaybackControls.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/ScalarPlotter.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/chartControls.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/batchPresets.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/batchVisibility.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/blobCodec.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/blobWindow.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/bodyTransforms.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/cameraRange.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/csv.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/episodes.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/errorMath.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/html.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/interpolate.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/keyboard.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/liveFollow.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/scalarSeries.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/terrainSample.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/uplot.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/viewerDefaults.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/visibleRanges.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/js/vendor/js-colormaps.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/uPlot-1.6.32/uPlot.esm.js +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/lib/uPlot-1.6.32/uPlot.min.css +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/textures/contacts/red-cross0.png +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/static/textures/points/ball1.png +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/templates/index.html +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/terrain.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview/utils.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview.egg-info/SOURCES.txt +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview.egg-info/dependency_links.txt +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview.egg-info/entry_points.txt +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview.egg-info/requires.txt +0 -0
- {simview-6.2.3 → simview-6.3.0}/simview.egg-info/top_level.txt +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_batch_selection.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_columnar_ondisk.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_columnar_states.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_contacts.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_diff.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_embed_defaults.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_episodes.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_info.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_launcher.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_merge.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_pointcloud_features.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_remote.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_roundtrip.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_scene.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_terrain.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_terrain_query.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_trajectory.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_utils.py +0 -0
- {simview-6.2.3 → simview-6.3.0}/tests/test_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.0
|
|
4
4
|
Summary: THREE.js-based simulation visualization tool
|
|
5
5
|
Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -297,9 +297,18 @@ def save_merged(
|
|
|
297
297
|
logger.error("Error: --save-merged requires at least 2 input files to merge.")
|
|
298
298
|
sys.exit(1)
|
|
299
299
|
|
|
300
|
+
from simview.columnar import columnarize_states, inline_blob
|
|
300
301
|
from simview.merge import merge_simulation_files
|
|
301
302
|
|
|
302
303
|
merged = merge_simulation_files(paths, selections)
|
|
304
|
+
# Pack the merged states the same way SimulationScene.save does: merge
|
|
305
|
+
# decodes every blob to plain lists, so dumping it directly would write
|
|
306
|
+
# the fat legacy per-frame layout. Falls back silently when numpy is
|
|
307
|
+
# unavailable or the states are too irregular (columnarize_states
|
|
308
|
+
# returns None).
|
|
309
|
+
columnar_states = columnarize_states(merged["states"], merged["model"], inline_blob)
|
|
310
|
+
if columnar_states is not None:
|
|
311
|
+
merged["states"] = columnar_states
|
|
303
312
|
payload = json.dumps(merged).encode("utf-8")
|
|
304
313
|
if out_path.suffix == ".gz":
|
|
305
314
|
payload = gzip.compress(payload, compresslevel=1)
|
|
@@ -372,7 +381,12 @@ def _add_host_port_flags(sp: argparse.ArgumentParser) -> None:
|
|
|
372
381
|
"--host",
|
|
373
382
|
type=str,
|
|
374
383
|
default="127.0.0.1",
|
|
375
|
-
help=
|
|
384
|
+
help=(
|
|
385
|
+
"Host/interface for the server to bind to (default: 127.0.0.1). "
|
|
386
|
+
"Note: the server has no authentication, so binding to a "
|
|
387
|
+
"non-loopback address exposes the scene data to everyone who can "
|
|
388
|
+
"reach it."
|
|
389
|
+
),
|
|
376
390
|
)
|
|
377
391
|
sp.add_argument(
|
|
378
392
|
"--port",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"""Structural summary of a scene JSON file, for `simview info` and for coding
|
|
2
2
|
agents inspecting simview-generated data files.
|
|
3
3
|
|
|
4
|
-
Deliberately dependency-free (stdlib only: json, gzip
|
|
5
|
-
`simview.utils.read_maybe_gzipped_bytes`, base64) so it works on a base
|
|
4
|
+
Deliberately dependency-free (stdlib only: json, gzip, base64) so it works on a base
|
|
6
5
|
install without the `authoring` extra (torch/numpy) -- see "Lazy imports" in
|
|
7
6
|
docs/dev/architecture.md. Does not import `simview.model`/`simview.server`:
|
|
8
7
|
the former pulls in torch at module scope, the latter fastapi/uvicorn --
|
|
@@ -11,6 +10,7 @@ both wrong layering for a lightweight inspection tool. Shares its blob-decoding/
|
|
|
11
10
|
which are stdlib-only on this read path.
|
|
12
11
|
"""
|
|
13
12
|
|
|
13
|
+
import gzip
|
|
14
14
|
import json
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any
|
|
@@ -23,7 +23,7 @@ from simview.columnar import (
|
|
|
23
23
|
is_blob,
|
|
24
24
|
is_columnar,
|
|
25
25
|
)
|
|
26
|
-
from simview.utils import body_label, cap, human_bytes
|
|
26
|
+
from simview.utils import body_label, cap, human_bytes
|
|
27
27
|
|
|
28
28
|
_MAX_DETAIL_ITEMS = 20
|
|
29
29
|
_LARGE_FILE_BYTES = 100_000_000
|
|
@@ -218,9 +218,12 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
218
218
|
|
|
219
219
|
scalars_summary = {}
|
|
220
220
|
for name in scalar_names:
|
|
221
|
-
|
|
221
|
+
# One pass per scalar: collect missing-frame indices and check
|
|
222
|
+
# lengths in the same walk.
|
|
223
|
+
missing = []
|
|
222
224
|
for i, s in enumerate(states):
|
|
223
225
|
if name not in s:
|
|
226
|
+
missing.append(i)
|
|
224
227
|
continue
|
|
225
228
|
value = s[name]
|
|
226
229
|
length = len(value) if isinstance(value, list) else 1
|
|
@@ -395,7 +398,10 @@ def summarize_scene(path: str | Path) -> dict:
|
|
|
395
398
|
path = Path(path)
|
|
396
399
|
raw = path.read_bytes()
|
|
397
400
|
gzipped = raw[:2] == b"\x1f\x8b"
|
|
398
|
-
|
|
401
|
+
# Decompress from the bytes already in hand rather than calling
|
|
402
|
+
# read_maybe_gzipped_bytes(path), which would read the whole file a
|
|
403
|
+
# second time -- scene files are often hundreds of MB.
|
|
404
|
+
data = json.loads(gzip.decompress(raw) if gzipped else raw)
|
|
399
405
|
if not isinstance(data, dict):
|
|
400
406
|
raise ValueError(
|
|
401
407
|
"scene file must contain a JSON object with 'model'/'states' keys"
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import secrets
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
@@ -23,6 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
23
24
|
from fastapi.responses import FileResponse, JSONResponse
|
|
24
25
|
from fastapi.staticfiles import StaticFiles
|
|
25
26
|
from pydantic import BaseModel
|
|
27
|
+
from starlette.datastructures import Headers
|
|
26
28
|
from starlette.types import Scope
|
|
27
29
|
|
|
28
30
|
from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
|
|
@@ -36,7 +38,31 @@ STATIC = str(files("simview").joinpath("static"))
|
|
|
36
38
|
# Local-only viewer: CORS is restricted to localhost/127.0.0.1 on any port so a
|
|
37
39
|
# browser tab open on another local dev server can't be silently allowed, while
|
|
38
40
|
# still letting the bundled UI (served from the same host) talk to the API.
|
|
39
|
-
|
|
41
|
+
# The host list is shared with the /ws/states origin check (see
|
|
42
|
+
# _LOCAL_ORIGIN_RE below), so the two can't drift apart.
|
|
43
|
+
_LOCAL_HOSTS = r"(localhost|127\.0\.0\.1|\[::1\])"
|
|
44
|
+
_ALLOWED_ORIGIN_REGEX = rf"^https?://{_LOCAL_HOSTS}(:\d+)?$"
|
|
45
|
+
|
|
46
|
+
# The same host list, compiled, for the /ws/states origin check (see
|
|
47
|
+
# _ws_origin_allowed): CORS does not apply to WebSockets, so the endpoint has
|
|
48
|
+
# to check the browser's Origin header itself.
|
|
49
|
+
_LOCAL_ORIGIN_RE = re.compile(rf"^{_LOCAL_HOSTS}(:\d+)?$")
|
|
50
|
+
# A bare address (IPv4 or IPv6 literal, optionally bracketed) -- what a Host
|
|
51
|
+
# header holds when the browser dialed the server by address rather than by a
|
|
52
|
+
# (rebindable) name. Also used by that check.
|
|
53
|
+
_IP_LITERAL_RE = re.compile(r"^\[?[0-9A-Fa-f:.]+\]?$")
|
|
54
|
+
|
|
55
|
+
# Hosts run()/ViewerHandle bind to without a warning (see _warn_public_bind).
|
|
56
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
57
|
+
|
|
58
|
+
# Hard cap on the declared size of any request body. /batch-names is the only
|
|
59
|
+
# endpoint that accepts one, and a scene's batch names are a handful of short
|
|
60
|
+
# strings, so 1 MiB is orders of magnitude above legitimate use while keeping a
|
|
61
|
+
# rogue POST (or a runaway client) from ballooning memory or the on-disk
|
|
62
|
+
# sidecar.
|
|
63
|
+
_MAX_REQUEST_BODY_BYTES = 1 << 20
|
|
64
|
+
# Per-name cap for POST /batch-names, same reasoning per string.
|
|
65
|
+
_MAX_BATCH_NAME_CHARS = 256
|
|
40
66
|
|
|
41
67
|
# Subdirectories of simview/static that hold vendored, version-pinned third-party
|
|
42
68
|
# libraries. These never change for a given release, so they get a long-lived,
|
|
@@ -101,6 +127,99 @@ def _not_found(what: str) -> JSONResponse:
|
|
|
101
127
|
return JSONResponse({"message": f"{what} data not available"}, status_code=404)
|
|
102
128
|
|
|
103
129
|
|
|
130
|
+
def _warn_public_bind(host: str) -> None:
|
|
131
|
+
"""Warn when the server is bound where other machines can reach it.
|
|
132
|
+
|
|
133
|
+
The server has no authentication: anyone who can connect can read the
|
|
134
|
+
scene data and persist batch names to disk next to the scene file. That is
|
|
135
|
+
fine on loopback (the default, and all a personal viewer needs) and a
|
|
136
|
+
deliberate choice on e.g. an HPC login node -- hence a warning, not an
|
|
137
|
+
error.
|
|
138
|
+
"""
|
|
139
|
+
if host not in _LOOPBACK_HOSTS:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"Serving on %s with NO authentication: anyone who can reach this "
|
|
142
|
+
"address can read the scene data and write batch names. Only bind "
|
|
143
|
+
"to a shared interface on networks you trust.",
|
|
144
|
+
host,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _host_of(authority: str) -> str:
|
|
149
|
+
"""The host part of a Host-header-style `host[:port]` authority, keeping
|
|
150
|
+
IPv6 brackets intact ('[::1]:5420' -> '[::1]')."""
|
|
151
|
+
if authority.startswith("["):
|
|
152
|
+
return authority.partition("]")[0] + "]"
|
|
153
|
+
return authority.partition(":")[0]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ws_origin_allowed(websocket: WebSocket) -> bool:
|
|
157
|
+
"""Whether /ws/states should serve this connection.
|
|
158
|
+
|
|
159
|
+
Browsers always send an Origin header on WebSocket connections, and CORS
|
|
160
|
+
does not apply to them, so without this check any site open in the user's
|
|
161
|
+
browser could connect to a running live viewer and read every pushed
|
|
162
|
+
frame. Two origins are allowed:
|
|
163
|
+
|
|
164
|
+
* a localhost one (the viewer itself, another local dev server, the
|
|
165
|
+
Jupyter iframe), matching the CORS list above;
|
|
166
|
+
* a same-origin one -- the Origin's host:port equals the Host header, i.e.
|
|
167
|
+
a page this very server served, connecting back to the address it was
|
|
168
|
+
loaded from. The Host must additionally name an address or 'localhost':
|
|
169
|
+
a hostname that Origin and Host agree on is the DNS-rebinding signature,
|
|
170
|
+
while an address the browser actually dialed is by construction this
|
|
171
|
+
machine. (The trade-off: a live viewer reached by hostname over the LAN
|
|
172
|
+
is refused -- reach it by address, e.g. http://10.0.0.5:5420.)
|
|
173
|
+
|
|
174
|
+
A missing Origin (tests, non-browser tooling) is allowed; browsers never
|
|
175
|
+
omit it.
|
|
176
|
+
"""
|
|
177
|
+
origin = websocket.headers.get("origin")
|
|
178
|
+
if origin is None:
|
|
179
|
+
return True
|
|
180
|
+
match = re.match(r"^https?://([^/?#]+)", origin)
|
|
181
|
+
if not match:
|
|
182
|
+
return False
|
|
183
|
+
if _LOCAL_ORIGIN_RE.match(match.group(1)):
|
|
184
|
+
return True
|
|
185
|
+
host_header = websocket.headers.get("host", "")
|
|
186
|
+
if match.group(1) != host_header:
|
|
187
|
+
return False
|
|
188
|
+
host = _host_of(host_header)
|
|
189
|
+
return host == "localhost" or _IP_LITERAL_RE.match(host) is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _BodySizeLimitMiddleware:
|
|
193
|
+
"""Rejects requests whose declared body exceeds _MAX_REQUEST_BODY_BYTES.
|
|
194
|
+
|
|
195
|
+
Checks Content-Length and answers 413 before FastAPI/uvicorn buffers and
|
|
196
|
+
parses anything. A chunked request with no Content-Length slips past this
|
|
197
|
+
-- acceptable for a local single-user tool, where the point is bounding
|
|
198
|
+
what a casual or runaway client can push, not defeating a deliberate
|
|
199
|
+
attacker with full protocol control.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, app):
|
|
203
|
+
self.app = app
|
|
204
|
+
|
|
205
|
+
async def __call__(self, scope: Scope, receive, send):
|
|
206
|
+
if scope["type"] == "http":
|
|
207
|
+
length = Headers(scope=scope).get("content-length")
|
|
208
|
+
if length is not None and length.isdigit():
|
|
209
|
+
if int(length) > _MAX_REQUEST_BODY_BYTES:
|
|
210
|
+
response = JSONResponse(
|
|
211
|
+
{
|
|
212
|
+
"message": (
|
|
213
|
+
f"Request body exceeds {_MAX_REQUEST_BODY_BYTES} bytes"
|
|
214
|
+
)
|
|
215
|
+
},
|
|
216
|
+
status_code=413,
|
|
217
|
+
)
|
|
218
|
+
await response(scope, receive, send)
|
|
219
|
+
return
|
|
220
|
+
await self.app(scope, receive, send)
|
|
221
|
+
|
|
222
|
+
|
|
104
223
|
class BatchNamesRequest(BaseModel):
|
|
105
224
|
names: list[str]
|
|
106
225
|
|
|
@@ -168,10 +287,15 @@ class SimViewServer:
|
|
|
168
287
|
# SimViewServer doesn't need a reference to it. Bounded: on a run
|
|
169
288
|
# longer than the cap the oldest frames are forgotten, so a late viewer
|
|
170
289
|
# replays the most recent window instead of the entire history.
|
|
171
|
-
|
|
290
|
+
# Each entry is (absolute frame index, frame): the index rides with
|
|
291
|
+
# the frame so a catch-up never has to reconstruct it from
|
|
292
|
+
# `frames_pushed - len(buffer)`, whose two halves are updated by
|
|
293
|
+
# different threads and could be read mid-update.
|
|
294
|
+
self.frame_buffer: deque[tuple[int, dict]] = deque(maxlen=frame_buffer_size)
|
|
172
295
|
# Absolute index (in the producer's scene.states) of the next frame to
|
|
173
|
-
# enter frame_buffer
|
|
174
|
-
#
|
|
296
|
+
# enter frame_buffer. Maintained by push_frame on the producer's thread
|
|
297
|
+
# and read there only (live.py); catch-up reads the indices stored in
|
|
298
|
+
# the buffer instead.
|
|
175
299
|
self.frames_pushed = 0
|
|
176
300
|
self._batch_selections = (
|
|
177
301
|
None if batch_selections is None else [str(s) for s in batch_selections]
|
|
@@ -213,6 +337,10 @@ class SimViewServer:
|
|
|
213
337
|
allow_methods=["GET", "POST"],
|
|
214
338
|
allow_headers=["*"],
|
|
215
339
|
)
|
|
340
|
+
# Body-size cap for the one endpoint with a request body. Added after
|
|
341
|
+
# CORS above, so it wraps it and rejects oversized payloads before any
|
|
342
|
+
# middleware or handler runs.
|
|
343
|
+
self.app.add_middleware(_BodySizeLimitMiddleware)
|
|
216
344
|
|
|
217
345
|
# Mount static files. StaticFiles adds ETag/Last-Modified headers so
|
|
218
346
|
# unchanged assets (vendored libs, textures, our own JS) are served
|
|
@@ -286,9 +414,13 @@ class SimViewServer:
|
|
|
286
414
|
# write dead "/blob/..." URLs, and a second show() would serve stale
|
|
287
415
|
# blob references). Deep-copying just the model is cheap: the big
|
|
288
416
|
# payloads are immutable blob strings, which deepcopy shares rather
|
|
289
|
-
# than duplicating. `states`
|
|
417
|
+
# than duplicating. A columnar `states` doc gets the same treatment
|
|
418
|
+
# below (rewrite_blobs rewrites its inline blobs in place too); the
|
|
419
|
+
# legacy per-frame list is only ever read.
|
|
290
420
|
if preloaded and model_data is not None:
|
|
291
421
|
model_data = copy.deepcopy(model_data)
|
|
422
|
+
if preloaded and is_columnar(states_data):
|
|
423
|
+
states_data = copy.deepcopy(states_data)
|
|
292
424
|
|
|
293
425
|
# Taken once, at load: the sidecar must describe the batches that were
|
|
294
426
|
# actually loaded, not whatever the file is by the time of a rename.
|
|
@@ -323,8 +455,11 @@ class SimViewServer:
|
|
|
323
455
|
# Random per-load token folded into every blob URL so it's safe to cache
|
|
324
456
|
# them forever: a later server restart serving a different scene on the
|
|
325
457
|
# same port gets a different token, so it can never collide with a
|
|
326
|
-
# stale cached response for blob id N from a previous load.
|
|
327
|
-
|
|
458
|
+
# stale cached response for blob id N from a previous load. It doubles
|
|
459
|
+
# as the only thing gating /blob responses (the /model payload that
|
|
460
|
+
# lists the URLs is CORS-restricted), so it is 128-bit rather than the
|
|
461
|
+
# few bits a pure cache-buster would need.
|
|
462
|
+
self._blob_token = secrets.token_hex(16)
|
|
328
463
|
|
|
329
464
|
def register_blob(raw: bytes) -> str:
|
|
330
465
|
blob_id = len(self.blobs)
|
|
@@ -461,6 +596,19 @@ class SimViewServer:
|
|
|
461
596
|
media_type="application/json",
|
|
462
597
|
status_code=400,
|
|
463
598
|
)
|
|
599
|
+
if any(len(name) > _MAX_BATCH_NAME_CHARS for name in names):
|
|
600
|
+
return Response(
|
|
601
|
+
content=json.dumps(
|
|
602
|
+
{
|
|
603
|
+
"message": (
|
|
604
|
+
"Batch names must each be at most "
|
|
605
|
+
f"{_MAX_BATCH_NAME_CHARS} characters"
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
),
|
|
609
|
+
media_type="application/json",
|
|
610
|
+
status_code=400,
|
|
611
|
+
)
|
|
464
612
|
|
|
465
613
|
self.model_data["batchNames"] = names
|
|
466
614
|
self._pack_model()
|
|
@@ -490,6 +638,23 @@ class SimViewServer:
|
|
|
490
638
|
# so a viewer opened mid-run still sees the recent timeline.
|
|
491
639
|
@self.app.websocket("/ws/states")
|
|
492
640
|
async def ws_states(websocket: WebSocket):
|
|
641
|
+
if not _ws_origin_allowed(websocket):
|
|
642
|
+
# CORS doesn't apply to WebSockets, so the endpoint checks
|
|
643
|
+
# the browser's Origin itself (see _ws_origin_allowed).
|
|
644
|
+
# Rejected during the handshake, before any frame is sent.
|
|
645
|
+
# A warning, not info: the hostname case is a formerly
|
|
646
|
+
# working setup now refused, and this line is the only
|
|
647
|
+
# server-side clue.
|
|
648
|
+
logger.warning(
|
|
649
|
+
"Rejected /ws/states connection with Origin %r: only "
|
|
650
|
+
"localhost and same-origin pages may connect to the "
|
|
651
|
+
"live stream. A viewer opened by hostname must be "
|
|
652
|
+
"opened by its address instead (e.g. "
|
|
653
|
+
"http://10.0.0.5:5420).",
|
|
654
|
+
websocket.headers.get("origin"),
|
|
655
|
+
)
|
|
656
|
+
await websocket.close(code=1008)
|
|
657
|
+
return
|
|
493
658
|
await websocket.accept()
|
|
494
659
|
try:
|
|
495
660
|
await self._send_catchup(websocket)
|
|
@@ -524,20 +689,22 @@ class SimViewServer:
|
|
|
524
689
|
if not pending:
|
|
525
690
|
self.ws_clients.add(websocket)
|
|
526
691
|
return
|
|
527
|
-
# Absolute index of buffered[0]: everything pushed minus what the
|
|
528
|
-
# (bounded) buffer still holds.
|
|
529
|
-
offset = self.frames_pushed - len(buffered) + replayed
|
|
530
692
|
for start in range(0, len(pending), _CATCHUP_CHUNK_SIZE):
|
|
531
693
|
chunk = pending[start : start + _CATCHUP_CHUNK_SIZE]
|
|
532
694
|
await websocket.send_text(
|
|
533
|
-
json.dumps(
|
|
695
|
+
json.dumps(
|
|
696
|
+
{
|
|
697
|
+
"states": [frame for _, frame in chunk],
|
|
698
|
+
"frameOffset": pending[start][0],
|
|
699
|
+
}
|
|
700
|
+
)
|
|
534
701
|
)
|
|
535
702
|
replayed += len(pending)
|
|
536
703
|
|
|
537
704
|
def push_frame(self, frame: dict) -> None:
|
|
538
705
|
"""Record one new frame for catch-up (live mode). The caller
|
|
539
706
|
broadcasts it separately via `broadcast_frame`."""
|
|
540
|
-
self.frame_buffer.append(frame)
|
|
707
|
+
self.frame_buffer.append((self.frames_pushed, frame))
|
|
541
708
|
self.frames_pushed += 1
|
|
542
709
|
|
|
543
710
|
def set_episodes(self, episodes: list[dict]) -> None:
|
|
@@ -599,6 +766,7 @@ class SimViewServer:
|
|
|
599
766
|
open_browser: bool = False,
|
|
600
767
|
):
|
|
601
768
|
"""Serve on `host:port`, blocking until the server stops."""
|
|
769
|
+
_warn_public_bind(host)
|
|
602
770
|
logger.info("SimView server running on http://%s:%s", host, port)
|
|
603
771
|
if open_browser:
|
|
604
772
|
import webbrowser
|
|
@@ -693,6 +861,7 @@ class ViewerHandle:
|
|
|
693
861
|
self.port = find_free_port(host, preferred_port)
|
|
694
862
|
self.view = view
|
|
695
863
|
self.iframe_height = iframe_height
|
|
864
|
+
_warn_public_bind(host)
|
|
696
865
|
|
|
697
866
|
# Prefer uvicorn's modern sansio websocket implementation when the
|
|
698
867
|
# `websockets` package is available -- the default "auto" still selects
|
|
@@ -25,7 +25,7 @@ import { escapeHtml } from "./utils/html.js";
|
|
|
25
25
|
import { bytesPerFrame, shouldWindowField } from "./utils/blobWindow.js";
|
|
26
26
|
import { shouldFollowLive } from "./utils/liveFollow.js";
|
|
27
27
|
import { shiftEpisodes } from "./utils/episodes.js";
|
|
28
|
-
import { parseViewState, parseStartupOptions, serializeViewState, toggleMapFromUiState } from "./utils/viewState.js";
|
|
28
|
+
import { parseViewState, parseStartupOptions, safeStaticBase, serializeViewState, toggleMapFromUiState } from "./utils/viewState.js";
|
|
29
29
|
import { mergeUiDefaults, applyViewerDomDefaults, panelHideCss } from "./utils/viewerDefaults.js";
|
|
30
30
|
|
|
31
31
|
export class SimView {
|
|
@@ -80,10 +80,13 @@ export class SimView {
|
|
|
80
80
|
console.log(`SimView: static demo mode, base='${simView.staticBase}'`);
|
|
81
81
|
}
|
|
82
82
|
// Startup hash keys (see parseStartupOptions): `data` wins over the global.
|
|
83
|
-
// `
|
|
84
|
-
//
|
|
83
|
+
// The `data` value is validated against this page's origin (see
|
|
84
|
+
// safeStaticBase) -- the hash is attacker-controllable via a crafted
|
|
85
|
+
// link. `hide = []`: a browser cache serving an older viewState.js
|
|
86
|
+
// alongside this file must degrade to showing the panels, not a
|
|
87
|
+
// broken viewer.
|
|
85
88
|
const { data, ui, play, hide = [] } = parseStartupOptions(location.hash);
|
|
86
|
-
if (data) simView.staticBase = data;
|
|
89
|
+
if (data) simView.staticBase = safeStaticBase(data);
|
|
87
90
|
if (!ui) document.body.classList.add("sv-embed");
|
|
88
91
|
simView.autoplay = play;
|
|
89
92
|
// `hide=recording,legend`: same stylesheet mechanism as
|
|
@@ -306,6 +309,20 @@ export class SimView {
|
|
|
306
309
|
console.log("Live stream closed (simulation finished serving).");
|
|
307
310
|
this.liveSocket = null;
|
|
308
311
|
this.hideLiveBadge();
|
|
312
|
+
// The splash is only removed once a frame arrives (above), so it
|
|
313
|
+
// still being attached means the connection died before the run
|
|
314
|
+
// started -- e.g. refused by the server's origin check (a viewer
|
|
315
|
+
// opened by hostname; see /ws/states). Show why instead of leaving
|
|
316
|
+
// "Fetching states…" up forever.
|
|
317
|
+
if (splash && splash.isConnected) {
|
|
318
|
+
splash.innerHTML =
|
|
319
|
+
`<h1 class="sv-splash-error">Couldn't start the live stream</h1>` +
|
|
320
|
+
`<p>${escapeHtml(
|
|
321
|
+
"The server refused the connection. If the viewer was opened by hostname, " +
|
|
322
|
+
"open it by its address instead (e.g. http://10.0.0.5:5420). " +
|
|
323
|
+
"See the browser console and the server log for details.",
|
|
324
|
+
)}</p>`;
|
|
325
|
+
}
|
|
309
326
|
};
|
|
310
327
|
socket.onerror = (event) => {
|
|
311
328
|
console.error("Live stream error:", event);
|
|
@@ -364,6 +381,9 @@ export class SimView {
|
|
|
364
381
|
if (this.scalarPlotter) {
|
|
365
382
|
this.scalarPlotter.onStatesAppended();
|
|
366
383
|
}
|
|
384
|
+
if (this.terrainProfile) {
|
|
385
|
+
this.terrainProfile.onStatesAppended();
|
|
386
|
+
}
|
|
367
387
|
if (wasFollowingLive) {
|
|
368
388
|
this.animationController.goToTime(this.store.lastTime());
|
|
369
389
|
}
|
|
@@ -129,6 +129,14 @@ export class TerrainProfile {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Called by SimView when live streaming appended states -- the cached
|
|
133
|
+
// whole-run series is now stale. Mirrors ErrorMetrics.onHistoryReady:
|
|
134
|
+
// recompute is deferred to the throttled animate() tick (and skipped
|
|
135
|
+
// entirely while hidden -- setVisible recomputes on open).
|
|
136
|
+
onStatesAppended() {
|
|
137
|
+
this.dirty = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
// Called by AnalysisPanel when this panel becomes/stops being the visible section.
|
|
133
141
|
setVisible(visible) {
|
|
134
142
|
if (this.isExpanded === visible) return;
|
|
@@ -174,8 +182,9 @@ export class TerrainProfile {
|
|
|
174
182
|
|
|
175
183
|
// Recomputes the full (whole-timeline) series for the current
|
|
176
184
|
// layer/body/path selection. This is the expensive step (bilinear
|
|
177
|
-
// sampling every frame x every batch) -- only run on open
|
|
178
|
-
//
|
|
185
|
+
// sampling every frame x every batch) -- only run on open, on a control
|
|
186
|
+
// change while open, or (live mode) on the throttled animate() tick
|
|
187
|
+
// after new states arrived, never per animation frame.
|
|
179
188
|
_recompute() {
|
|
180
189
|
this.dirty = false;
|
|
181
190
|
const store = this.app.animationController ? this.app.animationController.store : null;
|
|
@@ -311,7 +320,8 @@ export class TerrainProfile {
|
|
|
311
320
|
if (!this.isExpanded) return;
|
|
312
321
|
if (now - this.lastRenderTime < this.minRenderDelay) return;
|
|
313
322
|
this.lastRenderTime = now;
|
|
314
|
-
this.
|
|
323
|
+
if (this.dirty) this._recompute();
|
|
324
|
+
else this._updateMarker();
|
|
315
325
|
}
|
|
316
326
|
|
|
317
327
|
dispose() {
|
|
@@ -211,3 +211,24 @@ export function parseStartupOptions(hash) {
|
|
|
211
211
|
hide: (params.get("hide") || "").split(",").filter(Boolean),
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
|
+
|
|
215
|
+
// The `data=` startup key, validated: relative URLs (the save_static bundle
|
|
216
|
+
// layout, `#data=./my-scene/`) and absolute URLs on this page's own origin
|
|
217
|
+
// pass; anything cross-origin is ignored with a warning. The hash is
|
|
218
|
+
// attacker-controllable (any crafted link can set it), and without this check
|
|
219
|
+
// such a link would make the viewer fetch and render scene data from an
|
|
220
|
+
// arbitrary server. The `window.__simviewStaticBase` global is set by whoever
|
|
221
|
+
// hosts the page itself, so it stays unrestricted.
|
|
222
|
+
export function safeStaticBase(data) {
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(data, location.href);
|
|
225
|
+
if (url.origin !== location.origin) {
|
|
226
|
+
console.warn(`SimView: ignoring cross-origin data= '${data}'`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return data;
|
|
230
|
+
} catch {
|
|
231
|
+
console.warn(`SimView: ignoring invalid data= '${data}'`);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -79,11 +79,13 @@ def view_hash(
|
|
|
79
79
|
`point_clouds`, `track` (a body name, or ``"None"``), `color_map` and
|
|
80
80
|
`speed` are carried as named keys; leaving one ``None`` leaves the
|
|
81
81
|
viewer's current setting alone.
|
|
82
|
-
`data` (base URL of a static bundle, see `SimulationScene.save_static
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
`data` (base URL of a static bundle, see `SimulationScene.save_static`;
|
|
83
|
+
must be same-origin with the viewer page -- cross-origin values are
|
|
84
|
+
ignored by the viewer, which sets ``window.__simviewStaticBase``
|
|
85
|
+
instead), `ui=False` (hide all panels), `play=True` (start playing once
|
|
86
|
+
loaded) and `hide` (panel names as in `viewerDefaults.panels`, e.g.
|
|
87
|
+
``["recording"]`` for the REC/format/screenshot controls) are startup
|
|
88
|
+
options, not view state.
|
|
87
89
|
"""
|
|
88
90
|
params = []
|
|
89
91
|
if t is not None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.0
|
|
4
4
|
Summary: THREE.js-based simulation visualization tool
|
|
5
5
|
Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -15,6 +15,7 @@ pytest.importorskip("torch")
|
|
|
15
15
|
from conftest import build_scene
|
|
16
16
|
|
|
17
17
|
import simview.__main__ as cli
|
|
18
|
+
from simview.columnar import is_columnar
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _fail_if_called(*args, **kwargs):
|
|
@@ -185,6 +186,9 @@ def test_save_merged_writes_json_without_starting_server(monkeypatch, tmp_path):
|
|
|
185
186
|
assert out_path.is_file()
|
|
186
187
|
merged = json.loads(out_path.read_text())
|
|
187
188
|
assert merged["model"]["simBatches"] == 3
|
|
189
|
+
# Packed like SimulationScene.save, not the fat per-frame layout merge
|
|
190
|
+
# produces internally.
|
|
191
|
+
assert is_columnar(merged["states"])
|
|
188
192
|
|
|
189
193
|
|
|
190
194
|
def test_save_merged_gzips_when_path_ends_in_gz(monkeypatch, tmp_path):
|
|
@@ -23,6 +23,7 @@ pytest.importorskip("torch")
|
|
|
23
23
|
|
|
24
24
|
import torch
|
|
25
25
|
from fastapi.testclient import TestClient
|
|
26
|
+
from starlette.websockets import WebSocketDisconnect
|
|
26
27
|
|
|
27
28
|
from simview.live import LiveViewer
|
|
28
29
|
from simview.scene import BodyShapeType, SimulationScene
|
|
@@ -126,7 +127,7 @@ def test_frame_buffer_is_bounded():
|
|
|
126
127
|
server.push_frame({"time": float(i), "bodies": []})
|
|
127
128
|
|
|
128
129
|
# Oldest frames are forgotten rather than growing without limit.
|
|
129
|
-
assert [frame["time"] for frame in server.frame_buffer] == [7.0, 8.0, 9.0]
|
|
130
|
+
assert [frame["time"] for _, frame in server.frame_buffer] == [7.0, 8.0, 9.0]
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
def test_websocket_receives_frame_pushed_after_connect(live_client):
|
|
@@ -144,6 +145,65 @@ def test_websocket_receives_frame_pushed_after_connect(live_client):
|
|
|
144
145
|
assert message == {"states": [frame], "frameOffset": 0}
|
|
145
146
|
|
|
146
147
|
|
|
148
|
+
# --- /ws/states origin check --------------------------------------------------
|
|
149
|
+
#
|
|
150
|
+
# CORS does not apply to WebSockets, so the endpoint checks the browser's
|
|
151
|
+
# Origin header itself (see _ws_origin_allowed in server.py): a site open in
|
|
152
|
+
# the user's browser must not be able to connect and read the live stream.
|
|
153
|
+
# The existing tests above double as the "no Origin header" (non-browser
|
|
154
|
+
# client) case, which stays allowed.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_websocket_rejects_cross_origin(live_client):
|
|
158
|
+
_server, client = live_client
|
|
159
|
+
with pytest.raises(WebSocketDisconnect):
|
|
160
|
+
with client.websocket_connect(
|
|
161
|
+
"/ws/states", headers={"origin": "http://evil.example"}
|
|
162
|
+
):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_websocket_rejects_same_origin_hostname_host(live_client):
|
|
167
|
+
# The DNS-rebinding signature: Origin and Host agree, but on a hostname a
|
|
168
|
+
# browser could have re-resolved to loopback. Only an address (or
|
|
169
|
+
# 'localhost') is trusted there.
|
|
170
|
+
_server, client = live_client
|
|
171
|
+
with pytest.raises(WebSocketDisconnect):
|
|
172
|
+
with client.websocket_connect(
|
|
173
|
+
"/ws/states",
|
|
174
|
+
headers={
|
|
175
|
+
"origin": "http://evil.example:5420",
|
|
176
|
+
"host": "evil.example:5420",
|
|
177
|
+
},
|
|
178
|
+
):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_websocket_accepts_localhost_origin(live_client):
|
|
183
|
+
server, client = live_client
|
|
184
|
+
server.push_frame({"time": 0.0, "bodies": []})
|
|
185
|
+
|
|
186
|
+
with client.websocket_connect(
|
|
187
|
+
"/ws/states", headers={"origin": "http://localhost:3000"}
|
|
188
|
+
) as ws:
|
|
189
|
+
message = json.loads(ws.receive_text())
|
|
190
|
+
assert message["states"] == [{"time": 0.0, "bodies": []}]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_websocket_accepts_same_origin_address_host(live_client):
|
|
194
|
+
# A page this server served, reached by address over the LAN (Host is an
|
|
195
|
+
# IP literal matching the Origin) -- same-origin, so allowed.
|
|
196
|
+
server, client = live_client
|
|
197
|
+
server.push_frame({"time": 0.0, "bodies": []})
|
|
198
|
+
|
|
199
|
+
with client.websocket_connect(
|
|
200
|
+
"/ws/states",
|
|
201
|
+
headers={"origin": "http://10.0.0.5:5420", "host": "10.0.0.5:5420"},
|
|
202
|
+
) as ws:
|
|
203
|
+
message = json.loads(ws.receive_text())
|
|
204
|
+
assert message["states"] == [{"time": 0.0, "bodies": []}]
|
|
205
|
+
|
|
206
|
+
|
|
147
207
|
def test_broadcast_drops_dead_connection_without_raising(live_client):
|
|
148
208
|
server, _client = live_client
|
|
149
209
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
4
5
|
|
|
@@ -10,7 +11,7 @@ from conftest import build_scene
|
|
|
10
11
|
from fastapi import FastAPI
|
|
11
12
|
from fastapi.testclient import TestClient
|
|
12
13
|
|
|
13
|
-
from simview.server import SimViewServer
|
|
14
|
+
from simview.server import SimViewServer, _warn_public_bind
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@pytest.fixture
|
|
@@ -151,6 +152,47 @@ def test_batch_names_endpoint_rejects_wrong_length(tmp_path):
|
|
|
151
152
|
assert resp.status_code == 400
|
|
152
153
|
|
|
153
154
|
|
|
155
|
+
def test_batch_names_endpoint_rejects_overlong_names(client):
|
|
156
|
+
# Batch names are persisted to a sidecar next to the scene file; the
|
|
157
|
+
# per-name cap bounds what a single request can write (see
|
|
158
|
+
# _MAX_BATCH_NAME_CHARS). The rejection must also leave the served model
|
|
159
|
+
# untouched.
|
|
160
|
+
too_long = "x" * 257
|
|
161
|
+
resp = client.post("/batch-names", json={"names": ["fine", too_long]})
|
|
162
|
+
assert resp.status_code == 400
|
|
163
|
+
assert "batchNames" not in client.get("/model").json()
|
|
164
|
+
|
|
165
|
+
# Exactly at the cap is fine.
|
|
166
|
+
resp = client.post("/batch-names", json={"names": ["fine", "x" * 256]})
|
|
167
|
+
assert resp.status_code == 200
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_oversized_request_body_rejected(client):
|
|
171
|
+
# Any endpoint, not just /batch-names: the middleware answers 413 off the
|
|
172
|
+
# Content-Length before anything parses the body.
|
|
173
|
+
resp = client.post("/batch-names", content=b"x" * (2 << 20))
|
|
174
|
+
assert resp.status_code == 413
|
|
175
|
+
# An in-bounds body still reaches the endpoint (here: 422, pydantic).
|
|
176
|
+
resp = client.post("/batch-names", content=b"{}")
|
|
177
|
+
assert resp.status_code == 422
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_public_bind_warns_only_off_loopback(caplog):
|
|
181
|
+
# The server has no authentication, so binding anywhere other machines
|
|
182
|
+
# can reach must be loud about it; loopback (the default) stays quiet.
|
|
183
|
+
with caplog.at_level(logging.WARNING, logger="simview.server"):
|
|
184
|
+
_warn_public_bind("0.0.0.0")
|
|
185
|
+
_warn_public_bind("hpc-node03")
|
|
186
|
+
assert caplog.text.count("NO authentication") == 2
|
|
187
|
+
|
|
188
|
+
caplog.clear()
|
|
189
|
+
with caplog.at_level(logging.WARNING, logger="simview.server"):
|
|
190
|
+
_warn_public_bind("127.0.0.1")
|
|
191
|
+
_warn_public_bind("localhost")
|
|
192
|
+
_warn_public_bind("::1")
|
|
193
|
+
assert "NO authentication" not in caplog.text
|
|
194
|
+
|
|
195
|
+
|
|
154
196
|
# --- Gzip support ------------------------------------------------------------
|
|
155
197
|
|
|
156
198
|
|
|
@@ -8,6 +8,7 @@ httpx), rather than FastAPI's in-process TestClient, since the whole point of
|
|
|
8
8
|
`show` is that it doesn't block the caller.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import socket
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
@@ -17,7 +18,9 @@ pytest.importorskip("torch")
|
|
|
17
18
|
|
|
18
19
|
from conftest import build_scene
|
|
19
20
|
|
|
21
|
+
from simview.columnar import is_columnar
|
|
20
22
|
from simview.scene import SimulationScene, ViewerHandle
|
|
23
|
+
from simview.server import SimViewServer
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def _port_is_free(host: str, port: int) -> bool:
|
|
@@ -113,6 +116,23 @@ def test_show_does_not_mutate_the_scene_model():
|
|
|
113
116
|
assert len(blob_resp.content) == 5 * 3 * 4 # 5 points x 3 floats x 4 bytes
|
|
114
117
|
|
|
115
118
|
|
|
119
|
+
def test_preloaded_columnar_states_doc_is_not_mutated(tmp_path):
|
|
120
|
+
"""Serving a preloaded columnar `data=` dict must not rewrite its inline
|
|
121
|
+
__b64__ state blobs into /blob/ URLs in place -- the states twin of the
|
|
122
|
+
model regression above (a later save of the same dict would write dead
|
|
123
|
+
references)."""
|
|
124
|
+
scene = build_scene(batch_size=1)
|
|
125
|
+
path = tmp_path / "scene.json"
|
|
126
|
+
scene.save(path)
|
|
127
|
+
doc = json.loads(path.read_text())
|
|
128
|
+
assert is_columnar(doc["states"])
|
|
129
|
+
states_before = json.dumps(doc["states"])
|
|
130
|
+
|
|
131
|
+
SimViewServer(data=doc)
|
|
132
|
+
|
|
133
|
+
assert json.dumps(doc["states"]) == states_before
|
|
134
|
+
|
|
135
|
+
|
|
116
136
|
def test_concurrent_shows_get_independent_ports():
|
|
117
137
|
scene_a = build_scene(batch_size=1)
|
|
118
138
|
scene_b = build_scene(batch_size=1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js
RENAMED
|
File without changes
|
{simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|