blob: 2887ea3b9f22c5d06412c4ed3147e0288ba1cd07 [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:convert';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/file_system/memory_file_system.dart';
import 'package:analyzer/src/dart/analysis/byte_store.dart';
import 'package:async/async.dart';
import 'package:clock/clock.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:path/path.dart' as p;
import 'package:stream_channel/stream_channel.dart';
import 'resource_provider/resource_provider_ext.dart';
import 'resource_provider/resource_provider_wrap_cwd.dart';
import 'shared.dart' hide FileSystemException;
import 'tools/file_watch.dart';
import 'tools/language_server.dart';
import 'tools/pub.dart';
import 'tools/sandbox.dart';
import 'util/parameters_ext.dart';
import 'worker_protocol_version.dart';
final class Worker {
final ResourceProvider _rp;
final DartPadConfig _config;
final VersionInfo _version;
final _analysisCache = MemoryCachingByteStore(
NullByteStore(),
128 * 1024 * 1024,
);
int _nextLanguageServerId = 1;
int _nextWorkspaceId = 1;
int _nextWatcherId = 1;
int _nextSandboxId = 1;
Worker._(this._rp, this._config, this._version);
static String _readRequiredFile(ResourceProvider rp, String path) {
final file = rp.getFile(path);
if (!file.exists) {
throw FormatException('sdk.tar must contain $path');
}
return file.readAsStringSync().trim();
}
static Future<Worker> create(
Stream<List<int>> sdkTarStream, {
String? pubHostedUrl,
}) async {
final rp = MemoryResourceProvider(context: p.posix);
await rp.getFolder('/').extractTarStream(sdkTarStream);
final configFile = rp.getFile(DartPadConfig.defaultDartPadConfigPath);
if (!configFile.exists) {
throw const FormatException(
'sdk.tar must contain ${DartPadConfig.defaultDartPadConfigPath}',
);
}
final DartPadConfig config;
try {
config = DartPadConfig.fromJson(
jsonDecode(configFile.readAsStringSync()) as Map<String, Object?>,
).copyWith(pubHostedUrl: pubHostedUrl);
} catch (e) {
throw FormatException(
'Error reading ${DartPadConfig.defaultDartPadConfigPath}: $e',
);
}
final dartVersion = _readRequiredFile(
rp,
rp.pathContext.join(config.dartSdkPath, 'version'),
);
final dartRevision = _readRequiredFile(
rp,
rp.pathContext.join(config.dartSdkPath, 'revision'),
);
final properties = <String, String>{};
if (config.flutterSdkPath case final flutterSdkPath?) {
final flutterVersionPath = rp.pathContext.join(
flutterSdkPath,
'bin',
'cache',
'flutter.version.json',
);
final flutterVersionContent = _readRequiredFile(rp, flutterVersionPath);
final Map<String, Object?> flutterVersionJson;
try {
flutterVersionJson =
jsonDecode(flutterVersionContent) as Map<String, Object?>;
} catch (e) {
throw FormatException('Error reading $flutterVersionPath: $e');
}
properties.addAll(extractFlutterVersionProperties(flutterVersionJson));
}
final version = createVersionInfo(
workerProtocolMajor: workerProtocolMajorVersion,
workerProtocolMinor: workerProtocolMinorVersion,
modes: [for (final m in config.modes) m.mode],
dartVersion: dartVersion,
dartRevision: dartRevision,
properties: properties,
);
return Worker._(rp, config, version);
}
void session(StreamChannel<Object?> channel) {
_Session(channel, this);
}
}
class _Session {
static const _idleTimeout = Duration(minutes: 5);
static const _idleCheckInterval = Duration(seconds: 60);
static const _idleConfirmDelay = Duration(seconds: 40);
final Worker _worker;
late final Peer _rpc;
final _workspaces = <int, _Workspace>{};
DateTime _lastActivity = clock.now();
_Session(StreamChannel<Object?> channel, this._worker) {
_rpc = Peer.withoutJson(channel, onUnhandledError: _onUnhandledError);
_registerMethod('version', _version);
_registerMethod('ping', (_) => <String, Object?>{});
_registerMethod('createWorkspace', _createWorkspace);
_registerMethod('workspace/close', _closeWorkspace);
_registerMethod(
'workspace/writeFileFromText',
_forwardToWorkspace((ws) => ws._writeFileFromText),
);
_registerMethod(
'workspace/writeFileFromBytes',
_forwardToWorkspace((ws) => ws._writeFileFromBytes),
);
_registerMethod(
'workspace/readFileAsText',
_forwardToWorkspace((ws) => ws._readFileAsText),
);
_registerMethod(
'workspace/readFileAsBytes',
_forwardToWorkspace((ws) => ws._readFileAsBytes),
);
_registerMethod(
'workspace/deleteFileSystemEntity',
_forwardToWorkspace((ws) => ws._deleteFileSystemEntity),
);
_registerMethod('workspace/stat', _forwardToWorkspace((ws) => ws._stat));
_registerMethod(
'workspace/listDirectory',
_forwardToWorkspace((ws) => ws._listDirectory),
);
_registerMethod(
'workspace/importTarArchive',
_forwardToWorkspace((ws) => ws._importTarArchive),
);
_registerMethod(
'workspace/exportTarArchive',
_forwardToWorkspace((ws) => ws._exportTarArchive),
);
_registerMethod(
'workspace/createFolder',
_forwardToWorkspace((ws) => ws._createFolder),
);
_registerMethod('workspace/pub', _forwardToWorkspace((ws) => ws._pub));
_registerMethod(
'workspace/startLanguageServer',
_forwardToWorkspace((ws) => ws._startLanguageServer),
);
_registerMethod(
'workspace/languageServer/message',
_forwardToWorkspace((ws) => ws._languageServerMessage),
);
_registerMethod(
'workspace/languageServer/close',
_closeInWorkspace((ws) => ws._closeLanguageServer),
);
_registerMethod(
'workspace/startWatcher',
_forwardToWorkspace((ws) => ws._watch),
);
_registerMethod(
'workspace/watcher/close',
_closeInWorkspace((ws) => ws._closeWatcher),
);
_registerMethod(
'workspace/connectSandbox',
_forwardToWorkspace((ws) => ws._connectSandbox),
);
_registerMethod(
'workspace/sandbox/run',
_forwardToWorkspace((ws) => ws._sandboxRun),
);
_registerMethod(
'workspace/sandbox/hotRestart',
_forwardToWorkspace((ws) => ws._sandboxHotRestart),
);
_registerMethod(
'workspace/sandbox/hotReload',
_forwardToWorkspace((ws) => ws._sandboxHotReload),
);
_registerMethod(
'workspace/sandbox/close',
_closeInWorkspace((ws) => ws._sandboxClose),
);
_registerMethod(
'workspace/sandbox/connectServiceProtocol',
_forwardToWorkspace((ws) => ws._sandboxConnectServiceProtocol),
);
// Every 60s, if the session has been idle for at least 5 minutes, wait an
// extra 40s (30s ping interval + 10s slack) and re-check before closing.
// This gives a client that unfroze simultaneously with the worker (or whose
// pings were queued behind a long compile) time to send a ping before the
// session is reaped.
bool isIdle() => clock.now().difference(_lastActivity) >= _idleTimeout;
Timer? confirmTimer;
final idleTimer = Timer.periodic(_idleCheckInterval, (_) {
if (isIdle() && !(confirmTimer?.isActive ?? false)) {
confirmTimer = Timer(_idleConfirmDelay, () {
if (isIdle()) {
_rpc.close().ignore();
}
});
}
});
unawaited(() async {
try {
await _rpc.listen();
} finally {
idleTimer.cancel();
confirmTimer?.cancel();
// Delete all workspaces to cleanup resources
await Future.wait(
_workspaces.values.toList().map((ws) => ws._closeWorkspace()),
);
}
}());
}
void _registerMethod(
String name,
FutureOr<Object?> Function(Parameters) callback,
) {
_rpc.registerMethod(name, (Parameters params) async {
_lastActivity = clock.now();
try {
return await callback(params);
} finally {
_lastActivity = clock.now();
}
});
}
void _sendNotification(String method, Map<String, Object?> params) {
if (!_rpc.isClosed) {
_rpc.sendNotification(method, params);
}
}
Map<String, Object?> _version(Parameters _) => _worker._version.toJson();
Object? _createWorkspace(Parameters params) async {
final workspaceId = _worker._nextWorkspaceId++;
final workspaceFolder = '/workspace/pad_$workspaceId';
_worker._rp.getFolder(workspaceFolder).create();
_workspaces[workspaceId] = _Workspace(
_worker,
this,
workspaceId,
workspaceFolder,
resourceProviderWithCurrentWorkingDirectory(_worker._rp, workspaceFolder),
);
return {'workspaceId': workspaceId, 'workspaceFolder': workspaceFolder};
}
Object? _closeWorkspace(Parameters params) async {
final workspace = _workspaces.remove(params['workspaceId'].asNum.toInt());
if (workspace != null) {
await workspace._closeWorkspace();
}
// Closing a workspace that doesn't exist is a no-op
// This ensures that closing is an idempotent operation!
return <String, Object?>{};
}
Future<Object?> Function(Parameters) _forwardToWorkspace(
Object? Function(Parameters params) Function(_Workspace ws) resolveHandler,
) {
return (Parameters params) async {
final workspaceId = params['workspaceId'].asNum.toInt();
final workspace = _workspaces[workspaceId];
if (workspace == null) {
throw WorkspaceNotFoundException(
'Invalid "workspaceId", no such workspace exists',
data: {'workspaceId': workspaceId},
);
}
return resolveHandler(workspace)(params);
};
}
/// Like [_forwardToWorkspace], but for methods closing a resource held by
/// the workspace. Closing a workspace closes all its resources, so these
/// are a no-op when the workspace doesn't exist.
Future<Object?> Function(Parameters) _closeInWorkspace(
Object? Function(Parameters params) Function(_Workspace ws) resolveHandler,
) {
return (Parameters params) async {
final workspace = _workspaces[params['workspaceId'].asNum.toInt()];
if (workspace == null) {
return <String, Object?>{};
}
return resolveHandler(workspace)(params);
};
}
void _onUnhandledError(Object? e, Object? st) {
print('Unhandled error not forwarded to the client: $e, $st');
}
}
class _Workspace {
final Worker _worker;
final _Session _session;
final int _workspaceId;
final String _workspaceFolder;
final ResourceProvider _rp;
final _languageServers = <int, LanguageServer>{};
final _fileWatches = <int, FileWatch>{};
final _sandboxes = <int, Sandbox>{};
_Workspace(
this._worker,
this._session,
this._workspaceId,
this._workspaceFolder,
this._rp,
);
String _resolvePath(String path) =>
_rp.pathContext.normalize(_rp.pathContext.join(_workspaceFolder, path));
Object? _writeFileFromText(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final text = params['text'].asString;
try {
final file = _rp.getFile(path);
file.writeAsStringSync(text);
} on FileSystemException catch (e) {
throw FileWriteConflictException(e.message, data: {'path': path});
}
return <String, Object?>{};
}
Object? _writeFileFromBytes(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final bytes = params.bytesAsUint8List;
try {
final file = _rp.getFile(path);
file.writeAsBytesSync(bytes);
} on FileSystemException catch (e) {
throw FileWriteConflictException(e.message, data: {'path': path});
}
return <String, Object?>{};
}
Object? _readFileAsText(Parameters params) async {
final path = _resolvePath(params['path'].asString);
try {
return {'text': _rp.getFile(path).readAsStringSync()};
} on FileSystemException catch (e) {
throw FileNotFoundException(e.message, data: {'path': path});
}
}
Object? _readFileAsBytes(Parameters params) async {
final path = _resolvePath(params['path'].asString);
try {
return {'bytes': _rp.getFile(path).readAsBytesSync()};
} on FileSystemException catch (e) {
throw FileNotFoundException(e.message, data: {'path': path});
}
}
Object? _deleteFileSystemEntity(Parameters params) async {
final path = _resolvePath(params['path'].asString);
try {
final resource = _rp.getResource(path);
// Safe to check before delete: synchronous on an in-memory filesystem.
if (resource.exists) {
resource.delete();
}
} on FileSystemException catch (e) {
throw FileDeletionFailedException(e.message, data: {'path': path});
}
return <String, Object?>{};
}
Object? _stat(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final resource = _rp.getResource(path);
if (!resource.exists) {
throw FileNotFoundException(
'File or directory not found',
data: {'path': path},
);
}
if (resource is File) {
return {'type': 'file', 'size': resource.lengthSync};
} else if (resource is Folder) {
return {'type': 'folder'};
} else {
return {'type': 'other'};
}
}
Object? _createFolder(Parameters params) async {
final path = _resolvePath(params['path'].asString);
try {
_rp.getFolder(path).create();
} on FileSystemException catch (e) {
throw FileWriteConflictException(e.message, data: {'path': path});
}
return <String, Object?>{};
}
Object? _listDirectory(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final recursive = params['recursive'].asBoolOr(false);
final ignoreHidden = params['ignoreHidden'].asBoolOr(false);
try {
final folder = _rp.getFolder(path);
if (!folder.exists) {
throw FileSystemException('Directory not found', path);
}
final entries = <Map<String, String>>[];
void traverse(Folder dir) {
for (final child in dir.getChildren()) {
if (ignoreHidden && child.shortName.startsWith('.')) continue;
final relativePath = _rp.pathContext.relative(child.path, from: path);
if (child is File) {
entries.add({'path': relativePath, 'type': 'file'});
} else if (child is Folder) {
entries.add({'path': relativePath, 'type': 'folder'});
if (recursive) {
traverse(child);
}
}
}
}
traverse(folder);
return {'entries': entries};
} on FileSystemException catch (e) {
throw FileNotFoundException(e.message, data: {'path': path});
}
}
Object? _importTarArchive(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final bytes = params.bytesAsUint8List;
await _rp.getFolder(path).extractTarStream(Stream.value(bytes));
return <String, Object?>{};
}
Object? _exportTarArchive(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final folder = _rp.getFolder(path);
if (!folder.exists) {
throw FileNotFoundException('Directory not found', data: {'path': path});
}
return {'bytes': await collectBytes(folder.createTarStream())};
}
Object? _pub(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final command = params['command'].asString;
final args = params['args'].asListOr(const <String>[]);
if (!supportedPubCommands.contains(command)) {
throw RpcException.invalidParams(
'`command` must be one of: ${supportedPubCommands.join(', ')}',
);
}
if (args.any((a) => a is! String)) {
throw RpcException.invalidParams('args must be a list of strings');
}
final (:log) = await pub(
resourceProvider: _rp,
currentWorkingDirectory: path,
command: command,
args: args.whereType<String>().toList(),
config: _worker._config,
version: _worker._version,
);
return {'log': log};
}
Object? _startLanguageServer(Parameters params) async {
final languageServerId = _worker._nextLanguageServerId++;
final ls = _languageServers[languageServerId] = LanguageServer(
resourceProvider: _rp,
config: _worker._config,
byteStore: _worker._analysisCache,
);
ls.messages.listen((m) {
_session._sendNotification('workspace/languageServer/message', {
'workspaceId': _workspaceId,
'languageServerId': languageServerId,
'message': m,
});
});
unawaited(
ls.closed.whenComplete(() {
_languageServers.remove(languageServerId);
_session._sendNotification('workspace/languageServer/exited', {
'workspaceId': _workspaceId,
'languageServerId': languageServerId,
});
}),
);
return {'languageServerId': languageServerId};
}
Object? _languageServerMessage(Parameters params) async {
final languageServerId = params['languageServerId'].asNum.toInt();
final languageServer = _languageServers[languageServerId];
if (languageServer == null) {
throw LanguageServerNotFoundException(
'Language server not found, check the "languageServerId"',
data: {
'workspaceId': _workspaceId,
'languageServerId': languageServerId,
},
);
}
await languageServer.handle(params['message'].asMap.cast());
return <String, Object?>{};
}
Object? _closeLanguageServer(Parameters params) async {
final languageServerId = params['languageServerId'].asNum.toInt();
final languageServer = _languageServers.remove(languageServerId);
await languageServer?.close();
return <String, Object?>{};
}
Object? _watch(Parameters params) async {
final path = _resolvePath(params['path'].asString);
final watcherId = _worker._nextWatcherId++;
_fileWatches[watcherId] = await FileWatch.create(_rp, path, (events) {
_session._sendNotification('workspace/watcher/events', {
'workspaceId': _workspaceId,
'watcherId': watcherId,
'events': events.map((e) => {'type': e.event, 'path': e.path}).toList(),
});
});
return {'watcherId': watcherId};
}
Object? _closeWatcher(Parameters params) async {
final watcherId = params['watcherId'].asNum.toInt();
final fileWatch = _fileWatches.remove(watcherId);
await fileWatch?.close();
return <String, Object?>{};
}
Object? _connectSandbox(Parameters params) async {
final port = params.portAsMessagePort;
final sandboxId = _worker._nextSandboxId++;
final sandbox = _sandboxes[sandboxId] = await Sandbox.create(
port: port,
resourceProvider: _rp,
config: _worker._config,
version: _worker._version,
onClosed: () => _sandboxes.remove(sandboxId),
);
sandbox.onConsole.listen((e) {
_session._sendNotification('workspace/sandbox/console', {
'workspaceId': _workspaceId,
'sandboxId': sandboxId,
'level': e.level,
'message': e.message,
});
});
return {
'sandboxId': sandboxId,
'modes': _worker._config.modes.map((m) => m.mode).toList(),
};
}
Sandbox _getSandbox(Parameters params) {
final id = params['sandboxId'].asNum.toInt();
final s = _sandboxes[id];
if (s == null) {
throw SandboxNotFoundException(
'Sandbox not found',
data: {'sandboxId': id},
);
}
return s;
}
Object? _sandboxRun(Parameters params) async {
final s = _getSandbox(params);
final path = _resolvePath(params['path'].asString);
final mode = params['mode'].asString;
final m = _worker._config.modes.where((m) => m.mode == mode).firstOrNull;
if (m == null) {
throw RpcException.invalidParams('Unsupported mode: "$mode"');
}
final result = await s.run(path, m);
return {'log': result.log};
}
Object? _sandboxHotRestart(Parameters params) async {
final s = _getSandbox(params);
final result = await s.hotRestart();
return {'log': result.log};
}
Object? _sandboxHotReload(Parameters params) async {
final s = _getSandbox(params);
final result = await s.hotReload();
return {'log': result.log};
}
Object? _sandboxClose(Parameters params) async {
final id = params['sandboxId'].asNum.toInt();
final s = _sandboxes.remove(id);
await s?.close();
return <String, Object?>{};
}
Object? _sandboxConnectServiceProtocol(Parameters params) {
final s = _getSandbox(params);
final port = params.portAsMessagePort;
s.connectServiceProtocol(port);
return <String, Object?>{};
}
Future<void> _closeWorkspace() async {
try {
await Future.wait([
..._languageServers.values.map((ls) => ls.close()),
..._fileWatches.values.map((fw) => fw.close()),
..._sandboxes.values.map((s) => s.close()),
]);
} finally {
final folder = _rp.getFolder(_workspaceFolder);
// Safe to check before delete: synchronous on an in-memory filesystem.
if (folder.exists) {
folder.delete();
}
}
}
}