diff --git a/scripts/esbuild-main-build.mjs b/scripts/esbuild-main-build.mjs index fd7eb9fa5..9757f5000 100644 --- a/scripts/esbuild-main-build.mjs +++ b/scripts/esbuild-main-build.mjs @@ -5,37 +5,89 @@ * * When MESH_CLIENT_BUILD_INFO is set (CI packaging), embeds it via esbuild define * as __MESH_CLIENT_BUILD_INFO__ for src/shared/buildInfo.ts. + * + * Uses the esbuild JS API (not a direct spawn of bin/esbuild). On Windows, postinstall leaves + * bin/esbuild as a Node shim — execFile of that path fails with no stdout (EINVAL). */ -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; +import * as esbuild from 'esbuild'; +import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -import { mainEsbuildExternalArgs } from './esbuild-main-externals.mjs'; +import { MAIN_ESBUILD_EXTERNALS } from './esbuild-main-externals.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); -const require = createRequire(import.meta.url); -const esbuildBin = require.resolve('esbuild/bin/esbuild'); - -const extraArgs = process.argv.slice(2); -const buildInfoRaw = process.env.MESH_CLIENT_BUILD_INFO ?? ''; -const defineArg = `--define:__MESH_CLIENT_BUILD_INFO__=${JSON.stringify(buildInfoRaw)}`; - -const args = [ - 'src/main/index.ts', - '--bundle', - '--platform=node', - '--outfile=dist-electron/main/index.js', - ...mainEsbuildExternalArgs(), - '--format=cjs', - defineArg, - ...extraArgs, -]; - -// shell:false so JSON quotes in --define survive on Windows runners -const result = spawnSync(esbuildBin, args, { - cwd: projectRoot, - stdio: 'inherit', -}); -process.exit(result.status ?? 1); + +/** + * @param {string[]} argv + * @returns {{ minify: boolean, metafilePath: string | null }} + */ +export function parseEsbuildMainBuildArgs(argv) { + let minify = false; + /** @type {string | null} */ + let metafilePath = null; + for (const arg of argv) { + if (arg === '--minify') { + minify = true; + continue; + } + if (arg.startsWith('--metafile=')) { + metafilePath = arg.slice('--metafile='.length); + continue; + } + throw new Error(`Unknown esbuild-main-build argument: ${arg}`); + } + return { minify, metafilePath }; +} + +/** + * @param {{ + * minify?: boolean + * metafilePath?: string | null + * buildInfoRaw?: string + * absWorkingDir?: string + * }} [opts] + */ +export async function buildMainProcess(opts = {}) { + const minify = opts.minify === true; + const metafilePath = opts.metafilePath ?? null; + const buildInfoRaw = opts.buildInfoRaw ?? process.env.MESH_CLIENT_BUILD_INFO ?? ''; + const absWorkingDir = opts.absWorkingDir ?? projectRoot; + + const result = await esbuild.build({ + absWorkingDir, + entryPoints: ['src/main/index.ts'], + bundle: true, + platform: 'node', + outfile: 'dist-electron/main/index.js', + external: [...MAIN_ESBUILD_EXTERNALS], + format: 'cjs', + define: { + __MESH_CLIENT_BUILD_INFO__: JSON.stringify(buildInfoRaw), + }, + minify, + metafile: Boolean(metafilePath), + logLevel: 'info', + }); + + if (metafilePath && result.metafile) { + const outPath = path.resolve(absWorkingDir, metafilePath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(result.metafile)); + } + + return result; +} + +async function main() { + const { minify, metafilePath } = parseEsbuildMainBuildArgs(process.argv.slice(2)); + await buildMainProcess({ minify, metafilePath }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} diff --git a/scripts/esbuild-main-build.test.mjs b/scripts/esbuild-main-build.test.mjs new file mode 100644 index 000000000..5cf9663b7 --- /dev/null +++ b/scripts/esbuild-main-build.test.mjs @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { parseEsbuildMainBuildArgs } from './esbuild-main-build.mjs'; + +describe('esbuild-main-build', () => { + it('parses minify and metafile flags', () => { + expect(parseEsbuildMainBuildArgs(['--minify'])).toEqual({ + minify: true, + metafilePath: null, + }); + expect(parseEsbuildMainBuildArgs(['--metafile=dist-electron/main/metafile.json'])).toEqual({ + minify: false, + metafilePath: 'dist-electron/main/metafile.json', + }); + expect( + parseEsbuildMainBuildArgs(['--minify', '--metafile=dist-electron/main/meta.json']), + ).toEqual({ + minify: true, + metafilePath: 'dist-electron/main/meta.json', + }); + }); + + it('rejects unknown CLI flags', () => { + expect(() => parseEsbuildMainBuildArgs(['--watch'])).toThrow(/Unknown/); + }); + + it('uses the esbuild JS API instead of spawning bin/esbuild (Windows shim)', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const src = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'esbuild-main-build.mjs'), + 'utf8', + ); + // Regression: spawnSync(require.resolve('esbuild/bin/esbuild')) fails on win32 where + // postinstall leaves bin/esbuild as a Node shim (maybeOptimizePackage skips win32). + expect(src).toContain("from 'esbuild'"); + expect(src).toMatch(/\besbuild\.build\s*\(/); + expect(src).not.toMatch(/\bspawnSync\s*\(/); + expect(src).not.toContain("require.resolve('esbuild/bin/esbuild')"); + expect(src).not.toMatch(/\bchild_process\b/); + }); +}); diff --git a/src/main/ipc/reticulum-db-handlers.test.ts b/src/main/ipc/reticulum-db-handlers.test.ts index 302b0f2f0..8307c0d77 100644 --- a/src/main/ipc/reticulum-db-handlers.test.ts +++ b/src/main/ipc/reticulum-db-handlers.test.ts @@ -300,6 +300,136 @@ describe('reticulum destination / activity prune IPC', () => { expect(row.delivery_method).toBe('paper'); }); + it('saveReticulumMessage replaces exact pending hash while still sending', () => { + const identityId = 'id-rt-pending-orphan'; + const senderId = 'cc'.repeat(16); + const payload = 'hello aibot'; + const ts = 1_700_000_000_000; + const pendingHash = 'reticulum-pending-1700000000000'; + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts, + message_hash: pendingHash, + delivery_status: 'sending', + }); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts + 182, + message_hash: 'ab'.repeat(32), + replaces_message_hash: pendingHash, + delivery_status: 'sending', + }); + const rows = db! + .prepareOnce( + 'SELECT message_hash, delivery_status FROM reticulum_messages WHERE identity_id = ? ORDER BY id', + ) + .all(identityId) as { message_hash: string; delivery_status: string }[]; + expect(rows).toEqual([{ message_hash: 'ab'.repeat(32), delivery_status: 'sending' }]); + }); + + it('saveReticulumMessage replaces only the named pending when two identical payloads exist', () => { + const identityId = 'id-rt-twin-payload'; + const senderId = 'dd'.repeat(16); + const payload = 'hello'; + const ts = 1_700_000_100_000; + const pendingA = 'reticulum-pending-a'; + const pendingB = 'reticulum-pending-b'; + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts, + message_hash: pendingA, + delivery_status: 'sending', + }); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts + 50, + message_hash: pendingB, + delivery_status: 'sending', + }); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts + 80, + message_hash: 'ee'.repeat(32), + replaces_message_hash: pendingA, + delivery_status: 'sending', + }); + const rows = db! + .prepareOnce('SELECT message_hash FROM reticulum_messages WHERE identity_id = ? ORDER BY id') + .all(identityId) as { message_hash: string }[]; + expect(rows.map((r) => r.message_hash)).toEqual([pendingB, 'ee'.repeat(32)]); + }); + + it('saveReticulumMessage rolls back pending delete when replacement insert fails', () => { + const identityId = 'id-rt-pending-rollback'; + const senderId = 'ff'.repeat(16); + const payload = 'rollback me'; + const ts = 1_700_000_200_000; + const pendingHash = 'reticulum-pending-rollback'; + const save = handlers.get('db:saveReticulumMessage'); + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts, + message_hash: pendingHash, + delivery_status: 'sending', + }); + + const prepareOnce = db!.prepareOnce.bind(db!); + const spy = vi.spyOn(db!, 'prepareOnce').mockImplementation((sql: string) => { + const stmt = prepareOnce(sql); + if (sql.includes('INSERT INTO reticulum_messages')) { + return { + run: () => { + throw new Error('insert boom'); + }, + get: stmt.get.bind(stmt), + all: stmt.all.bind(stmt), + } as unknown as ReturnType; + } + return stmt; + }); + + expect(() => + save?.(event, { + identity_id: identityId, + sender_id: senderId, + sender_name: 'Me', + payload, + timestamp: ts + 10, + message_hash: '11'.repeat(32), + replaces_message_hash: pendingHash, + delivery_status: 'sending', + }), + ).toThrow('insert boom'); + spy.mockRestore(); + + const rows = db! + .prepareOnce( + 'SELECT message_hash, delivery_status FROM reticulum_messages WHERE identity_id = ?', + ) + .all(identityId) as { message_hash: string; delivery_status: string }[]; + expect(rows).toEqual([{ message_hash: pendingHash, delivery_status: 'sending' }]); + }); + it('pruneReticulumIdentityActivityByAge deletes stale millisecond last_seen rows', () => { const nowMs = Date.now(); db! diff --git a/src/main/ipc/reticulum-db-handlers.ts b/src/main/ipc/reticulum-db-handlers.ts index 835cfc5fa..b162c1bc6 100644 --- a/src/main/ipc/reticulum-db-handlers.ts +++ b/src/main/ipc/reticulum-db-handlers.ts @@ -171,73 +171,75 @@ export function registerReticulumDbIpcHandlers({ ipcMain }: ReticulumDbIpcDeps): m.next_delivery_attempt_at != null && Number.isFinite(Number(m.next_delivery_attempt_at)) ? Math.trunc(Number(m.next_delivery_attempt_at)) : null; + const replacesMessageHash = + typeof m.replaces_message_hash === 'string' && + m.replaces_message_hash.length > 0 && + m.replaces_message_hash.length <= 128 + ? m.replaces_message_hash.slice(0, 128) + : null; - if ( - messageHash && - !messageHash.startsWith('reticulum-pending-') && - deliveryStatus && - deliveryStatus !== 'sending' - ) { - db.prepareOnce( - `DELETE FROM reticulum_messages - WHERE identity_id = ? AND sender_id = ? AND payload = ? - AND message_hash LIKE 'reticulum-pending-%' - AND ABS(timestamp - ?) <= 60000`, - ).run(identityId, senderId, payload, truncatedTimestamp); - } - - if (messageHash) { - const existing = db - .prepareOnce( - 'SELECT id FROM reticulum_messages WHERE identity_id = ? AND message_hash = ? LIMIT 1', - ) - .get(identityId, messageHash) as { id?: number } | undefined; - if (existing?.id != null) { - // Never demote a delivered Completes back to in-flight (retry/echo saves). + // Exact prior-hash delete + upsert must be atomic so a failed write rolls back cleanup. + const run = db.transaction(() => { + if (replacesMessageHash && messageHash && replacesMessageHash !== messageHash) { db.prepareOnce( - `UPDATE reticulum_messages - SET delivery_status = CASE - WHEN delivery_status = 'delivered' - AND ? IN ('sending', 'pending', 'queued') - THEN delivery_status - ELSE COALESCE(?, delivery_status) - END, - received_via = COALESCE(?, received_via), - sender_name = COALESCE(?, sender_name), - delivery_method = COALESCE(?, delivery_method) - WHERE id = ?`, - ).run( - deliveryStatus, - deliveryStatus, - receivedVia, - senderName, - deliveryMethod, - existing.id, - ); - return { changes: 1 }; + 'DELETE FROM reticulum_messages WHERE identity_id = ? AND message_hash = ?', + ).run(identityId, replacesMessageHash); } - } - db.prepareOnce( - `INSERT INTO reticulum_messages (identity_id, sender_id, sender_name, payload, timestamp, to_hash, reply_to_hash, message_hash, received_via, delivery_status, delivery_attempts, next_delivery_attempt_at, attachment_path, delivery_method) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - identityId, - senderId, - senderName, - payload, - truncatedTimestamp, - toHash, - replyToHash, - messageHash, - receivedVia, - deliveryStatus, - deliveryAttempts, - nextDeliveryAttemptAt, - attachmentPath, - deliveryMethod, - ); - return { changes: 1 }; + if (messageHash) { + const existing = db + .prepareOnce( + 'SELECT id FROM reticulum_messages WHERE identity_id = ? AND message_hash = ? LIMIT 1', + ) + .get(identityId, messageHash) as { id?: number } | undefined; + if (existing?.id != null) { + // Never demote a delivered Completes back to in-flight (retry/echo saves). + db.prepareOnce( + `UPDATE reticulum_messages + SET delivery_status = CASE + WHEN delivery_status = 'delivered' + AND ? IN ('sending', 'pending', 'queued') + THEN delivery_status + ELSE COALESCE(?, delivery_status) + END, + received_via = COALESCE(?, received_via), + sender_name = COALESCE(?, sender_name), + delivery_method = COALESCE(?, delivery_method) + WHERE id = ?`, + ).run( + deliveryStatus, + deliveryStatus, + receivedVia, + senderName, + deliveryMethod, + existing.id, + ); + return { changes: 1 }; + } + } + + db.prepareOnce( + `INSERT INTO reticulum_messages (identity_id, sender_id, sender_name, payload, timestamp, to_hash, reply_to_hash, message_hash, received_via, delivery_status, delivery_attempts, next_delivery_attempt_at, attachment_path, delivery_method) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + identityId, + senderId, + senderName, + payload, + truncatedTimestamp, + toHash, + replyToHash, + messageHash, + receivedVia, + deliveryStatus, + deliveryAttempts, + nextDeliveryAttemptAt, + attachmentPath, + deliveryMethod, + ); + return { changes: 1 }; + }); + return run(); } catch (err) { finishDbIpcHandler('db:saveReticulumMessage', err); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 29f1dd5db..2d023b68f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -170,8 +170,10 @@ contextBridge.exposeInMainWorld('electronAPI', { to_hash?: string | null; reply_to_hash?: string | null; message_hash?: string | null; + replaces_message_hash?: string | null; received_via?: string | null; delivery_status?: string | null; + delivery_method?: string | null; delivery_attempts?: number | null; next_delivery_attempt_at?: number | null; attachment_path?: string | null; diff --git a/src/renderer/lib/ingest/reticulumIngest.ts b/src/renderer/lib/ingest/reticulumIngest.ts index 36a284b1e..5900f1032 100644 --- a/src/renderer/lib/ingest/reticulumIngest.ts +++ b/src/renderer/lib/ingest/reticulumIngest.ts @@ -204,6 +204,7 @@ export async function persistReticulumMessageToDb( identityId: IdentityId, p: ReticulumLxmfPayload, attachmentPath?: string | null, + replacesMessageHash?: string | null, ): Promise { if (!p.text || !p.sender_hash) return; const timestamp = p.timestamp ?? Date.now(); @@ -217,6 +218,7 @@ export async function persistReticulumMessageToDb( to_hash: p.to_hash ?? null, reply_to_hash: p.reply_to_hash ?? p.reaction_target ?? null, message_hash: p.message_hash ?? computeReticulumMessageHash(p.sender_hash, timestamp, p.text), + ...(replacesMessageHash ? { replaces_message_hash: replacesMessageHash } : {}), received_via: resolvePayloadTransport(p) ?? null, delivery_status: resolvePersistedDeliveryStatus(p), delivery_method: parseReticulumDeliveryMethod(p.delivery_method) ?? null, @@ -339,7 +341,7 @@ export function ingestReticulumLxmfPayloadWithSideEffects( void persistReticulumIconFromPayload(p); const ingested = ingestReticulumLxmfPayload(identityId, p, ctx); if (!ingested) return false; - void persistReticulumMessageToDb(identityId, p, ctx.attachmentPath); + void persistReticulumMessageToDb(identityId, p, ctx.attachmentPath, ctx.replacesMessageHash); // History stamp only — Contacts require explicit Save as contact. void persistReticulumHistoryFromPayload(p, identityId); return true; @@ -377,6 +379,7 @@ export function persistReticulumOutboundRecord( senderName: string, toHash: string | null, status: MessageStatus, + replacesMessageHash?: string | null, ): void { const deliveryStatus = mapMessageStatusToDeliveryStatus(status); void window.electronAPI.db @@ -389,6 +392,7 @@ export function persistReticulumOutboundRecord( to_hash: toHash, reply_to_hash: record.reticulumReplyToHash ?? null, message_hash: record.reticulumMessageHash ?? record.id, + ...(replacesMessageHash ? { replaces_message_hash: replacesMessageHash } : {}), received_via: record.receivedVia ?? null, delivery_status: deliveryStatus, ...(record.reticulumDeliveryMethod diff --git a/src/renderer/lib/reticulum/reticulumIngestMerge.ts b/src/renderer/lib/reticulum/reticulumIngestMerge.ts index 6b2ff0a36..a24468a0c 100644 --- a/src/renderer/lib/reticulum/reticulumIngestMerge.ts +++ b/src/renderer/lib/reticulum/reticulumIngestMerge.ts @@ -5,6 +5,8 @@ import { normalizeReticulumNodeId, reticulumHashToNodeId } from './destHash'; export interface ReticulumIngestMergeContext { selfLxmfHash?: string | null; attachmentPath?: string | null; + /** Exact SQLite message_hash to replace when persisting this ingest (pending or prior hash). */ + replacesMessageHash?: string | null; } interface LxmfDirectionPayload { diff --git a/src/renderer/lib/reticulum/reticulumOutboundRetry.test.ts b/src/renderer/lib/reticulum/reticulumOutboundRetry.test.ts index b58689e57..1a256d0a5 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundRetry.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundRetry.test.ts @@ -7,9 +7,9 @@ describe('shouldDeletePriorReticulumOutboundHash', () => { expect(shouldDeletePriorReticulumOutboundHash('aa'.repeat(32), 'bb'.repeat(32))).toBe(true); }); - it('keeps optimistic pending rows (no prior SQLite hash)', () => { + it('deletes optimistic pending rows when rekeyed to a real hash', () => { expect(shouldDeletePriorReticulumOutboundHash('reticulum-pending-1', 'bb'.repeat(32))).toBe( - false, + true, ); }); @@ -17,4 +17,9 @@ describe('shouldDeletePriorReticulumOutboundHash', () => { const hash = 'cc'.repeat(32); expect(shouldDeletePriorReticulumOutboundHash(hash, hash)).toBe(false); }); + + it('skips empty ids', () => { + expect(shouldDeletePriorReticulumOutboundHash('', 'bb'.repeat(32))).toBe(false); + expect(shouldDeletePriorReticulumOutboundHash('reticulum-pending-1', '')).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumOutboundRetry.ts b/src/renderer/lib/reticulum/reticulumOutboundRetry.ts index 5cf74a085..5227f7ef3 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundRetry.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundRetry.ts @@ -1,11 +1,12 @@ /** - * After a manual LXMF resend succeeds, the store rekeys `pendingId` → `newHash`. - * Delete the prior SQLite row when `pendingId` was a real LXMF hash (not an optimistic - * `reticulum-pending-*` id), so failed+retried messages do not leave duplicate DB rows. + * After LXMF send (or resend) rekeys `pendingId` → `newHash`, delete the prior SQLite row. + * Covers: + * - optimistic `reticulum-pending-*` rows (otherwise orphan while still `sending`) + * - prior real LXMF hashes after a successful retry */ export function shouldDeletePriorReticulumOutboundHash( pendingId: string, newHash: string, ): boolean { - return pendingId !== newHash && !pendingId.startsWith('reticulum-pending-'); + return pendingId !== newHash && pendingId.length > 0 && newHash.length > 0; } diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 2331308f2..27ccaac41 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -1932,16 +1932,13 @@ export function useReticulumRuntime(): ProtocolRuntime { }; if (pendingId && hash) { renameMessageId(identityId, pendingId, hash); - if (shouldDeletePriorReticulumOutboundHash(pendingId, hash)) { - void window.electronAPI.db - .deleteReticulumMessage(identityId, pendingId) - .catch((e: unknown) => { - console.warn( - '[useReticulumRuntime] deleteReticulumMessage failed ' + errLikeToLogString(e), - ); - }); - } - ingestOutboundSend(); + const replacesMessageHash = shouldDeletePriorReticulumOutboundHash(pendingId, hash) + ? pendingId + : undefined; + ingestReticulumLxmfPayloadWithSideEffects(identityId, lxmfPayload, { + selfLxmfHash: selfLxmfHash ?? undefined, + replacesMessageHash, + }); // Terminal WS may have arrived before rename; apply buffered Completes/Fails. flushPendingReticulumOutboundDeliveryStatus(identityId, hash); const afterFlush = useMessageStore.getState().messages[identityId]?.[hash]?.status; diff --git a/src/shared/buildInfo.ts b/src/shared/buildInfo.ts index 36e8fa45c..5ff5f922c 100644 --- a/src/shared/buildInfo.ts +++ b/src/shared/buildInfo.ts @@ -1,7 +1,7 @@ /** * Compile-time CI build stamp for packaged binaries. * - * Set at main-process esbuild time via `--define:__MESH_CLIENT_BUILD_INFO__=...` + * Set at main-process esbuild time via define `__MESH_CLIENT_BUILD_INFO__` * from env `MESH_CLIENT_BUILD_INFO` (see scripts/esbuild-main-build.mjs and * scripts/ci-write-build-info-env.mjs). Empty / unset → local unmarked build. */ diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 8dca97fa5..2311348c3 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -395,6 +395,8 @@ export interface ElectronAPI { to_hash?: string | null; reply_to_hash?: string | null; message_hash?: string | null; + /** Exact prior row to replace (optimistic pending or failed hash) in the same transaction. */ + replaces_message_hash?: string | null; received_via?: string | null; delivery_status?: string | null; delivery_method?: string | null;