Repository navigation
fix(cleanup): remove the temp directory of a scan that stops early - #666
Conversation
resolve_input records the directory it materializes as `temp_dir_for_cleanup`, and callers remove it with `cleanup_result` once the graph returns. A scan interrupted with Ctrl+C, a cancelled MCP tool call, or an exception in a later node returns no result, so the clone or extraction stayed in the temp dir. Record the directory from the node output with a `TempDirTracker` callback and remove it when the CLI's graph run (invoke and the progress stream) or the MCP scan ends without a result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @kevin9327, thank you for closing the temp-directory leak on interrupted and failed scans, and for the end-to-end tests that create real skillspector_* directories!
Value and readiness: The leak is real. A Git URL or archive scan that raises or is cancelled after resolve_input leaves up to INGEST_MAX_BYTES in the temp dir each time, and the MCP server is a long-lived process where these add up. Recording the directory with a callback and removing it on BaseException works for both graph.invoke and graph.stream in the CLI and for graph.ainvoke in MCP. The cleanup itself is safe. One CLI early-exit path is still not covered: in a --transitive scan, the root's temp directory still leaks if the scan stops after the root graph returns. That path is in the PR's stated scope and needs a small fix before this is ready.
Material findings
- [Blocker]
src/skillspector/cli.py:2409(_scan_skill→_scan_transitive): the tracker only guards the graph run. Once the root graph returns, itstemp_dir_for_cleanuplives only in the localresult.scan()removes it only when_scan_skillreturns (finally: if result is not None: cleanup_result(result),cli.py:762-764). Failing input:skillspector scan <git-url-or-skill.zip> --transitivewhoseSKILL.mdreferences an external skill. Press Ctrl+C while a transitive child is scanning, which can be the longest part of the run. The child's tracker removes the child's directory, butKeyboardInterruptis not caught byexcept Exceptionatcli.py:2189. It propagates out of_scan_transitiveand_scan_skillbefore anything returns, so the root clone or extraction stays in/tmp. The same happens ifreport(...)orfinalize_ledger(...)raises in the merge step (cli.py:1842,2313,2317). This is the "Ctrl+C during a long LLM scan" case from the PR body. Expected fix: in_scan_skill, remove the root result's directory when the transitive phase raises, for example:On success the returnedtry: return _scan_transitive(initial_result=result, ...) except BaseException: cleanup_result(result) raise
report_resultcarries the sametemp_dir_for_cleanup, so nothing is removed twice. Please add a test that interrupts a transitive child scan of a zipped root and asserts the rootskillspector_*directory is gone. - [Non-blocking]
src/skillspector/cleanup.py:65-70:TempDirTracker.on_chain_endacceptstemp_dir_for_cleanupfrom any chain output in the run, and that value later goes tormtree. Today it is safe. Onlyresolve_inputemits that key, and its value comes fromtempfile.mkdtemp(prefix="skillspector_"). LLM runnables inside nodes are invoked with an explicitcallbacks=[collector](llm_utils.py:484/502), which replaces the inherited callbacks, and they return Pydantic objects, not mappings. Still, this callback is now the only guard between a mapping key and recursive deletion. A future node, subgraph or dict-returning chain would silently become a deletion source. Consider recording only from theresolve_inputnode run (for example, remember itsrun_idfromon_chain_startviametadata["langgraph_node"]). Or require the path to be an existing, non-symlink directory whose name starts withskillspector_. - [Non-blocking]
tests/unit/test_cleanup.py: the tracker's own contract has no unit tests. Small cases would pin it: a latertemp_dir_for_cleanup: None(or a non-string) does not erase a recorded path;remove()on an already-removed directory is a no-op;removing_on_error()re-raises the original exception unchanged. Therecording_mkdtemphelper is duplicated intest_cli.pyandtest_mcp_server.pyand could live in a shared fixture.
PIC tradeoffs: None identified. On merge order: #583 also changes the same finally blocks in cli.py / mcp_server.py to call cleanup_result(None) on failure. Whichever PR lands second needs a rebase that keeps both behaviors.
Verification and gaps: I traced every graph entry point at the PR head. _run_graph_scan (all CLI single-skill, recursive-child and transitive-child runs, both the invoke and stream paths) and mcp_server.run_scan are covered. skillspector baseline (cli.py:3219) is out of scope as the PR body says. Cleanup safety: the tracked value only ever comes from InputHandler._get_temp_dir() (mkdtemp(prefix="skillspector_")). For local inputs resolve_input emits None, which the tracker ignores, so a user directory is never recorded. remove_temp_tree uses shutil.rmtree(onexc=_retry_writable). rmtree refuses a symlinked root and does not follow links inside the tree, and _retry_writable never chmods through a link. Removing an already-deleted directory reaches onexc from os.lstat and is ignored, so double removal is a no-op. On success nothing changes: removing_on_error only acts on an exception, and the caller's cleanup_result runs as before. Exceptions are re-raised unchanged, so exit codes do not change. Explicit callbacks in the trace config does not disable env-configured LangSmith tracing. One residual risk I could not verify: after a cancellation, sync node threads may still be reading the directory when it is removed. On Windows that removal is best-effort and could leave a partial directory. All 6 CI checks are green and the branch is mergeable. Per policy I did not run the tests locally.
Decision: Changes Requested (reviewed head e99aeb2cdf22c8dafde3307f5ef7bd2386758866)
The root graph's tracker only guards the graph run. Once it returns, the root's temp_dir_for_cleanup lives only in the local result, and scan() removes it only when _scan_skill returns. A Ctrl+C during a transitive child scan, or an error in the merge step, propagated out of _scan_transitive and left the root clone or extraction behind. Remove it in _scan_skill when the transitive phase raises; on success the merged result still carries the same path for the caller. Also accept a temp_dir_for_cleanup in TempDirTracker only when it names an existing, non-symlink directory with the skillspector_ prefix, since the value is later removed recursively, and add unit tests for the tracker's contract. Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com>
|
@rng1995 Thanks for tracing every entry point. Addressed in c1a25bf:
I left the shared |
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @kevin9327, thank you for the quick, well-tested follow-up and for hardening the tracker right where it feeds rmtree!
Value and readiness: The PR now removes the temp directory of every CLI and MCP scan that stops early, including the --transitive root that still leaked last round. A scan that finishes is cleaned up exactly as before. Exceptions still propagate unchanged, so exit codes do not move. The tracker now records only an existing, non-symlink skillspector_* directory. I found no required change, and the PR is ready for final review. The branch was not rebased, but it merges cleanly with current main and the functions it edits are unchanged there. The last CI run predates #702, #436 and #423, so a fresh run on current main before merge is advisable (for example via update-branch).
Previous findings:
-
[Blocker] The transitive root's temp dir leaked when a child scan was interrupted or the merge step raised (
_scan_skill→_scan_transitive): Resolved.src/skillspector/cli.py:2409-2431wraps_scan_transitiveintry/except BaseException, callscleanup_result(result)and re-raises.- On success, both return paths of
_scan_transitivestill copy the root'stemp_dir_for_cleanupinto the returned result (also on currentmain), so nothing is removed twice. test_transitive_scan_stopped_in_a_child_removes_the_root_temp_dirscans a zipped root with the real graph. It stops the first child withKeyboardInterrupt, and the merge step withRuntimeError. It asserts that a child was reached and that the rootskillspector_*directory is gone. By tracing, both cases leave the directory behind without the fix.
-
[Non-blocking] The tracker accepted
temp_dir_for_cleanupfrom any chain output: Resolved._is_scan_temp_dir(src/skillspector/cleanup.py:53-63) accepts only a non-empty string that names an existing directory whose name starts withskillspector_and that is not a symlink or junction. A later invalid value never replaces a path already recorded. This is the second option I offered. It also works on macOS, where the temp root sits under the/varsymlink, because only the last path component is checked. -
[Non-blocking] The tracker contract had no unit tests: Resolved.
tests/unit/test_cleanup.pynow covers:- recording a scan temp dir;
- later
None,""or non-string values; - paths that are not scan temp dirs (a user directory, a missing directory, a file);
- a symlink with the
skillspector_prefix; - calling
remove()twice; - re-raising the original
KeyboardInterruptorRuntimeError; - leaving the directory in place on success.
The shared
recording_mkdtemphelper was not extracted and now exists in three copies. That suggestion was optional and is fine to leave.
Material findings
- [Non-blocking]
skillspector baseline(src/skillspector/cli.py:3227) still callsgraph.invoke(state)without a tracker, as the PR body says. Since then #643 has been closed, so only #657 still touches that function. Please open a follow-up issue so this last entry point is tracked.
PIC tradeoffs: None identified. Merge order:
- #583 has not been updated since 2026-09-28. It already conflicts with
maininartifacts.pyandcleanup.py, and with this PR it also conflicts inmcp_server.py. Whichever lands second must keep both behaviours. - #736 and #744 merge cleanly with this PR.
Verification and gaps:
- Diff and merge: I read the full diff and the new commit
c1a25bf. The branch is still based on8831219, 236 commits behindmain, andgit merge-treeagainst currentmainis clean. - Merged tree: since the PR's base,
mainchanged_scan_skill,_run_graph_scanand_run_graph_scan_for_sourceonly through #635'sexclude_patternsplumbing, and the merge keeps it alongside the tracker.graphincli.pyis now #436's lazy proxy, which still forwardsinvokeandstream. In MCP,run_scanonly gainedrestore_package_graph_export(). The newmeta_review_requiredlines in_scan_transitivedo not touch the exception path the new test uses._format_jsonand_ensure_required_failure_events, which the tests patch, still exist. - Lint:
ruff checkandruff format --checkpass on the six touched files in the merged tree. - Entry points:
_run_graph_scancovers single, recursive-child and transitive-child CLI runs, andmcp_server.run_scanis covered.baselineis the only uncovered entry point. - CI: all six checks pass on
c1a25bf. That run tested the merge withfd211ac(mainat 2026-10-04 09:53Z). #702, #436, #423, #621 and #642 landed after it. - Residual risk from last round: after an MCP cancellation, sync node threads may still be reading the directory while it is removed.
- Contributor tests were not run locally, per policy.
Decision: Approved (reviewed head c1a25bfd08c83ab080cecea751f3dc6c3d38995a)
Problem
When a scan of a Git URL, file URL,
.zipor single file is stopped after its input has been materialized, the temp directory is left in%TEMP%//tmp. Stopping a scan can mean Ctrl+C during a long LLM scan, a cancelled MCP tool call, or an exception in a later node. A clone can be up toINGEST_MAX_BYTES(100 MiB), and each interrupted scan leaves its own directory.Reproduction on
main(8831219), Windows 11. I made the report step raise to stand in for the interrupt, then listed theskillspector_*directories created in the temp dir during the scan:With this change both lines print
leftover temp dirs: [].Cause
resolve_inputcreates the temp directory and reports it in the graph state astemp_dir_for_cleanup. The callers remove it withcleanup_result(result):scan()incli.py(through_run_graph_scan) andrun_scan()inmcp_server.py. That only works if the graph returns a result. Whengraph.invoke/graph.stream/graph.ainvokeraises,resultis stillNoneand nothing removes the directory.resolve_inputitself already cleans up when resolution fails, including onBaseException, with the comment "an interrupt or a cancellation that lands after the temp directory was allocated must clean up too". This PR covers the same case for the rest of the run.Fix
skillspector.cleanup.TempDirTracker, a smallBaseCallbackHandler. Itson_chain_endrecordstemp_dir_for_cleanupfrom a node output as soon asresolve_inputfinishes. It also hasremove()andremoving_on_error(), a context manager that removes the recorded directory on anyBaseExceptionand re-raises. The codebase already collects inference usage through aBaseCallbackHandlerin the same way.cli._run_graph_scanpasses the tracker in the run'scallbacks. It wrapsgraph.invokeinremoving_on_error(), and adds the same context manager to thewithblock of the progress-streaming path. Every CLI graph run goes through this function: single-skill, recursive children and transitive children.mcp_server.run_scanpasses the tracker tograph.ainvoke. In the existingfinally, it callstracker.remove()when no result came back.A scan that finishes is cleaned up exactly as before, through
cleanup_result(result). The exception is always re-raised unchanged, so exit codes and error messages do not change.Out of scope:
skillspector baselinecallsgraph.invoke(state)directly and has the same gap. I left it alone because #643 and #657 are changing that function right now; it can take the same two lines once they land.I used a callback rather than switching the non-interactive path to
graph.stream. Many tests stubgraph.invoke/graph.ainvokedirectly, and a callback works with those stubs unchanged.Tests
End-to-end with the real graph. A zipped skill is scanned, its
skillspector_*temp directories are created undertmp_path, andnodes.report._format_jsonraises after input resolution:tests/unit/test_cli.py::test_scan_that_stops_early_removes_its_temp_dir:KeyboardInterruptandRuntimeError, each with and without the progress stream (4 cases).tests/unit/test_mcp_server.py::test_run_scan_that_fails_removes_its_temp_dir: aRuntimeErrorduring the scan.tests/unit/test_mcp_server.py::test_run_scan_that_is_cancelled_removes_its_temp_dir: the report step blocks, the test cancels therun_scantask and expectsCancelledError.All six fail on
mainwithassert not True(the directory still exists) and pass with this change.Suite
Windows 11, Python 3.12:
tests/unit/test_cli.py tests/unit/test_mcp_server.py tests/unit/test_cleanup.py: 1 failed, 279 passed, 5 skipped. The failure istest_discovered_files_tree_renders_untrusted_names_literally, which also fails onmainon Windows.pytest -m "not integration and not provider" -p no:randomly tests/unit tests/opencode tests/nodes tests/test_batch_scan_reports.py tests/test_multi_skill.py tests/test_structured_skill.py tests/test_models.py: 16 failed, 7882 passed, 43 skipped, 4 xfailed, 10 errors. These are the Windows-only failures and errors thatmainhas too:/dev/nullas the AWS config file, theghandcompare_scan_accuracyharnesses, a mockedos.scandirthat Windowsrmtreewalks differently, a CRLF byte count, the Rich rendering test above, and test IDs over the Windows environment-variable length limit.ruff check src/ tests/: All checks passed.ruff format --check src/ tests/: all files already formatted.mypyoncleanup.py,cli.pyandmcp_server.pyreports the same errors in those files before and after.🤖 Generated with Claude Code