diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index b83730c..c411d1f 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -516,6 +516,21 @@ class ExtensionDriverSession { } } + /// Stops every live session backed by [extensionId] (across all its open + /// connections), releasing the driver process and any file handles it + /// holds. Call this before overwriting or deleting an installed + /// extension's files — on Windows a running `bin/.exe` otherwise + /// holds a mandatory lock that makes the overwrite/delete fail (#891). + Future stopAllForExtension(String extensionId) async { + final ids = [ + for (final entry in _manifests.entries) + if (entry.value.id == extensionId) entry.key, + ]; + for (final id in ids) { + await disconnect(id); + } + } + Map _decodeOptions(String? raw) { if (raw == null || raw.trim().isEmpty) return {}; try { diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index ee27345..e8b0f3f 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -21,15 +22,22 @@ class LocalExtensionInstaller { LocalExtensionInstaller({ Future Function()? extensionsDirectory, Future Function()? reloadRegistry, + Future Function(String extensionId)? stopSessionsForExtension, }) : _extensionsDirectory = extensionsDirectory ?? ExtensionPaths.ensureExtensionsDirectory, - _reloadRegistry = reloadRegistry ?? _defaultReloadRegistry; + _reloadRegistry = reloadRegistry ?? _defaultReloadRegistry, + _stopSessionsForExtension = + stopSessionsForExtension ?? _defaultStopSessionsForExtension; static Future _defaultReloadRegistry() => LocalExtensionRegistry.instance.reload(); + static Future _defaultStopSessionsForExtension(String extensionId) => + ExtensionDriverSession.instance.stopAllForExtension(extensionId); + final Future Function() _extensionsDirectory; final Future Function() _reloadRegistry; + final Future Function(String extensionId) _stopSessionsForExtension; /// Reads [archiveFile], validates, extracts under `extensions//`, reloads. Future installFromArchive( @@ -107,6 +115,11 @@ class LocalExtensionInstaller { onProgress?.call(0.4); + // A running driver process for this extension holds its executable/DLL + // open; on Windows that mandatory lock makes the overwrite below fail + // with Access is denied (#891). Stop every live session first. + await _stopSessionsForExtension(manifest.id); + final root = await _extensionsDirectory(); final extDir = Directory(p.join(root.path, manifest.id)); if (await extDir.exists()) { diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 0321021..7ae19fb 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -6,6 +6,7 @@ import 'package:archive/archive.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; @@ -257,6 +258,11 @@ class HttpMarketplaceRepository implements MarketplaceRepository { @override Future uninstall(String extensionId) async { + // A running driver process for this extension holds its executable/DLL + // open; on Windows that mandatory lock makes the delete below fail with + // Access is denied (#891). Stop every live session first. + await ExtensionDriverSession.instance.stopAllForExtension(extensionId); + final manifest = LocalExtensionRegistry.instance.manifests .where((e) => e.id == extensionId) .firstOrNull; diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 94481f9..fc4de3e 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; @@ -303,7 +304,12 @@ class MockMarketplaceRepository implements MarketplaceRepository { @override Future uninstall(String extensionId) async { await Future.delayed(const Duration(milliseconds: 150)); - + + // A running driver process for this extension holds its executable/DLL + // open; on Windows that mandatory lock makes the delete below fail with + // Access is denied (#891). Stop every live session first. + await ExtensionDriverSession.instance.stopAllForExtension(extensionId); + // Check local extension registry first final manifest = LocalExtensionRegistry.instance.manifests .where((e) => e.id == extensionId) diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index 7f7fce0..3b186f7 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -16,6 +16,12 @@ void main() { await ExtensionDriverSession.instance.disconnectAll(); }); + test('stopAllForExtension is a no-op when no session exists (#891)', + () async { + await ExtensionDriverSession.instance + .stopAllForExtension('queryahub.no-such-driver'); + }); + test('ConnectionRow.isExtensionDriver', () { const withExt = ConnectionRow( type: 'clickhouse', diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart index 10239df..81bc624 100644 --- a/test/core/extensions/local_extension_installer_test.dart +++ b/test/core/extensions/local_extension_installer_test.dart @@ -271,5 +271,46 @@ void main() { final mode = await entry.stat().then((s) => s.mode); expect(mode & 0x111, isNot(0)); }); + + test( + 'stops running driver sessions for the extension before overwriting its directory (#891)', + () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.reinstall-driver', + 'name': 'Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + final zip = await _writeZip(tempDir, archive, 'reinstall.zip'); + + final extDir = Directory(p.join(tempDir.path, 'test.reinstall-driver')); + final calls = []; + var extDirExistedWhenStopped = false; + + final installer = LocalExtensionInstaller( + stopSessionsForExtension: (extensionId) async { + calls.add(extensionId); + // A prior install of this same extension is already on disk when + // we're asked to stop its sessions, and it must still be there: + // the whole point is stopping the process *before* the directory + // gets deleted/overwritten, not after. + extDirExistedWhenStopped = await extDir.exists(); + }, + ); + + // Pre-existing install of the same extension id, simulating a running + // session that would otherwise lock its files on Windows. + await extDir.create(recursive: true); + await File(p.join(extDir.path, 'stale.txt')).writeAsString('old'); + + await installer.installFromArchive(zip); + + expect(calls, ['test.reinstall-driver']); + expect(extDirExistedWhenStopped, isTrue); + expect(await File(p.join(extDir.path, 'stale.txt')).exists(), isFalse); + }); }); }