Summary
With mqtt.requireAuthentication: false and anonymous MQTT clients, an abrupt (non-clean) client disconnect makes Harper read the internal system.hdb_session_will table using the disconnecting client's own request context — which has user: null and authorize: true. The authorization check fails with AccessViolation ("Must login", statusCode 401):
[warn]: Error publishing MQTT will for <sessionId> AccessViolation: Must login
at authorizeActionOnResource (resources/Resource.ts)
at hdb_session_will.applyContext (resources/Resource.ts)
at <anonymous> (server/DurableSubscriptionsSession.ts)
at transaction (resources/transaction.ts)
at SubscriptionsSession.disconnect (server/DurableSubscriptionsSession.ts)
at WebSocket.onClose (server/mqtt.ts)
statusCode: 401
Under connect/disconnect churn (e.g. a reconnect storm after a restart), the transactions wrapping this cleanup pile up on hdb_session_will and get aborted:
[error]: Transaction was open too long and has been aborted after exceeding the open-transaction limit, from table: hdb_session_will/
Affected versions
- Confirmed on harper-pro 5.1.22 (current latest stable).
- Present on
harper main as of a0f4a51 — server/DurableSubscriptionsSession.ts.
Root cause
SubscriptionsSession.disconnect() builds its context from createContext(), which sets user: this.user and authorize: true, then uses that context to touch the internal will store:
// server/DurableSubscriptionsSession.ts
createContext(): any {
const context: any = { session: this, socket: this.socket, user: this.user, authorize: true };
...
}
disconnect(clientTerminated) {
const context = this.createContext();
transaction(context, async () => {
try {
if (!clientTerminated) {
const will = await getLastWill().get(this.sessionId); // authorized read of internal system.hdb_session_will
if (will) await publishMessage(will, will.data, context);
}
} finally {
await getLastWill().delete(this.sessionId); // same problem
}
}).catch((error) => { warn(`Error publishing MQTT will for ${this.sessionId}`, error); });
}
getLastWill() is the internal system.hdb_session_will table. Accessing it under a context with authorize: true and user: null (anonymous session) runs authorizeActionOnResource → allowRead(null) on a system table → AccessViolation. The failure is on the .get(), before any will is published, so it fires on every non-clean anonymous disconnect regardless of whether the client actually registered a will.
For contrast, the startup-recovery path in the same file handles this correctly — it reconstructs the stored user before publishing and does not run under an anonymous authorizing context:
for await (const will of getLastWill().search({})) {
const message = { ...will };
if (message.user?.username) message.user = await (server as any).getUser(message.user.username);
await publishMessage(message, data, message);
getLastWill().delete(will.id);
}
Reproduction
- Set
mqtt.requireAuthentication: false.
- Connect an anonymous MQTT client (with or without a will) over WS/TLS.
- Drop the connection abruptly (non-clean disconnect).
- Observe the
AccessViolation: Must login warning. Under many concurrent disconnects, observe the hdb_session_will "transaction open too long" aborts.
Impact
- Log noise proportional to anonymous non-clean disconnects; spikes during restart-driven reconnect storms.
- Anonymous clients' Last-Will messages are never delivered on abrupt disconnect.
- Transaction pressure on
system.hdb_session_will during storms (the open-transaction-limit aborts).
Severity is low in steady state (the cleanup path only warns and the failed transaction unwinds), but it is constant noise for any deployment that permits anonymous MQTT, and it silently breaks will delivery for those clients.
Suggested fix
Access the internal hdb_session_will store with an internal/system context (e.g. authorize: false, or a dedicated internal context) for the disconnect get/delete, rather than the client's authorizing context. When (re)publishing the will, reconstruct the stored user (as the startup-recovery path already does) so the publish is authorized as the will's owner rather than the live — possibly anonymous — session. The will-store bookkeeping is internal plumbing and shouldn't be gated on the disconnecting client's permissions.
Related
Filed by an agent (Claude Code) on behalf of @harper-joseph, from a live investigation.
Summary
With
mqtt.requireAuthentication: falseand anonymous MQTT clients, an abrupt (non-clean) client disconnect makes Harper read the internalsystem.hdb_session_willtable using the disconnecting client's own request context — which hasuser: nullandauthorize: true. The authorization check fails withAccessViolation("Must login", statusCode 401):Under connect/disconnect churn (e.g. a reconnect storm after a restart), the transactions wrapping this cleanup pile up on
hdb_session_willand get aborted:Affected versions
harpermainas ofa0f4a51—server/DurableSubscriptionsSession.ts.Root cause
SubscriptionsSession.disconnect()builds its context fromcreateContext(), which setsuser: this.userandauthorize: true, then uses that context to touch the internal will store:getLastWill()is the internalsystem.hdb_session_willtable. Accessing it under a context withauthorize: trueanduser: null(anonymous session) runsauthorizeActionOnResource→allowRead(null)on a system table →AccessViolation. The failure is on the.get(), before any will is published, so it fires on every non-clean anonymous disconnect regardless of whether the client actually registered a will.For contrast, the startup-recovery path in the same file handles this correctly — it reconstructs the stored user before publishing and does not run under an anonymous authorizing context:
Reproduction
mqtt.requireAuthentication: false.AccessViolation: Must loginwarning. Under many concurrent disconnects, observe thehdb_session_will"transaction open too long" aborts.Impact
system.hdb_session_willduring storms (the open-transaction-limit aborts).Severity is low in steady state (the cleanup path only warns and the failed transaction unwinds), but it is constant noise for any deployment that permits anonymous MQTT, and it silently breaks will delivery for those clients.
Suggested fix
Access the internal
hdb_session_willstore with an internal/system context (e.g.authorize: false, or a dedicated internal context) for the disconnectget/delete, rather than the client's authorizing context. When (re)publishing the will, reconstruct the stored user (as the startup-recovery path already does) so the publish is authorized as the will's owner rather than the live — possibly anonymous — session. The will-store bookkeeping is internal plumbing and shouldn't be gated on the disconnecting client's permissions.Related
a0f4a51) does not touch this authorization path.Filed by an agent (Claude Code) on behalf of @harper-joseph, from a live investigation.