Skip to content

Commit df190a8

Browse files
authored
Fix brain CRDT sync packaging + surface remote runtime integrations (#542)
1 parent 8c7e79a commit df190a8

14 files changed

Lines changed: 547 additions & 84 deletions

File tree

apps/ade-cli/scripts/package-native-deps.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,50 @@ async function writeManifest(bundleRoot, target, packages) {
207207
await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
208208
}
209209

210+
// Targets shipped as the production brain, where cr-sqlite is mandatory. A
211+
// missing extension for one of these would silently re-ship the exact
212+
// crsql_internal_sync_bit crash this packaging step exists to prevent, so it's
213+
// a hard build failure rather than a warning. Other targets (not yet vendored)
214+
// warn-and-skip until their extension is added.
215+
const CRSQLITE_REQUIRED_TARGETS = new Set(["darwin-arm64"]);
216+
217+
function crsqliteExtensionFileName(target) {
218+
const { platform } = targetParts(target);
219+
if (platform === "darwin") return "crsqlite.dylib";
220+
if (platform === "linux") return "crsqlite.so";
221+
throw new Error(`No cr-sqlite extension filename mapping for platform '${platform}' (target ${target}).`);
222+
}
223+
224+
async function copyCrsqliteExtension(bundleRoot, target) {
225+
// The brain (this runtime) is the default sync host and needs cr-sqlite to
226+
// run CRDT replication to paired peers. The desktop app bundle ships it, but
227+
// the static/headless install only gets this native tarball — so without
228+
// copying it here the installed brain has no CRR engine and every write to a
229+
// CRR table crashes on crsql_internal_sync_bit. Mirror the desktop vendor
230+
// layout (vendor/crsqlite/<target>/) so crsqliteExtension.ts resolves it from
231+
// <ADE_HOME>/runtime/<target>/.
232+
const fileName = crsqliteExtensionFileName(target);
233+
const source = path.join(packageRoot, "..", "desktop", "vendor", "crsqlite", target, fileName);
234+
if (!(await exists(source))) {
235+
if (CRSQLITE_REQUIRED_TARGETS.has(target)) {
236+
throw new Error(
237+
`[package-native-deps] no cr-sqlite extension vendored for required target ${target} (${source}); ` +
238+
`the installed brain would crash on every CRR write. Add crsqlite for this target under ` +
239+
`apps/desktop/vendor/crsqlite/${target}/.`,
240+
);
241+
}
242+
process.stderr.write(
243+
`[package-native-deps] WARNING: no cr-sqlite extension vendored for ${target} ` +
244+
`(${source}); the installed brain on this target will lack CRDT sync.\n`,
245+
);
246+
return false;
247+
}
248+
const destination = path.join(bundleRoot, "vendor", "crsqlite", target, fileName);
249+
await fs.mkdir(path.dirname(destination), { recursive: true });
250+
await fs.copyFile(source, destination);
251+
return true;
252+
}
253+
210254
async function copyBuiltTuiClient(bundleRoot) {
211255
const source = path.join(packageRoot, "dist", "tuiClient", "cli.mjs");
212256
if (!(await exists(source))) {
@@ -271,6 +315,7 @@ async function main() {
271315
}
272316
}
273317
await copyBuiltTuiClient(bundleRoot);
318+
await copyCrsqliteExtension(bundleRoot, args.target);
274319
await chmodRuntimeExecutables(bundleRoot, args.target);
275320
await writeManifest(bundleRoot, args.target, copied);
276321

apps/desktop/src/main/main.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,7 +1535,12 @@ app.whenReady().then(async () => {
15351535
if (!activeProjectRoot || !rootsBoundToWindows().has(activeProjectRoot)) {
15361536
setForegroundProject(firstOpenWindowProjectRoot());
15371537
}
1538-
emitProjectChangedToWindow(windowId, null);
1538+
// Do NOT emit a standalone projectChanged(null) before the remote binding.
1539+
// It would reach the renderer as a separate IPC message and momentarily put
1540+
// the window into project==null && !remoteBinding && showWelcome==true,
1541+
// which used to wipe the open-tab lists (see TopBar). The binding-changed
1542+
// event below fully drives the remote view (AppShell.applyProjectState) and
1543+
// sets the remote window title itself, so the null precursor is redundant.
15391544
emitProjectBindingChangedToWindow(windowId, binding);
15401545
};
15411546

@@ -5933,7 +5938,9 @@ app.whenReady().then(async () => {
59335938
await switchProjectFromDialog(args.projectRoot!);
59345939
});
59355940
} else if (restoredRemoteBinding) {
5936-
emitProjectChangedToWindow(win.id, null);
5941+
// Binding-changed alone drives the remote view and title; skip the
5942+
// standalone projectChanged(null) precursor so the renderer never sees a
5943+
// transient "no project" state that would clear restored tabs.
59375944
emitProjectBindingChangedToWindow(win.id, restoredRemoteBinding);
59385945
} else {
59395946
emitProjectChangedToWindow(win.id, null);

apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.test.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,26 +475,38 @@ describe("RemoteConnectionService", () => {
475475
expect(pool.connect).toHaveBeenCalledTimes(10);
476476
});
477477

478-
it("does not spend the reconnect budget on ordinary remote action errors", async () => {
478+
it("keeps the connection healthy and spends no budget on ordinary remote action errors", async () => {
479479
const previouslyConnected = target("previously-connected", 1_700_000_000);
480480
const registry = {
481481
list: vi.fn(() => [previouslyConnected]),
482482
get: vi.fn((id: string) =>
483483
id === previouslyConnected.id ? previouslyConnected : null,
484484
),
485485
} as unknown as RemoteTargetRegistry;
486+
let succeed = true;
486487
const pool = {
487488
connect: vi.fn(async (target: RemoteRuntimeTarget) =>
488489
connectResult(target),
489490
),
490491
disconnect: vi.fn(),
491492
callActionForTarget: vi.fn(async () => {
493+
if (succeed) {
494+
return { domain: "file", action: "read", result: { ok: true }, statusHints: {} };
495+
}
492496
throw new Error("Action 'pr.listQueueStates' is not callable.");
493497
}),
494498
onEntryEvicted: vi.fn(() => () => {}),
495499
} as unknown as RemoteConnectionPool;
496500

497501
const service = new RemoteConnectionService(registry, pool);
502+
// Establish a healthy connection via a successful call.
503+
await service.callAction(previouslyConnected.id, "project-1", {
504+
domain: "file",
505+
action: "read",
506+
});
507+
expect(service.snapshot().connections[0]?.state).toBe("connected");
508+
509+
succeed = false;
498510
for (let attempt = 0; attempt < 10; attempt += 1) {
499511
await expect(
500512
service.callAction(previouslyConnected.id, "project-1", {
@@ -504,15 +516,86 @@ describe("RemoteConnectionService", () => {
504516
).rejects.toThrow(/not callable/i);
505517
}
506518

519+
// An application-level error came back over a live channel — the host is
520+
// reachable, so the connection must stay connected (no "unreachable" toast,
521+
// no reconnect loop) and the auto-reconnect budget is untouched.
522+
expect(pool.callActionForTarget).toHaveBeenCalledTimes(11);
523+
expect(service.snapshot().connections[0]?.state).toBe("connected");
524+
expect(service.snapshot().connections[0]?.lastError).toBeNull();
525+
});
526+
527+
it("does not flag the remote unreachable when adding a project fails with a host-side error", async () => {
528+
const previouslyConnected = target("previously-connected", 1_700_000_000);
529+
const registry = {
530+
list: vi.fn(() => [previouslyConnected]),
531+
get: vi.fn((id: string) =>
532+
id === previouslyConnected.id ? previouslyConnected : null,
533+
),
534+
} as unknown as RemoteTargetRegistry;
535+
const pool = {
536+
connect: vi.fn(async (target: RemoteRuntimeTarget) =>
537+
connectResult(target),
538+
),
539+
disconnect: vi.fn(),
540+
addProjectForTarget: vi.fn(async () => {
541+
throw new Error(
542+
"no such function: crsql_internal_sync_bit [sql=delete from process_definitions where project_id = ?]",
543+
);
544+
}),
545+
onEntryEvicted: vi.fn(() => () => {}),
546+
} as unknown as RemoteConnectionPool;
547+
548+
const service = new RemoteConnectionService(registry, pool);
549+
await service.connect(previouslyConnected.id, { explicit: true });
550+
expect(service.snapshot().connections[0]?.state).toBe("connected");
551+
552+
await expect(
553+
service.addProject(previouslyConnected.id, "/repo/versic"),
554+
).rejects.toThrow(/crsql_internal_sync_bit/i);
555+
556+
expect(service.snapshot().connections[0]?.state).toBe("connected");
557+
expect(service.snapshot().connections[0]?.lastError).toBeNull();
558+
});
559+
560+
it("flips to error when a remote call fails with a transport-level error", async () => {
561+
const previouslyConnected = target("previously-connected", 1_700_000_000);
562+
const registry = {
563+
list: vi.fn(() => [previouslyConnected]),
564+
get: vi.fn((id: string) =>
565+
id === previouslyConnected.id ? previouslyConnected : null,
566+
),
567+
} as unknown as RemoteTargetRegistry;
568+
let succeed = true;
569+
const pool = {
570+
connect: vi.fn(async (target: RemoteRuntimeTarget) =>
571+
connectResult(target),
572+
),
573+
disconnect: vi.fn(),
574+
callActionForTarget: vi.fn(async () => {
575+
if (succeed) {
576+
return { domain: "file", action: "read", result: { ok: true }, statusHints: {} };
577+
}
578+
throw new Error("remote ADE service connection closed");
579+
}),
580+
onEntryEvicted: vi.fn(() => () => {}),
581+
} as unknown as RemoteConnectionPool;
582+
583+
const service = new RemoteConnectionService(registry, pool);
584+
await service.callAction(previouslyConnected.id, "project-1", {
585+
domain: "file",
586+
action: "read",
587+
});
588+
589+
succeed = false;
507590
await expect(
508591
service.callAction(previouslyConnected.id, "project-1", {
509-
domain: "pr",
510-
action: "listQueueStates",
592+
domain: "file",
593+
action: "read",
511594
}),
512-
).rejects.toThrow(/not callable/i);
513-
expect(pool.callActionForTarget).toHaveBeenCalledTimes(11);
595+
).rejects.toThrow(/connection closed/i);
596+
expect(service.snapshot().connections[0]?.state).toBe("error");
514597
expect(service.snapshot().connections[0]?.lastError).toMatch(
515-
/not callable/i,
598+
/connection closed/i,
516599
);
517600
});
518601
});

apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts

Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,7 @@ export class RemoteConnectionService {
329329
this.clearAutomaticReconnectBudget(targetId);
330330
return projects;
331331
} catch (error) {
332-
this.mergeStatus(targetId, {
333-
state: "error",
334-
lastError: this.recordImplicitFailure(targetId, error),
335-
lastAttemptedAt: Date.now(),
336-
});
332+
this.markCallFailure(targetId, error);
337333
throw error;
338334
}
339335
}
@@ -350,11 +346,7 @@ export class RemoteConnectionService {
350346
this.clearAutomaticReconnectBudget(targetId);
351347
return project;
352348
} catch (error) {
353-
this.mergeStatus(targetId, {
354-
state: "error",
355-
lastError: this.recordImplicitFailure(targetId, error),
356-
lastAttemptedAt: Date.now(),
357-
});
349+
this.markCallFailure(targetId, error);
358350
throw error;
359351
}
360352
}
@@ -368,11 +360,7 @@ export class RemoteConnectionService {
368360
try {
369361
return await this.pool.ensureLocalPortForward(target.id, request);
370362
} catch (error) {
371-
this.mergeStatus(targetId, {
372-
state: "error",
373-
lastError: this.recordImplicitFailure(targetId, error),
374-
lastAttemptedAt: Date.now(),
375-
});
363+
this.markCallFailure(targetId, error);
376364
throw error;
377365
}
378366
}
@@ -482,11 +470,7 @@ export class RemoteConnectionService {
482470
this.clearAutomaticReconnectBudget(targetId);
483471
return result;
484472
} catch (error) {
485-
this.mergeStatus(targetId, {
486-
state: "error",
487-
lastError: this.recordImplicitFailure(targetId, error),
488-
lastAttemptedAt: Date.now(),
489-
});
473+
this.markCallFailure(targetId, error);
490474
throw error;
491475
}
492476
}
@@ -511,11 +495,7 @@ export class RemoteConnectionService {
511495
this.clearAutomaticReconnectBudget(targetId);
512496
return result;
513497
} catch (error) {
514-
this.mergeStatus(targetId, {
515-
state: "error",
516-
lastError: this.recordImplicitFailure(targetId, error),
517-
lastAttemptedAt: Date.now(),
518-
});
498+
this.markCallFailure(targetId, error);
519499
throw error;
520500
}
521501
}
@@ -544,11 +524,7 @@ export class RemoteConnectionService {
544524
this.clearAutomaticReconnectBudget(targetId);
545525
return cleanup;
546526
} catch (error) {
547-
this.mergeStatus(targetId, {
548-
state: "error",
549-
lastError: this.recordImplicitFailure(targetId, error),
550-
lastAttemptedAt: Date.now(),
551-
});
527+
this.markCallFailure(targetId, error);
552528
throw error;
553529
}
554530
}
@@ -571,11 +547,7 @@ export class RemoteConnectionService {
571547
this.clearAutomaticReconnectBudget(targetId);
572548
return registry;
573549
} catch (error) {
574-
this.mergeStatus(targetId, {
575-
state: "error",
576-
lastError: this.recordImplicitFailure(targetId, error),
577-
lastAttemptedAt: Date.now(),
578-
});
550+
this.markCallFailure(targetId, error);
579551
throw error;
580552
}
581553
}
@@ -602,11 +574,7 @@ export class RemoteConnectionService {
602574
this.clearAutomaticReconnectBudget(targetId);
603575
return result;
604576
} catch (error) {
605-
this.mergeStatus(targetId, {
606-
state: "error",
607-
lastError: this.recordImplicitFailure(targetId, error),
608-
lastAttemptedAt: Date.now(),
609-
});
577+
this.markCallFailure(targetId, error);
610578
throw error;
611579
}
612580
}
@@ -670,11 +638,7 @@ export class RemoteConnectionService {
670638
this.clearAutomaticReconnectBudget(target.id);
671639
return result;
672640
} catch (error) {
673-
this.mergeStatus(target.id, {
674-
state: "error",
675-
lastError: this.recordImplicitFailure(target.id, error),
676-
lastAttemptedAt: Date.now(),
677-
});
641+
this.markCallFailure(target.id, error);
678642
throw error;
679643
}
680644
}
@@ -711,6 +675,25 @@ export class RemoteConnectionService {
711675
this.automaticReconnectPausedTargetIds.delete(targetId);
712676
}
713677

