Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions lib/PuppeteerSharp.Tests/UtilitiesTests/ProcessExitCleanupTests.cs
Original file line number Diff line number Diff line change
@@ -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<Action> _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();
}
}
}
}
50 changes: 50 additions & 0 deletions lib/PuppeteerSharp/Helpers/AppDomainProcessExitEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.Collections.Concurrent;

namespace PuppeteerSharp.Helpers
{
/// <summary>
/// <see cref="IProcessExitEmitter"/> backed by <see cref="AppDomain.ProcessExit"/>.
/// </summary>
internal sealed class AppDomainProcessExitEmitter : IProcessExitEmitter
{
private readonly ConcurrentDictionary<Action, EventHandler> _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;
}
}
}
28 changes: 28 additions & 0 deletions lib/PuppeteerSharp/Helpers/IProcessExitEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;

namespace PuppeteerSharp.Helpers
{
/// <summary>
/// Abstraction over host-process exit notifications, matching Node's
/// <c>process.once('exit')</c> / <c>process.off('exit')</c> for testability.
/// </summary>
internal interface IProcessExitEmitter
{
/// <summary>
/// Gets the number of currently registered exit listeners.
/// </summary>
int ListenerCount { get; }

/// <summary>
/// Registers a one-shot exit listener.
/// </summary>
/// <param name="listener">The listener to invoke on process exit.</param>
void Once(Action listener);

/// <summary>
/// Removes a previously registered exit listener.
/// </summary>
/// <param name="listener">The listener to remove.</param>
void Off(Action listener);
}
}
129 changes: 129 additions & 0 deletions lib/PuppeteerSharp/Helpers/ProcessExitCleanup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;

namespace PuppeteerSharp.Helpers
{
/// <summary>
/// Registers a synchronous fallback for removing a temporary profile when the
/// host process exits before the browser process can run its async cleanup.
/// </summary>
/// <remarks>
/// Port of upstream <c>registerProcessExitCleanup</c> from BrowserLauncher.ts (#15441).
/// </remarks>
internal static class ProcessExitCleanup
{
private static readonly object Gate = new();
private static readonly Dictionary<IProcessExitEmitter, CleanupState> ProcessExitCleanupEntries = new();

/// <summary>
/// Registers synchronous cleanup of <paramref name="userDataDir"/> on process exit.
/// </summary>
/// <param name="userDataDir">Temporary user data directory to remove.</param>
/// <param name="logError">Optional error logger; defaults to <see cref="Debug.WriteLine(object)"/>.</param>
/// <param name="processEmitter">Process-exit source; defaults to <see cref="AppDomainProcessExitEmitter.Instance"/>.</param>
/// <returns>An action that unregisters this cleanup entry.</returns>
public static Action Register(
string userDataDir,
Action<Exception> 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<CleanupEntry>();
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<Exception> logError)
{
public string UserDataDir { get; } = userDataDir;

public Action<Exception> LogError { get; } = logError;
}

private sealed class CleanupState(HashSet<CleanupEntry> entries, Action onExit)
{
public HashSet<CleanupEntry> Entries { get; } = entries;

public Action OnExit { get; } = onExit;
}
}
}
49 changes: 36 additions & 13 deletions lib/PuppeteerSharp/Helpers/TempDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -44,31 +45,53 @@ public void Dispose()

public override string ToString() => Path;

/// <summary>
/// Registers a synchronous host-process-exit fallback that deletes this
/// temporary directory if async cleanup cannot run.
/// </summary>
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()
Expand Down
1 change: 1 addition & 0 deletions lib/PuppeteerSharp/LauncherBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ await ExitCompletionSource.Task.WithTimeout(
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that completes when cleanup finishes.</returns>
internal Task CleanTempUserDataDirAsync()
Expand Down
5 changes: 5 additions & 0 deletions lib/PuppeteerSharp/States/ProcessStartingState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading