-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Description
The didChange()
method in FileCache.java
throws a StringIndexOutOfBoundsException
when processing multiple incremental character insertions sent as separate changes in a single event.
Steps to reproduce
- Open an empty file in an LSP client that sends
didChange
events in a piecemeal fashion. - Type characters one by one (e.g., typing "proc")
Or alternatively, using the following python script:
#!/usr/bin/env python3
#### Please replace with the appropriate path to the LSP
LSP_PATH='/Users/ycc/.local/share/nvim/mason/bin/nextflow-language-server'
#### The content of my LSP executable (from `mason.nvim`)
# #!/usr/bin/env bash
#
# exec java -jar "$HOME/.local/share/nvim/mason/packages/nextflow-language-server/language-server-all.jar" "$@"
import json
import subprocess
import time
# Minimal JSON messages to reproduce the bug
messages = [
# Initialize request
{
"id": 1,
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"processId": None,
"rootUri": "file:///tmp/testlsp",
"capabilities": {},
"workspaceFolders": [{
"name": "testlsp",
"uri": "file:///tmp/testlsp"
}]
}
},
# Initialized notification
{
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
},
# Open file
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///tmp/testlsp/test.nf",
"languageId": "nextflow",
"version": 0,
"text": "\n"
}
}
},
# First didChange - add 'p'
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///tmp/testlsp/test.nf",
"version": 1
},
"contentChanges": [{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 0}
},
"rangeLength": 0,
"text": "p"
}]
}
},
# Second didChange - add 'r'
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///tmp/testlsp/test.nf",
"version": 2
},
"contentChanges": [{
"range": {
"start": {"line": 0, "character": 1},
"end": {"line": 0, "character": 1}
},
"rangeLength": 0,
"text": "r"
}]
}
},
# Delete 'r'
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///tmp/testlsp/test.nf",
"version": 3
},
"contentChanges": [{
"range": {
"start": {"line": 0, "character": 1},
"end": {"line": 0, "character": 2}
},
"rangeLength": 1,
"text": ""
}]
}
},
# Bug-triggering message - multiple changes in single request
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///tmp/testlsp/test.nf",
"version": 4
},
"contentChanges": [
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 1}
},
"rangeLength": 1,
"text": ""
},
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 0}
},
"rangeLength": 0,
"text": "p"
},
{
"range": {
"start": {"line": 0, "character": 1},
"end": {"line": 0, "character": 1}
},
"rangeLength": 0,
"text": "r"
}
]
}
}
]
def send_message(proc, message):
"""Send a JSON-RPC message to the LSP server"""
content = json.dumps(message)
header = f"Content-Length: {len(content.encode('utf-8'))}\r\n\r\n"
proc.stdin.write(header.encode('utf-8'))
proc.stdin.write(content.encode('utf-8'))
proc.stdin.flush()
print(f"Sent: {json.dumps(message, indent=2)}")
def read_response(proc, timeout=0.5):
"""Read response from LSP server with timeout"""
import select
try:
# Use select to check if data is available
if not select.select([proc.stdout], [], [], timeout)[0]:
return None
# Read headers
headers = {}
while True:
line = proc.stdout.readline().decode('utf-8').strip()
if not line:
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
# Read content
if 'Content-Length' in headers:
content_length = int(headers['Content-Length'])
content = proc.stdout.read(content_length).decode('utf-8')
return json.loads(content)
except Exception as e:
print(f"Error reading response: {e}")
return None
def check_stderr(proc, nonblocking=True):
"""Check for errors in stderr"""
import select
if nonblocking:
if select.select([proc.stderr], [], [], 0)[0]:
stderr = proc.stderr.read1().decode('utf-8')
if stderr:
print(f"\nServer error: {stderr}")
if "StringIndexOutOfBoundsException" in stderr:
print("\n*** BUG REPRODUCED! ***")
print("The server crashed with StringIndexOutOfBoundsException")
print("This happens when multiple content changes are sent in a single didChange request")
print("where the first change removes content that subsequent changes try to modify.")
return True
return False
def reproduce_bug():
"""Reproduce the LSP bug by sending the minimal message sequence"""
print("Starting Nextflow Language Server...")
# Start the LSP server
proc = subprocess.Popen(
[LSP_PATH],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0 # Unbuffered
)
try:
# Send messages to reproduce the bug
for i, message in enumerate(messages):
send_message(proc, message)
time.sleep(0.1) # Give server time to process
# For requests, wait for response
if 'id' in message:
response = read_response(proc)
if response:
print(f"Response: {json.dumps(response, indent=2)}")
else:
# For notifications, just check for any server messages
response = read_response(proc, timeout=0.1)
if response:
print(f"Server message: {json.dumps(response, indent=2)}")
# Check for errors in stderr
if check_stderr(proc):
break
# Also check if server has crashed
if proc.poll() is not None:
print("Server process terminated unexpectedly")
break
except Exception as e:
print(f"Error: {e}")
finally:
# Clean up
proc.terminate()
proc.wait()
# Read any remaining stderr
remaining_stderr = proc.stderr.read().decode('utf-8')
if remaining_stderr:
print(f"Remaining server errors: {remaining_stderr}")
if "StringIndexOutOfBoundsException" in remaining_stderr:
print("\n*** BUG REPRODUCED! ***")
if __name__ == "__main__":
reproduce_bug()
Error
# If with the python script above
java.lang.StringIndexOutOfBoundsException: Range [1, 0) out of bounds for length 2
at java.base/java.lang.String.substring(String.java:2925)
at nextflow.lsp.file.FileCache.didChange(FileCache.java:105)
Possible cause
The current implementation assumes change positions remain valid relative to the original text throughout processing, but this breaks when changes modify the text length (like single character insertions).
Note
This doesn't affect VSCode (maybe it aggregates changes?), but neovim (v0.11) using mason
-provided LSP (v24.10.3) and mason-lspconfig
or simulating LSP sessions could reproducibly trigger the error.
I will submit a pull request that seems not to trigger this error with the test script or my regular usage.
Thank you so much for all the awesome tool development. Please let me know if you have any questions.