Skip to content

FileCache.didChange() crashes with character-by-character insertions #112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
chenyenchung opened this issue Apr 26, 2025 · 0 comments · Fixed by #113
Closed

FileCache.didChange() crashes with character-by-character insertions #112

chenyenchung opened this issue Apr 26, 2025 · 0 comments · Fixed by #113

Comments

@chenyenchung
Copy link
Contributor

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

  1. Open an empty file in an LSP client that sends didChange events in a piecemeal fashion.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
1 participant