nds-mapviewer 2026.2.1.dev91__tar.gz → 2026.2.1.dev93__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.
Files changed (31) hide show
  1. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/CHANGELOG.md +16 -0
  2. {nds_mapviewer-2026.2.1.dev91/src/nds_mapviewer.egg-info → nds_mapviewer-2026.2.1.dev93}/PKG-INFO +25 -1
  3. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/README.md +24 -0
  4. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93/src/nds_mapviewer.egg-info}/PKG-INFO +25 -1
  5. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/nds_mapviewer.egg-info/SOURCES.txt +3 -0
  6. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/cli.py +65 -0
  7. nds_mapviewer-2026.2.1.dev93/src/ndslive/mapviewer/clipboard.py +81 -0
  8. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/docker_client.py +36 -14
  9. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/tui.py +110 -13
  10. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/viewer.py +43 -4
  11. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/tests/test_cli.py +53 -0
  12. nds_mapviewer-2026.2.1.dev93/tests/test_clipboard.py +76 -0
  13. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/tests/test_docker_client.py +57 -0
  14. nds_mapviewer-2026.2.1.dev93/tests/test_tui.py +67 -0
  15. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/tests/test_viewer.py +179 -0
  16. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/.gitignore +0 -0
  17. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/pyproject.toml +0 -0
  18. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/setup.cfg +0 -0
  19. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/setup.py +0 -0
  20. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  21. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  22. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  23. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  24. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/__init__.py +0 -0
  25. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/browser.py +0 -0
  26. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/config.py +0 -0
  27. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/container_runtime.py +0 -0
  28. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/exceptions.py +0 -0
  29. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/src/ndslive/mapviewer/ui.py +0 -0
  30. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/tests/conftest.py +0 -0
  31. {nds_mapviewer-2026.2.1.dev91 → nds_mapviewer-2026.2.1.dev93}/tests/test_config.py +0 -0
@@ -9,12 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - `--debug` flag to enable container debug logging (sets `HTTP_LOG_LEVEL=debug`)
13
+ - `--env KEY=VALUE` (repeatable, short `-E`) to pass custom environment
14
+ variables into the container; overrides `--debug` on key collision
15
+ - `--network NAME` to attach the container to a specific network (e.g. `host`);
16
+ in `host` mode port publishing is skipped and the viewer is reached on the
17
+ container's internal port
18
+ - Run screen now shows a config line below the info banner with the source
19
+ (config file or directly-passed data-source path, e.g. a `*.ndslive`
20
+ filestore) and the mapped config mounted into the container
12
21
  - Default Docker image version pinned to package (`--image-version` defaults to
13
22
  matching calver instead of `"latest"`)
14
23
  - SCM-based versioning — package version derived from MapViewer git tags
15
24
 
16
25
  ### Fixed
17
26
 
27
+ - Clipboard copy in the Docker screen now uses the native OS clipboard
28
+ (`clip.exe` / `pbcopy` / `wl-copy`/`xclip`/`xsel`) instead of relying on the
29
+ OSC 52 terminal escape, which is unreliable on Windows; warns when a copy
30
+ cannot be confirmed
31
+ - Quitting the TUI no longer hangs — the log-follow stream is torn down by
32
+ terminating the process before closing pipes, and container shutdown is
33
+ bounded so the terminal is always returned to the user
18
34
  - Container readiness check uses log sentinel before HTTP polling to avoid
