Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { utilityProcess, type UtilityProcess } from 'electron'
|
|
import type { ReadonlyQueryRequest, QueryTransport } from './readonly-query-reader'
|
|
import type { StorageRequest, StorageResponse } from './desktop-storage-contracts'
|
|
|
|
const EXIT_TIMEOUT_MS = 10_000
|
|
|
|
/** Adapts Electron messages to the existing query reader, without another request queue. */
|
|
export class DesktopStorageTransport extends EventEmitter implements QueryTransport {
|
|
private readonly child: UtilityProcess
|
|
private readonly exit: Promise<number>
|
|
private exitCode?: number
|
|
private closeAcknowledged = false
|
|
private closing?: Promise<number>
|
|
private failed = false
|
|
|
|
constructor(modulePath: string) {
|
|
super()
|
|
this.child = utilityProcess.fork(modulePath, [], {
|
|
serviceName: 'GoodBuddy Desktop Storage', stdio: 'ignore', allowLoadingUnsignedLibraries: false
|
|
})
|
|
this.exit = new Promise(resolve => this.child.once('exit', code => {
|
|
this.exitCode = code
|
|
resolve(code)
|
|
this.emit('exit', code)
|
|
}))
|
|
this.child.on('error', () => this.fail(new Error('Desktop storage process failed')))
|
|
this.child.on('spawn', () => this.emit('spawn'))
|
|
this.child.on('message', (message: StorageResponse) => {
|
|
if (message.type === 'closed') this.closeAcknowledged = true
|
|
if (message.type === 'failed' && message.error.code === 'STORAGE_CLOSE_FAILED') this.failed = true
|
|
if (message.type === 'result') this.emit('message', { id: message.id, result: message.result })
|
|
else if (message.type === 'error') this.emit('message', { id: message.id, error: message.error })
|
|
else this.emit('storageMessage', message)
|
|
})
|
|
}
|
|
|
|
private fail(error: unknown): void {
|
|
if (this.failed || this.exitCode !== undefined) return
|
|
this.failed = true
|
|
try { this.emit('storageFailure', error) } finally { this.child.kill() }
|
|
}
|
|
|
|
send(message: StorageRequest): void {
|
|
if (this.exitCode !== undefined) throw new Error('Desktop storage process has exited; unconfirmed writes must not be replayed automatically')
|
|
this.child.postMessage(message)
|
|
}
|
|
|
|
postMessage(message: ReadonlyQueryRequest | { type: 'cancel'; id: number }): void {
|
|
if (message.type === 'cancel') {
|
|
if (this.exitCode === undefined) this.child.postMessage(message)
|
|
return
|
|
}
|
|
const separator = message.op.indexOf('.')
|
|
// Throws synchronously (for example DataCloneError); the reader rejects that call.
|
|
this.send({ type: 'call', id: message.id,
|
|
domain: message.op.slice(0, separator) as Extract<StorageRequest, { type: 'call' }>['domain'],
|
|
method: message.op.slice(separator + 1), args: message.args })
|
|
}
|
|
|
|
get needsReplacement(): boolean { return this.failed || this.exitCode !== undefined }
|
|
|
|
waitForExit(): Promise<number> {
|
|
return withTimeout(this.exit, 'Desktop storage exit was not confirmed; replacement was not started')
|
|
}
|
|
|
|
ref(): void { /* Utility process lifetime is owned by application shutdown. */ }
|
|
unref(): void { /* No worker-thread ref counting is needed for utilityProcess. */ }
|
|
|
|
terminate(): Promise<number> {
|
|
this.closing ??= withTimeout((async () => {
|
|
if (this.exitCode === undefined) this.send({ type: 'close' })
|
|
return this.exit
|
|
})(), 'Desktop storage exit was not confirmed').then(code => {
|
|
if (!this.closeAcknowledged || code !== 0) throw new Error(`Desktop storage exited without a successful drain (${code}); unconfirmed writes require domain reconciliation`)
|
|
return code
|
|
})
|
|
return this.closing
|
|
}
|
|
}
|
|
|
|
async function withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(message)), EXIT_TIMEOUT_MS) })])
|
|
} finally { clearTimeout(timer) }
|
|
}
|