openclaw - 💡(How to fix) Fix [Bug]: Gateway data loss: Silent read error on `paired.json` leads to overwrite of all pairings [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#71873Fetched 2026-04-26 05:07:14
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
0
Author
Participants
Timeline (top)
closed ×1commented ×1

A critical data loss bug exists in the OpenClaw Gateway's device and node pairing logic. If paired.json cannot be read on gateway startup for any transient reason (e.g., file lock, temporary I/O error, file corruption), the gateway proceeds with an empty in-memory list of pairings. The next time the state is persisted, this empty list overwrites the valid paired.json file, permanently deleting all existing pairings.

This is likely the underlying root cause for several historical, stale-closed issues related to pairing loss after gateway restarts (e.g., #22866, #21647).

Error Message

A critical data loss bug exists in the OpenClaw Gateway's device and node pairing logic. If paired.json cannot be read on gateway startup for any transient reason (e.g., file lock, temporary I/O error, file corruption), the gateway proceeds with an empty in-memory list of pairings. The next time the state is persisted, this empty list overwrites the valid paired.json file, permanently deleting all existing pairings.

  1. Silent Failure in readJsonFile: The utility function readJsonFile (from src/infra/json-files.ts) is designed to catch any and all read/parse errors and return null instead of throwing an error.
  2. readJsonFile attempts to read paired.json but fails due to a transient error (e.g., another process holds a temporary lock). It returns null. The error handling in loadState needs to be more nuanced. It should not treat a read/parse failure on an existing file as an empty state. // This is the critical error condition // The gateway should log a severe error and perhaps enter a degraded state throw new Error(Failed to read or parse existing pairing file at ${pairedPath}. Halting to prevent data loss.);

Root Cause

The bug is located in the loadState function within src/infra/node-pairing.ts (and presumably a similar function in src/infra/device-pairing.ts).

File: src/infra/node-pairing.ts

async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
  const { pairedPath } = resolvePairingPaths(baseDir, "nodes");
  const [, paired] = await Promise.all([
    // ...
    readJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
  ]);
  const state: NodePairingStateFile = {
    // ...
    pairedByNodeId: paired ?? {}, // <-- ROOT CAUSE
  };
  return state;
}

The issue stems from two design decisions:

  1. Silent Failure in readJsonFile: The utility function readJsonFile (from src/infra/json-files.ts) is designed to catch any and all read/parse errors and return null instead of throwing an error.
  2. Nullish Coalescing in loadState: The loadState function uses the ?? {} operator, which treats the null return from a failed read as equivalent to the file being non-existent or empty.

Sequence of Events:

  1. Gateway starts.
  2. loadState is called.
  3. readJsonFile attempts to read paired.json but fails due to a transient error (e.g., another process holds a temporary lock). It returns null.
  4. loadState receives null and initializes state.pairedByNodeId to an empty object {}.
  5. The gateway continues to run, now believing no devices are paired.
  6. An event occurs that triggers persistState (e.g., a node attempts to reconnect, an admin action is taken).
  7. persistState calls writeJsonAtomic, writing the empty in-memory state to paired.json, permanently destroying the user's pairing data.

The writeJsonAtomic function itself is robust, which ironically ensures the data is overwritten very reliably.

Code Example

async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
  const { pairedPath } = resolvePairingPaths(baseDir, "nodes");
  const [, paired] = await Promise.all([
    // ...
    readJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
  ]);
  const state: NodePairingStateFile = {
    // ...
    pairedByNodeId: paired ?? {}, // <-- ROOT CAUSE
  };
  return state;
}

---

// Pseudo-code for a safer approach in loadState
const paired = await readJsonFile(pairedPath);
if (paired === null) {
  // Check if the file exists but failed to parse/read
  const fileExists = await fs.access(pairedPath).then(() => true).catch(() => false);
  if (fileExists) {
    // This is the critical error condition
    // The gateway should log a severe error and perhaps enter a degraded state
    // where it refuses to write back to this file to prevent data loss.
    throw new Error(`Failed to read or parse existing pairing file at ${pairedPath}. Halting to prevent data loss.`);
  }
}
// If we are here, either the file was read successfully, or it legitimately doesn't exist.
// It is now safe to default to an empty object.
const state = { pairedByNodeId: paired ?? {} };
RAW_BUFFERClick to expand / collapse

Summary

