Back to "StyloBot-utsläppsserien"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Architecture ASP.NET Bot Detection Performance StyloBot

StyloBot-utsläppsserien

Monday, 01 June 2026

långvarig service tenderar att sakta drifta mot ett ogränsat minne. Här är det. Detta är hur jag ser den driften. Det här är vad jag tänker på att fixa utan att skriva på den. Det är ett fungerande exempel från StyloBot. Masket av vektors likhet som tog den från GB på Big Object Heap till under MB.

StyloBot

StyloBot-utsläppsserien

  1. Behavior, Inte Identitet: varför StyloBot modellerar klienter beteendemässigt
  2. Behavior-Är medveten ASP.NET UI: servern - skickade ut ytan över det upptäcktsresultatet
  3. Att hitta och lösa gränslös tillväxt i långtids-, -- och / eller körande- -.-nätverk: tillförlitlighetsdisciplinen ( fungerande exempel
  4. Behavior-Akvis typscript-UI: Express , Fastify, och webbladerkomponenter
  5. Sidecar-arkitekturen: hur detektoren kopplas till icke--.NET-skivor
  6. Att lära sig bli snabbare: adaptiv inlärningssystem, ,, fyra, M SK2, övningsminnet, ,, och bedömningsキャッシュen
  7. Att testa det som inte stannar kvar: kontrolldisciplinen : en BDF-fielddriver regression M SK2 belastning , och kalibrering
  8. StyloExtract - en lokal lärande HTML omvandlare till Markdown: HTML, →, Markdown-lagret som paras med detektorn, ,, walkersbug lucidVIEW som fångade, M SK3, och den hundföda-loopan som gjorde det ärligt

Det fungerade exemplet är StyloBots vektorlayer, , men disciplinen är: ' t Stylobot, -, specifikt, M SK3, Allt långt fungerande -, NET-tjänster som samlar upp tillstånd från trafiken växer till en klass av bugs du kan ta tag i.

Behavioralmodellen finns i Behavior, Inte Identitet; ASP.NET ytan i Behavior-Är medveten ASP.NET UI; källa på github.com/scottgalM SK2stylobot.


Problemets klass

lång tid.-. körde tjänster samlas in. .. varje cache, ,. varje lärandebutik. ,. varje . M SK4. vi, '. vi håller bara de sista N-ansökningarna. Mska6. buffern är en liten samlare. Mske7. Var och en ser bra ut på egen hand. M Ska8. Tillsammans med Mska9. i en process som varit igång i en vecka. M ska11. de skapar ett minne som inget test någonsin återskapar.

  • fungerar bra i dev, CI, och första dagen i produktion
  • flyter gradvis mot OOM, sen kraschar den eller börjar bläddra

Du fångar det här med medvetet att leta efter den, på en process som

steg 1: periodiska tillförlitlighetsbedömningar (synningen

Det första stycket i disciplinen är: "'", inte tekniskt, . , det är:

Varje få release , Jag slutar att lägga till egenskaper och tittar bara på det som körs under realistisk trafik, frågar ser något av det här ut fel? Inga specifika bugs, inga mål.

Den senaste recensionen fångade bugs den här postan är om: Mostlylucid.BotDetection.Demo process som sitter vid en GB-resident som ställs under syntetisk testtrafik.

Reglern: sätter en återhämtande lucka på kalendern för att läsa dina egna mätvärden. Att inte fixa något

steg 2: Låt bokstäver överraska dig

Det rätta verktyget för " är något fel på ?" dotnet-counters. Det, ', är gratis, ,, det, ', är redan installerat, ,, och den berättar sanningen om vad din process gör just nu.

Några kommandor senare på StyloBot demo:

dotnet.gc.last_collection.heap.size[loh]   13,393,217,096 bytes (13.4 GB)
dotnet.gc.heap.total_allocated              97 MB/sec
dotnet.exceptions[SqliteException]          4/sec

Det är tillräckligt för att veta vad som är fel utan att läsa någon kod än. Den stora objekthaufen var på 13.4 GB och växte på nästan 100 MB/secM SK3 Något allocaterade enorma objekt kontinuerligt

En snabb uppvärmning på LOH ( eftersom varje | . | NET dev träffar detta i slutändan

Den stora objektmasken är en av de vanligaste överraskningar när du först profilerar en lång service.

  • GC har tre generationer (Gen0, GenM SK2 Gen+2) för normala korta apparater - levande objekt . De flesta allocationer lever och dör i gen+0, vilket är billigt att samla in
  • Inget större än 85 KB går inte in i dessa generationer Den går rakt ut på Den stora objekthaufen.
  • LOH samlas bara under Gen2 GC, som är den mest dyra samlingen .NET gör
  • värre, standardmässigt är LOH inte kompakt när den är samlad, ;, så frigör den bara luckorna.., så även efter en gen, 2,, blir LOH fragmenterad.:, så har man fri plats.M SK4, men det är ' som är fel. -, stora hål för nästa fördelning.MSC7, nya stora objekt sträcker ut högen istället för att återanvända den.
  • Nettoeffekt.:. Håll till att fördela stora objekt i en stabil takt.,. Och din processor. '. minnesavtrycket sträcker sig uppåt oavsett om objekten fortfarande är refererade.M SK3. Det ser ut som ett utbrott även när det inte är det.

De största orsakerna till oavsiktlig LOH-ökning i verkliga .NET-tjänster

källa ♫ ♫ Varför hamnar det på LOH ♫
JsonSerializer.Serialize(obj) återvänder en string Ett ♫ 100 ♫ objekt blir ett ♫200 ♫ MB UTF ♫
MemoryStream du låter växa obegränsad Internal buffer dubbleras förbi 85 KB och stannar på LOH
byte[] buf = new byte[n] för stora n Direkt LOH-alokation
List<T> som växer förbi ~10 Kreferens - skriftippade föremål ryggradsskivan korsar ♫ 85 KB och landar på LOH МSK4
string.Concat / StringBuilder över större text slutligen ToString() är en enda enorm fördelning
XmlSerializer / DataContractSerializer av stora diagram Samma form som JSON fallet

Om du bara minns ett tumregler: vara misstänksam mot vilken kodsträng som producerar ett enskilt, sammanhängande stort objekt på en timer eller per begäran. Att "'" är den form som förstör långa tjänster, och det är precis den form vi kommer att hitta i StyloBot.

flowchart LR
    classDef input fill:none,stroke:#3b82f6,stroke-width:2px
    classDef store fill:none,stroke:#f59e0b,stroke-width:2px
    classDef async fill:none,stroke:#a855f7,stroke-width:2px
    classDef problem fill:none,stroke:#ef4444,stroke-width:2px

    A["Per-request handler<br/>Add to collection"]:::input --> B["Long-lived List / Dict / Buffer"]:::store
    C["Timer / autosave<br/>every N min"]:::async --> D["JsonSerializer / MemoryStream<br/>single &gt;85 KB allocation"]:::problem
    B --> D
    D --> E["Large Object Heap"]:::problem
    E --> F["Gen2 GC<br/>infrequent, expensive"]:::async
    F --> G["LOH not compacted by default<br/>fragments build up"]:::problem
    G --> H["Process RSS marches upward"]:::problem

verktyg som hjälper att diagnostisera det:

  • dotnet-counters för riktiga siffror (LOH-mätaren ovanför ).

  • dotnet-gcdump och dotnet-dump för kortbilder som du kan öppna i Visual Studio eller PerfView.

  • PerfView själv för att spåra fördelningarna ner till en stapel.

  • JetBrains CLI profiler ( vad jag faktiskt använde för den här omställningen

    • JetBrains.dotMemory.GlobalTools: ansluta , ta ett minnessnapshot , öppna .dmw arbetsyta senare för att se vilka bevarade rötter som håller LOH-aloktionerna.
    • JetBrains.dotTrace.GlobalTools: samma form för att ta prover | / | spårning ♫ / | data från tidsramen ♫ МSK3 ♫ Använd detta när du behöver en stapel istället för en mätare ♫
    dotnet tool install -g JetBrains.dotMemory.GlobalTools
    dotnet tool install -g JetBrains.dotTrace.GlobalTools
    
    dotMemory get-snapshot <pid> --save-to-dir=./snapshots
    dotTrace attach <pid> --profiling-type=Sampling --timeout=60s --save-to=./trace.dtp
    

    Tilldelning av arbetskraft för denna omställning dotnet-counters berättade för mig att LOH var problemet.

Det finns flykthäckar om du verkligen behöver dem. GCSettings.LargeObjectHeapCompactionMode = CompactOnce tvingar en one-off LOH kompaktion RecyclableMemoryStream från Microsoft pools buffer för att undvika fördelningen på första plats. Båda är plaster. Det verkliga lösningen är nästan alltid att sluta producera det gigantiska objektet på första Stelle

Tillbaka till diagnosen

Så när du ser en stabil LOH-ökning i en lång service, så är frågan nästan alltid densamma. vad "'" innebär att man ska fördela väldigt stora objekt på vilken tidpunkt, , och varför Svarets form (timer? begäranbehandlare ? serialisator M SK3 bufferpool ?) talar om var man ska titta i kod

De undantagliga motsvarigheterna spelade också någon roll 4/sec av SqliteException är tillräckligt låg för att inte dyka upp i journaler någon läser, men tillräckligt hög för att säga att en kodpfad tyst testar om igen.

Reglern: när något ser fel ut, bekräfta med dotnet-counters före att gissa. siffrorna kommer att förväxla sökradiet med en storleksordning

steg 3: Den felaktiga - avstraktions lukten | ( | fungerande exempel | : | HNSW för kakning |

Det är här som disciplinen blir intressant, eftersom frestelsen fläcka symptomet snarare än att ifrågasätta abstraktionen

Quick vocab ( för de kommande sektionernaM SK1

  • Vektor: ett fixerat array av float float[64]. Varje lucka är en mätt egenskap av begäran. ( siffransräkning, , timing, M SK3 IP-familjen, ,, etc., .).. Två begäran som МSK6, som känns lika, MSC7, producerar vektorer som är nära varandra i den dimensionella utrymmet. float[].
  • Vektors likhet: vanligtvis kosine likhet - kosinusen av vinkeln mellan två vektorer . närmare 1.0 = mer liknär . Den faktiska matematiken är en punktprodukt som är delat av två normer
  • Ungefär närmaste granne (ANNM SK1: "given den här vektorn, hittar den närmaste N av miljonerM SK3 snabbtMSC4 Brutto-- krafts jämförelse är O~(NM SK7~ per query~ .~ ANN-algoritmer får sub--~millisekunderresultat till kostnaden att ibland missa sant närmaste match.
  • HNSW: en av dessa ANN-algoritmer . Den bygger en lagerad graf där den övre skivan har ett fåtal brunn M SK2 sammankopplade noder och lägre skivor fyller i detaljer . Du går in på toppen och zoomar in stabila rader vektorer. inte designad för " varenda begäran lägger till en och gamla försvinner " | | - den relevanta punkten i hela det här meddelandet
  • Centroider: genomsnittet i en grupp av vektorer ♫ - ♫ en vektor som summerar klustern ♫. ♫ Om du har ♫ 10,000 ♫ vektorr som alla ser ungefär likadana ut ♫ МSK4 ♫ så kan du kasta bort dem och behålla ett centroid ♫

För StyloBot ledde LOH-ökningen till tre klassier som var strukturellt identiska HNSW (Hierarchisk navigerad liten världoriginal Malkov & Yashunins pappers) som används för likhetssökningar över undertecknande , sessie M SK2 och intentionsvectorn . Var och en hade ett fält som

private readonly List<float[]> _graphVectors = new();

Var och en fick hjälp av en lärande handledare som LearningEventType.FullDetection, som aktiveras varje HTTP- begäran. Varje begäran satte till sig en vektor . varje begäran . Det fanns ingen utfånganing

Och var femte minut, ,, serialiserade en själv sparande timer hela grafen till JSON och skrev den på disket.

private readonly TimeSpan AutoSaveInterval = TimeSpan.FromMinutes(5);

På demoskala, signatures.vectors.json var 104 MB intent.meta.json på 70 MBM SK1 intent.vectors.json vid 51 MBM SK1 JSON serialisering skapar en sammanhängande raden i minnet vid ♫ 100+ ♫ MB ♫. ♫ Den raden går direkt till LOH ♫

flowchart LR
    classDef input fill:none,stroke:#3b82f6,stroke-width:2px
    classDef async fill:none,stroke:#a855f7,stroke-width:2px
    classDef problem fill:none,stroke:#ef4444,stroke-width:2px

    R["HTTP request"]:::input --> H["LearningHandler<br/>FullDetection event"]:::async
    H --> L["List&lt;float[]&gt;<br/>graph vectors<br/>(no eviction)"]:::problem
    L -.5 min timer.-> J["JsonSerializer.Serialize<br/>~100 MB string"]:::problem
    J --> LOH["Large Object Heap"]:::problem
    L -->|grows every request| L
    LOH -.fragmentation.-> RSS["Process RSS climbs"]:::problem

Nu är det enkelt att lägga till ett kapsyl MaxVectors = 10_000, LRU utfånganing , skicka iväg det . Det skulle ha fungerat, . siffrorna skulle ha kommit ner, , brädan skulle se bra ut, M SK5 Det hade också varit fel

HNSW är en utmärkt teknik och jag använder den medvetet på andra ställen.Self-Hosted Vector Databases with Qdrant, RAG Hybridsökning och indexering, Minimalt tänkbara grafRAG). Pinecone, Veva, pgvector, och Qdrant alla använder den internt, men det är en indeks av ett corpus, inte ett kass, och vad StyloBot faktiskt behövde var en kass. " för varje aktiv fingeravtryck , spara ett litet fönster av nyliga beteendevektorer så att spårning kan jämföra den nuvarande begäran mot det förflutna beteendet från liknande Bot fingerprints repeti (stay hot); human fingerprint donM SK2t (evict Så enkel som möjligt post listade inprocessen HNSW-indexet som en egenskap

Det finns ett snabbt prov på lukten, : om du föreställer dig att sätta en hård kapsyl på konstruktionen. , är resultatet fortfarande semantiskt det du ville ha.

Reglern: när man ser en gränslös tillväxt, så är frågan inte " hur kan vi täcka den här strukturen men " är den rätta strukturen för vad vi gör Den första gömmer symptomet. Den andra hör vad det säger till dig.

steg 4: Välj den rätta formen , inte bara en kapsyl

När du fått den felaktiga abstraktionen, ' och , så skriver ert substituerande vanligtvis upp sig själv.

Heiß lager: en begränsad BoundedVectorCache<TEntry>, en tunn förpackning runt ConcurrentDictionary med tillgång.

retentionScorer: (_, entry) => entry.WasBot ? 2.0 : 1.0

Hållbar lager (FOSS): tre nya SQLitetablar (signature_centroids, session_centroids, intent_centroids) lagring av komprimerade centroider från en nattlig VectorCompactionService. Vector som lagras som råfloat MemoryMarshal.AsBytes:

internal static byte[] PackFloats(float[] v) =>
    MemoryMarshal.AsBytes(v.AsSpan()).ToArray();

Kompakt binär serialisering. Nej 100+ MB JSON-strängarM SK2 Nej LOHMSC3

Storage tier note. SQLite är den FOSS-versionen / eninlägsen - det binära bakvändet | | - | det rätta samtalet för | " | släppa exet på Pi och gå iväg | МSK4 | Commercial StyloBot använder PostgreSQL med pgvector, ger dig rätt indexerade likheterssökningar inuti databasen - på ett stabilt korpus , där HNSW tillhör M SK2 horisontell skala, , och gemensamt tillstånd över en flod, sqlite-vss är en startplötslig punkt om du vill bli bättre på SQLite men vill vara enskild.

Sammalikhetssökningen på den FOSS-härdliga lagern är brute-force cosine över alla raderSystem.Numerics.Tensors.TensorPrimitives). På kompakta, -, centraliserade skalor, (, hundratusen till ett par tusen rader och ),, tar en full skanning ~1-2, ms på en Pi,

En anteckning om Compaction:, eftersom "kompakationen ♫ ♫ " ♫ gör verkligt arbete i den här sektionen ♫ . ♫ Kompaktionsservicen kör nattligt och reducerar historien på två gång ♫

  • L1: tar alla de råda vectorna som skrevs idag , blandar de som är väldigt lika M SK2 och ersätter varje grupp med en centroid
  • L2: tar L, 1, centroider som är liknande under dagar och veckor, och sammanfogar dem igen. ., Aggressiv, ;, händer mindre ofta, M SK5, storskalig minskning. ♫ (, något annat ♫

Det här är samma mönster LSM-trädslagsmotorer | ( | RocksDB |, | LevelDB ♫ , | Cassandra | ) | användar för SSTables | МSK5 | nivå | 0 | håller nygranna data |- | strukturerade data ♫ ; | lägre nivåer håller kompakta data ♪ , | sammanfattade data \ . | att låna det fungerar eftersom tillgångsmönstret är samma | : | de flesta läsarna träffar nydata ♪ ; | en liten bråkdel av läsarna behöver den långa stapeln | SMk13 | inget gynnar från att hålla varje rå rad för alltid |

flowchart LR
    classDef input fill:none,stroke:#3b82f6,stroke-width:2px
    classDef store fill:none,stroke:#f59e0b,stroke-width:2px
    classDef async fill:none,stroke:#a855f7,stroke-width:2px
    classDef good fill:none,stroke:#22c55e,stroke-width:2px

    R["HTTP request"]:::input --> FP["Fast path<br/>BoundedVectorCache.TryGet"]:::good
    FP -->|hit| S["Use similarity signal"]:::good
    FP -->|miss| N["null<br/>other detectors still run"]:::good
    R -.post-response.-> BG["Background learning handler"]:::async
    BG --> DB["SQLite (FOSS)<br/>or Postgres+pgvector (paid)"]:::store
    BG --> WARM["Warm cache for next time"]:::async
    WARM --> FP
    NC["Nightly VectorCompactionService<br/>L1 then L2"]:::async --> DB
    DB --> NC

Den generella formen (bounded hot cache for the steady state + compact persistent store for history + a periodic compactor between them) dyker upp om och om igen i långa, fungerande lärande system. . Det är värt att hålla det i sin verktygslåda.. När någon tar sig fram till HNSW eller pgvector för en arbetslast som ' i själva verket är ett kass.

Reglern: föredrar den enklaste datastrukturen som passar starttidsmönster, inte den som passar in i datauppsättningen

steg 5: Få den snabba vägen att tolerera missar

Ett subtilt: ,, eftersom det handlar om tystnad snarare än addition.

Om den långsamma-fix-stigen inbegriper en databassuppsökning, är frestelsen att blockera den snabba vägen på den . DonMSC3 tM SK4 Detektorns snabba bana i StyloBot är synkroniseradMska5 och likhetskontrollen är en icke-blockerande ordbokuppsökning

if (!_cache.TryGet(signatureId, out var entry))
    return null; // no signal this request - other 48 detectors still run

En miss innebär ingen likhetsignal för den här begäran. De andra detektörerna fortsätter att fungera . En bakgrundsbehandlare frågar SQLite efter att beställningen tagit slut och värmer upp cachet till nästa gång

Det är här som de flesta kackersystem går fel på.: människor bygger upp en kacker, ,, gör fallbacken synkroniserad, ", för korrekthet, ",, och missstigen blir en "50", mps p, msp5, spike, M SK6. Cachet var tänkt att göra saker snabbare, ;, men istället gjorde det värsta fallet värre.

Reglern: om du kan' inte tolerera en miss

steg 6: Revidering varenda dag accumulator, inte bara den högljudda

När du har funnit en ogränsad struktur så antar du att det finns andra. Den högljudda är den som bröt först.

Den är mekanisk. vad gränsar dess storlek, ,, vem bestämmer när inskrywingerna lämnar, M SK1, och vad som är det värsta fallet under fiendtrafiken? Om du kan svara på alla tre i en mening Äphemerala signaler modell: allt som samlas måste förrutsna , utfånga, , eller kompakta

För StyloBot, var de flesta akkumulatorer redan bra-begränsadeM SK2

  • EphemeralPatternReputationCache: hårda kapsyln vid 10,000 siffran med bakgrundsdeka och LRU-utskrävning NeutralSuspectConfirmedBad statmaskin, och asymmetrisk hysteresi lever i Att lära sig bli snabbare)
  • BehavioralPatternAnalyzer: IMemoryCache med per-identitetsgränser (50 vägar
  • DriftDetectionHandler: 10,000 mönster
  • SessionEscalationService: 35-minuter TTL med timer-driven bortföring

En sak som behövdes uppmärksamhet bortom HNSW-klassen MarkovTracker._cohortBaselines. Den Markovkedjan spåraren håller per-kohorts baseline-transition matriser M SK1separade från per -signaturkedjorn, som redan hade LRU utfångas vid MaxTrackedSignatures). Kohortens baseliner | ( | en per trafikkohort som |" | datacenter | - | ny | МSK4 | eller |" | beboestad ♫ - | återvänd ♫ SelfMaintenanceOptions.MarkovCohortSize.

Reglern: varenda enton-samtal besvarar tre frågor eller så: ' är en bug, what bounds it, what evicts it, ,, what,', what is its worst?

steg 7: Gör varje gräns konfigurerad

Nästa avbrottsmodi efter "no bound" är "bound som ' inte stämmer för den här hårdvaran MaxEntries = 10_000 är okej tills någon driver din tjänst på en Pi4, eller i en behållare med M SK1 MB.

För StyloBot, landade varje gräns under en enda SelfMaintenanceOptions blockera in appsettings.json. Standardstandarden fungerar för en standardserver LowMemory statisk preset:

public static SelfMaintenanceOptions LowMemory => new()
{
    SignatureCacheSize  = 1_000,
    SessionCacheSize    = 500,
    IntentCacheSize     = 300,
    MarkovCohortSize    = 2_000,
    CacheSlidingExpiration = TimeSpan.FromHours(1),
};
builder.Services.AddBotDetection(opts =>
{
    opts.SelfMaintenance = SelfMaintenanceOptions.LowMemory;
});

Eller via appsettings.json för miljön-specifik tunning:

{
  "BotDetection": {
    "SelfMaintenance": {
      "SignatureCacheSize": 1000,
      "SessionCacheSize": 500,
      "IntentCacheSize": 300,
      "CentroidRetentionDays": 14
    }
  }
}

Den djupare punkten, :, en gräns som sets på ett ställe är något som operatörer kan resonera med. const int deklarationer är något som ingen kan resonera med.

Reglern: varje gräns som operatören kanske vill ändra för sina hårdvara-liv i en konfigurationsblock

Hur "fixed" ser ut

Speciellt för StyloBot (FOSS build, LowMemory preset

Komponent Innan Efter
Handskrift HNSW-index ♫ ♫ Ogränsad LOH ♫ МSK2 ♫ ♫
Sessions HNSW-index Ogränsad LOH ♫ ♫
Intens HNSW-index Ogränsad LOH
JSON-autosave buffer 100-500 MB LOH varje 5 min
Markov-kohorts baseliner Ogränsad
Hela vektorskivan 13+ GB LOH <6 MB

Detektormodellen är oföränderlig. Vad som ändrats är där likheterna finns och när det tillåts att påverka den snabba vägen. Centroider överlever återuppstartar i SQLite (FOSS) eller PostgresM SK3pgvector | (paid |); | nattlig kompaktion producerar fortfarande L | L

På en Pi4 med LowMemory preset , placerar den FOSS-byggnaden under 500 MB RSS efter uppvärmning M SK3 oändligt . Belönad Postgres MSC5 stödda utpostningar ärvt samma varmhet Mska6 kassdiskriminering Mske7 den bestående lagern skalas horisontellt istället för att bo bredvid processen M Ska8 på vilket sätt som helst Msaka9 förutsägbar stabiltMska10 tillståndets omlopp M ska11 nått efter upplärmningen Mcka12 oberoende av upptime eller trafiken Mikka13

Den generella lärdomen

Att lägga till en kapsyl begränsar minnet utan att ändra arkitekturen.

Pattern som återhämtar sig i långa system

  • Capa den varma vägen: begränsad, , i, -, minnet, M SK3, designad för att hantera misser, MSC4, bortskövning som är sammanlänkad med arbetsbelan
  • Komprimera historien: kompakt binär bestående lager , periodiskt kompakt
  • Kontrollera allt annat: varje akkumulator svarar vad som gränsar den, , vad som utfångar den,
  • Centralisera knobarna: en config block , inte femton consts
  • Skedulera tittandet: problemet existerar bara i det som körs

Fixera formen, inte symptomet.


Fortsättning avläsning

StyloBot-utsläpp serie

HNSW och vektorsökning i den här bloggen

Externa referenser

källan till implementeringen: github.com/scottgalM SK2stylobot. Livmotorn , brädan, , och kommersiella kontrollen stylobot.net.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.