Visitar URL original
server: a failed start() stops the Java server, so the process can ex… · humemai/arcadedb-embedded-python@c4fdf9b · GitHub
Skip to content

Commit c4fdf9b

Browse files
tae898claude
andauthored
server: a failed start() stops the Java server, so the process can exit (#98)
* server: a failed start() stops the Java server, so the process can exit When a later plugin cannot bind its port, the engine's start() throws after it has started non-daemon threads (the HTTP idempotency cleaner, the security and session timers, any plugin already listening). The engine leaves the cleanup to the caller, and only the Java stop() ends them. Our stop() returned at once because _started stayed False, and __del__ skipped it for the same reason, so the Python process could never exit: a CI job on darwin/arm64 ran 28 minutes past its tests on 2026-10-02 and timed out. start() now stops the Java server before raising. The regression test runs a start with the Postgres port taken in a subprocess and fails on the old wrapper (120 s timeout); the wire-protocol fixture now chooses its four ports with every socket held, so the OS cannot hand one port out twice. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lj5isEaoaGCeaZe5nNoWX4 * test: force the failed server start with an out-of-range port, not a busy one On macOS the engine's listener binds a port another socket already holds, so the start succeeded, the snippet never stopped the server, and the test timed out on every darwin/arm64 job of PR #98. A Postgres port of 70000 fails on every OS. The snippet also stops the server if the start ever succeeds, so the assertion reports what happened instead of a timeout. Checked: the test still fails on the old wrapper (120 s timeout) and passes with the fix in 2 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lj5isEaoaGCeaZe5nNoWX4 --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0339d72 commit c4fdf9b

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

‎bindings/python/src/arcadedb_embedded/server.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ def start(self):
125125
self._java_server.start()
126126
self._started = True
127127
except Exception as e:
128+
# The engine leaves cleanup to the caller after a failed start: what
129+
# started before the failure (the HTTP idempotency cleaner, the security
130+
# and session timers, any plugin already listening) keeps running as
131+
# non-daemon threads until stop() is called, and stop() below returns at
132+
# once while _started is False. Without this the process cannot exit.
133+
try:
134+
self._java_server.stop()
135+
except (
136+
Exception
137+
): # nosec B110 - best effort; the start error is what the caller needs
138+
pass
128139
raise ArcadeDBError(f"Failed to start server: {e}") from e
129140

130141
def stop(self):

‎bindings/python/tests/test_server.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,51 @@ def test_default_host_is_localhost(temp_server_root):
133133
root_password=TEST_PASSWORD,
134134
)
135135
assert server.get_studio_url().startswith("http://localhost:")
136+
137+
138+
@pytest.mark.server
139+
@pytest.mark.skipif(not has_server_support(), reason="Requires server support")
140+
def test_failed_server_start_does_not_hang_process_exit(tmp_path):
141+
"""A server.start() that fails part-way must not leave the process unable to exit.
142+
143+
Regression: when a plugin fails to start, the engine has already started
144+
non-daemon threads (the HTTP idempotency cleaner, the security and session
145+
timers) and only the Java stop() ends them. The wrapper's stop() returned
146+
early because the start never completed, so the process hung at exit (a CI
147+
job ran 28 minutes past its tests on 2026-10-02). The failure is forced with
148+
a Postgres port out of range, which fails on every OS; a busy port does not
149+
(macOS lets the engine's listener bind a port another socket holds).
150+
"""
151+
import subprocess # nosec B404 - fixed argv, no shell
152+
import sys
153+
154+
code = (
155+
"import socket\n"
156+
"from arcadedb_embedded import create_server\n"
157+
"def free():\n"
158+
" with socket.socket() as s:\n"
159+
" s.bind(('127.0.0.1', 0))\n"
160+
" return s.getsockname()[1]\n"
161+
"server = create_server(\n"
162+
f" root_path={str(tmp_path / 'databases')!r},\n"
163+
f" root_password={TEST_PASSWORD!r},\n"
164+
" config={\n"
165+
" 'http_port': free(),\n"
166+
" 'server_plugins': 'Redis:com.arcadedb.redis.RedisProtocolPlugin,'\n"
167+
" 'Postgres:com.arcadedb.postgres.PostgresProtocolPlugin',\n"
168+
" 'redis_port': free(),\n"
169+
" 'postgres_port': 70000,\n"
170+
" },\n"
171+
")\n"
172+
"try:\n"
173+
" server.start()\n"
174+
" print('started')\n"
175+
" server.stop()\n"
176+
"except Exception:\n"
177+
" print('start failed')\n"
178+
)
179+
proc = subprocess.run( # nosec B603 - interpreter + inline snippet, no shell
180+
[sys.executable, "-c", code], capture_output=True, text=True, timeout=120
181+
)
182+
assert "start failed" in proc.stdout, proc.stdout + proc.stderr
183+
assert proc.returncode == 0

‎bindings/python/tests/test_server_wire_protocols.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ def _free_port():
4040
return s.getsockname()[1]
4141

4242

43+
def _free_ports(*names):
44+
"""Distinct free ports, one per name. Each socket stays bound until all are
45+
chosen, so the OS cannot hand the same port out twice (one port per call,
46+
closed before the next, let the Postgres plugin find its port taken on a
47+
macOS runner on 2026-10-02)."""
48+
socks = []
49+
try:
50+
for _ in names:
51+
s = socket.socket()
52+
s.bind(("127.0.0.1", 0))
53+
socks.append(s)
54+
return {n: s.getsockname()[1] for n, s in zip(names, socks)}
55+
finally:
56+
for s in socks:
57+
s.close()
58+
59+
4360
def _wait(port, timeout=60.0):
4461
"""Wait for a listener, then give the plugin a moment to finish binding."""
4562
deadline = time.time() + timeout
@@ -57,7 +74,7 @@ def wire_server(tmp_path):
5774
"""A server with all three bundled wire plugins enabled."""
5875
from arcadedb_embedded import create_server
5976

60-
ports = {k: _free_port() for k in ("http", "postgres", "redis", "bolt")}
77+
ports = _free_ports("http", "postgres", "redis", "bolt")
6178
server = create_server(
6279
root_path=str(tmp_path / "databases"),
6380
root_password=ROOT_PASSWORD,

0 commit comments

Comments
 (0)