asm-gc portal v1: explicit version header + resume-side version check

GC-build portal files now start with ";; lumbda-portal v1\n". The
line is a Scheme comment the reader already skips, so loading a
v1 file via bi_load works unchanged. What's new is that
portal-resume actively validates the header before delegating to
bi_load:

  file starts with ";; lumbda-portal v1\n"   -> load normally
  file starts with ";;" but different text   -> return #f (rejected)
  file does not start with ";;" at all       -> load as legacy (back-compat)

The check reads the first 32 bytes of the file, compares the
first two bytes against ";;", and on match compares the full
20-byte v1 prefix. Closes the versioning-friction concern a SEW
reviewer raised after reading §7.4.1: we can now add a v2 format
with new syntax (complex numbers, records, whatever) without
older consumers silently parsing new files into garbage — they
will cleanly return #f.

Verified:
  v1 portal -> resume loads all bindings, returns #<void>
  v2 portal -> resume returns #f without evaluating any forms
  legacy portal (no ;; header) -> resume loads normally
  missing file -> resume returns #f

The deeper framing worth writing down: this is a migration format
for handoff across process / tier / machine, not an archive format.
If archival becomes a real use case it earns its own format with
proper schema evolution and a builtin-rename table. The v1 tag is
the minimum hook that lets v2 happen cleanly when someone needs it.

137 asm no-GC + 137 asm GC still pass. §6.6.4 HTTP cells still
green at 10K: GC + no-snapshot at 543 req/s, peak 1.1 MB, 972 KB
growth (one chunk, steady state).
This commit is contained in:
russell@unturf.com 2026-04-19 09:43:15 -04:00
parent a606b6087e
commit 9e54c7aaf1
5 changed files with 477 additions and 358 deletions

Binary file not shown.

Binary file not shown.

View file

@ -416,6 +416,14 @@ s_rparen_nl: .ascii ")\n"
s_dotsp: .ascii " . "
s_minus: .ascii "-"
s_hashparen: .ascii "#("
# Portal v1 header. GC-build portal-save emits this as the first
# line of every output file; portal-resume reads the first bytes
# and verifies the prefix before loading. A file starting with ";;"
# that does NOT match is rejected as an unsupported version. A file
# that does not start with ";;" at all is accepted as legacy
# (pre-v1) and loaded normally for back-compat.
s_portal_v1: .ascii ";; lumbda-portal v1\n"
.equ s_portal_v1_len, 20
# Interned special form symbols (filled at init)
.align 8
@ -4992,6 +5000,14 @@ bi_portal_save:
js .ps_gc_fail
movq %rax, %rbx # fd
# Emit v1 header. resume() will read this first line and bail
# if it doesn't match, letting us evolve the format cleanly.
movq $SYS_WRITE, %rax
movq %rbx, %rdi
leaq s_portal_v1(%rip), %rsi
movq $s_portal_v1_len, %rdx
syscall
# Redirect printer to the portal file.
movq output_fd(%rip), %r12 # save caller's fd
movq %rbx, output_fd(%rip)
@ -5052,11 +5068,103 @@ bi_portal_save:
.endif
.ifdef GC_NAIVE
# GC build: portal-resume is equivalent to (load "filename") since
# the GC portal format is S-expressions. No magic header, no binary
# heap dump to splice back in just read+eval every form.
# GC build: portal-resume reads the v1 header, then delegates to
# bi_load for the actual form-by-form evaluation. Header check
# lets us reject unknown future versions cleanly instead of
# getting a mysterious parse error on new syntax.
#
# Policy:
# file starts with ";; lumbda-portal v1\n" -> load
# file starts with ";;" but different -> VAL_FALSE (reject)
# file does not start with ";;" at all -> load as legacy
bi_portal_resume:
# Peek at arg without consuming %r12 bi_load needs its own
# GETARG to re-read the filename after we've validated the
# header.
movq %r12, %rax
andq $-8, %rax
movq (%rax), %rbx # filename (tagged string)
andq $-8, %rbx
movq (%rbx), %rcx # string length
leaq 8(%rbx), %rsi # bytes
# Null-terminate on stack (256-byte slot).
subq $256, %rsp
movq %rsp, %rdi
movq %rcx, %rdx
.prg_cp:
testq %rdx, %rdx
jz .prg_cp_done
movb (%rsi), %al
movb %al, (%rdi)
incq %rsi
incq %rdi
decq %rdx
jmp .prg_cp
.prg_cp_done:
movb $0, (%rdi)
# Open read-only.
movq $SYS_OPEN, %rax
movq %rsp, %rdi
movq $O_RDONLY, %rsi
xorq %rdx, %rdx
syscall
addq $256, %rsp
testq %rax, %rax
js .prg_fail
movq %rax, %rbx # fd
# Read up to 32 bytes to inspect the header.
subq $32, %rsp
movq $SYS_READ, %rax
movq %rbx, %rdi
movq %rsp, %rsi
movq $32, %rdx
syscall
# rax = bytes actually read. If <2 we can't tell what it is accept.
cmpq $2, %rax
jl .prg_close_accept
# Is this a comment line (starts with ";;")?
movzwl (%rsp), %eax
cmpl $0x3b3b, %eax # ";;"
jne .prg_close_accept # no header marker legacy file, accept
# Header present. Must match s_portal_v1 exactly for the first
# s_portal_v1_len bytes.
leaq s_portal_v1(%rip), %rdi
movq %rsp, %rsi
movq $s_portal_v1_len, %rcx
.prg_match:
movb (%rdi), %al
cmpb (%rsi), %al
jne .prg_bad_version
incq %rdi
incq %rsi
decq %rcx
jnz .prg_match
# Fall through header matches.
.prg_close_accept:
addq $32, %rsp
movq $SYS_CLOSE, %rax
movq %rbx, %rdi
syscall
# Delegate to bi_load, which re-opens the file and reads every
# form. The v1 header itself is a ";; comment" line the reader
# already skips.
jmp bi_load
.prg_bad_version:
addq $32, %rsp
movq $SYS_CLOSE, %rax
movq %rbx, %rdi
syscall
movq $VAL_FALSE, %rax
RET_VAL
.prg_fail:
movq $VAL_FALSE, %rax
RET_VAL
.endif
.ifndef GC_NAIVE