laurent22/joplin · error · JoplinError
lockError
lockError
Error message
Sync target is locked - aborting API call
What it means
Thrown at the very top of Synchronizer.apiCall() when the internal flag syncTargetIsLocked_ is already true. That flag is latched on at Synchronizer.ts:582 inside the auto-lock-refresh failure callback — meaning Joplin's sync lock could no longer be refreshed (another client took an exclusive lock, the lock TTL expired, or the network dropped mid-sync). Once latched, every subsequent apiCall short-circuits with this error so the sync aborts cleanly instead of writing to a target it no longer owns.
Source
Thrown at packages/lib/Synchronizer.ts:380
}
private async setPpkIfNotExist(localInfo: SyncInfo, remoteInfo: SyncInfo) {
if (localInfo.ppk || remoteInfo.ppk) return localInfo;
const password = getMasterPassword(false);
if (!password) return localInfo;
try {
localInfo.ppk = await generateKeyPair(this.encryptionService(), password);
} catch (error) {
// TODO: Remove after RSA encryption is supported on all platforms.
logger.error('Failed to generate RSA key pair', error);
}
return localInfo;
}
private async apiCall(fnName: string, ...args: unknown[]) {
if (this.syncTargetIsLocked_) throw new JoplinError('Sync target is locked - aborting API call', 'lockError');
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FileApi exposes many methods with heterogeneous shapes (get/put/list/delete/multiPut/...); dispatching by name keeps the call generic across drivers
const output = await (this.api() as any)[fnName](...args);
return output;
} catch (error) {
const lockStatus = await this.lockErrorStatus_();
// When there's an error due to a lock, we re-wrap the error and change the error code so that error handling
// does not do special processing on the original error. For example, if a resource could not be downloaded,
// don't mark it as a "cannotSyncItem" since we don't know that.
if (lockStatus) {
throw new JoplinError(`Sync target lock error: ${lockStatus}. Original error was: ${error.message}`, 'lockError');
} else {
throw error;
}
}
}
View on GitHub (pinned to 2654b33620)
Solutions
- Ensure no other Joplin client is running a full sync against the same target at the same time; let the other client finish or release its exclusive lock.
- Check network connectivity — the lock-refresh request must succeed periodically within lockTtl; fix the connection and trigger a new sync.
- If the lock is stuck (a client crashed holding it), wait for the TTL to expire, then retry the sync from a single client.
- Review sync target configuration for the correct server/credentials so the lock endpoint is reachable.
Example fix
// No code fix — operational resolution. // Ensure only one client syncs at a time and that connectivity is stable // for the duration of the sync so lock refresh succeeds.
Defensive patterns
Strategy: retry
Validate before calling
// Before triggering sync, confirm no other client is actively syncing
// and that the lock can be acquired.
const locks = await synchronizer.lockHandler().locks();
const exclusive = await hasActiveLock(
locks, await synchronizer.lockHandler().currentDate(),
synchronizer.lockHandler().lockTtl, LockType.Exclusive,
);
if (exclusive) {
// wait for the other client to finish before syncing
return;
} Type guard
// JoplinError narrowing for lock errors
import JoplinError from './JoplinError';
function isLockError(e: unknown): e is JoplinError {
return e instanceof JoplinError && (e as any).code === 'lockError';
} Try / catch
try {
await synchronizer.apiCall('stat', path);
} catch (error) {
if (isLockError(error)) {
// stop the current sync and schedule a retry after backoff
await synchronizer.cancel();
scheduleRetry();
return;
}
throw error;
} Prevention
- Run only one full sync at a time across all clients sharing a target.
- Ensure network connectivity is stable for the whole sync so lock refresh succeeds.
- Surface lock errors to the user with a 'retry' affordance rather than treating them as fatal.
When it happens
Trigger: Calling any Synchronizer method that delegates to apiCall() (stat/get/put/list/delete/multiPut) AFTER the lock-refresh callback has fired. Concretely: a second device starts a full sync and acquires an exclusive lock, the auto-refresh network request fails, or the sync lock TTL elapses while a long upload is still running. The next apiCall in the same sync run hits the guard.
Common situations: Two clients syncing against the same target simultaneously; mobile sync interrupted by connectivity loss so the lock can't be refreshed; switching between devices rapidly; long sync runs on slow links where the lock TTL (lockTtl) is shorter than the upload.
Related errors
- Synchronisation is already in progress.
- uploadBlob: ${method} ${url}: ${error.toString()}
- AWS S3 bucket not found: ${SyncTargetAmazonS3.s3BucketName()
- Could not access data on server "${options.path()}"
- WebDAV directory not found: ${options.path()}
AI-assisted analysis of laurent22/joplin@2654b33620 (2026-08-12).
Data as JSON: /api/errors/c319ece09edf74b7.
Report an issue: GitHub.