mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-17 11:24:14 +02:00
Merge pull request #1507 from ispyisail/feat/project-lock
Add a project lock: tell a second person the project is open
This commit is contained in:
@@ -291,6 +291,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/qetmainwindow.h
|
||||
${QET_DIR}/sources/qetmessagebox.cpp
|
||||
${QET_DIR}/sources/qetmessagebox.h
|
||||
${QET_DIR}/sources/projectlock.cpp
|
||||
${QET_DIR}/sources/projectlock.h
|
||||
${QET_DIR}/sources/qetproject.cpp
|
||||
${QET_DIR}/sources/qetproject.h
|
||||
${QET_DIR}/sources/qetregexpvalidator.cpp
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "cli_export.h"
|
||||
#include "projectlock.h"
|
||||
#include "bomexport.h"
|
||||
|
||||
#include "bordertitleblock.h"
|
||||
@@ -1053,6 +1054,7 @@ int run(const QStringList &args)
|
||||
// --no-slaves and --no-junctions leave rows out of --export-bom.
|
||||
const bool includeSlaves = filtered.removeAll("--no-slaves") == 0;
|
||||
const bool includeJunctions = filtered.removeAll("--no-junctions") == 0;
|
||||
const bool ignoreLock = filtered.removeAll("--ignore-lock") > 0;
|
||||
|
||||
QString flag;
|
||||
QStringList rest;
|
||||
@@ -1127,6 +1129,19 @@ int run(const QStringList &args)
|
||||
return exportNets(project, output);
|
||||
if (format == "links")
|
||||
return exportLinks(project, output);
|
||||
//The verbs that write a project file leave alone one that someone
|
||||
//has open (their lock beside it), unless told to: the read-only
|
||||
//verbs above never look at locks
|
||||
if ((format == "resave" || format == "settb") && !ignoreLock) {
|
||||
const ProjectLock::Holder holder = ProjectLock::liveHolder(output);
|
||||
if (holder.valid) {
|
||||
err << "'" << output << "' is open by " << holder.user << " on "
|
||||
<< holder.host << " (lock file " << ProjectLock::lockPathFor(output)
|
||||
<< "). Not writing it; close it there, write to another file, "
|
||||
"or pass --ignore-lock.\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (format == "resave")
|
||||
return resaveProject(project, output);
|
||||
if (format == "settb")
|
||||
|
||||
@@ -95,6 +95,12 @@ namespace CLIExport {
|
||||
per-page element / conductor counts and unconnected terminals.
|
||||
check-elements: validate .elmt file(s) against the element schema.
|
||||
resave: load and rewrite the project XML (round-trip integrity).
|
||||
--ignore-lock: resave and set-titleblock leave a file alone when a lock
|
||||
file beside it (.~<name>.lock, made by a QElectroTech that has
|
||||
the project open) says someone else has it; this writes it
|
||||
anyway. The verbs that only read a project never look at locks,
|
||||
and none of them takes one. A lock made on this machine by a
|
||||
process that is gone is stale and does not count.
|
||||
set-titleblock: stamp title-block fields onto every folio, then save.
|
||||
Keys: title, author, date (or date=today), plant, location,
|
||||
revision, version, filename; any other key becomes a custom
|
||||
|
||||
@@ -240,6 +240,9 @@ int main(int argc, char **argv)
|
||||
// runs on a background thread referencing the project and races the
|
||||
// process exit (intermittent segfault in QET::writeToFile).
|
||||
QETProject::setBackupEnabled(false);
|
||||
// Headless runs read locks, to refuse writing a project someone
|
||||
// has open, but never take one
|
||||
ProjectLock::setHeadless(true);
|
||||
// Answer message boxes instead of showing them: opening a project
|
||||
// saved by an older QElectroTech raises a warning from
|
||||
// QETProject::readProjectXml(), and with nobody able to dismiss it
|
||||
@@ -255,6 +258,7 @@ int main(int argc, char **argv)
|
||||
installHeadlessMessageHandler();
|
||||
QApplication script_app(argc, argv);
|
||||
QETProject::setBackupEnabled(false);
|
||||
ProjectLock::setHeadless(true);
|
||||
QET::QetMessageBox::setNonInteractive(true);
|
||||
return QetScripting::run(headlessArguments(script_app.arguments()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "projectlock.h"
|
||||
|
||||
#include "qetversion.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QSettings>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
# include <windows.h>
|
||||
#else
|
||||
# include <cerrno>
|
||||
# include <signal.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
bool g_headless = false;
|
||||
int g_heartbeat_ms = 60000;
|
||||
//A lock file that cannot be read yet may be one just created and
|
||||
//not written: it counts as held for this long
|
||||
constexpr int unreadable_grace_s = 10;
|
||||
|
||||
bool processAlive(qint64 pid)
|
||||
{
|
||||
if (pid <= 0) return false;
|
||||
#ifdef Q_OS_WIN
|
||||
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
|
||||
if (!h) return GetLastError() == ERROR_ACCESS_DENIED;
|
||||
DWORD code = 0;
|
||||
const bool alive = GetExitCodeProcess(h, &code) && code == STILL_ACTIVE;
|
||||
CloseHandle(h);
|
||||
return alive;
|
||||
#else
|
||||
return ::kill(static_cast<pid_t>(pid), 0) == 0 || errno == EPERM;
|
||||
#endif
|
||||
}
|
||||
|
||||
QString isoUtc(const QDateTime &t)
|
||||
{
|
||||
return t.toUTC().toString(Qt::ISODate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::ProjectLock
|
||||
@param parent
|
||||
*/
|
||||
ProjectLock::ProjectLock(QObject *parent) :
|
||||
QObject(parent),
|
||||
m_session(QUuid::createUuid().toString(QUuid::WithoutBraces))
|
||||
{
|
||||
m_timer.setInterval(g_heartbeat_ms);
|
||||
connect(&m_timer, &QTimer::timeout, this, &ProjectLock::refresh);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::~ProjectLock
|
||||
Deletes the lock file if it is still ours.
|
||||
*/
|
||||
ProjectLock::~ProjectLock()
|
||||
{
|
||||
release();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::lockPathFor
|
||||
@return the lock file of the project @p project_path:
|
||||
".~plant.qetz" gives ".~plant.qetz.lock" in the same folder
|
||||
*/
|
||||
QString ProjectLock::lockPathFor(const QString &project_path)
|
||||
{
|
||||
const QFileInfo info(project_path);
|
||||
return info.absoluteDir().filePath(QStringLiteral(".~") + info.fileName() + QStringLiteral(".lock"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::enabled
|
||||
@return false when the user turned locking off (Settings, General,
|
||||
Projects); on by default
|
||||
*/
|
||||
bool ProjectLock::enabled()
|
||||
{
|
||||
QSettings settings;
|
||||
return settings.value(QStringLiteral("projectlock/enabled"), true).toBool();
|
||||
}
|
||||
|
||||
void ProjectLock::setEnabled(bool enabled)
|
||||
{
|
||||
QSettings settings;
|
||||
settings.setValue(QStringLiteral("projectlock/enabled"), enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::mayTake
|
||||
@return true when this process should take locks: locking is on and this
|
||||
is not a headless run (those only read locks, to refuse a write)
|
||||
*/
|
||||
bool ProjectLock::mayTake()
|
||||
{
|
||||
return !g_headless && enabled();
|
||||
}
|
||||
|
||||
void ProjectLock::setHeadless(bool headless)
|
||||
{
|
||||
g_headless = headless;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::setHeartbeatInterval
|
||||
For tests. Applies to locks made afterwards.
|
||||
*/
|
||||
void ProjectLock::setHeartbeatInterval(int msec)
|
||||
{
|
||||
g_heartbeat_ms = msec;
|
||||
}
|
||||
|
||||
QString ProjectLock::thisHost()
|
||||
{
|
||||
return QSysInfo::machineHostName();
|
||||
}
|
||||
|
||||
QString ProjectLock::thisUser()
|
||||
{
|
||||
for (const char *name : {"USER", "USERNAME", "LOGNAME"}) {
|
||||
const QString user = qEnvironmentVariable(name);
|
||||
if (!user.isEmpty()) return user;
|
||||
}
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::readHolder
|
||||
@return what the lock file of @p project_path says; not valid when there
|
||||
is none or it is not a lock
|
||||
*/
|
||||
ProjectLock::Holder ProjectLock::readHolder(const QString &project_path)
|
||||
{
|
||||
Holder holder;
|
||||
QFile file(lockPathFor(project_path));
|
||||
if (!file.open(QIODevice::ReadOnly)) return holder;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
if (!doc.isObject()) return holder;
|
||||
const QJsonObject o = doc.object();
|
||||
if (o.value(QStringLiteral("format")).toInt() < 1 || !o.contains(QStringLiteral("session"))) return holder;
|
||||
holder.session = o.value(QStringLiteral("session")).toString();
|
||||
holder.user = o.value(QStringLiteral("user")).toString();
|
||||
holder.host = o.value(QStringLiteral("host")).toString();
|
||||
holder.pid = static_cast<qint64>(o.value(QStringLiteral("pid")).toDouble());
|
||||
holder.app = o.value(QStringLiteral("app")).toString();
|
||||
holder.since = QDateTime::fromString(o.value(QStringLiteral("since")).toString(), Qt::ISODate);
|
||||
holder.heartbeat = QDateTime::fromString(o.value(QStringLiteral("heartbeat")).toString(), Qt::ISODate);
|
||||
holder.valid = !holder.session.isEmpty();
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::isStale
|
||||
A lock is known to be dead only when it was made on this machine by a
|
||||
process that no longer exists. Of a lock from another machine nothing is
|
||||
known, whatever its age.
|
||||
*/
|
||||
bool ProjectLock::isStale(const Holder &holder)
|
||||
{
|
||||
return holder.valid
|
||||
&& holder.host.compare(thisHost(), Qt::CaseInsensitive) == 0
|
||||
&& !processAlive(holder.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::liveHolder
|
||||
@param own_session the session of the caller, whose lock is not a conflict
|
||||
@return who holds @p project_path now: not valid when nobody does, the
|
||||
lock is stale or it is @p own_session's. A lock file that cannot be read
|
||||
and is younger than a few seconds is a lock being written: held, by an
|
||||
unknown holder.
|
||||
*/
|
||||
ProjectLock::Holder ProjectLock::liveHolder(const QString &project_path, const QString &own_session)
|
||||
{
|
||||
const QString lock_path = lockPathFor(project_path);
|
||||
if (!QFileInfo::exists(lock_path)) return Holder();
|
||||
Holder holder = readHolder(project_path);
|
||||
if (!holder.valid) {
|
||||
const QDateTime modified = QFileInfo(lock_path).lastModified();
|
||||
if (modified.secsTo(QDateTime::currentDateTime()) < unreadable_grace_s) {
|
||||
holder.valid = true;
|
||||
holder.session = QStringLiteral("?");
|
||||
holder.user = holder.host = QStringLiteral("?");
|
||||
holder.since = holder.heartbeat = modified;
|
||||
return holder;
|
||||
}
|
||||
return Holder();
|
||||
}
|
||||
if (!own_session.isEmpty() && holder.session == own_session) return Holder();
|
||||
if (isStale(holder)) return Holder();
|
||||
return holder;
|
||||
}
|
||||
|
||||
QByteArray ProjectLock::content(const QDateTime &since, const QDateTime &heartbeat) const
|
||||
{
|
||||
QJsonObject scope;
|
||||
scope.insert(QStringLiteral("kind"), QStringLiteral("project"));
|
||||
QJsonObject o;
|
||||
o.insert(QStringLiteral("format"), 1);
|
||||
o.insert(QStringLiteral("session"), m_session);
|
||||
o.insert(QStringLiteral("user"), thisUser());
|
||||
o.insert(QStringLiteral("host"), thisHost());
|
||||
o.insert(QStringLiteral("pid"), static_cast<double>(QCoreApplication::applicationPid()));
|
||||
o.insert(QStringLiteral("app"), QStringLiteral("QElectroTech %1").arg(QetVersion::currentVersion().toString()));
|
||||
o.insert(QStringLiteral("since"), isoUtc(since));
|
||||
o.insert(QStringLiteral("heartbeat"), isoUtc(heartbeat));
|
||||
o.insert(QStringLiteral("scope"), QJsonArray{scope});
|
||||
return QJsonDocument(o).toJson(QJsonDocument::Indented);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::createExclusive
|
||||
Make the lock file, failing if it exists. NewOnly is O_EXCL / CREATE_NEW:
|
||||
the check and the creation are one step on the file system, so two
|
||||
sessions cannot both succeed, also over SMB.
|
||||
*/
|
||||
bool ProjectLock::createExclusive(const QString &lock_path)
|
||||
{
|
||||
QFile file(lock_path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::NewOnly)) return false;
|
||||
const QByteArray data = content(m_since, QDateTime::currentDateTimeUtc());
|
||||
const bool ok = file.write(data) == data.size();
|
||||
file.close();
|
||||
if (!ok) QFile::remove(lock_path);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void ProjectLock::hide(const QString &lock_path)
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
const std::wstring path = QDir::toNativeSeparators(lock_path).toStdWString();
|
||||
SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_HIDDEN);
|
||||
#else
|
||||
Q_UNUSED(lock_path)
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::acquire
|
||||
Take the lock of @p project_path.
|
||||
@param take_over take it although another live session has it: its
|
||||
lock file is replaced, and that session learns of it at its next heartbeat
|
||||
@return Acquired; Held, with blockingHolder() saying by whom; or
|
||||
Unavailable when the lock file cannot be made (read-only folder):
|
||||
the caller opens the project as without locking
|
||||
*/
|
||||
ProjectLock::Result ProjectLock::acquire(const QString &project_path, bool take_over)
|
||||
{
|
||||
release();
|
||||
m_blocking = Holder();
|
||||
const QString lock_path = lockPathFor(project_path);
|
||||
m_since = QDateTime::currentDateTimeUtc();
|
||||
|
||||
for (int attempt = 0; attempt < 3; ++attempt)
|
||||
{
|
||||
if (createExclusive(lock_path)) {
|
||||
// Read back: if two sessions removed a stale lock and made
|
||||
// theirs at once, only the survivor's session is in the file
|
||||
if (readHolder(project_path).session != m_session) continue;
|
||||
m_project_path = project_path;
|
||||
m_held = true;
|
||||
hide(lock_path);
|
||||
m_timer.setInterval(g_heartbeat_ms);
|
||||
m_timer.start();
|
||||
return Result::Acquired;
|
||||
}
|
||||
if (!QFileInfo::exists(lock_path)) {
|
||||
//Not there and cannot be made
|
||||
if (!QFileInfo(QFileInfo(lock_path).absolutePath()).isWritable())
|
||||
return Result::Unavailable;
|
||||
continue;
|
||||
}
|
||||
const Holder holder = liveHolder(project_path);
|
||||
if (holder.valid && !take_over) {
|
||||
m_blocking = holder;
|
||||
return Result::Held;
|
||||
}
|
||||
//Stale, unreadable for good, or taken over: replace it
|
||||
if (!QFile::remove(lock_path) && QFileInfo::exists(lock_path))
|
||||
return Result::Unavailable;
|
||||
}
|
||||
return Result::Unavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::moveTo
|
||||
The project is now @p project_path (Save As, .qet saved as .qetz): take
|
||||
the new lock, then let the old one go. Nothing changes when the new one
|
||||
cannot be had.
|
||||
@return true when the lock is now on @p project_path
|
||||
*/
|
||||
bool ProjectLock::moveTo(const QString &project_path)
|
||||
{
|
||||
if (m_held && QFileInfo(project_path).absoluteFilePath() == QFileInfo(m_project_path).absoluteFilePath()) return true;
|
||||
const QString old_path = m_project_path;
|
||||
const bool was_held = m_held;
|
||||
m_held = false; // keep release() from touching the old file
|
||||
m_timer.stop();
|
||||
const Result result = acquire(project_path);
|
||||
if (result == Result::Acquired) {
|
||||
if (was_held) {
|
||||
const QString old_lock = lockPathFor(old_path);
|
||||
if (readHolder(old_path).session == m_session) QFile::remove(old_lock);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (was_held) { // go back to the old one
|
||||
m_project_path = old_path;
|
||||
m_held = true;
|
||||
m_timer.start();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::release
|
||||
Delete the lock file, if it is ours: a lock another session took over
|
||||
is left to it.
|
||||
*/
|
||||
void ProjectLock::release()
|
||||
{
|
||||
m_timer.stop();
|
||||
if (!m_held) return;
|
||||
m_held = false;
|
||||
if (readHolder(m_project_path).session == m_session)
|
||||
QFile::remove(lockPathFor(m_project_path));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectLock::refresh
|
||||
Write a fresh heartbeat. Called by the timer, every minute.
|
||||
If the file is gone (a share dropped it) it is made again; if another
|
||||
session's lock is there, ours was taken over: lockTaken() is emitted and
|
||||
the lock is let go.
|
||||
@return true when the lock is still ours
|
||||
*/
|
||||
bool ProjectLock::refresh()
|
||||
{
|
||||
if (!m_held) return false;
|
||||
const QString lock_path = lockPathFor(m_project_path);
|
||||
if (QFileInfo::exists(lock_path)) {
|
||||
const Holder holder = readHolder(m_project_path);
|
||||
if (holder.valid && holder.session != m_session) {
|
||||
m_held = false;
|
||||
m_timer.stop();
|
||||
emit lockTaken(holder);
|
||||
return false;
|
||||
}
|
||||
QFile file(lock_path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return true;
|
||||
file.write(content(m_since, QDateTime::currentDateTimeUtc()));
|
||||
return true;
|
||||
}
|
||||
if (createExclusive(lock_path)) hide(lock_path);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef PROJECTLOCK_H
|
||||
#define PROJECTLOCK_H
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
/**
|
||||
@brief The ProjectLock class
|
||||
A sidecar file beside a project that says who has it open, so that a second
|
||||
person (or a second QElectroTech) opening the same file on a shared drive
|
||||
is told, instead of both editing and the last save wiping the other's work.
|
||||
|
||||
The file is ".~<filename>.lock" in the project's folder, a small versioned
|
||||
JSON object. It is not the project file itself: a project is saved by
|
||||
writing a temporary file and renaming it over the old one, which would
|
||||
replace a lock kept in the project at every save.
|
||||
|
||||
Taking the lock is an exclusive create (QFile NewOnly: O_EXCL, or
|
||||
CREATE_NEW on Windows), the one operation shared drives do reliably. This is
|
||||
not a QLockFile: its stale time defaults to 30 s, and for a lock made on
|
||||
another machine it decides by age alone, so another PC would take a lock
|
||||
held for over 30 s. Here a lock is stale only when it was made on this
|
||||
machine by a process that is gone; any other lock is shown to the user, with
|
||||
the age of its heartbeat, to judge.
|
||||
*/
|
||||
class ProjectLock : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/// What a lock file says
|
||||
struct Holder
|
||||
{
|
||||
bool valid = false; ///< false: the file could not be read as a lock
|
||||
QString session;
|
||||
QString user;
|
||||
QString host;
|
||||
qint64 pid = 0;
|
||||
QString app;
|
||||
QDateTime since;
|
||||
QDateTime heartbeat;
|
||||
};
|
||||
|
||||
enum class Result {
|
||||
Acquired, ///< the lock is ours
|
||||
Held, ///< a live lock of another session is there
|
||||
Unavailable ///< the lock file cannot be made (read-only folder, ...)
|
||||
};
|
||||
|
||||
explicit ProjectLock(QObject *parent = nullptr);
|
||||
~ProjectLock() override;
|
||||
|
||||
Result acquire(const QString &project_path, bool take_over = false);
|
||||
bool moveTo(const QString &project_path);
|
||||
void release();
|
||||
bool refresh();
|
||||
|
||||
bool isHeld() const {return m_held;}
|
||||
QString projectPath() const {return m_project_path;}
|
||||
QString session() const {return m_session;}
|
||||
Holder blockingHolder() const {return m_blocking;}
|
||||
|
||||
static QString lockPathFor(const QString &project_path);
|
||||
static Holder readHolder(const QString &project_path);
|
||||
static bool isStale(const Holder &holder);
|
||||
static Holder liveHolder(const QString &project_path, const QString &own_session = QString());
|
||||
|
||||
static bool enabled();
|
||||
static void setEnabled(bool);
|
||||
static bool mayTake();
|
||||
static void setHeadless(bool);
|
||||
static void setHeartbeatInterval(int msec);
|
||||
|
||||
static QString thisHost();
|
||||
static QString thisUser();
|
||||
|
||||
signals:
|
||||
/// Another session took the lock over (its "Open anyway")
|
||||
void lockTaken(const ProjectLock::Holder &by);
|
||||
|
||||
private:
|
||||
QByteArray content(const QDateTime &since, const QDateTime &heartbeat) const;
|
||||
bool createExclusive(const QString &lock_path);
|
||||
static void hide(const QString &lock_path);
|
||||
|
||||
QString m_project_path;
|
||||
QString m_session;
|
||||
QDateTime m_since;
|
||||
bool m_held = false;
|
||||
Holder m_blocking;
|
||||
QTimer m_timer;
|
||||
};
|
||||
|
||||
Q_DECLARE_METATYPE(ProjectLock::Holder)
|
||||
|
||||
#endif
|
||||
@@ -385,6 +385,9 @@ QString ProjectView::askUserForFilePath(bool assign) {
|
||||
}
|
||||
filepath += zipped ? ".qetz" : ".qet";
|
||||
|
||||
//Not onto a file someone else has open
|
||||
if (!refuseIfLockedByOther(filepath)) return(QString());
|
||||
|
||||
if (assign) {
|
||||
// assign the provided filepath to the currently edited project
|
||||
m_project -> setFilePath(filepath);
|
||||
@@ -393,6 +396,26 @@ QString ProjectView::askUserForFilePath(bool assign) {
|
||||
return(filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ProjectView::refuseIfLockedByOther
|
||||
@return false, after telling the user, when another session holds the
|
||||
lock of the file @p filepath this project is about to be saved to
|
||||
*/
|
||||
bool ProjectView::refuseIfLockedByOther(const QString &filepath)
|
||||
{
|
||||
if (!ProjectLock::mayTake()) return(true);
|
||||
const ProjectLock::Holder holder = ProjectLock::liveHolder(
|
||||
filepath, m_project && m_project->lock() ? m_project->lock()->session() : QString());
|
||||
if (!holder.valid) return(true);
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
tr("Project in use", "message box title"),
|
||||
tr("%1 is open by %2 on %3. Choose another file name, or save again "
|
||||
"once it has been closed there.")
|
||||
.arg(QFileInfo(filepath).fileName(), holder.user, holder.host));
|
||||
return(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@return the QETResult object to be returned when it appears this project
|
||||
view is not associated to any project.
|
||||
@@ -819,6 +842,7 @@ QETResult ProjectView::doSave()
|
||||
//and ask before replacing a .qetz already there
|
||||
if (m_project->savesBesideOpenedFile()) {
|
||||
const QString beside = m_project->besideContainerPath();
|
||||
if (!refuseIfLockedByOther(beside)) return(QETResult());
|
||||
const QString message = tr("This project was opened from %1. It is saved as %2, "
|
||||
"beside it, in the zipped project format; %1 stays as "
|
||||
"it is.\n\nTo write a file for a version of "
|
||||
|
||||
@@ -163,6 +163,7 @@ class ProjectView : public QWidget
|
||||
bool tryClosingElementEditors();
|
||||
int tryClosingDiagrams();
|
||||
QString askUserForFilePath(bool = true);
|
||||
bool refuseIfLockedByOther(const QString &filepath);
|
||||
QETResult noProjectResult() const;
|
||||
|
||||
private slots:
|
||||
|
||||
@@ -1893,6 +1893,51 @@ bool QETDiagramEditor::closeProject(QETProject *project)
|
||||
return(true);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::askAboutLock
|
||||
Tell the user who has the project @p filepath open and let them choose.
|
||||
Read-only is the default: opening anyway can lose the other person's
|
||||
work, and a lock left by a crash on another machine cannot be told from
|
||||
a live one, which is why the age of the last sign of life is shown.
|
||||
*/
|
||||
QETDiagramEditor::LockChoice QETDiagramEditor::askAboutLock(
|
||||
const QString &filepath,
|
||||
const ProjectLock::Holder &holder)
|
||||
{
|
||||
const QString name = QFileInfo(filepath).fileName();
|
||||
QString text;
|
||||
if (holder.session == QLatin1String("?")) {
|
||||
text = tr("%1 is being opened by another session of QElectroTech.").arg(name);
|
||||
} else {
|
||||
const QString since = QLocale::system().toString(holder.since.toLocalTime(), QLocale::ShortFormat);
|
||||
text = tr("%1 is open by %2 on %3 since %4").arg(name, holder.user, holder.host, since);
|
||||
const qint64 minutes = holder.heartbeat.isValid()
|
||||
? holder.heartbeat.secsTo(QDateTime::currentDateTimeUtc()) / 60
|
||||
: -1;
|
||||
if (minutes >= 0) {
|
||||
text += minutes < 1
|
||||
? tr(" (active just now).")
|
||||
: tr(" (active %n minute(s) ago).", nullptr, static_cast<int>(minutes));
|
||||
} else {
|
||||
text += QLatin1Char('.');
|
||||
}
|
||||
}
|
||||
|
||||
QMessageBox box(QMessageBox::Warning, tr("Project in use", "message box title"), text, QMessageBox::NoButton, this);
|
||||
box.setInformativeText(tr("Open it read-only to look at it without changing it. "
|
||||
"Open it anyway only if you know that session is not "
|
||||
"using it any more: both of you could then save over each other."));
|
||||
QPushButton *read_only = box.addButton(tr("Open read-only"), QMessageBox::AcceptRole);
|
||||
QPushButton *anyway = box.addButton(tr("Open anyway"), QMessageBox::DestructiveRole);
|
||||
box.addButton(QMessageBox::Cancel);
|
||||
box.setDefaultButton(read_only);
|
||||
box.setEscapeButton(box.button(QMessageBox::Cancel));
|
||||
box.exec();
|
||||
if (box.clickedButton() == read_only) return LockChoice::ReadOnly;
|
||||
if (box.clickedButton() == anyway) return LockChoice::OpenAnyway;
|
||||
return LockChoice::Cancel;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETDiagramEditor::offerToRecover
|
||||
@p filepath could not be read (@p reason): say why, and offer its newest
|
||||
@@ -2080,6 +2125,40 @@ bool QETDiagramEditor::openAndAddProject(
|
||||
}
|
||||
}
|
||||
|
||||
//Is someone else working on this file? Taken before the project is
|
||||
//read, so a cancel costs nothing. Never blocks opening: a folder
|
||||
//the lock cannot be made in, or the setting off, opens as before.
|
||||
std::unique_ptr<ProjectLock> lock;
|
||||
bool opened_read_only_by_lock = false;
|
||||
if (ProjectLock::mayTake() && filepath_info.isWritable())
|
||||
{
|
||||
lock = std::make_unique<ProjectLock>();
|
||||
if (lock->acquire(filepath) == ProjectLock::Result::Held)
|
||||
{
|
||||
const ProjectLock::Holder holder = lock->blockingHolder();
|
||||
bool take_over = false;
|
||||
if (interactive) {
|
||||
switch (askAboutLock(filepath, holder)) {
|
||||
case LockChoice::Cancel:
|
||||
return(false);
|
||||
case LockChoice::OpenAnyway:
|
||||
take_over = true;
|
||||
break;
|
||||
case LockChoice::ReadOnly:
|
||||
break;
|
||||
}
|
||||
}
|
||||
//Not interactive (an assistant opening it): read-only, which
|
||||
//is the safe answer to a question nobody is there to ask
|
||||
if (take_over && lock->acquire(filepath, true) == ProjectLock::Result::Acquired) {
|
||||
// ours now
|
||||
} else if (!take_over) {
|
||||
lock.reset();
|
||||
opened_read_only_by_lock = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Create the project
|
||||
DialogWaiting::instance(this);
|
||||
|
||||
@@ -2122,6 +2201,17 @@ bool QETDiagramEditor::openAndAddProject(
|
||||
return(false);
|
||||
}
|
||||
|
||||
if (opened_read_only_by_lock) project->setOpenedReadOnlyByLock(true);
|
||||
if (lock && lock->isHeld()) {
|
||||
connect(lock.get(), &ProjectLock::lockTaken, this, [this, project](const ProjectLock::Holder &by) {
|
||||
statusBar()->showMessage(
|
||||
tr("%1 on %2 has taken over %3. Save your changes with Save As to keep them.")
|
||||
.arg(by.user, by.host, QFileInfo(project->filePath()).fileName()),
|
||||
30000);
|
||||
});
|
||||
project->setLock(std::move(lock));
|
||||
}
|
||||
|
||||
QETApp::projectsRecentFiles() -> fileWasOpened(filepath);
|
||||
addProject(project);
|
||||
DialogWaiting::dropInstance();
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
#include "SearchAndReplace/ui/searchandreplacewidget.h"
|
||||
#include "diagramcontext.h"
|
||||
#include "projectlock.h"
|
||||
#include "qetmainwindow.h"
|
||||
|
||||
#include <QActionGroup>
|
||||
@@ -75,6 +76,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
QList<ProjectView *> openedProjects () const;
|
||||
void addProjectView (ProjectView *);
|
||||
bool openAndAddProject (const QString &, bool = true);
|
||||
enum class LockChoice {ReadOnly, OpenAnyway, Cancel};
|
||||
LockChoice askAboutLock (const QString &, const ProjectLock::Holder &);
|
||||
QList<QString> editedFiles () const;
|
||||
ProjectView *viewForFile (const QString &) const;
|
||||
ProjectView *currentProjectView() const;
|
||||
|
||||
@@ -2805,6 +2805,10 @@ QETResult QETProject::write(bool overwrite_changed)
|
||||
if (m_file_path.isEmpty())
|
||||
return(QString("unable to save project to file: no filepath was specified"));
|
||||
|
||||
//Another session holds this file: it was opened read-only for that
|
||||
if (!m_locked_out_path.isEmpty() && m_locked_out_path == m_file_path)
|
||||
return(tr("%1 was opened read-only because someone else has it open. Use Save As to keep your changes in another file.").arg(m_file_path));
|
||||
|
||||
//A project read from a .qet is saved as a .qetz beside it; the
|
||||
//.qet stays as it was (stage D1). If the .qetz cannot be written
|
||||
//(a read-only folder), the project stays the .qet it was.
|
||||
@@ -2877,6 +2881,17 @@ QETResult QETProject::write(bool overwrite_changed)
|
||||
if (isReadOnly())
|
||||
setReadOnly(false);
|
||||
|
||||
//The file may be a new one (Save As, a .qet saved as .qetz): the lock
|
||||
//follows it. A project that had none (new, or opened read-only for
|
||||
//another's lock) takes one now. Never stops the save: it is done.
|
||||
if (ProjectLock::mayTake()) {
|
||||
if (!m_lock) m_lock = std::make_unique<ProjectLock>();
|
||||
if (!m_lock->isHeld() || m_lock->projectPath() != m_file_path) {
|
||||
if (m_lock->isHeld()) m_lock->moveTo(m_file_path);
|
||||
else m_lock->acquire(m_file_path);
|
||||
}
|
||||
}
|
||||
|
||||
//title block variables should be updated after file save dialog is confirmed, before file is saved.
|
||||
m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat));
|
||||
m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd"));
|
||||
@@ -2901,6 +2916,27 @@ bool QETProject::isReadOnly() const
|
||||
return(m_read_only && read_only_file_path_ == m_file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETProject::setLock
|
||||
Hand the lock sidecar taken for this project's file to the project: it
|
||||
is released when the project is closed.
|
||||
*/
|
||||
void QETProject::setLock(std::unique_ptr<ProjectLock> lock)
|
||||
{
|
||||
m_lock = std::move(lock);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETProject::setOpenedReadOnlyByLock
|
||||
Open this project read-only because another session has it: write()
|
||||
does not write to this file (Save As to another file is allowed).
|
||||
*/
|
||||
void QETProject::setOpenedReadOnlyByLock(bool by_lock)
|
||||
{
|
||||
m_locked_out_path = by_lock ? m_file_path : QString();
|
||||
if (by_lock) setReadOnly(true);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETProject::setReadOnly
|
||||
Set this project to read only if read_only = true
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
#include "diagram.h"
|
||||
#include "autoNum/assignvariables.h"
|
||||
#include "container/qetcontainerdb.h"
|
||||
#include "projectlock.h"
|
||||
#ifdef BUILD_WITHOUT_KF
|
||||
# include "ui/nokde/kautosavefile.h"
|
||||
#else
|
||||
@@ -342,6 +343,10 @@ class QETProject : public QObject
|
||||
/// Opened from a .qetz of a later format (opened read-only)
|
||||
bool isFromLaterVersion() const {return m_from_later_version;}
|
||||
void setReadOnly(bool);
|
||||
/// The lock sidecar this session holds on the project file, if any
|
||||
ProjectLock *lock() const {return m_lock.get();}
|
||||
void setLock(std::unique_ptr<ProjectLock> lock);
|
||||
void setOpenedReadOnlyByLock(bool by_lock);
|
||||
bool isEmpty() const;
|
||||
ElementsLocation importElement(ElementsLocation &location);
|
||||
QString integrateTitleBlockTemplate(const TitleBlockTemplateLocation &, MoveTitleBlockTemplatesHandler *handler);
|
||||
@@ -499,6 +504,11 @@ class QETProject : public QObject
|
||||
/// Whether the project is read only
|
||||
bool m_read_only = false;
|
||||
bool m_from_later_version = false;
|
||||
/// The lock sidecar of the project file (see ProjectLock)
|
||||
std::unique_ptr<ProjectLock> m_lock;
|
||||
/// File this project was opened read-only for, because another
|
||||
/// session holds its lock: write() does not write to it
|
||||
QString m_locked_out_path;
|
||||
/// Filepath for which this project is considered read only
|
||||
QString read_only_file_path_;
|
||||
/// Default dimensions and properties for new diagrams created within the project
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
#include "../dataBase/projectdatabase.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetproject.h"
|
||||
#include "../projectlock.h"
|
||||
#include "../qetresult.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/conductortextitem.h"
|
||||
@@ -269,6 +270,9 @@ bool QetScriptApi::runFlag(const QString &flag, const QStringList &args)
|
||||
}
|
||||
QStringList full_args;
|
||||
full_args << flag << path << args;
|
||||
//The project's own file, which this session has locked, is no conflict
|
||||
if (m_project->lock() && m_project->lock()->isHeld() && args.value(0) == path)
|
||||
full_args << QStringLiteral("--ignore-lock");
|
||||
|
||||
if (m_view)
|
||||
{
|
||||
@@ -420,6 +424,19 @@ bool QetScriptApi::save(const QString &output)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
|
||||
//Not onto a file someone else has open (their lock beside it); the
|
||||
//lock this project holds itself is no conflict
|
||||
{
|
||||
const QString target = output.isEmpty() ? m_project->filePath() : output;
|
||||
const ProjectLock::Holder holder = target.isEmpty() ? ProjectLock::Holder()
|
||||
: ProjectLock::liveHolder(target, m_project->lock() ? m_project->lock()->session() : QString());
|
||||
if (holder.valid) {
|
||||
log(QStringLiteral("qet.save: %1 is open by %2 on %3 (lock file %4); not written -- pass another output path")
|
||||
.arg(target, holder.user, holder.host, ProjectLock::lockPathFor(target)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (output.isEmpty())
|
||||
{
|
||||
if (m_project->filePath().isEmpty()) {
|
||||
|
||||
@@ -191,6 +191,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
|
||||
ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool());
|
||||
ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool());
|
||||
ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt());
|
||||
ui->m_project_lock->setChecked(settings.value("projectlock/enabled", true).toBool());
|
||||
ui->m_previous_versions_sb->setValue(PreviousVersions::count());
|
||||
|
||||
QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " +
|
||||
@@ -419,6 +420,7 @@ void GeneralConfigurationPage::applyConf()
|
||||
}
|
||||
}
|
||||
settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value());
|
||||
settings.setValue("projectlock/enabled", ui->m_project_lock->isChecked());
|
||||
PreviousVersions::setCount(ui->m_previous_versions_sb->value());
|
||||
|
||||
settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked());
|
||||
|
||||
@@ -337,6 +337,16 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QCheckBox" name="m_project_lock">
|
||||
<property name="toolTip">
|
||||
<string>A small hidden file beside the project says who has it open. Someone else opening it is told, and can open it read-only. Turn this off if your shared drive does not cope with it.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Tell others who has a project open (lock file beside the project)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QCheckBox" name="m_save_label_paste">
|
||||
<property name="text">
|
||||
@@ -387,7 +397,7 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<item row="9" column="0">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
@@ -1554,6 +1564,7 @@
|
||||
<tabstop>m_use_tab_mode_rb</tabstop>
|
||||
<tabstop>m_save_label_paste</tabstop>
|
||||
<tabstop>m_autonumber_pasted</tabstop>
|
||||
<tabstop>m_project_lock</tabstop>
|
||||
<tabstop>m_use_folio_label</tabstop>
|
||||
<tabstop>m_border_0</tabstop>
|
||||
<tabstop>m_autosave_sb</tabstop>
|
||||
|
||||
@@ -596,6 +596,17 @@ else()
|
||||
target_link_libraries(tst_crashhandler PRIVATE Qt::Test Qt::Xml)
|
||||
endif()
|
||||
|
||||
# projectlock.cpp is the sidecar file that says who has a project open; it
|
||||
# needs only Qt Core and the version number, so it is linked on its own.
|
||||
add_executable(
|
||||
tst_projectlock
|
||||
tst_projectlock.cpp
|
||||
${QET_DIR}/sources/projectlock.cpp
|
||||
${QET_DIR}/sources/qetversion.cpp)
|
||||
add_test(NAME tst_projectlock COMMAND tst_projectlock)
|
||||
target_include_directories(tst_projectlock PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_projectlock PRIVATE Qt::Test Qt::Xml)
|
||||
|
||||
# The crash-dump bookkeeping from #905: which dumps get listed, offered and
|
||||
# deleted, and what redact() masks. qetlogger.cpp needs exactly one symbol
|
||||
# from the application (QETApp::dataDir()), which the test supplies itself,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "projectlock.h"
|
||||
|
||||
#include <QtTest>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
#include <QSettings>
|
||||
#include <QTemporaryDir>
|
||||
#include <QJsonArray>
|
||||
#ifndef Q_OS_WIN
|
||||
# include <unistd.h>
|
||||
#endif
|
||||
|
||||
/**
|
||||
ProjectLock -- the sidecar file saying who has a project open.
|
||||
*/
|
||||
class tst_projectlock : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
QTemporaryDir m_dir;
|
||||
QString m_project;
|
||||
|
||||
static QJsonObject readJson(const QString &project)
|
||||
{
|
||||
QFile f(ProjectLock::lockPathFor(project));
|
||||
if (!f.open(QIODevice::ReadOnly)) return QJsonObject();
|
||||
return QJsonDocument::fromJson(f.readAll()).object();
|
||||
}
|
||||
|
||||
//A lock file as another session would have made it
|
||||
static void writeLock(const QString &project, const QString &host, qint64 pid,
|
||||
const QDateTime &heartbeat, const QString &user = QStringLiteral("alice"))
|
||||
{
|
||||
QJsonObject o;
|
||||
o["format"] = 1;
|
||||
o["session"] = QStringLiteral("other-session");
|
||||
o["user"] = user;
|
||||
o["host"] = host;
|
||||
o["pid"] = static_cast<double>(pid);
|
||||
o["app"] = QStringLiteral("QElectroTech test");
|
||||
o["since"] = heartbeat.addSecs(-600).toUTC().toString(Qt::ISODate);
|
||||
o["heartbeat"] = heartbeat.toUTC().toString(Qt::ISODate);
|
||||
QFile f(ProjectLock::lockPathFor(project));
|
||||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||||
f.write(QJsonDocument(o).toJson());
|
||||
}
|
||||
|
||||
static qint64 deadPid()
|
||||
{
|
||||
QProcess p;
|
||||
p.start(QStringLiteral("true"));
|
||||
p.waitForFinished();
|
||||
return p.processId() ? p.processId() : 999999;
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("QElectroTech-tst_projectlock"));
|
||||
QCoreApplication::setApplicationName(QStringLiteral("tst_projectlock"));
|
||||
QSettings().clear();
|
||||
QVERIFY(m_dir.isValid());
|
||||
}
|
||||
|
||||
void init()
|
||||
{
|
||||
m_project = m_dir.filePath(QStringLiteral("plant.qetz"));
|
||||
QFile::remove(ProjectLock::lockPathFor(m_project));
|
||||
ProjectLock::setHeadless(false);
|
||||
ProjectLock::setHeartbeatInterval(60000);
|
||||
}
|
||||
|
||||
void sidecarNameAndContent()
|
||||
{
|
||||
QCOMPARE(QFileInfo(ProjectLock::lockPathFor(m_project)).fileName(),
|
||||
QStringLiteral(".~plant.qetz.lock"));
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QVERIFY(lock.isHeld());
|
||||
const QJsonObject o = readJson(m_project);
|
||||
QCOMPARE(o["format"].toInt(), 1);
|
||||
QCOMPARE(o["session"].toString(), lock.session());
|
||||
QCOMPARE(o["user"].toString(), ProjectLock::thisUser());
|
||||
QCOMPARE(o["host"].toString(), ProjectLock::thisHost());
|
||||
QCOMPARE(static_cast<qint64>(o["pid"].toDouble()), QCoreApplication::applicationPid());
|
||||
QVERIFY(o["app"].toString().startsWith(QStringLiteral("QElectroTech")));
|
||||
QVERIFY(QDateTime::fromString(o["since"].toString(), Qt::ISODate).isValid());
|
||||
QVERIFY(QDateTime::fromString(o["heartbeat"].toString(), Qt::ISODate).isValid());
|
||||
QCOMPARE(o["scope"].toArray().at(0).toObject()["kind"].toString(), QStringLiteral("project"));
|
||||
}
|
||||
|
||||
void removedOnRelease()
|
||||
{
|
||||
{
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QVERIFY(QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
|
||||
}
|
||||
QVERIFY(!QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
|
||||
}
|
||||
|
||||
void secondSessionSeesItHeld()
|
||||
{
|
||||
ProjectLock first;
|
||||
QCOMPARE(first.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
ProjectLock second;
|
||||
QCOMPARE(second.acquire(m_project), ProjectLock::Result::Held);
|
||||
QVERIFY(!second.isHeld());
|
||||
QCOMPARE(second.blockingHolder().session, first.session());
|
||||
QCOMPARE(second.blockingHolder().user, ProjectLock::thisUser());
|
||||
//and it did not disturb the first one's file
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), first.session());
|
||||
//a released session does not remove another's lock
|
||||
second.release();
|
||||
QVERIFY(QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
|
||||
}
|
||||
|
||||
void takeOverReplacesAndTheOldOneIsTold()
|
||||
{
|
||||
ProjectLock first;
|
||||
QCOMPARE(first.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
ProjectLock second;
|
||||
QCOMPARE(second.acquire(m_project, true), ProjectLock::Result::Acquired);
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), second.session());
|
||||
QSignalSpy taken(&first, &ProjectLock::lockTaken);
|
||||
QVERIFY(!first.refresh());
|
||||
QCOMPARE(taken.count(), 1);
|
||||
//the old one must not delete the new one's file
|
||||
first.release();
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), second.session());
|
||||
}
|
||||
|
||||
void staleLockOfDeadProcessOnThisHostIsTaken()
|
||||
{
|
||||
writeLock(m_project, ProjectLock::thisHost(), deadPid(), QDateTime::currentDateTimeUtc());
|
||||
QVERIFY(ProjectLock::isStale(ProjectLock::readHolder(m_project)));
|
||||
QVERIFY(!ProjectLock::liveHolder(m_project).valid);
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
|
||||
}
|
||||
|
||||
void liveProcessOnThisHostIsHeldHoweverOld()
|
||||
{
|
||||
writeLock(m_project, ProjectLock::thisHost(), QCoreApplication::applicationPid(),
|
||||
QDateTime::currentDateTimeUtc().addDays(-3));
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Held);
|
||||
}
|
||||
|
||||
void otherHostIsHeldHoweverOldAndShowsHeartbeat()
|
||||
{
|
||||
const QDateTime old_beat = QDateTime::currentDateTimeUtc().addSecs(-3 * 3600);
|
||||
writeLock(m_project, QStringLiteral("WORKSHOP-PC"), 4120, old_beat);
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Held);
|
||||
QCOMPARE(lock.blockingHolder().host, QStringLiteral("WORKSHOP-PC"));
|
||||
QCOMPARE(lock.blockingHolder().user, QStringLiteral("alice"));
|
||||
QVERIFY(qAbs(lock.blockingHolder().heartbeat.secsTo(old_beat)) <= 1);
|
||||
//the 30 s age that makes QLockFile decide "stale" does not apply
|
||||
QVERIFY(ProjectLock::liveHolder(m_project).valid);
|
||||
}
|
||||
|
||||
void heartbeatIsRefreshed()
|
||||
{
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
const QDateTime first = ProjectLock::readHolder(m_project).heartbeat;
|
||||
const QString since = readJson(m_project)["since"].toString();
|
||||
QTest::qWait(1100);
|
||||
QVERIFY(lock.refresh());
|
||||
QVERIFY(ProjectLock::readHolder(m_project).heartbeat > first);
|
||||
//since stays what it was
|
||||
QCOMPARE(readJson(m_project)["since"].toString(), since);
|
||||
}
|
||||
|
||||
void heartbeatTimerRuns()
|
||||
{
|
||||
ProjectLock::setHeartbeatInterval(200);
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
const QDateTime first = ProjectLock::readHolder(m_project).heartbeat;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(ProjectLock::readHolder(m_project).heartbeat > first, 4000);
|
||||
}
|
||||
|
||||
void refreshMakesAgainAFileTheShareDropped()
|
||||
{
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QFile::remove(ProjectLock::lockPathFor(m_project));
|
||||
QVERIFY(lock.refresh());
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
|
||||
}
|
||||
|
||||
void readOnlyFolderDoesNotBlock()
|
||||
{
|
||||
#ifndef Q_OS_WIN
|
||||
if (geteuid() == 0)
|
||||
QSKIP("root can write anywhere");
|
||||
#endif
|
||||
QTemporaryDir ro;
|
||||
QVERIFY(ro.isValid());
|
||||
const QString project = ro.filePath(QStringLiteral("plant.qet"));
|
||||
QVERIFY(QFile::setPermissions(ro.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(project), ProjectLock::Result::Unavailable);
|
||||
QVERIFY(!lock.isHeld());
|
||||
QFile::setPermissions(ro.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
}
|
||||
|
||||
void moveToFollowsSaveAs()
|
||||
{
|
||||
const QString other = m_dir.filePath(QStringLiteral("copy.qetz"));
|
||||
QFile::remove(ProjectLock::lockPathFor(other));
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QVERIFY(lock.moveTo(other));
|
||||
QVERIFY(!QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
|
||||
QCOMPARE(readJson(other)["session"].toString(), lock.session());
|
||||
QCOMPARE(lock.projectPath(), other);
|
||||
}
|
||||
|
||||
void moveToRefusedOntoAFileSomeoneElseHas()
|
||||
{
|
||||
const QString other = m_dir.filePath(QStringLiteral("theirs.qetz"));
|
||||
writeLock(other, QStringLiteral("WORKSHOP-PC"), 4120, QDateTime::currentDateTimeUtc());
|
||||
QVERIFY(ProjectLock::liveHolder(other, QStringLiteral("my-session")).valid);
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QVERIFY(!lock.moveTo(other));
|
||||
//still holding the old file
|
||||
QVERIFY(lock.isHeld());
|
||||
QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
|
||||
QCOMPARE(readJson(other)["user"].toString(), QStringLiteral("alice"));
|
||||
QFile::remove(ProjectLock::lockPathFor(other));
|
||||
}
|
||||
|
||||
void ownLockIsNoConflict()
|
||||
{
|
||||
ProjectLock lock;
|
||||
QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
|
||||
QVERIFY(!ProjectLock::liveHolder(m_project, lock.session()).valid);
|
||||
QVERIFY(ProjectLock::liveHolder(m_project).valid); // a headless caller has no session
|
||||
}
|
||||
|
||||
void settingTurnsItOff()
|
||||
{
|
||||
QVERIFY(ProjectLock::enabled());
|
||||
QVERIFY(ProjectLock::mayTake());
|
||||
ProjectLock::setEnabled(false);
|
||||
QVERIFY(!ProjectLock::mayTake());
|
||||
ProjectLock::setEnabled(true);
|
||||
ProjectLock::setHeadless(true);
|
||||
QVERIFY(!ProjectLock::mayTake());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(tst_projectlock)
|
||||
#include "tst_projectlock.moc"
|
||||
Reference in New Issue
Block a user