19
35
  startup crash (mapget#151)
20
36
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.2.1.dev91
3
+ Version: 2026.2.1.dev93
4
4
  Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
5
5
  Author-email: NDS Association <support@nds-association.org>
6
6
  License: Proprietary
@@ -78,10 +78,30 @@ mapviewer --demo # Try it with NDS Islands sample data
78
78
 
79
79
  mapviewer mywork # Load a saved config by name
80
80
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
81
+
82
+ mapviewer --demo --debug # Enable debug logging in the container
83
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
84
+ mapviewer --demo --network host # Run the container on the host network
81
85
  ```
82
86
 
83
87
  All commands accept `-e community|member` and `-i VERSION`.
84
88
 
89
+ ### Container environment variables
90
+
91
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
92
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
93
+ `-E`) to pass arbitrary environment variables into the container; explicit
94
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
95
+ appear in the Docker screen's "Copy Command" output.
96
+
97
+ ### Container network
98
+
99
+ `--network NAME` attaches the container to a specific network (e.g. a custom
100
+ bridge or `host`). With `--network host` the container binds the host's port
101
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
102
+ internal port (8089). Host networking behaves differently across platforms and
103
+ runtimes — it is intended as a connectivity workaround.
104
+
85
105
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
86
106
  automatically and goes straight to the RunScreen.
87
107
 
@@ -89,6 +109,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
89
109
  paths. The CLI translates them into the correct container mounts when it starts
90
110
  the container runtime.
91
111
 
112
+ The run screen shows, below the info banner, the source you passed (a config
113
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
114
+ the mapped config that was generated and mounted into the container.
115
+
92
116
  ## Editions
93
117
 
94
118
  | Edition | Formats | Access |
@@ -45,10 +45,30 @@ mapviewer --demo # Try it with NDS Islands sample data
45
45
 
46
46
  mapviewer mywork # Load a saved config by name
47
47
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
48
+
49
+ mapviewer --demo --debug # Enable debug logging in the container
50
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
51
+ mapviewer --demo --network host # Run the container on the host network
48
52
  ```
49
53
 
50
54
  All commands accept `-e community|member` and `-i VERSION`.
51
55
 
56
+ ### Container environment variables
57
+
58
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
59
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
60
+ `-E`) to pass arbitrary environment variables into the container; explicit
61
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
62
+ appear in the Docker screen's "Copy Command" output.
63
+
64
+ ### Container network
65
+
66
+ `--network NAME` attaches the container to a specific network (e.g. a custom
67
+ bridge or `host`). With `--network host` the container binds the host's port
68
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
69
+ internal port (8089). Host networking behaves differently across platforms and
70
+ runtimes — it is intended as a connectivity workaround.
71
+
52
72
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
53
73
  automatically and goes straight to the RunScreen.
54
74
 
@@ -56,6 +76,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
56
76
  paths. The CLI translates them into the correct container mounts when it starts
57
77
  the container runtime.
58
78
 
