mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix(apps): stop task.yaml rewrite races + blank-load watchdog + loop-lag monitor
Investigating 'apps blank while a bg task runs': measured serving latency through a real scheduled bg-task run — 1-3ms on both loopbacks throughout, so the server is not the bottleneck. Found and fixed the likely renderer-era culprit plus made the failure observable: - agent sync (apps:list, polled every 4s) rewrote each bundled agent's task.yaml unconditionally — racing the bg runner's own patches mid-run and spamming watcher events; now writes only when the definition changed - AppFrame load watchdog: if the iframe hasn't loaded in 6s, show a visible 'taking too long' state with Retry instead of a silent blank pane - main event-loop lag monitor: stalls >300ms are logged so future blank reports can be tied to the offending work
This commit is contained in:
parent
9173097715
commit
7c9fe7214d
3 changed files with 65 additions and 2 deletions
|
|
@ -11,12 +11,29 @@ import { AppDetail } from '@/components/apps/app-detail'
|
|||
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
|
||||
// surface a visible retry state instead of a silent blank pane.
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
|
||||
const title = app.manifest?.name ?? app.folder
|
||||
|
||||
useEffect(() => {
|
||||
appOpened(app.folder)
|
||||
}, [app.folder])
|
||||
|
||||
// Reset the watchdog when the target changes (adjust-during-render pattern).
|
||||
const [watchKey, setWatchKey] = useState(`${app.folder}:${reloadNonce}`)
|
||||
if (watchKey !== `${app.folder}:${reloadNonce}`) {
|
||||
setWatchKey(`${app.folder}:${reloadNonce}`)
|
||||
setLoadState('loading')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoadState((s) => (s === 'loading' ? 'stuck' : s))
|
||||
}, 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [watchKey])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
|
|
@ -55,13 +72,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<iframe
|
||||
key={reloadNonce}
|
||||
title={title}
|
||||
src={`${app.origin}/`}
|
||||
onLoad={() => setLoadState('ok')}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
{loadState === 'stuck' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
|
||||
<div className="text-muted-foreground">This app is taking too long to load.</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadNonce((n) => n + 1)}
|
||||
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDetail && (
|
||||
<div className="w-80 shrink-0 border-l border-border">
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
|
|||
|
||||
if (await pathExists(taskYaml)) {
|
||||
// Update path (§8.4): overwrite definition fields, preserve the rest.
|
||||
// Write ONLY when the definition actually changed — this sync runs on
|
||||
// every apps:list poll, and an unconditional rewrite races the bg
|
||||
// runner's own task.yaml patches mid-run (and spams watcher events).
|
||||
try {
|
||||
const current = parseYaml(await fs.readFile(taskYaml, 'utf-8')) as BackgroundTask;
|
||||
const next: BackgroundTask = {
|
||||
|
|
@ -69,7 +72,14 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
|
|||
sourceApp: folder,
|
||||
};
|
||||
if (!def.triggers) delete next.triggers;
|
||||
await fs.writeFile(taskYaml, stringifyYaml(next), 'utf-8');
|
||||
const unchanged =
|
||||
current.name === next.name &&
|
||||
current.instructions === next.instructions &&
|
||||
current.sourceApp === next.sourceApp &&
|
||||
JSON.stringify(current.triggers ?? null) === JSON.stringify(next.triggers ?? null);
|
||||
if (!unchanged) {
|
||||
await fs.writeFile(taskYaml, stringifyYaml(next), 'utf-8');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[Apps] failed to update agent task ${slug}:`, e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -710,6 +710,27 @@ export function getServerStatus(): { running: boolean; error?: string } {
|
|||
return server ? { running: true } : { running: false, ...(serverError ? { error: serverError } : {}) };
|
||||
}
|
||||
|
||||
let lagMonitor: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Event-loop lag monitor: the apps server shares the main process with agent
|
||||
* runs, sync pipelines, etc. If the loop stalls, every open app hangs with it —
|
||||
* log stalls >300ms so "app went blank" reports can be tied to a culprit.
|
||||
*/
|
||||
function startLagMonitor(): void {
|
||||
if (lagMonitor) return;
|
||||
let last = Date.now();
|
||||
lagMonitor = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const lag = now - last - 500;
|
||||
if (lag > 300) {
|
||||
console.warn(`[Apps] main event-loop stalled ~${lag}ms (apps server shares this loop — open apps hang during stalls)`);
|
||||
}
|
||||
last = now;
|
||||
}, 500);
|
||||
lagMonitor.unref?.();
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
if (server) return;
|
||||
if (startPromise) return startPromise;
|
||||
|
|
@ -717,6 +738,7 @@ export async function init(): Promise<void> {
|
|||
startPromise = (async () => {
|
||||
try {
|
||||
await fsp.mkdir(APPS_DIR, { recursive: true });
|
||||
startLagMonitor();
|
||||
await startWatcher();
|
||||
const expressApp = createApp();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue