gui: use immutable ID aliases in 9P paths

Connection monitoring and all 9P operations now use immutable
session/agent UUIDs in paths instead of mutable display names.
The server resolves these via the new Aliases mechanism in findChild.

This fixes the disconnected-indicator bug after session rename:
connections no longer go stale when names change.

Removed dead sessionNameForId/agentNameForId helpers and the
m_agentConnectionPaths tracking (unnecessary with stable paths).
Rename operations no longer restart streams.
This commit is contained in:
Levi Neely 2026-08-03 09:40:44 +02:00
parent 0f51c83f24
commit e4c702a691
2 changed files with 39 additions and 115 deletions

View File

@ -127,30 +127,6 @@ void Ollie9pClient::setActiveSessionId(const QString &id)
emit activeStateChanged(); emit activeStateChanged();
} }
QString Ollie9pClient::sessionNameForId(const QString &sessionId) const
{
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() == sessionId)
return session.value("name").toString();
}
return {};
}
QString Ollie9pClient::agentNameForId(const QString &sessionId, const QString &agentId) const
{
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() != sessionId) continue;
for (const QVariant &agentValue : session.value("agents").toList()) {
const QVariantMap agent = agentValue.toMap();
if (agent.value("id").toString() == agentId)
return agent.value("name").toString();
}
}
return {};
}
QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId) const QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId) const
{ {
return sessionId + "\n" + agentId; return sessionId + "\n" + agentId;
@ -159,13 +135,12 @@ QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId
QList<Ollie9pClient::AgentRecord> Ollie9pClient::readAgentRecords(const QString &sessionId) QList<Ollie9pClient::AgentRecord> Ollie9pClient::readAgentRecords(const QString &sessionId)
{ {
QList<AgentRecord> result; QList<AgentRecord> result;
QString sessionName = sessionNameForId(sessionId); if (sessionId.isEmpty()) return result;
if (sessionName.isEmpty()) sessionName = sessionId; // Use immutable ID — the 9P namespace resolves it via alias.
if (sessionName.isEmpty()) return result; const QString raw = QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed();
const QString raw = QString::fromUtf8(run9p({"ls", "session/" + sessionName + "/agent"})).trimmed();
for (const QString &name : raw.split('\n', Qt::SkipEmptyParts)) { for (const QString &name : raw.split('\n', Qt::SkipEmptyParts)) {
if (name == "new") continue; if (name == "new") continue;
const QString path = "session/" + sessionName + "/agent/" + name; const QString path = "session/" + sessionId + "/agent/" + name;
const QString id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed(); const QString id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
AgentRecord r; AgentRecord r;
r.id = id.isEmpty() ? name : id; r.id = id.isEmpty() ? name : id;
@ -239,6 +214,11 @@ void Ollie9pClient::startAgentConnections()
reconcileAgentConnections(); reconcileAgentConnections();
} }
// reconcileAgentConnections ensures one NinePConnection per known agent.
// Connections use immutable session/agent IDs in their 9P paths. The server
// namespace lists directories by mutable display name, but accepts the
// immutable UUID as an alias during path walks. This means connection paths
// remain valid across session/agent renames without reconnection.
void Ollie9pClient::reconcileAgentConnections() void Ollie9pClient::reconcileAgentConnections()
{ {
if (!m_daemonConnected) return; if (!m_daemonConnected) return;
@ -246,7 +226,6 @@ void Ollie9pClient::reconcileAgentConnections()
for (const QVariant &value : std::as_const(m_sessions)) { for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap(); const QVariantMap session = value.toMap();
const QString sid = session.value("id").toString(); const QString sid = session.value("id").toString();
const QString name = session.value("name").toString();
for (const QVariant &agentValue : session.value("agents").toList()) { for (const QVariant &agentValue : session.value("agents").toList()) {
const QVariantMap agent = agentValue.toMap(); const QVariantMap agent = agentValue.toMap();
const QString aid = agent.value("id").toString(); const QString aid = agent.value("id").toString();
@ -268,8 +247,10 @@ void Ollie9pClient::reconcileAgentConnections()
connect(connection, &NinePConnection::disconnected, this, [this, key]() { connect(connection, &NinePConnection::disconnected, this, [this, key]() {
setAgentConnected(key, false); setAgentConnected(key, false);
}); });
// Use immutable IDs in path — aliases in the 9P namespace resolve
// these to the correct session/agent regardless of display name.
connection->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker", connection->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker",
"session/" + name + "/agent/" + agent.value("name").toString() + "/connection"}, 4000, true); "session/" + sid + "/agent/" + aid + "/connection"}, 4000, true);
} }
} }
for (auto it = m_agentConnections.begin(); it != m_agentConnections.end();) { for (auto it = m_agentConnections.begin(); it != m_agentConnections.end();) {
@ -299,14 +280,8 @@ void Ollie9pClient::refreshSessions()
// Don't trim — trailing tabs are significant fields // Don't trim — trailing tabs are significant fields
QString raw = QString::fromUtf8(out); QString raw = QString::fromUtf8(out);
const QVariantList oldSessions = m_sessions;
m_sessions.clear(); m_sessions.clear();
QSet<QString> seen; QSet<QString> seen;
QHash<QString, QString> sessionNames;
for (const QVariant &value : oldSessions) {
const QVariantMap session = value.toMap();
sessionNames.insert(session.value("id").toString(), session.value("name").toString());
}
if (!raw.isEmpty()) { if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) { for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t'); QStringList parts = line.split('\t');
@ -316,12 +291,10 @@ void Ollie9pClient::refreshSessions()
if (seen.contains(sid)) continue; // already added (multi-agent) if (seen.contains(sid)) continue; // already added (multi-agent)
seen.insert(sid); seen.insert(sid);
QVariantMap session; QVariantMap session;
// session/idx currently exposes the mutable display name. Read the // session/idx format: name\tstate\tcwd\tbackend\tmodel\tagentName\tid
// immutable id and retain both values in the GUI model. // The immutable ID is in field 6 (0-indexed), avoiding a round-trip.
QByteArray idOut = run9p({"read", "session/" + sid + "/id"}).trimmed(); QString sessionId = parts.size() > 6 ? parts[6] : "";
QString sessionId = QString::fromUtf8(idOut);
if (sessionId.isEmpty()) sessionId = sid; if (sessionId.isEmpty()) sessionId = sid;
sessionNames[sessionId] = sid;
session["id"] = sessionId; session["id"] = sessionId;
session["name"] = sid; session["name"] = sid;
session["state"] = parts.size() > 1 ? parts[1] : ""; session["state"] = parts.size() > 1 ? parts[1] : "";
@ -329,9 +302,9 @@ void Ollie9pClient::refreshSessions()
session["backend"] = parts.size() > 3 ? parts[3] : ""; session["backend"] = parts.size() > 3 ? parts[3] : "";
session["model"] = parts.size() > 4 ? parts[4] : ""; session["model"] = parts.size() > 4 ? parts[4] : "";
QVariantList agents; QVariantList agents;
for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sid + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) {
if (name == "new") continue; if (name == "new") continue;
const QString path = "session/" + sid + "/agent/" + name; const QString path = "session/" + sessionId + "/agent/" + name;
AgentRecord a; AgentRecord a;
a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed(); a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
a.name = name; a.name = name;
@ -363,11 +336,8 @@ QString Ollie9pClient::readLog()
QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId) QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId)
{ {
if (sessionId.isEmpty() || agentId.isEmpty()) return {}; if (sessionId.isEmpty() || agentId.isEmpty()) return {};
QString sessName = sessionNameForId(sessionId); // Use immutable IDs — the 9P namespace resolves them via aliases.
if (sessName.isEmpty()) sessName = sessionId; QByteArray out = run9p({"read", "session/" + sessionId + "/agent/" + agentId + "/log"});
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QByteArray out = run9p({"read", "session/" + sessName + "/agent/" + agentName + "/log"});
return QString::fromUtf8(out); return QString::fromUtf8(out);
} }
@ -416,11 +386,8 @@ bool Ollie9pClient::killSession(const QString &sessionId)
stopStreams(); stopStreams();
} }
// Resolve the directory name from the immutable session ID // Use immutable ID — the 9P namespace resolves it via alias.
QString sessName = sessionNameForId(sessionId); QString path = "session/" + sessionId + "/ctl";
if (sessName.isEmpty()) sessName = sessionId;
QString path = "session/" + sessName + "/ctl";
QProcess proc; QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", path}); proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
proc.waitForStarted(3000); proc.waitForStarted(3000);
@ -440,9 +407,8 @@ QString Ollie9pClient::getConfig()
QStringList Ollie9pClient::getAgents(const QString &sessionId) QStringList Ollie9pClient::getAgents(const QString &sessionId)
{ {
if (sessionId.isEmpty()) return {}; if (sessionId.isEmpty()) return {};
QString sessName = sessionNameForId(sessionId); // Use immutable ID — the 9P namespace resolves it via alias.
if (sessName.isEmpty()) sessName = sessionId; QByteArray out = run9p({"ls", "session/" + sessionId + "/agent"});
QByteArray out = run9p({"ls", "session/" + sessName + "/agent"});
QString raw = QString::fromUtf8(out).trimmed(); QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty()) return {}; if (raw.isEmpty()) return {};
QStringList all = raw.split('\n', Qt::SkipEmptyParts); QStringList all = raw.split('\n', Qt::SkipEmptyParts);
@ -608,9 +574,6 @@ bool Ollie9pClient::createAgent(const QString &sessionId, const QString &cwd, co
{ {
if (sessionId.isEmpty() || cwd.isEmpty()) return false; if (sessionId.isEmpty() || cwd.isEmpty()) return false;
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QStringList agentArgs; QStringList agentArgs;
agentArgs << "cwd=" + cwd; agentArgs << "cwd=" + cwd;
if (!backend.isEmpty()) agentArgs << "backend=" + backend; if (!backend.isEmpty()) agentArgs << "backend=" + backend;
@ -620,7 +583,8 @@ bool Ollie9pClient::createAgent(const QString &sessionId, const QString &cwd, co
if (!agentAlias.isEmpty()) agentArgs << "name=" + agentAlias; if (!agentAlias.isEmpty()) agentArgs << "name=" + agentAlias;
QProcess proc; QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "session/" + sessName + "/agent/new"}); // Use immutable ID — the 9P namespace resolves it via alias.
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "session/" + sessionId + "/agent/new"});
proc.waitForStarted(3000); proc.waitForStarted(3000);
proc.write((agentArgs.join(" ") + "\n").toUtf8()); proc.write((agentArgs.join(" ") + "\n").toUtf8());
proc.closeWriteChannel(); proc.closeWriteChannel();
@ -640,12 +604,8 @@ bool Ollie9pClient::killAgent(const QString &sessionId, const QString &agentId)
{ {
if (sessionId.isEmpty() || agentId.isEmpty()) return false; if (sessionId.isEmpty() || agentId.isEmpty()) return false;
QString sessName = sessionNameForId(sessionId); // Use immutable IDs — the 9P namespace resolves them via aliases.
if (sessName.isEmpty()) sessName = sessionId; QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QString path = "session/" + sessName + "/agent/" + agentName + "/ctl";
QProcess proc; QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", path}); proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
proc.waitForStarted(3000); proc.waitForStarted(3000);
@ -665,16 +625,11 @@ bool Ollie9pClient::renameSession(const QString &sessionId, const QString &newNa
return false; return false;
} }
// Session names are mutable display names, not directory IDs. Writing the // Session names are mutable display names. Writing the name file triggers
// name file also avoids relying on Twstat, which is not implemented by all // the rename. Use the immutable ID to address the session.
// 9P frontends.
// Resolve the directory name from the immutable session ID
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QProcess proc; QProcess proc;
proc.setProgram(bin); proc.setProgram(bin);
proc.setArguments({"-a", serverAddr(), "write", "session/" + sessName + "/name"}); proc.setArguments({"-a", serverAddr(), "write", "session/" + sessionId + "/name"});
proc.start(); proc.start();
if (!proc.waitForStarted(3000)) { if (!proc.waitForStarted(3000)) {
qDebug() << "renameSession failed to start:" << proc.errorString(); qDebug() << "renameSession failed to start:" << proc.errorString();
@ -689,21 +644,9 @@ bool Ollie9pClient::renameSession(const QString &sessionId, const QString &newNa
return false; return false;
} }
// Stop path-based streams before changing the namespace entry. Otherwise // With immutable-ID paths, renaming no longer invalidates streams or
// Guarded streams can observe the old path disappearing and repeatedly // the active selection. Just refresh the session list to pick up the
// restart against it while this synchronous write is in progress. // new display name.
const bool active = m_activeSessionId == sessionId;
const QString activeAgent = m_agentId;
if (active)
stopAgentStreams();
if (active) {
m_activeSessionId = newName;
emit activeSessionIdChanged();
if (!activeAgent.isEmpty())
switchAgent(newName, activeAgent);
}
refreshSessions(); refreshSessions();
return true; return true;
} }
@ -718,23 +661,12 @@ bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId
return false; return false;
} }
// Agent names are mutable display names. Write the name file directly; // Agent names are mutable display names. Write the name file directly
// ctl accepts commands, not name assignments, and the agent directory is // using immutable IDs to address the path.
// keyed by its current display name.
const bool active = m_activeSessionId == sessionId && m_agentId == agentId;
if (active)
stopAgentStreams();
// Resolve directory names from immutable IDs
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QProcess proc; QProcess proc;
proc.setProgram(bin); proc.setProgram(bin);
proc.setArguments({"-a", serverAddr(), "write", proc.setArguments({"-a", serverAddr(), "write",
"session/" + sessName + "/agent/" + agentName + "/name"}); "session/" + sessionId + "/agent/" + agentId + "/name"});
proc.start(); proc.start();
if (!proc.waitForStarted(3000)) { if (!proc.waitForStarted(3000)) {
qDebug() << "renameAgent failed to start:" << proc.errorString(); qDebug() << "renameAgent failed to start:" << proc.errorString();
@ -749,11 +681,7 @@ bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId
return false; return false;
} }
if (active) { // With immutable-ID paths, renaming no longer invalidates streams.
// switchAgent updates the active ID and restarts the path-based streams.
switchAgent(sessionId, newName);
}
refreshSessions(); refreshSessions();
return true; return true;
} }
@ -815,10 +743,8 @@ void Ollie9pClient::stopStreams()
QString Ollie9pClient::agentPath() const QString Ollie9pClient::agentPath() const
{ {
const QString sessionName = sessionNameForId(m_activeSessionId); // Use immutable IDs — the 9P namespace resolves them via aliases.
const QString agentName = agentNameForId(m_activeSessionId, m_agentId); return "session/" + m_activeSessionId + "/agent/" + m_agentId;
return "session/" + (sessionName.isEmpty() ? m_activeSessionId : sessionName)
+ "/agent/" + (agentName.isEmpty() ? m_agentId : agentName);
} }
QByteArray Ollie9pClient::run9p(const QStringList &args) QByteArray Ollie9pClient::run9p(const QStringList &args)

View File

@ -111,8 +111,6 @@ private:
void stopAgentStreams(); void stopAgentStreams();
void stopStreams(); void stopStreams();
QString agentPath() const; QString agentPath() const;
QString sessionNameForId(const QString &sessionId) const;
QString agentNameForId(const QString &sessionId, const QString &agentId) const;
QByteArray run9p(const QStringList &args); QByteArray run9p(const QStringList &args);
void ensureRootDataLoaded(); void ensureRootDataLoaded();
void setDaemonConnected(bool connected); void setDaemonConnected(bool connected);