Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Manifest-*.toml
!.JETLSConfig.toml
*.vsix
docs/build/
*.heapsnapshot
.JETLSProfile

# The following line is negated on release branches
/vendor
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions src/JETLS.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/apply-edit.jl
Original file line number Diff line number Diff line change
@@ -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(;
Expand Down Expand Up @@ -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
23 changes: 18 additions & 5 deletions src/did-change-watched-files.jl
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
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

# For dynamic registrations during development
# 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"
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/initialize.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions src/profile.jl
Original file line number Diff line number Diff line change
@@ -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
Loading