El propósito de un sistema depende de quién lo observa: Aaron Patterson sobre la regla «as-if», Ractors, ZJIT y la IA
Ruby on RailsEn la keynote de clausura de Rails World 2026, Aaron Patterson (Tenderlove), miembro de los equipos centrales de Ruby y Rails y parte del equipo de infraestructura de Ruby on Rails en Shopify, planteó una pregunta concreta: ¿qué puede cambiar una optimización en un programa sin traicionar a quien lo usa? Su respuesta parte de la «regla as-if» de los compiladores. Una optimización puede alterar cualquier cosa mientras nadie pueda observar la diferencia. Con esa idea recorrió los cambios que los Ractors imponen a patrones habituales, los marcos de pila que Ruby 4.0 elimina en silencio y la manera en que ZJIT suprime asignaciones de memoria. En la segunda parte, que presentó como «una segunda charla dentro de la misma», contó cómo unos agentes de IA encadenaron un fallo de caché de RubyGems.org con rubydoc.info. Su conclusión: el compilador nos prometió la regla «as-if», pero la IA no.
Patterson abrió con bromas que ya anunciaban el cierre. Presentó una supuesta distribución de Linux propia, «AaronXP», que «se instala en 100 milisegundos» y que dijo haber programado «según sus vibraciones». Comentó que le parecía irónico bromear sobre la crisis financiera y a la vez animar a todo el mundo a generar montones de código en Rust sin leerlo, y ofreció en broma «seguros contra la deuda técnica». También aseguró que la IA nos convertiría en «programadores 1.000 veces mejores» y luego se corrigió con un falso error tipográfico: «1.000 exprogramadores». Dejó claro, eso sí, que a él también le encanta usar la IA.
Mike Dalessio se une al equipo central de Rails
Antes de entrar en materia, Patterson hizo un anuncio. Mike Dalessio, presente entre el público, es el miembro más reciente del equipo central de Rails. Contó que se conocieron hace más de 20 años, cuando Dalessio le envió una contribución de código abierto para Mechanize, una biblioteca de Ruby de aquella época. Desde entonces han colaborado en muchos proyectos, como Nokogiri y la gema SQLite3, y durante un tiempo trabajaron juntos en Shopify, donde Dalessio fue su jefe. Crearon una organización de GitHub llamada Sparkle Motion. Según Patterson, él es «Sparkle», el que sale al escenario haciendo gestos de jazz, y Dalessio es «Motion», el que trabaja de verdad. Destacó además la actividad de Dalessio en el equipo de seguridad de Rails.
Patterson anunció después los temas de la charla, que son los mismos en los que ha trabajado este año: rendimiento, Ractors, ZJIT e IA. Mencionó también, sin desarrollarlo, que este año escribió un asignador de registros para ZJIT. Lo describió como el proyecto más difícil en el que ha trabajado nunca y del que se siente muy orgulloso.
Matizar «el propósito de un sistema es lo que hace»
El hilo conductor es la frase «el propósito de un sistema es lo que hace». Patterson admitió que no le gusta porque le parece ambigua. Cuando intenta aplicarla, la pregunta se convierte en «¿qué hace este sistema?», y eso no siempre tiene una respuesta clara.
Lo ilustró con un experimento mental. Si viajara al pasado con su «indestructible» Nokia 3310 y se lo diera a alguien que nunca hubiera visto un móvil, esa persona quizá lo usaría como martillo. Tomada al pie de la letra, la cita diría que el propósito del aparato es servir de martillo. Para Patterson, en cambio, es un teléfono. Lo que hace un sistema depende de quién lo observa. Por eso propuso su propia versión: el propósito de un sistema es lo que hace para quienes lo observan.
La regla «as-if»
Patterson explicó que esta idea de centrarse en el observador es bien conocida entre quienes implementan lenguajes de programación, bajo el nombre de «regla as-if» o «regla del como si». Está codificada en C++. El compilador puede aplicar cualquier transformación optimizadora siempre que no altere el comportamiento observable del programa tal como lo especifica el estándar. Dicho de otro modo, el compilador puede hacer lo que quiera con el código mientras este se ejecute «como si» fuera tal y como se escribió.
Señaló un detalle incómodo: la definición habla de «lo que especifica el estándar», y Ruby prácticamente no tiene un estándar comparable al de C++. Aun así, propuso analizar las optimizaciones desde el comportamiento observable. Según él, toda optimización tiene dos caras: lo que elimina y quién observa aquello que se elimina.
Memoización: una suposición oculta que los Ractors dejan al descubierto
El primer ejemplo es un patrón que, según Patterson, casi todo el mundo ha escrito: la memoización. Una función costosa se calcula una sola vez y el resultado se guarda en una variable de instancia. El problema es que el código incorpora una suposición implícita: que solo un hilo lo observa a la vez. En realidad hay una condición de carrera que podría hacer que el cálculo costoso se ejecute varias veces. No la vemos porque Ruby tiene el GVL.
Los Ractors cambian eso. Permiten paralelismo real en Ruby, así que cambian los observadores del código: dos ejecuciones pueden recorrer la misma ruta al mismo tiempo. Patterson considera una suerte que, en este caso, los Ractors lancen una excepción en vez de dejar que la condición de carrera corrompa algo en silencio. Así el desarrollador tiene la oportunidad de corregirlo. Remitió a la charla de Andrew, que trató estos temas con más detalle.
Insistió en que no quería asustar a nadie ni sugerir que el patrón esté prohibido. Mostró dos casos. En el primero, varios Ractors acceden a una variable de instancia memoizada de la clase Foo, y el Ractor R1 lanza una excepción avisando de que eso debe hacerse de forma segura para hilos. En el segundo, el patrón es idéntico pero solo hay un observador de esa ruta de código, así que no hay problema. Basta con asegurarse de que el acceso quede limitado a un Ractor concreto.
Herramientas para convivir con los Ractors
Como las variables de instancia a nivel de clase son habituales en muchas bases de código, Patterson anunció que la próxima versión de Rails traerá una forma de mitigar el problema: el módulo ActiveSupport Ractors. Incluye un método on_main que ejecuta cierto código en el Ractor principal. Así, un cálculo costoso cuyo resultado deba guardarse en una variable de instancia de clase puede hacerse allí.
Advirtió del coste, que Andrew ya había explicado. Si se desvían muchísimas operaciones al Ractor principal, este se convierte en el cuello de botella de la aplicación y la latencia aumenta porque todos compiten por él. Por eso recomendó, siempre que sea posible, la gema de estructuras de datos compartibles entre Ractors escrita por Koichi Sasada, autor principal de Ractor. Primero dijo que «esperaba» que funcionara bien y luego se corrigió: funcionará.
De esa gema destacó dos estructuras que resuelven el problema de la memoización. La elección depende del rendimiento que se busque:
- Variables de bloqueo: garantizan que un cálculo se realice una sola vez. Sirven para cálculos que no son idempotentes.
- TVars: permiten que el cálculo se haga una o unas pocas veces. Sirven para cálculos idempotentes, en los que repetirlo un par de veces no supone un problema.
Patterson animó a no tenerles miedo. A su juicio, por la forma en que están implementados los Ractors, usarlas no provocará interbloqueos ni problemas de seguridad entre hilos: en el peor caso se obtendrán excepciones.
Un alcance más pequeño de lo que parece
Para defender que el problema está acotado, mostró un servidor web ficticio basado en Ractors. Lee solicitudes de un socket en un bucle infinito y atiende cada una dentro de un Ractor. En el ciclo normal de solicitud y respuesta, la mayoría de los objetos se asignan dentro del método serve. Solo hay que preocuparse cuando se toca algo externo, como constantes o valores de configuración. Patterson cree que esos puntos de modificación son muchos menos de lo que solemos pensar.
Recolección de basura local por Ractor en Ruby 4.1
El siguiente tema fue la observabilidad de los datos y su efecto en el rendimiento. Hoy el recolector de basura de Ruby es un singleton: cada vez que un Ractor necesita un objeto, se lo pide a ese único GC. Con varios Ractors en paralelo que asignan muchos objetos, todos acaban esperando, y eso es un cuello de botella evidente. Ruby 4.1 incluirá un recolector de basura local para cada Ractor, con montones separados, lo que eliminará ese cuello de botella.
Patterson apuntó una posibilidad adicional. En el servidor ficticio se podría crear un Ractor por solicitud y, al terminar, liberar su montón entero, lo que en teoría permitiría una recolección más eficiente. Pero la historia no es tan simple. En un ejemplo de productor y consumidor, el productor asigna un objeto en su propio montón y se lo pasa al consumidor. Ambos apuntan exactamente al mismo objeto, con el mismo identificador. Por tanto, que un Ractor termine no significa que su montón pueda descartarse, porque otros Ractors pueden estar apuntando a él. La solución es un GC global, que Patterson comparó con una recolección «major», aunque se espera que ocurra con menos frecuencia.
En ese ejemplo el objeto se comparte a propósito. Patterson mostró otro en el que ocurre de forma implícita, y que ya había presentado en Rails World el año anterior. Al analizar dos documentos JSON, las claves resultantes son el mismo objeto, con el mismo ID. Si el análisis se reparte entre varios Ractors, la cadena "hello" sigue teniendo el mismo ID en todos: las claves se deduplican entre ellos. Por debajo, la cadena se asigna en el montón del Ractor principal y los demás apuntan a él. Según Patterson, esto posiblemente afecte al rendimiento del recolector sin que el programador lo sepa, porque sucede de manera implícita.
Backtraces que crecen: los callbacks de ActiveSupport
Tras las optimizaciones que hacen los desarrolladores y el nuevo observador que introducen los Ractors, Patterson pasó a las optimizaciones del framework y del lenguaje, en concreto a las inconsistencias en los backtraces.
Usó los callbacks de ActiveSupport con un ActiveRecord ficticio. El módulo se incluye en una clase y, al llamar a save, se ejecuta un callback de save. El código imprime la pila de llamadas desde dentro de save. Lo esperable son tres marcos: el bloque que ejecuta los callbacks, el método save y main. Sin callbacks, se ven tres marcos. Con un callback before, siguen siendo tres. Pero al definir un callback around, el backtrace «se dispara», aunque el código dentro de save no ha cambiado.
Investigando, encontró en ActiveSupport un comentario que leyó en voz alta. Como ese método se usa en muchos sitios y a menudo envuelve grandes porciones de código de usuario, tiene un objetivo de diseño adicional: minimizar su impacto en la pila de llamadas visible. Una excepción dentro de un callback before o after puede ser tan fea como haga falta, pero cuando el control pasa limpiamente al bloque proporcionado, se quiere dejar el menor rastro posible. Para Patterson, eso es exactamente la regla «as-if». Y el caso del callback around le parece aceptable, porque el código de la aplicación está haciendo más trabajo y es lógico que haya más pila.
El marco de new que desaparece en Ruby 4.0
La pregunta siguiente fue si es posible obtener un backtrace más corto de lo esperado. Patterson, que bromeó con que pasa los fines de semana contando marcos de pila, respondió que sí.
En su ejemplo, el método 1 llama al 2, el 2 al 3, el 3 instancia Foo y el initialize de Foo llama al 4. Lo esperable es ver initialize, new, 3, 2, 1 y main, y eso es lo que muestra Ruby 3.4. En Ruby 4.0, el marco de new ya no aparece.
Para averiguar por qué, volcó las instrucciones de la máquina virtual del método 3 en ambas versiones. Ruby 4.0 genera muchas más, porque ahora integra en línea la llamada a initialize en el punto donde se llama a new. Reescrito como pseudocódigo Ruby, el resultado dice: si el new de Foo es la implementación por defecto, se asigna el objeto, se llama directamente a initialize y se devuelve el objeto; si no lo es, se toma una ruta más lenta que llama a new. Antes, new llamaba a initialize. Ahora es el método 3 quien lo llama, y por eso falta el marco.
Lo demostró con un «monkey patch» mínimo que redefine new y se limita a llamar a super. Antes del parche, el backtrace corresponde a la ruta rápida, sin new. Después, en la ruta lenta, el marco de new reaparece.
¿Por qué tanto esfuerzo? Porque, según Patterson, en este ejemplo la asignación es un 70 % más rápida en Ruby 4.0, sin necesidad de un compilador JIT. Lo que se eliminó fue un marco de llamada, y quienes podrían notarlo son el llamante, los depuradores y cualquier cosa que inspeccione la pila. A cambio se obtiene ese 70 % de velocidad en las asignaciones, un intercambio que a él le parece bueno.
Añadió que CRuby «se toma ciertas libertades» con la pila constantemente. Como segundo ejemplo, mostró una clase Foo que implementa hash e imprime el backtrace cada vez que se invoca. Tres formas de desencadenar el cálculo del hash producían, contra lo esperado, pilas distintas, y en una de ellas faltaba un marco que las otras sí mostraban.
¿A quién le importa? Optimizaciones de bajo riesgo
Patterson preguntó al público si a alguien le importaba esto. Solo unas pocas personas levantaron la mano, y dijo alegrarse, porque si de verdad importara, este tipo de optimizaciones no podrían implementarse. Las describió como zonas grises de la regla. El compilador cambia el código, así que estrictamente la ejecución no es idéntica y se puede observar una diferencia, pero en casos donde a la gente no le importa. En Ruby, y cree que en la mayoría de los lenguajes, hay que preservar el comportamiento allí donde realmente importa. A estas las llama «optimizaciones de bajo riesgo»: cambian el comportamiento del lenguaje en aspectos que, en general, a nadie le preocupan.
ZJIT: optimizaciones de alto riesgo con red de seguridad
Después pasó a las optimizaciones de alto riesgo de ZJIT. Según Patterson, ZJIT puede arriesgarse porque es capaz de revertir una optimización si ocurre algo inesperado.
ZJIT usa dos representaciones intermedias: una de alto nivel (HIR) y otra de bajo nivel (LIR). La HIR puede inspeccionarse pasando a Ruby una opción para volcarla. Una de las suposiciones de ZJIT es que probablemente nadie redefinirá métodos. Pero si alguien lo hace, el compilador necesita saberlo para seguir comportándose correctamente.
El ejemplo asigna un punto y llama a un método que a su vez llama a otro sobre ese punto. En la HIR del bloque (compilado dentro de un 5.times) se ve que get se ha integrado en el bloque, con un marco en línea, y que la constante 42 se ha propagado hasta dentro del bloque. ZJIT simplifica así el código original hasta dejarlo en casi nada.
¿Cómo puede permitirse ese riesgo? Que falte un marco de pila quizá no importe, pero si el programa redefine get o x, sí importaría, porque dejaría de comportarse como se espera y se rompería la regla «as-if». La clave está en unas líneas de la HIR llamadas puntos de parche, una para la redefinición de get y otra para la de x. No corresponden a ningún código: en el código máquina generado no hay nada que las represente. Son marcadores que indican dónde habrá que parchear el código máquina.
Patterson mostró el código máquina antes y después de la invalidación. El marcador apunta a una instrucción de prueba. Cuando el método se redefine, el compilador sobrescribe literalmente esa instrucción con un salto hacia un código de salida. Ese código restaura el estado interno y la pila de la máquina virtual, y la ejecución continúa en el intérprete de Ruby.
La detección se apoya en CRuby. Cada vez que se redefine un método hay que invalidar las cachés de todos modos, y ese es el momento perfecto para que el JIT invalide también su código. El compilador lleva la cuenta de los métodos que ha compilado y, si alguno se redefine, invalida el código máquina asociado, tanto en YJIT como en ZJIT. La redefinición de métodos no es la única invariante vigilada. También se controlan las operaciones básicas (BOP), por ejemplo que + siga significando suma, las constantes y los tracepoints. Si se activa un tracepoint, se podrían observar cosas que el JIT ha optimizado, y eso debe corregirse para no romper la regla. Patterson dijo que hay muchas más invariantes y ofreció hablar de ellas después.
«IA» en ZJIT: interpretación abstracta para eliminar asignaciones
Con un juego de palabras, Patterson dijo que ZJIT usa mucha «IA»: interpretación abstracta. Si alguien quiere trabajar en «IA», está invitado a unirse al proyecto ZJIT. La definió como simular la ejecución del código y dejar que de esa simulación surjan las optimizaciones.
El ejemplo fue una aplicación Rack de prueba y un servidor ficticio que atiende unas 1.000 peticiones, junto con un método que cuenta los objetos asignados. Sin JIT se asignan unos 1.000 objetos, lo que cuadra con el número de peticiones. Con YJIT, más o menos los mismos. Con ZJIT, 8.
Las asignaciones vienen del array que devuelve el método call con el estado, las cabeceras y el cuerpo. ZJIT integra call dentro de serve. Patterson desglosó después ese código en pasos con variables temporales, que según él no cambian el comportamiento del programa. Ahí entra el intérprete abstracto. Crea un «montón abstracto» que registra cómo serían los objetos si el programa se ejecutara y recorre el código simulándolo. v1 recibe 200, v2 las cabeceras, v3 el cuerpo y v4 un array abstracto que contiene v1, v2 y v3, sin que importe cuáles sean esos valores. Luego se leen del array el estado, las cabeceras y el cuerpo.
Como el intérprete sabe qué valor ocupa cada posición, puede hacer una sustitución algebraica, y entonces salta a la vista que v4 ya no se usa, así que se elimina. Después sobran también las variables intermedias, y se puede ir aún más lejos integrando más funciones y aplicando el mismo algoritmo una y otra vez. Combinando integración en línea, plegado de constantes e interpretación abstracta, ZJIT transforma el código original en uno que ya no asigna esos arrays.
Patterson cerró esta parte con una pregunta al público. La eliminación de asignaciones se puede observar contando objetos, así que ¿rompe la regla «as-if»? ¿A alguien le importa? A él no: le parece estupendo reducirlas.
Segunda historia: la campaña «gem stuffer» en RubyGems.org
Con unos doce minutos por delante, Patterson anunció una segunda historia, reciente y contada desde su punto de vista: RubyGems y el incidente de OpenAI. Reconoció que no tenía previsto hablar de ello y que pasó el día anterior preparando las diapositivas.
Los días 11 y 12 de mayo, RubyGems.org empezó a recibir miles de gemas basura. Más tarde se conocería como la campaña «gem stuffer», pero entonces aún no tenía nombre. El 12 de mayo, Moshe Mansfield, que trabaja con RubyGems.org analizando los paquetes nuevos, lo hizo público en Twitter. Hablaron de un grave ataque malicioso contra RubyGems.org y anunciaron que los registros quedaban suspendidos. El 13 de mayo, Socket.dev publicó una entrada que lo llamaba «ataque gem stuffer».
Según esa entrada, las gemas contenían un script malicioso que descargaba material del Gobierno del Reino Unido, lo empaquetaba en una gema y volvía a subirla a RubyGems.org. Patterson lo leyó con extrañeza. Al instalar una gema con extensión C se ejecuta extconf, un problema de ejecución remota de código que todo el mundo conoce, pero aquí el script no estaba en un extconf. Instalar la gema no lo ejecutaría. Habría que entrar en ella y lanzarlo expresamente, y el artículo no explicaba en qué circunstancias se ejecutaba. Le pareció raro y lo dejó estar. El 16 de mayo se reabrieron los registros.
El fallo de caché en la autorización heredada
El 6 de julio, Luke Marshall, del equipo de seguridad de Truffle, notificó al equipo de RubyGems.org un problema de caché. Patterson aclaró que él forma parte del equipo del cliente de RubyGems, no del servidor, aunque ambos equipos se conocen y colaboran.
La autorización heredada se hacía mediante una petición GET. La víctima ejecutaba el comando de autenticación de gem, que enviaba autenticación básica, y el servidor respondía con su clave de API, en un formato concreto que Patterson pidió recordar. Entre RubyGems.org y los usuarios estaba Fastly, que almacenaba en caché las respuestas GET. Así, un atacante que hacía la misma petición sin cabecera de autorización podía recibir la clave cacheada de otra persona. Según Patterson, esto estuvo en producción unos seis años.
Dio varias razones por las que pasó desapercibido. En su momento no había pruebas de que se hubiera abusado de ello. Hacía falta enviar Accept-Encoding: gzip, que curl no activa por defecto aunque Net::HTTP sí. Y para un mantenedor el síntoma era confuso. Si al hacer gem push obtenía la clave de otro, solo veía un mensaje extraño de que no estaba autorizado. Volvía a iniciar sesión, todo funcionaba y lo olvidaba. Además, solo lo usaba el código antiguo de RubyGems y la caché caducaba a la hora. Tres días después del aviso, RubyGems.org desplegó una solución completa.
El correo sobre OpenAI
El 8 de septiembre, Emily, una colega de Patterson, le avisó por mensaje directo de que iba a recibir un correo de Gemma, antigua colega suya que ahora trabaja en Anthropic, sobre «un hackeo muy grave de RubyGems». El aviso llegó por mensaje directo porque, según Emily, a Patterson «se le da fatal el correo», algo que él reconoció. Gemma lo presentó a una persona llamada Neev. Patterson empezó a revisar con angustia su correo y HackerOne por si se le había pasado algo, pero no encontró nada. Neev le aclaró que no se trataba de un problema actual de RubyGems: había una investigadora de seguridad intentando contactar con alguien de RubyGems.
Después le escribió Sydney von Arx. Su mensaje decía que agentes de OpenAI habían intentado aprovechar la vulnerabilidad de caché de RubyGems y que cuatro paquetes intentaban explotarla para robar claves de API de usuarios. La primera reacción de Patterson fue de incredulidad, pero, como intenta ser responsable, abrió los enlaces y leyó el código. En una de las gemas, llamada sln-leaker-5, un script script.rb empezaba con un comentario sobre variantes de «leaked_keys», hacía una petición GET a la ruta de autorización de RubyGems.org y terminaba con una expresión regular que coincidía con el formato de la respuesta de la clave de autenticación. Ahí comprendió que el código intentaba atacar RubyGems.org.
Cómo rubydoc.info se convirtió en el vector del ataque
En una reunión posterior, Patterson preguntó lo mismo que se había preguntado en mayo: si el código no estaba en un extconf, ¿cómo se ejecutaba? La respuesta fue que se ejecutaba en rubydoc.info, un servidor que muestra la documentación de las gemas.
Dentro de la gema había un archivo .yardopts con una instrucción para cargar ./script.rb. La gema YARD instala un plugin de RubyGems: al instalarse otra gema, YARD busca en ella un archivo .yardopts y, si lo encuentra, ejecuta ese código.
La cadena completa, tal como la reconstruyó Patterson, fue esta:
- Un bot sube una gema a RubyGems.org.
- RubyGems.org envía un webhook a rubydoc.info, un servicio sin relación con RubyGems.org.
- rubydoc.info descarga la gema y la procesa en un contenedor Docker que aún tiene acceso a la red.
- Se ejecuta el script malicioso, que descarga datos del Gobierno del Reino Unido.
- Esos datos se empaquetan en una gema nueva que se sube a RubyGems.org, y el ciclo vuelve a empezar.
Patterson dijo que le parecía una locura que todas esas piezas encajaran así. Repasó la cronología. Las gemas de la campaña tienen fecha del 11 de mayo. El informe sobre la caché llegó el 6 de julio, de alguien sin relación con OpenAI que lo descubrió por su cuenta. Es decir, según su lectura, los bots conocían el problema uno o dos meses antes. Añadió que todo ocurrió mucho antes de la filtración de Hugging Face. El 12 de septiembre, Sydney von Arx y su equipo publicaron rubyhack.ai, un análisis detallado que recomendó leer.
Lo que más le impresionó es cuánto descubrieron los bots: que RubyGems.org envía webhooks a rubydoc.info, que YARD ejecuta código arbitrario y que rubydoc.info genera la documentación con conexión a Internet. Planteó además una hipótesis, que presentó como sospecha propia. No todas las gemas de la campaña contienen el código para robar claves. Cree que, cuando RubyGems.org empezó a cerrar cuentas y a impedir que volvieran a iniciar sesión, el bot «necesitó» una manera de entrar sin credenciales y recurrió a un 0-day contra RubyGems.org.
La historia tuvo un final feliz. Según Patterson, el equipo de RubyGems.org lo solucionó por completo y, hasta donde se sabe, nadie fue explotado ni la comunidad sufrió daños. Les dio las gracias.
Optimista, pero no rendido: la IA no es tu compilador
Para cerrar, Patterson se declaró optimista. Usa la IA a diario para escribir código, aunque prefirió llamarse optimista a secas y no «optimista de la IA». Cree que este es el mejor momento para estar vivo y que las cosas solo pueden ir a mejor. Pero ser optimista, dijo, no significa rendirse.
Respondió entonces a un argumento que ha oído: la IA sería nuestro próximo compilador, y si nadie lee el código máquina que genera un compilador, tampoco haría falta leer el que genera la IA. Patterson entiende el razonamiento y admite que probablemente nadie lee el código máquina de su compilador. Pero eso se debe a que el compilador hizo una promesa, la regla «as-if»: haga lo que haga, el comportamiento observable corresponde al código escrito. Todo lo que mostró en la charla, desde los marcos eliminados hasta los puntos de parche y las invalidaciones de ZJIT, ilustra cuánto cuesta cumplir esa promesa. La IA no ha hecho esa promesa: no existe una regla «as-if» para el inglés.
Por eso dijo que no dejará que la IA decida su destino, que seguirá leyendo su código y que espera que el público también lo haga. Terminó con una diapositiva: «Sube, perdedor, nos vamos a programar».
Hola a todos. Hola. Hola. Sí. Antes de empezar, quiero haceros un breve anuncio. He creado mi propia distribución de Linux. Quería enseñaros cómo se enciende, pero no podía subir un ordenador al escenario, así que se lo devolví al equipo de audiovisuales. Así que voy a pedirles que pongan en marcha mi presentación ahora, por favor.
Vale. Muy bien. Lo van a poner en marcha. Cuando… ¿puedes abrirme el PowerPoint cuando…? ¡Ay, Dios mío! Por cierto, he hecho esto con vibe coding.
De acuerdo. Pues supongo que vamos a ponernos manos a la obra. Lo voy a llamar AaronXP. Ese es el nombre de este programa. Se instala en 100 milisegundos. Y para mí es muy importante reducir el tiempo de instalación, porque lo hago constantemente. Me levanto por la mañana e instalo mi sistema operativo. Tomarme un café, instalar mi sistema operativo, actualizar Chrome, reinstalar el sistema operativo. Venga ya.
Me parece gracioso… Me parece irónico hacer una broma sobre la crisis financiera y luego animar a todo el mundo a generar un montón de código en Rust sin leerlo. Voy a empezar a vender seguros contra la deuda técnica, así que venid a verme después si queréis contratar alguno.
Ya lo sé, ya lo sé, a todo el mundo aquí le encanta usar la IA. A mí también. Me encanta usar la IA. Me encanta usarla para fabricar granadas de porquería. Es genial.
Muy bien. Pero quiero hacer una encuesta entre el público que está aquí. Creo que sí, voy a hacer una encuesta. ¿A cuántos de vosotros os gusta que os lancen granadas de porquería? Que levanten la mano. Vale, tenemos dos, tres. Ah, hay un par de personas muy comprometidas aquí delante. Genial, gracias. Me alegro de verlo.
Yo también he recibido algunos… Muchísimas gracias, Amanda, por todo el esfuerzo que has dedicado a esto. ¿Podemos darle un aplauso? Sí.
Pues bien, cuando estaba mostrando los registros durante esta conferencia, alguien me hizo una foto aquí, entre el público. Este soy yo.
Tuve que decírselo después. Y entonces le dije: «Ahora ya no puedes… Esta broma es mía. Ahora ya no puedes contarla en el escenario. Es mía. La voy a contar yo».
Vale. Antes de seguir con las bromas hoy, quiero hacer algo un poco más serio. Voy a señalar a alguien del público, a una persona concreta, y voy a dejarle en ridículo. Y está justo ahí: Mike Dalessio.
No sé si has visto su charla, pero Mike ha publicado esto. Nos conocimos hace ya más de 20 años. Me envió una solicitud de incorporación de cambios de código abierto para Mechanize, una biblioteca de Ruby de aquella época. Y desde entonces he estado colaborando con él en la comunidad de código abierto. Incluso tuve la oportunidad de trabajar con él en Shopify durante un tiempo. En realidad, él era mi jefe allí.
Empezamos juntos, creamos una organización en GitHub llamada Sparkle Motion. Yo soy Sparkle y él es Motion en esta relación. Lo que esto significa es que yo salgo al escenario haciendo gestos con las manos al estilo del jazz y él, en cambio, se dedica de verdad a trabajar.
Trabajamos juntos, colaboramos en muchísimos proyectos de código abierto. Por ejemplo, Nokogiri, quizá lo conozcas. Lo he visto en varias diapositivas sobre las instalaciones lentas de gemas. También hemos trabajado en la gema SQLite3, y además es muy, muy activo en el equipo de seguridad de Rails.
Pero el anuncio que quiero hacer aquí hoy es que también es el miembro más reciente del equipo central de Rails. Así que gracias. Gracias, Mike. Te agradezco muchísimo, de verdad, tu trabajo. No sé si la página web ya se ha actualizado o no, pero, en cualquier caso, gracias. Estoy deseando… Muchísimas gracias. Y nosotros… Me alegro de que sigas apoyándonos activamente.
Sí, sí. Muy bien, es emocionante estar aquí, en Rails World. Y sé que mucha gente ya ha hecho esta broma antes, pero yo también estoy usando Claude para generar mis diapositivas. Así que es genial estar aquí, en AI World, en Texas. Ha sido genial descubrir cómo podemos usar la IA, todos nosotros, todos podemos usar la IA para convertirnos en programadores 1.000 veces mejores. Así que todos los que estamos aquí hoy podemos utilizar la IA para convertirnos en programadores 1.000 veces mejores. Ah, perdón, un momento, se me ha escapado un error tipográfico. Quería decir 1.000 exprogramadores.
¿Hay alguien aquí que haya viajado en los robotaxis? A mí me parece alucinante. Sí, sí. Si aún no lo habéis probado, os recomiendo encarecidamente que lo hagáis. Es muy, muy divertido. Pero asegúrate de traer a un amigo contigo, porque así es mucho más divertido.
Sí. Vale. Feliz viernes. ¡Guau! Feliz viernes a todos. Siempre es viernes en algún lugar.
Me llamo Aaron Patterson. También me conocen como Tenderlove. Trabajo en el equipo de infraestructura de Ruby on Rails de Shopify y colaboro en diversas tareas del equipo. Allí me encargo de muchas cosas. Es muy divertido. Yo también tengo un gato. Este es mi gato. Quizá lo hayáis visto aquí, en uno de los mostradores de registro de Passport.
Hoy voy a hablar del rendimiento, obviamente, porque es de lo que siempre hablo en mis presentaciones. No sé de qué más hablar. Voy a hablar de Ractors. Voy a hablar de ZJIT. Y, por supuesto, voy a hablar de IA, porque estamos en AI World. Así que eso es lo que haremos.
Y la razón por la que voy a hablar de estas cosas, lo importante, es que este año he estado trabajando en rendimiento, en Ractors, en ZJIT y también en IA. Y sí, de hecho estoy repitiendo estas diapositivas porque me pongo muy, muy nervioso cuando preparo presentaciones, por miedo a no tener suficiente contenido para la conferencia, así que simplemente añado más diapositivas. Pero sí, esto es de lo que voy a hablar hoy.
Este año también he escrito un asignador de registros para ZJIT, y aunque hoy no vamos a hablar de ello, quería incluirlo aquí porque es el proyecto más difícil en el que he trabajado nunca. Y estoy muy, muy orgulloso de mi trabajo en este proyecto. Y solo quería poner esto aquí y decir que lo hice yo. Y fue genial. Así que estoy orgulloso de mí mismo por haberlo hecho. Oh, gracias.
Este año también tengo un nuevo jefe. Y creo que las cosas van bastante bien con él. No estoy del todo seguro. Grabé mi primera reunión individual con él y hoy voy a compartir la transcripción con vosotros; espero que podáis ayudarme a valorarlo. Así que, en nuestra primera reunión individual, me dijo: «Aaron, solo has estado probando las rutas de código que dan error». Y dije: «Bueno, eso es porque estoy esperando matrices».
Es porque mi código es excepcional. Y no creo que le hiciera mucha gracia. Pero quería tranquilizarlo, así que le dije: «No te preocupes, estas reuniones irán mejorando. Me encargaré de ello».
Sí. Bueno, creo que las cosas van bien. No estoy seguro.
Voy a empezar mi presentación con una cita: el propósito de un sistema es lo que hace. He oído esta cita muchas veces antes y, la verdad, nunca me había parado mucho a pensar en lo que significaba. No sé. Me hace pensar, y no me gusta hacer eso, así que no le di muchas vueltas.
Pero supongo que se lo comenté a mi jefe, y pensé para mis adentros: «Vale, cuando intento entender para qué sirve un sistema, tengo que entender qué hace». Y como mi jefe era nuevo aquí, me dijo: «Aaron, ¿qué es esto? ¿Para qué sirve este sistema en el que estás trabajando?». Y le dije: «Bueno, pues sí, ¿qué hace el sistema?». Y él me mandó a la mierda. Es broma. Es una broma, de verdad. Él nunca me diría algo así. He dicho que su finalidad es generar excepciones. Bueno, no estoy seguro. Creo que estas reuniones individuales están yendo bien. No lo sé. Ya veremos después de esto.
En fin, no me gusta especialmente esta frase, y la razón por la que no me gusta es porque es muy ambigua. Cuando pienso en ello, me digo: «Vale, bueno», y enseguida me pregunto: «¿Qué hace este sistema? ¿Qué hace?». No lo sé. Así que no sé cuál es su finalidad, y por eso me digo: «No sé cuál es su finalidad». No me lo acabo de creer. Mi cerebro es demasiado pequeño para esto. No puedo con ello.
Por poner un ejemplo, imaginemos que viajara atrás en el tiempo con mi fiel Nokia 3310. ¿Alguien tiene este móvil? Sí. Claro que sí. Estoy seguro de que hay gente aquí que nunca lo ha visto antes, pero este es un móvil indestructible. Es genial. Imaginemos que viajara al pasado con este móvil y se lo diera a alguien que nunca hubiera visto un móvil antes; es posible que lo utilizara como martillo.
Entonces me pregunto: ¿el propósito de este teléfono es servir de martillo? Es decir, si te tomaras al pie de la letra lo que he dicho antes, podrías decir que sí. Quiero decir, está claro que lo están usando como martillo, así que ese es el propósito de este aparato. Pero para mí, sería un móvil.
Creo que lo que hace el sistema depende de quién lo esté observando. Así que, cuando miro ese teléfono, veo un teléfono y lo usaré como tal. Pero si lo llevara al pasado, la persona que lo viera allí lo vería como un martillo y lo usaría como tal. Así que, en realidad, todo depende de quién observe ese sistema. Por eso, si pudiera matizar la cita original, diría que el propósito de un sistema es lo que hace por quienes lo observan. Alguien famoso dijo eso en Rails World 2026. Sí.
He estado reflexionando mucho sobre esto porque, como ya he dicho, me he dedicado principalmente a la optimización del rendimiento o del código. Y esta actitud concreta de la que te hablo, esta actitud observacional, es bien conocida entre los implementadores de lenguajes de programación. Se conoce como la «regla as-if». Existe una regla llamada «regla del como si», y podéis leer sobre ella en Wikipedia, pero en realidad está codificada en el lenguaje de programación C++ en lo que respecta a la aplicación de optimizaciones al código.
Así que vamos a leer un fragmento de la regla «como si». La regla «como si» establece que, en C++, se permite aplicar cualquier transformación optimizadora a un programa durante la compilación, siempre que dicha optimización no altere el comportamiento observable del programa, tal y como especifica el estándar. En otras palabras, el compilador tiene libertad para realizar cualquier cambio que desee en tu código, siempre y cuando este se ejecute tal y como lo has escrito, es decir, tal y como parece que debería ejecutarse.
Ahora bien, hay algo un poco extraño en esta cita en particular. Dice «tal y como especifica el estándar», algo de lo que prácticamente carecemos en Ruby. No existe un estándar específico como el que tiene C++. Pero no nos preocupemos por eso de momento. Así que hoy quiero hablar de las optimizaciones desde el punto de vista del comportamiento observable.
Toda optimización tiene dos caras: lo que se elimina en la optimización y lo que observa esa optimización concreta, sea quien sea quien observe ese código. Ahora, en cuanto a lo primero, vamos a ver algunos ejemplos de estos diferentes observadores y optimizaciones, y a analizar cómo se influyen mutuamente.
Lo primero que os voy a mostrar —y os garantizo que prácticamente todos lo habéis hecho antes, creo— es la memoización. Este es el primer ejemplo de todos. Estoy seguro de que todos hemos escrito este patrón concreto alguna vez. Tenemos una función; sabemos que esa función es costosa, pero solo necesitamos calcularla una vez. Así que la calculamos una vez y luego la guardamos en una variable de instancia. Estoy bastante seguro de que muchos de los que estáis aquí habéis escrito código similar a este. Yo, desde luego, lo he hecho.
El único problema con este patrón de código en concreto es que hemos incorporado una suposición en el código. Y se parte de la base de que este código solo lo observa un hilo cada vez. Así que, en realidad, existe una condición de carrera en este código por la que podríamos acabar ejecutando este cálculo costoso varias veces, pero en realidad no lo vemos porque en Ruby contamos con el GVL.
Ahora bien, aquí es donde entran en juego los Ractors. Los Ractors nos permiten disponer de un verdadero procesamiento paralelo en nuestro código Ruby. Eso significa que los observadores de nuestro código han cambiado. De hecho, puede ocurrir que dos subprocesos sigan esta misma ruta de código al mismo tiempo. Eso significa que podemos tener varios observadores de una ruta de código concreta en paralelo.
Ahora bien, afortunadamente, Ractors nos lanzará una excepción en este caso. Así que, en lugar de que se produzca una condición de carrera y se estropee algo en tu aplicación, recibirás una excepción y tendrás la oportunidad de solucionarlo. Espero que hayáis visto la presentación de Andrew. Habló en detalle sobre este tipo de cosas, pero voy a repetir aquí una pequeña parte.
Ahora bien, cuando digo esto, no quiero que os asuste y penséis: «Bueno, nunca debería usar este patrón en concreto». Os voy a mostrar un par de ejemplos de este patrón. En el primer caso, tenemos varios Ractors que acceden a esta variable de instancia memoizada de la clase Foo. Y lo que eso significa es que tenemos una condición de carrera en la clase Foo, y lo bueno es que este Ractor R1 nos lanzará una excepción diciendo: «Oye, no puedes hacer eso». Tienes que hacerlo de forma segura para los subprocesos.
Ahora bien, el segundo caso en realidad está bien. Como ves, el patrón es el mismo. Podemos utilizarlo. Está muy, muy bien hacerlo así. Lo que pasa es que, en el segundo caso, solo hay un observador de esa ruta de código concreta. Por eso, no hay problema en utilizar este patrón. Solo tenemos que asegurarnos de que se limite a un Ractor específico.
Ahora bien, este patrón de variables de instancia a nivel de clase es bastante habitual en todas las bases de código. Y, de hecho, habrá una forma de mitigar esto o de hacer frente a esta situación en la próxima versión de Rails. Se trata del módulo ActiveSupport/Ractors. Con este módulo, hay un método llamado `on_main` que nos permite ejecutar cierto código en el Ractor principal. Así que, si tenemos un cálculo que consume muchos recursos, necesitamos que se realice dentro del Ractor principal y que se guarde algo en una variable de instancia a nivel de clase. Podemos hacerlo.
Ahora bien, si has visto la presentación de Andrew, sabrás que esto provoca latencia en tu aplicación. Y la razón por la que provoca latencia es que, si desvías muchísimas operaciones de cálculo al Ractor principal, de repente este se convierte en el cuello de botella de tu aplicación. Y acabarás observando un aumento de la latencia porque todo el mundo compite por ese Ractor.
Por eso, siempre que sea posible, te recomiendo encarecidamente que utilices la gema «Ractor sharing». La ha escrito Koichi Sasada, que es el autor principal de Ractor. Así que espero que esto funcione bastante bien como código basado en Ractor. De hecho, no «espero», sino que funcionará. Así que deberías utilizarlo. Tiene muchas estructuras de datos diferentes compatibles con Ractor en su interior. Voy a compartir aquí un par de ellas.
Hay dos que quiero destacar y que resuelven este problema concreto que estábamos analizando. La primera son las variables de bloqueo y la segunda son las TVars; la que elijas dependerá del rendimiento que quieras obtener con tu aplicación. Una variable de bloqueo garantiza que un cálculo concreto solo se realice una vez, mientras que una TVar permite que se realice una o más veces, básicamente unas cuantas veces.
Así pues, la primera se utiliza cuando se tiene un cálculo que no es idempotente y es necesario garantizar que solo se realice una vez, y la segunda cuando se tiene un cálculo que sí es idempotente y quizá no suponga ningún problema que se haga un par de veces.
Además, hay muchísimas más estructuras de datos ahí dentro. Y no quiero disuadirte de usar estas estructuras de datos, porque creo que la naturaleza de la implementación de los Ractors implica que, aunque utilicemos estas estructuras de
datos, no habrá interbloqueos en nuestras aplicaciones, ni problemas de seguridad de subprocesos, ya que, en su lugar, simplemente obtendremos excepciones. Y la razón por la que creo que no deberíamos tener demasiado miedo a estas estructuras de datos es que considero que el alcance de los efectos es bastante limitado.
Veamos un ejemplo. Aquí tenemos un servidor web simulado basado en Ractor. Básicamente, lee una solicitud del socket en un bucle infinito y, a continuación, la atiende dentro de un Ractor. Y si pensamos en el ciclo de vida normal de una solicitud-respuesta, estamos asignando la mayoría de nuestros objetos dentro de este método «serve». Así que todas nuestras asignaciones se realizarán allí dentro. Y, en realidad, el único momento en el que realmente tenemos que preocuparnos por esto es cuando tocamos elementos del exterior. Ahora bien, por supuesto, sí que modificamos aspectos externos, como constantes o valores de configuración, cosas de ese tipo, pero creo que el número de puntos de modificación es mucho menor de lo que realmente pensamos.
Otro tema del que quiero hablar un poco es la observabilidad de los datos y cómo afecta al rendimiento. En Ruby 4.1 se va a incluir un recolector de basura local de Ractor. ¿Qué significa esto? El GC, el recolector de basura de Ruby, es un singleton. Así que cada vez que un Ractor necesite asignar un objeto, le pedirá al recolector de basura: «Oye, dame un objeto». Y si tenemos varios Ractors ejecutándose en paralelo, está bastante claro que nos vamos a encontrar con un cuello de botella. Así que intentaremos asignar un montón de cosas y, entonces, todos estos Ractors se quedarán esperando a que el recolector de basura asigne los recursos. Por eso, en Ruby 4.1, tendremos un recolector de basura local para cada Ractor, lo que significa montones separados para cada uno, lo que resolverá este problema y eliminará este cuello de botella en concreto. Así pues, cada Ractor puede solicitar un objeto a su montón.
Ahora bien, también podríamos aprovechar esto para conseguir una recolección de basura más eficiente. Por ejemplo, en nuestro servidor web ficticio, podemos crear un nuevo Ractor con cada solicitud. Y luego, cuando la solicitud haya finalizado, simplemente podemos liberar el montón y continuar con el proceso. Así pues, en teoría, esto nos permitiría conseguir una recolección de basura más eficiente.
Ojalá ahí acabara la historia, pero la cosa es un poco más complicada que eso, y quiero mostraros un ejemplo de por qué. Supongamos que tenemos este ejemplo de productor-consumidor en Ruby. Lo siento. Siento haceros leer código. El productor asigna un objeto en su propio montón de esta manera y, a continuación, pasa ese objeto a un consumidor. Así pues, cuando eso ocurre, tanto el consumidor como el productor apuntan exactamente al mismo objeto, y eso se puede ver aquí con el identificador del objeto. Los dos Ractors pueden observar exactamente el mismo objeto al mismo tiempo. Lo que esto significa es que, cuando uno de estos Ractors deja de funcionar, eso no implica necesariamente que podamos deshacernos del montón, ya que podría haber otros Ractors que apuntan a ese montón. Por lo tanto, necesitamos alguna forma de resolver esta situación. La forma en que lo solucionamos es mediante un recolector de basura global, un GC global, y puedes imaginártelo como una recolección «major», salvo que, con suerte, ocurre con menos frecuencia que una «major».
Quiero mostrar un ejemplo más. En el ejemplo anterior, pasamos a propósito un objeto de un Ractor a otro, y ambos pudieron ver ese objeto. Quiero mostrar un ejemplo diferente en el que lo hacemos de forma implícita. Ya mostré este ejemplo en Rails World el año pasado, pero creo que es un ejemplo estupendo. Tenemos dos análisis de JSON. Estamos analizando dos documentos JSON y, a continuación, observamos la clave del documento JSON; como podréis ver aquí, esas claves corresponden exactamente al mismo objeto. Tienen el mismo ID de objeto. Podemos comprobar que se trata del mismo objeto. Ahora bien, si tomamos este código y lo dividimos en varios Ractors, veremos que la cadena «hello» tiene el mismo ID de objeto en ambos Ractors. Así pues, en ambos Ractors, esas claves se deduplican entre ellos, y ambos Ractors ven la misma cadena «hello». Y lo que ocurre en segundo plano es que estamos asignando una cadena «hello» en un montón, nuestro montón del Ractor principal, y luego los otros dos Ractors apuntan a ese montón. Lo interesante de este ejemplo es que posiblemente esté afectando al rendimiento del recolector de basura, pero no lo sabemos. Está ocurriendo de forma implícita.
Así que quiero dar un paso atrás un momento. Hemos estado analizando temas bastante profundos. Empezamos hablando de las optimizaciones que realizan los desarrolladores. Vimos cómo los Ractors añaden un nuevo observador al sistema y las repercusiones que tiene la incorporación de nuevos observadores. Ahora quiero centrarme en las optimizaciones del framework y del lenguaje y, en concreto, en las inconsistencias en el backtrace.
Aquí tenemos un ejemplo de un callback de «save». Estamos utilizando los callbacks de ActiveSupport. Estamos creando aquí una especie de ActiveRecord ficticio en el que puedes incluir esto y, a continuación, llamar a «save», con lo que obtienes un callback de «save». Y este código de ejemplo muestra la pila de llamadas desde dentro de nuestro callback de «save». Si nos imaginamos el rastro de la pila a partir de esto, podemos imaginar que lo primero que veremos será el bloque para ejecutar los callbacks y, a continuación, veremos el marco del método «save». Y como estamos ejecutando esto directamente desde un script, veremos «main» como el nivel superior.
Así que cojamos este módulo e integrémoslo en una clase, una clase sin callbacks. Este es, por así decirlo, nuestro caso base. Si lo ejecutamos, veremos que, efectivamente, solo tiene tres marcos de pila. Probémoslo de nuevo, pero esta vez con un callback. En este caso, vamos a definir un callback «before» y vamos a analizar el seguimiento de pila. Y vemos que, de nuevo, solo hay tres marcos aquí. Eso está muy bien, y es lo que esperábamos. Así que ahora vamos a hacer una más. Vamos a definir un callback «around». Si hacemos eso, veremos que nuestro seguimiento de pila se ha disparado de repente. Recordad que no hemos cambiado nada del código que había dentro de ese método «save», y de repente nuestro seguimiento de pila es totalmente diferente.
Al investigar esto, me pareció muy curioso encontrar este comentario en ActiveSupport. Voy a leerlo. Dice: «Dado que este método se utiliza en muchos sitios y a menudo envuelve grandes porciones de código de usuario, tiene un objetivo de diseño adicional: minimizar su impacto en la pila de llamadas visible. Una excepción desde el interior de un callback «before» o «after» puede ser tan molesta como quiera, pero cuando el control pasa sin problemas al bloque proporcionado, queremos que quede el menor rastro posible de que hemos estado aquí».
Y esta es precisamente la regla «como si» de la que hablábamos antes. Así que, en este caso, veíamos más pila de lo esperado, y esto ocurría dentro de Rails. Y puedo pasar por alto eso porque tenemos código de aplicación que está haciendo más cosas. Por ejemplo, hemos añadido un callback «around», así que, en cierto modo, sabemos que, sí, vale, está haciendo más trabajo, así que, por supuesto, habrá más traza de pila.
Pero, ¿es posible obtener un seguimiento de la pila más corto de lo esperado? Es decir, ¿se da ese caso? Sí, se da. Pero, antes de nada, sí, me gusta pasar los fines de semana contando marcos de la pila. Aunque no creo que eso me convierta en un perdedor.
Aquí tenemos un ejemplo de la falta de un marco de pila de «new». Voy a explicar en qué consiste. En este código, tenemos un método 1 que llama al método 2 y, a continuación, este llama al 3, el cual asigna «Foo». «Foo» llama al 4 desde dentro de «initialize». Y la razón por la que hago esto es simplemente para que el seguimiento de la pila quede un poco más claro. Así que, si ejecutamos este código, esperamos ver un seguimiento de la pila como este. Veremos «initialize», «new», «3», «2», «1» y «main». No hay nada sorprendente. Si ejecutamos el código en Ruby 3.4, eso es lo que vemos. Este es exactamente el seguimiento de la pila que vemos en Ruby 3.4.
Si ejecutamos esto en Ruby 4.0, veremos que se ve un poco diferente. Y, de hecho, en Ruby 4.0 ya no aparece este marco de «new». Ha desaparecido. Entonces, ¿qué ha pasado con este marco de pila? Creo que podemos encontrar algunas pistas.
Vamos a averiguarlo aquí. Si volcamos las instrucciones del método 3, creo que podremos aprender algo. Así que vamos a volcar las instrucciones de la máquina virtual de este método en concreto. Arriba tenemos las secuencias de instrucciones de Ruby 3.4. Y abajo, las de Ruby 4.0. Y lo primero que podemos observar es que Ruby 4.0 genera muchas más instrucciones que Ruby 3.4. No voy a explicar estas instrucciones en detalle, pero el cambio fundamental es que hemos integrado la llamada a `initialize` en el punto de llamada de `new`. Justo ahí. La hemos integrado en línea.
Así que, si tomamos esas instrucciones y las volvemos a escribir como código Ruby, a la izquierda está el código que hemos escrito, y a la derecha está, por así decirlo, el pseudocódigo de las instrucciones que hemos generado. El pseudocódigo de las instrucciones básicamente dice: «Oye, si el método `new` de Foo es la implementación por defecto, lo que vamos a hacer es asignar un nuevo Foo, llamar directamente a `initialize` y, a continuación, devolver el objeto. Si no es el `new` por defecto, seguiremos una ruta más lenta y simplemente llamaremos al método `new`».
Por lo tanto, tiene sentido que haya más instrucciones aquí, ya que simplemente está haciendo más de lo que hacía Ruby 3.4. Otra cosa a tener en cuenta es que, en realidad, estamos llamando al método `initialize` desde el método `3`. Antes, el modelo era que `new` llamaba a `initialize`. Ahora, en cambio, podemos ver que `3` llama directamente a `initialize`. Por eso tiene sentido que falte ese marco de pila de `new`.
Supongamos que tenemos este código. Es nuestra clase Foo. Imprimimos el seguimiento de la pila desde el método `initialize` antes de implementar el método `new`. Así que, básicamente, vamos a aplicar un «monkey patch» al método `new`. Pero no es un «monkey patch» muy complicado. Simplemente estamos llamando a `super` desde dentro de él. Así que aquí imprimiremos el seguimiento de la pila antes del paso 3. Añadiremos `new` y, a continuación, volveremos a llamar al método e imprimiremos de nuevo el seguimiento de la pila. Si lo hacemos, a la izquierda tenemos el seguimiento de la pila de la ruta rápida y a la derecha, el de la ruta lenta, y podemos ver que ha vuelto a aparecer nuestra llamada a `new`.
Entonces, ¿por qué nos hemos tomado tantas molestias? Es decir, ¿por qué hacemos todo esto? La razón es que así conseguimos asignaciones más rápidas. En este ejemplo concreto, la asignación es un 70 % más rápida en Ruby 4.0 que en Ruby 3.4. Y eso sin necesidad de un compilador JIT. Así que obtenemos unas mejoras de rendimiento realmente notables gracias a esta optimización en concreto.
Así que lo que eliminamos en este caso fue un marco de llamada, y los observadores de esto serían, por ejemplo, `caller`, los depuradores y todo aquello que pudiera inspeccionar la pila. Estas son, pues, las cosas a las que afectamos. Sin embargo, lo que conseguimos con ello fue un aumento del 70 % en la velocidad de las asignaciones, lo que me parece un buen intercambio. Y CRuby hace esto constantemente. Hay muchos lugares diferentes en los que lo hace.
Voy a poner otro ejemplo muy rápido. Aquí tenemos una clase de ejemplo llamada «Foo» que implementa el método «hash». Imprime el rastro de pila cada vez que algo llama al método «hash» en ella. Activamos un cálculo de hash e imprimimos el rastro de pila. Así pues, cabría esperar que estas dos primeras llamadas tuvieran el mismo rastro de pila, y que la última tuviera un rastro de pila diferente, pero con la misma profundidad. Y si lo analizamos, las pilas son completamente diferentes. Aquí, de hecho, podemos omitir el método «square square» del extremo izquierdo. Así que verás que falta ese marco, mientras que los otros dos lados lo muestran todo. CRuby hace esto constantemente. Se toma ciertas libertades con el seguimiento de la pila.
Pero creo que la pregunta es: ¿a quién le importa? En serio, ¿a alguien le importa esto? ¿No? ¿A alguien? Ah, a unas cuantas personas. Vale. Sé que a mí me parece interesante. Pero, en realidad, eso es lo que quiero que sintáis. Si realmente os importara si esas cosas están o no en la pila, entonces no podríamos implementar este tipo de funciones. Por eso me alegro muchísimo de que a la mayoría de vosotros os dé igual.
Creo que este tipo de cosas son zonas grises en cuanto a las reglas. El compilador ha introducido un cambio en el código, por lo que la ejecución, estrictamente hablando, no es la misma. Se puede observar una diferencia, pero se trata de casos en los que a la gente no le importa. Es decir, realmente no les importa, así que no pasa nada.
Y en Ruby, y creo que en la mayoría de los demás lenguajes, tenemos que mantener el comportamiento allí donde realmente importa. Así que esos casos no tienen importancia. Estamos consiguiendo optimizaciones de rendimiento, pero a cambio de sacrificar aspectos que realmente no importan.
Creo que este tipo de optimizaciones son lo que me gusta llamar «optimizaciones de bajo riesgo», en las que cambiamos el comportamiento del lenguaje, pero a la gente, en general, realmente no le importa. Son de bajo riesgo.
A continuación, quiero analizar algunas optimizaciones de alto riesgo, concretamente dentro de ZJIT. ZJIT es capaz de realizar optimizaciones de alto riesgo porque puede revertir la optimización en caso de que ocurra algo inesperado. Así que vamos a ver algunas de ellas. ZJIT cuenta con dos tipos de representación intermedia a nivel interno. Vamos a analizarlos.
La representación intermedia, también conocida como IR, cuenta con un HIR (representación de alto nivel) y un LIR (representación de bajo nivel). Si quieres probarlo en casa, puedes ver el HIR añadiendo estas opciones a tu ejecutable de Ruby. Así, si añades «ZJIT dump HIR», podrás ver el resultado y echarle un vistazo.
Una de las suposiciones que hacemos en ZJIT es que probablemente no redefinirás métodos. Probablemente no lo harás. Pero si lo haces, necesitamos saberlo, porque si lo haces, tenemos que comportarnos correctamente. Así que supongamos que tenemos un ejemplo como este, en el que asignamos un punto y, a continuación, llamamos a un método que, a su vez, llama a un método sobre ese punto.
Si generamos el HIR para esto, tendrá este aspecto, y voy a destacar algunas cosas dentro de este HIR. En primer lugar, quiero mostraros que aquí tenemos un bloque. Así que podemos ver que se trata de la compilación, el HIR del bloque en `5.times`. Lo siguiente que quiero señalar es que hemos integrado «get» en el bloque, pero insertamos un marco en línea. Así que estamos insertando un marco en línea para «get», de modo que queda integrado en el bloque. Lo siguiente que quiero mostrar es que, de hecho, hemos podido trasladar esta constante 42 al interior del bloque.
Así pues, ZJIT fue capaz de tomar el código de la izquierda y compilarlo hasta obtener el código de la derecha. Consiguió simplificarlo todo.
Pero, ¿cómo puede ZJIT arriesgarse tanto? A nosotros quizá no nos importe que falte un marco de pila, pero si tu código redefine «get» o «x», probablemente sí te importaría, porque entonces tu código no se comportaría como esperabas. No seguiría la regla «como si».
Si volvemos a la salida de HIR, veremos un par de líneas de puntos de parche. Voy a resaltarlas. Aquí tenemos una. Hay un punto de parche para la redefinición del método «gets». Y aquí tenemos otro punto de parche definido para la redefinición del método «x».
Estos puntos de parcheo no representan ningún código. Así que, si miras el código máquina generado, no hay nada en él que represente estos puntos de parcheo. Son simplemente marcadores. Estos marcadores los utilizamos para saber en qué parte del código máquina hay que aplicar el parche.
Supongamos que tenemos un código como el de nuestro ejemplo anterior. En este caso, vamos a compilar mediante JIT el bloque «5.times». A continuación, vamos a redefinir el método «x» y volveremos a compilarlo mediante JIT. Si hacemos eso, este es un fragmento del código máquina que genera el JIT, y vamos a mostrarlo antes y después de la invalidación. Esto es antes de la invalidación.
Así pues, cuando se invalida el método `gets`, el compilador JIT sobrescribirá literalmente el código máquina. Por lo tanto, tomará ese código de prueba. Tenemos un marcador de invalidación que apunta a la instrucción de prueba y, cuando ese método se redefine, lo que hacemos es sobrescribir esa instrucción de prueba con una instrucción de salto, y esa instrucción de salto saltará a un código de salida. Ese código de salida restaurará todos los componentes internos de la máquina virtual y la pila de la máquina virtual, y podrás seguir ejecutando tu código de nuevo en la máquina virtual de Ruby.
Entonces, ¿cómo detectamos este caso? Por ejemplo, ¿cómo sabemos que has redefinido un método? Lo hacemos con la ayuda de CRuby, la implementación de CRuby. Cada vez que se redefine un método, tenemos que invalidar las cachés de todos modos, y este es también el momento perfecto para que el compilador JIT invalide cualquier código JIT. Así, el compilador JIT puede simplemente realizar un seguimiento de los métodos que ha compilado y, si alguien redefine un método, puede invalidar cualquier código máquina asociado a ese método. Por lo tanto, aquí diremos: «Ah, se ha redefinido un método». Vamos a invalidar todo nuestro código JIT para YJIT o ZJIT, dependiendo de cuál estés utilizando.
La redefinición de métodos no es la única invariante que supervisamos. También hacemos un seguimiento de elementos como las operaciones básicas, o BOP. Por ejemplo, en el caso del método «plus», tenemos que asegurarnos de que «plus» siga significando «suma». Hacemos un seguimiento de las constantes, por lo que debemos asegurarnos de que no se redefinan, o de que haya puntos de traza, ya que si se ejecuta un punto de traza, significa que se pueden observar elementos que el compilador JIT podría haber optimizado, y tenemos que corregirlo. No se aplicará la regla «como si». Y hay un montón más; si quieres saber cuáles son, ven a hablar conmigo después.
Y, como ya he dicho, iba a hablar de la IA porque esto es AI World. Y ZJIT utiliza mucha IA. La usamos mucho. Y, por desgracia, sé que esto va a decepcionar a algunas personas, pero me refiero a la interpretación abstracta.
¡Genial! Así que, si te apetece trabajar en inteligencia artificial, únete al proyecto ZJIT, por favor. Nos encantaría contar con tu ayuda.
¿Qué es la interpretación abstracta? La interpretación abstracta consiste en simular la ejecución de un código. Vamos a simular que ejecutamos código y, a partir de ahí, dejar que surjan las optimizaciones. Así que voy a mostrar un ejemplo para explicar mejor qué significa esto exactamente.
Aquí a la izquierda tenemos un servidor de pruebas. Se trata simplemente de una aplicación Rack de prueba. A la derecha está nuestro servidor real o, perdón, un servidor de pruebas. A la derecha está nuestra aplicación Rack. Y aquí abajo, en la parte inferior, tenemos un método sencillo que mide la basura —es decir, el número de objetos que hemos asignado—. Además, intenta gestionar unas 1.000 peticiones ficticias a través de esta aplicación Rack.
Tanto si lo ejecutamos con un compilador JIT como si lo hacemos sin él, se asignarán unos 1.000 objetos. Esto tiene sentido, ya que hemos realizado unas 1.000 solicitudes. Si lo ejecutamos con YJIT, veremos que asigna más o menos el mismo número de objetos. Y si lo ejecutamos con ZJIT, veremos que asigna 8 objetos. Entonces, ¿de dónde vienen estos objetos y cómo es capaz ZJIT de eliminarlos?
Volvamos a nuestro programa de demostración; en él queda bastante claro de dónde proceden estas asignaciones. Estamos asignando aquí un array en el método `call`, con nuestro estado, los encabezados y el cuerpo.
Como ya hemos visto en el ejemplo anterior, ZJIT puede integrar funciones. Lo que ocurre aquí es que ZJIT toma ese método «call» y lo integra dentro del método «serve». Digamos que hemos hecho eso. Lo vamos a integrar aquí de esta manera.
Ahora bien, lo que quiero hacer aquí es coger este código y desglosarlo en partes más sencillas. Vamos a dividirlo en varios pasos con variables temporales y hacerlo poco a poco. Así que vamos a tomar la asignación del estado, los encabezados y el cuerpo, y la vamos a desglosar. Si hiciéramos eso, acabaríamos con algo como esto, en lo que hemos asignado todas estas variables a variables intermedias y luego las usamos así. Y creo que todos estamos de acuerdo en que el comportamiento de este programa es el mismo que el del anterior. La única diferencia es que tenemos más variables temporales.
Y aquí es donde entra en juego la interpretación abstracta. Nuestro intérprete abstracto recorrerá este código y simulará ejecutarlo. Así que vamos a repasar eso ahora mismo. Lo primero que haremos será crear un montón abstracto. Y este montón abstracto lleva un registro de cómo serían los objetos si ejecutáramos el programa. Y simulamos esta ejecución. Para ello, creamos un montón abstracto y, a continuación, almacenamos los valores en él.
Así que vamos a simular la ejecución. Sabemos que, si ejecutamos la primera línea, a v1 se le asignará el valor 200; a v2 se le asignará la constante «headers»; y a v3 se le asignará «body». Y entonces ocurre algo especial con v4. A v4 se le va a asignar un array abstracto. Así que aquí tenemos un array abstracto. Este array abstracto contiene tres valores: v1, v2 y v3. Y no nos importa cuáles sean v1, v2 y v3. No nos importa en absoluto cuáles sean. Solo sabemos que contiene esos valores.
A continuación, vamos a asignar el estado al primer elemento del array. Después, vamos a asignar los encabezados al segundo elemento del array y, a continuación, el cuerpo al tercer elemento del array. Ahora, lo siguiente que podemos hacer es decir: «Bueno, vamos a realizar una sustitución aquí». Sabemos que se asigna al estado el valor v1. Así que, en este caso, basta con hacer una sustitución algebraica. Y diremos: «Vale, simplemente vamos a sustituir esos valores».
Y si lo hacemos, hay algo que nos llama la atención en este programa. Y es que v4 no se utiliza. No utilizamos v4 en absoluto. Como no la utilizamos, eso significa que podemos eliminarla. Así que la eliminamos. Luego vamos aún más allá y decimos: «Bueno, tampoco necesitamos estas variables intermedias, así que las trasladaremos al estado, los encabezados y el cuerpo, y haremos asignaciones directas». Y podemos ser aún más agresivos. Podemos decir: «Bueno, bajemos aquí también», así que eliminaremos todas esas.
Y podemos integrar otras funciones y aplicar el mismo tipo de algoritmo una y otra vez. Y de esta manera, utilizando la integración, el plegado de constantes y la interpretación abstracta, somos capaces de convertir el código de la izquierda en el de la derecha, y así es como conseguimos eliminar esas asignaciones.
Así pues, hemos hablado de las optimizaciones y del impacto que tienen los observadores en ellas. Hemos hablado del paralelismo con Ractors, de la eliminación de marcos de pila en el intérprete, y de la integración y la eliminación de asignaciones en el compilador JIT. Así que hemos conseguido eliminar todas esas asignaciones en este compilador JIT.
Y tengo una pregunta para todos los que estáis aquí presentes. ¿Esta optimización incumple la regla «as-if»? Hemos podido observar ese cambio contando el número de asignaciones en el código. Entonces, ¿incumplimos la regla «as-if»?
¿Te importa?
A mí, desde luego, no. Me parece estupendo que las estemos reduciendo.
Muy bien. Iba a dar por terminada la presentación aquí. Así que iba a daros las gracias. Lo hemos conseguido. Hemos eliminado algunas asignaciones. Pero, en realidad, tengo otra historia que contar. Lo siento. En realidad, son dos presentaciones en una. Vamos a pasar al segundo tema. Me quedan 12 minutos. Vamos a intentar hacerlo.
Quiero hablar de algo que nos ha pasado muy, muy recientemente en la comunidad y de la historia que ha surgido a raíz de ello. Creo que es muy interesante. Espero que os resulte interesante a todos. Quiero hablaros de RubyGems y del incidente de OpenAI. ¿Alguien ha oído hablar de esto? Algunas personas, sí. Vale. Bueno, pues espero que esta historia os resulte interesante. Y para aquellos que aún no la conozcáis, espero que también os resulte interesante.
En mayo… Quiero contar esta historia desde mi punto de vista, básicamente. Los días 11 y 12 de mayo ocurrió algo. Empezaron a subirse miles de «gems» basura a RubyGems.org.
Y, la verdad, quiero retroceder un poco. Dejadme retroceder hasta aquí. Iba a terminar aquí. Gracias a todos. Dadme un aplauso. ¡Yuju!
Ya se han eliminado todos esos objetos. Genial.
Muy bien. Vamos a animarnos un poco. Pues bien, los días 11 y 12 de mayo, RubyGems.org empezó a recibir miles de correos basura… o, mejor dicho, «gems» basura subidas a la plataforma. Una auténtica avalancha. Más adelante, esto se conocería como la campaña «gem stuffer», pero por entonces aún no tenía un nombre concreto. Es que, cuando estás subiendo un montón de cosas, la verdad es que no te paras a pensar en un nombre.
El 12 de mayo, Moshe Mansfield reveló el ataque en Twitter. Trabaja con RubyGems.org y analiza los nuevos paquetes que se suben a la plataforma. Estaban recibiendo una gran cantidad de paquetes. Dijeron: «Vale, tenemos que ponerle freno a esto. Vamos a cerrar esto». «En estos momentos estamos haciendo frente a un grave ataque malicioso contra RubyGems.org. Se han suspendido los registros». Dijo que estaban haciendo frente a un grave ataque malicioso contra RubyGems.org y anunció que los registros se habían suspendido por el momento.
El 13 de mayo, Socket.dev publicó una entrada en su blog en la que lo denominaban «ataque gem stuffer». Y en aquel momento pensé: «Vale, qué interesante». Leí su entrada al respecto. La entrada está aquí y puedes escanear el código QR aquí. Lo llamaron «gem stuffer», «ataque gem stuffer» y «campaña gem stuffer».
Así que leí la entrada del blog, y en ella decía algo así como: «Vale, hay unas gemas que se están subiendo, y esas gemas contienen un script malicioso, y el script descargará material del Gobierno del Reino Unido, y luego…». En realidad, quizá tenga algunas animaciones aquí. No tenía ninguna intención de hablar de esto, y ayer me pasé literalmente todo el día trabajando en las diapositivas. Así que esto es… ah, sí, genial.
Pues tienen esta «gem» maliciosa o este script malicioso dentro. El script va a enviar solicitudes al Gobierno del Reino Unido por alguna razón y, a continuación, descargará todo ese material del Gobierno del Reino Unido. A continuación, lo empaquetaría; curiosamente, tomaría los datos que había descargado, los empaquetaría en una gema y, después, volvería a subir esa gema a RubyGems.org.
En su momento me pareció bastante interesante, pero si lees el código que mostraron en la entrada del blog… Lo primero que pensé fue: «Bueno, dicen que puedes saber que has sido víctima de esto porque habrá unos archivos en tu sistema de archivos. Así que puedes buscar esos archivos en tu sistema de archivos».
Lo revisé y, cuando instalas una gem normalmente —creo que todos sabemos que, al instalar una extensión C, por ejemplo, se ejecuta el extconf—, ya tenemos ahí un problema de ejecución remota de código. Pero eso ya lo sabe todo el mundo.
Y cuando eché un vistazo a su ejemplo, vi que el script malicioso no estaba dentro de un extconf. No estaban utilizando una extensión de C para hacerlo. Así que pensé: «Bueno, da igual. A ver, ¿cómo puede esto afectar a alguien? Nadie… Incluso si instalases esta gem, no se va a ejecutar por sí sola». Es decir, tendrías que entrar en la gema y hacer esto específicamente. Así que esto me parece raro. ¿Cómo? Sí. ¿Cómo…? El artículo nunca explica cómo se ejecuta el programa ni en qué circunstancias se ejecutaría. Así que, básicamente, me dije: «Vale, da igual, qué raro».
El 16 de mayo se reabrieron los registros y, sinceramente, ya no le había dado muchas vueltas. El 6 de julio, Luke Marshall, del equipo de seguridad de Truffle, notificó al equipo de RubyGems.org un problema de seguridad relacionado con el almacenamiento en caché en RubyGems.org. Y yo trabajo un poco en el cliente de RubyGems. Así que formo parte del equipo del cliente de RubyGems, pero no del equipo del servidor. Aunque, claro, nos comunicamos entre nosotros, así que nos conocemos y colaboramos.
Y lo interesante de este ataque es que… Os voy a mostrar el ataque aquí. Lo que podría ocurrir es que la autorización heredada se realizara mediante una solicitud GET. Esto es desde la perspectiva de la víctima. Así que se haría una solicitud GET para autorizar las gemas. Por lo tanto, si se ejecutara «gem auth», se ejecutaría esto. Y lo que pasaría es que se realizaría una autorización básica y, a continuación, la respuesta del servidor sería algo así, y tendrías esta caché, esta clave al final. Esa era tu clave para iniciar sesión. Y quiero que tengas presente este formato de clave durante un segundo. Quizá un minuto, probablemente más de un segundo. Así es como se veía.
Y desde el punto de vista del atacante, la situación se presentaba así. Lo que ocurría era que Fastly se interponía entre RubyGems.org y los usuarios, y almacenaba en caché las respuestas GET de RubyGems.org. Así, cuando los atacantes enviaban una solicitud, de repente recibían una clave almacenada en caché como respuesta. Por lo tanto, aunque los atacantes no enviaban el encabezado de autorización, conseguían obtener un token de autorización válido a cambio. Esto no es nada bueno.
No está nada bien.
Estuvo en producción durante unos seis años.
Sí, no es nada emocionante, pero no creo que mucha gente se diera cuenta porque, si lo piensas bien, si analizas cómo funcionaba esto, para empezar, en aquel momento no había pruebas de que se estuviera abusando de ello. Es decir, no se sabía en aquel momento que se hubiera abusado de ello. Hay que configurar «Accept-Encoding: gzip» para que ocurra. Y esto es un poco raro, porque significaba que quienes usaban «curl» no lo verían, ya que esa opción no está activada por defecto. Así que esto pasó desapercibido durante mucho tiempo. Sin embargo, Net::HTTP sí que lo activa por defecto.
Y creo que también pasó desapercibido porque, imagínate que eres el responsable de mantenimiento de una gem: vas a hacer un «gem push». Así que harías un «gem push» y, digamos, acabarías obteniendo la clave de otra persona. Por ejemplo, obtendrías mi clave, pero vas a subir tu propia gem. Así que harías un «gem push» y solo recibirías un mensaje extraño diciendo que no estás autorizado a realizar el push. Porque estás intentando usar tu propia gema, pero lo haces con mi clave. Así que simplemente no funciona, y entonces dices: «Vaya, qué raro. Inicio de sesión con gema». Y de repente se soluciona y funciona, y ya te da igual, ¿verdad? Es como si esto pudiera haber pasado.
Así que creo que por eso pasó desapercibido durante tanto tiempo. Además, solo lo utilizaba el antiguo código de RubyGems, y la caché caducaba al cabo de una hora. Así que, tres días después, lanzaron una solución para esto. Se solucionó por completo. Así que RubyGems lanzó una solución.
El 8 de septiembre estaba en el trabajo.
Este es mi atuendo de trabajo.
Recibí un mensaje directo de una de mis compañeras, Emily, y me dijo: «Aaron, vas a recibir un correo de Gemma». Gemma es una antigua compañera mía que ahora trabaja en Anthropic. Y Emily me dijo: «Vas a recibir un correo de Gemma y va a ser sobre un hackeo muy grave de RubyGems». Y yo le respondí: «Bueno, podría simplemente enviarme un correo y no hacía falta que me enviaras un mensaje directo». Y entonces Emily me dijo: «No, es que se te da fatal el correo». Y yo pensé: «Sí, ah, claro, es verdad. Vale».
Entonces me llega un correo electrónico. Me llega un correo electrónico de… En realidad, esto… Me he saltado una parte de la historia.
Gemma me envía un correo y me dice: «Aaron, te voy a presentar a una persona, Neev, y es que ha habido un hackeo muy grave en RubyGems y quieren hablar con alguien al respecto». Y le dije: «Sí, claro, vale, bueno, puedo hablar contigo sobre eso».
Y pensé… y entonces empecé a flipar porque me di cuenta de que no se me da bien revisar el correo. Así que pensé: «¿Se me ha pasado algún correo? ¿Hay algo en HackerOne que se me haya escapado? ¿Qué he hecho?». Así que me pongo a rebuscar como loca en mi correo electrónico. Oh, no. Busco cosas y no hay nada. Así que le respondo a la persona. Le digo: «Lo siento muchísimo. Debo de haberme perdido tu correo electrónico o algo así, pero no lo encuentro por ninguna parte».
Esa persona me ha respondido y me ha dicho que no, que debe de haber un error. No se trata de un problema actual de RubyGems. Pero hay alguien, un investigador de seguridad, que está intentando ponerse en contacto con alguien de RubyGems. Y yo estoy recurriendo a mis contactos para poder hablar contigo. Y es, no sé, interesante. Así que pensé: «Vale, está bien».
Entonces recibí un correo electrónico de una tal Sydney von Arx en el que me decía que los agentes de OpenAI habían intentado aprovechar esta vulnerabilidad de RubyGems, la del almacenamiento en caché. Me envió el enlace. Y parece que esas cuatro propuestas de paquetes intentaban aprovechar la vulnerabilidad para robar las claves API de los usuarios. Y yo pensé: «Esto es una auténtica tontería. Venga ya. No lo sabes». Pero ella me facilitó unos enlaces. Y yo digo: «Vale. Bueno, ya sabes, intento ser una persona responsable. Voy a hacer clic en todos estos enlaces y los leeré».
Meludite leyendo el código. En fin, pues hago clic en los enlaces y los leo. Y os voy a enseñar un fragmento de una de las gemas que me envió. No es el texto completo, pero echad un vistazo al código QR que hay ahí. Os lleva al texto completo, así que podéis echarle un vistazo y leerlo si queréis.
Y voy a señalar algunas cosas de este código. Lo primero es el comentario de la parte superior que dice «leaked_keys variants». Y empiezo a sudar en mi silla, y pienso: «Dios mío, eso no pinta bien». Y se puede ver que está dentro de este script llamado script.rb. Además, el nombre de la gema es sln-leaker-5. Qué raro. Sí.
Lo siguiente que me llamó la atención es que está realizando una solicitud GET a RubyGems.org. Y yo no he puesto la ruta aquí, pero es la ruta de autorización. Eso es lo que es.
Entonces miré al final y vi esta expresión regular concreta, que quizá hayáis reconocido de la respuesta, la respuesta de la clave de autenticación. Y me di cuenta de lo que estaba haciendo y pensé: «Joder, sí. Esto intentaba piratear RubyGems.org». Así que les respondí y les dije: «Ah, vale, sí. Eso es muy, muy interesante».
Y me preguntaron si podían tener una reunión, una reunión conmigo. Y yo les dije: «Sí, quedemos y hablemos de esto». Así que tuvimos una reunión. Sí, claro, así soy yo. Dándome cuenta de lo que está pasando aquí.
Estoy hablando con ellos y les digo: «¿Cómo? Vale, eso es muy interesante. Me habéis enviado esto. Está, de nuevo, en script.rb. ¿Cómo se ejecuta esto en algún sitio? Es decir, ¿cómo podría alguien ejecutar esto?». No está en el extconf. Y ella me dijo que se estaba ejecutando en rubydoc.info.
Y yo pensé: «¿Qué? ¿RubyDoc.info?». Si entras en RubyDoc.info, verás que es simplemente un servidor de documentación. Muestra la documentación de diferentes gemas. A ver, ¿qué es eso, qué es lo que pasa?
Si miras dentro de la gema, verás que hay un archivo «.yardopts». El archivo «.yardopts» tiene este aspecto. Y verás que apunta a… dice algo así como «load ./script.rb». Y lo que ocurre es que, si tienes instalada la gema Yard, esta instala un complemento de RubyGems. Y luego, si instalas otra gema, al instalarla, la gema Yard buscará dentro de tu gema un archivo .yardopts. Y si encuentra un archivo .yardopts, ejecutará este código que hay aquí. Así que ejecutará esto.
Y ella me dijo que cada vez que se subía una gema, a ver si la tengo, sí. Lo que ocurría aquí era que un bot subía una gema a RubyGems.org de esta manera. RubyGems.org enviaría entonces un webhook a RubyDoc.info. Así pues, RubyDoc.info no tiene nada que ver —no guarda ninguna relación— con RubyGems.org. Enviaría un webhook a RubyDoc.info. RubyDoc.info diría: «Genial, se ha publicado una nueva gema». Y se descargaría la gema. Así que la descargaba, la ejecutaba y la abría dentro de un contenedor de Docker. El contenedor de Docker seguía teniendo acceso a la red. Ejecutaba el script malicioso. El script malicioso procedía a descargar datos del Gobierno del Reino Unido. Los empaquetaba en forma de gema. Subiría la gema a RubyGems.org. RubyGems.org enviaría un webhook a RubyDoc.info. RubyDoc.info ejecutaría scripts maliciosos. Descargaría datos del Gobierno del Reino Unido, etcétera, etcétera, etcétera. Así que esto era una locura. Me parece una locura. No me lo podía creer.
Todas estas piezas encajan así. Esto es una locura. Es decir, ¿cómo, cómo es posible, cómo demonios puede estar pasando esto? Quiero hacer, bueno, quiero hacer una especie de repaso cronológico de todo esto porque me ha dejado alucinado.
Así que los días 11 y 12 de mayo fue la campaña de «Gem Stuffer». Y en cuanto a esas gemas, puedes fijarte en las fechas de subida. Son del 11 de mayo. Muy bien. El informe sobre la caché de RubyGems se recibió el 6 de julio, y la persona que lo envió no tenía ninguna relación con OpenAI. No tenía ningún tipo de afiliación con ellos. Lo descubrieron por su cuenta. Así que estos bots llevaban al tanto de este problema desde hacía, no sé, un mes, o quizá dos, supongo. Y todo esto ocurrió mucho antes de la filtración de Hugging Face. Finalmente, el 12 de septiembre, Sydney y su equipo publicaron rubyhack.ai, y os animo a que lo leáis. Ofrece un análisis muy detallado de este ataque en concreto.
Lo que me ha dejado alucinado es que los bots se han dado cuenta de que RubyGems.org envía un webhook a RubyDoc.info. Han descubierto que Yard ejecuta código arbitrario en RubyDoc. Bueno, ejecuta código arbitrario. Luego se han dado cuenta de que rubydoc.info procesa YardDoc con conexión a Internet y ejecuta ese código. También se han dado cuenta de esto, o al menos eso es lo que sospecho. Si echas un vistazo a todas las gemas y a la campaña de «gem stuffing», te darás cuenta de que no todas contienen ese código para descargar claves. Y creo que se debe a que, cuando empezaron a subirse esas gemas, el equipo de RubyGems.org comenzó a cerrar las cuentas y a impedir que sus usuarios volvieran a iniciar sesión. Y el bot pensó: «Vaya, necesito una forma de iniciar sesión cuando no tengo credenciales. Así que usaré estas credenciales». Y decidieron lanzar un ataque de «0-day» contra RubyGems.org.
A mí me parece una locura. Se las ingeniaron para resolver este lío tan enrevesado. Y ahora, el equipo de RubyGems.org… ahí hay un final feliz. El equipo de RubyGems.org fue capaz de solucionarlo. Es decir, lo resolvieron por completo, lo arreglaron todo y taparon las fugas. Por lo que sabemos, nadie, absolutamente nadie, fue explotado. Así que no se causó ningún daño a la gente de la comunidad. Él está bien. Sí. Así que muchísimas gracias a todos ellos.
Así que quiero dejar esto más o menos aquí. Soy un optimista en lo que respecta a la IA. Utilizo la IA todos los días para escribir código. La uso constantemente. De hecho, quiero cambiar esto. Quiero cambiar un poco esto. Solo soy un optimista, no necesariamente un optimista en lo que respecta a la IA. Creo que este es el mejor momento para estar vivo, y realmente creo que las cosas solo pueden ir a mejor. Pero la cuestión es que, bueno, no he tenido que tomarme ninguna pastilla para ser optimista. De hecho, soy un hombre de mediana edad y no quiero tomarme más pastillas. Es que tengo… Oh, no. Tengo muchas, pero no creo que ser optimista respecto a la IA o respecto a nuestro futuro signifique necesariamente que tenga que rendirme ante ella.
He oído el argumento de que la IA no es más que nuestro próximo compilador. No hace falta leer el código máquina que genera tu compilador, ¿verdad? Entonces, ¿por qué habría que leer el código máquina o el código que genera tu IA? En cierto modo, puedo entender ese argumento. Es cierto, probablemente no leas el código máquina que genera tu compilador. Pero eso se debe a que el compilador te hizo una promesa, te hizo esta promesa: la regla «como si». Haga lo que haga, el comportamiento que puedes observar se corresponde con el código que escribiste. Y todo lo que te he mostrado en esta presentación te muestra lo que cuesta cumplir esa promesa. Ahora bien, tu IA no te ha hecho esta promesa. No existe una regla «como si» para tu inglés.
Así que no voy a dejar que la IA decida mi destino. Voy a seguir leyendo mi código, y espero que tú también lo hagas.
Y quiero terminar con esta diapositiva. Sube, perdedor, nos vamos a programar. Gracias.
Artículo publicado · Actualizado
