From 5251a13a2594b6add9e43e7acae3452d650ac00b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 22:09:03 +0800 Subject: [PATCH] fix(app-boot): survive a surface that exits before the tree settles boot() asserts over ctx.loader after awaiting the Loader, but the TUI renders as soon as its own fiber starts: an /exit typed before the last entry settles runs disposeRootAndExit, which takes the Loader service with the tree. The assertions then read undefined and crashed the process with a TypeError over an app that exited exactly as asked. The keyless personal-overlay PTY smoke lost this race in roughly two of three runs. --- packages/ui/app-boot/src/index.ts | 10 +++++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 25e6291bbf..b404809527 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -234,7 +234,8 @@ export function assertEntriesActive(ctx: Context, binName: string): void { * @param patches - optional overlay patches applied over the included tree * (see {@link loadPersonalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. - * @returns the root context once every entry has started. + * @returns the root context once every entry has started, or as soon as a + * surface disposed the tree while startup was still in flight. */ export async function boot( binName: string, @@ -255,6 +256,13 @@ export async function boot( }, }) await ctx.loader.await() + // A surface can finish and dispose the whole tree while that await is still + // pending: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and both assertions below describe a live + // tree — reading `ctx.loader` here would throw a TypeError over an app that + // exited exactly as asked. + if (ctx.get('loader') === undefined) return ctx assertEntriesLoaded(ctx, binName) assertEntriesActive(ctx, binName) return ctx diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 264ba82e65..d6807bbaed 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -207,6 +207,25 @@ describe('boot', () => { } }) + it('returns instead of asserting over a tree a surface disposed mid-startup', async () => { + // What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root + // fiber, which lands while boot() is still awaiting the Loader whenever the + // surface renders before the last entry settles. The Loader service goes + // with the tree, so reading it for the post-boot assertions would crash an + // app that exited exactly as the user asked. + const dir = tmp() + writeFileSync(join(dir, 'exiting.mjs'), [ + 'export const name = "exiting"', + 'export function apply(ctx) {', + ' void ctx.root.fiber.dispose()', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + expect(ctx.get('loader')).toBeUndefined() + }) + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')