Skip to content

Commit 68866c7

Browse files
committed
Add stop-wm command to shut down the shared workspace server
The CLI could start a shared workspace server (start-wm-server --detach --keep-alive) and recover it (restart-wm and friends), but had no counterpart to stop it. A keep-alive server outlives its clients by design, so the only way to end one was to kill the process by hand. - Add `stop-wm` alongside the other recovery commands. Like them it requires --shared-server: stopping a private per-command server would report success while leaving the workspace an editor or agent uses untouched. - Refuse with RecoveryFailed when no server is running, naming how to start one, rather than reporting a stop that did nothing. - Send server/shutdown through the new ApiClient.shutdown and tolerate the server closing the socket before the response is read, as replace_running_server already does; the wait that follows verifies the stop. - Add wm_lifecycle.wait_until_stopped, which polls running_port in a thread (its synchronous probe blocks for the full timeout on a filtered port) and fails with a timeout naming the port. - Document the command in docs/cli.md and update "all four" to "all five".
1 parent f7fa6cd commit 68866c7

6 files changed

Lines changed: 73 additions & 2 deletions

File tree

‎docs/cli.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,9 @@ narrowest one that covers what you edited, because nothing detects staleness for
272272
| `restart-runner` | any code a runner imported, and stuck or crashed runners | configuration |
273273
| `reload-config` | `pyproject.toml`, `finecode.toml` and presets — and, since it replaces runners, all code too | FineCode's own source |
274274
| `restart-wm` | everything, including FineCode's own source | — |
275+
| `stop-wm` | nothing — it stops the workspace | restart — pair with `start-wm-server --detach --keep-alive` |
275276

276-
**All four require `--shared-server`.** Without it each command would start a
277+
**All five require `--shared-server`.** Without it each command would start a
277278
workspace server of its own, recover that, and exit — leaving the workspace an editor
278279
or agent is actually using untouched while reporting success. They exit with status 1
279280
and name the mode as the reason.
@@ -299,6 +300,9 @@ python -m finecode reload-config --shared-server --all-projects --rescan
299300

300301
# after editing FineCode itself
301302
python -m finecode restart-wm --shared-server
303+
304+
# stop the shared workspace server
305+
python -m finecode stop-wm --shared-server
302306
```
303307

304308
## Dev environment detection
@@ -441,6 +445,9 @@ whatever owns that lifetime — a container start script, a systemd unit, a supe
441445
python -m finecode start-wm-server --detach --keep-alive
442446
```
443447

448+
Its counterpart is `python -m finecode stop-wm --shared-server`, which shuts that
449+
server back down.
450+
444451
Keep-alive has no environment variable and is never inherited: it is passed
445452
explicitly by whoever starts the server, so the dedicated per-command servers cannot
446453
pick it up and stop stopping. The flip side is that a server started *lazily* by a

‎src/finecode/cli.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
restart_runner,
1010
restart_wm,
1111
run,
12+
stop_wm,
1213
version,
1314
)
1415
from finecode.lsp_server.cli import start_lsp
@@ -31,6 +32,7 @@ def cli(): ...
3132
cli.add_command(restart_runner)
3233
cli.add_command(reload_config)
3334
cli.add_command(restart_wm)
35+
cli.add_command(stop_wm)
3436
cli.add_command(version)
3537

3638

‎src/finecode/cli_app/cli.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,19 @@ def restart_wm(log_level: str, shared_server: bool):
10771077
)
10781078

10791079

1080+
@click.command()
1081+
@_LOG_LEVEL_OPTION
1082+
@click.option("--shared-server", "shared_server", is_flag=True, default=False)
1083+
def stop_wm(log_level: str, shared_server: bool):
1084+
"""Stop the shared workspace server."""
1085+
from finecode.cli_app.commands import recover_cmd
1086+
1087+
_run_recovery(
1088+
lambda cwd: recover_cmd.stop_wm(workdir_path=cwd, own_server=not shared_server),
1089+
log_level,
1090+
)
1091+
1092+
10801093
@click.command()
10811094
@_LOG_LEVEL_OPTION
10821095
@click.option("--shared-server", "shared_server", is_flag=True, default=False)

‎src/finecode/cli_app/commands/recover_cmd.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# docs: docs/cli.md
2-
"""Recovery commands: make a running workspace pick up what changed on disk.
2+
"""Recovery and stop commands: make a running workspace pick up what changed on disk,
3+
or stop the workspace.
34
45
Every one of them requires ``--shared-server``. Recovery only means something to
56
a workspace someone else is already using: starting a private server, recovering
@@ -176,3 +177,34 @@ async def restart_wm(workdir_path: pathlib.Path, own_server: bool = True) -> Non
176177
"otherClientsDisconnected": other_clients,
177178
}
178179
)
180+
181+
182+
async def stop_wm(
183+
workdir_path: pathlib.Path, own_server: bool = True, timeout: float = 30
184+
) -> None:
185+
require_shared_server(own_server, "stop-wm")
186+
previous_port = await asyncio.to_thread(wm_lifecycle.running_port)
187+
if previous_port is None:
188+
raise RecoveryFailed(
189+
"No FineCode workspace server is running, so there is nothing to stop. "
190+
"Start one — an editor with the FineCode LSP, the MCP server, or "
191+
"'python -m finecode start-wm-server' — and run this again."
192+
)
193+
client = await _connected_client(workdir_path)
194+
try:
195+
try:
196+
await client.shutdown()
197+
except (ConnectionError, OSError) as exception:
198+
# Same as replace_running_server (wm_lifecycle.py):
199+
# the server may close the socket before its response is read;
200+
# it is stopping either way, which the wait below verifies.
201+
logger.debug(f"WM server closed the connection on shutdown: {exception}")
202+
finally:
203+
await client.close()
204+
try:
205+
await wm_lifecycle.wait_until_stopped(timeout=timeout)
206+
except TimeoutError as exception:
207+
raise RecoveryFailed(
208+
f"FineCode WM server on port {previous_port} did not stop within {timeout}s"
209+
) from exception
210+
_report({"stopped": True, "port": previous_port})

‎src/finecode/wm_client.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ async def unsubscribe_logs(self) -> None:
379379
"""Unsubscribe this connection from WM diagnostic logs."""
380380
await self.request("server/unsubscribeLogs", {})
381381

382+
async def shutdown(self) -> dict:
383+
"""Ask the WM server to shut down."""
384+
return await self.request("server/shutdown")
385+
382386
# -- Workspace methods --------------------------------------------------
383387

384388
async def list_projects(self) -> list[dict]:

‎src/finecode/wm_server/wm_lifecycle.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ async def wait_until_ready(timeout: float = 30) -> int:
153153
)
154154

155155

156+
async def wait_until_stopped(timeout: float = 30) -> None:
157+
"""Wait until no WM server is listening. Returns None."""
158+
deadline = asyncio.get_event_loop().time() + timeout
159+
while asyncio.get_event_loop().time() < deadline:
160+
# In a thread: `running_port` probes with a synchronous connect that
161+
# takes its full timeout when the port is filtered rather than refused.
162+
port = await asyncio.to_thread(running_port)
163+
if port is None:
164+
return
165+
await asyncio.sleep(STARTUP_READY_POLL_INTERVAL_SECONDS)
166+
raise TimeoutError(f"FineCode WM server did not stop within {timeout}s")
167+
168+
156169
def start_own_server(
157170
workdir: pathlib.Path,
158171
log_level: str = "INFO",

0 commit comments

Comments
 (0)