perf: OTA 업로드에 빌드 캐시 + 진행 상황 스트리밍

사용자 신고: "이후 너무 오래 조용하네", "뭐하는 지 알 수 있음 좋겠군".

두 가지 문제가 겹쳐 있었다.

1) 매 요청마다 새 임시 디렉토리에 컴파일해서 ESP8266 코어 전체를 처음부터 다시
   빌드했다. 실측: 21.7초(cold) vs 3.8초(build-path 재사용). 기다린 시간의 대부분이
   바뀌지도 않은 코드를 다시 컴파일하는 것이었다. 보드별로 유지되는 build-path를
   쓴다. 같은 디렉토리에 동시 컴파일이 겹치면 결과물이 조용히 깨지므로 업로드를
   직렬화한다 — 가정용이라 동시 업로드는 드물지만, 터졌을 때 증상이 "이상한
   바이너리"라 원인 추적이 어렵다.

2) 응답이 끝나야 한 번에 오는 구조라 30초 넘게 아무것도 안 보였다. 멈춘 진행바는
   먹통과 구분이 안 된다. 단계를 줄 단위로 흘려보내고 프런트가 읽어서 찍는다.
   espota는 \r로 한 줄을 덮어쓰며 1460바이트 chunk마다 진행률을 뱉으므로,
   10% 단위로 실제로 값이 바뀔 때만 내보내 로그 범람을 막았다.
   실패/성공은 마지막 __DONE__ 줄로 구분한다.

espota 호출을 execFile에서 spawn으로 바꿨다(진행률을 실시간으로 받으려면 필요).
인자는 여전히 배열로 넘겨 암호가 셸에 닿지 않는다.

