From 464fcb4231ea16c05b24e5ae87e09ff869b73092 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Tue, 3 Mar 2026 10:17:41 +0100 Subject: [PATCH] 1.0.20 --- Dockerfile | 9 ++-- src/lib/server/docker.ts | 14 ++++++- src/lib/server/subprocess-manager.ts | 30 ++++++++++++-- src/routes/api/logs/merged/+server.ts | 59 ++++++++++++--------------- 4 files changed, 69 insertions(+), 43 deletions(-) diff --git a/Dockerfile b/Dockerfile index d1c409f..abb4f82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN apk add --no-cache curl unzip \ | tar -xz --strip-components=1 -C /usr/local/bin \ && chmod +x /usr/local/bin/apko -# Generate apko.yaml — Node.js instead of Bun +# Generate apko.yaml — Node.js binary comes from node:24-slim, not Wolfi RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \ && printf '%s\n' \ "contents:" \ @@ -36,7 +36,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - ca-certificates" \ " - busybox" \ " - tzdata" \ - " - nodejs-24" \ " - docker-cli" \ " - docker-compose" \ " - docker-cli-buildx" \ @@ -66,7 +65,7 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \ # ----------------------------------------------------------------------------- # Stage 2: Application Builder (pure Node.js) # ----------------------------------------------------------------------------- -FROM node:24-slim AS app-builder +FROM --platform=$TARGETPLATFORM node:24-slim AS app-builder WORKDIR /app @@ -104,6 +103,10 @@ FROM scratch # Install custom Wolfi OS with Node.js COPY --from=os-builder /work/rootfs/ / +# Copy Node.js binary from official node:24-slim (platform-correct, conservative CPU baseline) +# Wolfi's nodejs-24 targets ARMv8.1+ which causes SIGILL on Cortex-A53 (Raspberry Pi 3+) +COPY --from=app-builder /usr/local/bin/node /usr/local/bin/node + # Copy libnss_wrapper for git SSH with arbitrary UIDs COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 1fd7f5a..d9b179e 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -2616,6 +2616,9 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise { const environments = await getEnvironments(); const activeIds = new Set(); + const lines: string[] = []; + + const enqueue = (msg: Record) => { + lines.push(JSON.stringify(msg)); + }; for (const env of environments) { // Skip hawser-edge (events come via WebSocket) @@ -446,7 +451,7 @@ async function sendEnvironmentConfigs(): Promise { // Only send if env has metrics or activity collection enabled if (env.collectMetrics === false && env.collectActivity === false) continue; - sendToGo({ + enqueue({ type: 'configure', envId: env.id, name: env.name, @@ -461,7 +466,7 @@ async function sendEnvironmentConfigs(): Promise { // Remove envs that are no longer active for (const envId of configuredEnvs) { if (!activeIds.has(envId)) { - sendToGo({ type: 'remove', envId }); + enqueue({ type: 'remove', envId }); configuredEnvs.delete(envId); envNames.delete(envId); } @@ -469,11 +474,18 @@ async function sendEnvironmentConfigs(): Promise { // Send settings const metricsInterval = await getMetricsCollectionInterval(); - sendToGo({ type: 'set_metrics_interval', intervalMs: metricsInterval }); + enqueue({ type: 'set_metrics_interval', intervalMs: metricsInterval }); const eventMode = await getEventCollectionMode(); const pollInterval = await getEventPollInterval(); - sendToGo({ type: 'set_event_mode', mode: eventMode, pollIntervalMs: pollInterval }); + enqueue({ type: 'set_event_mode', mode: eventMode, pollIntervalMs: pollInterval }); + + // Single atomic write — avoids pipe backpressure on low-memory ARM devices + // where multiple rapid writes can overflow small OS pipe buffers (4-16KB on + // some ARM Linux configs) before Go has drained them. + if (lines.length > 0 && proc?.stdin?.writable) { + proc.stdin.write(lines.join('\n') + '\n'); + } } // --------------------------------------------------------------------------- @@ -544,6 +556,16 @@ export async function startSubprocesses(): Promise { stdio: ['pipe', 'pipe', 'inherit'] }); + // Prevent unhandled 'error' events on stdin from destroying the pipe. + // Without this, any write error (e.g. EPIPE on a momentarily full pipe buffer + // on low-memory systems) destroys the stream, sending EOF to Go and causing + // it to exit — which looks like a mysterious restart loop on Raspberry Pi. + proc.stdin?.on('error', (err: NodeJS.ErrnoException) => { + if (!isShuttingDown) { + console.error('[SubprocessManager] stdin pipe error:', err.message); + } + }); + // Start reading stdout readStdout(); diff --git a/src/routes/api/logs/merged/+server.ts b/src/routes/api/logs/merged/+server.ts index 8480c6b..29a42b9 100644 --- a/src/routes/api/logs/merged/+server.ts +++ b/src/routes/api/logs/merged/+server.ts @@ -588,46 +588,37 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; - // Continuously process all sources - console.log('[merged-logs] Starting processing loop'); - let loopCount = 0; - while (!controllerClosed) { - const activeSources = sources.filter(s => !s.done && s.reader); - if (activeSources.length === 0) { + // Each source streams independently — no lockstep polling + console.log(`[merged-logs] Starting ${sources.length} independent read loops`); + + let endedCount = 0; + const checkAllDone = () => { + endedCount++; + if (endedCount >= sources.length) { safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'all streams ended' })}\n\n`); - break; - } - - if (loopCount === 0) { - console.log(`[merged-logs] Processing ${activeSources.length} active sources, first read...`); - } - loopCount++; - - await Promise.all(activeSources.map(processSource)); - - // Small delay to prevent tight loop - await new Promise(resolve => setTimeout(resolve, 10)); - } - - // Cleanup readers - for (const source of sources) { - if (source.reader) { - try { - await source.reader.cancel().catch(() => {}); - source.reader.releaseLock(); - } catch { - // Ignore + if (!controllerClosed) { + try { controller.close(); } catch { /* Already closed */ } } } - } + }; - if (!controllerClosed) { + const runSource = async (source: ContainerLogSource) => { try { - controller.close(); - } catch { - // Already closed + while (!controllerClosed && !source.done) { + await processSource(source); + } + } finally { + if (source.reader) { + try { + await source.reader.cancel().catch(() => {}); + source.reader.releaseLock(); + } catch { /* Ignore */ } + } + checkAllDone(); } - } + }; + + await Promise.all(sources.map(runSource)); }, cancel() { controllerClosed = true;