El 6.6 de la unidad 9 del SDD 26. Una navegación de PRIMER NIVEL a un medio
(`Content-Type: video/*` o `audio/*`) se cancela y la URL se la lleva `mpv`, que ya viaja en las
cuatro imágenes de escritorio: esto no agrega ni una receta, es cablear lo que ya estaba.
MEDIO http://…/audio.wav
CANCELADA la navegación http://…/audio.wav
ABIERTO /usr/bin/mpv pid=266 http://…/audio.wav
mpv: AO: [null] 8000Hz mono 1ch u8 ← su propio log: DECODIFICÓ, no sólo arrancó
LA REGLA ES ESTRECHA A PROPÓSITO: sólo el primer nivel. Un `<video>` embebido es parte de la página y
sacarlo de ahí rompería el sitio que lo puso. Las reglas anchas en el camino de cada petición son las
que terminan rompiendo la web de alguien.
FAIL-OPEN, y es lo que prueba el control negativo: cancelar es SÍNCRONO y la respuesta del host llega
después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo con el
puerto vivo y, si el host contesta que no pudo, la URL VUELVE al navegador con una marca para no
entrar en bucle:
SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es justo el que se consigue si
uno confía en que salió bien.
⚠ EL BUG QUE ESTE GUARDIÁN DESTAPÓ VALE MÁS QUE LA FUNCIÓN, y estaba DESPUÉS del éxito aparente: la
primera corrida detectó el medio, canceló y recibió el pid… y después el puerto murió con «Native
application tried to send a message of 546281442 bytes». **Un hijo hereda los descriptores del padre,
y los del padre SON la tubería de native messaging**: `mpv` escribía su salida ahí y el navegador la
leía como un marco. Arreglado en tawasuyu (`2e99d216`, pineado acá) con tres redirecciones, cada una
contra un fallo distinto — `stdin` a null porque mpv LEE stdin y se comería los mensajes del
navegador; `stdout` al stderr del host y no a `/dev/null`, porque apagarlo arreglaría el bug y se
llevaría puesto el diagnóstico; `stderr` heredado por lo mismo.
Y dos cosas del ARNÉS, las dos ya vistas antes en esta misma sesión:
· la URL NO se pasa por la línea de comandos: una petición del arranque compite con la
inicialización de la extensión (la misma carrera del guardián de `sct`). El guardián navega a una
página que redirige a los 3 s, que además es lo que hace una persona: seguir un enlace;
· la evidencia de que el reproductor CORRIÓ se le pide a su propio `log-file`, porque su salida ya no
va al stdout del host y Gecko no vuelca el stderr del host al del navegador. Sin eso sólo se sabría
que hubo un `spawn`.
Del §6 quedan el foco (6.5), la IA local (6.7) —que además es la que traería el motor que le falta al
archivo del §6.3— y el torrent (6.9). Medido de paso: **el corpus no tiene ninguna receta de LLM ni de
embeddings**, así que 6.7 hoy no se puede pagar.
Medido sobre `atuq b3:a7080058` y `puriy-costura b3:060314e6`.
251 lines
12 KiB
Python
Executable File
251 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""¿Un medio se abre en el reproductor de la distro y no en una pestaña? (SDD 26 §6.6)
|
|
|
|
python3 scripts/test-atuq-medios.py # el vídeo sale por mpv
|
|
python3 scripts/test-atuq-medios.py --negative-control # SIN mpv: la navegación vuelve al navegador
|
|
|
|
── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
|
|
Un servidor HTTP sirve un audio real (`audio/wav`); el navegador navega a él; la extensión ve por la
|
|
cabecera que es un medio, **cancela la navegación** y le pasa la URL al host, que lanza el reproductor
|
|
de la distro. Se comprueban las tres cosas: que la extensión lo detectó y canceló, que el host
|
|
contestó con el **pid** del proceso, y que **el reproductor de verdad abrió esa URL** — su propia
|
|
salida aparece en el log, porque hereda la del navegador.
|
|
|
|
── EL CONTROL NEGATIVO PRUEBA LA PROMESA QUE MÁS IMPORTA ─────────────────────────────────────
|
|
`--negative-control` borra `/usr/bin/mpv` dentro de la jaula y exige que la navegación **vuelva al
|
|
navegador**. La decisión de cancelar es síncrona y la respuesta del host llega después, así que el
|
|
único modo de fallo inaceptable es quedarse sin vídeo Y sin pestaña. Sin este control, una extensión
|
|
que cancelara siempre se vería idéntica a una que funciona — hasta el día que falte el reproductor.
|
|
|
|
REQUISITOS: el rootfs de un escritorio (el mínimo de `atuq-nested.sh` NO trae `mpv`), y `atuq` +
|
|
`puriy-costura` sellados.
|
|
"""
|
|
import http.server
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
|
|
ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
|
|
HAMMER = os.path.join(ROOT, "target/release/takana")
|
|
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
|
|
MPV = "/usr/bin/mpv"
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"✗ {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def artefacto(receta, nombre, escotilla=None):
|
|
forzado = os.environ.get(escotilla) if escotilla else None
|
|
if forzado:
|
|
if not os.path.isdir(forzado):
|
|
fatal(f"{escotilla}={forzado} no existe")
|
|
print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
|
|
return forzado
|
|
r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
|
|
cwd=ROOT, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
|
|
h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
|
|
d = os.path.join(STORE, f"{h}-{nombre}")
|
|
if not os.path.isdir(d):
|
|
fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
|
|
return d
|
|
|
|
|
|
def wav(segundos=0.3, hz=8000):
|
|
"""Un WAV real, hecho a mano: sin esto habría que traer un binario de prueba al
|
|
repo, y un fichero opaco en un test es una dependencia que nadie revisa."""
|
|
n = int(segundos * hz)
|
|
datos = b"\x00" * n
|
|
cab = (b"RIFF" + (36 + len(datos)).to_bytes(4, "little") + b"WAVEfmt "
|
|
+ (16).to_bytes(4, "little") + (1).to_bytes(2, "little") + (1).to_bytes(2, "little")
|
|
+ hz.to_bytes(4, "little") + hz.to_bytes(4, "little")
|
|
+ (1).to_bytes(2, "little") + (8).to_bytes(2, "little")
|
|
+ b"data" + len(datos).to_bytes(4, "little"))
|
|
return cab + datos
|
|
|
|
|
|
AUDIO = wav()
|
|
|
|
|
|
class Servidor(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/audio.wav"):
|
|
datos, tipo = AUDIO, "audio/wav"
|
|
elif self.path.startswith("/ir.html"):
|
|
# ⚠ NO se navega al medio desde la línea de comandos, y la razón está
|
|
# medida: una URL en el argumento se pide en el ARRANQUE y compite con la
|
|
# inicialización de la extensión — la misma carrera que ya apareció en el
|
|
# guardián de `sct`, sólo que acá hay una sola carga y no hay segunda
|
|
# oportunidad. Esta página espera tres segundos y recién entonces va al
|
|
# medio, que además es lo que hace una persona: seguir un enlace.
|
|
datos = (b'<!doctype html><html><head><meta charset="utf-8">'
|
|
b'<meta http-equiv="refresh" content="3;url=/audio.wav">'
|
|
b"</head><body>yendo al medio</body></html>")
|
|
tipo = "text/html; charset=utf-8"
|
|
else:
|
|
datos, tipo = b"<html><body>no soy un medio</body></html>", "text/html"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", tipo)
|
|
self.send_header("Content-Length", str(len(datos)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(datos)
|
|
|
|
|
|
def puerto_libre():
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
p = s.getsockname()[1]
|
|
s.close()
|
|
return p
|
|
|
|
|
|
def main():
|
|
negativo = "--negative-control" in sys.argv[1:]
|
|
if negativo:
|
|
print("== MODO CONTROL NEGATIVO: sin mpv; la navegación tiene que VOLVER al navegador")
|
|
if not os.path.isdir(RFS):
|
|
fatal(f"no está el rootfs en {RFS} — hidratá un perfil de escritorio (trae `mpv`)")
|
|
if not os.path.isfile(os.path.join(RFS, MPV.lstrip("/"))):
|
|
fatal(f"{RFS} no trae {MPV}: este guardián necesita el reproductor de la imagen")
|
|
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
|
|
costura = artefacto("puriy-costura", "puriy-costura")
|
|
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
|
|
|
|
puerto = puerto_libre()
|
|
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
|
|
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
|
url = f"http://127.0.0.1:{puerto}/audio.wav"
|
|
partida = f"http://127.0.0.1:{puerto}/ir.html"
|
|
|
|
tmp = tempfile.mkdtemp(prefix="atuq-medios-")
|
|
salida = os.path.join(tmp, "salida")
|
|
os.makedirs(os.path.join(salida, "perfil"))
|
|
# La configuración del reproductor la pone el TEST, no el producto: sin salida de
|
|
# vídeo ni de audio (no hay pantalla ni tarjeta en la jaula) y con un tope de
|
|
# duración para que termine solo. Meter esto en el host sería cablear el arnés
|
|
# dentro del artefacto.
|
|
conf = os.path.join(salida, "hogar", ".config", "mpv")
|
|
os.makedirs(conf)
|
|
with open(os.path.join(conf, "mpv.conf"), "w") as fh:
|
|
# `log-file` es la evidencia: la salida del reproductor ya NO va al stdout del
|
|
# host —eso era el bug que este guardián encontró, ver §6.6— y Gecko no vuelca
|
|
# el stderr del host al del navegador, así que hay que pedirle al propio
|
|
# reproductor que deje su rastro donde se pueda leer.
|
|
fh.write("vo=null\nao=null\nlength=0.2\nmsg-level=all=info\nlog-file=/salida/mpv.log\n")
|
|
try:
|
|
with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
|
|
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
|
|
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
|
|
|
|
borrar = f"rm -f {MPV}" if negativo else "true"
|
|
guion = f"""
|
|
mkdir -p /root /salida/hogar
|
|
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
|
|
export HOME=/salida/hogar
|
|
{borrar}
|
|
timeout 50 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{partida}' \
|
|
> /salida/consola.log 2>&1
|
|
exit 0
|
|
"""
|
|
subprocess.run([
|
|
"bwrap",
|
|
"--overlay-src", ALPINE, "--overlay-src", RFS,
|
|
"--overlay-src", atuq, "--overlay-src", costura,
|
|
"--tmp-overlay", "/",
|
|
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
|
|
"--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
|
|
"--bind", salida, "/salida",
|
|
"--uid", "0", "--gid", "0",
|
|
"--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
"--setenv", "XDG_RUNTIME_DIR", "/run",
|
|
"--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
|
|
"--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
|
|
"sh", "-c", guion,
|
|
], check=False)
|
|
|
|
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
|
|
lineas = [l[len("MEDIOS "):] for l in log.splitlines() if l.startswith("MEDIOS ")]
|
|
for l in lineas:
|
|
print(" " + l)
|
|
if not lineas:
|
|
destino = os.path.join(ROOT, "work", "atuq-medios.log")
|
|
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
|
open(destino, "w").write(log)
|
|
fatal(f"la extensión no dijo nada — log en {destino}")
|
|
|
|
detecto = any(l.startswith("MEDIO ") for l in lineas)
|
|
cancelo = any(l.startswith("CANCELADA ") for l in lineas)
|
|
abierto = [l for l in lineas if l.startswith("ABIERTO ")]
|
|
volvio = [l for l in lineas if l.startswith("VUELVE AL NAVEGADOR")]
|
|
sin_repro = [l for l in lineas if l.startswith("SIN REPRODUCTOR")]
|
|
|
|
if not detecto:
|
|
fatal("la extensión no reconoció el `audio/wav` como medio")
|
|
|
|
if negativo:
|
|
if abierto:
|
|
fatal(f"sin mpv dijo que abrió algo: {abierto}")
|
|
if not sin_repro:
|
|
fatal("sin mpv, el host tenía que fallar DICIÉNDOLO y la extensión decirlo")
|
|
if not volvio:
|
|
fatal("sin reproductor, la navegación NO volvió al navegador —"
|
|
" el usuario se quedaría sin vídeo y sin pestaña")
|
|
print("\n✓ control negativo: sin reproductor no se finge nada y la navegación vuelve"
|
|
" al navegador")
|
|
return 0
|
|
|
|
if not cancelo:
|
|
fatal("la extensión no canceló la navegación: el vídeo seguiría en una pestaña")
|
|
if not abierto:
|
|
fatal("el host no confirmó haber abierto el reproductor")
|
|
if MPV not in abierto[0]:
|
|
fatal(f"abrió algo que no es el reproductor de la imagen: {abierto[0]}")
|
|
if url not in abierto[0]:
|
|
fatal(f"abrió otra URL: {abierto[0]}")
|
|
if volvio:
|
|
fatal(f"volvió al navegador habiendo reproductor: {volvio}")
|
|
|
|
# Y la prueba de que el reproductor CORRIÓ de verdad, y no que hubo un `spawn`:
|
|
# su propio log. Su salida ya no va al stdout del host (eso era el bug) y Gecko
|
|
# no vuelca el stderr del host al del navegador, así que la evidencia se le pide
|
|
# al reproductor.
|
|
ruta_mpv = os.path.join(salida, "mpv.log")
|
|
if not os.path.isfile(ruta_mpv):
|
|
destino = os.path.join(ROOT, "work", "atuq-medios.log")
|
|
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
|
open(destino, "w").write(log)
|
|
fatal(f"el reproductor no dejó su log: no llegó a correr — consola en {destino}")
|
|
propio = open(ruta_mpv, errors="replace").read()
|
|
if url not in propio:
|
|
fatal(f"el reproductor corrió pero no abrió esa URL — su log dice:\n{propio[:400]}")
|
|
for l in [x for x in propio.splitlines() if "Playing" in x or "AO:" in x][:3]:
|
|
print(" mpv: " + l.strip()[:110])
|
|
|
|
print("\n✓ una navegación a un medio se CANCELA y la abre el reproductor de la distro")
|
|
print("✓ y el reproductor corrió de verdad: su propia salida lo dice, no un `spawn` a ciegas")
|
|
return 0
|
|
finally:
|
|
servidor.shutdown()
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
sys.exit(main())
|