Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,11 @@ Layer 2 creates one workspace Effect resource owner for file preparation, image
Deterministic tests cover parallel cancellation, late store acquisition, sibling isolation, actual XHR cancellation, and progress versus successful response. Transfer still starts on addition. Persistent submission and complete draft behavior remain the next two layers.

Layer 2 validation: `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes completely (components: 478 files, 3,661 tests). `pnpm format` and `pnpm run docs check` completed; docs have no errors. Packaged-device draft acceptance remains outstanding.

Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705.

Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding.

Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer keeps the queued journal identity but first promotes its delivered queue operation back to saved history work; the history turn is durably prepared and committed before the queue row can be removed or guide delivery begins. A prepared or committed record may be explicitly discarded only after disclosure; destructive logout/cache-clear writes a forced-clear marker that actually deletes the recovery database on boot. A non-forced clear blocked by recovery stays pending without preventing runtime initialization, including a one-shot native reset request copied into the local boot marker before it is deferred. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed.

Cross-window takeover records the replica that actually prepared the operations. The admitting window is not necessarily the source baseline owner. A deterministic journal test covers this recovery boundary.
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,11 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并
新增确定性测试覆盖并行取消、迟到的 store 获取、兄弟任务隔离、XHR 实际取消及上传进度与成功响应的区别。该层保持添加时上传;持久化发送和完整 draft 行为仍属于后两层。

第二层验证:`TMPDIR=/private/tmp NODE_ENV=test pnpm check` 全部通过(组件 478 个文件、3,661 个测试),`pnpm format` 和 `pnpm run docs check` 已完成;文档无错误。仍未声称完成真实设备上的 draft 验收。

第二层 PR:[#707](https://github.com/LodyAI/Lody/pull/707),基于 #705。

第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。

Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 保留 queued journal 的身份,但会先将已经投递的 queue 操作提升回已保存的 history 工作;history turn 已持久准备并提交后,才能删除 queue 行或开始 guide 投递。prepared 或 committed 记录只能在披露后显式丢弃;退出登录/清缓存会写入强制清理标记,并在下次启动时实际删除恢复数据库。被恢复记录阻挡的非强制清理会保留请求,但不会阻止 runtime 初始化;一次性 native reset 请求也会在延后前复制为本地启动标记。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed.

跨窗口接管时记录实际准备操作的副本;接管输入的窗口不一定拥有原操作基线。确定性 journal 测试覆盖此恢复边界。
2 changes: 1 addition & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"homepage": "https://github.com/LodyAI/Lody",
"scripts": {
"format": "oxfmt",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/devbar.test.mjs src/system-language-argument.test.mjs src/main/reload-shortcut.test.mjs src/main/close-focused-tab-or-window.test.mjs src/main/onboarding-launch-policy.test.mjs src/main/auto-launch-policy.test.mjs src/main/context-menu-template.test.mjs src/main/window-runtime-policy.test.mjs src/main/window-theme.test.mjs src/main/local-platform-snapshot.test.mjs src/main/ipc/ipc-channel-list.test.mjs src/main/services/local-file-resource.test.mjs src/main/services/local-path-launcher-core.test.mjs src/main/services/image-export-core.test.mjs src/main/services/notification-delivery.test.mjs src/main/services/public-browser-state.test.mjs src/main/services/app-updater-linux-install.test.mjs src/main/services/app-updater-metadata.test.mjs src/main/services/app-updater-sparkle-policy.test.mjs src/main/services/app-updater-sparkle-events.test.mjs src/main/services/loro-data-plane-relay.test.mjs src/renderer/renderer-csp.test.mjs src/renderer/src/auth-callback-transaction.test.mjs src/renderer/src/auth-query-generation.test.mjs src/renderer/src/renderer-error-reporting.test.mjs scripts/sparkle-packaging.test.mjs",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/devbar.test.mjs src/system-language-argument.test.mjs src/main/reload-shortcut.test.mjs src/main/close-focused-tab-or-window.test.mjs src/main/onboarding-launch-policy.test.mjs src/main/auto-launch-policy.test.mjs src/main/context-menu-template.test.mjs src/main/window-runtime-policy.test.mjs src/main/window-theme.test.mjs src/main/local-platform-snapshot.test.mjs src/main/ipc/ipc-channel-list.test.mjs src/main/services/local-file-resource.test.mjs src/main/services/local-path-launcher-core.test.mjs src/main/services/image-export-core.test.mjs src/main/services/renderer-send-lifecycle-core.test.mjs src/main/services/notification-delivery.test.mjs src/main/services/public-browser-state.test.mjs src/main/services/app-updater-linux-install.test.mjs src/main/services/app-updater-metadata.test.mjs src/main/services/app-updater-sparkle-policy.test.mjs src/main/services/app-updater-sparkle-events.test.mjs src/main/services/loro-data-plane-relay.test.mjs src/renderer/renderer-csp.test.mjs src/renderer/src/auth-callback-transaction.test.mjs src/renderer/src/auth-query-generation.test.mjs src/renderer/src/renderer-error-reporting.test.mjs scripts/sparkle-packaging.test.mjs",
"lint": "eslint --cache .",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
Expand Down
42 changes: 22 additions & 20 deletions apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prepareRendererSendsForExit } from './services/renderer-send-lifecycle'
import {
registerLocalFileResourceScheme,
installLocalFileResourceProtocol
Expand Down Expand Up @@ -310,34 +311,35 @@ if (hasSingleInstanceLock) {
})

let cliShutdownComplete = false
let preparingQuit = false
app.on('before-quit', (event) => {
setAppQuitting(true)
setWindowsTrayAvailable(false)
windowsTrayService.stop()
windowBadgeService.reset()
terminalRelay.destroy()
loroDataPlaneRelay.destroy()
appUpdaterService.stop()
publicBrowserService.destroyAll()

if (cliShutdownComplete) {
// Cleanup already ran on the first pass; let this quit proceed.
cliService.killAllProcesses()
return
}

// Defer the quit until the embedded CLI has actually exited. Killing it
// fire-and-forget would let the app exit while the CLI is still shutting
// down, orphaning it holding the local ports + terminal socket and breaking
// the next launch. shutdownForQuit() SIGTERMs, waits briefly, then SIGKILLs.
event.preventDefault()
void Promise.allSettled([
cliService.shutdownForQuit(),
flushElectronMainErrorReporting()
]).finally(() => {
if (preparingQuit) return
preparingQuit = true
void (async () => {
if (!(await prepareRendererSendsForExit('quit'))) return
setAppQuitting(true)
setWindowsTrayAvailable(false)
windowsTrayService.stop()
windowBadgeService.reset()
terminalRelay.destroy()
loroDataPlaneRelay.destroy()
appUpdaterService.stop()
publicBrowserService.destroyAll()
await Promise.allSettled([cliService.shutdownForQuit(), flushElectronMainErrorReporting()])
cliShutdownComplete = true
app.quit()
})
})()
.catch((error: unknown) => {
console.error('[Electron] Could not finish renderer shutdown', error)
})
.finally(() => {
preparingQuit = false
})
})

process.on('exit', () => {
Expand Down
26 changes: 25 additions & 1 deletion apps/electron/src/main/ipc/services/app-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
registerRendererSendLifecycle,
resolveRendererSendLifecycle,
prepareRendererSendsForExit
} from '../../services/renderer-send-lifecycle'
import { assertProductWindowSender } from '../assert-sender'
import { productWindows } from '../../window-state'
import { parseWindowTarget, openSessionWindow, type WindowTarget } from '../../session-windows'
Expand Down Expand Up @@ -97,6 +102,21 @@ export function installNativeThemeWatch(): void {
export class AppIpc extends IpcService {
static override readonly groupName = 'app'

@IpcMethod()
async registerSendLifecycle() {
const { event } = getIpcContext()
assertProductWindowSender(event)
const window = BrowserWindow.fromWebContents(event.sender)
if (window) registerRendererSendLifecycle(window)
}

@IpcMethod()
async replySendLifecycle(input: unknown) {
const { event } = getIpcContext()
assertProductWindowSender(event)
resolveRendererSendLifecycle(event.sender.id, input)
}

@IpcMethod()
async openWindow(raw: WindowTarget) {
const { event } = getIpcContext()
Expand All @@ -109,7 +129,11 @@ export class AppIpc extends IpcService {
const { event } = getIpcContext()
assertProductWindowSender(event)
for (const window of productWindows) {
if (window.webContents !== event.sender) window.destroy()
if (window.webContents !== event.sender) {
if (!(await prepareRendererSendsForExit('close', window)))
throw new Error('Cache clearing was cancelled')
window.destroy()
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions apps/electron/src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,7 @@ export function setMenuLanguage(locale: string): void {
}
buildAndSetMenu()
}

export function translateAppText(key: string): string {
return t(currentLocale, key)
}
10 changes: 10 additions & 0 deletions apps/electron/src/main/renderer-recovery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prepareRendererSendsForExit } from './services/renderer-send-lifecycle'
import { app, BrowserWindow, type WebContents } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
Expand Down Expand Up @@ -111,6 +112,15 @@ function loadTarget(window: BrowserWindow, target: ReloadTarget): Promise<void>
}

export function requestRendererReload(window: BrowserWindow): void {
if (window.isDestroyed()) return
void prepareRendererSendsForExit('reload', window)
.then((allowed) => {
if (allowed && !window.isDestroyed()) reloadRendererAfterCleanup(window)
})
.catch((error: unknown) => console.error('[Electron] Reload cleanup failed', error))
}

function reloadRendererAfterCleanup(window: BrowserWindow): void {
if (window.isDestroyed()) return
const state = getState(window)
state.hasNotifiedMounted = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { runRendererSendExit } from './renderer-send-lifecycle-core.ts'

function gate() {
let resolve
const promise = new Promise((done) => {
resolve = done
})
return { promise, resolve }
}

void test('Stay preserves every live renderer and its dependencies', async () => {
const windows = [{ live: true }, { live: true }]
const allowed = await runRendererSendExit(windows, {
check: async () => ({ ready: true, pending: true }),
unavailable: async () => {},
confirm: async () => false,
drain: async (window) => {
window.live = false
return { ready: true, pending: false }
}
})
assert.equal(allowed, false)
assert.deepEqual(windows, [{ live: true }, { live: true }])
})

void test('one unresponsive renderer cannot authorize stopping the CLI', async () => {
let cliStopped = false
const allowed = await runRendererSendExit([1, 2], {
check: async (window) => ({ ready: window === 1, pending: false }),
unavailable: async () => {},
confirm: async () => true,
drain: async () => ({ ready: true, pending: false })
})
if (allowed) cliStopped = true
assert.equal(cliStopped, false)
})

void test('all renderer cleanup must settle before stopping dependencies', async () => {
const started = [gate(), gate()]
const finish = [gate(), gate()]
const live = [true, true]
let cliStopped = false
const exit = runRendererSendExit([0, 1], {
check: async () => ({ ready: true, pending: true }),
unavailable: async () => {},
confirm: async () => true,
drain: async (index) => {
started[index].resolve()
await finish[index].promise
live[index] = false
return { ready: true, pending: false }
}
}).then((allowed) => {
if (allowed) cliStopped = true
})
await Promise.all(started.map((item) => item.promise))
finish[0].resolve()
assert.equal(cliStopped, false)
assert.equal(live[1], true)
finish[1].resolve()
await exit
assert.deepEqual(live, [false, false])
assert.equal(cliStopped, true)
})
21 changes: 21 additions & 0 deletions apps/electron/src/main/services/renderer-send-lifecycle-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type RendererSendExitReply = { ready: boolean; pending: boolean }

/** Approval is a separate phase: denying exit must leave every renderer usable. */
export async function runRendererSendExit<T>(
targets: readonly T[],
ports: {
check(target: T): Promise<RendererSendExitReply>
unavailable(): Promise<void>
confirm(): Promise<boolean>
drain(target: T): Promise<RendererSendExitReply>
}
): Promise<boolean> {
const checks = await Promise.all(targets.map((target) => ports.check(target)))
if (checks.some((reply) => !reply.ready)) {
await ports.unavailable()
return false
}
if (checks.some((reply) => reply.pending) && !(await ports.confirm())) return false
const stopped = await Promise.all(targets.map((target) => ports.drain(target)))
return stopped.every((reply) => reply.ready)
}
121 changes: 121 additions & 0 deletions apps/electron/src/main/services/renderer-send-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { runRendererSendExit } from './renderer-send-lifecycle-core'
import { translateAppText as t } from '../menu'
import { randomUUID } from 'node:crypto'
import { dialog, type BrowserWindow } from 'electron'
import { IPC_PUSH_CHANNELS } from '@lody/shared/electron-ipc'
import { productWindows } from '../window-state'

type Reply = { ready: boolean; pending: boolean }
const registered = new Set<number>()
const requests = new Map<string, { senderId: number; resolve: (reply: Reply) => void }>()

export function registerRendererSendLifecycle(window: BrowserWindow): void {
const id = window.webContents.id
if (registered.has(id)) return
registered.add(id)
window.webContents.once('destroyed', () => {
registered.delete(id)
for (const [requestId, request] of requests) {
if (request.senderId === id) {
requests.delete(requestId)
request.resolve({ ready: false, pending: true })
}
}
})
}

export function resolveRendererSendLifecycle(senderId: number, input: unknown): void {
if (!input || typeof input !== 'object') throw new Error('Invalid lifecycle response')
const value = input as { requestId?: unknown; ready?: unknown; pending?: unknown }
if (
typeof value.requestId !== 'string' ||
typeof value.ready !== 'boolean' ||
typeof value.pending !== 'boolean'
)
throw new Error('Invalid lifecycle response')
const request = requests.get(value.requestId)
if (!request || request.senderId !== senderId)
throw new Error('Lifecycle response owner mismatch')
requests.delete(value.requestId)
request.resolve({ ready: value.ready, pending: value.pending })
}

function requestLifecycle(
window: BrowserWindow,
phase: 'check' | 'commit',
reason: 'quit' | 'reload' | 'close'
): Promise<Reply> {
if (!registered.has(window.webContents.id))
return Promise.resolve({ ready: true, pending: false })
const requestId = randomUUID()
return new Promise((resolve) => {
// A check may time out conservatively. Cleanup itself must join raw IPC,
// so the commit phase cannot pretend that elapsed time released ownership.
const timer =
phase === 'check'
? setTimeout(() => {
requests.delete(requestId)
resolve({ ready: false, pending: true })
}, 5000)
: undefined
requests.set(requestId, {
senderId: window.webContents.id,
resolve: (reply) => {
clearTimeout(timer)
resolve(reply)
}
})
try {
window.webContents.send(IPC_PUSH_CHANNELS.appSendLifecycle, { requestId, phase, reason })
} catch {
requests.delete(requestId)
clearTimeout(timer)
resolve({ ready: false, pending: true })
}
})
}

/** Approve first, then drain every renderer before main destroys transports or CLI. */
export async function prepareRendererSendsForExit(
reason: 'quit' | 'reload' | 'close',
target?: BrowserWindow
): Promise<boolean> {
const windows = (target ? [target] : [...productWindows]).filter(
(window) => !window.isDestroyed()
)
return runRendererSendExit(windows, {
check: (window) => requestLifecycle(window, 'check', reason),
unavailable: async () => {
await dialog.showMessageBox({
type: 'warning',
title: 'Lody',
message: t('sessions.pendingSendExitUnavailable'),
buttons: [t('sessions.stayWithPendingSends')],
defaultId: 0,
cancelId: 0
})
},
confirm: async () => {
const confirmation = await dialog.showMessageBox({
type: 'warning',
title: 'Lody',
message: t('sessions.pendingSendExitTitle'),
detail: t('sessions.pendingSendRetainedExit'),
buttons: [
t('sessions.stayWithPendingSends'),
t(
reason === 'quit'
? 'sessions.pendingSendQuit'
: reason === 'close'
? 'sessions.pendingSendClose'
: 'sessions.pendingSendReload'
)
],
defaultId: 0,
cancelId: 0
})
return confirmation.response === 1
},
drain: (window) => requestLifecycle(window, 'commit', reason)
})
}
Loading