Génération de code à la compilation
spin run gen (bin/gen.rb) exécute le DSL create_table de db/schema.rb et le DSL Routes.draw de config/routes.rb pour obtenir les données des tables et des routes. Il fait séparément un balayage lexical (des regex, pas un parseur — Spinel n’en expose aucun à l’exécution) de app/controllers/**/*.rb et app/models/**/*.rb à la recherche des affectations @ivar =, des noms before_action/after_action/rescue_from ... with: et des def sans argument.
À partir de cela, il écrit du Ruby simple et lisible sous gen/ :
gen/models/<name>.rb: accesseurs, conversions, finders, associations déduites des clés étrangères ;gen/routes.rb: la table des routes, le dispatcher, les helpers*_path/*_url;gen/controllers.rb:view_assignsetrun_callbackpar contrôleur ;gen/migrations.rb;gen/views.rb: la table des vues intégrées, vide sauf lors d’une compilation pour le déploiement ;gen/app.rb: le manifeste desrequire_relative.
gen/ est versionné, pas ignoré par git : spin build et spin test n’ont pas de hook pour générer d’abord, donc la sortie versionnée doit déjà être ce qui compile. La CI relance spin run gen et échoue s’il y a un diff : du code généré périmé ne peut donc pas être fusionné.
Les vues sont interprétées
Les fichiers app/views/**/*.html.erb ressemblent à de l’ERB de Rails, mais sont analysés en un AST au moment de la requête, puis parcourus par un interpréteur qui parcourt l’arbre. Spinel n’a pas d’eval, il s’agit donc d’une grammaire fermée (littéraux, lectures de @ivar/variables locales, appels via des tables de dispatch fixes par type, if/unless, each/each_with_index/un bloc de helper), pas de vrai Ruby. La grammaire complète, les helpers et les lacunes se trouvent dans la référence du langage de templates.
L’origine du source dépend du mode :
- En développement (et en test), le moteur lit
app/views/depuis le disque, et en développement il réanalyse un template quand celui-ci change, donc modifier une vue ne demande pas de recompilation. cybertrain buildlancespin run gen -- --embed-views, qui écrit le source de chaque template dansgen/views.rbsous forme de table de chaînes pour ce seulspin build, puis relancespin run genpour remettre la table vide versionnée.- Le binaire de production ne rend que cette table (en analysant chaque template une seule fois) et refuse de démarrer quand elle est vide (
error: views are not embedded in this binary).
Le serveur HTTP
Cybertrain::Server est un serveur HTTP/1.1 en Ruby pur : TCPServer#accept plus un green thread Spinel par connexion (ordonnancement M:N, pas de GVL), Request/Response typés, pas de couche Rack. TLS/HTTP/2 sont laissés à un reverse proxy. SPINEL_WORKERS vaut 1 par défaut — les spikes ont montré que c’est plus rapide et sans blocage pour cette charge limitée par les E/S à 100 connexions simultanées.
Le serveur appelle un HttpHandler (call(request) -> Response) ; ContextHandler le relie à la pile de middlewares, de sorte que la couche HTTP (cybertrain/http/, plus Params et Logger) ne dépend pas du reste du framework. script/check-http-layer l’impose dans la CI.
SQLite via FFI
Les modèles parlent à SQLite directement via ffi_func/ffi_lib de Spinel — pas d’extension C, pas de gem. Chaque requête est paramétrée, jamais interpolée. C’est le seul adaptateur aujourd’hui (PostgreSQL via la FFI de libpq est noté comme travail futur possible).
Un pool (SizedQueue, 4 connexions par défaut) distribue des connexions avec :
- WAL ;
- un délai d’attente sur verrou d’environ cinq secondes (un busy handler qui réessaie toutes les 100 µs, là où le
busy_timeoutde SQLite dort une milliseconde ou plus) ; - jusqu’à 64 requêtes préparées en cache ;
- les clés étrangères activées.
Comment une transaction emprunte l’une de ces connexions, et ce qui s’exécute dessus : Transactions.
La boucle de développement
cybertrain server (spin run gen, puis spin run NAME) en development (le CYBERTRAIN_ENV par défaut, sauf dans un binaire issu de cybertrain build) surveille aussi app/**/*.rb, config/**/*.rb, db/schema.rb et gen/**/*.rb toutes les demi-secondes. Les vues sont exclues (le moteur les recharge lui-même), tout comme gen/views.rb, vide en développement.
Un changement lance spin run gen && spin build NAME -- --incremental en arrière-plan : la compilation incrémentale de Spinel, qui ne recompile que les unités C touchées par la modification. Une version de Spinel sans --incremental rejette l’option, et la boucle compile alors sans elle.
Un succès demande un redémarrage (le même indicateur qu’un kill -HUP externe) : un thread de surveillance arrête l’écoute et laisse se terminer les requêtes ouvertes, puis le thread principal fait execv du nouveau binaire sur le même port et le même PID, de façon invisible pour un client en pleine session. Une compilation échouée continue de servir l’ancien binaire et affiche la sortie du compilateur en bannière sur chaque réponse HTML. Les recompilations sont suspendues tant que cybertrain build détient tmp/cybertrain-build.lock. Rien de tout cela n’est chargé en production.
Différences avec Rails
| Rails | CyberTrain |
|---|---|
rails console | Pas de console — Spinel n’a pas d’eval |
rails runner, lib/tasks/*.rake | Cybertrain::Main.command("name", "description") { |argv| ...; 0 } dans bin/<app>.rb : ./<app> name ARGS la lance avec la base de données connectée |
| Modifiez le code, l’application en cours d’exécution le prend en compte | Le Ruby demande une recompilation ; en développement, cybertrain server s’en charge pour vous (recompilation, arrêt, execv) |
| Modifiez une vue, aucun rechargement nécessaire | Pareil — en développement, les vues sont analysées depuis le disque à chaque requête |
def new | def new_action (new masquerait Klass.new(ctx)) |
before_action { do_thing } (self implicite) | before_action { |c| c.do_thing } — pas d’instance_exec sur un bloc stocké, donc les callbacks reçoivent explicitement le contrôleur/l’enregistrement |
resources :posts do resources :comments end | resources :posts do |posts| posts.resources :comments end — les blocs imbriqués reçoivent le mapper explicitement |
| ERB est compilé en méthode ; n’importe quel objet, n’importe quelle méthode | Interprété selon une grammaire/table de dispatch fixe (Langage de templates) |
| La session contient n’importe quel objet sérialisé (marshal) | Valeurs de session de type String uniquement, cookie signé HMAC |
| De nombreux adaptateurs de base de données | SQLite uniquement, via FFI |
Article.transaction { ... } renvoie la valeur du bloc ; raise ActiveRecord::Rollback ; savepoints avec requires_new: true | Article.transaction { |c| ... } passe la connexion au bloc et renvoie nil ; on en sort en levant une exception ; les appels imbriqués rejoignent la transaction externe, pas de savepoints (Transactions) |
has_many :through, includes, dependent:, enums, STI, associations polymorphes | Non implémenté ; les associations viennent uniquement des clés étrangères du schéma |
pluck(:a), sum(:a), group(:a).count, upsert, update_counters, where("title LIKE ?", "%#{q}%") | Des variantes typées, car une méthode Spinel a un seul type de retour : pluck_int et ses semblables, sum_int, count_by, upsert, increment(:col, by: n), where_like (Requêtes au-delà de where) |
N’importe quel nom de colonne ; une association peut masquer errors | Des identifiants ASCII uniquement ; une colonne nommée comme une méthode du framework ou un mot-clé Ruby est générée sous <column>_column, une association qui entre en conflit sous un nom de repli (Noms de colonnes) |
namespace, format/respond_to, constraints, mount | Non implémenté — noms plats, render json: uniquement |
rescue StandardError attrape un JSON.parse qui échoue | Sous Spinel, JSON::ParserError n’est pas une StandardError : le code de l’application doit faire rescue JSON::ParserError, StandardError. Le serveur l’attrape en dernier recours et répond 500 |
skip_forgery_protection dans un contrôleur d’API | Le CSRF s’exécute avant le routage, donc une API est exemptée par chemin, c.csrf_exempt_prefixes = ["/api/"], et authentifie elle-même ses appelants (Configuration) |
| Rack laisse un corps de requête gzip tel quel | Un corps Content-Encoding: gzip est request.decoded_body, décompressé sous un plafond de 10 Mio ; un corps corrompu ou trop gros donne une 400 ; request.body reste tel qu’envoyé (Chaînes de requête et corps) |
order("lower(title)"), order("posts.title"), order(params[:sort]) | order n’accepte que des listes column [ASC|DESC] ; les données de la requête passent par une liste blanche, le SQL brut par order_sql (Tri). Des paramètres mal formés ou trop imbriqués donnent une 400 (Chaînes de requête et corps) |
| Backtrace complète lors d’une exception | Classe, message, ligne de requête, nom/ligne du template — Spinel n’expose pas de backtraces |
Rack, ses middlewares, et n’importe quelle gem d’un Gemfile | Pas de compatibilité Rack ; un petit ensemble fixe de middlewares ; le spin-index propre à Spinel, limité à ce qui compile avec son sous-ensemble de Ruby |
| minitest / RSpec | Cybertrain::Test — les runners fondés sur la réflexion ne peuvent pas fonctionner avec une compilation AOT |
bin/rails generate authentication, has_secure_password + bcrypt | cybertrain generate authentication écrit la même structure plus une page d’inscription ; les mots de passe utilisent PBKDF2-HMAC-SHA256 (bcrypt demanderait une bibliothèque C ; le String#crypt de macOS n’a pas bcrypt) ; pas de réinitialisation du mot de passe (Ajouter la connexion) |
Active Storage, has_one_attached :cover | cybertrain generate attachment écrit cinq colonnes cover_* et les méthodes de modèle ; les fichiers résident sur le disque local sous storage/uploads/ et sont servis par send_blob ; un fichier par champ, ni variantes ni envois directs (Ajouter une image de couverture) |
skip_before_action :x | Pareil, pour les callbacks before nommés (un callback sous forme de bloc n’a pas de nom à sauter) ; pas de skip_after_action |
resource :session | Les mêmes six routes vers SessionsController ; pas d’imbrication en dessous |
get "/x" puis post "/x" | Les deux sont acceptés : le nom déduit de la seconde route est déjà pris, elle reste donc sans nom, comme dans Rails ; un as: en double reste une erreur |
Time.zone = "Asia/Tokyo", time.in_time_zone, les données de fuseaux fournies avec ActiveSupport | Cybertrain::TimeZone.find("Asia/Tokyo") lit les fichiers TZif du système (/usr/share/zoneinfo, ou TZDIR) et convertit des secondes epoch Integer (utc_offset_at, utc_to_local, local_to_utc) ; pas encore de Time.zone par requête ni de l(time, zone:) (Fuseaux horaires) |
Net::HTTP, Faraday | Cybertrain::HttpClient pour les webhooks et les API : chaque attente est bornée, les destinations non publiques sont refusées par défaut, ni redirections ni keep-alive (voir HTTP sortant) ; le Net::HTTP de Spinel bloque tout le serveur quand un pair ne répond plus |
Action Mailer (deliver_later, classes de mailer, vues) | Cybertrain::Mailer.deliver(to:, subject:, body:) envoie du texte brut en SMTP (STARTTLS, TLS implicite ou en clair vers le loopback ; AUTH PLAIN ; chaque attente bornée) ; ni templates, ni HTML, ni pièces jointes, ni file d’attente (voir Envoyer des e-mails) |
Active Job, perform_later, solid_queue, tâches récurrentes | Cybertrain::Job.handle(kind) et Job.enqueue(kind, payload, run_at:) (une charge utile String, réessayée avec un délai croissant), Cybertrain::Scheduler.every(seconds, name) ; un thread dans le processus qui sert (Tâches de fond) |
| Client ActionCable, faye-websocket, websocket-client-simple | Cybertrain::WebSocket.subscribe(name, url) garde un flux connecté tant que le serveur tourne (reconnexions, pings, détection de silence) ; WebSocket::Client pour une connexion synchrone unique ; sortant uniquement (Consommer des flux WebSocket) |
config.filter_parameters += [:passw, /\Apin\z/, ->(k, v) { ... }] | config.filter_parameters est un Array de String comparées par sous-chaîne, sans tenir compte de la casse (ni Regexp ni lambdas) ; il s’applique au journal des requêtes et à la page d’erreur de développement (Paramètres filtrés) |
get "up" => "rails/health#show" (une route vers un contrôleur) | GET /up est intégré et reçoit sa réponse avant les fichiers statiques, la session et le routeur, donc aucun before_action ne s’exécute ; il interroge la base de données et le worker, 200 ok ou 503 (Exploitation) |
Tâches bin/rails db:... pour les sauvegardes (aucune intégrée) | ./<app> db backup PATH / db backup DIR/ --keep N (VACUUM INTO, mode 0600, renommé à sa place) et ./<app> db vacuum |
| ActionCable (le serveur), asset pipeline, i18n | Non développé (hors des objectifs du MVP, docs/design.md §2.3) |
Liste non exhaustive : docs/design.md §8 (« What was deliberately left out ») en est l’inventaire plus complet.