실측(라우트 직접 호출, 도착 시각 확인):
  1회차 15:19:20 시작 → 21.6초 컴파일 → 전송 → 실패 힌트까지 단계별 도착
  2회차 캐시 적용 7.9초

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-15 15:20:42 +09:00
co-authored by Claude Opus 5
parent 435c8f4e9c
commit f315f437b6
2 changed files with 96 additions and 14 deletions
+74 -10
View File
@@ -1761,6 +1761,32 @@ function findEspotaTool(fqbn: string): string | null {
return null;
}
/**
* A build directory that survives between uploads, keyed by board.
*
* arduino-cli rebuilds the whole ESP8266 core when it has nowhere to cache, and the route used to
* hand it a fresh temp dir every time. Measured 2026-08-15 on this sketch: 21.7s cold versus 3.8s
* reusing the path — most of the wait the user was staring at was recompiling code that had not
* changed.
*/
function otaBuildPath(fqbn: string): string {
const path = require('path'), fs = require('fs');
const key = String(fqbn).replace(/[^\w.-]+/g, '_');
const dir = path.join(require('os').tmpdir(), 'homeclaw-arduino-build', key);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
// Two compiles sharing one build directory corrupt each other, so uploads are queued rather than
// run concurrently. A home setup rarely flashes two boards at once, but the failure would be a
// baffling corrupted binary rather than an obvious error.
let _uploadChain: Promise<unknown> = Promise.resolve();
function serializeUpload<T>(fn: () => Promise<T>): Promise<T> {
const next = _uploadChain.then(fn, fn);
_uploadChain = next.catch(() => {});
return next;
}
// ── Arduino: compile + upload ─────────────────────────────────────────────────
app.post('/api/arduino/upload', async (req, res) => {
const { code, fqbn: rawFqbn, port } = req.body || {};
@@ -1813,27 +1839,64 @@ app.post('/api/arduino/upload', async (req, res) => {
const outDir = tmpDir + '/out';
require('fs').mkdirSync(outDir, { recursive: true });
const c = await execQ(`"${cliPath}" compile --fqbn ${fqbn} --output-dir "${outDir}" ${sketchDir}`, 300000);
// Stream the phases instead of answering once at the end. A cold compile plus a 300 KB
// wireless push is well over half a minute of nothing, and a silent progress bar is
// indistinguishable from a hang — reported 2026-08-15 as "너무 오래 조용하네".
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no'); // don't let a proxy sit on the chunks
const say = (s: string) => { try { res.write(s.endsWith('\n') ? s : s + '\n'); } catch {} };
const finish = (ok: boolean, msg: string) => { say(msg); say(ok ? '__DONE__ ok' : '__DONE__ fail'); res.end(); };
say(`📡 대상 ${port} · 보드 ${fqbn}`);
say('① 컴파일 중…');
const t0 = Date.now();
const c = await serializeUpload(() =>
execQ(`"${cliPath}" compile --fqbn ${fqbn} --build-path "${otaBuildPath(fqbn)}" --output-dir "${outDir}" ${sketchDir}`, 300000));
const cOut = (c.stdout + '\n' + c.stderr).trim();
if (c.err) return res.json({ error: '컴파일 실패\n' + cOut });
if (c.err) { finish(false, '컴파일 실패\n' + cOut); return; }
// Skip bootloader/partition images — the OTA payload is the application binary.
const bin = (require('fs').readdirSync(outDir) as string[])
.filter(f => f.endsWith('.bin') && !/bootloader|partitions|boot_app/i.test(f))
.sort((a, b) => b.length - a.length)[0];
if (!bin) return res.json({ error: '컴파일은 됐으나 .bin을 찾지 못했습니다\n' + cOut });
if (!bin) { finish(false, '컴파일은 됐으나 .bin을 찾지 못했습니다\n' + cOut); return; }
const kb = (require('fs').statSync(outDir + '/' + bin).size / 1024).toFixed(1);
say(` 완료 — ${bin} (${kb} KB, ${((Date.now() - t0) / 1000).toFixed(1)}초)`);
const otaPort = fqbn.startsWith('esp32:') ? '3232' : '8266';
const args = [espota, '-i', String(port), '-p', otaPort, '-f', outDir + '/' + bin, '-r'];
if (otaPassword) args.push('--auth=' + otaPassword);
// execFile, not exec: the password is user-supplied and must never reach a shell.
const r = await new Promise<{ out: string; err: any }>(resolve =>
require('child_process').execFile('python3', args, { timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
(err: any, stdout: string, stderr: string) => resolve({ out: ((stdout || '') + '\n' + (stderr || '')).trim(), err })));
say('② 무선 전송 중…');
// spawn, not execFile: espota reports percentage as it goes and that is the whole point of
// streaming. Args are passed as an array so the password never reaches a shell.
const r = await new Promise<{ out: string; code: number | null }>(resolve => {
const p = require('child_process').spawn('python3', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', lastPct = -1;
const onChunk = (d: Buffer) => {
const s = d.toString();
out += s;
// espota redraws one line with \r; forward only when the percentage actually moves,
// otherwise this floods the log with a bar per 1460-byte chunk.
const m = [...s.matchAll(/(\d+)%/g)].pop();
if (m) {
const pct = Number(m[1]);
if (pct !== lastPct && pct % 10 === 0) { lastPct = pct; say(` ${pct}%`); }
} else if (s.trim()) {
say(' ' + s.trim().split('\n').pop());
}
};
p.stdout.on('data', onChunk);
p.stderr.on('data', onChunk);
const killer = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} }, 300000);
p.on('close', (code: number | null) => { clearTimeout(killer); resolve({ out: out.trim(), code }); });
p.on('error', (e: any) => { clearTimeout(killer); resolve({ out: String(e.message), code: -1 }); });
});
if (!r.err || /Result: OK/i.test(r.out)) {
return res.json({ success: true, output: `📡 OTA 업로드 성공 (${port}:${otaPort})\n${r.out}` });
if (r.code === 0 || /Result: OK/i.test(r.out)) {
finish(true, `✅ OTA 업로드 성공 (${port}:${otaPort}) — 보드가 재부팅됩니다`);
return;
}
// Match espota.py's actual wording — checked against the tool's own logging.error() calls,
// because a hint that never fires is worse than none (verified 2026-08-15: an unreachable
@@ -1846,7 +1909,8 @@ app.post('/api/arduino/upload', async (req, res) => {
: /Listen Failed/i.test(r.out)
? '\n\n힌트: 서버가 응답 포트를 열지 못했습니다. 잠시 후 다시 시도하세요.'
: '';
return res.json({ error: (r.out || 'OTA 업로드 실패') + hint });
finish(false, '❌ ' + (r.out.split('\n').pop() || 'OTA 업로드 실패') + hint);
return;
}
const isFlashError = (s: string) =>
+22 -4
View File
@@ -6580,7 +6580,6 @@ async function _cupUploadOTA(target) {
_cupSetProgress(null);
_cupSetStatus('uploading', 'OTA 업로드 중...');
cupLog(`📡 OTA 업로드 [${_cupBoardName}] → ${target.ip}`, 'prog');
cupLog('서버에서 컴파일 후 무선 전송합니다. 30초 이상 걸릴 수 있습니다.', 'info');
try {
const r = await fetch('/api/arduino/upload', {
method: 'POST', credentials: 'include',
@@ -6590,9 +6589,28 @@ async function _cupUploadOTA(target) {
port: target.ip, otaPassword: target.password,
}),
});
const data = await r.json();
if (data.error) { cupLog('❌ ' + data.error, 'err'); _cupSetStatus('error', 'OTA 실패'); }
else { cupLog(data.output || '완료', 'ok'); _cupSetStatus('done', '완료 ✓'); }
// The server streams phase lines as it goes; a silent bar for half a minute is
// indistinguishable from a hang, which is what this replaces.
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = '', ok = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('__DONE__')) { ok = line.includes('ok'); continue; }
if (!line.trim()) continue;
const pct = line.match(/^\s*(\d+)%\s*$/);
if (pct) _cupSetProgress(Number(pct[1]));
cupLog(line, line.startsWith('❌') ? 'err' : line.startsWith('✅') ? 'ok' : 'info');
}
}
if (buf.trim() && !buf.startsWith('__DONE__')) cupLog(buf, 'info');
if (ok === false) _cupSetStatus('error', 'OTA 실패');
else if (ok === true) _cupSetStatus('done', '완료 ✓');
else { cupLog('⚠ 응답이 중간에 끊겼습니다', 'warn'); _cupSetStatus('error', 'OTA 중단'); }
} catch (e) {
cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', 'OTA 실패');
} finally {