fix(dev): harden smoke log diagnostics

This commit is contained in:
Vincent Koc
2026-06-07 08:42:13 +02:00
parent a77d0fdd97
commit 2fe7b5e8c9
4 changed files with 193 additions and 32 deletions
+5
View File
@@ -27,5 +27,10 @@ xcrun devicectl device copy from \
--source Documents/openclaw-gateway.log \
--destination "$DEST" >/dev/null
if [[ ! -s "$DEST" ]]; then
echo "Gateway log pull produced an empty file: $DEST" >&2
exit 1
fi
echo "Pulled to: $DEST"
tail -n 200 "$DEST"
+59 -19
View File
@@ -1,6 +1,6 @@
// Tui Pty Test Watch script supports OpenClaw repository automation.
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, open, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -24,6 +24,8 @@ const DEFAULT_PTY_COLS = 100;
const DEFAULT_PTY_ROWS = 30;
const CHILD_SIGTERM_GRACE_MS = 500;
const CHILD_SIGKILL_GRACE_MS = 5_000;
const MIRROR_READ_CHUNK_BYTES = 1024 * 1024;
const CHILD_OUTPUT_TAIL_BYTES = 128 * 1024;
type KillableChild = {
pid?: number;
@@ -155,16 +157,54 @@ async function createMirrorFile(mirrorPath: string): Promise<void> {
await writeFile(mirrorPath, "", "utf8");
}
async function readNewMirrorData(mirrorPath: string, offset: number) {
const data = await readFile(mirrorPath);
const nextOffset = data.byteLength;
if (nextOffset < offset) {
return { chunk: data, offset: nextOffset };
async function readNewMirrorData(
mirrorPath: string,
offset: number,
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
) {
const file = await open(mirrorPath, "r");
try {
const stats = await file.stat();
const readOffset = stats.size < offset ? 0 : offset;
const availableBytes = stats.size - readOffset;
if (availableBytes <= 0) {
return { chunk: Buffer.alloc(0), offset: readOffset };
}
const bytesToRead = Math.min(availableBytes, maxChunkBytes);
const buffer = Buffer.alloc(bytesToRead);
const { bytesRead } = await file.read(buffer, 0, bytesToRead, readOffset);
return { chunk: buffer.subarray(0, bytesRead), offset: readOffset + bytesRead };
} finally {
await file.close();
}
if (nextOffset === offset) {
return { chunk: Buffer.alloc(0), offset };
}
function appendBufferTail(current: Buffer, chunk: Buffer, maxBytes = CHILD_OUTPUT_TAIL_BYTES) {
if (chunk.byteLength >= maxBytes) {
return chunk.subarray(chunk.byteLength - maxBytes);
}
if (current.byteLength + chunk.byteLength <= maxBytes) {
return current.byteLength === 0 ? Buffer.from(chunk) : Buffer.concat([current, chunk]);
}
const keepBytes = maxBytes - chunk.byteLength;
return Buffer.concat([current.subarray(current.byteLength - keepBytes), chunk]);
}
async function drainNewMirrorData(
mirrorPath: string,
offset: number,
onChunk: (chunk: Buffer) => void,
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
) {
let nextOffset = offset;
for (;;) {
const result = await readNewMirrorData(mirrorPath, nextOffset, maxChunkBytes);
nextOffset = result.offset;
if (result.chunk.byteLength === 0) {
return nextOffset;
}
onChunk(result.chunk);
}
return { chunk: data.subarray(offset), offset: nextOffset };
}
async function main(): Promise<void> {
@@ -200,8 +240,8 @@ async function main(): Promise<void> {
},
);
let childStdout = "";
let childStderr = "";
let childStdout = Buffer.alloc(0);
let childStderr = Buffer.alloc(0);
let restored = false;
let mirrorOffset = 0;
let mirrorFilterPending = "";
@@ -309,10 +349,10 @@ async function main(): Promise<void> {
}
child.stdout?.on("data", (chunk: Buffer) => {
childStdout += chunk.toString("utf8");
childStdout = appendBufferTail(childStdout, chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
childStderr += chunk.toString("utf8");
childStderr = appendBufferTail(childStderr, chunk);
});
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
@@ -345,10 +385,7 @@ async function main(): Promise<void> {
await delay(sawMirrorOutput ? 25 : 250);
}
const result = await readNewMirrorData(options.mirrorPath, mirrorOffset);
if (result.chunk.byteLength > 0) {
writeMirrorChunk(result.chunk);
}
mirrorOffset = await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
} finally {
if (!childExit) {
stopChild();
@@ -368,10 +405,10 @@ async function main(): Promise<void> {
childExit = await childFinished;
}
if (childStdout) {
if (childStdout.byteLength > 0) {
process.stdout.write(childStdout);
}
if (childStderr) {
if (childStderr.byteLength > 0) {
process.stderr.write(childStderr);
}
@@ -393,6 +430,9 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
}
export const testing = {
appendBufferTail,
createChildStopper,
drainNewMirrorData,
readNewMirrorData,
signalChildProcessTree,
};
+61 -1
View File
@@ -19,8 +19,13 @@ import {
redactJsonValueForDevToolLog,
} from "../../scripts/lib/dev-tooling-safety.ts";
afterEach(() => {
const tempDirs: string[] = [];
afterEach(async () => {
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { force: true, recursive: true });
}
});
describe("dev tooling safety helpers", () => {
@@ -224,6 +229,61 @@ describe("script-specific dev tooling hardening", () => {
expect(signals).toEqual(["SIGINT", "SIGTERM", "SIGKILL"]);
});
it("reads TUI PTY mirror updates incrementally with a bounded chunk", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-watch-test-"));
tempDirs.push(tempRoot);
const mirrorPath = path.join(tempRoot, "mirror.ansi");
await fs.writeFile(mirrorPath, "first-second-third", "utf8");
const first = await tuiPtyWatchTesting.readNewMirrorData(mirrorPath, 0, 6);
expect(first.chunk.toString("utf8")).toBe("first-");
expect(first.offset).toBe(6);
const second = await tuiPtyWatchTesting.readNewMirrorData(mirrorPath, first.offset, 6);
expect(second.chunk.toString("utf8")).toBe("second");
expect(second.offset).toBe(12);
});
it("restarts TUI PTY mirror reads when the mirror file is truncated", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-watch-test-"));
tempDirs.push(tempRoot);
const mirrorPath = path.join(tempRoot, "mirror.ansi");
await fs.writeFile(mirrorPath, "fresh", "utf8");
const result = await tuiPtyWatchTesting.readNewMirrorData(mirrorPath, 10, 1024);
expect(result.chunk.toString("utf8")).toBe("fresh");
expect(result.offset).toBe(5);
});
it("drains all pending TUI PTY mirror chunks after the child exits", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-watch-test-"));
tempDirs.push(tempRoot);
const mirrorPath = path.join(tempRoot, "mirror.ansi");
await fs.writeFile(mirrorPath, "first-second-third", "utf8");
const chunks: string[] = [];
const offset = await tuiPtyWatchTesting.drainNewMirrorData(
mirrorPath,
0,
(chunk: Buffer) => chunks.push(chunk.toString("utf8")),
6,
);
expect(chunks).toEqual(["first-", "second", "-third"]);
expect(offset).toBe("first-second-third".length);
});
it("keeps only diagnostic tails from noisy TUI PTY child output", () => {
const retained = tuiPtyWatchTesting.appendBufferTail(
Buffer.from("0123456789", "utf8"),
Buffer.from("abcdef", "utf8"),
8,
);
expect(retained.toString("utf8")).toBe("89abcdef");
});
it.runIf(process.platform !== "win32")(
"signals the TUI PTY watch process group before falling back to the child",
() => {
+68 -12
View File
@@ -1,20 +1,76 @@
// Ios Pull Gateway Log tests cover ios pull gateway log script behavior.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const scriptPath = "scripts/dev/ios-pull-gateway-log.sh";
const tempDirs: string[] = [];
function makeTempDir(): string {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-ios-log-pull-"));
tempDirs.push(root);
return root;
}
function runWithFakeXcrun(
root: string,
fakeXcrunBody: string,
destPath: string,
): ReturnType<typeof spawnSync> {
const binDir = path.join(root, "bin");
const xcrunPath = path.join(binDir, "xcrun");
mkdirSync(binDir);
writeFileSync(
xcrunPath,
["#!/usr/bin/env bash", "set -euo pipefail", fakeXcrunBody, ""].join("\n"),
);
chmodSync(xcrunPath, 0o755);
return spawnSync("bash", [scriptPath, "device-udid", "ai.openclaw.ios.dev", destPath], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
describe("scripts/dev/ios-pull-gateway-log.sh", () => {
it("does not bake local device or bundle identifiers into the log pull helper", () => {
const script = readFileSync(scriptPath, "utf8");
it("fails when the copied gateway log is empty", () => {
const root = makeTempDir();
const destPath = path.join(root, "openclaw-gateway.log");
const result = runWithFakeXcrun(
root,
'while [[ "$#" -gt 0 ]]; do if [[ "$1" == "--destination" ]]; then shift; : > "$1"; fi; shift || break; done',
destPath,
);
expect(script).toContain('DEVICE_UDID="${1:-${OPENCLAW_IOS_DEVICE_UDID:-}}"');
expect(script).toContain('BUNDLE_ID="${2:-${OPENCLAW_IOS_BUNDLE_ID:-}}"');
expect(script).toContain('DEST="${3:-${OPENCLAW_IOS_GATEWAY_LOG_DEST:-}}"');
expect(script).toContain('mktemp -d "${TMPDIR:-/tmp}/openclaw-ios-gateway.XXXXXX"');
expect(script).toContain("exit 2");
expect(script).not.toMatch(/DEVICE_UDID="\$\{1:-[0-9A-F-]+/u);
expect(script).not.toMatch(/BUNDLE_ID="\$\{2:-ai\.openclaw\.ios\.dev\.[^}]+/u);
expect(script).not.toContain("/tmp/openclaw-gateway.log");
expect(result.status).toBe(1);
expect(result.stderr).toContain("Gateway log pull produced an empty file");
expect(result.stdout).not.toContain("Pulled to:");
});
it("prints the pulled gateway log tail when the copied file has content", () => {
const root = makeTempDir();
const destPath = path.join(root, "openclaw-gateway.log");
const result = runWithFakeXcrun(
root,
'while [[ "$#" -gt 0 ]]; do if [[ "$1" == "--destination" ]]; then shift; printf "gateway ready\\n" > "$1"; fi; shift || break; done',
destPath,
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Pulled to: ${destPath}`);
expect(result.stdout).toContain("gateway ready");
});
});