Compare commits

...
Author SHA1 Message Date
Haze b47bfbd66a 0.3.12-alpha.0 2026-04-24 17:20:59 +08:00
HazeandClaude Opus 4 efa76b37d3 fix(gateway): fall back to junction when symlink unavailable on Windows
ensureExtensionDepsResolvable called symlinkSync without a type argument
and without path normalization. On Windows without Developer Mode or
admin rights, plain symlinkSync throws EPERM; the failure was silently
swallowed, leaving extension-owned packages unresolvable from shared
dist/ chunks and breaking gateway startup.

Extract the link logic into electron/gateway/fs-link.ts:
- linkDirSafe prefers junction on Windows (works without elevation),
  falls back to a plain dir symlink only if junction creation fails
  (e.g. cross-volume).
- normalizeFsPath centralizes the \\?\ extended-length + UNC prefixing
  that was previously an inline helper in config-sync.ts.

Also drop the now-redundant inline fsPath helper in config-sync.ts and
replace the two bare symlinkSync calls with linkDirSafe.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-24 17:11:52 +08:00
Haze 6bacbd964d perf(gateway): shorten gateway.ready fallback timeout from 30s to 5s
The fallback exists as a safety net for the server-side gateway.ready
event. In practice OpenClaw's plugin bootstrap can push the real event
well past 30s (observed: handshake completes, then 30s tick by, then the
fallback fires with no event having arrived). That long tail kept the
stale gating code blocking UI state for the full 30s.

Step 1 moved sessions.list off the gatewayReady gate, so this value now
only matters as a belt-and-braces signal for any future consumer. 5s is
long enough to preserve "event wins when it actually fires on a healthy
boot" while avoiding a multi-second stall whenever the server is slow.

Updated gateway-ready-fallback.test.ts to advance timers around the new
boundary.
2026-04-24 17:04:10 +08:00
Haze a3d5b0555f perf(gateway): gate sessions.list on state=running instead of gatewayReady
The maybeLoadSessions() guard previously waited for status.gatewayReady
to become true, which is driven by the server-side gateway.ready event
and backed by a 30s fallback timer in GatewayManager. In practice,
OpenClaw's plugin bootstrap often exceeds that window, so the fallback
fired and users stared at the loading state for ~30s after the WS
handshake had already completed.

