Skip to content

Commit 990fd2b

Browse files
authored
Merge pull request #195 from redxzeta/sync/selective-upstream-20260907
Backport Synara reliability fixes while preserving Forkara
2 parents 1c50b5f + dd8ea66 commit 990fd2b

219 files changed

Lines changed: 14308 additions & 1305 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.mise.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[tools]
22
node = "24.13.1"
3-
bun = "1.3.12"
3+
bun = "1.4.2"

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ and usage limits remain with that provider.
7676
See the [development guide](docs/development.md) for supported tool versions, repository structure,
7777
focused tests, and the pull-request base branch.
7878

79+
### Type checking
80+
81+
`bun run typecheck` uses TypeScript 7 with the native Effect checker for the TypeScript workspaces. The Astro marketing workspace retains `astro check` to validate its `.astro` files. Installation patches the native checker; the root check also reapplies the patch before running. TypeScript 5 remains installed for build tools and `bun run typecheck:legacy`. Native and legacy checks use separate caches.
82+
83+
The native Effect checker does not currently enforce every legacy diagnostic, including `importFromBarrel`. `bun run architecture:check` remains mandatory; use the legacy check when investigating diagnostic differences. `bun run typecheck:native` is an alias for the default check.
84+
7985
## Documentation
8086

8187
- [Quickstart](docs/quickstart.md)

