diff --git a/bin/devdesk b/bin/devdesk index 872cefa..d5c066f 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -11,7 +11,7 @@ # devdesk pick interactive picker # # The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES | -# --new NAME [PROJECT] | --rename OLD NEW | --end NAME` +# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME` # through ttyd; those print JSON. set -euo pipefail @@ -25,7 +25,8 @@ usage() { # Tab-separated running sessions: name, dir, attached clients, last activity, windows. running_tsv() { dd_server_up || return 0 - dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true + # Path last: it may contain anything (tabs included) and is re-joined below. + dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true } # Tab-separated top-level project directories: slug, dir. @@ -68,9 +69,9 @@ cmd_list() { --arg root "$DEVDESK_PROJECTS_ROOT" ' def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); ($reg.sessions // {}) as $known - | [rows($run)[] | {name: .[0], dir: .[1], running: true, - attached: (.[2] | tonumber), activity: (.[3] | tonumber), - windows: (.[4] | tonumber)}] as $live + | [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true, + attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0), + windows: (.[3] | tonumber? // 0)}] as $live | ($live | map(.name)) as $liveNames | {default: $dflt, root: $root, sessions: ( @@ -82,7 +83,7 @@ cmd_list() { claude_session_id: (.value.claude_session_id // null)})) ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}), projects: ([{name: $dflt, dir: $root}] - + [rows($proj)[] | {name: .[0], dir: .[1]} + + [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))} | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' } @@ -143,19 +144,23 @@ cmd_rename() { cmd_end() { [ $# -eq 1 ] || dd_die "usage: devdesk end NAME" dd_valid_name "$1" || dd_die "invalid session name: $1" + # Forget first: killing the session SIGHUPs us when run from inside it. + dd_registry_remove "$1" + rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1" if dd_server_up; then dd_tmux kill-session -t "=$1" 2>/dev/null || true fi - dd_registry_remove "$1" } # GET $DEVDESK_GITEA_URL/api/v1; token optional (private repos only). gitea_get() { - local auth=() - if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then - auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")") - fi - curl -fsS --max-time 4 "${auth[@]}" "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null + # The token goes in via a curl config on stdin, never on the command line (ps). + { + if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then + # shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token + printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")" + fi + } | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null } # owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing. @@ -177,7 +182,9 @@ cmd_repo() { dd_valid_name "$1" || dd_die "invalid session name: $1" local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null dir=$(dd_resolve_dir "$1") - g=(git -c safe.directory='*' -C "$dir") + # --no-optional-locks: never take .git/index.lock (Claude's own git would collide). + # No safe.directory override: repos owned by other users stay untrusted. + g=(git --no-optional-locks -C "$dir") if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}' return @@ -189,7 +196,7 @@ cmd_repo() { upstream=true read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD') fi - remote=$("${g[@]}" remote get-url origin 2>/dev/null || true) + remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true) [ -n "$remote" ] && slug=$(gitea_slug "$remote") if [ -n "$slug" ] && [ -n "$sha" ]; then ci=$(gitea_get "/repos/$slug/commits/$sha/status" | @@ -216,11 +223,13 @@ cmd_alerts() { jq -cMn '{configured: false, alerts: []}' return fi - local auth=() raw - if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then - auth=(-H "Authorization: Bearer $(tr -d '[:space:]' <"$DEVDESK_GRAFANA_TOKEN_FILE")") - fi - if ! raw=$(curl -fsS --max-time 6 "${auth[@]}" "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then + local raw + if ! raw=$({ + if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then + # shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token + printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")" + fi + } | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}' return fi @@ -255,7 +264,10 @@ cmd_upload() { dir="$DEVDESK_STATE_DIR/uploads/$session" mkdir -p "$dir" find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true - dest="$dir/$(date +%Y%m%d-%H%M%S)-$name" + # Unique even for same-second uploads of the same name. + dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX") + # A dropped connection SIGHUPs us mid-transfer: never leave a partial file. + trap 'rm -f "$dest"' HUP INT TERM # shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose b64len=$(((size + 2) / 3 * 4)) stty raw -echo 2>/dev/null || true @@ -267,6 +279,8 @@ cmd_upload() { rm -f "$dest" dd_die "upload incomplete ($actual of $size bytes)" fi + trap - HUP INT TERM + chmod 0644 "$dest" jq -cMn --arg p "$dest" '{ok: true, path: $p}' } @@ -305,6 +319,15 @@ cmd_attach() { ok_json return ;; + --existing) + # Reconnects: never re-create a session that was ended or renamed meanwhile. + shift + [ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name" + dd_valid_name "$1" || dd_die "invalid session name: $1" + if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then + dd_die "session '$1' no longer exists (it was ended or renamed)" + fi + ;; -*) dd_die "unknown option: $1" ;; esac [ $# -eq 1 ] || dd_die "attach takes exactly one session name" diff --git a/libexec/devdesk-serve b/libexec/devdesk-serve index c25d250..3a2fbb1 100755 --- a/libexec/devdesk-serve +++ b/libexec/devdesk-serve @@ -13,7 +13,7 @@ args=( --writable # ttyd >= 1.7.4 is read-only by default --url-arg # ?arg= becomes `devdesk attach ` --check-origin # refuse cross-site WebSocket connections - --max-clients "${DEVDESK_MAX_CLIENTS:-8}" + --max-clients "${DEVDESK_MAX_CLIENTS:-32}" --credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header --index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}" --debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential diff --git a/share/tmux.conf b/share/tmux.conf index d3e798b..149a206 100644 --- a/share/tmux.conf +++ b/share/tmux.conf @@ -6,10 +6,10 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat set -s focus-events on set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard set -s default-terminal "tmux-256color" -set -as terminal-features ",xterm-256color:RGB" +set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append set -g history-limit 50000 set -g mouse on # wheel scrolls history; Shift+drag selects natively -set -g allow-passthrough on +set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing) set -g renumber-windows on # --- bells reach the browser (Claude's terminal_bell notifications) ---------- @@ -29,7 +29,7 @@ set -g status-position bottom set -g status-style "bg=#060c14,fg=#7fa3bf" set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ " set -g status-left-length 40 -set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M " +set -g status-right "" set -g window-status-format " #I:#W " set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W " set -g window-status-bell-style "fg=#FFB300,bold" diff --git a/tests/attach.bats b/tests/attach.bats index 78d7b0b..d962a39 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -295,6 +295,58 @@ claude_args() { [ "$status" -ne 0 ] } +@test "end run from inside the session leaves no ghost registry entry (#3)" { + start_server + devdesk new alpha + tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter + for _ in $(seq 1 50); do + tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break + sleep 0.1 + done + run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha + [ "$status" -ne 0 ] + [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] +} + +@test "attach --existing refuses sessions that were ended or renamed (#4)" { + start_server + run devdesk attach --existing ghost + [ "$status" -ne 0 ] + [[ $output == *"no longer exists"* ]] + run devdesk attach --existing ../x + [ "$status" -ne 0 ] +} + +@test "same-second uploads of the same name get distinct files (#6)" { + printf 'one' | base64 -w0 >"$TMP/a.b64" + printf 'two' | base64 -w0 >"$TMP/b.b64" + p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path) + p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path) + [ "$p1" != "$p2" ] + [ "$(cat "$p1")" = one ] + [ "$(cat "$p2")" = two ] + [[ $p1 == *-image.png ]] +} + +@test "a tab in a project path does not break --list (#10)" { + mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name" + start_server + run devdesk attach --list + [ "$status" -eq 0 ] + jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null +} + +@test "repo hides credentials embedded in the remote URL (#11)" { + export DEVDESK_GITEA_URL=http://127.0.0.1:9 + repo="$DEVDESK_PROJECTS_ROOT/alpha" + git -C "$repo" init -q -b main + git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git + run devdesk repo alpha + [ "$status" -eq 0 ] + [ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ] + [[ $output != *hunter2* ]] +} + @test "web actions run through attach and answer with JSON" { start_server run devdesk attach --new web1 alpha diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs index 1d049db..d88d126 100644 --- a/tests/protocol.test.mjs +++ b/tests/protocol.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize, - parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl, + parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl, } from '../web/src/protocol.js'; const text = (bytes) => new TextDecoder().decode(bytes); @@ -80,3 +80,9 @@ test('flow control pauses after highWater pending writes and resumes below lowWa assert.equal(flow.release(), null); // 1 assert.equal(flow.release(), Client.RESUME); // 0 }); + +test('untrusted text loses control characters before it is typed (#2)', () => { + assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred '); + assert.equal(pasteSafe('plain text'), 'plain text'); + assert.equal(pasteSafe(null), ''); +}); diff --git a/web/src/connection.js b/web/src/connection.js index f9a1c7a..3998a9a 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -94,9 +94,28 @@ export class TtydConnection { } } +// Side-channel commands each occupy a ttyd client slot and a server process: +// run at most two at a time (devdesk#8). +let active = 0; +const waiting = []; +async function limited(task) { + if (active >= 2) await new Promise((resolve) => waiting.push(resolve)); + active += 1; + try { + return await task(); + } finally { + active -= 1; + waiting.shift()?.(); + } +} + // Run a short-lived `devdesk attach ` and return its output as text. // ttyd is the only server-side endpoint, so session actions travel this way. -export async function runCommand(args, timeoutMs = 8000) { +export function runCommand(args, timeoutMs = 8000) { + return limited(() => runCommandNow(args, timeoutMs)); +} + +async function runCommandNow(args, timeoutMs) { const token = await fetchToken(); return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl(window.location, args), ['tty']); @@ -139,31 +158,47 @@ function toBase64(bytes) { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +export const UPLOAD_MAX = 25 * 1024 * 1024; + // Upload a file into the session's upload folder through ttyd: wait for the -// server's ready line, stream the file as base64 input, read back {ok, path}. -export async function uploadFile(session, file, onProgress) { +// server's ready line, stream the file as base64 input in slices (never the +// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity +// rather than a fixed total, so slow links still work (devdesk#9). +export function uploadFile(session, file, onProgress) { + if (!file.size) return Promise.reject(new Error('file is empty')); + if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB')); + return limited(() => uploadNow(session, file, onProgress)); +} + +async function uploadNow(session, file, onProgress) { const token = await fetchToken(); - const bytes = new Uint8Array(await file.arrayBuffer()); - const b64 = toBase64(bytes); const name = (file.name || 'pasted-image.png').slice(-100); + const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly return new Promise((resolve, reject) => { - const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(bytes.length)]), ['tty']); + const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']); socket.binaryType = 'arraybuffer'; const decoder = new TextDecoder(); let output = ''; let sending = false; - const timer = setTimeout(() => socket.close(), 30000 + bytes.length / 50); + let idle = 0; + const touch = () => { + clearTimeout(idle); + idle = setTimeout(() => socket.close(), 20000); + }; const send = async () => { - const chunk = 48 * 1024; - for (let i = 0; i < b64.length; i += chunk) { + for (let offset = 0; offset < file.size; offset += slice) { while (socket.bufferedAmount > 1 << 20) await sleep(20); if (socket.readyState !== WebSocket.OPEN) return; - socket.send(encodeInput(b64.slice(i, i + chunk))); - onProgress?.(Math.min(1, (i + chunk) / b64.length)); + const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer()); + socket.send(encodeInput(toBase64(bytes))); + touch(); + onProgress?.(Math.min(1, (offset + slice) / file.size)); } }; + touch(); socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onmessage = (event) => { + touch(); const { type, payload } = decodeServer(event.data); if (type !== Server.OUTPUT) return; output += decoder.decode(payload, { stream: true }); @@ -174,7 +209,7 @@ export async function uploadFile(session, file, onProgress) { }; socket.onerror = () => {}; socket.onclose = () => { - clearTimeout(timer); + clearTimeout(idle); const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, '')); try { const result = parseListOutput(text); diff --git a/web/src/main.js b/web/src/main.js index 55acfd6..f17332d 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -3,7 +3,7 @@ import { createTerminal, loadFonts } from './term.js'; import { TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError, } from './connection.js'; -import { parseTitle, sessionFromSearch } from './protocol.js'; +import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js'; import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js'; import { renderAlerts } from './alerts.js'; import { renderContext } from './context.js'; @@ -246,7 +246,9 @@ function updateUrl(session) { // ---------------------------------------------------------------- connection -function connect(session) { +// reconnect: automatic re-attach after a drop. It must not re-create a session +// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4). +function connect(session, { reconnect = false } = {}) { clearTimeout(state.retryTimer); state.conn?.dispose(); state.session = session; @@ -260,7 +262,7 @@ function connect(session) { term.reset(); const conn = new TtydConnection({ - arg: session, + arg: session && reconnect ? ['--existing', session] : session, size: () => ({ cols: term.cols, rows: term.rows }), onOpen: () => { state.openedAt = Date.now(); @@ -353,13 +355,13 @@ function scheduleReconnect() { showOverlay({ title: 'Reconnecting', text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`, - actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction], + actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction], }); - state.retryTimer = setTimeout(() => connect(state.session), delay); + state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay); } function retrySoon() { - if (state.retryTimer && !state.conn?.isOpen) connect(state.session); + if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true }); } // ---------------------------------------------------------------- titles & bell @@ -474,6 +476,10 @@ function wireChrome() { setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts(); }, 30 * 1000); + // Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8). + setInterval(() => { + if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo(); + }, 60 * 1000); ui.attention.addEventListener('click', () => { if (ui.attention.dataset.target) connect(ui.attention.dataset.target); }); @@ -500,6 +506,7 @@ async function boot() { term.onBell(() => { notify.ring(state.session, ui.paneTitle.textContent); refreshSoon(); // Claude finished a turn: context numbers just changed + setTimeout(refreshRepo, 2000); // …and maybe committed or pushed }); let fitFrame = 0; @@ -537,14 +544,13 @@ async function boot() { ui.sessionChip.textContent = to; sessionsPanel.setCurrent(to); }, - onData: () => { - showContext(); - refreshRepo(); - }, + onData: () => showContext(), }, }); bindAskClaude($('sidebar'), (text) => { - state.conn?.sendInput(text); + // Alert text is untrusted: no control characters (a newline would run it in a + // shell window, CR would submit), and bracketed paste around it (devdesk#2). + term.paste(pasteSafe(text)); term.focus(); }); wireChrome(); diff --git a/web/src/protocol.js b/web/src/protocol.js index b477481..e364267 100644 --- a/web/src/protocol.js +++ b/web/src/protocol.js @@ -67,6 +67,14 @@ export function slugify(text) { .replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64); } +// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop +// every control character so it can't run a command (newline in a shell), +// submit early (CR) or inject escape sequences (devdesk#2). +export function pasteSafe(text) { + // eslint-disable-next-line no-control-regex + return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' '); +} + export function stripAnsi(text) { // eslint-disable-next-line no-control-regex return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, ''); diff --git a/web/src/term.js b/web/src/term.js index d541ba9..23688a9 100644 --- a/web/src/term.js +++ b/web/src/term.js @@ -3,7 +3,17 @@ import { FitAddon } from '@xterm/addon-fit'; import { WebglAddon } from '@xterm/addon-webgl'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { Unicode11Addon } from '@xterm/addon-unicode11'; -import { ClipboardAddon } from '@xterm/addon-clipboard'; +import { Base64, ClipboardAddon } from '@xterm/addon-clipboard'; + +// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard +// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7). +const writeOnlyClipboard = { + readText: () => '', + writeText: (selection, text) => { + if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {}); + return undefined; + }, +}; // LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette. export const THEME = Object.freeze({ @@ -62,7 +72,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) { term.loadAddon(fit); term.loadAddon(new Unicode11Addon()); term.unicode.activeVersion = '11'; - term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude + term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard)); term.loadAddon(new WebLinksAddon((event, uri) => { event.preventDefault(); window.open(uri, '_blank', 'noopener'); @@ -90,6 +100,8 @@ export function createTerminal(container, { fontSize, onShiftEnter }) { term.open(container); + // Touch devices: the DOM renderer is more reliable at high DPR than WebGL. + if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit }; try { const webgl = new WebglAddon(); webgl.onContextLoss(() => webgl.dispose());