sessions.list is a plain RPC — it needs the handshake to be done, not
plugins to be up. Gate it on state === 'running' so the session list is
fetched immediately after handshake completion. Existing throttling via
LOAD_SESSIONS_MIN_INTERVAL_MS still prevents spam on state flaps.
2026-04-24 17:03:05 +08:00
7 changed files with 210 additions and 24 deletions
+4 -15
View File
@@ -1,20 +1,9 @@
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
import { linkDirSafe, normalizeFsPath as fsPath } from './fs-link';
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
@@ -245,7 +234,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(join(topNM, pkg.name), { recursive: true });
symlinkSync(join(scopeDir, sub.name), dest);
linkDirSafe(join(scopeDir, sub.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
@@ -254,7 +243,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(topNM, { recursive: true });
symlinkSync(join(extNM, pkg.name), dest);
linkDirSafe(join(extNM, pkg.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
+47
View File
@@ -0,0 +1,47 @@
import { symlinkSync } from 'fs';
import path from 'path';
/**
* Normalize a filesystem path for the current platform. On Windows, convert
* forward slashes to backslashes and apply the `\\?\` extended-length prefix
* for absolute paths so long paths are handled correctly. On POSIX, return
* the path unchanged.
*/
export function normalizeFsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
/**
* Create a directory link from `src` to `dest`.
*
* On POSIX uses a regular symlink. On Windows prefers a junction (which does
* not require Developer Mode or administrator privileges) and falls back to
* a regular symlink only if the junction attempt fails.
*
* Throws on POSIX when symlink creation fails. On Windows, both attempts
* failing will throw the symlink error — callers guard with try/catch when
* link creation is non-fatal (e.g. optional extension dependency linking).
*/
export function linkDirSafe(src: string, dest: string): void {
const isWin = process.platform === 'win32';
const srcP = normalizeFsPath(src);
const destP = normalizeFsPath(dest);
if (!isWin) {
symlinkSync(srcP, destP, 'dir');
return;
}
try {
symlinkSync(srcP, destP, 'junction');
} catch {
// Junction failed (e.g. cross-volume). Try a symlink as a last resort.
symlinkSync(srcP, destP, 'dir');
}
}
+6 -1
View File
@@ -155,7 +155,12 @@ export class GatewayManager extends EventEmitter {
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
public static readonly RESTART_COOLDOWN_MS = 5_000;
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
// Fallback for the server-side gateway.ready event: if the event doesn't
// arrive within this window after the WS handshake completes, we assume the
// gateway is effectively ready so downstream consumers don't block forever.
// Kept short (5s) because handshake completion already implies a working
// RPC channel — this is only a safety net, not the primary signal.
private static readonly GATEWAY_READY_FALLBACK_MS = 5_000;
private lastRestartAt = 0;
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
private isAutoReconnectStart = false;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.3.11",
"version": "0.3.12-alpha.0",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
+5 -1
View File
@@ -98,7 +98,11 @@ function maybeLoadSessions(
force = false,
): void {
const { status } = useGatewayStore.getState();
if (status.gatewayReady === false) return;
// Gate on the RPC channel being live (handshake complete), not the later
// gateway.ready event. The ready event can lag up to GATEWAY_READY_FALLBACK_MS
// behind handshake completion while plugins finish booting, and sessions.list
// does not require plugins to be up.
if (status.state !== 'running') return;
const now = Date.now();
if (!force && now - lastLoadSessionsAt < LOAD_SESSIONS_MIN_INTERVAL_MS) return;
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { symlinkSyncMock } = vi.hoisted(() => ({ symlinkSyncMock: vi.fn() }));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
const mocked = {
...actual,
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
};
return { ...mocked, default: mocked };
});
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const mocked = {
...actual,
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
};
return { ...mocked, default: mocked };
});
describe('fs-link', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
beforeEach(() => {
symlinkSyncMock.mockReset();
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
describe('linkDirSafe', () => {
it('creates a plain dir symlink on POSIX and passes paths through unchanged', async () => {
setPlatform('darwin');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
linkDirSafe('/src/a', '/dest/a');
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
expect(symlinkSyncMock).toHaveBeenCalledWith('/src/a', '/dest/a', 'dir');
});
it('prefers junction on Windows and normalizes paths with the extended prefix', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
linkDirSafe('C:/foo/bar', 'C:/baz/qux');
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
expect(symlinkSyncMock).toHaveBeenCalledWith(
'\\\\?\\C:\\foo\\bar',
'\\\\?\\C:\\baz\\qux',
'junction',
);
});
it('falls back to symlink when junction creation throws on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
symlinkSyncMock.mockImplementationOnce(() => {
throw new Error('EXDEV: cross-volume junction not supported');
});
linkDirSafe('C:/foo', 'D:/bar');
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
const [firstCall, secondCall] = symlinkSyncMock.mock.calls;
expect(firstCall[2]).toBe('junction');
expect(secondCall[2]).toBe('dir');
});
it('rethrows when both junction and symlink fail on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
symlinkSyncMock.mockImplementation(() => {
throw new Error('EPERM');
});
expect(() => linkDirSafe('C:/foo', 'C:/bar')).toThrow('EPERM');
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
});
});
describe('normalizeFsPath', () => {
it('passes POSIX paths through unchanged', async () => {
setPlatform('linux');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('/a/b/c')).toBe('/a/b/c');
expect(normalizeFsPath('')).toBe('');
});
it('adds the \\\\?\\ prefix on Windows for absolute drive paths', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('C:/a/b')).toBe('\\\\?\\C:\\a\\b');
});
it('adds the UNC extended prefix for UNC paths on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('//server/share/file')).toBe('\\\\?\\UNC\\server\\share\\file');
});
it('does not double-prefix already-normalized Windows paths', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
const already = '\\\\?\\C:\\x\\y';
expect(normalizeFsPath(already)).toBe(already);
});
it('leaves relative Windows paths un-prefixed', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('a/b')).toBe('a\\b');
});
});
});
+6 -6
View File
@@ -90,10 +90,10 @@ describe('GatewayManager gatewayReady fallback', () => {
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
// Before timeout, no gatewayReady update
vi.advanceTimersByTime(29_000);
vi.advanceTimersByTime(4_000);
expect(statusUpdates.find((u) => u.gatewayReady === true)).toBeUndefined();
// After 30s fallback timeout
// After fallback timeout (5s)
vi.advanceTimersByTime(2_000);
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
expect(readyUpdate).toBeDefined();
@@ -115,13 +115,13 @@ describe('GatewayManager gatewayReady fallback', () => {
// Schedule fallback
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
// gateway:ready event arrives at 5s
vi.advanceTimersByTime(5_000);
// gateway:ready event arrives before fallback (at 1s, well under 5s)
vi.advanceTimersByTime(1_000);
manager.emit('gateway:ready', {});
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
// After 30s, no duplicate gatewayReady=true
vi.advanceTimersByTime(30_000);
// Well past the fallback window, no duplicate gatewayReady=true
vi.advanceTimersByTime(10_000);
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
});
});