678+
/**
679+
* Classify a failure from an RPC call made over an already-established
680+
* connection. If the response came back over a live channel, the host is
681+
* reachable and the error is application-level (e.g. a host-side SQL or
682+
* validation failure). Such errors must NOT flip the connection to
683+
* "error"/reconnecting — doing so surfaces a false "host is unreachable"
684+
* toast and a reconnect loop for what is really a per-action failure. Only
685+
* genuine transport failures update the connection status and reconnect
686+
* budget; everything else is left to rethrow to the caller untouched.
687+
*/
688+
private markCallFailure(targetId: string, error: unknown): void {
689+
if (!isImplicitConnectionFailure(error)) return;
690+
this.mergeStatus(targetId, {
691+
state: "error",
692+
lastError: this.recordImplicitFailure(targetId, error),
693+
lastAttemptedAt: Date.now(),
694+
});
695+
}
696+
714697
private recordImplicitFailure(targetId: string, error: unknown): string {
715698
if (!isImplicitConnectionFailure(error)) return errorMessage(error);
716699
if (isConnectionBackoffThrottle(error)) return errorMessage(error);

apps/desktop/src/main/services/state/crsqliteExtension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs";
2+
import os from "node:os";
23
import path from "node:path";
34

45
function extensionFileName(): string {
@@ -52,10 +53,14 @@ export function resolveCrsqliteExtensionPath(): string | null {
5253
const moduleRepoRoot = findRepoRoot(moduleDir);
5354
if (moduleRepoRoot) repoRootSet.add(moduleRepoRoot);
5455
}
56+
const adeHome = process.env.ADE_HOME ?? path.join(os.homedir(), ".ade");
5557
const candidates = [
5658
process.resourcesPath ? path.join(process.resourcesPath, `${packagedAsarName()}.unpacked`, relativePath) : null,
5759
process.resourcesPath ? path.join(process.resourcesPath, "app.asar.unpacked", relativePath) : null,
5860
process.resourcesPath ? path.join(process.resourcesPath, relativePath) : null,
61+
// Installed static/headless brain: native tarball is extracted to
62+
// <ADE_HOME>/runtime/<target>/, carrying crsqlite at vendor/crsqlite/<arch>/.
63+
path.join(adeHome, "runtime", platformArchDir(), relativePath),
5964
path.resolve(process.cwd(), relativePath),
6065
path.resolve(process.cwd(), "apps", "desktop", relativePath),
6166
...Array.from(repoRootSet, (repoRoot) => path.join(repoRoot, "apps", "desktop", relativePath)),

0 commit comments

Comments
 (0)