A critical data loss bug exists in the OpenClaw Gateway's device and node pairing logic. If paired.json cannot be read on gateway startup for any transient reason (e.g., file lock, temporary I/O error, file corruption), the gateway proceeds with an empty in-memory list of pairings. The next time the state is persisted, this empty list overwrites the valid paired.json file, permanently deleting all existing pairings.

This is likely the underlying root cause for several historical, stale-closed issues related to pairing loss after gateway restarts (e.g., #22866, #21647).

Root Cause Analysis

The bug is located in the loadState function within src/infra/node-pairing.ts (and presumably a similar function in src/infra/device-pairing.ts).

File: src/infra/node-pairing.ts

async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
  const { pairedPath } = resolvePairingPaths(baseDir, "nodes");
  const [, paired] = await Promise.all([
    // ...
    readJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
  ]);
  const state: NodePairingStateFile = {
    // ...
    pairedByNodeId: paired ?? {}, // <-- ROOT CAUSE
  };
  return state;
}

The issue stems from two design decisions:

  1. Silent Failure in readJsonFile: The utility function readJsonFile (from src/infra/json-files.ts) is designed to catch any and all read/parse errors and return null instead of throwing an error.
  2. Nullish Coalescing in loadState: The loadState function uses the ?? {} operator, which treats the null return from a failed read as equivalent to the file being non-existent or empty.

Sequence of Events:

  1. Gateway starts.
  2. loadState is called.
  3. readJsonFile attempts to read paired.json but fails due to a transient error (e.g., another process holds a temporary lock). It returns null.
  4. loadState receives null and initializes state.pairedByNodeId to an empty object {}.
  5. The gateway continues to run, now believing no devices are paired.
  6. An event occurs that triggers persistState (e.g., a node attempts to reconnect, an admin action is taken).
  7. persistState calls writeJsonAtomic, writing the empty in-memory state to paired.json, permanently destroying the user's pairing data.

The writeJsonAtomic function itself is robust, which ironically ensures the data is overwritten very reliably.

Suggested Fix

The error handling in loadState needs to be more nuanced. It should not treat a read/parse failure on an existing file as an empty state.

A potential fix would be to check if the file exists when readJsonFile returns null.

// Pseudo-code for a safer approach in loadState
const paired = await readJsonFile(pairedPath);
if (paired === null) {
  // Check if the file exists but failed to parse/read
  const fileExists = await fs.access(pairedPath).then(() => true).catch(() => false);
  if (fileExists) {
    // This is the critical error condition
    // The gateway should log a severe error and perhaps enter a degraded state
    // where it refuses to write back to this file to prevent data loss.
    throw new Error(`Failed to read or parse existing pairing file at ${pairedPath}. Halting to prevent data loss.`);
  }
}
// If we are here, either the file was read successfully, or it legitimately doesn't exist.
// It is now safe to default to an empty object.
const state = { pairedByNodeId: paired ?? {} };

This would prevent the data loss scenario and make the underlying I/O or corruption issue visible instead of silently failing.

Affected Files

  • src/infra/node-pairing.ts
  • src/infra/device-pairing.ts (likely has the same logic)
  • src/infra/json-files.ts (source of the silent-failing readJsonFile)

extent analysis

TL;DR

The most likely fix involves modifying the loadState function in src/infra/node-pairing.ts to handle read/parse failures of the paired.json file more robustly, preventing silent data loss.

Guidance

  • Check if the paired.json file exists when readJsonFile returns null to differentiate between a non-existent file and a read/parse failure.
  • Implement error handling to log a severe error and potentially enter a degraded state if the file exists but cannot be read or parsed, preventing further writes to avoid data loss.
  • Review and apply similar fixes to src/infra/device-pairing.ts if it contains the same logic.
  • Consider revising the readJsonFile function in src/infra/json-files.ts to throw an error instead of returning null on failure, to make issues more visible.

Example

const paired = await readJsonFile(pairedPath);
if (paired === null) {
  const fileExists = await fs.access(pairedPath).then(() => true).catch(() => false);
  if (fileExists) {
    throw new Error(`Failed to read or parse existing pairing file at ${pairedPath}. Halting to prevent data loss.`);
  }
}
const state = { pairedByNodeId: paired ?? {} };

Notes

This approach focuses on preventing data loss by making read/parse failures visible and handling them appropriately. It does not address the underlying causes of the transient errors (e.g., file locks, I/O errors) but ensures that such errors do not result in permanent data loss.

Recommendation

Apply the workaround by modifying the loadState function as suggested, to prevent data loss and make underlying issues more visible. This approach is preferable because it directly addresses the silent failure issue without requiring changes to other parts of the system.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING