From fffcab22c8136a6aa11e312efb6de7b02df66387 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 13:12:03 +0000 Subject: [PATCH 1/2] Fix: Clean temporary profiles on process exit (#15441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port upstream puppeteer#15441: register a synchronous host-process-exit fallback that removes temporary browser profiles when async cleanup cannot run, and unregister after normal cleanup completes. Co-authored-by: Darío Kondratiuk --- .../UtilitiesTests/ProcessExitCleanupTests.cs | 76 +++++++++++ .../Helpers/AppDomainProcessExitEmitter.cs | 50 +++++++ .../Helpers/IProcessExitEmitter.cs | 28 ++++ .../Helpers/ProcessExitCleanup.cs | 129 ++++++++++++++++++ lib/PuppeteerSharp/Helpers/TempDirectory.cs | 49 +++++-- lib/PuppeteerSharp/LauncherBase.cs | 1 + .../States/ProcessStartingState.cs | 5 + 7 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 lib/PuppeteerSharp.Tests/UtilitiesTests/ProcessExitCleanupTests.cs create mode 100644 lib/PuppeteerSharp/Helpers/AppDomainProcessExitEmitter.cs create mode 100644 lib/PuppeteerSharp/Helpers/IProcessExitEmitter.cs create mode 100644 lib/PuppeteerSharp/Helpers/ProcessExitCleanup.cs diff --git a/lib/PuppeteerSharp.Tests/UtilitiesTests/ProcessExitCleanupTests.cs b/lib/PuppeteerSharp.Tests/UtilitiesTests/ProcessExitCleanupTests.cs new file mode 100644 index 000000000..4df4dbe84 --- /dev/null +++ b/lib/PuppeteerSharp.Tests/UtilitiesTests/ProcessExitCleanupTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using PuppeteerSharp.Helpers; + +namespace PuppeteerSharp.Tests.UtilitiesTests +{ + public class ProcessExitCleanupTests + { + [Test] + public void RemovesATemporaryProfileSynchronouslyOnProcessExit() + { + var userDataDir = Directory.CreateTempSubdirectory("puppeteer-process-exit-cleanup-").FullName; + File.WriteAllText(Path.Combine(userDataDir, "profile"), "profile"); + var processEmitter = new ProcessEmitter(); + + ProcessExitCleanup.Register(userDataDir, _ => { }, processEmitter); + processEmitter.Emit(); + + Assert.That(Directory.Exists(userDataDir), Is.False); + } + + [Test] + public void CanUnregisterCleanupAfterTheBrowserProcessExits() + { + var userDataDir = Directory.CreateTempSubdirectory("puppeteer-process-exit-cleanup-").FullName; + var processEmitter = new ProcessEmitter(); + var unregister = ProcessExitCleanup.Register(userDataDir, _ => { }, processEmitter); + + unregister(); + processEmitter.Emit(); + + Assert.That(Directory.Exists(userDataDir), Is.True); + Directory.Delete(userDataDir, recursive: true); + } + + [Test] + public void UsesOneProcessExitListenerForMultipleTemporaryProfiles() + { + var firstDir = Directory.CreateTempSubdirectory("puppeteer-process-exit-cleanup-").FullName; + var secondDir = Directory.CreateTempSubdirectory("puppeteer-process-exit-cleanup-").FullName; + var processEmitter = new ProcessEmitter(); + + ProcessExitCleanup.Register(firstDir, _ => { }, processEmitter); + ProcessExitCleanup.Register(secondDir, _ => { }, processEmitter); + Assert.That(processEmitter.ListenerCount, Is.EqualTo(1)); + + processEmitter.Emit(); + + Assert.That(Directory.Exists(firstDir), Is.False); + Assert.That(Directory.Exists(secondDir), Is.False); + } + + private sealed class ProcessEmitter : IProcessExitEmitter + { + private readonly HashSet _listeners = []; + + public int ListenerCount => _listeners.Count; + + public void Once(Action listener) => _listeners.Add(listener); + + public void Off(Action listener) => _listeners.Remove(listener); + + public void Emit() + { + foreach (var listener in _listeners) + { + listener(); + } + + _listeners.Clear(); + } + } + } +} diff --git a/lib/PuppeteerSharp/Helpers/AppDomainProcessExitEmitter.cs b/lib/PuppeteerSharp/Helpers/AppDomainProcessExitEmitter.cs new file mode 100644 index 000000000..d8b484518 --- /dev/null +++ b/lib/PuppeteerSharp/Helpers/AppDomainProcessExitEmitter.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Concurrent; + +namespace PuppeteerSharp.Helpers +{ + /// + /// backed by . + /// + internal sealed class AppDomainProcessExitEmitter : IProcessExitEmitter + { + private readonly ConcurrentDictionary _handlers = new(); + + public static AppDomainProcessExitEmitter Instance { get; } = new(); + + public int ListenerCount => _handlers.Count; + + public void Once(Action listener) + { + if (listener == null) + { + throw new ArgumentNullException(nameof(listener)); + } + + EventHandler handler = null; + handler = (_, _) => + { + AppDomain.CurrentDomain.ProcessExit -= handler; + _handlers.TryRemove(listener, out _); + listener(); + }; + + if (!_handlers.TryAdd(listener, handler)) + { + return; + } + + AppDomain.CurrentDomain.ProcessExit += handler; + } + + public void Off(Action listener) + { + if (listener == null || !_handlers.TryRemove(listener, out var handler)) + { + return; + } + + AppDomain.CurrentDomain.ProcessExit -= handler; + } + } +} diff --git a/lib/PuppeteerSharp/Helpers/IProcessExitEmitter.cs b/lib/PuppeteerSharp/Helpers/IProcessExitEmitter.cs new file mode 100644 index 000000000..78bf84468 --- /dev/null +++ b/lib/PuppeteerSharp/Helpers/IProcessExitEmitter.cs @@ -0,0 +1,28 @@ +using System; + +namespace PuppeteerSharp.Helpers +{ + /// + /// Abstraction over host-process exit notifications, matching Node's + /// process.once('exit') / process.off('exit') for testability. + /// + internal interface IProcessExitEmitter + { + /// + /// Gets the number of currently registered exit listeners. + /// + int ListenerCount { get; } + + /// + /// Registers a one-shot exit listener. + /// + /// The listener to invoke on process exit. + void Once(Action listener); + + /// + /// Removes a previously registered exit listener. + /// + /// The listener to remove. + void Off(Action listener); + } +} diff --git a/lib/PuppeteerSharp/Helpers/ProcessExitCleanup.cs b/lib/PuppeteerSharp/Helpers/ProcessExitCleanup.cs new file mode 100644 index 000000000..ea70a5bac --- /dev/null +++ b/lib/PuppeteerSharp/Helpers/ProcessExitCleanup.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; + +namespace PuppeteerSharp.Helpers +{ + /// + /// Registers a synchronous fallback for removing a temporary profile when the + /// host process exits before the browser process can run its async cleanup. + /// + /// + /// Port of upstream registerProcessExitCleanup from BrowserLauncher.ts (#15441). + /// + internal static class ProcessExitCleanup + { + private static readonly object Gate = new(); + private static readonly Dictionary ProcessExitCleanupEntries = new(); + + /// + /// Registers synchronous cleanup of on process exit. + /// + /// Temporary user data directory to remove. + /// Optional error logger; defaults to . + /// Process-exit source; defaults to . + /// An action that unregisters this cleanup entry. + public static Action Register( + string userDataDir, + Action logError = null, + IProcessExitEmitter processEmitter = null) + { + if (string.IsNullOrEmpty(userDataDir)) + { + throw new ArgumentException("Path must be specified", nameof(userDataDir)); + } + + processEmitter ??= AppDomainProcessExitEmitter.Instance; + logError ??= static ex => Debug.WriteLine(ex); + + lock (Gate) + { + if (!ProcessExitCleanupEntries.TryGetValue(processEmitter, out var cleanup)) + { + var entries = new HashSet(); + void OnExit() + { + CleanupEntry[] snapshot; + lock (Gate) + { + snapshot = new CleanupEntry[entries.Count]; + entries.CopyTo(snapshot); + } + + foreach (var entry in snapshot) + { + try + { + DeleteDirectorySync(entry.UserDataDir); + } + catch (Exception error) + { + entry.LogError(error); + } + } + } + + cleanup = new CleanupState(entries, OnExit); + ProcessExitCleanupEntries[processEmitter] = cleanup; + processEmitter.Once(OnExit); + } + + var entry = new CleanupEntry(userDataDir, logError); + cleanup.Entries.Add(entry); + + return () => + { + lock (Gate) + { + if (!cleanup.Entries.Remove(entry) || cleanup.Entries.Count > 0) + { + return; + } + + processEmitter.Off(cleanup.OnExit); + ProcessExitCleanupEntries.Remove(processEmitter); + } + }; + } + } + + private static void DeleteDirectorySync(string path) + { + const int maxRetries = 3; + const int retryDelayMs = 100; + + for (var attempt = 0; ; attempt++) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + + return; + } + catch (Exception) when (attempt < maxRetries - 1) + { + Thread.Sleep(retryDelayMs); + } + } + } + + private sealed class CleanupEntry(string userDataDir, Action logError) + { + public string UserDataDir { get; } = userDataDir; + + public Action LogError { get; } = logError; + } + + private sealed class CleanupState(HashSet entries, Action onExit) + { + public HashSet Entries { get; } = entries; + + public Action OnExit { get; } = onExit; + } + } +} diff --git a/lib/PuppeteerSharp/Helpers/TempDirectory.cs b/lib/PuppeteerSharp/Helpers/TempDirectory.cs index f66c0988d..5fb528c6a 100644 --- a/lib/PuppeteerSharp/Helpers/TempDirectory.cs +++ b/lib/PuppeteerSharp/Helpers/TempDirectory.cs @@ -12,6 +12,7 @@ namespace PuppeteerSharp.Helpers internal sealed class TempDirectory : IDisposable { private int _disposed; + private Action _unregisterProcessExitCleanup; public TempDirectory() : this(PathHelper.Combine(PathHelper.GetTempPath(), PathHelper.GetRandomFileName())) @@ -44,31 +45,53 @@ public void Dispose() public override string ToString() => Path; + /// + /// Registers a synchronous host-process-exit fallback that deletes this + /// temporary directory if async cleanup cannot run. + /// + public void RegisterProcessExitCleanup() + { + _unregisterProcessExitCleanup ??= ProcessExitCleanup.Register(Path); + } + public async Task DeleteAsync() { const int maxRetries = 10; const int retryDelayInMillis = 100; const int maxDelayInMillis = 8000; - var retryDelay = retryDelayInMillis; - var attempt = 0; - while (Directory.Exists(Path)) + try { - try + var retryDelay = retryDelayInMillis; + var attempt = 0; + while (Directory.Exists(Path)) { - Directory.Delete(Path, true); - return; - } - catch - { - await Task.Delay(retryDelay).ConfigureAwait(false); - attempt++; - if (attempt >= maxRetries && retryDelay < maxDelayInMillis) + try + { + Directory.Delete(Path, true); + return; + } + catch { - retryDelay = Math.Min(2 * retryDelay, maxDelayInMillis); + await Task.Delay(retryDelay).ConfigureAwait(false); + attempt++; + if (attempt >= maxRetries && retryDelay < maxDelayInMillis) + { + retryDelay = Math.Min(2 * retryDelay, maxDelayInMillis); + } } } } + finally + { + UnregisterProcessExitCleanup(); + } + } + + private void UnregisterProcessExitCleanup() + { + var unregister = Interlocked.Exchange(ref _unregisterProcessExitCleanup, null); + unregister?.Invoke(); } private void DisposeCore() diff --git a/lib/PuppeteerSharp/LauncherBase.cs b/lib/PuppeteerSharp/LauncherBase.cs index 42550fee6..eab8bbeb2 100644 --- a/lib/PuppeteerSharp/LauncherBase.cs +++ b/lib/PuppeteerSharp/LauncherBase.cs @@ -164,6 +164,7 @@ await ExitCompletionSource.Task.WithTimeout( /// /// Deletes the temporary user data directory if one was created for this launch. /// Cleanup errors are swallowed so they cannot become uncaught exceptions. + /// Also unregisters any process-exit cleanup fallback. /// /// A task that completes when cleanup finishes. internal Task CleanTempUserDataDirAsync() diff --git a/lib/PuppeteerSharp/States/ProcessStartingState.cs b/lib/PuppeteerSharp/States/ProcessStartingState.cs index e6f8e6ecb..7ce9c3d65 100644 --- a/lib/PuppeteerSharp/States/ProcessStartingState.cs +++ b/lib/PuppeteerSharp/States/ProcessStartingState.cs @@ -79,6 +79,11 @@ void OnProcessExitedWhileStarting(object sender, EventArgs e) try { p.Process.Start(); + + // Register after the browser process has started so a host-process + // exit can still remove the temporary profile synchronously when + // async cleanup cannot run (upstream #15441). + p.TempUserDataDir?.RegisterProcessExitCleanup(); } catch (Exception ex) { From 8a1aadaaece27fffa0c674ffbb3c7003707ef210 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 17:59:08 +0000 Subject: [PATCH 2/2] chore: retrigger CI after unrelated flakes on #3591 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome pipe: PageGoBackTests.ShouldWork NRE (GoForward null). Firefox headful: host abort (Request Interception) + WaitTask flakes. Co-authored-by: Darío Kondratiuk