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:
Laurent Trinques
2026-10-16 07:05:44 +02:00
committed by GitHub
17 changed files with 1013 additions and 1 deletions
+2
View File
@@ -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
+15
View File
@@ -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")
+6
View File
@@ -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
+4
View File
@@ -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()));
}
+385
View File
@@ -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;
}
+116
View File
@@ -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
+24
View File
@@ -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 "
+1
View File
@@ -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:
+90
View File
@@ -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();
+3
View File
@@ -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;
+36
View File
@@ -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
+10
View File
@@ -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
+17
View File
@@ -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>
+11
View File
@@ -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,
+279
View File
@@ -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"