From b93070a709d9a087aeb5bc57586dff4bc707fb8e Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Wed, 3 Dec 2025 20:04:01 +0900 Subject: [PATCH] profile: Add heap snapshot profiling support Add a mechanism for capturing heap snapshots of the JETLS server process to analyze memory footprint and detect potential memory leaks. Creating a `.JETLSProfile` file in the workspace root triggers a heap snapshot. The server saves it as `JETLS_YYYYMMDD_HHMMSS.heapsnapshot`, shows a notification, and automatically deletes the trigger file via LSP `workspace/applyEdit`. The implementation uses `Profile.take_heap_snapshot` with streaming mode and includes progress indicator support. The generated snapshots can be analyzed using Chrome DevTools. Written with Claude --- .gitignore | 2 + CHANGELOG.md | 7 ++++ DEVELOPMENT.md | 62 ++++++++++++++++++++++++++++ Project.toml | 2 + src/JETLS.jl | 5 +++ src/apply-edit.jl | 20 +++++++++ src/did-change-watched-files.jl | 23 ++++++++--- src/initialize.jl | 2 +- src/profile.jl | 72 +++++++++++++++++++++++++++++++++ 9 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 src/profile.jl diff --git a/.gitignore b/.gitignore index 198fdcffb..e6a65fffa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ Manifest-*.toml !.JETLSConfig.toml *.vsix docs/build/ +*.heapsnapshot +.JETLSProfile # The following line is negated on release branches /vendor diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ff77650..8c8b49d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Commit: [`HEAD`](https://github.com/aviatesk/JETLS.jl/commit/HEAD) - Diff: [`aae52f5...HEAD`](https://github.com/aviatesk/JETLS.jl/compare/aae52f5...HEAD) +### Internal + +- Added heap snapshot profiling support. Create a `.JETLSProfile` file in the + workspace root to trigger a heap snapshot. The snapshot is saved as + `JETLS_YYYYMMDD_HHMMSS.heapsnapshot` and can be analyzed using Chrome DevTools. + See [DEVELOPMENT.md's Profiling](./DEVELOPMENT.md#profiling) section for details. + ## 2025-12-02 - Commit: [`aae52f5`](https://github.com/aviatesk/JETLS.jl/commit/aae52f5) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 842d13e96..0ec800851 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -210,6 +210,68 @@ That is, register it via the `client/registerCapability` request in response to notifications sent from the client, most likely `InitializedNotification`. The `JETLS.register` utility is especially useful for this purpose. +## Profiling + +JETLS provides a mechanism for capturing heap snapshots of the language server +process itself. This is useful for investigating JETLS's memory footprint and +detecting potential memory leaks in the server implementation. + +### Taking a heap snapshot + +To trigger a heap snapshot, create a `.JETLSProfile` file in the workspace root +directory: + +```bash +touch .JETLSProfile +``` + +When JETLS detects this file, it will: +1. Take a heap snapshot using `Profile.take_heap_snapshot` +2. Save it as `JETLS_YYYYMMDD_HHMMSS.heapsnapshot` in the workspace root +3. Show a notification with the file path +4. Automatically delete the `.JETLSProfile` trigger file + +### Analyzing the snapshot + +The generated `.heapsnapshot` file uses the V8 heap snapshot format, which can +be analyzed using Chrome DevTools: + +1. Open Chrome and navigate to any page +2. Open DevTools (F12) +3. Go to the "Memory" tab +4. Click "Load" and select the `.heapsnapshot` file +5. Use the "Summary" view to see memory usage by type (Constructor) +6. Use the "Comparison" view to compare two snapshots and identify memory growth + +### What you can learn from snapshots + +- **Self size**: Memory directly held by objects of each type +- **Retained size**: Total memory that would be freed if objects were garbage + collected +- **Count**: Number of instances of each type + +Common things to look for: +- Large `Dict` or `Vector` instances that may be caching too much data +- Growing counts of `FileInfo`, `AnalysisResult`, or other JETLS-specific types +- Unexpected retention of objects that should have been garbage collected + +### Comparing snapshots + +To investigate memory growth: +1. Take a snapshot shortly after server startup +2. Perform some operations (open files, trigger analysis, etc.) +3. Take another snapshot +4. Compare them in Chrome DevTools using the "Comparison" view + +This helps identify which types are accumulating over time. + +### Limitations + +- Only Julia GC-managed heap is captured; memory allocated by external libraries + (BLAS, LAPACK, etc.) is not included +- The snapshot process itself requires additional memory, so for very large + processes, ensure sufficient memory is available + ## Release process JETLS avoids dependency conflicts with packages being analyzed by rewriting the diff --git a/Project.toml b/Project.toml index 6f577c70a..df8434294 100644 --- a/Project.toml +++ b/Project.toml @@ -17,6 +17,7 @@ Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Preferences = "21216c6a-2e73-6563-6e65-726566657250" +Profile = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" @@ -40,6 +41,7 @@ Markdown = "1.11.0" Pkg = "1.11.0" PrecompileTools = "1.3.2" Preferences = "1.4.3" +Profile = "1.11.0" REPL = "1.11.0" Revise = "3.12.3" Sockets = "1.11.0" diff --git a/src/JETLS.jl b/src/JETLS.jl index 39974655c..ac2479699 100644 --- a/src/JETLS.jl +++ b/src/JETLS.jl @@ -100,6 +100,7 @@ include("formatting.jl") include("inlay-hint.jl") include("rename.jl") include("testrunner/testrunner.jl") +include("profile.jl") include("did-change-watched-files.jl") include("initialize.jl") @@ -353,6 +354,8 @@ function (dispatcher::ResponseMessageDispatcher)(server::Server, msg::Dict{Symbo handle_show_document_response(server, msg, request_caller) elseif request_caller isa SetDocumentContentCaller handle_apply_workspace_edit_response(server, msg, request_caller) + elseif request_caller isa DeleteFileCaller + handle_apply_workspace_edit_response(server, msg, request_caller) elseif request_caller isa TestRunnerMessageRequestCaller2 handle_test_runner_message_response2(server, msg, request_caller) elseif request_caller isa TestRunnerMessageRequestCaller4 @@ -369,6 +372,8 @@ function (dispatcher::ResponseMessageDispatcher)(server::Server, msg::Dict{Symbo handle_formatting_progress_response(server, msg, request_caller) elseif request_caller isa RangeFormattingProgressCaller handle_range_formatting_progress_response(server, msg, request_caller) + elseif request_caller isa ProfileProgressCaller + handle_profile_progress_response(server, msg, request_caller) elseif request_caller isa WorkspaceConfigurationCaller handle_workspace_configuration_response(server, msg, request_caller) elseif request_caller isa RegisterCapabilityRequestCaller || request_caller isa UnregisterCapabilityRequestCaller diff --git a/src/apply-edit.jl b/src/apply-edit.jl index 32c9958fb..10b704978 100644 --- a/src/apply-edit.jl +++ b/src/apply-edit.jl @@ -1,4 +1,5 @@ struct SetDocumentContentCaller <: RequestCaller end +struct DeleteFileCaller <: RequestCaller end function set_document_content(server::Server, uri::URI, content::String; context::Union{Nothing,String}=nothing) edits = TextEdit[TextEdit(; @@ -36,3 +37,22 @@ function handle_apply_workspace_edit_response( show_error_message(server, "Unexpected response from workspace edit request") end end + +function request_delete_file(server::Server, uri::URI) + delete_op = DeleteFile(; + kind="delete", + uri, + options = DeleteFileOptions(; + ignoreIfNotExists = true)) + documentChanges = DeleteFile[delete_op] + edit = WorkspaceEdit(; documentChanges) + id = String(gensym(:ApplyWorkspaceEditRequest)) + addrequest!(server, id=>DeleteFileCaller()) + return send(server, ApplyWorkspaceEditRequest(; + id, + params = ApplyWorkspaceEditParams(; label="Delete file", edit))) +end + +function handle_apply_workspace_edit_response(::Server, ::Dict{Symbol,Any}, ::DeleteFileCaller) + # Silently ignore errors for file deletion +end diff --git a/src/did-change-watched-files.jl b/src/did-change-watched-files.jl index f9e702a82..ef7aca986 100644 --- a/src/did-change-watched-files.jl +++ b/src/did-change-watched-files.jl @@ -1,15 +1,24 @@ const DID_CHANGE_WATCHED_FILES_REGISTRATION_ID = "jetls-did-change-watched-files" const DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD = "workspace/didChangeWatchedFiles" -function did_change_watched_files_registration() +const CONFIG_FILE = ".JETLSConfig.toml" +const PROFILE_TRIGGER_FILE = ".JETLSProfile" + +function did_change_watched_files_registration(server::Server) + root_uri = filepath2uri(server.state.root_path) Registration(; id = DID_CHANGE_WATCHED_FILES_REGISTRATION_ID, method = DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD, registerOptions = DidChangeWatchedFilesRegistrationOptions(; watchers = FileSystemWatcher[ FileSystemWatcher(; - globPattern = "**/.JETLSConfig.toml", + globPattern = "**/$CONFIG_FILE", kind = WatchKind.Create | WatchKind.Change | WatchKind.Delete), + FileSystemWatcher(; + globPattern = RelativePattern(; + baseUri = root_uri, + pattern = PROFILE_TRIGGER_FILE), + kind = WatchKind.Create), ])) end @@ -17,7 +26,7 @@ end # unregister(currently_running, Unregistration(; # id = DID_CHANGE_WATCHED_FILES_REGISTRATION_ID, # method = DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD)) -# register(currently_running, did_change_watched_files_registration()) +# register(currently_running, did_change_watched_files_registration(currently_running)) config_file_created_msg(path::AbstractString) = "JETLS configuration file loaded: $path" config_file_deleted_msg(path::AbstractString) = "JETLS configuration file removed: $path" @@ -94,11 +103,15 @@ function delete_file_config!(callback, manager::ConfigManager, filepath::Abstrac end end +is_profile_trigger_file(path::AbstractString) = endswith(path, PROFILE_TRIGGER_FILE) + function handle_DidChangeWatchedFilesNotification(server::Server, msg::DidChangeWatchedFilesNotification) for change in msg.params.changes - changed_path = uri2filepath(change.uri) - if changed_path !== nothing && is_config_file(changed_path) + changed_path = @something uri2filepath(change.uri) continue + if is_config_file(changed_path) handle_config_file_change!(server, changed_path, change.type) + elseif is_profile_trigger_file(changed_path) && change.type == FileChangeType.Created + trigger_profile!(server, changed_path) end end end diff --git a/src/initialize.jl b/src/initialize.jl index 153b57a17..3626a6e85 100644 --- a/src/initialize.jl +++ b/src/initialize.jl @@ -409,7 +409,7 @@ function handle_InitializedNotification(server::Server) end if supports(server, :workspace, :didChangeWatchedFiles, :dynamicRegistration) - push!(registrations, did_change_watched_files_registration()) + push!(registrations, did_change_watched_files_registration(server)) if JETLS_DEV_MODE @info "Dynamically registering 'workspace/didChangeWatchedFiles' upon `InitializedNotification`" end diff --git a/src/profile.jl b/src/profile.jl new file mode 100644 index 000000000..2dc4f9ca5 --- /dev/null +++ b/src/profile.jl @@ -0,0 +1,72 @@ +using Profile: Profile + +struct ProfileProgressCaller <: RequestCaller + trigger_path::String + token::ProgressToken +end +cancellable_token(caller::ProfileProgressCaller) = caller.token + +function trigger_profile!(server::Server, trigger_path::String) + if supports(server, :window, :workDoneProgress) + id = String(gensym(:WorkDoneProgressCreateRequest_profile)) + token = String(gensym(:ProfileProgress)) + addrequest!(server, id => ProfileProgressCaller(trigger_path, token)) + params = WorkDoneProgressCreateParams(; token) + send(server, WorkDoneProgressCreateRequest(; id, params)) + else + do_profile(server, trigger_path) + end +end + +function handle_profile_progress_response( + server::Server, msg::Dict{Symbol,Any}, request_caller::ProfileProgressCaller + ) + if handle_response_error(server, msg, "create work done progress") + return + end + (; trigger_path, token) = request_caller + do_profile_with_progress(server, trigger_path, token) +end + +function do_profile_with_progress(server::Server, trigger_path::String, token::ProgressToken) + send_progress(server, token, + WorkDoneProgressBegin(; title="Taking heap snapshot")) + completed = false + try + do_profile(server, trigger_path) + completed = true + finally + send_progress(server, token, + WorkDoneProgressEnd(; + message = "Heap snapshot " * (completed ? "completed" : "failed"))) + end +end + +function do_profile(server::Server, trigger_path::String) + root_path = server.state.root_path + timestamp = Libc.strftime("%Y%m%d_%H%M%S", time()) + output_path = joinpath(root_path, "JETLS_$timestamp") + + assembled_path = output_path * ".heapsnapshot" + try + Profile.take_heap_snapshot(output_path; streaming=true) + Profile.HeapSnapshot.assemble_snapshot(output_path, assembled_path) + show_info_message(server, "Heap snapshot saved to: $assembled_path") + catch e + @error "Failed to take heap snapshot" trigger_path + Base.showerror(stderr, e, catch_backtrace()) + println(stderr) + show_error_message(server, "Failed to take heap snapshot. See server log for details.") + finally + cleanup_streaming_files(output_path) + end + + request_delete_file(server, filepath2uri(trigger_path)) +end + +function cleanup_streaming_files(base_path::String) + for suffix in (".nodes", ".edges", ".strings", ".metadata.json") + path = base_path * suffix + isfile(path) && rm(path) + end +end