Skip to content

Commit 020c2cb

Browse files
16bit-ykikoclaude
andauthored
feat: implement multi-process LSP server architecture (#364)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 73afcfb commit 020c2cb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+4237
-680
lines changed

.github/workflows/test-cmake.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ jobs:
2020
- name: Build
2121
run: pixi run build ${{ matrix.build_type }} ON
2222

23-
- name: Test
23+
- name: Unit Test
2424
run: pixi run unit-test ${{ matrix.build_type }}
25+
26+
- name: Integration Test
27+
run: pixi run integration-test ${{ matrix.build_type }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,7 @@ tests/unit/Local/
6666
.env
6767
.pixi/*
6868
!.pixi/config.toml
69+
70+
.codex/
71+
.claude/
72+
openspec/

CMakeLists.txt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
101101
endif()
102102

103103
if(MSVC OR (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND
104-
CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC"))
104+
CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC"))
105105
target_compile_options(clice_options INTERFACE
106106
/GR-
107107
/EHsc-
@@ -111,9 +111,9 @@ else()
111111
target_compile_options(clice_options INTERFACE
112112
-fno-rtti
113113
-Wno-deprecated-declarations
114-
-Wno-undefined-inline
115114
-ffunction-sections
116115
-fdata-sections
116+
$<$<COMPILE_LANG_AND_ID:CXX,Clang,AppleClang>:-Wno-undefined-inline>
117117
)
118118
endif()
119119

@@ -164,6 +164,11 @@ add_library(clice-core STATIC
164164
"${PROJECT_SOURCE_DIR}/src/index/usr_generation.cpp"
165165
"${PROJECT_SOURCE_DIR}/src/index/project_index.cpp"
166166
"${PROJECT_SOURCE_DIR}/src/index/merged_index.cpp"
167+
"${PROJECT_SOURCE_DIR}/src/server/stateless_worker.cpp"
168+
"${PROJECT_SOURCE_DIR}/src/server/stateful_worker.cpp"
169+
"${PROJECT_SOURCE_DIR}/src/server/worker_pool.cpp"
170+
"${PROJECT_SOURCE_DIR}/src/server/master_server.cpp"
171+
"${PROJECT_SOURCE_DIR}/src/server/config.cpp"
167172
)
168173
add_library(clice::core ALIAS clice-core)
169174
add_dependencies(clice-core generate_flatbuffers_schema)
@@ -178,12 +183,12 @@ target_link_libraries(clice-core PUBLIC
178183
spdlog::spdlog
179184
roaring::roaring
180185
flatbuffers
181-
eventide::async
182-
eventide::language
186+
eventide::ipc::lsp
187+
eventide::serde::toml
183188
)
184189

185190
add_executable(clice "${PROJECT_SOURCE_DIR}/src/clice.cc")
186-
target_link_libraries(clice PRIVATE clice::core)
191+
target_link_libraries(clice PRIVATE clice::core eventide::deco)
187192
install(TARGETS clice RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
188193

189194
message(STATUS "Copying resource directory for development build")
@@ -214,5 +219,5 @@ if(CLICE_ENABLE_TEST)
214219
"${PROJECT_SOURCE_DIR}/src"
215220
"${PROJECT_SOURCE_DIR}/tests/unit"
216221
)
217-
target_link_libraries(unit_tests PRIVATE clice::core eventide::zest)
222+
target_link_libraries(unit_tests PRIVATE clice::core eventide::zest eventide::deco)
218223
endif()

cmake/package.cmake

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,18 @@ set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
1111
FetchContent_Declare(
1212
spdlog
1313
GIT_REPOSITORY https://github.com/gabime/spdlog.git
14-
GIT_TAG v1.15.3
15-
GIT_SHALLOW TRUE
16-
)
17-
18-
# tomlplusplus
19-
FetchContent_Declare(
20-
tomlplusplus
21-
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
22-
GIT_TAG v3.4.0
23-
GIT_SHALLOW TRUE
14+
GIT_TAG v1.15.3
15+
GIT_SHALLOW TRUE
2416
)
17+
set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE)
18+
set(SPDLOG_NO_EXCEPTIONS ON CACHE BOOL "" FORCE)
2519

2620
# croaring
2721
FetchContent_Declare(
2822
croaring
2923
GIT_REPOSITORY https://github.com/RoaringBitmap/CRoaring.git
30-
GIT_TAG v4.4.2
31-
GIT_SHALLOW TRUE
24+
GIT_TAG v4.4.2
25+
GIT_SHALLOW TRUE
3226
)
3327
set(ENABLE_ROARING_TESTS OFF CACHE INTERNAL "" FORCE)
3428
set(ENABLE_ROARING_MICROBENCHMARKS OFF CACHE INTERNAL "" FORCE)
@@ -37,8 +31,8 @@ set(ENABLE_ROARING_MICROBENCHMARKS OFF CACHE INTERNAL "" FORCE)
3731
FetchContent_Declare(
3832
flatbuffers
3933
GIT_REPOSITORY https://github.com/google/flatbuffers.git
40-
GIT_TAG v25.9.23
41-
GIT_SHALLOW TRUE
34+
GIT_TAG v25.9.23
35+
GIT_SHALLOW TRUE
4236
)
4337
set(FLATBUFFERS_BUILD_GRPC OFF CACHE BOOL "" FORCE)
4438
set(FLATBUFFERS_BUILD_TESTS OFF CACHE BOOL "" FORCE)
@@ -47,17 +41,16 @@ set(FLATBUFFERS_BUILD_FLATHASH OFF CACHE BOOL "" FORCE)
4741
FetchContent_Declare(
4842
eventide
4943
GIT_REPOSITORY https://github.com/clice-io/eventide
50-
GIT_TAG main
51-
GIT_SHALLOW TRUE
44+
GIT_TAG main
45+
GIT_SHALLOW TRUE
5246
)
53-
set(EVENTIDE_ENABLE_ZEST ON)
54-
set(EVENTIDE_ENABLE_TEST OFF)
55-
set(EVENTIDE_SERDE_ENABLE_SIMDJSON ON)
56-
set(EVENTIDE_SERDE_ENABLE_YYJSON ON)
5747

58-
FetchContent_MakeAvailable(eventide spdlog tomlplusplus croaring flatbuffers)
48+
set(ETD_ENABLE_ZEST ON)
49+
set(ETD_ENABLE_TEST OFF)
50+
set(ETD_SERDE_ENABLE_SIMDJSON ON)
51+
set(ETD_SERDE_ENABLE_YYJSON ON)
52+
set(ETD_SERDE_ENABLE_TOML ON)
53+
set(ETD_ENABLE_EXCEPTIONS OFF)
54+
set(ETD_ENABLE_RTTI OFF)
5955

60-
target_compile_definitions(spdlog PUBLIC
61-
SPDLOG_USE_STD_FORMAT=1
62-
SPDLOG_NO_EXCEPTIONS=1
63-
)
56+
FetchContent_MakeAvailable(eventide spdlog croaring flatbuffers)

docs/en/architecture.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Server Architecture
2+
3+
clice uses a **multi-process architecture** where a single **Master Server** coordinates multiple **Worker** processes. This design isolates Clang AST operations (which are memory-heavy and may crash) from the main LSP event loop.
4+
5+
## Overview
6+
7+
```
8+
┌──────────────┐ JSON/LSP ┌────────────────┐ Bincode/IPC ┌──────────────────┐
9+
│ LSP Client │ ◄──────────► │ Master Server │ ◄─────────────► │ Stateful Workers │
10+
│ (Editor) │ (stdio) │ │ (stdio) │ (AST cache) │
11+
└──────────────┘ │ - Lifecycle │ └──────────────────┘
12+
│ - Documents │
13+
│ - CDB │ Bincode/IPC ┌──────────────────┐
14+
│ - Build drain │ ◄─────────────► │ Stateless Workers│
15+
│ - Indexing │ (stdio) │ (one-shot tasks)│
16+
└────────────────┘ └──────────────────┘
17+
```
18+
19+
## Master Server
20+
21+
The master server (`src/server/master_server.cpp`) is the central coordinator. It runs a single-threaded async event loop and never touches Clang directly. Its responsibilities:
22+
23+
### LSP Lifecycle
24+
25+
The server progresses through these states:
26+
27+
1. **Uninitialized** — waiting for `initialize` request
28+
2. **Initialized** — capabilities exchanged, waiting for `initialized` notification
29+
3. **Ready** — workers spawned, workspace loaded, accepting requests
30+
4. **ShuttingDown**`shutdown` received, draining work
31+
5. **Exited**`exit` received, stopping the event loop
32+
33+
On `initialized`, the master:
34+
35+
- Loads configuration from `clice.toml` (or uses defaults)
36+
- Starts the worker pool (spawns stateful + stateless processes)
37+
- Loads `compile_commands.json` and builds an include graph
38+
- Starts the background indexer coroutine (if enabled)
39+
40+
### Document Management
41+
42+
Each open document is tracked in a `DocumentState` with:
43+
44+
- Current `version` and `text` (kept in sync via `didOpen`/`didChange`)
45+
- A `generation` counter to detect stale compile results
46+
- Build state flags (`build_running`, `build_requested`, `drain_scheduled`)
47+
48+
When a document is opened or changed:
49+
50+
1. The include graph is re-scanned (via dependency directives)
51+
2. The compile unit is registered/updated in the `CompileGraph`
52+
3. A debounced build is scheduled
53+
54+
### Build Drain
55+
56+
The `run_build_drain` coroutine implements debounced compilation:
57+
58+
1. Wait for the debounce timer (default 200ms) to expire
59+
2. Ensure PCH/PCM dependencies are ready via `CompileGraph`
60+
3. Send a `compile` request to the assigned stateful worker
61+
4. Publish diagnostics from the result (or clear them on failure)
62+
5. If more edits arrived during compilation (`build_requested`), loop back to step 2
63+
64+
This ensures rapid typing doesn't trigger a compile per keystroke.
65+
66+
### Request Routing
67+
68+
Feature requests are split between two worker types:
69+
70+
**Stateful workers** (affinity-routed by file path):
71+
72+
- `textDocument/hover`
73+
- `textDocument/semanticTokens/full`
74+
- `textDocument/inlayHint`
75+
- `textDocument/foldingRange`
76+
- `textDocument/documentSymbol`
77+
- `textDocument/documentLink`
78+
- `textDocument/codeAction`
79+
- `textDocument/definition`
80+
81+
**Stateless workers** (round-robin):
82+
83+
- `textDocument/completion`
84+
- `textDocument/signatureHelp`
85+
86+
All feature responses use `RawValue` passthrough — the worker serializes the LSP result to JSON, and the master forwards the raw JSON bytes to the client without deserializing. This avoids bincode↔JSON conversion overhead and serde annotation conflicts.
87+
88+
## Worker Pool
89+
90+
The worker pool (`src/server/worker_pool.cpp`) manages spawning and communicating with worker processes. Each worker is a child process of the same `clice` binary, launched with `--mode stateful-worker` or `--mode stateless-worker`.
91+
92+
### Communication
93+
94+
Workers communicate with the master via **stdio pipes** using a **bincode** serialization format (via `eventide::ipc::BincodePeer`). This is more compact and faster than JSON for internal IPC, while the master handles JSON for the external LSP protocol.
95+
96+
### Stateful Worker Routing
97+
98+
Stateful workers use **affinity routing**: each file is consistently assigned to the same worker so that the worker retains the cached AST. Assignment uses a **least-loaded** strategy for new files, with **LRU tracking** to manage ownership.
99+
100+
When a worker exceeds its document capacity (currently hardcoded at 16 documents), it evicts the least-recently-used document and notifies the master via an `evicted` notification.
101+
102+
### Stateless Worker Routing
103+
104+
Stateless workers use simple **round-robin** dispatch. Each request includes the full source text and compilation arguments, so any worker can handle it independently.
105+
106+
## Stateful Worker
107+
108+
The stateful worker (`src/server/stateful_worker.cpp`) caches compiled ASTs in memory. Key behavior:
109+
110+
- **Compile**: Parses source code into a `CompilationUnit`, caches the AST, and returns diagnostics as a `RawValue` (JSON bytes)
111+
- **Feature queries**: Look up the cached AST and invoke the corresponding `feature::*` function (hover, semantic tokens, etc.), serializing the result to JSON
112+
- **Document updates**: Received as notifications — the worker updates the stored text and marks the document as `dirty`, causing feature queries to return `null` until recompilation
113+
- **Eviction**: LRU-based; evicts the oldest document when capacity is exceeded, notifying the master
114+
- **Concurrency**: Each document has a per-document `et::mutex` (strand) to serialize compilation and feature queries. Heavy work (compilation, feature extraction) runs on a thread pool via `et::queue`.
115+
116+
## Stateless Worker
117+
118+
The stateless worker (`src/server/stateless_worker.cpp`) handles one-shot requests that don't benefit from cached ASTs:
119+
120+
- **Completion**: Creates a fresh compilation with `CompilationKind::Completion` and invokes `feature::code_complete`
121+
- **Signature help**: Similar to completion, using `feature::signature_help`
122+
- **Build PCH**: Compiles a precompiled header to a temporary file
123+
- **Build PCM**: Compiles a C++20 module interface to a temporary file
124+
- **Index**: Compiles a file for indexing (TUIndex generation — currently a stub)
125+
126+
All requests are dispatched to a thread pool via `et::queue`.
127+
128+
## Compile Graph
129+
130+
The compile graph (`src/server/compile_graph.cpp`) tracks compilation unit dependencies as a DAG. It handles:
131+
132+
- **Registration**: Each file registers its included dependencies
133+
- **Cascade invalidation**: When a file changes, all transitive dependents are marked dirty and their ongoing compilations are cancelled
134+
- **Dependency compilation**: Before compiling a file, `compile_deps` ensures all dependencies (PCH, PCMs) are built first
135+
- **Cancellation**: Uses `et::cancellation_source` to abort in-flight compilations when files are invalidated
136+
137+
## Configuration
138+
139+
The server reads configuration from `clice.toml` (or `.clice/config.toml`) in the workspace root. If no config file exists, sensible defaults are computed from system resources:
140+
141+
| Setting | Default | Description |
142+
| ------------------------ | --------------------- | ------------------------------------------- |
143+
| `stateful_worker_count` | CPU cores / 4 | Number of stateful worker processes |
144+
| `stateless_worker_count` | CPU cores / 4 | Number of stateless worker processes |
145+
| `worker_memory_limit` | 4 GB | Memory limit per stateful worker |
146+
| `compile_commands_path` | auto-detect | Path to `compile_commands.json` |
147+
| `cache_dir` | `<workspace>/.clice/` | Cache directory for PCH/PCM files |
148+
| `debounce_ms` | 200 | Debounce interval for recompilation |
149+
| `enable_indexing` | true | Enable background indexing |
150+
| `idle_timeout_ms` | 3000 | Idle time before background indexing starts |
151+
152+
String values support `${workspace}` substitution.
153+
154+
## IPC Protocol
155+
156+
The master and workers communicate using custom RPC messages defined in `src/server/protocol.h`. Each message type has a `RequestTraits` or `NotificationTraits` specialization that defines the method name and result type.
157+
158+
### Stateful Worker Messages
159+
160+
| Method | Direction | Purpose |
161+
| ----------------------------- | ------------ | ------------------------------------- |
162+
| `clice/worker/compile` | Request | Compile source and return diagnostics |
163+
| `clice/worker/hover` | Request | Get hover info at position |
164+
| `clice/worker/semanticTokens` | Request | Get semantic tokens for file |
165+
| `clice/worker/inlayHints` | Request | Get inlay hints for range |
166+
| `clice/worker/foldingRange` | Request | Get folding ranges |
167+
| `clice/worker/documentSymbol` | Request | Get document symbols |
168+
| `clice/worker/documentLink` | Request | Get document links |
169+
| `clice/worker/codeAction` | Request | Get code actions for range |
170+
| `clice/worker/goToDefinition` | Request | Go to definition at position |
171+
| `clice/worker/documentUpdate` | Notification | Update document text (marks dirty) |
172+
| `clice/worker/evict` | Notification | Master → Worker: evict a document |
173+
| `clice/worker/evicted` | Notification | Worker → Master: document was evicted |
174+
175+
### Stateless Worker Messages
176+
177+
| Method | Direction | Purpose |
178+
| ---------------------------- | --------- | ---------------------------- |
179+
| `clice/worker/completion` | Request | Code completion at position |
180+
| `clice/worker/signatureHelp` | Request | Signature help at position |
181+
| `clice/worker/buildPCH` | Request | Build precompiled header |
182+
| `clice/worker/buildPCM` | Request | Build C++20 module interface |
183+
| `clice/worker/index` | Request | Index a translation unit |

0 commit comments

Comments
 (0)