Skip to content

Commit c92bf6d

Browse files
Merge pull request #235 from cloudflare/fix-r2-persistence
Fix R2 persistence: three interacting bugs + sandbox API workarounds
2 parents 8a9cb7c + 6ecc273 commit c92bf6d

11 files changed

Lines changed: 425 additions & 165 deletions

File tree

src/gateway/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { buildEnvVars } from './env';
22
export { mountR2Storage } from './r2';
33
export { findExistingMoltbotProcess, ensureMoltbotGateway } from './process';
4-
export { syncToR2 } from './sync';
4+
export { fireAndForgetSync, syncToR2 } from './sync';
55
export { waitForProcess } from './utils';

src/gateway/r2.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ describe('mountR2Storage', () => {
145145
it('returns true if mount fails but check shows it is actually mounted', async () => {
146146
const { sandbox, mountBucketMock, startProcessMock } = createMockSandbox();
147147
startProcessMock
148-
.mockResolvedValueOnce(createMockProcess(''))
149-
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'));
148+
.mockResolvedValueOnce(createMockProcess('not-mounted\n'))
149+
.mockResolvedValueOnce(createMockProcess('mounted\n'));
150150

151151
mountBucketMock.mockRejectedValue(new Error('Transient error'));
152152

src/gateway/r2.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import type { Sandbox } from '@cloudflare/sandbox';
22
import type { MoltbotEnv } from '../types';
33
import { R2_MOUNT_PATH, getR2BucketName } from '../config';
4+
import { waitForProcess } from './utils';
45

56
/**
6-
* Check if R2 is already mounted by looking at the mount table
7+
* Check if R2 is already mounted by looking at the mount table.
8+
* Uses stdout marker pattern because getLogs() often returns empty.
79
*/
810
async function isR2Mounted(sandbox: Sandbox): Promise<boolean> {
911
try {
10-
const proc = await sandbox.startProcess(`mount | grep "s3fs on ${R2_MOUNT_PATH}"`);
11-
// Wait for the command to complete
12-
let attempts = 0;
13-
while (proc.status === 'running' && attempts < 10) {
14-
// eslint-disable-next-line no-await-in-loop -- intentional sequential polling
15-
await new Promise((r) => setTimeout(r, 200));
16-
attempts++;
17-
}
12+
const proc = await sandbox.startProcess(
13+
`mount | grep -q "s3fs on ${R2_MOUNT_PATH}" && echo mounted || echo not-mounted`,
14+
);
15+
await waitForProcess(proc, 5000);
1816
const logs = await proc.getLogs();
19-
// If stdout has content, the mount exists
20-
const mounted = !!(logs.stdout && logs.stdout.includes('s3fs'));
17+
const mounted = !!(
18+
logs.stdout &&
19+
logs.stdout.includes('mounted') &&
20+
!logs.stdout.includes('not-mounted')
21+
);
2122
console.log('isR2Mounted check:', mounted, 'stdout:', logs.stdout?.slice(0, 100));
2223
return mounted;
2324
} catch (err) {
@@ -34,15 +35,13 @@ async function isR2Mounted(sandbox: Sandbox): Promise<boolean> {
3435
* @returns true if mounted successfully, false otherwise
3536
*/
3637
export async function mountR2Storage(sandbox: Sandbox, env: MoltbotEnv): Promise<boolean> {
37-
// Skip if R2 credentials are not configured
3838
if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.CF_ACCOUNT_ID) {
3939
console.log(
4040
'R2 storage not configured (missing R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or CF_ACCOUNT_ID)',
4141
);
4242
return false;
4343
}
4444

45-
// Check if already mounted first - this avoids errors and is faster
4645
if (await isR2Mounted(sandbox)) {
4746
console.log('R2 bucket already mounted at', R2_MOUNT_PATH);
4847
return true;
@@ -53,7 +52,6 @@ export async function mountR2Storage(sandbox: Sandbox, env: MoltbotEnv): Promise
5352
console.log('Mounting R2 bucket', bucketName, 'at', R2_MOUNT_PATH);
5453
await sandbox.mountBucket(bucketName, R2_MOUNT_PATH, {
5554
endpoint: `https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
56-
// Pass credentials explicitly since we use R2_* naming instead of AWS_*
5755
credentials: {
5856
accessKeyId: env.R2_ACCESS_KEY_ID,
5957
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
@@ -65,13 +63,12 @@ export async function mountR2Storage(sandbox: Sandbox, env: MoltbotEnv): Promise
6563
const errorMessage = err instanceof Error ? err.message : String(err);
6664
console.log('R2 mount error:', errorMessage);
6765

68-
// Check again if it's mounted - the error might be misleading
66+
// Check again if it's mounted - the error might be misleading (e.g. "already mounted")
6967
if (await isR2Mounted(sandbox)) {
7068
console.log('R2 bucket is mounted despite error');
7169
return true;
7270
}
7371

74-
// Don't fail if mounting fails - moltbot can still run without persistent storage
7572
console.error('Failed to mount R2 bucket:', err);
7673
return false;
7774
}

src/gateway/sync.test.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ describe('syncToR2', () => {
4242
it('returns error when source has no config file', async () => {
4343
const { sandbox, startProcessMock } = createMockSandbox();
4444
startProcessMock
45-
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
46-
.mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })) // No openclaw.json
47-
.mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })); // No clawdbot.json either
45+
.mockResolvedValueOnce(createMockProcess('mounted\n'))
46+
.mockResolvedValueOnce(createMockProcess('')) // No openclaw.json
47+
.mockResolvedValueOnce(createMockProcess('')); // No clawdbot.json either
4848

4949
const env = createMockEnvWithR2();
5050

@@ -56,62 +56,76 @@ describe('syncToR2', () => {
5656
});
5757

5858
describe('sync execution', () => {
59-
it('returns success when sync completes', async () => {
59+
it('returns success when timestamp file is written', async () => {
6060
const { sandbox, startProcessMock } = createMockSandbox();
6161
const timestamp = '2026-01-27T12:00:00+00:00';
6262

63-
// Calls: mount check, check openclaw.json, rsync, cat timestamp
63+
// Calls: mount check, config detect, rsync (all-in-one), cat timestamp
6464
startProcessMock
65-
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
66-
.mockResolvedValueOnce(createMockProcess('ok'))
67-
.mockResolvedValueOnce(createMockProcess(''))
68-
.mockResolvedValueOnce(createMockProcess(timestamp));
65+
.mockResolvedValueOnce(createMockProcess('mounted\n'))
66+
.mockResolvedValueOnce(createMockProcess('exists'))
67+
.mockResolvedValueOnce(createMockProcess('')) // rsync chain
68+
.mockResolvedValueOnce(createMockProcess(timestamp)); // cat timestamp
6969

7070
const env = createMockEnvWithR2();
71-
7271
const result = await syncToR2(sandbox, env);
7372

7473
expect(result.success).toBe(true);
7574
expect(result.lastSync).toBe(timestamp);
7675
});
7776

78-
it('returns error when rsync fails (no timestamp created)', async () => {
77+
it('falls back to legacy clawdbot config directory', async () => {
7978
const { sandbox, startProcessMock } = createMockSandbox();
79+
const timestamp = '2026-01-27T12:00:00+00:00';
8080

81-
// Calls: mount check, check openclaw.json, rsync (fails), cat timestamp (empty)
8281
startProcessMock
83-
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
84-
.mockResolvedValueOnce(createMockProcess('ok'))
85-
.mockResolvedValueOnce(createMockProcess('', { exitCode: 1 }))
86-
.mockResolvedValueOnce(createMockProcess(''));
82+
.mockResolvedValueOnce(createMockProcess('mounted\n'))
83+
.mockResolvedValueOnce(createMockProcess('')) // No openclaw.json
84+
.mockResolvedValueOnce(createMockProcess('exists')) // clawdbot.json found
85+
.mockResolvedValueOnce(createMockProcess('')) // rsync chain
86+
.mockResolvedValueOnce(createMockProcess(timestamp));
8787

8888
const env = createMockEnvWithR2();
89-
9089
const result = await syncToR2(sandbox, env);
9190

91+
expect(result.success).toBe(true);
92+
93+
// rsync chain should reference .clawdbot
94+
const rsyncCall = startProcessMock.mock.calls[3][0];
95+
expect(rsyncCall).toContain('/root/.clawdbot/');
96+
});
97+
98+
it('returns error when no timestamp after sync', async () => {
99+
const { sandbox, startProcessMock } = createMockSandbox();
100+
101+
startProcessMock
102+
.mockResolvedValueOnce(createMockProcess('mounted\n'))
103+
.mockResolvedValueOnce(createMockProcess('exists'))
104+
.mockResolvedValueOnce(createMockProcess('')) // rsync chain
105+
.mockResolvedValue(createMockProcess('')); // all cat polls return empty
106+
107+
const env = createMockEnvWithR2();
108+
const result = await syncToR2(sandbox, env, 10, 3); // fast: 10ms interval, 3 polls
109+
92110
expect(result.success).toBe(false);
93-
expect(result.error).toBe('Sync failed');
111+
expect(result.error).toBe('Sync timed out');
94112
});
95113

96-
it('verifies rsync command is called with correct flags', async () => {
114+
it('verifies rsync command excludes .git', async () => {
97115
const { sandbox, startProcessMock } = createMockSandbox();
98116
const timestamp = '2026-01-27T12:00:00+00:00';
99117

100118
startProcessMock
101-
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
102-
.mockResolvedValueOnce(createMockProcess('ok'))
119+
.mockResolvedValueOnce(createMockProcess('mounted\n'))
120+
.mockResolvedValueOnce(createMockProcess('exists'))
103121
.mockResolvedValueOnce(createMockProcess(''))
104122
.mockResolvedValueOnce(createMockProcess(timestamp));
105123

106124
const env = createMockEnvWithR2();
107-
108125
await syncToR2(sandbox, env);
109126

110-
// Third call should be rsync to openclaw/ R2 prefix
111127
const rsyncCall = startProcessMock.mock.calls[2][0];
112-
expect(rsyncCall).toContain('rsync');
113-
expect(rsyncCall).toContain('--no-times');
114-
expect(rsyncCall).toContain('--delete');
128+
expect(rsyncCall).toContain("--exclude='.git'");
115129
expect(rsyncCall).toContain('/root/.openclaw/');
116130
expect(rsyncCall).toContain('/data/moltbot/openclaw/');
117131
});

0 commit comments

Comments
 (0)