From 1284562034eb9732f6484fe60daae6ad28312021 Mon Sep 17 00:00:00 2001 From: pedrofrxncx Date: Tue, 28 Jul 2026 12:52:09 -0300 Subject: [PATCH] fix(sandbox): make daemon.json read async to stop blocking the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readConfig() used readFileSync on the daemon's single-threaded Bun event loop. It runs from orchestrator.fillApplicationDefaults() during stepClone — a live-daemon path, not just boot — so every clone blocked the loop from answering its HTTP health probe for the duration of the read. Studio tears down a daemon on a single missed probe. Switch to node:fs/promises readFile and thread the await through both call sites (entry.ts hydrate, orchestrator.ts fillApplicationDefaults). --- packages/sandbox/daemon/entry.ts | 6 ++-- packages/sandbox/daemon/persistence.test.ts | 32 +++++++++---------- packages/sandbox/daemon/persistence.ts | 6 ++-- packages/sandbox/daemon/setup/orchestrator.ts | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/sandbox/daemon/entry.ts b/packages/sandbox/daemon/entry.ts index 66e58bfeb4..9e673b15fb 100644 --- a/packages/sandbox/daemon/entry.ts +++ b/packages/sandbox/daemon/entry.ts @@ -528,8 +528,8 @@ const configUpdateH = makeConfigUpdateHandler({ }, }); -function hydrate(): void { - const diskOutcome = readConfig(bootConfig.repoDir); +async function hydrate(): Promise { + const diskOutcome = await readConfig(bootConfig.repoDir); if (diskOutcome.kind === "invalid") { console.warn( `[daemon] ignoring .decocms/daemon.json: ${diskOutcome.reason}`, @@ -541,7 +541,7 @@ function hydrate(): void { orchestrator.handle({ kind: "bootstrap", config: initial }); } -hydrate(); +await hydrate(); if (!store.read()) { console.log( diff --git a/packages/sandbox/daemon/persistence.test.ts b/packages/sandbox/daemon/persistence.test.ts index 97e30a9d86..4a748161b0 100644 --- a/packages/sandbox/daemon/persistence.test.ts +++ b/packages/sandbox/daemon/persistence.test.ts @@ -1,48 +1,48 @@ import { afterAll, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfig } from "./persistence"; -function repoWithDaemonJson(contents: string): string { - const dir = mkdtempSync(join(tmpdir(), "persistence-test-")); - mkdirSync(join(dir, ".decocms"), { recursive: true }); - writeFileSync(join(dir, ".decocms", "daemon.json"), contents); +async function repoWithDaemonJson(contents: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "persistence-test-")); + await mkdir(join(dir, ".decocms"), { recursive: true }); + await writeFile(join(dir, ".decocms", "daemon.json"), contents); return dir; } describe("readConfig", () => { const tmpDirs: string[] = []; - afterAll(() => { - for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }); + afterAll(async () => { + for (const d of tmpDirs) await rm(d, { recursive: true, force: true }); }); - it("returns absent when no file exists", () => { - const dir = mkdtempSync(join(tmpdir(), "persistence-test-")); + it("returns absent when no file exists", async () => { + const dir = await mkdtemp(join(tmpdir(), "persistence-test-")); tmpDirs.push(dir); - expect(readConfig(dir)).toEqual({ kind: "absent" }); + expect(await readConfig(dir)).toEqual({ kind: "absent" }); }); - it("accepts a valid config", () => { - const dir = repoWithDaemonJson( + it("accepts a valid config", async () => { + const dir = await repoWithDaemonJson( JSON.stringify({ application: { port: 3000 } }), ); tmpDirs.push(dir); - expect(readConfig(dir)).toEqual({ + expect(await readConfig(dir)).toEqual({ kind: "valid", config: { application: { port: 3000 } }, }); }); - it("rejects a config that fails schema validation instead of trusting it blindly", () => { + it("rejects a config that fails schema validation instead of trusting it blindly", async () => { // Regression: a tenant-committed .decocms/daemon.json bypasses the // daemon-token auth that guards PUT /config, so it must not skip the // same field validation that route enforces (here: port out of range). - const dir = repoWithDaemonJson( + const dir = await repoWithDaemonJson( JSON.stringify({ application: { port: 99999 } }), ); tmpDirs.push(dir); - const outcome = readConfig(dir); + const outcome = await readConfig(dir); expect(outcome.kind).toBe("invalid"); }); }); diff --git a/packages/sandbox/daemon/persistence.ts b/packages/sandbox/daemon/persistence.ts index cdd1bfa7a1..8cd7792e8a 100644 --- a/packages/sandbox/daemon/persistence.ts +++ b/packages/sandbox/daemon/persistence.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { TenantConfig } from "./types"; import { validateTenantConfig } from "./validate"; @@ -22,10 +22,10 @@ export type ReadOutcome = * never writes this file; it exists only if a tenant committed one to the * repo themselves. */ -export function readConfig(repoDir: string): ReadOutcome { +export async function readConfig(repoDir: string): Promise { let raw: string; try { - raw = readFileSync(configPath(repoDir), "utf-8"); + raw = await readFile(configPath(repoDir), "utf-8"); } catch (e) { const err = e as NodeJS.ErrnoException; if (err.code === "ENOENT") return { kind: "absent" }; diff --git a/packages/sandbox/daemon/setup/orchestrator.ts b/packages/sandbox/daemon/setup/orchestrator.ts index f9619a8a16..da7c88357f 100644 --- a/packages/sandbox/daemon/setup/orchestrator.ts +++ b/packages/sandbox/daemon/setup/orchestrator.ts @@ -540,7 +540,7 @@ export class SetupOrchestrator { * read-then-write. */ private async fillApplicationDefaults(repoDir: string): Promise { - const outcome = readConfig(repoDir); + const outcome = await readConfig(repoDir); const diskApp = outcome.kind === "valid" ? outcome.config.application : undefined;