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

File diff suppressed because it is too large Load diff

View file

@ -786,6 +786,8 @@ Every non-builtin, non-closure binding round-trips. Closures and builtins are sk
**Trade-off.** The GC build's portal is slower than the binary dump (walks each binding through the printer) and stricter about what it can preserve (data only, no closures or continuations). In exchange: **every portal produced by the GC build resumes on every other tier**, with no MAP_FIXED trick, no architecture constraint, no "same binary" requirement. A GC-asm producer can hand a portal to a Python consumer — we added the asm-gc column to the §7.2 matrix and all 16 cells are green.
**Version tag.** Every v1 portal starts with a single comment line — ``;; lumbda-portal v1`` — that Scheme readers already skip but the resume path actively parses. The matching policy: a file beginning with ``;;`` is required to match the v1 prefix exactly, or ``portal-resume`` returns ``#f`` instead of trying to evaluate forms that may use syntax the current reader doesn't understand. A file that doesn't begin with ``;;`` at all is accepted as legacy (pre-v1) for back-compat. The S-expression portal is a **migration format**, not an archive format — it exists to hand live data across a process boundary at handoff time, not to carry state across years of language evolution. When we need the latter, it earns its own format with proper schema evolution and a builtin-rename mapping table; the current portal stays simple and the version tag is the hook that lets v2 happen cleanly when someone actually needs it.
7.5 Cross-Process Benchmarks
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^