blob: 14d80ac080e86a1a5a7d68849b903ce435fe20d1 [file] [edit]
/*
* Copyright (C) 2026 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
#include "config.h"
#include "SessionHost.h"
#if PLATFORM(COCOA)
#include "Logging.h"
#include "WebDriverService.h"
#include <algorithm>
#include <array>
#include <crt_externs.h>
#include <fcntl.h>
#include <limits.h>
#include <mach-o/dyld.h>
#include <signal.h>
#include <spawn.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <wtf/FileSystem.h>
#include <wtf/MonotonicTime.h>
#include <wtf/RunLoop.h>
#include <wtf/SafeStrerror.h>
#include <wtf/UUID.h>
#include <wtf/darwin/DispatchExtras.h>
#include <wtf/posix/POSIXExtras.h>
#include <wtf/text/MakeString.h>
#include <wtf/text/StringView.h>
// The browser is launched for each session with one end of a socketpair() at a fixed
// descriptor. Driver and browser speak the inspector socket protocol over it, the same
// protocol that SessionHostSocket uses over TCP: JSON events with an "event" field, each
// preceded by its length as a 4-byte big-endian integer. A "message" field holds JSON as a
// string, except in the SendMessageTo events, where it's an automation protocol message.
//
// The driver sends:
// - StartAutomationSession, with "message" { "sessionID", "capabilities": { "acceptInsecureCerts" } }.
// - Setup, with "connectionID" and "targetID", to pair with the session's automation target.
// - SendMessageToBackend, with "connectionID", "targetID" and "message".
// The browser sends:
// - StartAutomationSession_Return, with "message" { "browserName", "browserVersion" }.
// - SetTargetList, with "connectionID" and "message", an array of targets with "targetID", "type",
// "name" and "isPaired". An empty list means that the automation target went away.
// - SendMessageToFrontend, with "connectionID", "targetID" and "message".
//
// The driver replies with Setup to the first SetTargetList that has an automation target, and that's
// when the session has started. So the browser has to send StartAutomationSession_Return first, or
// its name and version aren't matched against the capabilities.
//
// Other implementations of this protocol, which have to change along with this one:
// - Source/WebDriver/socket/SessionHostSocket.cpp, the driver on Windows and PlayStation.
// - Source/JavaScriptCore/inspector/remote/socket/RemoteInspectorSocket.cpp, the browser side there.
// - Tools/MiniBrowser/mac/AutomationConnection.m, the browser side for minidriver.
namespace WebDriver {
static constexpr int browserConnectionFileDescriptor = 3;
static constexpr size_t messageHeaderSize = sizeof(uint32_t);
static constexpr auto startAutomationSessionTimeout = 30_s;
static constexpr auto browserTerminationGracePeriod = 2_s;
// minidriver drives the MiniBrowser that's built with it, in the same build directory.
static std::optional<String> buildDirectory()
{
std::array<char, PATH_MAX> executablePath;
uint32_t size = executablePath.size();
if (_NSGetExecutablePath(executablePath.data(), &size))
return std::nullopt;
std::array<char, PATH_MAX> resolvedPath;
if (!realpath(executablePath.data(), resolvedPath.data()))
return std::nullopt;
return FileSystem::parentPath(String::fromUTF8(unsafeSpan(resolvedPath.data())));
}
static void terminateProcess(pid_t processID)
{
kill(processID, SIGTERM);
// Reap the process, killing it if it doesn't exit within the grace period. This waits, so that
// it's done before minidriver exits too. MiniBrowser takes the default action for SIGTERM, so
// the wait is normally short. MiniBrowser also exits by itself when minidriver does.
auto deadline = MonotonicTime::now() + browserTerminationGracePeriod;
while (!waitpid(processID, nullptr, WNOHANG)) {
if (MonotonicTime::now() >= deadline) {
kill(processID, SIGKILL);
waitpid(processID, nullptr, 0);
return;
}
sleep(10_ms);
}
}
SessionHost::~SessionHost()
{
if (m_channel)
dispatch_io_close(m_channel.get(), DISPATCH_IO_STOP);
terminateBrowser();
}
bool SessionHost::isConnected() const
{
return !!m_channel;
}
void SessionHost::connectToBrowser(Function<void(std::optional<String> error)>&& completionHandler)
{
if (m_channel) {
completionHandler("Already connected to the browser."_s);
return;
}
if (!m_targetIp.isEmpty() || m_capabilities.targetAddr) {
completionHandler("minidriver can't connect to a browser that is already running."_s);
return;
}
if (auto error = launchBrowser()) {
completionHandler(WTF::move(error));
return;
}
startReading();
completionHandler(std::nullopt);
}
std::optional<String> SessionHost::launchBrowser()
{
auto buildDirectory = WebDriver::buildDirectory();
if (!buildDirectory)
return "Failed to find the build directory that minidriver is in."_s;
std::array<StringView, 4> browserPathComponents { "MiniBrowser.app"_s, "Contents"_s, "MacOS"_s, "MiniBrowser"_s };
auto browserPathString = FileSystem::pathByAppendingComponents(*buildDirectory, browserPathComponents);
auto browserPath = browserPathString.utf8();
if (posixAccess(browserPath, X_OK))
return makeString("There is no MiniBrowser next to minidriver, at "_s, browserPathString);
int sockets[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets))
return makeString("Failed to create the browser connection: "_s, String::fromUTF8(safeStrerror(errno).span()));
int driverSocket = sockets[0];
int browserSocket = sockets[1];
fcntl(driverSocket, F_SETFD, FD_CLOEXEC);
fcntl(browserSocket, F_SETFD, FD_CLOEXEC);
int noSigPipe = 1;
setsockopt(driverSocket, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe));
// dup2() onto the same descriptor wouldn't clear FD_CLOEXEC, so make sure the browser's
// end isn't already at the descriptor it gets in the browser.
if (browserSocket == browserConnectionFileDescriptor) {
int duplicate = fcntl(browserSocket, F_DUPFD_CLOEXEC, browserConnectionFileDescriptor + 1);
close(browserSocket);
browserSocket = duplicate;
}
Vector<UTF8CString> arguments;
arguments.append(browserPath);
// AppKit opens arguments that aren't options as documents. Each option has to be followed
// by its value as a separate argument, or AppKit pairs the next option with it.
arguments.append(String("--automation-fd"_s).utf8());
arguments.append(String::number(browserConnectionFileDescriptor).utf8());
// AppKit window-state restoration can show a modal "reopen windows?" alert after an
// abnormal exit, which automation can't dismiss. Turn it off for this launch only.
for (auto argument : { "-NSQuitAlwaysKeepsWindows"_s, "NO"_s, "-ApplePersistenceIgnoreState"_s, "YES"_s })
arguments.append(String(argument).utf8());
if (m_capabilities.browserArguments) {
for (auto& argument : *m_capabilities.browserArguments)
arguments.append(argument.utf8());
}
auto argv = arguments.map([](auto& argument) {
return const_cast<char*>(argument.legacyCStringPointer());
});
argv.append(nullptr);
auto hasPrefix = [](const char* string, ASCIILiteral prefix) {
return spanHasPrefix(unsafeSpan(string), prefix.span());
};
// minidriver doesn't depend on WebKit, and runs without any dyld path overrides.
// We need to add them to reliably launch the browser.
static constexpr std::array dyldPathAssignments { "DYLD_FRAMEWORK_PATH="_s, "__XPC_DYLD_FRAMEWORK_PATH="_s, "DYLD_LIBRARY_PATH="_s, "__XPC_DYLD_LIBRARY_PATH="_s };
Vector<char*> envp;
for (char** variable = *_NSGetEnviron(); *variable; ++variable) {
if (std::ranges::none_of(dyldPathAssignments, [&](auto assignment) { return hasPrefix(*variable, assignment); }))
envp.append(*variable);
}
auto dyldPaths = WTF::map(dyldPathAssignments, [&](auto assignment) {
return makeString(assignment, *buildDirectory).utf8();
});
for (auto& variable : dyldPaths)
envp.append(const_cast<char*>(variable.legacyCStringPointer()));
envp.append(nullptr);
// Only the standard descriptors and the browser's end of the connection are inherited.
posix_spawn_file_actions_t fileActions;
posix_spawn_file_actions_init(&fileActions);
for (int descriptor : { STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO })
posix_spawn_file_actions_addinherit_np(&fileActions, descriptor);
posix_spawn_file_actions_adddup2(&fileActions, browserSocket, browserConnectionFileDescriptor);
// minidriver ignores SIGTERM and SIGINT to handle them itself, and ignored signals stay
// ignored in a launched process unless they're reset.
posix_spawnattr_t attributes;
posix_spawnattr_init(&attributes);
sigset_t defaultSignals;
sigemptyset(&defaultSignals);
sigaddset(&defaultSignals, SIGTERM);
sigaddset(&defaultSignals, SIGINT);
posix_spawnattr_setsigdefault(&attributes, &defaultSignals);
posix_spawnattr_setflags(&attributes, POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGDEF);
pid_t processID = 0;
int result = posix_spawn(&processID, browserPath.legacyCStringPointer(), &fileActions, &attributes, argv.mutableSpan().data(), envp.mutableSpan().data());
posix_spawnattr_destroy(&attributes);
posix_spawn_file_actions_destroy(&fileActions);
close(browserSocket);
if (result) {
close(driverSocket);
return makeString("Failed to launch the browser: "_s, String::fromUTF8(safeStrerror(result).span()));
}
m_browserProcessID = processID;
m_capabilities.processID = processID;
RELEASE_LOG(SessionHost, "Launched browser %s with process ID %d", browserPath, processID);
WeakPtr weakThis { *this };
m_browserExitSource = adoptOSObject(dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, processID, DISPATCH_PROC_EXIT, mainDispatchQueueSingleton()));
dispatch_source_set_event_handler(m_browserExitSource.get(), ^{
if (RefPtr protectedThis = weakThis)
protectedThis->browserDidExit();
});
dispatch_resume(m_browserExitSource.get());
m_channel = adoptOSObject(dispatch_io_create(DISPATCH_IO_STREAM, driverSocket, mainDispatchQueueSingleton(), ^(int) {
close(driverSocket);
}));
dispatch_io_set_low_water(m_channel.get(), 1);
return std::nullopt;
}
void SessionHost::terminateBrowser()
{
if (m_browserExitSource) {
dispatch_source_cancel(m_browserExitSource.get());
m_browserExitSource = nullptr;
}
if (auto processID = std::exchange(m_browserProcessID, 0))
terminateProcess(processID);
}
void SessionHost::browserDidExit()
{
dispatch_source_cancel(m_browserExitSource.get());
m_browserExitSource = nullptr;
// The process has exited, so this doesn't block for long.
int status = 0;
if (waitpid(std::exchange(m_browserProcessID, 0), &status, 0) > 0)
RELEASE_LOG(SessionHost, "Browser exited with status %d", status);
connectionDidClose();
}
void SessionHost::startReading()
{
WeakPtr weakThis { *this };
dispatch_io_read(m_channel.get(), 0, SIZE_MAX, mainDispatchQueueSingleton(), ^(bool done, dispatch_data_t data, int error) {
RefPtr protectedThis = weakThis;
if (!protectedThis || !protectedThis->m_channel)
return;
if (data)
protectedThis->didReceiveData(data);
// The read is done at end of file or on error.
if (done) {
if (error)
RELEASE_LOG_ERROR(SessionHost, "Reading from the browser failed: %s", safeStrerror(error));
protectedThis->connectionDidClose();
}
});
}
void SessionHost::didReceiveData(dispatch_data_t data)
{
dispatch_data_apply(data, ^bool(dispatch_data_t, size_t, const void* buffer, size_t size) {
m_receiveBuffer.append(unsafeMakeSpan(static_cast<const uint8_t*>(buffer), size));
return true;
});
size_t offset = 0;
while (m_channel && m_receiveBuffer.size() - offset >= messageHeaderSize) {
auto header = m_receiveBuffer.subspan(offset, messageHeaderSize);
uint32_t messageSize = static_cast<uint32_t>(header[0]) << 24 | static_cast<uint32_t>(header[1]) << 16 | static_cast<uint32_t>(header[2]) << 8 | header[3];
if (!messageSize) {
RELEASE_LOG_ERROR(SessionHost, "Received an empty message from the browser");
connectionDidClose();
return;
}
if (m_receiveBuffer.size() - offset - messageHeaderSize < messageSize)
break;
auto message = String::fromUTF8(m_receiveBuffer.subspan(offset + messageHeaderSize, messageSize));
offset += messageHeaderSize + messageSize;
auto value = JSON::Value::parseJSON(message);
auto event = value ? value->asObject() : nullptr;
if (!event) {
RELEASE_LOG_ERROR(SessionHost, "Received a message from the browser that isn't a JSON object");
continue;
}
didReceiveEvent(*event);
}
// Handling an event can close the connection, which discards the buffer.
if (m_channel)
m_receiveBuffer.removeAt(0, offset);
}
void SessionHost::sendEvent(const JSON::Object& event)
{
if (!m_channel)
return;
auto payload = event.toJSONString().utf8();
auto size = static_cast<uint32_t>(payload.length());
Vector<uint8_t> message;
message.reserveInitialCapacity(messageHeaderSize + size);
message.appendList({ static_cast<uint8_t>(size >> 24), static_cast<uint8_t>(size >> 16), static_cast<uint8_t>(size >> 8), static_cast<uint8_t>(size) });
message.append(byteCast<uint8_t>(payload.span()));
OSObjectPtr data = adoptOSObject(dispatch_data_create(message.span().data(), message.size(), nullptr, DISPATCH_DATA_DESTRUCTOR_DEFAULT));
dispatch_io_write(m_channel.get(), 0, data.get(), mainDispatchQueueSingleton(), ^(bool done, dispatch_data_t, int error) {
if (done && error)
RELEASE_LOG_ERROR(SessionHost, "Writing to the browser failed: %s", safeStrerror(error));
});
}
void SessionHost::didReceiveEvent(const JSON::Object& event)
{
auto name = event.getString("event"_s);
auto message = event.getString("message"_s);
auto connectionID = event.getInteger("connectionID"_s);
if (name == "StartAutomationSession_Return"_s) {
didReceiveStartAutomationSessionReturn(message);
return;
}
if (name == "SetTargetList"_s) {
if (connectionID && !message.isNull())
didReceiveSetTargetList(*connectionID, message);
return;
}
if (name == "SendMessageToFrontend"_s) {
auto targetID = event.getInteger("targetID"_s);
if (!connectionID || !targetID || message.isNull())
return;
if (static_cast<uint64_t>(*connectionID) != m_connectionID || static_cast<uint64_t>(*targetID) != m_target.id)
return;
dispatchMessage(message);
return;
}
RELEASE_LOG_ERROR(SessionHost, "Received an unknown event from the browser: %s", name.utf8());
}
void SessionHost::didReceiveStartAutomationSessionReturn(const String& message)
{
auto value = JSON::Value::parseJSON(message);
auto browserInfo = value ? value->asObject() : nullptr;
if (!browserInfo)
return;
// Match the requested browser name and version against the browser's, and report the browser's.
auto browserName = browserInfo->getString("browserName"_s);
if (!browserName.isEmpty()) {
if (m_capabilities.browserName && !equalIgnoringASCIICase(*m_capabilities.browserName, browserName))
m_browserMatchesCapabilities = false;
m_capabilities.browserName = browserName;
}
auto browserVersion = browserInfo->getString("browserVersion"_s);
if (!browserVersion.isEmpty()) {
if (m_capabilities.browserVersion && !WebDriverService::platformCompareBrowserVersions(*m_capabilities.browserVersion, browserVersion))
m_browserMatchesCapabilities = false;
m_capabilities.browserVersion = browserVersion;
}
}
void SessionHost::didReceiveSetTargetList(uint64_t connectionID, const String& message)
{
auto value = JSON::Value::parseJSON(message);
auto targetArray = value ? value->asArray() : nullptr;
if (!targetArray)
return;
Vector<Target> targetList;
for (auto& targetValue : *targetArray) {
auto targetObject = targetValue->asObject();
if (!targetObject || targetObject->getString("type"_s) != "automation"_s)
continue;
auto targetID = targetObject->getInteger("targetID"_s);
if (!targetID)
continue;
targetList.append({ static_cast<uint64_t>(*targetID), targetObject->getString("name"_s).utf8(), targetObject->getBoolean("isPaired"_s).value_or(false) });
}
setTargetList(connectionID, WTF::move(targetList));
}
void SessionHost::setTargetList(uint64_t connectionID, Vector<Target>&& targetList)
{
if (m_connectionID && m_connectionID != connectionID)
return;
// An empty target list means that the automation target went away.
if (targetList.isEmpty()) {
connectionDidClose();
return;
}
m_target = targetList[0];
if (m_connectionID || !m_startSessionCompletionHandler)
return;
m_connectionID = connectionID;
auto setupEvent = JSON::Object::create();
setupEvent->setString("event"_s, "Setup"_s);
setupEvent->setInteger("connectionID"_s, m_connectionID);
setupEvent->setInteger("targetID"_s, m_target.id);
sendEvent(setupEvent);
auto completionHandler = std::exchange(m_startSessionCompletionHandler, nullptr);
completionHandler(m_browserMatchesCapabilities, std::nullopt);
}
void SessionHost::startAutomationSession(Function<void(bool, std::optional<String>)>&& completionHandler)
{
ASSERT(!m_startSessionCompletionHandler);
if (!m_channel) {
completionHandler(false, "Not connected to the browser."_s);
return;
}
m_startSessionCompletionHandler = WTF::move(completionHandler);
m_sessionID = createVersion4UUIDString();
auto capabilitiesObject = JSON::Object::create();
capabilitiesObject->setBoolean("acceptInsecureCerts"_s, m_capabilities.acceptInsecureCerts.value_or(false));
auto messageObject = JSON::Object::create();
messageObject->setString("sessionID"_s, m_sessionID);
messageObject->setObject("capabilities"_s, WTF::move(capabilitiesObject));
auto startEvent = JSON::Object::create();
startEvent->setString("event"_s, "StartAutomationSession"_s);
startEvent->setString("message"_s, messageObject->toJSONString());
sendEvent(startEvent);
RunLoop::mainSingleton().dispatchAfter(startAutomationSessionTimeout, [weakThis = WeakPtr { *this }] {
RefPtr protectedThis = weakThis;
if (!protectedThis || !protectedThis->m_startSessionCompletionHandler)
return;
auto completionHandler = std::exchange(protectedThis->m_startSessionCompletionHandler, nullptr);
completionHandler(false, "Timed out waiting for the browser to start the automation session."_s);
});
}
void SessionHost::sendMessageToBackend(const String& message)
{
auto messageEvent = JSON::Object::create();
messageEvent->setString("event"_s, "SendMessageToBackend"_s);
messageEvent->setInteger("connectionID"_s, m_connectionID);
messageEvent->setInteger("targetID"_s, m_target.id);
messageEvent->setString("message"_s, message);
sendEvent(messageEvent);
}
void SessionHost::connectionDidClose()
{
if (!m_channel)
return;
dispatch_io_close(m_channel.get(), DISPATCH_IO_STOP);
m_channel = nullptr;
m_receiveBuffer.clear();
if (auto completionHandler = std::exchange(m_startSessionCompletionHandler, nullptr))
completionHandler(false, "The browser closed the connection."_s);
inspectorDisconnected();
}
} // namespace WebDriver
#endif // PLATFORM(COCOA)