aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 17:12:52 +0200
committeruser <user@clank>2026-09-22 17:12:52 +0200
commit30e67fe0f4e66e2161ca61f870f8947d50287995 (patch)
treea04189f34687a4d600903100feca18aedc5d1604
parentnet: passive open (listen/accept) + own sockets + close promptly (diff)
downloadgbos-30e67fe0f4e66e2161ca61f870f8947d50287995.tar.gz
gbos-30e67fe0f4e66e2161ca61f870f8947d50287995.tar.xz
gbos-30e67fe0f4e66e2161ca61f870f8947d50287995.zip
usr: httpd - the Game Boy serves its filesystem over HTTP
/# httpd & host$ curl http://10.0.0.2/ -> a listing of the cart RAM host$ curl http://10.0.0.2/readme -> the file, from battery-backed SRAM GET only. "/" serves /index.html if present, else a generated listing; any directory lists with every entry linked, so you can click around a 1997 handheld's disk from a browser. Content type from the extension. No Content-Length: HTTP/1.0 + Connection: close delimits the body by closing, which suits a stack with one in-flight segment. All output goes through one 200-byte buffer, so each net_send is a full segment instead of one tiny segment per string. It drains the whole request, not just the request line. That is not politeness: leaving the rest unread means those segments are never acknowledged, so the client retransmits headers for a minute, never sends its FIN, and its dead connection keeps arriving at port 80 long after we re-listen. One curl with 1.4 KB of headers stalled every following request for 30-120s. It deliberately does NOT read the keyboard either. Backgrounded - the useful way to run it - every key it polled would be a key stolen from the shell's prompt. It is a daemon: kill(1) stops it, and the kernel reclaims its socket. Verified with curl: index, byte-exact file bodies, nested paths, directory listings, 404, 501, a 1.4 KB request spanning segments, browser user-agents, three parallel clients (0.1/1.1/2.1s), the shell staying usable alongside it, and six kill/restart cycles - more than there are sockets.
-rw-r--r--Makefile2
-rw-r--r--README.md10
-rw-r--r--docs/internals.md40
-rw-r--r--src/programs.asm7
-rw-r--r--usr/httpd.c277
5 files changed, 331 insertions, 5 deletions
diff --git a/Makefile b/Makefile
index f4214d8..71ade40 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ $(BUILD)/%.o: src/%.asm | $(BUILD)
PROGS := chello echo true false uname pid cat wc head args \
ls save rm sh ps kill spin blk mkdir count ptest necho \
wget chat netd ping nslookup dhcp irc uptime ansi \
- grep more cp nc
+ grep more cp nc httpd
CBLOBS := $(patsubst %,$(BUILD)/usr/%.bin,$(PROGS))
$(BUILD)/programs.o: $(CBLOBS)
diff --git a/README.md b/README.md
index 95bc362..920ed1f 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,10 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
`nc` (netcat: `nc HOST PORT` interactively, or `nc -s HOST PORT < file` to
pipe a request in and print the reply), `chat`, and a colorized **IRC
client** (`irc HOST [NICK]`, `/join` and friends, SELECT for the keyboard).
+- **It serves, too**: the kernel does passive opens (`listen`/`accept`), and
+ `httpd` puts the cart's filesystem on the web — `httpd &`, then point a
+ browser at the Game Boy: files are served from battery-backed RAM and
+ directories come back as clickable listings.
- **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server +
NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach
the real internet; `tools/gbjoin` attaches an interactive GB to it, and
@@ -33,7 +37,7 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
- **40×18 color terminal** on the LCD (4×8 font, two chars per tile, ANSI-ish
colors) + an **on-screen keyboard** on the window layer (SELECT toggles it).
- **Persistent filesystem** on battery-backed cart RAM: inodes, nested
- directories, per-process cwd — survives power-off.
+ directories, per-process cwd — survives power-off (files up to 3 KiB).
- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~35
tools ship in `usr/` (`cat`, `wc`, `head`, `grep`, `cp`, `more`, `ls`,
`uptime`, …). Adding a program is one `.c` file + a table entry.
@@ -51,6 +55,10 @@ make # -> gbos.gb (needs RGBDS + SDCC)
sudo tools/gbhub --daemon # virtual net + DHCP + NAT (needs root)
tools/gbjoin # a windowed GB joins 10.0.0.0/24
tools/gbtype 'ping 10.0.0.3' # or type into it from the host
+
+# serve the Game Boy's filesystem to your browser:
+tools/gbtype 'httpd &' # then open http://10.0.0.2/
+curl http://10.0.0.2/readme # files straight out of cart RAM
```
On the Game Boy: **SELECT** toggles the on-screen keyboard, d-pad + **A**
diff --git a/docs/internals.md b/docs/internals.md
index 8ee7816..62167f4 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -149,6 +149,17 @@ Userland networking is plain C on top of that: `dhcp` (DISCOVER→ACK, then
pump stdin and print the reply), `irc` (a full client: channels, /join /msg
/me, nick colors, flood-safe rendering) and `chat`.
+**Listening** (`net_op_listen` / `net_op_accept`): sockets demux on the local
+port *alone*, so a listener and its connection are the same socket - the SYN
+fills in the peer and the socket becomes the connection. That means one
+connection at a time, re-armed by listening again after close; a second client
+knocking is dropped in silence and its SYN retransmit is taken when we are
+free again (~1s later), which is why `httpd` serves three parallel clients in
+0.1/1.1/2.1s. `accept` never blocks: one RX pump per call, like `recv_nb`.
+Since SYNs reach the same socket as the live connection, `tcp_peer_is_current`
+is what tells a peer's retransmitted SYN apart from a newcomer's - without it
+a second client silently *hijacks* the connection mid-request.
+
**Receive-path invariants** (all three were bugs once, and each one is
invisible until a peer sends more than a toy reply):
@@ -165,6 +176,22 @@ invisible until a peer sends more than a toy reply):
that sits past a gap marks the socket `TCP_DONE`, and `recv` then reports
EOF at the hole — truncating the transfer just before the last segments.
+`tcp_close` FINs and then pumps for the peer's FIN/ACK, but **stops as soon as
+it arrives** instead of burning its whole budget: the socket otherwise sits in
+`TCP_FINWAIT` for about a second, and a server that closes and re-listens drops
+the next client's SYN in that window, so every request after the first paid a
+SYN retransmit (1.1s → 0.1s per request).
+
+A server must also **drain the request it was sent**. Reading only the request
+line leaves the rest unacknowledged: the client retransmits headers for a
+minute, never sends its FIN, and its dead connection keeps arriving at the port
+long after the server re-listens. One 1.4 KB request stalled every following
+connection for 30-120s.
+
+Sockets are owned by the pid that created them (`SK_OWNER`) and released by
+`SockReleaseOwner` on exit *and* on kill — the table has only 4 entries, and a
+killed `irc`/`nc`/`httpd` used to hold its slot until reboot.
+
Bytes that arrive **outside** SLIP frames land in a console ring instead —
that's how the host can type into the shell remotely (`tools/gbtype`).
`sys_pollcon` pumps the link itself before draining that ring, so a program
@@ -185,13 +212,20 @@ A small **block-based, persistent filesystem** on battery-backed cart RAM
```
block 0 superblock (magic + version)
block 1 bitmaps (free blocks + free inodes)
-blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs)
+blocks 2-3 inode table (32 inodes x 16 B: type, nlink, size, 12 block ptrs)
blocks 4.. data blocks
```
- **Inodes + nested directories.** A directory is a file of 16-byte entries
- (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct
- blocks); **32** inodes, 16 entries per directory.
+ (inode# + name), each carrying `.`/`..`. Files are up to **3 KiB**
+ (`NDIRECT` = 12 direct blocks, the whole tail of the inode); **32** inodes,
+ 16 entries per directory. `sys_getb`/`sys_putb` **bounds-check the block
+ index** - they did not, so a file that grew past the pointers simply kept
+ walking into the *next inode*, reading its fields as block numbers. A file
+ could round-trip fine (reads and writes aliased identically) and only show
+ the damage once something else touched the neighbouring inode.
+- **One-block data cache.** Two files open at once thrash it: `cp` of a 3 KiB
+ file alternates read/write blocks per byte and takes ~25s. Correct, slow.
- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths
(absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`,
inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt.
diff --git a/src/programs.asm b/src/programs.asm
index a3bd680..31125a2 100644
--- a/src/programs.asm
+++ b/src/programs.asm
@@ -151,6 +151,9 @@ ProgCp:
SECTION "prog_nc", ROMX[$4000], BANK[38]
ProgNc:
INCBIN "build/usr/nc.bin"
+SECTION "prog_httpd", ROMX[$4000], BANK[39]
+ProgHttpd:
+ INCBIN "build/usr/httpd.bin"
; -----------------------------------------------------------------------------
; PROG_SH (bank 3) - the shell, now written in C (usr/sh.c): parses >/</| and
@@ -267,6 +270,8 @@ ProgramTable::
dw ProgCp
db LOW(BANK(ProgNc)), HIGH(BANK(ProgNc))
dw ProgNc
+ db LOW(BANK(ProgHttpd)), HIGH(BANK(ProgHttpd))
+ dw ProgHttpd
ProgramTableEnd::
; -----------------------------------------------------------------------------
@@ -348,4 +353,6 @@ NameTable::
db PROG_CP
db "nc", 0
db PROG_NC
+ db "httpd", 0
+ db PROG_HTTPD
db 0
diff --git a/usr/httpd.c b/usr/httpd.c
new file mode 100644
index 0000000..335e824
--- /dev/null
+++ b/usr/httpd.c
@@ -0,0 +1,277 @@
+#include "sock.h"
+/* httpd [PORT] - serve the Game Boy's filesystem over HTTP. Default port 80.
+ *
+ * /# dhcp (or let boot do it)
+ * /# httpd & background it and keep using the shell
+ * host$ curl http://10.0.0.2/ -> a directory listing of the cart RAM
+ * /# ps ; kill 4 stop it (the kernel frees its socket)
+ *
+ * Deliberately does NOT read the keyboard: backgrounded (which is the useful
+ * way to run it) any key it polled would be a key stolen from the shell's own
+ * prompt. It is a daemon; kill(1) stops it.
+ *
+ * GET only. "/" serves /index.html if it exists, else a generated listing;
+ * any directory lists; anything else is served from the filesystem with a
+ * content type guessed from the extension. No Content-Length: HTTP/1.0 with
+ * Connection: close delimits the body by closing, which is also all a stack
+ * with one in-flight segment wants to promise.
+ *
+ * One connection at a time: sockets demux on local port alone, so the
+ * listener IS the connection (see net_op_listen). Serve, close, re-listen.
+ *
+ * All output goes through obuf, so every net_send() is one ~MSS segment
+ * rather than one tiny segment per string.
+ *
+ * Statics are fine here (unlike grep/more/cp) because main() copies the
+ * command line off $A000 before touching any of them - sock.h's _nr already
+ * sits there. Same trick as ping/irc.
+ */
+
+#define PORT_DEF 80
+#define BUFSZ 208 /* one full segment in */
+#define OBUFSZ 200 /* one full segment out (TCP_MSS) */
+#define REQSZ 120 /* the request line is all we parse */
+#define PATHSZ 48
+
+static unsigned char sock;
+static unsigned char rbuf[BUFSZ];
+static char obuf[OBUFSZ];
+static unsigned char olen;
+static char req[REQSZ];
+static unsigned char reqlen;
+static char path[PATHSZ];
+
+/* ---- buffered output ------------------------------------------------------ */
+static void oflush(void) {
+ if (olen) {
+ net_send(sock, obuf, olen);
+ olen = 0;
+ }
+}
+static void oputc(char c) {
+ if (olen >= OBUFSZ) oflush();
+ obuf[olen++] = c;
+}
+static void oputs(const char *s) {
+ while (*s) oputc(*s++);
+}
+
+/* ---- request parsing ------------------------------------------------------ */
+/* Read the request line, then KEEP READING to the blank line that ends the
+ headers - only the first line is kept.
+
+ Draining is not politeness, it is required. Stopping at the request line
+ leaves the rest of a browser-sized request unread, so our window never
+ opens and those segments are never acknowledged: the client retransmits
+ headers for a minute, never sends its FIN, and its dead connection keeps
+ arriving at port 80 long after we re-listen. One curl with 1.4 KB of
+ headers was enough to stall every following request for 30-120s. */
+static unsigned char read_request(void) {
+ unsigned char idle = 0, n, i, c, nlrun = 0, got_line = 0;
+ unsigned int total = 0;
+ reqlen = 0;
+ req[0] = 0;
+ for (;;) {
+ n = net_recv_nb(sock, rbuf, BUFSZ);
+ if (n == 0) return reqlen; /* peer closed */
+ if (n == 0xFE || n == 0xFF) {
+ if (++idle >= 40) return reqlen; /* ~2s of silence */
+ msleep(50);
+ continue;
+ }
+ idle = 0;
+ total += n;
+ for (i = 0; i < n; i++) {
+ c = rbuf[i];
+ if (c == '\r') continue;
+ if (c == '\n') {
+ if (!got_line) { /* first line: that's the one */
+ req[reqlen] = 0;
+ got_line = 1;
+ }
+ if (++nlrun >= 2) return reqlen; /* blank line: headers done */
+ continue;
+ }
+ nlrun = 0;
+ if (!got_line && reqlen < REQSZ - 1) req[reqlen++] = (char)c;
+ }
+ if (total > 4000) return reqlen; /* don't read a body forever */
+ }
+}
+
+/* "GET /path HTTP/1.0" -> path[]; query strings are dropped, %xx is not
+ decoded (gbos filenames are 15 chars of plain text). */
+static void parse_path(void) {
+ unsigned char i = 0, j = 0;
+ while (req[i] && req[i] != ' ') i++; /* skip the method */
+ while (req[i] == ' ') i++;
+ while (req[i] && req[i] != ' ' && req[i] != '?' && j < PATHSZ - 1) path[j++] = req[i++];
+ path[j] = 0;
+ if (!path[0]) {
+ path[0] = '/';
+ path[1] = 0;
+ }
+}
+
+static unsigned char is_get(void) {
+ return (unsigned char)(req[0] == 'G' && req[1] == 'E' && req[2] == 'T');
+}
+
+/* content type from the extension (everything unknown is text/plain, which
+ browsers will happily render) */
+static const char *ctype_for(const char *p) {
+ unsigned char i = 0, dot = 0;
+ while (p[i]) {
+ if (p[i] == '.') dot = (unsigned char)(i + 1);
+ i++;
+ }
+ if (!dot) return "text/plain";
+ if (p[dot] == 'h' && p[dot + 1] == 't' && p[dot + 2] == 'm') return "text/html";
+ if (p[dot] == 'c' && p[dot + 1] == 's' && p[dot + 2] == 's') return "text/css";
+ if (p[dot] == 'j' && p[dot + 1] == 's') return "text/javascript";
+ return "text/plain";
+}
+
+/* ---- responses ------------------------------------------------------------ */
+static void hdr(const char *status, const char *ctype) {
+ oputs("HTTP/1.0 ");
+ oputs(status);
+ oputs("\r\nServer: gbos\r\nContent-Type: ");
+ oputs(ctype);
+ oputs("\r\nConnection: close\r\n\r\n");
+}
+
+static void logline(const char *code) {
+ puts(code);
+ putc(' ');
+ puts(path);
+ nl();
+}
+
+static void serve_error(const char *status, const char *body) {
+ hdr(status, "text/plain");
+ oputs(body);
+ oputs("\r\n");
+ oflush();
+ logline(status);
+}
+
+static void serve_fd(unsigned char fd, const char *ctype) {
+ int ch;
+ hdr("200 OK", ctype);
+ while ((ch = fgetc(fd)) >= 0) oputc((char)ch);
+ close(fd);
+ oflush();
+ logline("200");
+}
+
+/* a generated index: every entry linked, so you can click around the cart RAM
+ from a browser on the other end of the link cable */
+static void serve_dir(const char *dir) {
+ char name[16];
+ unsigned char i, root;
+ if (opendir(dir)) {
+ serve_error("404 Not Found", "no such directory");
+ return;
+ }
+ root = (unsigned char)(dir[0] == '/' && dir[1] == 0);
+ hdr("200 OK", "text/html");
+ oputs("<html><head><title>gbos:");
+ oputs(dir);
+ oputs("</title></head><body><h1>gbos:");
+ oputs(dir);
+ oputs("</h1><ul>");
+ for (i = 0; flist(i, name); i++) {
+ name[15] = 0;
+ oputs("<li><a href=\"");
+ oputs(dir);
+ if (!root) oputc('/');
+ oputs(name);
+ oputs("\">");
+ oputs(name);
+ oputs("</a></li>");
+ }
+ oputs("</ul><hr><i>served from battery-backed cart RAM by a Game Boy "
+ "Color</i></body></html>");
+ oflush();
+ logline("200");
+}
+
+static void handle(void) {
+ unsigned char fd;
+ olen = 0;
+ if (!read_request()) return; /* connected, then said nothing */
+ parse_path();
+ if (!is_get()) {
+ serve_error("501 Not Implemented", "GET only");
+ return;
+ }
+ if (path[0] == '/' && path[1] == 0) { /* "/" -> index.html, else listing */
+ fd = open("/index.html", O_READ);
+ if (fd <= 3) {
+ serve_fd(fd, "text/html");
+ return;
+ }
+ serve_dir("/");
+ return;
+ }
+ fd = open(path, O_READ);
+ if (fd == EISDIR) {
+ serve_dir(path);
+ return;
+ }
+ if (fd > 3) {
+ serve_error("404 Not Found", "no such file");
+ return;
+ }
+ serve_fd(fd, ctype_for(path));
+}
+
+/* atou() is 8-bit; ports are not */
+static unsigned int atou16(const char *s) {
+ unsigned int n = 0;
+ while (*s >= '0' && *s <= '9') {
+ n = n * 10 + (unsigned char)(*s - '0');
+ s++;
+ }
+ return n;
+}
+
+void main(void) {
+ char args[16];
+ unsigned int port;
+ unsigned char i;
+
+ { /* copy the command line before any static (incl. _nr) lands on $A000 */
+ char *a = getargs();
+ for (i = 0; a[i] && i < 15; i++) args[i] = a[i];
+ args[i] = 0;
+ }
+ olen = 0;
+ port = (args[0] >= '0' && args[0] <= '9') ? atou16(args) : PORT_DEF;
+
+ puts("httpd: listening on port ");
+ putu(port);
+ nl();
+ puts(" (run it with & and kill it to stop)");
+ nl();
+
+ for (;;) {
+ sock = net_socket(SOCK_TCP);
+ if (sock == 0xFF) {
+ puts("httpd: no socket");
+ nl();
+ sexit(1);
+ }
+ if (net_listen(sock, port) == 0xFF) {
+ puts("httpd: listen failed");
+ nl();
+ net_close(sock);
+ sexit(1);
+ }
+ while (net_accept(sock) != 0) /* one RX pump per call */
+ msleep(20);
+ handle();
+ net_close(sock); /* FIN: this is what ends the body */
+ }
+}