[{"content":"A fleet of Go services kept getting OOM-killed inside containers whose memory limits were generous — two to three times the live heap the runtime reported. We had GODEBUG=madvdontneed=1 set from years back, so I expected RSS to track the heap closely. Instead RSS climbed in a sawtooth that never came back down, while runtime.MemStats.HeapReleased insisted the memory had been handed back to the kernel.\nBoth were telling the truth. The host had /sys/kernel/mm/transparent_hugepage/enabled set to always.\nWhat the scavenger does The runtime\u0026rsquo;s background scavenger walks free heap pages and returns them to the OS with madvise(2), in 64 KiB chunks, paced to spend about 1% of a CPU. Virtual address space is untouched; only the physical backing goes away.\nWhich flavor of madvise matters. MADV_DONTNEED unmaps the pages on the spot: RSS drops immediately, and the next touch page-faults a fresh zero page. MADV_FREE marks them lazily reclaimable: the kernel takes them only under memory pressure, so RSS stays high and monitoring lies.\nGo 1.12 switched Linux to MADV_FREE and added GODEBUG=madvdontneed=1 as the escape hatch. The misleading RSS was enough of a problem that Go 1.16 flipped the default back to MADV_DONTNEED (golang/go#42330). Our flag was redundant on a modern toolchain, but the behavior it asks for is exactly the default, so it is the behavior that matters below.\nKeep the MADV_FREE picture in mind, because it is also the reason the lazy variant does not suffer from what follows: pages that were never unmapped leave nothing for the kernel to fill back in.\nEnter transparent huge pages With THP in madvise mode the kernel only builds 2 MiB huge pages for ranges a process explicitly asks for. In always mode every anonymous mapping is eligible: page faults may be served with a whole huge page, and the khugepaged daemon scans in the background collapsing groups of 512 small pages into one huge page.\nHow aggressive that collapse is comes down to one knob, khugepaged/max_ptes_none. It says how many of the 512 entries may be empty and still be collapsed. The kernel default is 511. One live 4 KiB page in an otherwise empty 2 MiB region is enough: khugepaged allocates a fresh huge page, copies the 4 KiB, and zero-fills the other 2044 KiB.\nThe fight Put the two together and the scavenger and khugepaged are working against each other on the same memory.\nA region of the heap goes mostly free after a GC cycle. The scavenger calls madvise(MADV_DONTNEED) on the free 64 KiB chunks. That splits the huge page and punches holes in the page table: PTEs go from present to none. RSS drops. khugepaged comes around, sees one present PTE and 511 none, which is within max_ptes_none, and collapses the region again. The kernel hands back a full 2 MiB, zero-filled. RSS is back where it was, plus whatever the application grew in the meantime. The runtime has no way to see step 4. From its point of view the pages were released; HeapReleased still counts them, and GOMEMLIMIT is tuned against heap accounting that no longer matches what the cgroup is charging. Every cycle burns CPU on split, zap, allocate, copy, zero-fill — and the RSS floor ratchets up until the OOM killer ends it.\nMADV_FREE would not have raced the same way: lazily freed pages stay mapped until the kernel actually reclaims them, so there are no holes for khugepaged to fill — and no RSS drop either. MADV_DONTNEED is the right call, but it is what creates the sparse regions always is so eager to re-inflate. The kernel considers that a feature; the comment above the check in mm/khugepaged.c reads \u0026ldquo;default collapse hugepages if there is at least one pte mapped like it would have happened if the vma was large enough during page fault.\u0026rdquo;\nThis is a known interaction, and Go used to fight it. Before Go 1.21.1 the runtime worked around the kernel default by marking scavenged memory MADV_NOHUGEPAGE and flipping it back with MADV_HUGEPAGE, unevenly and at a CPU cost. 1.21.1 dropped that workaround, 1.21.4 removed the last of the runtime\u0026rsquo;s huge page hints, and the memory growth people saw on upgrade became golang/go#64332. The maintainers\u0026rsquo; position there is that this belongs to whoever owns the host, not the language runtime, and the GC guide was updated to say so.\nThe fix The kernel side is a one-liner either way. Take THP away from anything that does not ask for it:\necho madvise \u0026gt; /sys/kernel/mm/transparent_hugepage/enabled Or keep always and follow the GC guide, which asks for two settings whenever THP is on for Go programs:\necho 0 \u0026gt; /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none echo defer+madvise \u0026gt; /sys/kernel/mm/transparent_hugepage/defrag We went with the second. Other workloads on those hosts wanted huge pages, and so did our bigger Go heaps: since 1.21.4 the runtime never asks for huge pages on the heap, so madvise mode means no huge pages for Go at all. With max_ptes_none at zero, khugepaged only collapses regions that are already fully populated, which is the one case where a huge page is pure win. RSS stopped ratcheting the same day.\nSame region, same scavenger, same khugepaged scan. The only difference is step 3 now says \u0026ldquo;not eligible\u0026rdquo;, so the release in step 2 is the last thing that happens to those pages.\nIf you cannot touch the host, two process-level escapes exist. Any Go version can call unix.Prctl(unix.PR_SET_THP_DISABLE, 1, 0, 0, 0) at startup to opt the whole process out of THP. Go 1.21.6 and later also ship GODEBUG=disablethp=1, which marks every heap arena MADV_NOHUGEPAGE as it is mapped. The runtime documents that one as a compatibility setting that may be removed, so treat it as a stopgap.\nTakeaway madvdontneed=1 was never the problem, and GOMEMLIMIT was never going to be the fix. When RSS and HeapReleased disagree, the kernel is doing something with memory the runtime believes it gave away; check /sys/kernel/mm/transparent_hugepage/ before you check the heap.\n","permalink":"https://mvega.dev/posts/go-scavenger-thp-oom/","summary":"\u003cp\u003eA fleet of Go services kept getting OOM-killed inside containers whose\nmemory limits were generous — two to three times the live heap the\nruntime reported. We had \u003ccode\u003eGODEBUG=madvdontneed=1\u003c/code\u003e set from years back,\nso I expected RSS to track the heap closely. Instead RSS climbed in a\nsawtooth that never came back down, while \u003ccode\u003eruntime.MemStats.HeapReleased\u003c/code\u003e\ninsisted the memory had been handed back to the kernel.\u003c/p\u003e\n\u003cp\u003eBoth were telling the truth. The host had\n\u003ccode\u003e/sys/kernel/mm/transparent_hugepage/enabled\u003c/code\u003e set to \u003ccode\u003ealways\u003c/code\u003e.\u003c/p\u003e","title":"When THP always Undoes the Go Scavenger's Work"},{"content":"While testing an internal HTTP service against Go 1.27, a handler that had worked for years started reporting errors from an unlikely place: r.Body.Close().\nThe handler does what any length-aware decoder does — it reads exactly the payload it expects and stops, without reading far enough to observe io.EOF. Then it closes the body. On Go 1.26 that Close returned nil. On 1.27 it returns io.EOF.\nPinning it down Timer-driven HTTP repros are flaky, so I drove net/http over a net.Pipe with a one-shot listener: fully deterministic, no real sockets, no timeouts. The whole thing fits in a playground link. A handler reads 2 of 4 body bytes, closes, and reports the error:\nreq.Body.Close() = EOF want \u0026lt;nil\u0026gt; Bisecting pointed at CL 794640, \u0026ldquo;net/http: don\u0026rsquo;t rely on server request body not changing\u0026rdquo;, which consolidated server-side body draining into (*body).Close. The old drain path cleared the sentinel after a successful drain:\nn, err = io.CopyN(io.Discard, bodyLocked{b}, maxPostHandlerReadBytes) if err == io.EOF { err = nil } The refactor kept the drain, improved the bookkeeping — and dropped the err = nil. Draining to EOF is the success path here, but the sentinel now leaks out of Close. The CL message describes a pure refactor, so the behavior change looks unintended.\nUpstream Filed as golang/go#80964, fix sent as golang/go#80968: restore the clear inside the new EOF branch, keeping the CL\u0026rsquo;s maxPostHandlerReadBytes+1 read bound and the earlyClose/sawEOF bookkeeping intact. One line, plus a regression test.\nTakeaway Close errors are contract, even when nobody documents them. A refactor that only \u0026ldquo;moves code around\u0026rdquo; can still change what callers observe on the error path — and somewhere, a decoder that trusts its Content-Length will notice.\n","permalink":"https://mvega.dev/posts/go-http-body-close-eof/","summary":"\u003cp\u003eWhile testing an internal HTTP service against Go 1.27, a handler that\nhad worked for years started reporting errors from an unlikely place:\n\u003ccode\u003er.Body.Close()\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThe handler does what any length-aware decoder does — it reads exactly\nthe payload it expects and stops, without reading far enough to observe\n\u003ccode\u003eio.EOF\u003c/code\u003e. Then it closes the body. On Go 1.26 that \u003ccode\u003eClose\u003c/code\u003e returned\n\u003ccode\u003enil\u003c/code\u003e. On 1.27 it returns \u003ccode\u003eio.EOF\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"pinning-it-down\"\u003ePinning it down\u003c/h2\u003e\n\u003cp\u003eTimer-driven HTTP repros are flaky, so I drove \u003ccode\u003enet/http\u003c/code\u003e over a\n\u003ccode\u003enet.Pipe\u003c/code\u003e with a one-shot listener: fully deterministic, no real\nsockets, no timeouts. The whole thing fits in a\n\u003ca href=\"https://go.dev/play/p/P6gM5GS84Vq\"\u003eplayground link\u003c/a\u003e. A handler reads 2\nof 4 body bytes, closes, and reports the error:\u003c/p\u003e","title":"When Request.Body.Close Started Returning io.EOF"},{"content":"Notes from Alexander Baygeldin\u0026rsquo;s GopherCon 2026 talk, \u0026ldquo;(Sync)testing Concurrent Code with Confidence.\u0026rdquo;\nTesting concurrent code used to mean picking one of three bad options: hacky (poke at internals), flaky (time.Sleep(100 * time.Millisecond) and hope), or slow (sleep long enough that it\u0026rsquo;s reliable). testing/synctest removes the tradeoff by giving the test a fake clock.\nThe fake clock Inside synctest.Test, time doesn\u0026rsquo;t pass on its own. It jumps forward only when every goroutine in the bubble is blocked. A one-hour timeout resolves instantly and deterministically.\nfunc TestCacheExpiry(t *testing.T) { synctest.Test(t, func(t *testing.T) { c := NewCache(time.Hour) c.Set(\u0026#34;k\u0026#34;, \u0026#34;v\u0026#34;) time.Sleep(time.Hour + time.Second) // instant if _, ok := c.Get(\u0026#34;k\u0026#34;); ok { t.Error(\u0026#34;entry should have expired\u0026#34;) } }) } No real sleep, no flake, no arbitrary duration constant chosen because it passed on your laptop.\nWaiting for goroutines, not for time synctest.Wait blocks until every other goroutine in the bubble is durably blocked. It replaces the \u0026ldquo;sleep and hope the worker got there\u0026rdquo; pattern outright.\nfunc TestWorkerConsumes(t *testing.T) { synctest.Test(t, func(t *testing.T) { ch := make(chan int) go worker(ch) ch \u0026lt;- 1 synctest.Wait() // worker has processed it, or is blocked again // assert on post-processing state }) } The constraints are the point The bubble only knows about blocking it can see: channels, mutexes, time.Sleep, sync.WaitGroup. Real I/O — a network read, a syscall — is opaque to it, so a goroutine parked there is not \u0026ldquo;durably blocked\u0026rdquo; and the clock will not advance.\nThat sounds like a limitation. Baygeldin\u0026rsquo;s argument is that it\u0026rsquo;s a design signal: code that can\u0026rsquo;t be tested under synctest usually has its I/O tangled up with its coordination logic. Separate them — push the network call behind an interface, keep the timing and orchestration pure — and both the tests and the design get better.\nGo 1.27 adds synctest.Sleep() as a helper. Small addition, but it signals the package is settling in rather than being reworked.\nTalk repo\n","permalink":"https://mvega.dev/posts/synctest-concurrent-testing/","summary":"\u003cp\u003eNotes from Alexander Baygeldin\u0026rsquo;s GopherCon 2026 talk, \u0026ldquo;(Sync)testing\nConcurrent Code with Confidence.\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eTesting concurrent code used to mean picking one of three bad options:\nhacky (poke at internals), flaky (\u003ccode\u003etime.Sleep(100 * time.Millisecond)\u003c/code\u003e\nand hope), or slow (sleep long enough that it\u0026rsquo;s reliable). \u003ccode\u003etesting/synctest\u003c/code\u003e\nremoves the tradeoff by giving the test a fake clock.\u003c/p\u003e\n\u003ch2 id=\"the-fake-clock\"\u003eThe fake clock\u003c/h2\u003e\n\u003cp\u003eInside \u003ccode\u003esynctest.Test\u003c/code\u003e, time doesn\u0026rsquo;t pass on its own. It jumps forward\nonly when every goroutine in the bubble is blocked. A one-hour timeout\nresolves instantly and deterministically.\u003c/p\u003e","title":"Testing Concurrent Go with synctest"},{"content":"Notes from Naoki Kuroda\u0026rsquo;s GopherCon 2026 talk, \u0026ldquo;Loosening the Reins: Go Generics Get More Flexible.\u0026rdquo;\nThe talk starts from one line in the Go 1.26 release notes:\ntype Ordered[T Ordered[T]] interface { ... } A constraint that refers to itself. Go 1.25 rejected it as an invalid recursive type; Go 1.26 accepts it. Kuroda went through the spec history, issues, and the go/types checker to find out what changed — and the answer wasn\u0026rsquo;t \u0026ldquo;we found a clever fix.\u0026rdquo; The original rule had simply been drawn wider than the problem it was guarding against.\nThe pattern, repeated That turned out to be the shape of every generics relaxation since 1.18:\nGo 1.20 — relaxed rules around comparable, letting more types satisfy constraints that had been over-restricted. Go 1.25 — further loosening in the type checker. Go 1.26 — self-referential constraints, the example above. Go 1.27 — methods can declare their own type parameters, and function type inference generalizes to every context where a generic function is assigned to a matching function type. Each time the question was the same: what problem was the original restriction actually preventing? And each time the honest answer was that the rule caught the real problem plus a lot of valid code alongside it.\nWhy the rules started strict This is the part worth internalizing. Generics landed in 1.18 with conservative rules on purpose. Restrictions can be removed later without breaking anyone\u0026rsquo;s code; permissions cannot. A rule that turns out to be too tight is an annoyance you can fix in a later release. A rule that turns out to be too loose is a compatibility promise you\u0026rsquo;re stuck with under Go 1\u0026rsquo;s guarantee.\nSo the loosening isn\u0026rsquo;t the type checker catching up to what it should have allowed all along. It\u0026rsquo;s the plan working as intended.\nWhat it means in practice If you hit a puzzling \u0026ldquo;this is not allowed\u0026rdquo; from the type checker, there are now two live possibilities rather than one. It might be protecting you from something genuinely unsound. Or it might be a rule that outlived the problem it was written for — worth checking the issue tracker before restructuring your API around it.\nTalk repo · Slides\n","permalink":"https://mvega.dev/posts/go-generics-loosening/","summary":"\u003cp\u003eNotes from Naoki Kuroda\u0026rsquo;s GopherCon 2026 talk, \u0026ldquo;Loosening the Reins: Go\nGenerics Get More Flexible.\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eThe talk starts from one line in the Go 1.26 release notes:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eOrdered\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eOrdered\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e]] \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e { \u003cspan style=\"color:#f92672\"\u003e...\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eA constraint that refers to itself. Go 1.25 rejected it as an invalid\nrecursive type; Go 1.26 accepts it. Kuroda went through the spec\nhistory, issues, and the \u003ccode\u003ego/types\u003c/code\u003e checker to find out what changed —\nand the answer wasn\u0026rsquo;t \u0026ldquo;we found a clever fix.\u0026rdquo; The original rule had\nsimply been drawn wider than the problem it was guarding against.\u003c/p\u003e","title":"Why Go's Generics Rules Keep Loosening"},{"content":"I worked through Joel Boursiquot\u0026rsquo;s GopherCon 2026 workshop, Agentic Systems the Hard Way. Zero third-party dependencies. The framing that stuck: Go is the control plane; the LLM is the decision engine. Here\u0026rsquo;s the core of it.\nWhat an agent actually is Model + tools + loop. The model never executes anything — it emits a wish (\u0026ldquo;call search_docs with these args\u0026rdquo;), your Go code validates and runs it, feeds the result back, and repeats until the model answers without asking for tools. Deterministic Go around a nondeterministic model. That\u0026rsquo;s the whole trick.\nLocal model with Ollama ollama serve # listens on 127.0.0.1:11434 ollama pull qwen3:8b # or llama3.2:3b on 8 GB machines Ollama\u0026rsquo;s /api/chat speaks a tool-calling protocol borrowed from OpenAI: you POST a transcript plus tool specs, the reply may carry tool_calls. Plain net/http and encoding/json cover it.\nThe loop Tools are anything that can describe itself and run:\ntype Tool interface { Name() string Description() string Schema() json.RawMessage Run(ctx context.Context, args json.RawMessage) (string, error) } type Message struct { Role string `json:\u0026#34;role\u0026#34;` // system, user, assistant, tool Content string `json:\u0026#34;content\u0026#34;` ToolCalls []ToolCall `json:\u0026#34;tool_calls,omitempty\u0026#34;` } The loop itself, adapted from the workshop\u0026rsquo;s module 4:\nfunc Loop(ctx context.Context, cfg Config, question string) (Result, error) { messages := []Message{ {Role: \u0026#34;system\u0026#34;, Content: systemPrompt}, {Role: \u0026#34;user\u0026#34;, Content: question}, } for step := 1; step \u0026lt;= cfg.MaxSteps; step++ { reply, err := callModel(ctx, cfg, messages, cfg.Tools) if err != nil { return Result{}, fmt.Errorf(\u0026#34;step %d: %w\u0026#34;, step, err) } if len(reply.ToolCalls) == 0 { // No tool calls means the model is done talking. return Result{Answer: reply.Content, Steps: step}, nil } messages = append(messages, reply) messages = append(messages, executeToolCalls(ctx, cfg.Tools, reply.ToolCalls)...) } return Result{Exhausted: true}, nil } Three policies hide in there, and each is a decision, not a framework default: termination is \u0026ldquo;a reply with no tool calls\u0026rdquo;; hitting MaxSteps returns a best-effort answer, not an error; and the full transcript — every assistant reply and tool result — is carried forward each iteration.\nLessons Route model mistakes back, don\u0026rsquo;t crash. An unregistered tool name or bad JSON args becomes a role: \u0026quot;tool\u0026quot; message like error: no tool named \u0026quot;x\u0026quot; is registered. The model sees its own mistake and self-corrects. Reserve Go errors for what the model can\u0026rsquo;t fix. Prompts are code. The workshop\u0026rsquo;s system prompt encodes hard-won operational detail — search one concrete word at a time, read the full doc before answering. Version and review it like code. Read-only tools bound the blast radius. The corpus tools reject path traversal (.., separators) before touching the filesystem. Send deterministic requests. Tool specs get sorted by name so offline tests can assert on exact request bodies. The workshop goes further — guardrails, evals, multi-agent composition, MCP, OTel — one earned abstraction per module. Worth the four hours: jboursiquot/gc26buildingagenticsystems.\n","permalink":"https://mvega.dev/posts/go-agents-ollama/","summary":"\u003cp\u003eI worked through Joel Boursiquot\u0026rsquo;s GopherCon 2026 workshop, \u003ca href=\"https://github.com/jboursiquot/gc26buildingagenticsystems\"\u003eAgentic\nSystems the Hard Way\u003c/a\u003e.\nZero third-party dependencies. The framing that stuck: Go is the control\nplane; the LLM is the decision engine. Here\u0026rsquo;s the core of it.\u003c/p\u003e\n\u003ch2 id=\"what-an-agent-actually-is\"\u003eWhat an agent actually is\u003c/h2\u003e\n\u003cp\u003eModel + tools + loop. The model never executes anything — it emits a\nwish (\u0026ldquo;call \u003ccode\u003esearch_docs\u003c/code\u003e with these args\u0026rdquo;), your Go code validates and\nruns it, feeds the result back, and repeats until the model answers\nwithout asking for tools. Deterministic Go around a nondeterministic\nmodel. That\u0026rsquo;s the whole trick.\u003c/p\u003e","title":"Building Agentic Systems in Go with Ollama"},{"content":"I\u0026rsquo;ve been running local models through Ollama\u0026rsquo;s HTTP API for a while. It works, but it means a separate daemon, JSON over localhost, and no control over when models load or unload. Kronk, from Ardan Labs, takes the other route: it\u0026rsquo;s a Go SDK that binds llama.cpp (and whisper.cpp, via Bucky) directly into your process. No Python, no sidecar server. The model lives in your binary\u0026rsquo;s memory and you drive it with Go calls.\nWhy in-process The tradeoff versus calling an HTTP API is simple. In-process you get direct control over model loading and lifetime, application-level caching and concurrency, and one fewer moving part to deploy. The HTTP route wins when multiple clients need the same model. Kronk covers both: the same SDK powers a model server with OpenAI- and Anthropic-compatible APIs (more below).\nSetup brew install ardanlabs/kronk/kronk # or go install github.com/ardanlabs/kronk/cmd/kronk@latest The SDK downloads compatible native libraries and models on first run — versions are pinned per Kronk release, so don\u0026rsquo;t mix in your own llama.cpp build. Hardware acceleration is per-platform: Metal on macOS, CUDA/Vulkan/HIP/ROCm/SYCL on Linux.\nMinimal chat Condensed from examples/question in the repo. Download the native libs, init, download a model, load it, stream a chat completion:\npackage main import ( \u0026#34;context\u0026#34; \u0026#34;fmt\u0026#34; \u0026#34;github.com/ardanlabs/kronk/sdk/kronk\u0026#34; \u0026#34;github.com/ardanlabs/kronk/sdk/kronk/model\u0026#34; \u0026#34;github.com/ardanlabs/kronk/sdk/tools/libs\u0026#34; \u0026#34;github.com/ardanlabs/kronk/sdk/tools/models\u0026#34; ) func run(ctx context.Context) error { lb, err := libs.New(libs.WithDetect(ctx, kronk.FmtLogger)) if err != nil { return err } if _, err := lb.Download(ctx, kronk.FmtLogger); err != nil { return err } if err := kronk.Init(kronk.WithLibPath(lb.LibsPath())); err != nil { return err } mdls, err := models.New() if err != nil { return err } mp, err := mdls.Download(ctx, kronk.FmtLogger, \u0026#34;unsloth/Qwen3-0.6B-Q8_0\u0026#34;) if err != nil { return err } krn, err := kronk.New( model.WithModelFiles(mp.ModelFiles), model.WithAutoTune(true), ) if err != nil { return err } defer krn.Unload(context.Background()) d := model.D{ \u0026#34;messages\u0026#34;: model.DocumentArray( model.TextMessage(model.RoleUser, \u0026#34;Why is the sky blue?\u0026#34;), ), \u0026#34;temperature\u0026#34;: 0.7, \u0026#34;max_tokens\u0026#34;: 2048, } ch, err := krn.ChatStreaming(ctx, d) if err != nil { return err } for resp := range ch { if resp.Choices[0].FinishReason() == model.FinishReasonStop { break } fmt.Print(resp.Choices[0].Delta.Content) } return nil } model.D is a document type mirroring the OpenAI request shape, so the request parameters look familiar. The response deltas carry Reasoning separately from Content if the model emits thinking tokens.\nThe model server When you do want HTTP, kronk server start runs a server on localhost:11435 built on the same SDK: OpenAI-compatible Chat Completions, Responses, embeddings, reranking, transcription, plus an Anthropic-style Messages API and a browser UI for model management. Existing OpenAI-client code points at it unchanged.\nWhen to use it Use the SDK when inference belongs inside a Go service — agents, RAG pipelines, embedding jobs — and you want lifecycle control without a daemon. Use the server (or stick with Ollama) when several clients share models or you need auth, rate limiting, and metrics out of the box. Skip Kronk if you\u0026rsquo;re not on Go or need image generation in production — Malina, its stable-diffusion.cpp SDK, is still experimental.\n","permalink":"https://mvega.dev/posts/kronk-local-inference/","summary":"\u003cp\u003eI\u0026rsquo;ve been running local models through Ollama\u0026rsquo;s HTTP API for a while. It works, but it means a separate daemon, JSON over localhost, and no control over when models load or unload. \u003ca href=\"https://github.com/ardanlabs/kronk\"\u003eKronk\u003c/a\u003e, from Ardan Labs, takes the other route: it\u0026rsquo;s a Go SDK that binds llama.cpp (and whisper.cpp, via Bucky) directly into your process. No Python, no sidecar server. The model lives in your binary\u0026rsquo;s memory and you drive it with Go calls.\u003c/p\u003e","title":"Local LLM Inference in Go with Kronk"},{"content":"Go 1.27 lands this month. The release notes are long; these are the changes I actually care about.\nGeneric methods The headline: methods can finally declare their own type parameters. Helpers that used to be package-scoped functions can now live on the type they belong to.\ntype Set[E comparable] struct{ m map[E]struct{} } func (s *Set[E]) Map[T comparable](f func(E) T) *Set[T] { out := \u0026amp;Set[T]{m: make(map[T]struct{}, len(s.m))} for e := range s.m { out.m[f(e)] = struct{}{} } return out } One catch: interface methods cannot declare type parameters, so you cannot abstract over generic methods with an interface.\nencoding/json/v2 A full revision of encoding/json, on by default. Stricter than v1 — it rejects invalid UTF-8 and duplicate object keys — and unmarshaling is significantly faster. The new encoding/json/jsontext package exposes the low-level token stream. Old code keeps working; GOEXPERIMENT=nojsonv2 opts out if something breaks.\nCheaper small allocations The compiler now emits size-specialized allocation routines: small allocations (under 80 bytes) get up to 30% cheaper for ~60 KB of binary size. Free performance for allocation-heavy code — most Go services, in practice.\nGoroutine leak profiles runtime/pprof gains a goroutineleak profile, generally available after being experimental. It finds goroutines blocked on concurrency primitives nothing else can reach:\ncurl localhost:6060/debug/pprof/goroutineleak Leaked goroutines used to mean staring at full goroutine dumps and guessing which of them would never wake up. Now there\u0026rsquo;s a profile that answers exactly that question.\nGrab bag New uuid package in the standard library — one less dependency. strings.CutLast and bytes.CutLast complete the Cut family. crypto/mldsa brings post-quantum signatures (FIPS 204), and TLS gains MLKEM1024 plus ML-DSA signature schemes. Experimental portable SIMD under GOEXPERIMENT=simd. time package channels are now always unbuffered; the asynctimerchan escape hatch is gone. Upgrade motivation ranked: allocation speedup for free, json/v2 for correctness, generic methods for API design headroom.\n","permalink":"https://mvega.dev/posts/go-1-27/","summary":"\u003cp\u003eGo 1.27 lands this month. The \u003ca href=\"https://go.dev/doc/go1.27\"\u003erelease notes\u003c/a\u003e\nare long; these are the changes I actually care about.\u003c/p\u003e\n\u003ch2 id=\"generic-methods\"\u003eGeneric methods\u003c/h2\u003e\n\u003cp\u003eThe headline: methods can finally declare their own type parameters.\nHelpers that used to be package-scoped functions can now live on the\ntype they belong to.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSet\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eE\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ecomparable\u003c/span\u003e] \u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e{ \u003cspan style=\"color:#a6e22e\"\u003em\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003emap\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eE\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e{} }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eSet\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eE\u003c/span\u003e]) \u003cspan style=\"color:#a6e22e\"\u003eMap\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ecomparable\u003c/span\u003e](\u003cspan style=\"color:#a6e22e\"\u003ef\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eE\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eSet\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e] {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003eout\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eSet\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e]{\u003cspan style=\"color:#a6e22e\"\u003em\u003c/span\u003e: make(\u003cspan style=\"color:#66d9ef\"\u003emap\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e{}, len(\u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003em\u003c/span\u003e))}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ee\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003erange\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003em\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003eout\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003em\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003ef\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ee\u003c/span\u003e)] = \u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e{}{}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eout\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eOne catch: interface methods cannot declare type parameters, so you\ncannot abstract over generic methods with an interface.\u003c/p\u003e","title":"Go 1.27: The Highlights"},{"content":"Git identity is self-declared. user.email is a config value, not a credential — anyone can set it to your address, commit, and push wherever they have write access. GitHub renders your avatar on the result. Nothing in the process verifies that you wrote anything.\nCommit signing fixes that, and since Git 2.34 you can do it with the SSH key you already push with. No GPG keyring, no key servers, no expiry dance.\nSetup Four config lines:\n[user] signingkey = ~/.ssh/id_ed25519.pub [gpg] format = ssh [commit] gpgsign = true Note signingkey points at the public key. Git derives the private half from it — and if you use an agent, it never touches the file at all.\nThen upload the same public key to GitHub a second time, as a signing key (Settings → SSH and GPG keys → New SSH key → key type: signing). Authentication keys and signing keys are separate slots even when the key material is identical; uploading to one does not populate the other. With gh installed:\ngh ssh-key add ~/.ssh/id_ed25519.pub --type signing --title \u0026#34;signing-key\u0026#34; That needs the admin:ssh_signing_key scope, which the default gh login does not include — gh auth refresh -h github.com -s admin:ssh_signing_key first if it 404s.\nVerifying locally GitHub verifies signatures server-side against the key you uploaded. For git log --show-signature to work on your own machine, Git needs to know which keys to trust, via an allowed-signers file:\necho \u0026#34;you@example.com $(cat ~/.ssh/id_ed25519.pub)\u0026#34; \\ \u0026gt; ~/.config/git/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers The first field is an email, and it must match the committer email on the commits you want verified — a mismatch produces \u0026ldquo;No principal matched\u0026rdquo; even though the signature itself is fine. Then:\n$ git log -1 --show-signature Good \u0026#34;git\u0026#34; signature for you@example.com with ED25519 key SHA256:TbzyV5... What this does and doesn\u0026rsquo;t get you It proves a commit was made by someone holding your private key. That is a real improvement over an unauthenticated email string, and it is what GitHub\u0026rsquo;s Verified badge attests to.\nIt does not prove the commit is good, and it does not protect a key sitting readable on a laptop that gets compromised. If you want the private half to be genuinely unextractable, generate the key on a hardware token:\nssh-keygen -t ed25519-sk -C \u0026#34;signing key\u0026#34; Same config, same GitHub upload — the key now lives on the token and signing requires a physical touch. Buy two and register both, because a hardware key you lose without a backup is a lockout, not an inconvenience.\nOne caveat about history Signing is not retroactive. Commits made before you turned it on stay unsigned, and rewriting history to sign them changes every hash from that point forward. Not worth it on a shared branch. Turn it on, and let the verified history start from today.\n","permalink":"https://mvega.dev/posts/ssh-commit-signing/","summary":"\u003cp\u003eGit identity is self-declared. \u003ccode\u003euser.email\u003c/code\u003e is a config value, not a\ncredential — anyone can set it to your address, commit, and push\nwherever they have write access. GitHub renders your avatar on the\nresult. Nothing in the process verifies that you wrote anything.\u003c/p\u003e\n\u003cp\u003eCommit signing fixes that, and since Git 2.34 you can do it with the\nSSH key you already push with. No GPG keyring, no key servers, no\nexpiry dance.\u003c/p\u003e","title":"Signing Git Commits with SSH Keys"},{"content":"Dotfile setups tend to start as a repo full of files plus a shell script that copies them into $HOME. The copies drift from the repo, the script grows special cases, and eventually nobody is sure which version is real.\nGNU Stow removes the copy step. One directory per tool, and stow \u0026lt;tool\u0026gt; symlinks its contents into the parent directory. Editing the repo edits the live config, because they are the same file.\nThe layout Stow\u0026rsquo;s model is simple once you see it: each package directory\u0026rsquo;s contents are mirrored into the parent of the stow directory. So with the repo at ~/.dotfiles, the target is ~:\n~/.dotfiles/ ├── git/ │ └── .gitconfig → ~/.gitconfig ├── zsh/ │ ├── .zshrc → ~/.zshrc │ └── .zsh/ → ~/.zsh/ └── nvim/ └── .config/nvim/ → ~/.config/nvim/ Note the intermediate directories are part of the package. nvim/.config/nvim/ is what makes the symlink land at ~/.config/nvim. Then:\ncd ~/.dotfiles stow git zsh nvim Adding a new tool is a directory and one command. Removing one is stow -D \u0026lt;tool\u0026gt; — the symlinks go, the repo keeps the config.\nConflicts The one thing that will bite you: Stow refuses to overwrite a real file.\nWARNING! stowing git would cause conflicts: * existing target is neither a link nor a directory: .gitconfig All operations aborted. That is Stow declining to destroy a config you may not have copied anywhere. Two ways out, and the choice matters:\nstow --adopt git — moves the existing ~/.gitconfig into the package, then links it back. Your live config wins, and the repo now contains it. Check git diff immediately after: adopt overwrites the repo\u0026rsquo;s version, so this is exactly when you find out whether the two had diverged. Delete the target first — rm ~/.gitconfig \u0026amp;\u0026amp; stow git. The repo\u0026rsquo;s version wins. Only do this once you have confirmed the live file has nothing you want. Both are fine. Picking the wrong one silently is not, so look at the file before deciding.\nFolding, and the surprise it causes When a target directory does not exist, Stow symlinks the directory rather than creating it and linking each file — this is called folding. It keeps the tree tidy, but it means ~/.config/nvim is a link to the package, so anything written into it by another program lands in your repo.\nFor config directories where a tool writes its own state alongside your config, that is not what you want. stow --no-folding \u0026lt;tool\u0026gt; creates real directories and links only the files.\nWhy it holds up Deploying config is a solved problem the moment you stop copying files. A new machine is git clone plus one stow per package. There is no sync step to forget, no \u0026ldquo;did I update the repo after fixing this?\u0026rdquo; because there is only one file.\nThe catch is that Stow does exactly one thing: it makes symlinks. It will not install packages, set macOS defaults, or bootstrap a machine. That is a feature — those belong in their own script, where you can read them — but it does mean Stow is the deployment layer of a dotfiles setup, not the whole thing.\n","permalink":"https://mvega.dev/posts/dotfiles-with-stow/","summary":"\u003cp\u003eDotfile setups tend to start as a repo full of files plus a shell script\nthat copies them into \u003ccode\u003e$HOME\u003c/code\u003e. The copies drift from the repo, the\nscript grows special cases, and eventually nobody is sure which version\nis real.\u003c/p\u003e\n\u003cp\u003eGNU Stow removes the copy step. One directory per tool, and \u003ccode\u003estow \u0026lt;tool\u0026gt;\u003c/code\u003e\nsymlinks its contents into the parent directory. Editing the repo edits\nthe live config, because they are the same file.\u003c/p\u003e","title":"Managing Dotfiles with GNU Stow"},{"content":"Every Go codebase converges on the same test shape: a slice of cases, a loop, one t.Run per case. It is the closest thing Go has to a testing idiom, and it is worth writing deliberately rather than by habit.\nThe shape func TestParseDuration(t *testing.T) { tests := []struct { name string give string want time.Duration wantErr string }{ { name: \u0026#34;seconds\u0026#34;, give: \u0026#34;30s\u0026#34;, want: 30 * time.Second, }, { name: \u0026#34;compound\u0026#34;, give: \u0026#34;1h30m\u0026#34;, want: 90 * time.Minute, }, { name: \u0026#34;missing unit\u0026#34;, give: \u0026#34;30\u0026#34;, wantErr: \u0026#34;missing unit\u0026#34;, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseDuration(tt.give) if tt.wantErr != \u0026#34;\u0026#34; { require.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } Three things earn their place here.\nname is required, not optional. It is what go test -run matches against and what a failure report prints. tests[2] tells you nothing; TestParseDuration/missing_unit tells you everything.\ngive and want, not input and expected. Short, and they line up in the struct literal so the table reads as a table. This is Uber\u0026rsquo;s convention and it holds up.\nError cases live in the same table. A separate TestParseDurationErrors duplicates the setup and drifts from the success path over time. A wantErr string field and an early return keeps one table.\nMatch on error values, not strings ErrorContains above is the pragmatic choice, but string matching on error text makes the message part of your API — reword it and the test breaks for no real reason. Where you control the error, prefer a sentinel or a typed error:\nwant error // e.g. ErrMissingUnit ... require.ErrorIs(t, err, tt.want) Subtests, and the parallel trap t.Run gives each case its own failure scope: one case failing does not stop the rest, and -run 'TestX/case_name' isolates a single one.\nAdding t.Parallel() inside the subtest used to require re-binding the loop variable, because every closure captured the same tt. Go 1.22 changed loop variables to be per-iteration, so as of any module declaring go 1.22 or later:\nfor _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // tt is per-iteration since Go 1.22 — no shadowing needed ... }) } The tt := tt line you still see in older code is a no-op now, not a bug — but new code should not add it.\nKeep tests hermetic A table-driven test that reaches the network is still a flaky test with extra structure. No live services, no dependence on wall-clock time, no ordering assumptions between cases. When you need a real API response, save it once in testdata/ and read it from disk:\nbody, err := os.ReadFile(filepath.Join(\u0026#34;testdata\u0026#34;, tt.fixture)) require.NoError(t, err) testdata/ is ignored by the go tool, so fixtures never end up in a build. The payoff is that the suite runs identically on your laptop, in CI, and on a plane.\nWhen not to use a table A table is for one function across many inputs. If each case needs different setup, different mocks, or a different call sequence, the struct grows a field per special case and every case pays for the others\u0026rsquo; complexity. That is the signal to write separate test functions — the table has stopped describing a table.\n","permalink":"https://mvega.dev/posts/table-driven-tests/","summary":"\u003cp\u003eEvery Go codebase converges on the same test shape: a slice of cases, a\nloop, one \u003ccode\u003et.Run\u003c/code\u003e per case. It is the closest thing Go has to a testing\nidiom, and it is worth writing deliberately rather than by habit.\u003c/p\u003e\n\u003ch2 id=\"the-shape\"\u003eThe shape\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eTestParseDuration\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003etesting\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003etests\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e []\u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003egive\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003ewant\u003c/span\u003e    \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eDuration\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003ewantErr\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;seconds\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003egive\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;30s\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ewant\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e30\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSecond\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t},\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;compound\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003egive\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;1h30m\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ewant\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e90\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eMinute\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t},\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e:    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;missing unit\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003egive\u003c/span\u003e:    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;30\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003ewantErr\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;missing unit\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t},\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003e_\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003erange\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etests\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRun\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003etesting\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eT\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003egot\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eParseDuration\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003egive\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ewantErr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\t\u003cspan style=\"color:#a6e22e\"\u003erequire\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eErrorContains\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ewantErr\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\t\u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003erequire\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNoError\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\t\u003cspan style=\"color:#a6e22e\"\u003eassert\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eEqual\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ett\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ewant\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003egot\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t})\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThree things earn their place here.\u003c/p\u003e","title":"Table-Driven Tests in Go"},{"content":"I\u0026rsquo;m Moises Vega, a software engineer working mostly in Go.\nWhat I do Day to day I build backend services and the tooling around them: static analyzers and linters, test infrastructure, CI pipelines — the layer that makes a codebase pleasant to work in rather than merely functional. I care a lot about hermetic tests, boring deploys, and commit histories that explain why.\nWhy this blog Most of what I know I learned from other engineers writing down the non-obvious parts of their work. This is my end of that bargain. The posts are short, snippet-heavy, and aimed at the version of me from six months ago: what actually bit, what the fix was, and the one caveat the documentation buried.\nThe infrastructure angle I keep everything as code and run my own infrastructure where it makes sense — a homelab built on Proxmox alongside a VPS or two, this site deployed to one of them by CI, and my machines configured from a dotfiles repo managed with GNU Stow. Half the posts here started as something breaking in that setup. Self-hosting is not always the practical choice, but it is the one you learn from.\nElsewhere GitHub — github.com/moisesvega Email — github@mvega.dev RSS — /index.xml ","permalink":"https://mvega.dev/about/","summary":"\u003cp\u003eI\u0026rsquo;m Moises Vega, a software engineer working mostly in Go.\u003c/p\u003e\n\u003ch2 id=\"what-i-do\"\u003eWhat I do\u003c/h2\u003e\n\u003cp\u003eDay to day I build backend services and the tooling around them:\nstatic analyzers and linters, test infrastructure, CI pipelines — the\nlayer that makes a codebase pleasant to work in rather than merely\nfunctional. I care a lot about hermetic tests, boring deploys, and\ncommit histories that explain \u003cem\u003ewhy\u003c/em\u003e.\u003c/p\u003e\n\u003ch2 id=\"why-this-blog\"\u003eWhy this blog\u003c/h2\u003e\n\u003cp\u003eMost of what I know I learned from other engineers writing down the\nnon-obvious parts of their work. This is my end of that bargain. The\nposts are short, snippet-heavy, and aimed at the version of me from six\nmonths ago: what actually bit, what the fix was, and the one caveat the\ndocumentation buried.\u003c/p\u003e","title":"About"}]