apps/desktop/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
"dev:electron": "bun run scripts/dev-electron.mjs",
1010
"build": "tsdown",
1111
"start": "bun run scripts/start-electron.mjs",
12-
"typecheck": "tsc --noEmit",
12+
"typecheck": "node ../../node_modules/@typescript/native/bin/tsc --noEmit --tsBuildInfoFile node_modules/.cache/typescript-native.tsbuildinfo",
13+
"typecheck:native": "bun run typecheck",
1314
"test": "vitest run --passWithNoTests",
14-
"smoke-test": "node scripts/smoke-test.mjs"
15+
"smoke-test": "node scripts/smoke-test.mjs",
16+
"typecheck:legacy": "tsc --noEmit"
1517
},
1618
"dependencies": {
1719
"effect": "catalog:",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// FORKARA_PERF=1 FORKARA_PERF_OUT=/tmp/diagnostics.json bun run --cwd apps/desktop test perf/browserDiagnostics.perf.test.ts
2+
import { createHash } from "node:crypto";
3+
import { EventEmitter } from "node:events";
4+
import { writeFileSync } from "node:fs";
5+
import type { WebContents } from "electron";
6+
import { ThreadId } from "@forkara/contracts";
7+
import { expect, it, vi } from "vitest";
8+
import { BrowserDiagnosticsStore } from "../src/browserAutomation/browserDiagnostics";
9+
10+
it.skipIf(process.env.FORKARA_PERF !== "1")(
11+
"measures bounded browser-log reads",
12+
async () => {
13+
vi.useFakeTimers({ toFake: ["Date"] });
14+
vi.setSystemTime(new Date("2026-09-06T00:00:00Z"));
15+
const report = [];
16+
try {
17+
for (const urlLength of [100, 8_000]) {
18+
const events = new EventEmitter();
19+
const runtime = {
20+
threadId: ThreadId.makeUnsafe("perf"),
21+
tabId: "025aa711-edf6-4c63-b957-d7c96a3fdabb",
22+
webContents: {
23+
isDestroyed: () => false,
24+
once: () => {},
25+
debugger: {
26+
isAttached: () => true,
27+
sendCommand: async () => ({}),
28+
on: events.on.bind(events),
29+
removeListener: events.removeListener.bind(events),
30+
},
31+
} as unknown as WebContents,
32+
};
33+
const store = new BrowserDiagnosticsStore();
34+
await store.observe(runtime);
35+
try {
36+
for (let index = 0; index < 200; index += 1) {
37+
events.emit("message", {}, "Network.requestWillBeSent", {
38+
requestId: `request-${index}`,
39+
request: {
40+
method: "GET",
41+
url: `https://example.test/${"x".repeat(urlLength)}?token=secret-${index}`,
42+
},
43+
});
44+
}
45+
const input = { includeConsole: false, includeNetwork: true, limit: 200 };
46+
const iterations = urlLength === 100 ? 50 : 1;
47+
const wallMs: number[] = [];
48+
let output;
49+
for (let sample = -3; sample < 11; sample += 1) {
50+
const start = performance.now();
51+
for (let iteration = 0; iteration < iterations; iteration += 1)
52+
output = await store.read(runtime, input);
53+
if (sample >= 0) wallMs.push((performance.now() - start) / iterations);
54+
}
55+
const json = JSON.stringify(output);
56+
expect(Buffer.byteLength(json)).toBeLessThanOrEqual(320 * 1_024);
57+
const ordered = wallMs.toSorted((a, b) => a - b);
58+
report.push({
59+
name: `read/200-entries-${urlLength}-char-url`,
60+
iterations,
61+
wallMs,
62+
medianMs: ordered[5],
63+
p95Ms: ordered[10],
64+
entries: output?.entries.length,
65+
bytes: Buffer.byteLength(json),
66+
outputHash: createHash("sha256").update(json).digest("hex"),
67+
});
68+
} finally {
69+
store.dispose(runtime);
70+
}
71+
}
72+
} finally {
73+
vi.useRealTimers();
74+
}
75+
writeFileSync(
76+
process.env.FORKARA_PERF_OUT ?? "/tmp/forkara-diagnostics.json",
77+
JSON.stringify({ node: process.version, warmups: 3, samples: 11, report }, null, 2),
78+
);
79+
},
80+
120_000,
81+
);

apps/desktop/src/backendProcessOutput.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Purpose: Tee piped backend output into startup detectors and the configured log destination.
33

44
export interface BackendOutputDetector {
5-
push(chunk: Buffer): void;
5+
push(chunk: Buffer, source: "stdout" | "stderr"): void;
6+
end?(source: "stdout" | "stderr"): void;
67
}
78

89
export interface CaptureBackendProcessOutputInput {
@@ -28,6 +29,7 @@ export function captureBackendProcessOutput(
2829
const attachStream = (
2930
stream: NodeJS.ReadableStream | null | undefined,
3031
writeFallback: (chunk: Buffer) => void,
32+
source: "stdout" | "stderr",
3133
): Promise<void> => {
3234
if (!stream) return Promise.resolve();
3335

@@ -39,7 +41,7 @@ export function captureBackendProcessOutput(
3941
writeFallback(buffer);
4042
}
4143
for (const detector of input.detectors) {
42-
detector.push(buffer);
44+
detector.push(buffer, source);
4345
}
4446
});
4547

@@ -48,6 +50,9 @@ export function captureBackendProcessOutput(
4850
const resolveOnce = () => {
4951
if (resolved) return;
5052
resolved = true;
53+
for (const detector of input.detectors) {
54+
detector.end?.(source);
55+
}
5156
resolve();
5257
};
5358
stream.once("end", resolveOnce);
@@ -58,8 +63,8 @@ export function captureBackendProcessOutput(
5863
});
5964
};
6065

61-
const stdoutDrained = attachStream(input.stdout, input.writeStdout);
62-
const stderrDrained = attachStream(input.stderr, input.writeStderr);
66+
const stdoutDrained = attachStream(input.stdout, input.writeStdout, "stdout");
67+
const stderrDrained = attachStream(input.stderr, input.writeStderr, "stderr");
6368
return {
6469
drained: Promise.all([stdoutDrained, stderrDrained]).then(() => undefined),
6570
};

apps/desktop/src/backendStartupBlock.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
11
import { describe, expect, it } from "vitest";
2+
import {
3+
createMigrationDivergenceConsentToken,
4+
serializeMigrationDivergenceConsentChallenge,
5+
serializeMigrationSchemaTooNewStartupBlock,
6+
type MigrationDivergenceConsentChallenge,
7+
type MigrationSchemaTooNewStartupBlock,
8+
} from "@forkara/shared/migrationRecovery";
29

310
import { BackendStartupBlockDetector } from "./backendStartupBlock";
411

12+
const divergenceChallengeWithoutToken: Omit<MigrationDivergenceConsentChallenge, "consentToken"> = {
13+
version: 1,
14+
databasePath: "/data/state.sqlite",
15+
backupDirectory: "/data/state.sqlite.backups",
16+
sourceVersion: "imported-v90-from90",
17+
targetVersion: 96,
18+
firstDivergedId: 90,
19+
expectedName: "ProjectionThreadMessageTextSegments",
20+
recordedName: "AuthSessionRenewalPolicy",
21+
highWaterMark: 90,
22+
lineageFingerprint: "a".repeat(64),
23+
};
24+
const divergenceChallenge: MigrationDivergenceConsentChallenge = {
25+
...divergenceChallengeWithoutToken,
26+
consentToken: createMigrationDivergenceConsentToken(divergenceChallengeWithoutToken),
27+
};
28+
29+
const schemaTooNewBlock: MigrationSchemaTooNewStartupBlock = {
30+
version: 1,
31+
databasePath: "/data/state.sqlite",
32+
databaseMigrationId: 97,
33+
latestSupportedMigrationId: 96,
34+
recovery: {
35+
kind: "restore-available",
36+
backupPath: "/data/state.sqlite.backups/exact.sqlite",
37+
provenancePath: "/data/state.sqlite.migration-backup.json",
38+
backupMigrationId: 90,
39+
},
40+
};
41+
542
describe("BackendStartupBlockDetector", () => {
643
it("recognizes a live database owner across output chunks", () => {
744
const detector = new BackendStartupBlockDetector();
@@ -30,6 +67,133 @@ describe("BackendStartupBlockDetector", () => {
3067
expect(detector.read()).toEqual({ kind: "migration-recovery-required" });
3168
});
3269

70+
it("extracts a divergence consent challenge across output chunks", () => {
71+
const detector = new BackendStartupBlockDetector();
72+
const serialized = serializeMigrationDivergenceConsentChallenge(divergenceChallenge);
73+
74+
detector.push(`MigrationDivergenceConsentRequiredError: blocked\n${serialized.slice(0, 80)}`);
75+
detector.push(`${serialized.slice(80)}\n at migrate`);
76+
77+
expect(detector.read()).toEqual({
78+
kind: "migration-divergence-consent-required",
79+
challenge: divergenceChallenge,
80+
});
81+
});
82+
83+
it("selects the final authoritative challenge from drained output", () => {
84+
const detector = new BackendStartupBlockDetector();
85+
const injectedWithoutToken = {
86+
...divergenceChallengeWithoutToken,
87+
databasePath: "/injected/state.sqlite",
88+
backupDirectory: "/injected/state.sqlite.backups",
89+
recordedName: "InjectedMigration",
90+
};
91+
const injected = {
92+
...injectedWithoutToken,
93+
consentToken: createMigrationDivergenceConsentToken(injectedWithoutToken),
94+
};
95+
96+
detector.push(`${serializeMigrationDivergenceConsentChallenge(injected)}\n`);
97+
detector.push(`${serializeMigrationDivergenceConsentChallenge(divergenceChallenge)}\n`);
98+
99+
expect(detector.read()).toEqual({
100+
kind: "migration-divergence-consent-required",
101+
challenge: divergenceChallenge,
102+
});
103+
});
104+
105+
it("decodes split UTF-8 independently for stdout and stderr", () => {
106+
const detector = new BackendStartupBlockDetector();
107+
const challengeWithoutToken = {
108+
...divergenceChallengeWithoutToken,
109+
recordedName: "Migrazione cafè",
110+
};
111+
const challenge = {
112+
...challengeWithoutToken,
113+
consentToken: createMigrationDivergenceConsentToken(challengeWithoutToken),
114+
};
115+
const bytes = Buffer.from(`${serializeMigrationDivergenceConsentChallenge(challenge)}\n`);
116+
const splitAt = bytes.indexOf(Buffer.from("è")) + 1;
117+
118+
detector.push(Buffer.from("🙂").subarray(0, 1), "stdout");
119+
detector.push(bytes.subarray(0, splitAt), "stderr");
120+
detector.push(bytes.subarray(splitAt), "stderr");
121+
detector.end("stderr");
122+
123+
expect(detector.read()).toEqual({
124+
kind: "migration-divergence-consent-required",
125+
challenge,
126+
});
127+
});
128+
129+
it("preserves a consent challenge larger than the general output buffer", () => {
130+
const detector = new BackendStartupBlockDetector();
131+
const challengeWithoutToken = {
132+
...divergenceChallengeWithoutToken,
133+
recordedName:
134+
`prefix-${serializeMigrationDivergenceConsentChallenge(divergenceChallenge)}-` +
135+
"x".repeat(20_000),
136+
};
137+
const challenge = {
138+
...challengeWithoutToken,
139+
consentToken: createMigrationDivergenceConsentToken(challengeWithoutToken),
140+
};
141+
const serialized = serializeMigrationDivergenceConsentChallenge(challenge);
142+
143+
detector.push(`MigrationDivergenceConsentRequiredError: blocked\n${serialized.slice(0, 100)}`);
144+
detector.push(serialized.slice(100));
145+
146+
expect(detector.read()).toEqual({
147+
kind: "migration-divergence-consent-required",
148+
challenge,
149+
});
150+
});
151+
152+
it("recognizes a migration bundle identity mismatch", () => {
153+
const detector = new BackendStartupBlockDetector();
154+
155+
detector.push(
156+
"MigrationRuntimeIdentityMismatchError: desktop and server bundles were built from different migration sources\n",
157+
);
158+
159+
expect(detector.read()).toEqual({ kind: "migration-runtime-identity-mismatch" });
160+
});
161+
162+
it("extracts schema-too-new recovery before crash supervision retries", () => {
163+
const detector = new BackendStartupBlockDetector();
164+
const serialized = serializeMigrationSchemaTooNewStartupBlock(schemaTooNewBlock);
165+
166+
detector.push(`MigrationSchemaTooNewError: blocked\n${serialized.slice(0, 70)}`);
167+
detector.push(`${serialized.slice(70)}\n at migrate`);
168+
169+
expect(detector.read()).toEqual({ kind: "migration-schema-too-new", block: schemaTooNewBlock });
170+
});
171+
172+
it("fails closed instead of retrying a malformed structured block", () => {
173+
const detector = new BackendStartupBlockDetector();
174+
175+
detector.push("FORKARA_MIGRATION_SCHEMA_TOO_NEW={not-json}\n");
176+
177+
expect(detector.read()).toEqual({ kind: "migration-startup-block-invalid" });
178+
});
179+
180+
it("rejects a valid structured block before parsing when it exceeds the safety cap", () => {
181+
const detector = new BackendStartupBlockDetector();
182+
const oversizedBlock: MigrationSchemaTooNewStartupBlock = {
183+
...schemaTooNewBlock,
184+
recovery: {
185+
kind: "restore-available",
186+
backupPath: `/data/${"x".repeat(1_050_000)}.sqlite`,
187+
provenancePath: "/data/state.sqlite.migration-backup.json",
188+
backupMigrationId: 90,
189+
},
190+
};
191+
192+
detector.push(serializeMigrationSchemaTooNewStartupBlock(oversizedBlock));
193+
194+
expect(detector.read()).toEqual({ kind: "migration-startup-block-invalid" });
195+
});
196+
33197
it("ignores unrelated startup failures", () => {
34198
const detector = new BackendStartupBlockDetector();
35199

0 commit comments

Comments
 (0)