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:
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user