79
+ The run screen shows, below the info banner, the source you passed (a config
80
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
81
+ the mapped config that was generated and mounted into the container.
82
+
59
83
  ## Editions
60
84
 
61
85
  | Edition | Formats | Access |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.2.1.dev91
3
+ Version: 2026.2.1.dev93
4
4
  Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
5
5
  Author-email: NDS Association <support@nds-association.org>
6
6
  License: Proprietary
@@ -78,10 +78,30 @@ mapviewer --demo # Try it with NDS Islands sample data
78
78
 
79
79
  mapviewer mywork # Load a saved config by name
80
80
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
81
+
82
+ mapviewer --demo --debug # Enable debug logging in the container
83
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
84
+ mapviewer --demo --network host # Run the container on the host network
81
85
  ```
82
86
 
83
87
  All commands accept `-e community|member` and `-i VERSION`.
84
88
 
89
+ ### Container environment variables
90
+
91
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
92
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
93
+ `-E`) to pass arbitrary environment variables into the container; explicit
94
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
95
+ appear in the Docker screen's "Copy Command" output.
96
+
97
+ ### Container network
98
+
99
+ `--network NAME` attaches the container to a specific network (e.g. a custom
100
+ bridge or `host`). With `--network host` the container binds the host's port
101
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
102
+ internal port (8089). Host networking behaves differently across platforms and
103
+ runtimes — it is intended as a connectivity workaround.
104
+
85
105
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
86
106
  automatically and goes straight to the RunScreen.
87
107
 
@@ -89,6 +109,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
89
109
  paths. The CLI translates them into the correct container mounts when it starts
90
110
  the container runtime.
91
111
 
112
+ The run screen shows, below the info banner, the source you passed (a config
113
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
114
+ the mapped config that was generated and mounted into the container.
115
+
92
116
  ## Editions
93
117
 
94
118
  | Edition | Formats | Access |
@@ -12,6 +12,7 @@ src/nds_mapviewer.egg-info/top_level.txt
12
12
  src/ndslive/mapviewer/__init__.py
13
13
  src/ndslive/mapviewer/browser.py
14
14
  src/ndslive/mapviewer/cli.py
15
+ src/ndslive/mapviewer/clipboard.py
15
16
  src/ndslive/mapviewer/config.py
16
17
  src/ndslive/mapviewer/container_runtime.py
17
18
  src/ndslive/mapviewer/docker_client.py
@@ -21,6 +22,8 @@ src/ndslive/mapviewer/ui.py
21
22
  src/ndslive/mapviewer/viewer.py
22
23
  tests/conftest.py
23
24
  tests/test_cli.py
25
+ tests/test_clipboard.py
24
26
  tests/test_config.py
25
27
  tests/test_docker_client.py
28
+ tests/test_tui.py
26
29
  tests/test_viewer.py
@@ -73,6 +73,37 @@ def _get_pull_policy_default() -> str:
73
73
  return "prompt"
74
74
 
75
75
 
76
+ def _build_environment(args: argparse.Namespace) -> dict[str, str]:
77
+ """Build the container environment dict from ``--debug`` and ``--env``.
78
+
79
+ ``--debug`` is shorthand for ``HTTP_LOG_LEVEL=debug``. Explicit ``--env``
80
+ entries are applied afterwards, so they win on key collision.
81
+
82
+ Raises:
83
+ SystemExit: If an ``--env`` entry is not in ``KEY=VALUE`` form.
84
+ """
85
+ env: dict[str, str] = {}
86
+ if getattr(args, "debug", False):
87
+ env["HTTP_LOG_LEVEL"] = "debug"
88
+ for item in getattr(args, "env", None) or []:
89
+ if "=" not in item:
90
+ console.print(
91
+ f"[red]Error:[/red] Invalid --env value '{item}', "
92
+ "expected KEY=VALUE"
93
+ )
94
+ raise SystemExit(2)
95
+ key, _, value = item.partition("=")
96
+ key = key.strip()
97
+ if not key:
98
+ console.print(
99
+ f"[red]Error:[/red] Invalid --env value '{item}', "
100
+ "empty key"
101
+ )
102
+ raise SystemExit(2)
103
+ env[key] = value
104
+ return env
105
+
106
+
76
107
  # ---------------------------------------------------------------------------
77
108
  # Command handler
78
109
  # ---------------------------------------------------------------------------
@@ -97,6 +128,8 @@ def cmd_main(args: argparse.Namespace) -> int:
97
128
  bind_address = args.bind
98
129
  pull_policy = args.pull_policy
99
130
  source_args = args.sources
131
+ environment = _build_environment(args)
132
+ network = getattr(args, "network", None)
100
133
 
101
134
  # --demo → demo config
102
135
  if args.demo:
@@ -108,6 +141,8 @@ def cmd_main(args: argparse.Namespace) -> int:
108
141
  port=port,
109
142
  bind_address=bind_address,
110
143
  pull_policy=pull_policy,
144
+ environment=environment,
145
+ network=network,
111
146
  run_config=config,
112
147
  run_label="Demo (NDS Islands)",
113
148
  is_demo=True,
@@ -128,6 +163,8 @@ def cmd_main(args: argparse.Namespace) -> int:
128
163
  port=port,
129
164
  bind_address=bind_address,
130
165
  pull_policy=pull_policy,
166
+ environment=environment,
167
+ network=network,
131
168
  run_config=config,
132
169
  run_config_path=DEFAULT_CONFIG,
133
170
  run_label="~/.nds/config.yaml",
@@ -151,6 +188,8 @@ def cmd_main(args: argparse.Namespace) -> int:
151
188
  port=port,
152
189
  bind_address=bind_address,
153
190
  pull_policy=pull_policy,
191
+ environment=environment,
192
+ network=network,
154
193
  run_config=config,
155
194
  run_config_path=LEGACY_CONFIG,
156
195
  run_label="~/.nds/mapviewer.yaml",
@@ -167,6 +206,8 @@ def cmd_main(args: argparse.Namespace) -> int:
167
206
  port=port,
168
207
  bind_address=bind_address,
169
208
  pull_policy=pull_policy,
209
+ environment=environment,
210
+ network=network,
170
211
  )
171
212
  app.run()
172
213
  return 0
@@ -240,9 +281,12 @@ def cmd_main(args: argparse.Namespace) -> int:
240
281
  port=port,
241
282
  bind_address=bind_address,
242
283
  pull_policy=pull_policy,
284
+ environment=environment,
285
+ network=network,
243
286
  run_config=merged,
244
287
  run_config_path=source_config_path,
245
288
  run_label=source_desc,
289
+ run_source_paths=source_args,
246
290
  )
247
291
  app.run()
248
292
  return 0
@@ -323,6 +367,27 @@ Examples:
323
367
  "--bind", default="127.0.0.1",
324
368
  help="Host address to bind (default: 127.0.0.1, use 0.0.0.0 for network access)",
325
369
  )
370
+ parser.add_argument(
371
+ "--env", "-E",
372
+ action="append",
373
+ metavar="KEY=VALUE",
374
+ dest="env",
375
+ default=[],
376
+ help="Set a container environment variable (repeatable), "
377
+ "e.g. --env HTTP_LOG_LEVEL=debug",
378
+ )
379
+ parser.add_argument(
380
+ "--debug",
381
+ action="store_true",
382
+ help="Enable debug logging in the container (sets HTTP_LOG_LEVEL=debug)",
383
+ )
384
+ parser.add_argument(
385
+ "--network",
386
+ metavar="NAME",
387
+ default=None,
388
+ help="Container network to attach (e.g. 'host'). With 'host' the "
389
+ "MapViewer binds the host port directly and --port/--bind are ignored.",
390
+ )
326
391
 
327
392
  pull_group = parser.add_mutually_exclusive_group()
328
393
  pull_group.add_argument(
@@ -0,0 +1,81 @@
1
+ """Cross-platform clipboard helper.
2
+
3
+ Textual's ``App.copy_to_clipboard`` relies on the OSC 52 terminal escape
4
+ sequence, which many Windows terminals (conhost, some Windows Terminal
5
+ configs, PowerShell hosts) silently drop — so the copy buttons appear to do
6
+ nothing. This module copies via the native OS clipboard utility first and
7
+ only falls back to OSC 52 when no native tool is available.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+
16
+
17
+ def _run_copy(cmd: list[str], text: str) -> bool:
18
+ """Pipe *text* into *cmd* on stdin; return True on success."""
19
+ try:
20
+ proc = subprocess.run(
21
+ cmd,
22
+ input=text,
23
+ text=True,
24
+ capture_output=True,
25
+ timeout=5,
26
+ )
27
+ return proc.returncode == 0
28
+ except (OSError, subprocess.SubprocessError):
29
+ return False
30
+
31
+
32
+ def _native_copy(text: str) -> bool:
33
+ """Copy *text* using the platform-native clipboard tool.
34
+
35
+ Returns True if a tool was found and reported success.
36
+ """
37
+ system = platform.system()
38
+
39
+ if system == "Windows":
40
+ # clip.exe ships with every supported Windows version.
41
+ clip = shutil.which("clip") or shutil.which("clip.exe")
42
+ if clip:
43
+ return _run_copy([clip], text)
44
+ return False
45
+
46
+ if system == "Darwin":
47
+ if shutil.which("pbcopy"):
48
+ return _run_copy(["pbcopy"], text)
49
+ return False
50
+
51
+ # Linux / other Unix — try Wayland then X11 helpers.
52
+ if shutil.which("wl-copy"):
53
+ if _run_copy(["wl-copy"], text):
54
+ return True
55
+ if shutil.which("xclip"):
56
+ if _run_copy(["xclip", "-selection", "clipboard"], text):
57
+ return True
58
+ if shutil.which("xsel"):
59
+ if _run_copy(["xsel", "--clipboard", "--input"], text):
60
+ return True
61
+ return False
62
+
63
+
64
+ def copy_text(text: str, app=None) -> bool:
65
+ """Copy *text* to the clipboard, returning True on confirmed success.
66
+
67
+ Tries the native OS clipboard utility first (reliable on Windows where
68
+ OSC 52 is commonly unsupported). If no native tool is available and an
69
+ *app* is given, falls back to Textual's OSC 52 escape — which cannot be
70
+ confirmed, so this returns False to let callers warn the user.
71
+ """
72
+ if _native_copy(text):
73
+ return True
74
+
75
+ if app is not None:
76
+ try:
77
+ app.copy_to_clipboard(text)
78
+ except Exception:
79
+ pass
80
+
81
+ return False
@@ -36,6 +36,9 @@ IMAGE_NAME = "nds-mapviewer"
36
36
 
37
37
  KNOWN_TAGS = ["latest", "dev", "dev-rc"]
38
38
 
39
+ # Port the MapViewer HTTP server listens on inside the container.
40
+ CONTAINER_PORT = 8089
41
+
39
42
 
40
43
  # ---------------------------------------------------------------------------
41
44
  # Container info dataclass (replaces Docker SDK Container object)
@@ -543,6 +546,7 @@ def run_container(
543
546
  config_dir: str | Path | None = None,
544
547
  volumes: list[str] | None = None,
545
548
  environment: dict[str, str] | None = None,
549
+ network: str | None = None,
546
550
  name: str = CONTAINER_NAME,
547
551
  detach: bool = True,
548
552
  remove_existing: bool = False,
@@ -556,6 +560,9 @@ def run_container(
556
560
  config_dir: Config directory to mount at ``/config``.
557
561
  volumes: Additional volume mounts as ``"host:container:mode"`` strings.
558
562
  environment: Environment variables.
563
+ network: Container network to attach (e.g. ``"host"``). When ``"host"``,
564
+ port publishing is skipped because the container binds the host's
565
+ port directly.
559
566
  name: Container name.
560
567
  detach: Run in detached mode.
561
568
  remove_existing: Remove existing container if running.
@@ -571,12 +578,20 @@ def run_container(
571
578
  rt.run("stop", "-t", "2", name, timeout=30)
572
579
  rt.run("rm", name, check=False, timeout=10)
573
580
 
581
+ use_host_net = network == "host"
582
+
574
583
  # Build command
575
584
  cmd: list[str] = ["run"]
576
585
  if detach:
577
586
  cmd.append("-d")
578
587
  cmd.extend(["--name", name])
579
- cmd.extend(["-p", f"{bind_address}:{port}:8089"])
588
+ if network:
589
+ cmd.extend(["--network", network])
590
+ # Host networking ignores published ports — the container binds the
591
+ # host's port directly — so skip the -p mapping to avoid the runtime
592
+ # warning/error.
593
+ if not use_host_net:
594
+ cmd.extend(["-p", f"{bind_address}:{port}:{CONTAINER_PORT}"])
580
595
 
581
596
  # Volume mounts
582
597
  if config_dir:
@@ -592,8 +607,10 @@ def run_container(
592
607
  for key, value in environment.items():
593
608
  cmd.extend(["-e", f"{key}={value}"])
594
609
 
595
- # Extra hosts for host.docker.internal on Linux
596
- cmd.extend(rt.get_extra_host_args())
610
+ # Extra hosts for host.docker.internal on Linux. The host-gateway mapping
611
+ # conflicts with host networking, so skip it in that mode.
612
+ if not use_host_net:
613
+ cmd.extend(rt.get_extra_host_args())
597
614
 
598
615
  # Labels
599
616
  cmd.extend(["--label", "app=ndslive-mapviewer"])
@@ -673,6 +690,22 @@ class ContainerLogStream:
673
690
  def close(self, timeout: float = 3.0) -> None:
674
691
  if self._proc.poll() is not None:
675
692
  return
693
+ # Terminate the process FIRST so the OS closes the write end of the
694
+ # pipe and any thread blocked in `for line in proc.stdout` unblocks
695
+ # via EOF. Closing the pipe from another thread while it is being
696
+ # read is unsafe in CPython and can leave the reader (and thus app
697
+ # shutdown) hung indefinitely.
698
+ self._proc.terminate()
699
+ try:
700
+ self._proc.wait(timeout=timeout)
701
+ except subprocess.TimeoutExpired:
702
+ self._proc.kill()
703
+ try:
704
+ self._proc.wait(timeout=timeout)
705
+ except subprocess.TimeoutExpired:
706
+ pass
707
+ # Now that the process is gone, closing the pipes is safe and frees
708
+ # the file descriptors.
676
709
  for pipe in (getattr(self._proc, "stdout", None), getattr(self._proc, "stderr", None)):
677
710
  if pipe is None:
678
711
  continue
@@ -680,17 +713,6 @@ class ContainerLogStream:
680
713
  pipe.close()
681
714
  except Exception:
682
715
  pass
683
- self._proc.terminate()
684
- try:
685
- self._proc.wait(timeout=timeout)
686
- return
687
- except subprocess.TimeoutExpired:
688
- pass
689
- self._proc.kill()
690
- try:
691
- self._proc.wait(timeout=timeout)
692
- except subprocess.TimeoutExpired:
693
- pass
694
716
 
695
717
 
696
718
  _READY_SENTINEL = "NDS MapViewer is ready!"