spiegel-keyman/developer/src/tike/main/Keyman.Developer.System.MultiProcess.pas
Marc Durdin 39b7c882a3 feat(developer): Multi-process model for projects 🦕
This is a bit of an omnibus commit, apologies for that. This commit
moves Keyman Developer to a proper multi-process model, where editing
files from multiple projects is handled much more cleanly, with each
project loaded in a separate process.

Files that are edited outside of a project structure are loaded into a
'temporary project' in a single process, which provides a pathway for
existing users who may have legacy files outside the normal Keyman
Developer project model.

There are several components to this:

1. Inter-process communication (Multiprocess, CopyDataHelper units and
   multiprocess test project). These modules establish a method of
   enumerating running Keyman Developer instances (with EnumWindow),
   recording relevant metadata for each instance (by thread id) in the
   registry (registry used because it manages contention without
   additional effort from us), and communicating between processes with
   WM_COPYDATA.
2. Command-line parsing (TikeCommandLine). Determines project ownership
   (ProjectOwningFile) for each filename passed on the command-line, and
   passes these over to existing instances of Keyman Developer that have
   that project loaded, or starts new instances as needed.
3. Temporary project management. Mostly in ProjectUI.

Also, moved GlobalProjectStateWnd management out of ProjectFile.pas and
into Project.pas, alongside other global project variables.

A follow-up will add functionality to determine if a file opened within
the Keyman Developer UI should open in the same instance or in a
separate instance (see TfrmKeymanDeveloper.OpenFileInProject). This will
use the same methodology as TikeCommandLine does now, so may involve
further refactoring.

This commit establishes the idea of 'keyman.kpj' as a future default
filename for Keyman Developer projects, but does not enable it, in
ProjectOwningFile. It is planned to introduce this fixed filename in
version 18.0 (see #10113).
2023-12-01 11:48:09 +07:00

225 lines
5.2 KiB
ObjectPascal

unit Keyman.Developer.System.MultiProcess;
interface
uses
System.Generics.Collections,
System.Win.Registry;
type
TMultiProcessInstance = class
ThreadId: Cardinal;
Handle: THandle;
Identifier: string;
end;
TMultiProcessCoordinator = class
private
FWindowHandles: TList<THandle>;
FProcesses: TObjectList<TMultiProcessInstance>;
FParentWindowClassName: string;
FRegistryKey: string;
function EnumWindowsProc(hwnd: THandle): Boolean;
function OpenActiveProcessKey: TRegistry;
procedure ClearProcessIdentifier;
function CurrentProcessValueName: string;
public
constructor Create(const ParentWindowClassName, RegistryKey: string);
destructor Destroy; override;
procedure Enumerate;
procedure SetProcessIdentifier(const Identifier: string);
procedure CleanupStaleRegisteredInstances;
property Processes: TObjectList<TMultiProcessInstance> read FProcesses;
end;
function MultiProcessCoordinator: TMultiProcessCoordinator;
procedure CreateMultiProcessCoordinator(ParentWindowClassName, RegistryKey: string);
implementation
uses
System.Classes,
System.SysUtils,
Winapi.Windows;
{ TMultiProcessCoordinator }
constructor TMultiProcessCoordinator.Create(const ParentWindowClassName, RegistryKey: string);
begin
inherited Create;
FParentWindowClassName := ParentWindowClassName;
FRegistryKey := RegistryKey;
FWindowHandles := TList<THandle>.Create;
FProcesses := TObjectList<TMultiProcessInstance>.Create;
SetProcessIdentifier('');
end;
destructor TMultiProcessCoordinator.Destroy;
begin
ClearProcessIdentifier;
FWindowHandles.Free;
FProcesses.Free;
inherited Destroy;
end;
function _enumwindowsproc(hwnd: THandle; lParam: LPARAM): BOOL; stdcall;
begin
Result := TMultiProcessCoordinator(lParam).EnumWindowsProc(hwnd);
end;
procedure TMultiProcessCoordinator.Enumerate;
var
h: THandle;
reg: TRegistry;
p: TMultiProcessInstance;
tid: DWord;
begin
// Looks for windows with the matching class name, and then
// matches those with running instances
FWindowHandles.Clear;
FProcesses.Clear;
EnumWindows(@_enumwindowsproc, LPARAM(Self));
reg := OpenActiveProcessKey;
try
for h in FWindowHandles do
begin
tid := GetWindowThreadProcessId(h);
if (tid <> 0) and reg.ValueExists(IntToStr(tid)) then
begin
p := TMultiProcessInstance.Create;
p.ThreadId := tid;
p.Handle := h;
p.Identifier := reg.ReadString(IntToStr(tid));
FProcesses.Add(p);
end;
end;
finally
reg.Free;
end;
end;
function TMultiProcessCoordinator.EnumWindowsProc(hwnd: THandle): Boolean;
var
szbuf: array[0..32] of char;
begin
Result := True;
if GetClassName(hwnd, szbuf, 32) = 0 then
Exit;
if FParentWindowClassName <> szbuf then
Exit;
FWindowHandles.Add(hwnd);
end;
/// Looks for windows with the matching class name, and then
/// matches those with running instances. Stale instances can be left
/// if an instance crashes or if Windows does not shutdown cleanly.
/// There is a slight risk of a race here, if a new process is started
/// between window enumeration and the loop in this function, but it is
/// not worth introducing the extra complexity to avoid this rare situation.
procedure TMultiProcessCoordinator.CleanupStaleRegisteredInstances;
function ProcessListHasTid(tid: Cardinal): Boolean;
var
p: TMultiProcessInstance;
begin
for p in FProcesses do
begin
if p.ThreadId = tid then
begin
Exit(True);
end;
end;
Result := False;
end;
var
reg: TRegistry;
tidString: string;
tid: Cardinal;
tids: TStrings;
begin
Enumerate;
reg := OpenActiveProcessKey;
tids := TStringList.Create;
try
reg.GetValueNames(tids);
for tidString in tids do
begin
tid := StrToIntDef(tidString, 0);
if not ProcessListHasTid(tid) then
begin
reg.DeleteValue(tidString);
end;
end;
finally
tids.Free;
reg.Free;
end;
end;
procedure TMultiProcessCoordinator.SetProcessIdentifier(const Identifier: string);
var
reg: TRegistry;
begin
reg := OpenActiveProcessKey;
try
reg.WriteString(CurrentProcessValueName, Identifier);
finally
reg.Free;
end;
end;
procedure TMultiProcessCoordinator.ClearProcessIdentifier;
var
reg: TRegistry;
begin
reg := OpenActiveProcessKey;
try
if reg.ValueExists(CurrentProcessValueName) then
reg.DeleteValue(CurrentProcessValueName);
finally
reg.Free;
end;
end;
function TMultiProcessCoordinator.OpenActiveProcessKey: TRegistry;
begin
Result := TRegistry.Create;
if not Result.OpenKey(FRegistryKey, True) then
begin
FreeAndNil(Result);
RaiseLastOSError;
end;
end;
function TMultiProcessCoordinator.CurrentProcessValueName: string;
begin
Result := IntToStr(GetCurrentThreadId);
end;
//------------------------------------------------------------------------------
var
FInstance: TMultiProcessCoordinator = nil;
function MultiProcessCoordinator: TMultiProcessCoordinator;
begin
Result := FInstance;
end;
procedure CreateMultiProcessCoordinator(ParentWindowClassName, RegistryKey: string);
begin
Assert(FInstance = nil);
FInstance := TMultiProcessCoordinator.Create(ParentWindowClassName, RegistryKey);
end;
initialization
finalization
FreeAndNil(FInstance);
end.