Repository navigation
Stop leftover Lean LSP processes by an environment token - #360
Conversation
#322 recorded the process groups of lean --server's descendants before EOF and before the shutdown request, then terminated the recorded groups that still held a recorded process. That needs a live server: when lean --server dies before close starts, its file workers are reparented, nothing ties them to the session, and they and their `lake setup-file` and `lean ... -o` keep running, the case #251 saw leak every time. start() now puts a random token in the server's environment. Lean v4.32.2 starts file workers and `lake setup-file` with the default inheritEnv, and `lake serve` sets only a fixed list of Elan, Lake and Lean variables and search paths, so every process the server starts carries the token, orphaned or not. Where the worker groups were stopped, after the server's own group is retired and before its leader is reaped, the session sends SIGTERM to every process whose environment carries the token, then SIGKILL to those a new scan still finds, and fails the attempt while a final scan finds any, so close and abort retry without signalling the retired group again. Each signal goes through the psutil handle created before that process's environment was read, which checks the PID's creation time first, so a reused PID is never signalled. A process whose environment cannot be read is never signalled and does not hold up the close: setuid programs the user runs, such as login, are unreadable at all times, while Lean and Lake run as the user. A process table that cannot be read, or any other error reading an environment, fails the attempt. The scan refuses a missing token, which would match every process without the variable. It lives beside the LSP code rather than reusing the skeleton's: nothing in servers/ imports autoform_cli, and the runtime's code fingerprint covers only servers/. The recording helpers go. Tests cover the scan and its failures, the retry and the leader-last order, the re-scan before SIGKILL, workers orphaned before close with a build that ignores SIGTERM, which fails against main, and a real-Lean variant that kills lean --server first. Follow-up to #251 and #322.
Review at 3a5c1d9Verdict: Ready to merge. The token sweep replaces #322's recording at the same point in retirement, keeps the leader-last order and the retry, and stops workers orphaned before close, which main leaks. The review findings are applied: the retry test now fails without the final scan, the scan refuses a missing token, and the README says that a process started without the token is not found and that daemons a tagged process started are stopped. Checked
Landing
This PR came from the same PR swarm that reviewed it, so this is the author's review, not an independent one. Ruff passes. Load on the review machine was too high for pytest, so a script called 25 cases from Posted by PR swarm: PR Swarm Lead |
Follow-up to #251 and #322.
Problem
#322 records the process groups of
lean --server's descendants before EOF and before the shutdown request, then terminates the recorded groups that still hold a recorded process. Recording needs a live server. Whenlean --serverdies before close starts, its file workers are reparented and nothing ties them to the session, so the workers, theirlake setup-fileand itslean ... -okeep running afterclose()returns. #251 saw that case leak every time, and #322 left it uncovered. #322's review recommended tagging the session's processes with an environment token instead, and the maintainer chose that mechanism.Change
servers/lsp/server.py:start()puts a random token (secrets.token_hex(16)) in the server's environment as_AUTOFORM_LSP_SESSION_TOKEN. The runtime's own environment is untouched, so its REPL and diagnostics children never carry it. Lean v4.32.2 starts each file worker andlake setup-filewith the defaultinheritEnv := true(SpawnArgsinsrc/Init/System/IO.lean; nothing in Lean or Lake sets it to false), andlake servestartslean --serverwithenv := extraEnv, which sets only a fixed list of Elan, Lake and Lean variables and search paths. So every process the server starts carries the token, in any process group, orphaned or not._stop_worker_groupsran, after the server's own group is retired and before its leader is reaped,_stop_processes_with_tokensends SIGTERM to every live process whose environment carries the token, then SIGKILL to those a new scan still finds, with the same waits as the group termination (0.5 s, then 1 s), and raises while a final scan finds any.close()andabort()retry it before the leader is reaped, as they retried a failed worker group, and the retry does not signal the retired group again.AccessDenied), that process is never signalled, since nothing shows that it belongs to the session, and it does not hold up the close either. Setuid programs a user runs, such asloginon macOS, are unreadable to that user for as long as they run, so waiting for unreadable processes would make every close retry forever; Lean and Lake run as the user, so their environments can be read. If the process table cannot be read, or reading an environment fails in any other way, the attempt fails and close or abort retries. Exited processes and zombies count as gone. A process started without the variable is not found (unsetting it in a running process does not hide that process), and the README says so._descendant_process_groups,_still_in_process_group,_record_worker_groups,_stop_worker_groups,_worker_groups, and the recording call before the shutdown request.Helper: a minimal scan beside the LSP code, rather than
autoform_cli/skeleton.py's_remember_tagged_processesor a shared module. Nothing inservers/importsautoform_cli. The wheel ships both packages, so the import would resolve, but the runtime's code fingerprint (_RUNTIME_SOURCESinservers/lean_client.py) covers onlyservers/files, so a cleanup helper inautoform_clicould change without replacing a live runtime. The skeleton helper also passes over processes it cannot read, raisesSkeletonError, and would load the skeleton module and its imports into the runtime. A shared module would mean reworking the skeleton's cleanup, which this change does not need. The variable is distinct from the skeleton's_AUTOFORM_PROCESS_TOKEN.servers/README.mdgets a paragraph, after the one onstop, on what closing a project's Lean server stops, including any daemon a tagged process started, and on what the sweep cannot see.Tests
tests/test_lsp_protocol.py:test_close_stops_workers_orphaned_before_close: a fake server under a wrapper that has already exited starts a worker in its own session, and the worker starts a build that ignores SIGTERM, so only the SIGKILL round can stop it. The test kills the server, checks that both are still running, and closes the session. Against main'sservers/lsp/server.pyit fails with the worker still running.test_real_lean_close_stops_workers_orphaned_before_close: real Lean opens a file whose import blocks in#eval IO.sleep; the test killslean --server, checks that theSlow.leanbuild is still running, then retires and closes the session. Nothing it recorded may outlive the close. CI's real-Lean job selects it with-k real_lean.test_close_stops_a_build_the_watchdog_left_running(abort and graceful) andtest_close_stops_a_build_left_running_after_the_wrapper_crashedare Stop worker groups that Lean's watchdog leaves running at LSP close #322's skipping-watchdog tests, now with the session's token in the fake processes' environment.test_abort_lets_the_watchdog_stop_workers_outside_the_groupandtest_abort_after_the_wrapper_crashed_still_reaches_the_watchdogrun with no token, so only the watchdog can stop the worker, as when Stop worker groups that Lean's watchdog leaves running at LSP close #322 turned the recording off in them.test_abort_retries_the_token_sweep_before_reaping_the_leaderreplaces the worker-group retry test. It records every group termination, scan, signal and reap in order: the scan after SIGKILL still finds a process and fails the attempt, the retry does not signal the retired group, and the leader is reaped only after a sweep finds nothing.test_token_sweep_signals_only_what_its_latest_scan_found;test_token_scan_finds_only_the_processes_carrying_the_token, on real processes carrying this token, another session's token, and none;test_token_scan_leaves_out_processes_it_cannot_read(unreadable, zombie, exited, and the runtime's own process);test_token_scan_fails_when_the_process_table_cannot_be_read; andtest_token_scan_fails_on_any_other_environment_error(anOSErrorand aSystemError).test_start_gives_each_server_its_own_tokenandtest_token_scan_refuses_a_missing_token(Noneand the empty string).What ran:
uvx ruff@0.15.17 check autoform_cli servers testspasses. Load on the machine was too high for pytest, so a script called 25 cases fromtests/test_lsp_protocol.pydirectly (every new and changed unit test, each parametrized case, and the lifecycle tests around them) andtest_partial_lsp_startup_transfers_cleanup_without_masking_the_errorfromtests/test_shared_lean_runtime.py; all pass. The same script checked that a child which unsets the variable after it starts is still found and that one started without it is not. Mutants ofservers/lsp/server.pyfail the tests written for them: without the final scan, the retry test fails; with every environment error passed over, both cases of the error test fail; without the missing-token check, both of its cases fail. Against main'sservers/lsp/server.py, the orphan test fails and the two skipping-watchdog tests it was compared with (the crashed-wrapper one and the abort case of the other) pass. The full suite and the real-Lean tests run only in CI.Landing
_read_message's first branch, adds a test aftertest_json_rpc_error_response_raises_protocol_error, and rewrites theLEAN_LSP_TIMEOUTparagraph ofservers/README.md. This PR touches none of those lines, andgit merge-treemerges the two cleanly.servers/README.md; it also merges cleanly with this one.git merge-treeof each against this branch merged into main matches the same against main alone.