-
Notifications
You must be signed in to change notification settings - Fork 814
Bring back async API, allow API connection to LSP process #2620
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
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR reintroduces the async JSON-RPC-based API that was prototyped in #711 and adds infrastructure for LSP-based API connections. The implementation refactors the API server stack to support both synchronous (MessagePack) and asynchronous (JSON-RPC) protocols, allowing a single api.Session to work over either STDIO or Unix domain socket/named pipe transports.
Changes:
- Introduces a generic
jsonrpcpackage that extracts common JSON-RPC types fromlsproto, enabling code reuse - Adds
api.Sessionwith protocol abstraction supporting both sync (MessagePack) and async (JSON-RPC) connections - Implements LSP custom command
custom/initializeAPISessionto expose API connections to VS Code extensions - Splits TypeScript API client into sync and async variants with shared base interfaces
Reviewed changes
Copilot reviewed 46 out of 48 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/jsonrpc/ | New package with common JSON-RPC types and base protocol handling |
| internal/lsproto/ | Refactored to use jsonrpc types, reducing code duplication |
| internal/api/session.go | Core API session managing requests, registries, and lifecycle |
| internal/api/conn_*.go | Connection implementations for sync and async request handling |
| internal/api/protocol_*.go | Protocol adapters for MessagePack and JSON-RPC |
| internal/api/transport*.go | Platform-specific transport for pipes/sockets |
| internal/api/callbackfs.go | Virtual filesystem with client-side callbacks |
| internal/lsp/server.go | LSP integration for API session initialization |
| _packages/api/src/async/ | New async TypeScript client using vscode-jsonrpc |
| _packages/api/src/base/ | Shared types and object registry for sync/async clients |
| _packages/api/test/ | Split tests into sync and async variants |
| _extension/ | VS Code command to expose API connection paths |
| this.process = spawn(options.tsserverPath, args, { | ||
| stdio: ["pipe", "pipe", "inherit"], | ||
| }); | ||
|
|
||
| const reader = new StreamMessageReader(this.process.stdout!); | ||
| const writer = new StreamMessageWriter(this.process.stdin!); | ||
| this.connection = createMessageConnection(reader, writer); | ||
| this.connection.listen(); | ||
| this.connected = true; |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The connectViaSpawn method doesn't check if the spawned process fails to start or exits prematurely. If spawn fails, process.stdout or process.stdin could be null, leading to errors. Consider adding error handling for the spawned process, including listening to the 'error' and 'exit' events on the child process.
| this.process = spawn(options.tsserverPath, args, { | |
| stdio: ["pipe", "pipe", "inherit"], | |
| }); | |
| const reader = new StreamMessageReader(this.process.stdout!); | |
| const writer = new StreamMessageWriter(this.process.stdin!); | |
| this.connection = createMessageConnection(reader, writer); | |
| this.connection.listen(); | |
| this.connected = true; | |
| const child = spawn(options.tsserverPath, args, { | |
| stdio: ["pipe", "pipe", "inherit"], | |
| }); | |
| this.process = child; | |
| return new Promise((resolve, reject) => { | |
| let settled = false; | |
| const fail = (error: Error) => { | |
| if (settled) return; | |
| settled = true; | |
| reject(error); | |
| }; | |
| child.on("error", error => { | |
| fail(new Error(`Failed to start tsgo process: ${error.message}`)); | |
| }); | |
| child.on("exit", (code, signal) => { | |
| if (this.connected || settled) { | |
| return; | |
| } | |
| const reason = | |
| code !== null | |
| ? `exit code ${code}` | |
| : signal !== null | |
| ? `signal ${signal}` | |
| : "unknown reason"; | |
| fail(new Error(`tsgo process exited prematurely (${reason})`)); | |
| }); | |
| if (!child.stdout || !child.stdin) { | |
| child.kill(); | |
| fail(new Error("Failed to establish communication with tsgo process: stdin/stdout not available")); | |
| return; | |
| } | |
| const reader = new StreamMessageReader(child.stdout); | |
| const writer = new StreamMessageWriter(child.stdin); | |
| this.connection = createMessageConnection(reader, writer); | |
| this.connection.listen(); | |
| this.connected = true; | |
| if (!settled) { | |
| settled = true; | |
| resolve(); | |
| } | |
| }); |
| import { Bench } from "tinybench"; | ||
| import ts from "typescript"; | ||
| import { runBenchmarks as runAsyncBenchmarks } from "./api.async.bench.ts"; | ||
| import { runBenchmarks as runSyncBenchmarks } from "./api.async.bench.ts"; |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line 3 imports runSyncBenchmarks from api.async.bench.ts instead of api.sync.bench.ts. This should import from the sync benchmark file.
| import { runBenchmarks as runSyncBenchmarks } from "./api.async.bench.ts"; | |
| import { runBenchmarks as runSyncBenchmarks } from "./api.sync.bench.ts"; |
| select { | ||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| case <-c.closedChan: | ||
| return nil, ErrConnClosed | ||
| default: | ||
| // Non-blocking check - continue to read response | ||
| } | ||
|
|
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The context check on lines 150-157 uses a non-blocking select with default, which doesn't actually wait for context cancellation or closed channel. This means if the context is canceled or the connection is closed after the check but before ReadMessage, the operation continues anyway. The check should be removed since it doesn't provide the intended protection, and ReadMessage should be made interruptible via context if needed.
| select { | |
| case <-ctx.Done(): | |
| return nil, ctx.Err() | |
| case <-c.closedChan: | |
| return nil, ErrConnClosed | |
| default: | |
| // Non-blocking check - continue to read response | |
| } |
| func GeneratePipePath(name string) string { | ||
| return "/tmp/" + name + ".sock" |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using /tmp/ as the socket directory on Unix systems can be problematic on multi-user systems or in containerized environments. Consider using a user-specific directory like os.TempDir() which respects platform conventions and environment variables like $TMPDIR, or create sockets in $XDG_RUNTIME_DIR if available for better security and cleanup guarantees.
| func (fs *CallbackFS) SetConnection(ctx context.Context, conn Conn) { | ||
| fs.ctx = ctx | ||
| fs.conn = conn | ||
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The SetConnection method on lines 54-57 lacks any synchronization, yet conn and ctx can be accessed concurrently by filesystem operations from multiple goroutines. This creates a data race. Consider adding a mutex or using atomic operations to ensure safe concurrent access.
| } | ||
|
|
||
| func parseProjectHandle(handle Handle[project.Project]) tspath.Path { | ||
| return tspath.Path(handle[2:]) |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parseProjectHandle function on lines 74-76 assumes the handle always starts with "p." (prefix + dot), but doesn't validate this. If a malformed handle is passed, this could lead to incorrect behavior. Consider adding validation to ensure the handle has the correct prefix and format before slicing.
| return tspath.Path(handle[2:]) | |
| s := string(handle) | |
| if len(s) < 3 || s[0] != byte(handlePrefixProject) || s[1] != '.' { | |
| // Return the zero value for tspath.Path on invalid handles. | |
| return "" | |
| } | |
| return tspath.Path(s[2:]) |
| for id, ch := range c.pending { | ||
| delete(c.pending, id) | ||
| close(ch) |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the Close method, when canceling pending requests by closing their channels (line 206), there's a potential race condition. If a response arrives at the same time Close is called, handleResponse might try to send to the channel after it's been closed, causing a panic. Consider checking if the connection is closed in handleResponse before sending, or use a different signaling mechanism.
| for id, ch := range c.pending { | |
| delete(c.pending, id) | |
| close(ch) | |
| for id := range c.pending { | |
| delete(c.pending, id) |
| go func() { | ||
| if apiErr := apiSession.Run(ctx, transport); apiErr != nil { | ||
| s.logger.Errorf("API session %s: %v", apiSession.ID(), apiErr) | ||
| } | ||
| }() |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The goroutine started on line 1274 uses the context passed to handleInitializeAPISession, but this context is likely bound to the LSP request lifetime. When the request completes, the context may be canceled, which would terminate the API session prematurely. Consider using a background context or a context tied to the server's lifecycle instead.
| s.symbolRegistryMu.Lock() | ||
| s.symbolRegistry = nil | ||
| s.symbolRegistryMu.Unlock() | ||
|
|
||
| s.typeRegistryMu.Lock() | ||
| s.typeRegistry = nil | ||
| s.typeRegistryMu.Unlock() |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting the registries to nil on lines 591 and 595 instead of clearing them creates a potential race condition. If concurrent requests are still accessing these registries during shutdown, they will panic with nil pointer dereference. Consider setting a closed flag and checking it in registry access methods, or using clear() to empty the maps while keeping them non-nil.
| this.channel = new SyncRpcChannel(options.tsserverPath, [ | ||
| const args = [ | ||
| "--api", | ||
| "-cwd", |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The command-line arguments on lines 17-21 use -cwd (single dash) but this is non-standard. Most command-line tools use double dashes for full-word options (--cwd) or single dashes for single-letter options. The --api flag already uses double dashes. Consider using --cwd for consistency, or verify this matches what the Go server expects.
| "-cwd", | |
| "--cwd", |
api.Session) can be used over either STDIO or UDS / named pipe, and communicate over either JSON-RPC or the custom sync protocol we built on top of MessagePack.The API surface area is still early prototype quality and very incomplete; this PR doesn’t add any methods. But the infrastructure provides the beginnings of a path toward replacing TS Server plugins, and will hopefully make it less cumbersome to maintain both a sync and async version of the API. (It’s still pretty annoying in the TS client code, thanks to async function coloring in JavaScript. Possibly future codegen can help us out a little there.)