Skip to content

Commit b3fcc64

Browse files
committed
Reintroduce Io.Group-based LSP client threading
Reverts 9944851. That commit swapped Client.zig's Io.Group/pooled-worker task dispatch for plain std.Thread as a diagnostic step while chasing the Windows hover crash — the real cause turned out to be unrelated (PATH splitting on POSIX ':' in resolveExecutable, fixed in 91ebfe3): the crash reproduced identically under both threading models, which rules out Io.Group as the culprit and confirms it's safe to bring back for its actual benefits (pooled workers instead of a fresh OS thread per LSP client, futex-based await instead of a blocking join).
1 parent 91ebfe3 commit b3fcc64

1 file changed

Lines changed: 32 additions & 46 deletions

File tree

src/core/lsp/Client.zig

Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -328,18 +328,13 @@ encoding: Protocol.PositionEncoding = .utf16,
328328
completion_resolve_supported: bool = false,
329329
shutdown: std.atomic.Value(bool) = .init(false),
330330

331-
/// Background threads this client owns. Plain `std.Thread` (not `Io.Group`/`Io.concurrent`
332-
/// onto `Io.Threaded`'s pooled workers) — reverted after the pooled-worker version shipped a
333-
/// Windows-only hover crash that survived two targeted point-fixes elsewhere (see git history
334-
/// around "rework using new Io" and the "utf16 fix"/"Attempt to fix crash on Windows" follow-ups)
335-
/// with the failure unchanged, which narrows it to this task-dispatch machinery itself. Each is
336-
/// joined (not detached) at `shutdownProcess`, including `startup_thread` — unlike the original
337-
/// pre-`Io.Group` version of this code, which detached the startup thread and never waited for
338-
/// it, a real use-after-free risk if shutdown landed mid-`spawnProcess`/mid-`handshake`.
339-
startup_thread: ?std.Thread = null,
340-
reader_thread: ?std.Thread = null,
341-
stderr_thread: ?std.Thread = null,
342-
dispatch_thread: ?std.Thread = null,
331+
/// Every background task this client owns (`startupThreadMain`, `readerThreadMain`,
332+
/// `stderrDrainThreadMain`, `dispatchThreadMain`) — see `ensureStarted`'s and
333+
/// `shutdownProcess`'s doc comments. One shared group rather than four separate
334+
/// `?std.Thread` fields: `shutdownProcess` needs to wait for all of them together anyway
335+
/// (there's no scenario where only some are running), and `Io.Group.concurrent` dispatches
336+
/// onto `Io.Threaded`'s pooled workers instead of a fresh OS thread per (re)start.
337+
tasks: std.Io.Group = .init,
343338

344339
write_lock: SpinLock = .{},
345340

