diff --git a/README.md b/README.md index a4df11a..a2dddb1 100644 --- a/README.md +++ b/README.md @@ -238,13 +238,15 @@ skills/ ### Lifecycle Safeguards -The server enforces one active desktop-control instance per Windows user profile by taking an exclusive lock at: +The server allows multiple Codex sessions to start their own MCP server process so tool discovery remains available in each session. Input-changing tools still coordinate desktop control by taking an exclusive, renewable lease at: ```text -%LOCALAPPDATA%\CodexComputerRunMCPServer\server.lock +%LOCALAPPDATA%\CodexComputerRunMCPServer\control.lock ``` -If another instance is already running, startup exits with code `2` before exposing MCP tools. This prevents multiple agents from sending mouse, keyboard, and clipboard input to the same desktop at the same time. +The control lease is acquired by `move_mouse`, `click`, `scroll`, `press_key`, `hotkey`, and `type_text`. If another Codex session currently owns the lease, the tool call fails with a busy message instead of allowing simultaneous mouse, keyboard, or clipboard input. Observation tools (`screenshot`, `cursor_position`, and `list_windows`) remain available from every session. + +After the latest control action, the owning process keeps the lease briefly so follow-up clicks or keystrokes from the same session are not interleaved with another session. The lease is also released immediately when the owning MCP process exits. Idle shutdown is disabled by default so long-lived Codex sessions can call the MCP tools later without finding a closed stdio transport. If you explicitly enable idle shutdown, every tool call updates activity state and active calls are never stopped mid-invocation. @@ -252,7 +254,8 @@ Optional environment overrides: | Variable | Default | Detail | |----------|---------|--------| -| `CODEX_COMPUTER_RUN_SINGLE_INSTANCE` | `true` | Set `false` to disable the single-instance lock. | +| `CODEX_COMPUTER_RUN_CONTROL_LOCK` | `true` | Set `false` to disable cross-session desktop-control coordination. | +| `CODEX_COMPUTER_RUN_CONTROL_LEASE_SECONDS` | `60` | Seconds the owning session keeps desktop control after the latest input-changing action. Set `0` to release immediately after each action. | | `CODEX_COMPUTER_RUN_IDLE_SHUTDOWN` | `false` | Set `true` to enable idle shutdown. | | `CODEX_COMPUTER_RUN_IDLE_TIMEOUT_SECONDS` | `300` | Seconds without tool activity before shutdown when idle shutdown is enabled. Values `0` or lower disable idle shutdown. | | `CODEX_COMPUTER_RUN_IDLE_CHECK_INTERVAL_SECONDS` | `10` | Seconds between idle checks. | @@ -353,12 +356,12 @@ dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Test Coverage with TUnit/Microsoft Testing Platform: ```powershell -dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Tests.csproj --configuration Release -- --coverage --coverage-output .\artifacts\test-results\coverage.cobertura.xml --coverage-output-format cobertura --results-directory .\artifacts\test-results +dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Tests.csproj --configuration Release -- --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura --results-directory .\artifacts\test-results ``` Current verification: -- 37 TUnit tests passed. -- Coverage: 87.50% line coverage, 66.16% branch coverage for testable code. +- 40 TUnit tests passed. +- Coverage: 89.09% line coverage, 69.01% branch coverage for testable code. - NuGet package verification confirms `skills/codex-computer-run/SKILL.md` and `skills/codex-computer-run/agents/openai.yaml` are bundled. - Native Win32 P/Invoke shims are excluded from coverage and verified through the service boundary plus live MCP tool discovery. diff --git a/src/CodexComputerRunMCPServer.Tests/ComputerRunLifecycleTests.cs b/src/CodexComputerRunMCPServer.Tests/ComputerRunLifecycleTests.cs index eab8c00..a80d739 100644 --- a/src/CodexComputerRunMCPServer.Tests/ComputerRunLifecycleTests.cs +++ b/src/CodexComputerRunMCPServer.Tests/ComputerRunLifecycleTests.cs @@ -28,6 +28,7 @@ public async Task ActivityTracker_TracksActiveInvocationAndCompletion() [Test] public async Task StaticTools_TrackActivityDuringServiceInvocation() { + using var runtimeLock = await RuntimeMutationLock.AcquireAsync(); var service = new ActivityInspectingService(); var tracker = new ComputerRunActivityTracker(); using var restoreService = ComputerRunToolRuntime.ReplaceServiceForTests(service); @@ -62,7 +63,8 @@ public async Task LifecycleOptions_DefaultKeepsLongLivedMcpTransportsOpen() { var options = ComputerRunLifecycleOptions.Default; - await Assert.That(options.SingleInstanceEnabled).IsTrue(); + await Assert.That(options.ControlLockEnabled).IsTrue(); + await Assert.That(options.ControlLeaseDuration).IsEqualTo(ComputerRunLifecycleOptions.DefaultControlLeaseDuration); await Assert.That(options.IdleShutdownEnabled).IsFalse(); await Assert.That(options.IdleTimeout).IsEqualTo(ComputerRunLifecycleOptions.DefaultIdleTimeout); } @@ -73,7 +75,8 @@ public async Task LifecycleOptions_ReadsConfigurationOverrides() var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["ComputerRun:SingleInstanceEnabled"] = "false", + ["ComputerRun:ControlLockEnabled"] = "false", + ["ComputerRun:ControlLeaseSeconds"] = "0", ["CODEX_COMPUTER_RUN_IDLE_SHUTDOWN"] = "false", ["ComputerRun:IdleTimeoutSeconds"] = "12.5", ["ComputerRun:IdleCheckIntervalSeconds"] = "1", @@ -82,26 +85,75 @@ public async Task LifecycleOptions_ReadsConfigurationOverrides() var options = ComputerRunLifecycleOptions.FromConfiguration(configuration); - await Assert.That(options.SingleInstanceEnabled).IsFalse(); + await Assert.That(options.ControlLockEnabled).IsFalse(); + await Assert.That(options.ControlLeaseDuration).IsEqualTo(TimeSpan.Zero); await Assert.That(options.IdleShutdownEnabled).IsFalse(); await Assert.That(options.IdleTimeout).IsEqualTo(TimeSpan.FromSeconds(12.5)); await Assert.That(options.IdleCheckInterval).IsEqualTo(TimeSpan.FromSeconds(1)); } [Test] - public async Task SingleInstanceGuard_RejectsSecondConcurrentOwner() + public async Task DesktopControlLease_RejectsSecondConcurrentOwner() { var lockFilePath = Path.Combine( Path.GetTempPath(), "codex-computer-run-tests", Guid.NewGuid().ToString("N"), - "server.lock"); + "control.lock"); - using var first = SingleInstanceGuard.TryAcquire(enabled: true, lockFilePath: lockFilePath); - using var second = SingleInstanceGuard.TryAcquire(enabled: true, lockFilePath: lockFilePath); + using var first = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath); + using var second = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath); + using var firstControl = first.BeginControlInvocation(); - await Assert.That(first.HasOwnership).IsTrue(); - await Assert.That(second.HasOwnership).IsFalse(); + await Assert.That(Throws(() => + { + using var secondControl = second.BeginControlInvocation(); + })).IsTrue(); + } + + [Test] + public async Task DesktopControlLease_ReleasesAfterIdleLeaseExpires() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2026, 5, 12, 12, 0, 0, TimeSpan.Zero)); + var lockFilePath = Path.Combine( + Path.GetTempPath(), + "codex-computer-run-tests", + Guid.NewGuid().ToString("N"), + "control.lock"); + + using var first = new DesktopControlLease(enabled: true, TimeSpan.FromDays(1), lockFilePath, clock); + using var second = new DesktopControlLease(enabled: true, TimeSpan.FromDays(1), lockFilePath, clock); + + first.BeginControlInvocation().Dispose(); + clock.Advance(TimeSpan.FromDays(2)); + + await Assert.That(first.ReleaseExpiredIdleLease()).IsTrue(); + using var secondControl = second.BeginControlInvocation(); + } + + [Test] + public async Task StaticTools_ApplyControlLeaseOnlyToInputChangingActions() + { + using var runtimeLock = await RuntimeMutationLock.AcquireAsync(); + var lockFilePath = Path.Combine( + Path.GetTempPath(), + "codex-computer-run-tests", + Guid.NewGuid().ToString("N"), + "control.lock"); + var service = new ControlLeaseInspectingService(); + + using var competingLease = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath); + using var competingControl = competingLease.BeginControlInvocation(); + using var restoreService = ComputerRunToolRuntime.ReplaceServiceForTests(service); + using var restoreLease = ComputerRunToolRuntime.ReplaceControlLeaseForTests( + new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath)); + + _ = ComputerRunTools.cursor_position(); + var moveRejected = Throws(() => ComputerRunTools.move_mouse(1, 2)); + + await Assert.That(moveRejected).IsTrue(); + await Assert.That(service.CursorCalls).IsEqualTo(1); + await Assert.That(service.MoveCalls).IsEqualTo(0); } private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider @@ -140,4 +192,52 @@ public string Click(int? x, int? y, string button, int clicks, double interval, public string ListWindows(int limit) => throw new NotSupportedException(); } + + private sealed class ControlLeaseInspectingService : IComputerRunService + { + public int CursorCalls { get; private set; } + + public int MoveCalls { get; private set; } + + public string CursorPosition() + { + CursorCalls++; + return "{}"; + } + + public string MoveMouse(int x, int y, double? delay) + { + MoveCalls++; + return "move"; + } + + public CallToolResult Screenshot(string? path, bool includeImage) => throw new NotSupportedException(); + + public string Click(int? x, int? y, string button, int clicks, double interval, double? delay) + => throw new NotSupportedException(); + + public string Scroll(int amount, int? x, int? y, double? delay) => throw new NotSupportedException(); + + public string PressKey(string key, double duration, double? delay) => throw new NotSupportedException(); + + public string Hotkey(string keys, double? delay) => throw new NotSupportedException(); + + public string TypeText(string text, double? delay) => throw new NotSupportedException(); + + public string ListWindows(int limit) => throw new NotSupportedException(); + } + + private static bool Throws(Action action) + where TException : Exception + { + try + { + action(); + return false; + } + catch (TException) + { + return true; + } + } } diff --git a/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs b/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs index 36d848a..8577f31 100644 --- a/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs +++ b/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs @@ -13,6 +13,7 @@ public class McpIntegrationTests [Test] public async Task Host_CreatesMcpHostedService() { + using var runtimeLock = await RuntimeMutationLock.AcquireAsync(); using var host = Program.CreateHost([]); var hostedServices = host.Services.GetServices(); @@ -51,8 +52,11 @@ public async Task ToolSurface_HasDescriptionsForCodexToolDiscovery() [Test] public async Task StaticToolFacade_DelegatesToRuntimeService() { + using var runtimeLock = await RuntimeMutationLock.AcquireAsync(); var service = new TestComputerRunService(); using var restore = ComputerRunToolRuntime.ReplaceServiceForTests(service); + using var restoreLease = ComputerRunToolRuntime.ReplaceControlLeaseForTests( + new DesktopControlLease(enabled: false, TimeSpan.Zero)); _ = ComputerRunTools.move_mouse(1, 2); _ = ComputerRunTools.click(button: "middle"); diff --git a/src/CodexComputerRunMCPServer.Tests/RuntimeMutationLock.cs b/src/CodexComputerRunMCPServer.Tests/RuntimeMutationLock.cs new file mode 100644 index 0000000..06e6fa0 --- /dev/null +++ b/src/CodexComputerRunMCPServer.Tests/RuntimeMutationLock.cs @@ -0,0 +1,17 @@ +namespace CodexComputerRunMCPServer.Tests; + +internal static class RuntimeMutationLock +{ + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + public static async Task AcquireAsync() + { + await Semaphore.WaitAsync().ConfigureAwait(false); + return new Releaser(); + } + + private sealed class Releaser : IDisposable + { + public void Dispose() => Semaphore.Release(); + } +} diff --git a/src/CodexComputerRunMCPServer/ComputerRunLifecycleOptions.cs b/src/CodexComputerRunMCPServer/ComputerRunLifecycleOptions.cs index 6f996ce..34ff609 100644 --- a/src/CodexComputerRunMCPServer/ComputerRunLifecycleOptions.cs +++ b/src/CodexComputerRunMCPServer/ComputerRunLifecycleOptions.cs @@ -6,16 +6,23 @@ namespace CodexComputerRunMCPServer; /// /// Configures process lifecycle safeguards for the computer run MCP server. /// -/// Whether startup should reject concurrent server instances. +/// Whether desktop-control actions should be coordinated across server instances. +/// How long a server keeps desktop control after its latest control action. /// Whether the host should stop after a period without tool activity. /// How long the server may remain unused before it shuts down. /// How often idle state is checked. internal sealed record ComputerRunLifecycleOptions( - bool SingleInstanceEnabled, + bool ControlLockEnabled, + TimeSpan ControlLeaseDuration, bool IdleShutdownEnabled, TimeSpan IdleTimeout, TimeSpan IdleCheckInterval) { + /// + /// Default time a session keeps desktop-control ownership after its latest input-changing action. + /// + public static readonly TimeSpan DefaultControlLeaseDuration = TimeSpan.FromSeconds(60); + /// /// Default time without MCP tool use before the server stops itself. /// @@ -30,7 +37,8 @@ internal sealed record ComputerRunLifecycleOptions( /// Gets the default lifecycle options. /// public static ComputerRunLifecycleOptions Default { get; } = new( - SingleInstanceEnabled: true, + ControlLockEnabled: true, + ControlLeaseDuration: DefaultControlLeaseDuration, IdleShutdownEnabled: false, IdleTimeout: DefaultIdleTimeout, IdleCheckInterval: DefaultIdleCheckInterval); @@ -44,11 +52,17 @@ public static ComputerRunLifecycleOptions FromConfiguration(IConfiguration confi { ArgumentNullException.ThrowIfNull(configuration); - var singleInstanceEnabled = ReadBoolean( + var controlLockEnabled = ReadBoolean( + configuration, + "ControlLockEnabled", + "CODEX_COMPUTER_RUN_CONTROL_LOCK", + Default.ControlLockEnabled); + + var controlLeaseDuration = ReadSeconds( configuration, - "SingleInstanceEnabled", - "CODEX_COMPUTER_RUN_SINGLE_INSTANCE", - Default.SingleInstanceEnabled); + "ControlLeaseSeconds", + "CODEX_COMPUTER_RUN_CONTROL_LEASE_SECONDS", + Default.ControlLeaseDuration); var idleShutdownEnabled = ReadBoolean( configuration, @@ -79,7 +93,8 @@ public static ComputerRunLifecycleOptions FromConfiguration(IConfiguration confi } return new ComputerRunLifecycleOptions( - singleInstanceEnabled, + controlLockEnabled, + controlLeaseDuration < TimeSpan.Zero ? Default.ControlLeaseDuration : controlLeaseDuration, idleShutdownEnabled, idleTimeout, idleCheckInterval); diff --git a/src/CodexComputerRunMCPServer/ComputerRunToolRuntime.cs b/src/CodexComputerRunMCPServer/ComputerRunToolRuntime.cs index 817f07c..232908b 100644 --- a/src/CodexComputerRunMCPServer/ComputerRunToolRuntime.cs +++ b/src/CodexComputerRunMCPServer/ComputerRunToolRuntime.cs @@ -8,6 +8,7 @@ internal static class ComputerRunToolRuntime { private static IComputerRunService _service = ComputerRunService.CreateDefault(); private static ComputerRunActivityTracker _activityTracker = new(); + private static DesktopControlLease _controlLease = DesktopControlLease.FromOptions(ComputerRunLifecycleOptions.Default); /// /// Gets the current instance using a volatile read @@ -20,12 +21,23 @@ internal static class ComputerRunToolRuntime /// public static ComputerRunActivityTracker ActivityTracker => Volatile.Read(ref _activityTracker); + /// + /// Gets the desktop-control lease used to coordinate input-changing tool calls. + /// + public static DesktopControlLease ControlLease => Volatile.Read(ref _controlLease); + /// /// Starts tracking a tool invocation until the returned scope is disposed. /// /// An invocation scope that must be disposed when the tool call completes. public static IDisposable BeginToolInvocation() => ActivityTracker.BeginInvocation(); + /// + /// Starts a desktop-control operation, acquiring or renewing exclusive control ownership. + /// + /// An invocation scope that must be disposed when the control operation completes. + public static IDisposable BeginDesktopControlInvocation() => ControlLease.BeginControlInvocation(); + /// /// Replaces the current with the specified instance /// for the duration of a test, restoring the previous service when the returned @@ -61,6 +73,37 @@ internal static IDisposable ReplaceActivityTrackerForTests(ComputerRunActivityTr return new RestoreActivityTracker(activityTracker, previous); } + /// + /// Replaces the current desktop-control lease. + /// + /// The control lease to use. + internal static void ConfigureControlLease(DesktopControlLease controlLease) + { + ArgumentNullException.ThrowIfNull(controlLease); + var previous = Interlocked.Exchange(ref _controlLease, controlLease); + if (!ReferenceEquals(previous, controlLease)) + { + previous.Dispose(); + } + } + + /// + /// Replaces the current desktop-control lease for the duration of a test. + /// + /// The test control lease to use. + /// + /// An that, when disposed, restores the previous lease. + /// + /// + /// Thrown when is . + /// + internal static IDisposable ReplaceControlLeaseForTests(DesktopControlLease controlLease) + { + ArgumentNullException.ThrowIfNull(controlLease); + var previous = Interlocked.Exchange(ref _controlLease, controlLease); + return new RestoreControlLease(controlLease, previous); + } + /// /// Restores a previously held instance when disposed. /// @@ -87,4 +130,23 @@ public void Dispose() _ = Interlocked.CompareExchange(ref _activityTracker, previous, current); } } + + /// + /// Restores a previously held instance when disposed. + /// + private sealed class RestoreControlLease( + DesktopControlLease current, + DesktopControlLease previous) : IDisposable + { + /// + /// Restores the previous instance in a thread-safe manner. + /// + public void Dispose() + { + if (ReferenceEquals(Interlocked.CompareExchange(ref _controlLease, previous, current), current)) + { + current.Dispose(); + } + } + } } diff --git a/src/CodexComputerRunMCPServer/ComputerRunTools.cs b/src/CodexComputerRunMCPServer/ComputerRunTools.cs index 026eeda..f1a8fb6 100644 --- a/src/CodexComputerRunMCPServer/ComputerRunTools.cs +++ b/src/CodexComputerRunMCPServer/ComputerRunTools.cs @@ -47,7 +47,7 @@ public static string move_mouse( [Description("Absolute X coordinate.")] int x, [Description("Absolute Y coordinate.")] int y, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.MoveMouse(x, y, delay)); + => InvokeControl(service => service.MoveMouse(x, y, delay)); /// /// Performs a mouse click at the current cursor position or at provided coordinates. @@ -68,7 +68,7 @@ public static string click( [Description("Number of clicks.")] int clicks = 1, [Description("Delay between repeated clicks, in seconds.")] double interval = 0.08, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.Click(x, y, button, clicks, interval, delay)); + => InvokeControl(service => service.Click(x, y, button, clicks, interval, delay)); /// /// Scrolls the mouse wheel, optionally after moving to specified coordinates. @@ -85,7 +85,7 @@ public static string scroll( [Description("Optional absolute X coordinate to move to before scrolling.")] int? x = null, [Description("Optional absolute Y coordinate to move to before scrolling.")] int? y = null, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.Scroll(amount, x, y, delay)); + => InvokeControl(service => service.Scroll(amount, x, y, delay)); /// /// Presses and releases a single keyboard key. @@ -100,7 +100,7 @@ public static string press_key( [Description("Key name or single character.")] string key, [Description("How long to hold the key, in seconds.")] double duration = 0.03, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.PressKey(key, duration, delay)); + => InvokeControl(service => service.PressKey(key, duration, delay)); /// /// Presses a keyboard shortcut chord such as ctrl+l or ctrl+shift+escape. @@ -113,7 +113,7 @@ public static string press_key( public static string hotkey( [Description("Shortcut text. Use +, comma, or space separators, e.g. ctrl+shift+escape.")] string keys, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.Hotkey(keys, delay)); + => InvokeControl(service => service.Hotkey(keys, delay)); /// /// Pastes Unicode text into the currently focused Windows application via clipboard and Ctrl+V. @@ -126,7 +126,7 @@ public static string hotkey( public static string type_text( [Description("Text to paste into the focused application.")] string text, [Description("Optional delay after the action, in seconds.")] double? delay = null) - => Invoke(service => service.TypeText(text, delay)); + => InvokeControl(service => service.TypeText(text, delay)); /// /// Gets the current cursor position. @@ -153,4 +153,12 @@ private static TResult Invoke(Func action using var invocation = ComputerRunToolRuntime.BeginToolInvocation(); return action(ComputerRunToolRuntime.Service); } + + private static TResult InvokeControl(Func action) + { + ArgumentNullException.ThrowIfNull(action); + using var invocation = ComputerRunToolRuntime.BeginToolInvocation(); + using var control = ComputerRunToolRuntime.BeginDesktopControlInvocation(); + return action(ComputerRunToolRuntime.Service); + } } diff --git a/src/CodexComputerRunMCPServer/DesktopControlLease.cs b/src/CodexComputerRunMCPServer/DesktopControlLease.cs new file mode 100644 index 0000000..524763c --- /dev/null +++ b/src/CodexComputerRunMCPServer/DesktopControlLease.cs @@ -0,0 +1,290 @@ +namespace CodexComputerRunMCPServer; + +/// +/// Coordinates exclusive desktop-control access across concurrently running MCP server processes. +/// +internal sealed class DesktopControlLease : IDisposable +{ + private readonly object _sync = new(); + private readonly TimeProvider _timeProvider; + private readonly Guid _ownerId = Guid.NewGuid(); + private FileStream? _lockFile; + private Timer? _releaseTimer; + private DateTimeOffset _leaseExpiresUtc; + private int _activeControlInvocations; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Whether cross-process desktop-control coordination should be active. + /// How long this process keeps control after its latest control action. + /// Optional lock path used by tests. + /// Optional time provider used by tests. + public DesktopControlLease( + bool enabled, + TimeSpan leaseDuration, + string? lockFilePath = null, + TimeProvider? timeProvider = null) + { + IsEnabled = enabled; + LeaseDuration = leaseDuration < TimeSpan.Zero ? TimeSpan.Zero : leaseDuration; + LockFilePath = Path.GetFullPath( + string.IsNullOrWhiteSpace(lockFilePath) ? DefaultLockFilePath : lockFilePath); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Gets the process-wide desktop-control lease file path used by default. + /// + public static string DefaultLockFilePath { get; } = CreateDefaultLockFilePath(); + + /// + /// Gets a value indicating whether cross-process desktop-control locking is enabled. + /// + public bool IsEnabled { get; } + + /// + /// Gets the idle lease duration after the latest desktop-control action. + /// + public TimeSpan LeaseDuration { get; } + + /// + /// Gets the lease file path used by this instance. + /// + public string LockFilePath { get; } + + /// + /// Creates a lease from configured lifecycle options. + /// + /// The resolved lifecycle options. + /// A configured instance. + public static DesktopControlLease FromOptions(ComputerRunLifecycleOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return new DesktopControlLease(options.ControlLockEnabled, options.ControlLeaseDuration); + } + + /// + /// Begins a desktop-control invocation, acquiring or renewing the process lease. + /// + /// A scope that must be disposed when the control action completes. + /// + /// Thrown when another server process or concurrent invocation owns desktop control. + /// + public IDisposable BeginControlInvocation() + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!IsEnabled) + { + _activeControlInvocations++; + return new ControlInvocationScope(this); + } + + if (_activeControlInvocations > 0) + { + throw CreateBusyException("Another desktop-control operation is already running in this server process."); + } + + if (_lockFile is null && !TryAcquireLockFile()) + { + throw CreateBusyException( + "Another Codex Computer Run MCP session currently owns desktop control."); + } + + _activeControlInvocations = 1; + RenewLease(); + _releaseTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + return new ControlInvocationScope(this); + } + } + + /// + /// Releases the lock immediately when the configured lease has expired and no action is running. + /// + /// when a held lease was released. + internal bool ReleaseExpiredIdleLease() + { + lock (_sync) + { + return ReleaseExpiredIdleLeaseCore(_timeProvider.GetUtcNow()); + } + } + + /// + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + _releaseTimer?.Dispose(); + _releaseTimer = null; + ReleaseLockFile(); + } + } + + private void EndControlInvocation() + { + lock (_sync) + { + if (_activeControlInvocations > 0) + { + _activeControlInvocations--; + } + + if (_activeControlInvocations == 0) + { + ScheduleIdleRelease(); + } + } + } + + private bool TryAcquireLockFile() + { + Directory.CreateDirectory(Path.GetDirectoryName(LockFilePath)!); + + try + { + _lockFile = new FileStream( + LockFilePath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + + return true; + } + catch (IOException) + { + _lockFile = null; + return false; + } + catch (UnauthorizedAccessException) + { + _lockFile = null; + return false; + } + } + + private void RenewLease() + { + _leaseExpiresUtc = _timeProvider.GetUtcNow() + LeaseDuration; + WriteOwnerMetadata(); + } + + private void ScheduleIdleRelease() + { + if (!IsEnabled || _lockFile is null) + { + return; + } + + if (LeaseDuration <= TimeSpan.Zero) + { + ReleaseLockFile(); + return; + } + + _releaseTimer ??= new Timer(_ => ReleaseExpiredIdleLease(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _releaseTimer.Change(LeaseDuration, Timeout.InfiniteTimeSpan); + } + + private bool ReleaseExpiredIdleLeaseCore(DateTimeOffset nowUtc) + { + if (_activeControlInvocations > 0 || _lockFile is null) + { + return false; + } + + if (nowUtc < _leaseExpiresUtc) + { + _releaseTimer?.Change(_leaseExpiresUtc - nowUtc, Timeout.InfiniteTimeSpan); + return false; + } + + ReleaseLockFile(); + return true; + } + + private void ReleaseLockFile() + { + var lockFile = _lockFile; + _lockFile = null; + lockFile?.Dispose(); + + if (lockFile is null) + { + return; + } + + try + { + File.Delete(LockFilePath); + } + catch (IOException) + { + // A stale unlocked file is harmless; the exclusive file handle is the coordination primitive. + } + catch (UnauthorizedAccessException) + { + // A stale unlocked file is harmless; the exclusive file handle is the coordination primitive. + } + } + + private void WriteOwnerMetadata() + { + if (_lockFile is null) + { + return; + } + + var metadata = string.Join( + Environment.NewLine, + $"ownerId={_ownerId:N}", + $"pid={Environment.ProcessId}", + $"leaseExpiresUtc={_leaseExpiresUtc:O}", + $"leaseSeconds={LeaseDuration.TotalSeconds}"); + + using var writer = new StreamWriter(_lockFile, leaveOpen: true); + _lockFile.SetLength(0); + writer.Write(metadata); + writer.Flush(); + _lockFile.Flush(flushToDisk: true); + } + + private InvalidOperationException CreateBusyException(string reason) + => new( + $"{reason} Try again after the active control action completes or after " + + $"{LeaseDuration.TotalSeconds:0.###} second(s) without control input."); + + private static string CreateDefaultLockFilePath() + { + var localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var root = string.IsNullOrWhiteSpace(localApplicationData) + ? Path.GetTempPath() + : localApplicationData; + + return Path.Combine(root, "CodexComputerRunMCPServer", "control.lock"); + } + + private sealed class ControlInvocationScope(DesktopControlLease owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + owner.EndControlInvocation(); + } + } + } +} diff --git a/src/CodexComputerRunMCPServer/Program.cs b/src/CodexComputerRunMCPServer/Program.cs index 9fe00b3..585b405 100644 --- a/src/CodexComputerRunMCPServer/Program.cs +++ b/src/CodexComputerRunMCPServer/Program.cs @@ -18,8 +18,7 @@ public static class Program /// /// A task that resolves to an exit code: /// 0 when the server exits normally; 1 when startup is rejected - /// because the current operating system is not Windows; 2 when another - /// server instance already owns the desktop-control lock. + /// because the current operating system is not Windows. /// /// /// This server is supported only in a signed-in Windows desktop session. @@ -44,16 +43,6 @@ public static async Task Main(string[] args) return result.Success ? 0 : 1; } - var lifecycleOptions = ComputerRunLifecycleOptions.FromEnvironment(); - using var instanceGuard = SingleInstanceGuard.TryAcquire(lifecycleOptions.SingleInstanceEnabled); - if (!instanceGuard.HasOwnership) - { - Console.Error.WriteLine( - "Another CodexComputerRunMCPServer instance is already running. " + - "Only one computer-control MCP server can own the desktop at a time."); - return SingleInstanceGuard.ConcurrentInstanceExitCode; - } - NativeMethods.TryEnablePerMonitorDpiAwareness(); _ = CodexSkillInstaller.TryAutoInstall(Console.Error); @@ -82,7 +71,10 @@ public static IHost CreateHost(string[] args) options.LogToStandardErrorThreshold = LogLevel.Trace; }); - builder.Services.AddSingleton(_ => ComputerRunLifecycleOptions.FromConfiguration(builder.Configuration)); + var lifecycleOptions = ComputerRunLifecycleOptions.FromConfiguration(builder.Configuration); + ComputerRunToolRuntime.ConfigureControlLease(DesktopControlLease.FromOptions(lifecycleOptions)); + + builder.Services.AddSingleton(_ => lifecycleOptions); builder.Services.AddHostedService(); builder.Services diff --git a/src/CodexComputerRunMCPServer/SingleInstanceGuard.cs b/src/CodexComputerRunMCPServer/SingleInstanceGuard.cs deleted file mode 100644 index a6b8996..0000000 --- a/src/CodexComputerRunMCPServer/SingleInstanceGuard.cs +++ /dev/null @@ -1,118 +0,0 @@ -namespace CodexComputerRunMCPServer; - -/// -/// Owns a process-wide lock file that prevents concurrent computer run MCP server instances. -/// -internal sealed class SingleInstanceGuard : IDisposable -{ - private readonly FileStream? _lockFile; - - private SingleInstanceGuard(string lockFilePath, FileStream? lockFile, bool isEnabled) - { - LockFilePath = lockFilePath; - _lockFile = lockFile; - IsEnabled = isEnabled; - } - - /// - /// Gets the exit code used when startup is rejected because another instance is running. - /// - public const int ConcurrentInstanceExitCode = 2; - - /// - /// Gets the process-wide lock file path used by default. - /// - public static string DefaultLockFilePath { get; } = CreateDefaultLockFilePath(); - - /// - /// Gets a value indicating whether single-instance enforcement is enabled. - /// - public bool IsEnabled { get; } - - /// - /// Gets a value indicating whether this process owns the single-instance lock. - /// - public bool HasOwnership => !IsEnabled || _lockFile is not null; - - /// - /// Gets the lock file path used by this guard. - /// - public string LockFilePath { get; } - - /// - /// Attempts to acquire the single-instance guard. - /// - /// Whether single-instance enforcement should be active. - /// Optional lock file path used by tests. - /// A guard describing whether this process owns the lock file. - public static SingleInstanceGuard TryAcquire(bool enabled, string? lockFilePath = null) - { - var resolvedLockFilePath = Path.GetFullPath( - string.IsNullOrWhiteSpace(lockFilePath) ? DefaultLockFilePath : lockFilePath); - - if (!enabled) - { - return new SingleInstanceGuard(resolvedLockFilePath, lockFile: null, isEnabled: false); - } - - Directory.CreateDirectory(Path.GetDirectoryName(resolvedLockFilePath)!); - - try - { - var lockFile = new FileStream( - resolvedLockFilePath, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.None); - - WriteOwnerMetadata(lockFile); - return new SingleInstanceGuard(resolvedLockFilePath, lockFile, isEnabled: true); - } - catch (IOException) - { - return new SingleInstanceGuard(resolvedLockFilePath, lockFile: null, isEnabled: true); - } - } - - /// - public void Dispose() - { - _lockFile?.Dispose(); - - if (_lockFile is not null) - { - try - { - File.Delete(LockFilePath); - } - catch (IOException) - { - // The process is already shutting down; a stale unlocked file is harmless. - } - catch (UnauthorizedAccessException) - { - // The process is already shutting down; a stale unlocked file is harmless. - } - } - } - - private static void WriteOwnerMetadata(FileStream lockFile) - { - var metadata = $"pid={Environment.ProcessId}{Environment.NewLine}startedUtc={DateTimeOffset.UtcNow:O}"; - using var writer = new StreamWriter(lockFile, leaveOpen: true); - lockFile.SetLength(0); - writer.Write(metadata); - writer.Flush(); - lockFile.Flush(flushToDisk: true); - } - - private static string CreateDefaultLockFilePath() - { - var localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var root = string.IsNullOrWhiteSpace(localApplicationData) - ? Path.GetTempPath() - : localApplicationData; - - return Path.Combine(root, "CodexComputerRunMCPServer", "server.lock"); - } -}