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

  1. 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.
  2. Check network connectivity — the lock-refresh request must succeed periodically within lockTtl; fix the connection and trigger a new sync.
  3. If the lock is stuck (a client crashed holding it), wait for the TTL to expire, then retry the sync from a single client.
  4. 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

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


AI-assisted analysis of laurent22/joplin@2654b33620 (2026-08-12). Data as JSON: /api/errors/c319ece09edf74b7. Report an issue: GitHub.