@@ -328,18 +328,13 @@ encoding: Protocol.PositionEncoding = .utf16,
328328completion_resolve_supported : bool = false ,
329329shutdown : 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
344339write_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.
10551053fn 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
12841282fn 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