fix(torrent): handle WebTorrent error events (bad magnet/torrent no longer crashes service) + install-lxc restarts on upgrade + npm-ci lock-hash guard (ref #917)

- engine: client.on('error') + torrent.on('error',reject) — invalid input rejects cleanly (was: unhandled 'error'→process crash, exposed by /add-file with bad bytes)
- install-lxc: 'systemctl enable --now' does NOT restart a running unit → in-place upgrade kept old code; add explicit restart
- install-lxc: skip the ~10min npm ci native rebuild when package-lock hash unchanged
- 28 node tests (+error-path)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-28 17:27:08 +02:00
parent 5ec54ab52b
commit 4c87101e43
4 changed files with 41 additions and 5 deletions

View File

@ -10,6 +10,10 @@ export class Engine {
this.webrtc = webrtc;
// webrtc=false (spike fallback) tells WebTorrent to skip the WebRTC transport.
this.client = new WebTorrentCtor(webrtc ? {} : { tracker: { wrtc: false } });
// A bad magnet/URL/.torrent (or a peer/tracker fault) makes WebTorrent
// emit 'error' on the CLIENT — unhandled it crashes the whole process.
// Swallow it here (per-add errors are surfaced by add()'s own handler).
if (typeof this.client.on === 'function') this.client.on('error', () => {});
}
// torrentId is anything WebTorrent's add() accepts: a magnet URI, an http(s)
// URL to a .torrent file (fetched by WebTorrent), a .torrent Buffer, or an
@ -32,6 +36,10 @@ export class Engine {
files: t.files.map((f, i) => ({ idx: i, name: f.name, length: f.length, type: ext(f.name) })),
});
};
t.on('error', (err) => {
clearTimeout(to);
reject(err instanceof Error ? err : new Error(String(err || 'torrent error')));
});
t.on('metadata', done);
if (t.files && t.files.length) done();
});

View File

@ -42,3 +42,11 @@ test('remove frees capacity for new add', async () => {
const r2 = await eng.add('magnet:?xt=urn:btih:' + 'b'.repeat(40));
assert.equal(r2.infohash, 'b'.repeat(40));
});
test('add rejects (never hangs/crashes) when the torrent emits error', async () => {
const client = new FakeWebTorrent();
const eng = new Engine({ WebTorrentCtor: function () { return client; },
downloadDir: '/tmp', maxActive: 5, webrtc: true });
client._next = new FakeTorrent('e'.repeat(40), 'Bad', [], { emitError: true });
await assert.rejects(() => eng.add('magnet:?xt=urn:btih:bad'), /bad torrent/);
});

View File

@ -9,15 +9,19 @@ export class FakeFile {
}
export class FakeTorrent {
constructor(infoHash, name, files, { failMeta = false } = {}) {
constructor(infoHash, name, files, { failMeta = false, emitError = false } = {}) {
this.infoHash = infoHash; this.name = name;
this.magnetURI = 'magnet:?xt=urn:btih:' + infoHash;
this.files = files.map((f, i) => Object.assign(new FakeFile(f.name, f.length), { idx: i }));
this.progress = 0.1; this.downloadSpeed = 1000; this.uploadSpeed = 500;
this.numPeers = 3; this.wires = [{ type: 'webrtc' }, { type: 'tcp' }];
this._failMeta = failMeta; this._handlers = {}; this._client = null;
this._failMeta = failMeta; this._emitError = emitError; this._handlers = {}; this._client = null;
}
on(ev, cb) {
this._handlers[ev] = cb;
if (ev === 'error' && this._emitError) queueMicrotask(() => cb(new Error('bad torrent')));
if (ev === 'metadata' && !this._failMeta && !this._emitError) queueMicrotask(cb);
}
on(ev, cb) { this._handlers[ev] = cb; if (ev === 'metadata' && !this._failMeta) queueMicrotask(cb); }
destroy(_opts, cb) {
if (this._client) {
const idx = this._client.torrents.indexOf(this);
@ -35,5 +39,6 @@ export class FakeWebTorrent {
t._client = this; this.torrents.push(t); if (cb) queueMicrotask(() => cb(t)); return t;
}
get(infohash) { return this.torrents.find(t => t.infoHash === infohash) || null; }
on() {} // real WebTorrent client is an EventEmitter; engine attaches 'error'
destroy(cb) { if (cb) cb(); }
}

View File

@ -75,7 +75,18 @@ APP_SRC="${SECUBOX_APP_SRC:-/usr/lib/secubox/torrent/app}"
la mkdir -p /opt/secubox-torrent/app /opt/secubox-torrent/www
tar -C "$APP_SRC" -cf - . | la tar -C /opt/secubox-torrent/app -xf -
tar -C /usr/share/secubox/www/torrent -cf - . | la tar -C /opt/secubox-torrent/www -xf -
la bash -lc 'cd /opt/secubox-torrent/app && npm ci --omit=dev || npm install --omit=dev'
# npm ci wipes + rebuilds node_modules, which recompiles the native deps
# (@roamhq/wrtc's node-datachannel is ~10min on arm64). Only pay that on a
# code-only upgrade when the lockfile actually changed — otherwise the
# already-built node_modules is fine and we just reload the JS.
LOCK_HASH="$(sha256sum "$APP_SRC/package-lock.json" 2>/dev/null | awk '{print $1}')"
if [ -n "$LOCK_HASH" ] && [ "$LOCK_HASH" = "$(cat "$STATE_DIR/.npm-lock-hash" 2>/dev/null)" ] \
&& la test -f /opt/secubox-torrent/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node; then
log "deps unchanged (lockfile hash match) — skipping npm ci"
else
la bash -lc 'cd /opt/secubox-torrent/app && npm ci --omit=dev || npm install --omit=dev'
[ -n "$LOCK_HASH" ] && printf '%s\n' "$LOCK_HASH" > "$STATE_DIR/.npm-lock-hash"
fi
# env file from host TOML values (exported by postinst into these vars)
la bash -c "cat > /opt/secubox-torrent/torrent.env <<EOF
TORRENT_DOWNLOAD_DIR=/data/torrent
@ -89,5 +100,9 @@ EOF"
# /usr/lib/secubox/torrent/ dir so $(dirname "$0") resolves on the board)
tar -C "$(dirname "$0")" -cf - secubox-torrent.service | la tar -C /etc/systemd/system -xf -
la systemctl daemon-reload
la systemctl enable --now secubox-torrent.service
la systemctl enable secubox-torrent.service
# `enable --now` does NOT restart an already-running unit — so an in-place app
# upgrade (re-copied code above) would keep serving the OLD code. Restart so
# the new code is actually loaded (start-if-stopped is covered by restart too).
la systemctl restart secubox-torrent.service
log "torrent LXC app deployed + started on $LXC_IP:8090"