Skip to content

Commit a13f483

Browse files
committed
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
1 parent d10998f commit a13f483

File tree

9 files changed

+186
-6
lines changed

9 files changed

+186
-6
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Manifest-*.toml
88
!.JETLSConfig.toml
99
*.vsix
1010
docs/build/
11+
*.heapsnapshot
12+
.JETLSProfile
1113

1214
# The following line is negated on release branches
1315
/vendor

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1616
- Commit: [`HEAD`](https://github.com/aviatesk/JETLS.jl/commit/HEAD)
1717
- Diff: [`aae52f5...HEAD`](https://github.com/aviatesk/JETLS.jl/compare/aae52f5...HEAD)
1818

19+
### Internal
20+
21+
- Added heap snapshot profiling support. Create a `.JETLSProfile` file in the
22+
workspace root to trigger a heap snapshot. The snapshot is saved as
23+
`JETLS_YYYYMMDD_HHMMSS.heapsnapshot` and can be analyzed using Chrome DevTools.
24+
See [DEVELOPMENT.md's Profiling](./DEVELOPMENT.md#profiling) section for details.
25+
1926
## 2025-12-02
2027

2128
- Commit: [`aae52f5`](https://github.com/aviatesk/JETLS.jl/commit/aae52f5)

DEVELOPMENT.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,68 @@ That is, register it via the `client/registerCapability` request in response to
210210
notifications sent from the client, most likely `InitializedNotification`.
211211
The `JETLS.register` utility is especially useful for this purpose.
212212

213+
## Profiling
214+
215+
JETLS provides a mechanism for capturing heap snapshots of the language server
216+
process itself. This is useful for investigating JETLS's memory footprint and
217+
detecting potential memory leaks in the server implementation.
218+
219+
### Taking a heap snapshot
220+
221+
To trigger a heap snapshot, create a `.JETLSProfile` file in the workspace root
222+
directory:
223+
224+
```bash
225+
touch .JETLSProfile
226+
```
227+
228+
When JETLS detects this file, it will:
229+
1. Take a heap snapshot using `Profile.take_heap_snapshot`
230+
2. Save it as `JETLS_YYYYMMDD_HHMMSS.heapsnapshot` in the workspace root
231+
3. Show a notification with the file path
232+
4. Automatically delete the `.JETLSProfile` trigger file
233+
234+
### Analyzing the snapshot
235+
236+
The generated `.heapsnapshot` file uses the V8 heap snapshot format, which can
237+
be analyzed using Chrome DevTools:
238+
239+
1. Open Chrome and navigate to any page
240+
2. Open DevTools (F12)
241+
3. Go to the "Memory" tab
242+
4. Click "Load" and select the `.heapsnapshot` file
243+
5. Use the "Summary" view to see memory usage by type (Constructor)
244+
6. Use the "Comparison" view to compare two snapshots and identify memory growth
245+
246+
### What you can learn from snapshots
247+
248+
- **Self size**: Memory directly held by objects of each type
249+
- **Retained size**: Total memory that would be freed if objects were garbage
250+
collected
251+
- **Count**: Number of instances of each type
252+
253+
Common things to look for:
254+
- Large `Dict` or `Vector` instances that may be caching too much data
255+
- Growing counts of `FileInfo`, `AnalysisResult`, or other JETLS-specific types
256+
- Unexpected retention of objects that should have been garbage collected
257+
258+
### Comparing snapshots
259+
260+
To investigate memory growth:
261+
1. Take a snapshot shortly after server startup
262+
2. Perform some operations (open files, trigger analysis, etc.)
263+
3. Take another snapshot
264+
4. Compare them in Chrome DevTools using the "Comparison" view
265+
266+
This helps identify which types are accumulating over time.
267+
268+
### Limitations
269+
270+
- Only Julia GC-managed heap is captured; memory allocated by external libraries
271+
(BLAS, LAPACK, etc.) is not included
272+
- The snapshot process itself requires additional memory, so for very large
273+
processes, ensure sufficient memory is available
274+
213275
## Release process
214276

215277
JETLS avoids dependency conflicts with packages being analyzed by rewriting the

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
1717
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
1818
PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
1919
Preferences = "21216c6a-2e73-6563-6e65-726566657250"
20+
Profile = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79"
2021
REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
2122
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
2223
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
@@ -40,6 +41,7 @@ Markdown = "1.11.0"
4041
Pkg = "1.11.0"
4142
PrecompileTools = "1.3.2"
4243
Preferences = "1.4.3"
44+
Profile = "1.11.0"
4345
REPL = "1.11.0"
4446
Revise = "3.12.3"
4547
Sockets = "1.11.0"

src/JETLS.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ include("formatting.jl")
100100
include("inlay-hint.jl")
101101
include("rename.jl")
102102
include("testrunner/testrunner.jl")
103+
include("profile.jl")
103104
include("did-change-watched-files.jl")
104105
include("initialize.jl")
105106

@@ -353,6 +354,8 @@ function (dispatcher::ResponseMessageDispatcher)(server::Server, msg::Dict{Symbo
353354
handle_show_document_response(server, msg, request_caller)
354355
elseif request_caller isa SetDocumentContentCaller
355356
handle_apply_workspace_edit_response(server, msg, request_caller)
357+
elseif request_caller isa DeleteFileCaller
358+
handle_apply_workspace_edit_response(server, msg, request_caller)
356359
elseif request_caller isa TestRunnerMessageRequestCaller2
357360
handle_test_runner_message_response2(server, msg, request_caller)
358361
elseif request_caller isa TestRunnerMessageRequestCaller4
@@ -369,6 +372,8 @@ function (dispatcher::ResponseMessageDispatcher)(server::Server, msg::Dict{Symbo
369372
handle_formatting_progress_response(server, msg, request_caller)
370373
elseif request_caller isa RangeFormattingProgressCaller
371374
handle_range_formatting_progress_response(server, msg, request_caller)
375+
elseif request_caller isa ProfileProgressCaller
376+
handle_profile_progress_response(server, msg, request_caller)
372377
elseif request_caller isa WorkspaceConfigurationCaller
373378
handle_workspace_configuration_response(server, msg, request_caller)
374379
elseif request_caller isa RegisterCapabilityRequestCaller || request_caller isa UnregisterCapabilityRequestCaller

src/apply-edit.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
struct SetDocumentContentCaller <: RequestCaller end
2+
struct DeleteFileCaller <: RequestCaller end
23

34
function set_document_content(server::Server, uri::URI, content::String; context::Union{Nothing,String}=nothing)
45
edits = TextEdit[TextEdit(;
@@ -36,3 +37,22 @@ function handle_apply_workspace_edit_response(
3637
show_error_message(server, "Unexpected response from workspace edit request")
3738
end
3839
end
40+
41+
function request_delete_file(server::Server, uri::URI)
42+
delete_op = DeleteFile(;
43+
kind="delete",
44+
uri,
45+
options = DeleteFileOptions(;
46+
ignoreIfNotExists = true))
47+
documentChanges = DeleteFile[delete_op]
48+
edit = WorkspaceEdit(; documentChanges)
49+
id = String(gensym(:ApplyWorkspaceEditRequest))
50+
addrequest!(server, id=>DeleteFileCaller())
51+
return send(server, ApplyWorkspaceEditRequest(;
52+
id,
53+
params = ApplyWorkspaceEditParams(; label="Delete file", edit)))
54+
end
55+
56+
function handle_apply_workspace_edit_response(::Server, ::Dict{Symbol,Any}, ::DeleteFileCaller)
57+
# Silently ignore errors for file deletion
58+
end

src/did-change-watched-files.jl

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
const DID_CHANGE_WATCHED_FILES_REGISTRATION_ID = "jetls-did-change-watched-files"
22
const DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD = "workspace/didChangeWatchedFiles"
33

4-
function did_change_watched_files_registration()
4+
const CONFIG_FILE = ".JETLSConfig.toml"
5+
const PROFILE_TRIGGER_FILE = ".JETLSProfile"
6+
7+
function did_change_watched_files_registration(server::Server)
8+
root_uri = filepath2uri(server.state.root_path)
59
Registration(;
610
id = DID_CHANGE_WATCHED_FILES_REGISTRATION_ID,
711
method = DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD,
812
registerOptions = DidChangeWatchedFilesRegistrationOptions(;
913
watchers = FileSystemWatcher[
1014
FileSystemWatcher(;
11-
globPattern = "**/.JETLSConfig.toml",
15+
globPattern = "**/$CONFIG_FILE",
1216
kind = WatchKind.Create | WatchKind.Change | WatchKind.Delete),
17+
FileSystemWatcher(;
18+
globPattern = RelativePattern(;
19+
baseUri = root_uri,
20+
pattern = PROFILE_TRIGGER_FILE),
21+
kind = WatchKind.Create),
1322
]))
1423
end
1524

1625
# For dynamic registrations during development
1726
# unregister(currently_running, Unregistration(;
1827
# id = DID_CHANGE_WATCHED_FILES_REGISTRATION_ID,
1928
# method = DID_CHANGE_WATCHED_FILES_REGISTRATION_METHOD))
20-
# register(currently_running, did_change_watched_files_registration())
29+
# register(currently_running, did_change_watched_files_registration(currently_running))
2130

2231
config_file_created_msg(path::AbstractString) = "JETLS configuration file loaded: $path"
2332
config_file_deleted_msg(path::AbstractString) = "JETLS configuration file removed: $path"
@@ -94,11 +103,15 @@ function delete_file_config!(callback, manager::ConfigManager, filepath::Abstrac
94103
end
95104
end
96105

106+
is_profile_trigger_file(path::AbstractString) = endswith(path, PROFILE_TRIGGER_FILE)
107+
97108
function handle_DidChangeWatchedFilesNotification(server::Server, msg::DidChangeWatchedFilesNotification)
98109
for change in msg.params.changes
99-
changed_path = uri2filepath(change.uri)
100-
if changed_path !== nothing && is_config_file(changed_path)
110+
changed_path = @something uri2filepath(change.uri) continue
111+
if is_config_file(changed_path)
101112
handle_config_file_change!(server, changed_path, change.type)
113+
elseif is_profile_trigger_file(changed_path) && change.type == FileChangeType.Created
114+
trigger_profile!(server, changed_path)
102115
end
103116
end
104117
end

src/initialize.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ function handle_InitializedNotification(server::Server)
409409
end
410410

411411
if supports(server, :workspace, :didChangeWatchedFiles, :dynamicRegistration)
412-
push!(registrations, did_change_watched_files_registration())
412+
push!(registrations, did_change_watched_files_registration(server))
413413
if JETLS_DEV_MODE
414414
@info "Dynamically registering 'workspace/didChangeWatchedFiles' upon `InitializedNotification`"
415415
end

src/profile.jl

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using Profile: Profile
2+
3+
struct ProfileProgressCaller <: RequestCaller
4+
trigger_path::String
5+
token::ProgressToken
6+
end
7+
cancellable_token(caller::ProfileProgressCaller) = caller.token
8+
9+
function trigger_profile!(server::Server, trigger_path::String)
10+
if supports(server, :window, :workDoneProgress)
11+
id = String(gensym(:WorkDoneProgressCreateRequest_profile))
12+
token = String(gensym(:ProfileProgress))
13+
addrequest!(server, id => ProfileProgressCaller(trigger_path, token))
14+
params = WorkDoneProgressCreateParams(; token)
15+
send(server, WorkDoneProgressCreateRequest(; id, params))
16+
else
17+
do_profile(server, trigger_path)
18+
end
19+
end
20+
21+
function handle_profile_progress_response(
22+
server::Server, msg::Dict{Symbol,Any}, request_caller::ProfileProgressCaller
23+
)
24+
if handle_response_error(server, msg, "create work done progress")
25+
return
26+
end
27+
(; trigger_path, token) = request_caller
28+
do_profile_with_progress(server, trigger_path, token)
29+
end
30+
31+
function do_profile_with_progress(server::Server, trigger_path::String, token::ProgressToken)
32+
send_progress(server, token,
33+
WorkDoneProgressBegin(; title="Taking heap snapshot"))
34+
completed = false
35+
try
36+
do_profile(server, trigger_path)
37+
completed = true
38+
finally
39+
send_progress(server, token,
40+
WorkDoneProgressEnd(;
41+
message = "Heap snapshot " * (completed ? "completed" : "failed")))
42+
end
43+
end
44+
45+
function do_profile(server::Server, trigger_path::String)
46+
root_path = server.state.root_path
47+
timestamp = Libc.strftime("%Y%m%d_%H%M%S", time())
48+
output_path = joinpath(root_path, "JETLS_$timestamp")
49+
50+
assembled_path = output_path * ".heapsnapshot"
51+
try
52+
Profile.take_heap_snapshot(output_path; streaming=true)
53+
Profile.HeapSnapshot.assemble_snapshot(output_path, assembled_path)
54+
show_info_message(server, "Heap snapshot saved to: $assembled_path")
55+
catch e
56+
show_error_message(server, "Failed to take heap snapshot: $(sprint(showerror, e))")
57+
finally
58+
cleanup_streaming_files(output_path)
59+
end
60+
61+
request_delete_file(server, filepath2uri(trigger_path))
62+
end
63+
64+
function cleanup_streaming_files(base_path::String)
65+
for suffix in (".nodes", ".edges", ".strings", ".metadata.json")
66+
path = base_path * suffix
67+
isfile(path) && rm(path)
68+
end
69+
end

0 commit comments

Comments
 (0)