move daemon services to backend isolate
The merged UI/platform thread on macOS (Flutter 3.41) freezes on any synchronous work — file I/O, Process.run, even isolate spawning during early frames. No timing workaround (Timer, addPostFrameCallback, Future.delayed) was reliable. Fix: spawn a backend isolate that owns the DaemonDispatcher, GitClient, PqlClient, FilesService, EditorRegistry, and Toolchain resolution. The main isolate stays free for rendering. Communication uses SendPort with the existing IPC message protocol (IpcRequest/IpcResponse/IpcEvent) — zero new serialization. New files: backend.dart — spawns isolate, manages SendPort/ReceivePort backend_entry.dart — isolate entry point, boots all services isolate_client.dart — replaces InProcessClient for production Toolchain now exposes resolveToolchainPaths() as a top-level function with self-contained PATH expansion (no module-level state that would prevent isolate message passing). ClideTestApp gains boot-sequence tests: compute(), Isolate.run(), sequential Process.run, and the full resolve+exec chain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9fff157c9e
commit
a89910dc36
@@ -0,0 +1,91 @@
|
||||
/// Manages the backend isolate lifecycle.
|
||||
///
|
||||
/// Call [spawn] to start the backend, which resolves the toolchain and
|
||||
/// boots all daemon services in a separate isolate. Returns when the
|
||||
/// backend is ready to accept requests.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/backend_entry.dart';
|
||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
|
||||
class Backend {
|
||||
Backend._({
|
||||
required this.client,
|
||||
required this.toolchain,
|
||||
required Isolate isolate,
|
||||
required ReceivePort receivePort,
|
||||
}) : _isolate = isolate,
|
||||
_receivePort = receivePort;
|
||||
|
||||
final IsolateClient client;
|
||||
final Toolchain toolchain;
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _receivePort;
|
||||
|
||||
/// Spawn the backend isolate and wait for it to be ready.
|
||||
static Future<Backend> spawn({
|
||||
required String workspaceRoot,
|
||||
required IsolateClient Function(SendPort backendPort) clientFactory,
|
||||
}) async {
|
||||
final receivePort = ReceivePort();
|
||||
final completer = Completer<Backend>();
|
||||
|
||||
late final IsolateClient client;
|
||||
late final Isolate isolate;
|
||||
|
||||
receivePort.listen((message) {
|
||||
if (message is Map<String, Object?>) {
|
||||
final type = message['type'] as String?;
|
||||
if (type == 'ready') {
|
||||
// Backend is booted — extract its request port and toolchain state.
|
||||
final requestPort = message['requestPort'] as SendPort;
|
||||
client = clientFactory(requestPort);
|
||||
|
||||
// Apply toolchain state from the backend.
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
ptyc: tcData['ptyc'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(Backend._(
|
||||
client: client,
|
||||
toolchain: toolchain,
|
||||
isolate: isolate,
|
||||
receivePort: receivePort,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Response or event — forward to the client.
|
||||
client.handleMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isolate = await Isolate.spawn(
|
||||
backendEntry,
|
||||
BackendBootMessage(
|
||||
frontendPort: receivePort.sendPort,
|
||||
workspaceRoot: workspaceRoot,
|
||||
),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Shut down the backend isolate.
|
||||
void dispose() {
|
||||
_isolate.kill(priority: Isolate.beforeNextEvent);
|
||||
_receivePort.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/// Backend isolate entry point.
|
||||
///
|
||||
/// Boots the daemon dispatcher, toolchain, and all subprocess services.
|
||||
/// Communicates with the main isolate via SendPort (responses + events)
|
||||
/// and ReceivePort (incoming requests).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/daemon/files_commands.dart' show FilesService;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
|
||||
/// Message sent from main isolate to bootstrap the backend.
|
||||
class BackendBootMessage {
|
||||
const BackendBootMessage({
|
||||
required this.frontendPort,
|
||||
required this.workspaceRoot,
|
||||
});
|
||||
final SendPort frontendPort;
|
||||
final String workspaceRoot;
|
||||
}
|
||||
|
||||
/// Top-level entry point for the backend isolate.
|
||||
void backendEntry(BackendBootMessage boot) {
|
||||
final frontendPort = boot.frontendPort;
|
||||
final workspaceRoot = boot.workspaceRoot;
|
||||
|
||||
// Set up the receive port for incoming requests from the frontend.
|
||||
final requestPort = ReceivePort();
|
||||
|
||||
// Resolve toolchain (file I/O — safe here, not on UI thread).
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(workspaceRoot));
|
||||
|
||||
// Boot the dispatcher and all services.
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _IsolateEventSink(frontendPort);
|
||||
final workDir = Directory(workspaceRoot);
|
||||
|
||||
final filesService = FilesService(root: workDir, events: eventSink);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workDir);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workDir);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
|
||||
final pql = PqlClient(workDir: workDir, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain);
|
||||
|
||||
// Listen for requests from the frontend.
|
||||
requestPort.listen((message) async {
|
||||
if (message is Map<String, Object?>) {
|
||||
final req = IpcRequest.fromJson(message);
|
||||
final resp = await dispatcher.dispatch(req);
|
||||
frontendPort.send(resp.toJson());
|
||||
}
|
||||
});
|
||||
|
||||
// Tell the frontend we're ready, and give it our request port.
|
||||
frontendPort.send({
|
||||
'type': 'ready',
|
||||
'requestPort': requestPort.sendPort,
|
||||
'toolchain': {
|
||||
'git': toolchain.git,
|
||||
'pql': toolchain.pql,
|
||||
'tmux': toolchain.tmux,
|
||||
'ptyc': toolchain.ptyc,
|
||||
'shell': toolchain.shell,
|
||||
'gitEnv': toolchain.gitEnv,
|
||||
'missing': toolchain.missing,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Sends IPC events to the frontend via SendPort.
|
||||
class _IsolateEventSink implements DaemonEventSink {
|
||||
_IsolateEventSink(this._port);
|
||||
final SendPort _port;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_port.send(event.toJson());
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ class KernelServices {
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||
DaemonClient? isolateClient,
|
||||
bool autoStartDaemonClient = true,
|
||||
Toolchain? toolchain,
|
||||
}) async {
|
||||
@@ -148,13 +149,14 @@ class KernelServices {
|
||||
settings: settings,
|
||||
toolchain: tc,
|
||||
);
|
||||
final ipc = daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
final ipc = isolateClient
|
||||
?? (daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
));
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/// IPC client that sends requests to a backend isolate via SendPort.
|
||||
///
|
||||
/// Replaces [InProcessClient] for production use. The backend isolate
|
||||
/// owns the [DaemonDispatcher] and all subprocess/file-I/O services.
|
||||
/// Requests and responses travel as serialized Maps over SendPort,
|
||||
/// reusing the existing IPC protocol (IpcRequest/IpcResponse/IpcEvent).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
|
||||
class IsolateClient extends DaemonClient {
|
||||
IsolateClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required SendPort backendPort,
|
||||
}) : _backendPort = backendPort,
|
||||
_events = events,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
|
||||
final SendPort _backendPort;
|
||||
final DaemonBus _events;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
int _nextId = 0;
|
||||
|
||||
/// Called by [Backend] to feed incoming messages from the backend isolate.
|
||||
void handleMessage(Map<String, Object?> msg) {
|
||||
final type = msg['type'] as String?;
|
||||
switch (type) {
|
||||
case 'response':
|
||||
final resp = IpcResponse.fromJson(msg);
|
||||
final c = _pending.remove(resp.id);
|
||||
if (c != null && !c.isCompleted) c.complete(resp);
|
||||
case 'event':
|
||||
final evt = IpcEvent.fromJson(msg);
|
||||
_events.emit(DaemonEvent(
|
||||
subsystem: evt.subsystem,
|
||||
kind: evt.kind,
|
||||
data: evt.data,
|
||||
ts: evt.timestamp,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
final c = Completer<IpcResponse>();
|
||||
_pending[id] = c;
|
||||
_backendPort.send(req.toJson());
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../src/pty/env.dart' show expandedPath;
|
||||
|
||||
/// Serializable result of tool resolution (crosses isolate boundary).
|
||||
class ResolvedPaths {
|
||||
const ResolvedPaths({
|
||||
@@ -129,7 +127,7 @@ class Toolchain extends ChangeNotifier {
|
||||
}
|
||||
|
||||
static String? _findOnPath(String name) {
|
||||
for (final dir in expandedPath.split(':')) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
@@ -137,6 +135,23 @@ class Toolchain extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
static String _expandedPath() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
static String? _firstExisting(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
@@ -144,3 +159,72 @@ class Toolchain extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level function for compute/isolate use. Takes a single String
|
||||
/// argument (the workspace root) and returns a plain-data result.
|
||||
ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExistingStandalone(['$dugite/git']);
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPathStandalone('git');
|
||||
}
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: _findOnPathStandalone('pql'),
|
||||
tmux: _findOnPathStandalone('tmux'),
|
||||
ptyc: _firstExistingStandalone([
|
||||
'$workspaceRoot/ptyc/bin/ptyc',
|
||||
'$workspaceRoot/native/linux-x64/ptyc',
|
||||
'$workspaceRoot/native/macos-arm64/ptyc',
|
||||
'$workspaceRoot/native/macos-x64/ptyc',
|
||||
if (Platform.environment['HOME'] case final home?)
|
||||
'$home/.local/bin/ptyc',
|
||||
]) ?? _findOnPathStandalone('ptyc'),
|
||||
shell: _findOnPathStandalone(
|
||||
Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
String? _findOnPathStandalone(String name) {
|
||||
for (final dir in _expandedPathStandalone().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstExistingStandalone(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPathStandalone() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user