@@ -1032,17 +1027,20 @@ fn ensureStarted(self: *Client, doc_path: []const u8) bool {
10321027
// non-atomic global the draw thread also *writes* every frame
10331028
// (`Editor.syncLoadedPluginDvuiContexts` → `dvui_context.inject`) — reading it
10341029
// concurrently from another thread would race that writer with no synchronization,
1035-
// risking a torn read of the vtable pointer. A freshly spawned `std.Thread` sees
1036-
// this exact value from the moment it starts running, same guarantee `Io.Group`
1037-
// gave here — this capture is the last read of the global on this thread for this
1030+
// risking a torn read of the vtable pointer. `Io.Group.concurrent` guarantees the
1031+
// task is fully assigned a unit of concurrency before returning (unlike `Io.async`,
1032+
// which is allowed to fall back to running inline on this — the draw — thread under
1033+
// load), so this capture is the last read of the global on this thread for this
10381034
// task; `startupThreadMain` only ever sees its own copy from here on.
10391035
const io = dvui.io;
1040-
self.state.store(.starting, .release);
1041-
self.startup_thread = std.Thread.spawn(.{}, startupThreadMain, .{ self, io }) catch |err| {
1042-
dvui.log.warn("{s}: std.Thread.spawn(startupThreadMain) failed: {any}", .{ self.config.language_id, err });
1043-
self.state.store(.unavailable, .release);
1036+
self.tasks.concurrent(io, startupThreadMain, .{ self, io }) catch |err| {
1037+
// `ConcurrencyUnavailable` is a documented transient condition (pool
1038+
// exhaustion) — leave state as `not_started` so the next hover retries,
1039+
// rather than permanently disabling the client the way `.unavailable` would.
1040+
dvui.log.warn("{s}: Io.Group.concurrent(startupThreadMain) failed: {any}", .{ self.config.language_id, err });
10441041
return false;
10451042
};
1043+
self.state.store(.starting, .release);
10461044
return false;
10471045
},
10481046
}
@@ -1051,7 +1049,7 @@ fn ensureStarted(self: *Client, doc_path: []const u8) bool {
10511049
/// Spawns the subprocess (see `spawnProcess`'s doc comment for why that's safe from a
10521050
/// background thread now — it was not, before `darwin_spawn` replaced the fork-based
10531051
/// `std.process.spawn` on the platform that mattered) and then runs the handshake — both on
1054-
/// this one background thread, so `ensureStarted` never blocks the draw thread.
1052+
/// this one `Io.Group`-dispatched task, so `ensureStarted` never blocks the draw thread.
10551053
fn startupThreadMain(self: *Client, io: std.Io) void {
10561054
// Wakes the UI on a failed start — without this, a `hover()`/etc. call that arrived while
10571055
// zls was still starting up silently no-ops via `ensureStarted` returning false (before
@@ -1202,8 +1200,8 @@ fn handshake(self: *Client, io: std.Io) !void {
12021200
const root = self.workspace_root orelse return error.NoWorkspace;
12031201
const gpa = self.config.allocator;
12041202

1205-
self.reader_thread = try std.Thread.spawn(.{}, readerThreadMain, .{ self, io });
1206-
self.stderr_thread = try std.Thread.spawn(.{}, stderrDrainThreadMain, .{ self, io });
1203+
try self.tasks.concurrent(io, readerThreadMain, .{ self, io });
1204+
try self.tasks.concurrent(io, stderrDrainThreadMain, .{ self, io });
12071205

12081206
const root_uri = try UriUtil.pathToUri(gpa, root);
12091207
defer gpa.free(root_uri);
@@ -1278,7 +1276,7 @@ fn handshake(self: *Client, io: std.Io) !void {
12781276

12791277
try self.sendNotification(io, "initialized", Protocol.EmptyObject{});
12801278

1281-
self.dispatch_thread = try std.Thread.spawn(.{}, dispatchThreadMain, .{ self, io });
1279+
try self.tasks.concurrent(io, dispatchThreadMain, .{ self, io });
12821280
}
12831281

12841282
fn shutdownProcess(self: *Client) void {
@@ -1289,29 +1287,17 @@ fn shutdownProcess(self: *Client) void {
12891287
self.shutdown.store(true, .release);
12901288
if (self.child) |*c| c.kill(io);
12911289

1292-
// Joins every thread this client may have started, `startup_thread` included — not just
1293-
// the three loop threads it starts partway through. Covers shutdown landing while still
1294-
// mid-`spawnProcess`/mid-`handshake` (state `.starting`, reader/stderr/dispatch not started
1295-
// yet): without joining `startup_thread` too, it would keep running past `self`'s lifetime,
1296-
// a real use-after-free. `shutdown`/`child.kill` above are what actually make each loop
1297-
// thread notice and exit promptly — the joins below are purely that wait, not the
1298-
// cancellation signal.
1299-
if (self.startup_thread) |t| {
1300-
t.join();
1301-
self.startup_thread = null;
1302-
}
1303-
if (self.reader_thread) |t| {
1304-
t.join();
1305-
self.reader_thread = null;
1306-
}
1307-
if (self.stderr_thread) |t| {
1308-
t.join();
1309-
self.stderr_thread = null;
1310-
}
1311-
if (self.dispatch_thread) |t| {
1312-
t.join();
1313-
self.dispatch_thread = null;
1314-
}
1290+
// Waits for every task in the group — `startupThreadMain` included, not just the three
1291+
// loop threads it starts partway through. The old per-thread `?std.Thread` fields could
1292+
// only ever join threads that had *already* been spawned by the time shutdown ran; if
1293+
// shutdown landed while still mid-`spawnProcess`/mid-handshake (state `.starting`, reader/
1294+
// stderr/dispatch not started yet), the detached startup thread was never waited on at
1295+
// all — a real use-after-free risk if it kept running past `self`'s lifetime. `Io.Group`
1296+
// tracks the whole family as one unit, so this now genuinely blocks until nothing is left
1297+
// touching `self`, regardless of which stage shutdown catches it at. `shutdown`/`child.kill`
1298+
// above are what actually make each loop thread notice and exit promptly — `await` here is
1299+
// purely the join, not the cancellation signal.
1300+
self.tasks.await(io) catch {};
13151301

13161302
self.child = null;
13171303
self.shutdown.store(false, .release);

0 commit comments

Comments
 (0)