Solod v0.2: Networking, new targets, friendlier interop
Solod (So) is a system-level language with Go syntax, zero runtime, and a familiar standard library. It's designed for two main audiences:
- Go developers who want low-level control and zero-cost C interop without having to learn Zig or Odin.
- C developers who like Go's style.
The previous version (v0.1) focused on porting core Go stdlib packages and providing convenient C interop. At the end of that post, I said the next release would focus on networking, concurrency, or both. Now, networking is here — the v0.2 release I'm sharing today includes support for TCP, UDP, and Unix domain sockets. Concurrency is still planned for the future, so for now, servers handle one connection at a time.
This release also lets you compile So to more targets, like 32-bit platforms, WebAssembly, and bare metal. And C interop even smoother!
Networking • TCP server • TCP client • Deadlines • IP addresses • Targets • Interop • Stdlib • Wrapping up
Networking
The main feature in v0.2 is the net package. It's a simplified version of Go's net package which supports the three most commonly used transports:
- TCP (networks
tcp,tcp4,tcp6) viaResolveTCPAddr,DialTCP, andListenTCP, with theTCPConnandTCPListenertypes. - UDP (networks
udp,udp4,udp6) viaResolveUDPAddr,DialUDP(a connected socket), andListenUDP(an unconnected socket withReadFrom/WriteTo). - Unix domain sockets (
unixfor streams,unixgramfor datagrams) viaResolveUnixAddr,DialUnix,ListenUnix, andListenUnixgram.
The API mirrors Go closely, so most of it will feel familiar. The big difference is that So has no goroutines, so there's no concurrent server support — you accept and serve connections sequentially. More on that in a moment.
TCP server
Let's build a classic: an echo server that accepts a connection, reads a message, and sends it back.
package main
import "solod.dev/so/net"
func main() {
// Resolve the local address to listen on.
laddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
if err != nil {
panic(err)
}
// Start listening on the local address.
ln, err := net.ListenTCP("tcp", &laddr)
if err != nil {
panic(err)
}
defer ln.Close()
println("listening on", "127.0.0.1:8080")
// Accept connections and serve them in a loop.
for {
conn, err := ln.Accept()
if err != nil {
panic(err)
}
serve(&conn)
}
}
// serve reads one message from the connection, echoes it back,
// and closes the connection.
func serve(conn *net.TCPConn) {
defer conn.Close()
var buf [256]byte
n, err := conn.Read(buf[:])
if err != nil {
return
}
conn.Write(buf[:n])
}
listening on 127.0.0.1:8080
If you've written a TCP server in Go, this should look familiar — ListenTCP, an Accept loop, and Read/Write on the connection. The only thing missing is a go serve(conn): without goroutines, each connection is handled to completion before moving on to the next Accept.
TCP client
The client starts the connection using DialTCP, then uses Write to send a request and Read to get the reply:
package main
import "solod.dev/so/net"
func main() {
// Resolve the server address.
raddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
if err != nil {
panic(err)
}
// A nil laddr lets the system choose the local address.
conn, err := net.DialTCP("tcp", nil, &raddr)
if err != nil {
panic(err)
}
defer conn.Close()
// Send a request and read the reply.
conn.Write([]byte("hello"))
var buf [256]byte
n, err := conn.Read(buf[:])
if err != nil {
panic(err)
}
println(string(buf[:n]))
}
hello
UDP and Unix domain sockets work in a similar way. For UDP, an unconnected ListenUDP socket uses ReadFrom to get data and the sender's address, and WriteTo to send a reply. For Unix sockets, there are ListenUnix (stream) and ListenUnixgram (datagram).
Deadlines
By default, Accept, Read, and Write are blocking. In Go, you'd typically use goroutines and contexts to prevent getting stuck forever. Since that's not available in So (yet), every connection and listener supports deadlines instead:
// Give the client 5 seconds to send something.
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf[:])
if err == net.ErrTimeout {
// The client went quiet; drop the connection.
return
}
SetDeadline, SetReadDeadline, and SetWriteDeadline are available on TCPConn, UDPConn, UnixConn, and listener types. When the deadline passes, any pending call fails with net.ErrTimeout. If you don't set a deadline, a blocked call will wait forever. This isn't concurrency, but it's enough to keep a single-threaded server responsive.
IP addresses
Along with net, v0.2 ports Go's net/netip package, which provides small, allocation-free value types for IP addresses. Addr represents an IP address, AddrPort combines an IP address with a port, and Prefix is an IP with a prefix length (a CIDR block):
addr, err := netip.ParseAddr("192.168.1.10")
if err != nil {
panic(err)
}
println(addr.Is4()) // true
ap := netip.AddrPortFrom(addr, 8080)
println(ap.Port()) // 8080
prefix := netip.MustParsePrefix("192.168.1.0/24")
println(prefix.Contains(addr)) // true
These are simple value types that don't use any heap allocation, which fits well with So's explicit-memory approach. The net package also provides SplitHostPort and JoinHostPort functions to help you work with host:port strings.
New targets
Solod compiles to plain C, which (in theory) means it can target anything a C compiler can. Because of this, v0.2 adds new targets:
- 32-bit platforms. The compiler and stdlib now work correctly on 32-bit platforms, where
intand pointers are narrower. - WebAssembly (WASI). You can compile a So program to
wasm32-wasiand run it under any WASI runtime. - Freestanding mode. So programs can run on bare-metal systems without any C standard library. No libc means no malloc, but you can use
mem.Arenainstead.
Here's the complete toolchain you need to build a freestanding wasm32 binary using zig cc:
export CC="zig cc"
export CFLAGS="-Oz --target=wasm32-freestanding -nostdlib -Wl,--no-entry -Wl,--export=main"
so build -o main.wasm .
A large part of the standard library (bytes, strings, strconv, slices, maps, math, encoding/binary, and more) works just fine in freestanding mode. For more details, check out the freestanding guide.
Friendlier interop
A bunch of smaller changes make Solod nicer to write.
Three new directives for low-level work, all documented in the interop guide:
//so:volatile
var counter int // emits a C volatile
//so:thread_local
var perThread int // emits C11 _Thread_local
//so:attr packed
type header struct { // emits __attribute__((packed))
version byte
length int
}
so:attr works with variables, constants, types, and functions. You can use it on multiple lines, and the attributes will stack. For example, //so:attr aligned(16) will combine with //so:attr packed.
Type aliases. So now supports Go-style type aliases:
type Byte = uint8
Numeric C types. The so/c package now includes named types for C's numeric types — Int, UInt, Long, Short, UChar, LongLong, and others. When you declare an extern function, you can use the actual C types in its signature instead of trying to guess the correct fixed-width Go type for your platform.
Third-party packages. You can now add external So packages using go install or by vendoring, and you can organize your own code into multiple modules. So doesn't have a real package ecosystem yet, but it's a good start.
Better diagnostics. By default, panic messages report the C file and line. Pass --track-source to report the original So source location instead:
so run --track-source .
There's also an optional --check-nil flag that adds nil-pointer checks when accessing struct fields and calling interface methods. This way, if there's a bad dereference, the program will panic cleanly instead of causing a segmentation fault. Both options are off by default to keep the generated code more readable.
More stdlib
Beyond net and net/netip, v0.2 adds a few more packages:
encoding/hex— hex encoding and decoding, includingDumpfor hexdump-style output.uuid— generating and parsing UUIDs (v4 and v7), with random components from a cryptographically secure source.
And a small but handy update to memory management: mem.Arena.Free now reclaims the last allocation if you give it the matching pointer. It's a minor optimization, but it means a quick alloc/free pair on an arena no longer wastes space.
Wrapping up
With v0.2, Solod has evolved from just "command-line tools and C glue" into something you can actually use on a network — like a TCP or UDP server, a small protocol client, or a Unix-socket daemon. The new targets (32-bit, WASM, freestanding) mean the same code can now run in more places, even down to bare metal.
The big thing that's still missing is concurrency. A server that handles requests one at a time works for some tasks, but a real network service needs to manage many connections at once. That's the obvious goal for v0.3 — adding some kind of concurrency, along with the stdlib packages that support it.
If you're interested, take a look at So's readme — it has everything you need to get started. Or try So online without installing anything.
★ Subscribe to keep up with new posts.