diff --git a/.gitignore b/.gitignore index 8651669..33bf961 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ os.iso iso/boot/frostbyte.elf *.o *.elf +iso/boot/initrd.img diff --git a/Makefile b/Makefile index 1ea9ba9..90518dc 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ CFLAGS := -std=gnu11 -ffreestanding -nostdlib \ -fno-asynchronous-unwind-tables \ -mno-red-zone -mno-mmx -mno-sse -mno-sse2 \ -mcmodel=kernel \ - -Wall -Wextra -Iinclude -g -c + -Wall -Wextra -Iinclude -MMD -MP -g -c ASFLAGS := -ffreestanding -Iinclude -g -c @@ -16,7 +16,8 @@ UCFLAGS := -std=gnu11 -ffreestanding -nostdlib \ -fno-stack-protector -fno-pic -fno-pie \ -fno-asynchronous-unwind-tables \ -mno-red-zone -mno-mmx -mno-sse -mno-sse2 \ - -Wall -Wextra -Iinclude -Iuser -g -c + -mcmodel=large \ + -Wall -Wextra -Iinclude -Iuser -MMD -MP -g -c UASFLAGS := -ffreestanding -Iinclude -Iuser -g -c @@ -33,7 +34,55 @@ OBJS := $(SOBJS) $(COBJS) USERCOMMON := $(UBUILD)/crt.o $(UBUILD)/ulib.o USERPROGRAMS := $(UBUILD)/hello.elf $(UBUILD)/counter.elf \ - $(UBUILD)/sbrktest.elf $(UBUILD)/fileread.elf + $(UBUILD)/sbrktest.elf $(UBUILD)/fileread.elf \ + $(UBUILD)/pipetest.elf $(UBUILD)/disktest.elf \ + $(UBUILD)/fstest.elf $(UBUILD)/linktest.elf \ + $(UBUILD)/spawntarget.elf $(UBUILD)/exectarget.elf \ + $(UBUILD)/proctest.elf $(UBUILD)/argtest.elf \ + $(UBUILD)/fifomaster.elf $(UBUILD)/fifowriter.elf \ + $(UBUILD)/fiforeader.elf $(UBUILD)/killtest.elf \ + $(UBUILD)/killvictim.elf $(UBUILD)/namedreader.elf \ + $(UBUILD)/namedwriter.elf $(UBUILD)/consolereader1.elf \ + $(UBUILD)/consolereader2.elf $(UBUILD)/sigtest.elf \ + $(UBUILD)/sigtarget.elf $(UBUILD)/sigdefault.elf \ + $(UBUILD)/jobtest.elf $(UBUILD)/jobtarget.elf \ + $(UBUILD)/nesttest.elf $(UBUILD)/nesttarget.elf \ + $(UBUILD)/eintrtest.elf $(UBUILD)/eintrtarget.elf \ + $(UBUILD)/restarttest.elf $(UBUILD)/restarttarget.elf \ + $(UBUILD)/masktest.elf $(UBUILD)/masktarget.elf \ + $(UBUILD)/sigchldtest.elf $(UBUILD)/sigchldchild.elf \ + $(UBUILD)/waitanytest.elf $(UBUILD)/waitanyfast.elf \ + $(UBUILD)/waitanyslow.elf \ + $(UBUILD)/stopchldtest.elf $(UBUILD)/stopchldtarget.elf \ + $(UBUILD)/nodefertest.elf \ + $(UBUILD)/alarmtest.elf $(UBUILD)/alarmdefault.elf \ + $(UBUILD)/siginfotest.elf $(UBUILD)/siginfotarget.elf \ + $(UBUILD)/pgroupfg.elf $(UBUILD)/pgroupbg.elf \ + $(UBUILD)/jobctlfg.elf $(UBUILD)/jobctlbg.elf \ + $(UBUILD)/polltest.elf $(UBUILD)/pollwriter.elf \ + $(UBUILD)/pipesrc.elf $(UBUILD)/pipesink.elf \ + $(UBUILD)/redirwriter.elf $(UBUILD)/redirreader.elf \ + $(UBUILD)/stderrwriter.elf $(UBUILD)/stderrreader.elf \ + $(UBUILD)/selfstoptest.elf $(UBUILD)/selfstoptarget.elf \ + $(UBUILD)/herestest.elf \ + $(UBUILD)/hcat.elf $(UBUILD)/hsink.elf \ + $(UBUILD)/pipecat.elf \ + $(UBUILD)/cwdtest.elf $(UBUILD)/cwdtarget.elf \ + $(UBUILD)/envprint.elf \ + $(UBUILD)/nicetest.elf $(UBUILD)/nicetarget.elf \ + $(UBUILD)/date.elf \ + $(UBUILD)/globcat.elf \ + $(UBUILD)/timetest.elf \ + $(UBUILD)/false.elf \ + $(UBUILD)/argecho.elf \ + $(UBUILD)/rmtest.elf \ + $(UBUILD)/permtest.elf \ + $(UBUILD)/identtest.elf \ + $(UBUILD)/dirpermtest.elf \ + $(UBUILD)/grouptest.elf \ + $(UBUILD)/pidfdtest.elf \ + $(UBUILD)/mmaptest.elf $(UBUILD)/mmapprotvictim.elf \ + $(UBUILD)/mmapexecvictim.elf FSFILES := fsroot/etc/motd.txt fsroot/etc/readme.txt fsroot/home/notes.txt @@ -41,6 +90,8 @@ KERNEL := $(BUILD)/frostbyte.elf INITRD := $(BUILD)/initrd.img MKTAR := $(BUILD)/mktar ISO := os.iso +DISK := $(BUILD)/disk.img +DISKFLAGS := -drive file=$(DISK),format=raw,if=ide .PHONY: all clean run runserial test @@ -79,6 +130,430 @@ $(UBUILD)/sbrktest.elf: $(UBUILD)/sbrktest.o $(USERCOMMON) user/user.ld $(UBUILD)/fileread.elf: $(UBUILD)/fileread.o $(USERCOMMON) user/user.ld $(LD) $(ULDFLAGS) --defsym USER_BASE=0x70000000 -o $@ $(UBUILD)/fileread.o $(USERCOMMON) +$(UBUILD)/pipetest.elf: $(UBUILD)/pipetest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x80000000 -o $@ $(UBUILD)/pipetest.o $(USERCOMMON) + +$(UBUILD)/disktest.elf: $(UBUILD)/disktest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x90000000 -o $@ $(UBUILD)/disktest.o $(USERCOMMON) + +$(UBUILD)/fstest.elf: $(UBUILD)/fstest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0xA0000000 -o $@ $(UBUILD)/fstest.o $(USERCOMMON) + +$(UBUILD)/linktest.elf: $(UBUILD)/linktest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0xB0000000 -o $@ $(UBUILD)/linktest.o $(USERCOMMON) + +# Note: 0xC0000000 is deliberately skipped -- kmain.c's runPagingTest() +# hardcodes a single supervisor-only diagnostic page at that exact address +# for the whole kernel's lifetime, which would silently collide with a +# user binary's first page (elfLoad's pagingMapUserRange only maps pages +# that are not already present, so it would find that one "already mapped" +# and leave it non-user-accessible, faulting the instant ring 3 tried to +# execute from it). +$(UBUILD)/spawntarget.elf: $(UBUILD)/spawntarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0xD0000000 -o $@ $(UBUILD)/spawntarget.o $(USERCOMMON) + +# exectarget is never listed in kmain.c's boot-time spawnUser calls -- it is +# only ever reached via proctest.c's userExec, which derives its stack/brk +# purely from this ELF's own entry point (elfSlotStackTop/elfSlotBrkStart, +# elf.c/elf.h). Its USER_BASE still needs to be reserved here so it never +# collides with another binary's slot. +$(UBUILD)/exectarget.elf: $(UBUILD)/exectarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0xE0000000 -o $@ $(UBUILD)/exectarget.o $(USERCOMMON) + +$(UBUILD)/proctest.elf: $(UBUILD)/proctest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0xF0000000 -o $@ $(UBUILD)/proctest.o $(USERCOMMON) + +# Like exectarget, argtest is never in kmain.c's boot-time spawnUser list -- +# only proctest.c's userSpawn (with a specific argv) ever launches it. Its +# slot picks up right where proctest's leaves off (0xF0000000 + elfSlotSize). +$(UBUILD)/argtest.elf: $(UBUILD)/argtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x100000000 -o $@ $(UBUILD)/argtest.o $(USERCOMMON) + +# fifomaster IS in kmain.c's boot-time spawnUser list (it needs no argv of +# its own); fifowriter/fiforeader are only ever spawned by fifomaster, each +# with the shared pipe handle it created as their one argv. +$(UBUILD)/fifomaster.elf: $(UBUILD)/fifomaster.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x110000000 -o $@ $(UBUILD)/fifomaster.o $(USERCOMMON) + +$(UBUILD)/fifowriter.elf: $(UBUILD)/fifowriter.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x120000000 -o $@ $(UBUILD)/fifowriter.o $(USERCOMMON) + +$(UBUILD)/fiforeader.elf: $(UBUILD)/fiforeader.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x130000000 -o $@ $(UBUILD)/fiforeader.o $(USERCOMMON) + +# killtest is in kmain.c's boot-time spawnUser list; killvictim is only ever +# spawned by killtest, and is designed to never return on its own (see +# killvictim.c) -- proving threadKill (sched.c) is the only way out. +$(UBUILD)/killtest.elf: $(UBUILD)/killtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x140000000 -o $@ $(UBUILD)/killtest.o $(USERCOMMON) + +$(UBUILD)/killvictim.elf: $(UBUILD)/killvictim.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x150000000 -o $@ $(UBUILD)/killvictim.o $(USERCOMMON) + +# namedreader/namedwriter are both in kmain.c's boot-time spawnUser list, +# with no parent-child relationship to each other -- they rendezvous purely +# through the vfsMkfifo'd path "/tmp/rendezvous", not a shared argv handle. +$(UBUILD)/namedreader.elf: $(UBUILD)/namedreader.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x160000000 -o $@ $(UBUILD)/namedreader.o $(USERCOMMON) + +$(UBUILD)/namedwriter.elf: $(UBUILD)/namedwriter.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x170000000 -o $@ $(UBUILD)/namedwriter.o $(USERCOMMON) + +# consolereader1/consolereader2 are both in kmain.c's boot-time spawnUser +# list, with no relationship to each other -- each simply reads its own fd 0, +# which keyboard.c's per-reader fan-out delivers a synthetic keystroke to +# independently (consoleInjectorThread, kmain.c). +$(UBUILD)/consolereader1.elf: $(UBUILD)/consolereader1.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x180000000 -o $@ $(UBUILD)/consolereader1.o $(USERCOMMON) + +$(UBUILD)/consolereader2.elf: $(UBUILD)/consolereader2.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x190000000 -o $@ $(UBUILD)/consolereader2.o $(USERCOMMON) + +# sigtest is spawned directly by kmain.c at boot; sigtarget/sigdefault are +# only ever spawned at runtime by sigtest itself (userSpawn), the same +# relationship killtest.elf/killvictim.elf already have -- each still needs +# its own reserved USER_BASE regardless of who spawns it, since elfLoad +# maps every ELF at whatever fixed address it was linked at and all of +# them share this one address space (see elf.h). +$(UBUILD)/sigtest.elf: $(UBUILD)/sigtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1A0000000 -o $@ $(UBUILD)/sigtest.o $(USERCOMMON) + +$(UBUILD)/sigtarget.elf: $(UBUILD)/sigtarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1B0000000 -o $@ $(UBUILD)/sigtarget.o $(USERCOMMON) + +$(UBUILD)/sigdefault.elf: $(UBUILD)/sigdefault.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1C0000000 -o $@ $(UBUILD)/sigdefault.o $(USERCOMMON) + +# jobtest is spawned directly by kmain.c at boot; jobtarget is only ever +# spawned at runtime by jobtest itself (userSpawn), the same relationship +# sigtest.elf/sigtarget.elf above already have. +$(UBUILD)/jobtest.elf: $(UBUILD)/jobtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1D0000000 -o $@ $(UBUILD)/jobtest.o $(USERCOMMON) + +$(UBUILD)/jobtarget.elf: $(UBUILD)/jobtarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1E0000000 -o $@ $(UBUILD)/jobtarget.o $(USERCOMMON) + +# nesttest is spawned directly by kmain.c at boot; nesttarget is only ever +# spawned at runtime by nesttest itself (userSpawn), the same relationship +# sigtest.elf/sigtarget.elf and jobtest.elf/jobtarget.elf above already have. +$(UBUILD)/nesttest.elf: $(UBUILD)/nesttest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x1F0000000 -o $@ $(UBUILD)/nesttest.o $(USERCOMMON) + +$(UBUILD)/nesttarget.elf: $(UBUILD)/nesttarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x200000000 -o $@ $(UBUILD)/nesttarget.o $(USERCOMMON) + +# eintrtest is spawned directly by kmain.c at boot; eintrtarget is only ever +# spawned at runtime by eintrtest itself (userSpawn), the same relationship +# killtest.elf/killvictim.elf above already have. +$(UBUILD)/eintrtest.elf: $(UBUILD)/eintrtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x210000000 -o $@ $(UBUILD)/eintrtest.o $(USERCOMMON) + +$(UBUILD)/eintrtarget.elf: $(UBUILD)/eintrtarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x220000000 -o $@ $(UBUILD)/eintrtarget.o $(USERCOMMON) + +# restarttest is spawned directly by kmain.c at boot; restarttarget is only +# ever spawned at runtime by restarttest itself (userSpawn), the same +# relationship eintrtest.elf/eintrtarget.elf above already have. +$(UBUILD)/restarttest.elf: $(UBUILD)/restarttest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x230000000 -o $@ $(UBUILD)/restarttest.o $(USERCOMMON) + +$(UBUILD)/restarttarget.elf: $(UBUILD)/restarttarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x240000000 -o $@ $(UBUILD)/restarttarget.o $(USERCOMMON) + +# masktest is spawned directly by kmain.c at boot; masktarget is only ever +# spawned at runtime by masktest itself (userSpawn), the same relationship +# nesttest.elf/nesttarget.elf above already have. +$(UBUILD)/masktest.elf: $(UBUILD)/masktest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x250000000 -o $@ $(UBUILD)/masktest.o $(USERCOMMON) + +$(UBUILD)/masktarget.elf: $(UBUILD)/masktarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x260000000 -o $@ $(UBUILD)/masktarget.o $(USERCOMMON) + +# sigchldtest is spawned directly by kmain.c at boot; sigchldchild is only +# ever spawned at runtime by sigchldtest itself (userSpawn), the same +# relationship nesttest.elf/nesttarget.elf above already have. +$(UBUILD)/sigchldtest.elf: $(UBUILD)/sigchldtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x270000000 -o $@ $(UBUILD)/sigchldtest.o $(USERCOMMON) + +$(UBUILD)/sigchldchild.elf: $(UBUILD)/sigchldchild.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x280000000 -o $@ $(UBUILD)/sigchldchild.o $(USERCOMMON) + +# waitanytest is spawned directly by kmain.c at boot; waitanyfast and +# waitanyslow are only ever spawned at runtime by waitanytest itself +# (userSpawn), the same relationship nesttest.elf/nesttarget.elf above +# already have. +$(UBUILD)/waitanytest.elf: $(UBUILD)/waitanytest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x290000000 -o $@ $(UBUILD)/waitanytest.o $(USERCOMMON) + +$(UBUILD)/waitanyfast.elf: $(UBUILD)/waitanyfast.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2A0000000 -o $@ $(UBUILD)/waitanyfast.o $(USERCOMMON) + +$(UBUILD)/waitanyslow.elf: $(UBUILD)/waitanyslow.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2B0000000 -o $@ $(UBUILD)/waitanyslow.o $(USERCOMMON) + +# stopchldtest is spawned directly by kmain.c at boot; stopchldtarget is +# only ever spawned at runtime by stopchldtest itself (userSpawn), the same +# relationship jobtest.elf/jobtarget.elf above already have. +$(UBUILD)/stopchldtest.elf: $(UBUILD)/stopchldtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2C0000000 -o $@ $(UBUILD)/stopchldtest.o $(USERCOMMON) + +$(UBUILD)/stopchldtarget.elf: $(UBUILD)/stopchldtarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2D0000000 -o $@ $(UBUILD)/stopchldtarget.o $(USERCOMMON) + +# nodefertest is spawned directly by kmain.c at boot; it is a single, +# self-contained process (self-raises signals against its own pid) with no +# spawned counterpart, unlike every other pair above. +$(UBUILD)/nodefertest.elf: $(UBUILD)/nodefertest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2E0000000 -o $@ $(UBUILD)/nodefertest.o $(USERCOMMON) + +$(UBUILD)/alarmtest.elf: $(UBUILD)/alarmtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x2F0000000 -o $@ $(UBUILD)/alarmtest.o $(USERCOMMON) + +$(UBUILD)/alarmdefault.elf: $(UBUILD)/alarmdefault.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x300000000 -o $@ $(UBUILD)/alarmdefault.o $(USERCOMMON) + +$(UBUILD)/siginfotest.elf: $(UBUILD)/siginfotest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x310000000 -o $@ $(UBUILD)/siginfotest.o $(USERCOMMON) + +$(UBUILD)/siginfotarget.elf: $(UBUILD)/siginfotarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x320000000 -o $@ $(UBUILD)/siginfotarget.o $(USERCOMMON) + +# pgroupfg/pgroupbg are both spawned directly by kmain.c at boot, with no +# parent-child relationship to each other (unlike every *test/*target pair +# above) -- each simply becomes its own default process group leader +# (sched.c's initialPgid), the same way two independently boot-launched +# programs already do for consolereader1/consolereader2 above. +$(UBUILD)/pgroupfg.elf: $(UBUILD)/pgroupfg.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x330000000 -o $@ $(UBUILD)/pgroupfg.o $(USERCOMMON) + +$(UBUILD)/pgroupbg.elf: $(UBUILD)/pgroupbg.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x340000000 -o $@ $(UBUILD)/pgroupbg.o $(USERCOMMON) + +# jobctlfg/jobctlbg are never spawned by kmain.c at all -- unlike every +# other program in this list, they are loaded on demand, at runtime, when +# kmain.c's jobctlDriverThread types their names at the real interactive +# shell (shell.c), which resolves them through jobctl.c exactly like a real +# shell's $PATH lookup. They still need their own fixed link slot up front +# like any other binary (elf.h's elfLoad maps a binary at whatever address +# it was linked at, chosen here, not at load time). +$(UBUILD)/jobctlfg.elf: $(UBUILD)/jobctlfg.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x350000000 -o $@ $(UBUILD)/jobctlfg.o $(USERCOMMON) + +$(UBUILD)/jobctlbg.elf: $(UBUILD)/jobctlbg.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x360000000 -o $@ $(UBUILD)/jobctlbg.o $(USERCOMMON) + +$(UBUILD)/polltest.elf: $(UBUILD)/polltest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x370000000 -o $@ $(UBUILD)/polltest.o $(USERCOMMON) + +$(UBUILD)/pollwriter.elf: $(UBUILD)/pollwriter.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x380000000 -o $@ $(UBUILD)/pollwriter.o $(USERCOMMON) + +# pipesrc/pipesink are, like jobctlfg/jobctlbg above, never spawned by +# kmain.c directly -- loaded on demand when the real interactive shell +# processes a typed "pipesrc | pipesink" line (shell.c's new pipeline +# syntax, kmain.c's pipelineDriverThread). +$(UBUILD)/pipesrc.elf: $(UBUILD)/pipesrc.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x390000000 -o $@ $(UBUILD)/pipesrc.o $(USERCOMMON) + +$(UBUILD)/pipesink.elf: $(UBUILD)/pipesink.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3A0000000 -o $@ $(UBUILD)/pipesink.o $(USERCOMMON) + +# redirwriter/redirreader are, like pipesrc/pipesink above, never spawned by +# kmain.c directly -- loaded on demand when the real interactive shell +# processes typed "redirwriter > FILE" / "redirwriter >> FILE" / +# "redirreader < FILE" lines (shell.c's new file redirection syntax, +# kmain.c's redirDriverThread). +$(UBUILD)/redirwriter.elf: $(UBUILD)/redirwriter.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3B0000000 -o $@ $(UBUILD)/redirwriter.o $(USERCOMMON) + +$(UBUILD)/redirreader.elf: $(UBUILD)/redirreader.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3C0000000 -o $@ $(UBUILD)/redirreader.o $(USERCOMMON) + +# stderrwriter/stderrreader are, like redirwriter/redirreader above, never +# spawned by kmain.c directly -- loaded on demand when the real interactive +# shell processes typed "stderrwriter 2> FILE" / "stderrwriter 2>> FILE" / +# "stderrreader < FILE" lines (shell.c's new stderr redirection syntax, +# kmain.c's stderrDriverThread). +$(UBUILD)/stderrwriter.elf: $(UBUILD)/stderrwriter.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3D0000000 -o $@ $(UBUILD)/stderrwriter.o $(USERCOMMON) + +$(UBUILD)/stderrreader.elf: $(UBUILD)/stderrreader.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3E0000000 -o $@ $(UBUILD)/stderrreader.o $(USERCOMMON) + +# selfstoptest is spawned directly by kmain.c at boot; selfstoptarget is +# only ever spawned at runtime by selfstoptest itself (userSpawn), the same +# relationship jobtest.elf/jobtarget.elf above already have. +$(UBUILD)/selfstoptest.elf: $(UBUILD)/selfstoptest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x3F0000000 -o $@ $(UBUILD)/selfstoptest.o $(USERCOMMON) + +$(UBUILD)/selfstoptarget.elf: $(UBUILD)/selfstoptarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x400000000 -o $@ $(UBUILD)/selfstoptarget.o $(USERCOMMON) + +# herestest is, like redirreader/stderrreader above, never spawned by kmain.c +# directly -- loaded on demand when the real interactive shell processes a +# typed "herestest <<< TEXT" line (shell.c's new here-string redirection +# syntax, kmain.c's hereDriverThread). Unlike redirreader/stderrreader it has +# no writer counterpart binary at all -- the "writer" is the shell's own +# kernel thread, feeding the typed text straight into a pipe (jobctl.c's +# jobctlSpawnHereStringPiped), never another spawned process's stdout. +$(UBUILD)/herestest.elf: $(UBUILD)/herestest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x410000000 -o $@ $(UBUILD)/herestest.o $(USERCOMMON) + +# hcat/hsink are, like pipesrc/pipesink above, never spawned by kmain.c +# directly -- loaded on demand when the real interactive shell processes a +# typed "hcat <<< TEXT | hsink" line (shell.c's new here-string-into- +# pipeline syntax, jobctl.c's jobctlSpawnPipedHereString, kmain.c's +# hereStringPipelineDriverThread). +$(UBUILD)/hcat.elf: $(UBUILD)/hcat.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x420000000 -o $@ $(UBUILD)/hcat.o $(USERCOMMON) + +$(UBUILD)/hsink.elf: $(UBUILD)/hsink.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x430000000 -o $@ $(UBUILD)/hsink.o $(USERCOMMON) + +# pipecat is, like pipesrc/pipesink above, never spawned by kmain.c directly +# -- loaded on demand when the real interactive shell processes a typed +# "pipesrc | pipecat | pipesink" line (shell.c's new N-stage pipeline +# syntax, kmain.c's threeStagePipelineDriverThread). It is the middle stage +# of that pipeline: unlike hcat/hsink above, both its fd 0 and fd 1 are real +# process-to-process pipes, never the console or a here-string. +$(UBUILD)/pipecat.elf: $(UBUILD)/pipecat.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x440000000 -o $@ $(UBUILD)/pipecat.o $(USERCOMMON) + +# cwdtest is spawned directly by kmain.c at boot; cwdtarget is only ever +# spawned at runtime by cwdtest itself (userSpawn, no argv), the same +# relationship jobtest.elf/jobtarget.elf above already have -- except this +# pair rendezvous purely through the real per-process cwd the kernel itself +# hands cwdtarget at creation time (sched.c's initialCwd), not a shared +# pipe handle passed as an argv string. +$(UBUILD)/cwdtest.elf: $(UBUILD)/cwdtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x450000000 -o $@ $(UBUILD)/cwdtest.o $(USERCOMMON) + +$(UBUILD)/cwdtarget.elf: $(UBUILD)/cwdtarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x460000000 -o $@ $(UBUILD)/cwdtarget.o $(USERCOMMON) + +# envprint is, like jobctlfg/jobctlbg/hcat/pipecat above, never spawned by +# kmain.c directly -- loaded on demand when the real interactive shell +# processes a typed "envprint" line (shell.c's fallback /bin/ lookup, +# kmain.c's new envDriverThread). +$(UBUILD)/envprint.elf: $(UBUILD)/envprint.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x470000000 -o $@ $(UBUILD)/envprint.o $(USERCOMMON) + +# nicetest is spawned directly by kmain.c at boot; nicetarget is only ever +# spawned at runtime by nicetest itself (userSpawn, no argv), the same +# *test / *target relationship cwdtest.elf/cwdtarget.elf above already +# have -- this pair rendezvous purely through sysNice/sysGetPriority +# (syscall.c), never a shared pipe handle or the filesystem. +$(UBUILD)/nicetest.elf: $(UBUILD)/nicetest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x480000000 -o $@ $(UBUILD)/nicetest.o $(USERCOMMON) + +$(UBUILD)/nicetarget.elf: $(UBUILD)/nicetarget.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x490000000 -o $@ $(UBUILD)/nicetarget.o $(USERCOMMON) + +# date is spawned directly by kmain.c at boot, fully synchronous like +# nicetest.elf above -- no shell rendezvous, no injected keystroke. +$(UBUILD)/date.elf: $(UBUILD)/date.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x4A0000000 -o $@ $(UBUILD)/date.o $(USERCOMMON) + +# globcat is, like envprint/hcat/pipecat above, never spawned by kmain.c +# directly -- loaded on demand when the real interactive shell processes a +# typed "globcat /tmp/gd/*.txt" line (shell.c's new wildcard glob +# expansion, kmain.c's new globDriverThread). USER_BASE picks up well past +# the last-used slot (date.elf, plus its own boot-time spawnUser stack +# address) rather than the immediately-following one, to stay clear of any +# address this file's own boot-time spawnUser calls already reference. +$(UBUILD)/globcat.elf: $(UBUILD)/globcat.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x4C0000000 -o $@ $(UBUILD)/globcat.o $(USERCOMMON) + +# timetest is spawned directly by kmain.c at boot, fully synchronous like +# nicetest.elf/date.elf above -- no shell rendezvous, no injected keystroke. +# USER_BASE is the slot right after globcat.elf's (the last one reserved); +# nothing else references any address in or past this slot. +$(UBUILD)/timetest.elf: $(UBUILD)/timetest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x4D0000000 -o $@ $(UBUILD)/timetest.o $(USERCOMMON) + +# false is, like globcat above, never spawned by kmain.c directly -- +# loaded on demand when the real interactive shell processes a typed +# `false && echo X || echo ...` line (shell.c's new `&&`/`||` conditional +# chaining, kmain.c's new andOrDriverThread). USER_BASE picks up the slot +# right after timetest.elf's (the last one reserved). +$(UBUILD)/false.elf: $(UBUILD)/false.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x4E0000000 -o $@ $(UBUILD)/false.o $(USERCOMMON) + +# argecho is, like globcat/false above, never spawned by kmain.c directly -- +# loaded on demand when the real interactive shell processes a typed +# `argecho $?` line (shell.c's new `$?` expansion, kmain.c's extended +# andOrDriverThread). USER_BASE picks up the slot right after false.elf's +# (the last one reserved). +$(UBUILD)/argecho.elf: $(UBUILD)/argecho.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x4F0000000 -o $@ $(UBUILD)/argecho.o $(USERCOMMON) + +# rmtest is spawned directly by kmain.c at boot, fully synchronous like +# timetest.elf/date.elf/nicetest.elf above -- no shell rendezvous, no +# injected keystroke. USER_BASE is the slot right after argecho.elf's (the +# last one reserved); nothing else references any address in or past this +# slot. +$(UBUILD)/rmtest.elf: $(UBUILD)/rmtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x500000000 -o $@ $(UBUILD)/rmtest.o $(USERCOMMON) + +# permtest is spawned directly by kmain.c at boot, fully synchronous like +# rmtest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after rmtest.elf's (the last one reserved); nothing +# else references any address in or past this slot. +$(UBUILD)/permtest.elf: $(UBUILD)/permtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x510000000 -o $@ $(UBUILD)/permtest.o $(USERCOMMON) + +# identtest is spawned directly by kmain.c at boot, fully synchronous like +# permtest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after permtest.elf's (the last one reserved); nothing +# else references any address in or past this slot. +$(UBUILD)/identtest.elf: $(UBUILD)/identtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x520000000 -o $@ $(UBUILD)/identtest.o $(USERCOMMON) + +# dirpermtest is spawned directly by kmain.c at boot, fully synchronous like +# identtest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after identtest.elf's (the last one reserved); nothing +# else references any address in or past this slot. +$(UBUILD)/dirpermtest.elf: $(UBUILD)/dirpermtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x530000000 -o $@ $(UBUILD)/dirpermtest.o $(USERCOMMON) + +# grouptest is spawned directly by kmain.c at boot, fully synchronous like +# dirpermtest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after dirpermtest.elf's (the last one reserved); nothing +# else references any address in or past this slot. +$(UBUILD)/grouptest.elf: $(UBUILD)/grouptest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x540000000 -o $@ $(UBUILD)/grouptest.o $(USERCOMMON) + +# pidfdtest is spawned directly by kmain.c at boot, fully synchronous like +# grouptest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after grouptest.elf's (the last one reserved, verified by +# scanning every USER_BASE in this Makefile, not just assumed to be "one past +# the highest" -- see kmain.c's own nicetest/date address-space inconsistency, +# left as a warning for exactly this kind of addition); nothing else +# references any address in or past this slot. +$(UBUILD)/pidfdtest.elf: $(UBUILD)/pidfdtest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x550000000 -o $@ $(UBUILD)/pidfdtest.o $(USERCOMMON) + +# mmaptest is spawned directly by kmain.c at boot, fully synchronous like +# pidfdtest.elf above -- no shell rendezvous, no injected keystroke. USER_BASE +# is the slot right after pidfdtest.elf's (the last one reserved, verified by +# scanning every USER_BASE in this Makefile); nothing else references any +# address in or past this slot. +$(UBUILD)/mmaptest.elf: $(UBUILD)/mmaptest.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x560000000 -o $@ $(UBUILD)/mmaptest.o $(USERCOMMON) + +# mmapprotvictim is only ever spawned at RUNTIME by mmaptest.elf itself +# (userSpawn), never directly by kmain.c -- same relationship killvictim.elf +# already has to killtest.elf. USER_BASE is the slot right after +# mmaptest.elf's own. +$(UBUILD)/mmapprotvictim.elf: $(UBUILD)/mmapprotvictim.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x570000000 -o $@ $(UBUILD)/mmapprotvictim.o $(USERCOMMON) + +# mmapexecvictim is only ever spawned at RUNTIME by mmaptest.elf itself +# (userSpawn), exactly like mmapprotvictim.elf above -- never directly by +# kmain.c. USER_BASE is the slot right after mmapprotvictim.elf's own. +$(UBUILD)/mmapexecvictim.elf: $(UBUILD)/mmapexecvictim.o $(USERCOMMON) user/user.ld + $(LD) $(ULDFLAGS) --defsym USER_BASE=0x580000000 -o $@ $(UBUILD)/mmapexecvictim.o $(USERCOMMON) + $(MKTAR): tools/mktar.c | $(BUILD) gcc -O2 -Wall -Wextra -o $@ $< @@ -89,6 +564,85 @@ $(INITRD): $(MKTAR) $(USERPROGRAMS) $(FSFILES) --file bin/counter $(UBUILD)/counter.elf \ --file bin/sbrktest $(UBUILD)/sbrktest.elf \ --file bin/fileread $(UBUILD)/fileread.elf \ + --file bin/pipetest $(UBUILD)/pipetest.elf \ + --file bin/disktest $(UBUILD)/disktest.elf \ + --file bin/fstest $(UBUILD)/fstest.elf \ + --file bin/linktest $(UBUILD)/linktest.elf \ + --file bin/spawntarget $(UBUILD)/spawntarget.elf \ + --file bin/exectarget $(UBUILD)/exectarget.elf \ + --file bin/proctest $(UBUILD)/proctest.elf \ + --file bin/argtest $(UBUILD)/argtest.elf \ + --file bin/fifomaster $(UBUILD)/fifomaster.elf \ + --file bin/fifowriter $(UBUILD)/fifowriter.elf \ + --file bin/fiforeader $(UBUILD)/fiforeader.elf \ + --file bin/killtest $(UBUILD)/killtest.elf \ + --file bin/killvictim $(UBUILD)/killvictim.elf \ + --file bin/namedreader $(UBUILD)/namedreader.elf \ + --file bin/namedwriter $(UBUILD)/namedwriter.elf \ + --file bin/consolereader1 $(UBUILD)/consolereader1.elf \ + --file bin/consolereader2 $(UBUILD)/consolereader2.elf \ + --file bin/sigtest $(UBUILD)/sigtest.elf \ + --file bin/sigtarget $(UBUILD)/sigtarget.elf \ + --file bin/sigdefault $(UBUILD)/sigdefault.elf \ + --file bin/jobtest $(UBUILD)/jobtest.elf \ + --file bin/jobtarget $(UBUILD)/jobtarget.elf \ + --file bin/nesttest $(UBUILD)/nesttest.elf \ + --file bin/nesttarget $(UBUILD)/nesttarget.elf \ + --file bin/eintrtest $(UBUILD)/eintrtest.elf \ + --file bin/eintrtarget $(UBUILD)/eintrtarget.elf \ + --file bin/restarttest $(UBUILD)/restarttest.elf \ + --file bin/restarttarget $(UBUILD)/restarttarget.elf \ + --file bin/masktest $(UBUILD)/masktest.elf \ + --file bin/masktarget $(UBUILD)/masktarget.elf \ + --file bin/sigchldtest $(UBUILD)/sigchldtest.elf \ + --file bin/sigchldchild $(UBUILD)/sigchldchild.elf \ + --file bin/waitanytest $(UBUILD)/waitanytest.elf \ + --file bin/waitanyfast $(UBUILD)/waitanyfast.elf \ + --file bin/waitanyslow $(UBUILD)/waitanyslow.elf \ + --file bin/stopchldtest $(UBUILD)/stopchldtest.elf \ + --file bin/stopchldtarget $(UBUILD)/stopchldtarget.elf \ + --file bin/nodefertest $(UBUILD)/nodefertest.elf \ + --file bin/alarmtest $(UBUILD)/alarmtest.elf \ + --file bin/alarmdefault $(UBUILD)/alarmdefault.elf \ + --file bin/siginfotest $(UBUILD)/siginfotest.elf \ + --file bin/siginfotarget $(UBUILD)/siginfotarget.elf \ + --file bin/pgroupfg $(UBUILD)/pgroupfg.elf \ + --file bin/pgroupbg $(UBUILD)/pgroupbg.elf \ + --file bin/jobctlfg $(UBUILD)/jobctlfg.elf \ + --file bin/jobctlbg $(UBUILD)/jobctlbg.elf \ + --file bin/polltest $(UBUILD)/polltest.elf \ + --file bin/pollwriter $(UBUILD)/pollwriter.elf \ + --file bin/pipesrc $(UBUILD)/pipesrc.elf \ + --file bin/pipesink $(UBUILD)/pipesink.elf \ + --file bin/redirwriter $(UBUILD)/redirwriter.elf \ + --file bin/redirreader $(UBUILD)/redirreader.elf \ + --file bin/stderrwriter $(UBUILD)/stderrwriter.elf \ + --file bin/stderrreader $(UBUILD)/stderrreader.elf \ + --file bin/selfstoptest $(UBUILD)/selfstoptest.elf \ + --file bin/selfstoptarget $(UBUILD)/selfstoptarget.elf \ + --file bin/herestest $(UBUILD)/herestest.elf \ + --file bin/hcat $(UBUILD)/hcat.elf \ + --file bin/hsink $(UBUILD)/hsink.elf \ + --file bin/pipecat $(UBUILD)/pipecat.elf \ + --file bin/cwdtest $(UBUILD)/cwdtest.elf \ + --file bin/cwdtarget $(UBUILD)/cwdtarget.elf \ + --file bin/envprint $(UBUILD)/envprint.elf \ + --file bin/nicetest $(UBUILD)/nicetest.elf \ + --file bin/nicetarget $(UBUILD)/nicetarget.elf \ + --file bin/date $(UBUILD)/date.elf \ + --file bin/globcat $(UBUILD)/globcat.elf \ + --file bin/timetest $(UBUILD)/timetest.elf \ + --file bin/false $(UBUILD)/false.elf \ + --file bin/argecho $(UBUILD)/argecho.elf \ + --file bin/rmtest $(UBUILD)/rmtest.elf \ + --file bin/permtest $(UBUILD)/permtest.elf \ + --file bin/identtest $(UBUILD)/identtest.elf \ + --file bin/dirpermtest $(UBUILD)/dirpermtest.elf \ + --file bin/grouptest $(UBUILD)/grouptest.elf \ + --file bin/pidfdtest $(UBUILD)/pidfdtest.elf \ + --file bin/mmaptest $(UBUILD)/mmaptest.elf \ + --file bin/mmapprotvictim $(UBUILD)/mmapprotvictim.elf \ + --file bin/mmapexecvictim $(UBUILD)/mmapexecvictim.elf \ --file etc/motd.txt fsroot/etc/motd.txt \ --file etc/readme.txt fsroot/etc/readme.txt \ --file home/notes.txt fsroot/home/notes.txt @@ -98,14 +652,27 @@ $(ISO): $(KERNEL) $(INITRD) iso/boot/grub/grub.cfg @cp $(INITRD) iso/boot/initrd.img grub-mkrescue -o $(ISO) iso 2>/dev/null -run: $(ISO) - qemu-system-x86_64 -cdrom $(ISO) -serial stdio -m 128M +# Scratch disk backing /dev/disk0 (ata.c / vfs.c blockDev*). Zeroed, not +# committed: a fresh 8 MiB raw image is exactly as good as any other for a +# driver that owns the whole device and formats nothing on it. +$(DISK): | $(BUILD) + dd if=/dev/zero of=$@ bs=1M count=8 status=none -runserial: $(ISO) - qemu-system-x86_64 -cdrom $(ISO) -serial stdio -display none -m 128M -no-reboot +run: $(ISO) $(DISK) + qemu-system-x86_64 -cdrom $(ISO) $(DISKFLAGS) -serial stdio -m 128M -test: $(ISO) +runserial: $(ISO) $(DISK) + qemu-system-x86_64 -cdrom $(ISO) $(DISKFLAGS) -serial stdio -display none -m 128M -no-reboot + +test: $(ISO) $(DISK) sh tools/ci-test.sh clean: rm -rf $(BUILD) $(ISO) iso/boot/frostbyte.elf iso/boot/initrd.img + +# Auto-generated header dependencies (-MMD -MP above): without these, editing +# a widely-included header (e.g. sched.h) would not force a rebuild of every +# .c file that includes it, silently linking objects compiled against +# different struct layouts of the same type -- exactly the kind of ABI +# mismatch that corrupts memory at runtime instead of failing to build. +-include $(wildcard $(BUILD)/*.d) $(wildcard $(UBUILD)/*.d) diff --git a/include/ata.h b/include/ata.h new file mode 100644 index 0000000..2b08b0c --- /dev/null +++ b/include/ata.h @@ -0,0 +1,34 @@ +#ifndef FROSTBYTE_ATA_H +#define FROSTBYTE_ATA_H + +#include "types.h" + +/* + * ATA PIO driver (primary bus, master drive only, LBA28 addressing). + * + * This talks to real IDE hardware registers (0x1F0-0x1F7 + the 0x3F6 + * alternate-status/device-control port) using polling, not IRQ14: ataInit + * disables the drive's interrupt line (nIEN) up front, so every command is + * driven by busy-waiting on the status register. That keeps the driver + * usable from the in-kernel test harness, which runs before the scheduler + * and with interrupts disabled. + * + * All polling loops are bounded (ataPollTimeout iterations): a missing or + * wedged drive returns false/failure instead of hanging the kernel forever. + */ + +#define ataSectorSize 512 + +bool ataInit(void); +bool ataPresent(void); +uint64 ataSectorCount(void); +const char* ataModelString(void); + +/* count is a sector count in [0, 255]; 0 trivially succeeds without + touching hardware. Both fail (return false) if no drive was found by + ataInit, if the request runs past ataSectorCount(), or if the drive + reports an error/timeout mid-transfer. */ +bool ataReadSectors(uint64 lba, uint32 count, void* buffer); +bool ataWriteSectors(uint64 lba, uint32 count, const void* buffer); + +#endif diff --git a/include/bmp.h b/include/bmp.h new file mode 100644 index 0000000..32a0470 --- /dev/null +++ b/include/bmp.h @@ -0,0 +1,37 @@ +#ifndef FROSTBYTE_BMP_H +#define FROSTBYTE_BMP_H + +#include "types.h" + +/* + * Bitmap: general-purpose fixed-size bitmap over caller-supplied 64-bit + * word storage. No heap dependency (the pmm frame allocator uses its own + * private copy of this same idea and predates this generic version; this + * one is for any other subsystem that needs a bit-indexed set). + */ + +typedef struct Bitmap { + uint64* words; + usize bitCount; +} Bitmap; + +/* backingWords must hold at least (bitCount + 63) / 64 uint64 words. */ +void bmpInit(Bitmap* b, uint64* backingWords, usize bitCount); + +void bmpSet(Bitmap* b, usize index); +void bmpClear(Bitmap* b, usize index); +bool bmpTest(const Bitmap* b, usize index); + +void bmpSetRange(Bitmap* b, usize start, usize count); +void bmpClearRange(Bitmap* b, usize start, usize count); + +void bmpSetAll(Bitmap* b); +void bmpClearAll(Bitmap* b); + +usize bmpCountSet(const Bitmap* b); + +/* Returns the index of the first clear/set bit, or -1 if none exists. */ +isize bmpFindFirstClear(const Bitmap* b); +isize bmpFindFirstSet(const Bitmap* b); + +#endif diff --git a/include/elf.h b/include/elf.h index 97fdc25..da304c3 100644 --- a/include/elf.h +++ b/include/elf.h @@ -33,4 +33,60 @@ typedef struct Elf64ProgramHeader { uint64 elfLoad(const void* image, uint64 imageSize); +/* Maps one ELF64 program header's p_flags (segmentFlags: standard PF_X=1/ + PF_W=2/PF_R=4 bit values) onto the extra paging.h leaf-PTE bits + (pageWrite, pageNoExecute) elfLoad passes to pagingMapUserRangeExact for + that segment -- real per-segment W^X (a binary's own .text/.rodata ends + up read-only+executable, its .data/.bss read-write+non-executable) + instead of one blanket writable+executable range for the whole image. + Pure address-flag arithmetic, no paging/scheduler/VFS state touched, so + it is fully unit-testable (elfTest.c) the same way elfSlotBase and + friends already are. */ +uint64 elfSegmentPagingFlags(uint32 segmentFlags); + +/* Every userspace binary in this kernel is linked (Makefile --defsym + USER_BASE=...) at a fixed, elfSlotSize-aligned base of its own, since + there is one shared address space system-wide (no per-process page + tables/CR3) and no relocation support (-fno-pic): two binaries must + simply never overlap. kmain.c's boot-time spawnUser calls hardcode each + binary's stack/brk address following the same base+offset pattern by + hand; elfSlotStackTop/elfSlotBrkStart let syscall.c's exec()/spawn() + (a path resolved at runtime, not known at kmain.c's compile time) + derive the same addresses purely from the ELF's own entry point. */ +#define elfSlotSize 0x10000000ull +#define elfSlotStackOffset 0x08000000ull +#define elfSlotBrkOffset 0x04000000ull + +uint64 elfSlotBase(uint64 entry); +uint64 elfSlotStackTop(uint64 entry); +uint64 elfSlotBrkStart(uint64 entry); + +/* Ceiling on argv entries any single exec()/spawn()/boot launch can pass + (syscall.c's copyUserArgv enforces the same bound while copying them out + of userspace). Chosen together with the 256-byte per-argument cap + syscall.c already uses for path-like strings so that + elfMaxArgs*256 + a few hundred bytes of pointers/argc never comes close + to execStackSize (syscall.c) or kmain.c's userStackSize -- both far + larger than any realistic argv list this kernel's own binaries pass. */ +#define elfMaxArgs 16 + +/* Lays out a process's very first stack frame the way a real x86_64 SysV + process entry looks (used by user/crt.S's _start): from stackTop down, + the argument strings themselves, then (16-byte aligned per the ABI) + argc, argv[0..argc-1], and a NULL argv terminator -- *outRsp is where + _start should start executing with, pointing at argc. + argv/argc are already kernel-side data (copyUserArgv, doExec/doSpawn in + syscall.c, or a literal array for kmain.c's boot spawnUser) by the time + this runs; this function only ever writes into [stackTop-stackSize, + stackTop), which the caller must have already mapped (pagingMapUserRange) + -- same contract as elfLoad's segment writes. + Returns false, writing nothing, if argc is out of [0, elfMaxArgs] or the + strings plus the fixed-size argv/argc area do not fit in stackSize bytes + -- unreachable for every call site in this kernel today given the bound + above, but checked rather than assumed so a future caller with a bigger + argv (or a deliberately tiny stack in elfTest.c) fails cleanly instead of + silently writing before stackTop-stackSize. */ +bool elfBuildInitialStack(uint64 stackTop, uint64 stackSize, const char* const* argv, + int argc, uint64* outRsp); + #endif diff --git a/include/fs.h b/include/fs.h index c7b2b4c..4831a71 100644 --- a/include/fs.h +++ b/include/fs.h @@ -14,13 +14,144 @@ #define fsTypeFile 1 #define fsTypeDir 2 +#define fsTypeFifo 3 + +/* Real POSIX permission bits (chmod(2)/open(2)'s access check). This kernel + now has a real (if minimal) uid AND gid model -- sched.h's Thread.uid/ + Thread.gid, vfs.c's vfsPermitted -- so all three (owner/group/other) + triads are genuinely stored AND enforced. A mode value like 0750 is + accepted and echoed back by vfsStat/userStat exactly as given. Enforced + at exactly two real choke points, both uid+gid-aware via vfsPermitted: + resolveOpen (vfs.c) refuses to open an existing file for writing without + the right write bit for the CALLING thread's identity, and + loadImageByPath/jobctlSpawn (syscall.c/jobctl.c) refuse to exec a file + without the right exec bit -- both real, both checked at open/spawn time + only, never re-checked against an fd/process already granted (matching + real Unix). Deliberately no uid-0-bypasses-everything superuser rule + like real Unix's root has -- see vfs.c's vfsPermitted doc comment for + why a from-scratch bypass would have silently broken every permission + test already relying on chmod denying write even to a file's own + creator (every boot-time process starts at uid 0). */ +#define fsModeOwnerRead 0400u +#define fsModeOwnerWrite 0200u +#define fsModeOwnerExec 0100u + +/* The "group" triad -- vfsPermitted's middle fallback, consulted once a + caller's uid fails to match a node's owner uid but its GID (sched.h's + Thread.gid) matches the node's own group gid (fs.h's FbStat.gid). Each + bit is its own owner bit shifted right by 3 (real Unix's own rwxrwxrwx + bit layout: owner at bits 8-6, group at bits 5-3, other at bits 2-0), + the same derivation fsModeOther* below already used for the next shift + down -- vfsPermitted computes both from wantOwnerBit rather than storing + six separate constants per permission. */ +#define fsModeGroupRead 0040u +#define fsModeGroupWrite 0020u +#define fsModeGroupExec 0010u + +/* The "other" (neither owner nor group) triad -- vfsPermitted's final + fallback once a caller's uid AND gid both fail to match. Numerically each + is its own owner bit shifted right by 6, matching the 6-bit gap between + the owner and other triads in a real Unix mode. */ +#define fsModeOtherRead 0004u +#define fsModeOtherWrite 0002u +#define fsModeOtherExec 0001u + +/* Defaults applied at creation time (never retroactive -- an existing + file's mode never changes on its own, only through chmod). Directory + mode bits are stored and reported by stat/ls -l but never enforced: this + kernel's existing per-node `writable` (tmpfs) / dir-emptiness (SFS) gates + already govern directory mutation, a second, redundant enforcement axis + was not worth the complexity for a case no test needs. */ +#define fsModeDefaultFile 0644u +#define fsModeDefaultDir 0755u +#define fsModeDefaultExec 0755u + +/* sysMmap's `prot` argument (ulib.h's userMmap) -- POSIX PROT_NONE/READ/ + WRITE/EXEC by shape. mmapProtExec IS real and hardware-enforced + (mmapFaultHandler, mmap.c, maps every page of a Vma missing this bit + with paging.h's pageNoExecute): jumping into a mapping that never asked + for it takes a real, catchable SIGSEGV (isr.c's I/D-bit short-circuit in + tryResolveUserPageFault), never silently succeeds -- WHEN + paging.h's pagingNxSupported() is true (boot.S enables IA32_EFER.NXE + only after confirming CPUID.80000001H:EDX.NX; unsupported hardware + silently keeps every mapping executable rather than write a reserved PTE + bit, the same honest best-effort this codebase already applies + elsewhere). This mechanism is not limited to mmap: elf.c's own loader + now maps each ELF program header with the same paging.h flags, derived + from that segment's own p_flags (elfSegmentPagingFlags) -- a binary's + .text/.rodata comes up read-only+executable, its .data/.bss read-write+ + non-executable (user/user.ld emits them as two separate PT_LOAD + segments precisely so they can get different flags), and the + fixed-size stack and brk heap (kmain.c/syscall.c/jobctl.c/sched.c) still + ask pagingMapUserRangeFlags for pageNoExecute explicitly the same way + they always have. mmapProtWrite IS also real and enforced + (mmapFaultHandler, mmap.c): a write fault against a mapping missing this + bit is refused and turns into a real SIGSEGV (isr.c), never silently + allowed -- the same real enforcement elf.c's own read-only .text/.rodata + segments now get too (user/elfwritevictim.c). */ +#define mmapProtNone 0x0 +#define mmapProtRead 0x1 +#define mmapProtWrite 0x2 +#define mmapProtExec 0x4 + +/* sysMmap's `flags` argument -- MAP_ANONYMOUS-equivalent: fd/offset are + ignored and every page is demand-zero-filled instead of read from a file + (mmap.c's mmapFaultHandler). There is no MAP_SHARED bit: every mapping + here behaves like MAP_PRIVATE (a file-backed page's own frame is this + mapping's alone -- writing to it, when mmapProtWrite allows that, never + writes back to the underlying file, exactly like a real MAP_PRIVATE + mapping's own copy-on-write semantics, just without ever actually + sharing the frame with the file cache to begin with, since this kernel + has none), so the file-backed default already gets MAP_PRIVATE's own + behavior without a second flag bit to ask for it. */ +#define mmapAnonymous 0x1 #define fsNameMax 64 +/* Shared cap for a full path string (as opposed to fsNameMax's single + component): sched.h's Thread.cwd, vfs.h's vfsResolvePath/vfsGetcwd, and + every syscall.c path-copying stack buffer (sysOpen/sysMkdir/sysChdir/...) + all use this same constant, so a path that fits one always fits the + other -- shell.c's own longstanding local pathCapacity used the same 256 + value before this constant existed. */ +#define fsPathMax 256 + typedef struct FbStat { unsigned long size; unsigned int type; + /* Real owner rwx permission bits (see fsModeOwner* above) for a tmpfs + or SFS node -- a synthetic 0666/0444 derived from `writable` before + chmod(2) existed, now the actual per-node value. Always 0666 for + /dev/disk0 (no real per-file mode concept for a raw block device, + an honest, never-enforced placeholder, unlike the file/dir case). */ unsigned int mode; + /* Real owner identity (POSIX uid): the sched.h Thread.uid value that + was current at creation time for a tmpfs or SFS node -- see vfs.c's + vfsUid/sfs.h's sfsUidOf, the same accessors vfsPermitted itself reads + to decide owner-vs-other access. Never retroactive at creation time, + like mode's own default, and never changed by chmod -- only a real + vfsChown (root-only, real Unix's _POSIX_CHOWN_RESTRICTED default) can + reassign it afterward. Always 0 ("root") for /dev/disk0 and the in-memory + tmpfs root itself, mirroring mtime/ctime's own "0 means never + tracked" placeholder immediately below for cases with no real + creation event of their own. */ + unsigned int uid; + /* Real group identity (POSIX gid): the sched.h Thread.gid value that was + current at creation time for a tmpfs or SFS node -- see vfs.c's + vfsGid/sfs.h's sfsGidOf, the same accessors vfsPermitted itself reads + to decide the group branch of owner-vs-group-vs-other access. Never + retroactive at creation time, like uid's own default, and never + changed by chmod -- only vfsChgrp (owner may only set it to their OWN + current gid, root may set it to anything, see vfs.c's vfsChgrp doc + comment) can reassign it afterward. Always 0 for /dev/disk0 and the + in-memory tmpfs root itself, mirroring uid's own placeholder. */ + unsigned int gid; + /* Real wall-clock timestamps (seconds since the Unix epoch, rtc.h's + rtcNow()) for an SFS ("/mnt") inode -- see sfs.h's sfsMtimeOf/ + sfsCtimeOf. Always 0 for the in-memory tmpfs tree and /dev/disk0, + which have never tracked time (documented gap, not a faked value). */ + unsigned long mtime; + unsigned long ctime; } FbStat; typedef struct FbDirent { diff --git a/include/hmap.h b/include/hmap.h new file mode 100644 index 0000000..2068f0f --- /dev/null +++ b/include/hmap.h @@ -0,0 +1,45 @@ +#ifndef FROSTBYTE_HMAP_H +#define FROSTBYTE_HMAP_H + +#include "types.h" + +/* + * Hmap: a string-keyed hash map with chained buckets, backed by the kernel + * heap. Keys are copied into owned storage on hmapSet (the caller's key + * buffer need not outlive the call); values are stored as opaque void* + * (the map does not take ownership of pointed-to value memory). + */ + +typedef struct HmapEntry { + char* key; + void* value; + struct HmapEntry* next; +} HmapEntry; + +typedef struct Hmap { + HmapEntry** buckets; + usize bucketCount; + usize count; +} Hmap; + +/* FNV-1a. Exposed so tests (and callers who want a custom bucket count + heuristic) can reason about it directly. */ +uint64 hmapHashString(const char* key); + +bool hmapInit(Hmap* h, usize bucketCount); +void hmapFree(Hmap* h); + +/* Inserts key/value, or overwrites value if key is already present. + Returns false only on allocation failure. */ +bool hmapSet(Hmap* h, const char* key, void* value); +void* hmapGet(const Hmap* h, const char* key); +bool hmapHas(const Hmap* h, const char* key); +bool hmapRemove(Hmap* h, const char* key); + +usize hmapCount(const Hmap* h); + +/* Visits every entry in unspecified order; stop early if visit returns + false. userData is passed through unchanged. */ +void hmapForEach(const Hmap* h, bool (*visit)(const char* key, void* value, void* userData), void* userData); + +#endif diff --git a/include/jobctl.h b/include/jobctl.h new file mode 100644 index 0000000..1788cc0 --- /dev/null +++ b/include/jobctl.h @@ -0,0 +1,232 @@ +#ifndef FROSTBYTE_JOBCTL_H +#define FROSTBYTE_JOBCTL_H + +#include "types.h" + +/* + * Generic "run a binary by path, with real job control" helpers, callable + * only from plain kernel-thread context (shell.c's shellRun, or a boot-time + * demo thread in kmain.c) -- never from inside a syscall's interrupt-gate + * handler, exactly like keyboardReaderReadBlocking/pipeReadBlocking's own + * restriction, since jobctlWaitForeground below parks with threadJoin's + * real halt() loop. + * + * Unlike syscall.c's doSpawn (sysSpawn's underlying implementation, used by + * one ring-3 process to spawn another), a job started here always becomes + * its OWN new process group leader (threadSetPgid(pid, pid)) regardless of + * the caller's own pgid -- matching a real shell's fork()+setpgid(child, + * child) convention for every new job/pipeline, not doSpawn's default of + * inheriting the parent's group (right for a process spawning a plain + * worker of its own, wrong for an interactive shell launching a new + * foreground/background command). argv is already kernel memory here (the + * caller IS the kernel, not a ring-3 process going through a syscall), so + * neither function copies or range-checks it the way copyUserArgv/ + * userRangeOk (syscall.c) must for a user-supplied pointer. + */ + +/* Loads `path` and spawns it as a new process group leader, hands it "the + terminal" (keyboardSetForegroundPgid, keyboard.h) immediately, and + returns its pid without waiting -- split from the actual wait + (jobctlWaitForeground below) so a caller can learn the pid the instant + the job exists, not only after it has already exited (shell.c's own + background job table needs the same immediacy from + jobctlRunBackground below, so this mirrors it instead of hiding the pid + inside a single combined call). Returns threadInvalidId, touching the + terminal not at all, if `path` cannot be loaded (missing from the VFS, + malformed ELF, or the thread table is full). */ +uint32 jobctlSpawnForeground(const char* path, const char* const* argv, int argc); + +/* Blocks (threadJoin) until the pid jobctlSpawnForeground just returned + exits, then reclaims the terminal for the CALLING thread's own group -- + deliberately not "whatever pgid owned the terminal right before + jobctlSpawnForeground ran" (which could itself be some earlier, now-dead + job's stale id): a real shell always gets its own prompt's terminal back + when a foreground job quits, so a later Ctrl-C typed at the prompt + reaches the shell's NEXT job, not a group nothing will ever answer for + again. Returns the job's real exit code, same contract as threadJoin + itself. */ +int64 jobctlWaitForeground(uint32 pid); + +/* Loads `path` and spawns it as a new process group leader exactly like + jobctlSpawnForeground, but never touches the foreground pgid at all and + returns the new pid immediately without waiting -- a real shell's `&` + suffix. The caller is responsible for eventually reaping it (e.g. + shell.c's own background job table, reaped opportunistically via + threadJoinAnyChild). Returns false (touching *outPid not at all) on the + same load/spawn failures as jobctlSpawnForeground. */ +bool jobctlRunBackground(const char* path, const char* const* argv, int argc, + uint32* outPid); + +/* Generalizes jobctlSpawnForeground/jobctlRunBackground's shared "load and + spawn" step for shell.c's new `cmd1 | cmd2` pipeline syntax: `stdinHandle`/ + `stdoutHandle` are raw pipe.h handles from vfsPipeCreateHandle (vfs.h), + baked into the new process's own fd 0/fd 1 (vfsPipeAttachInto) BEFORE it + ever runs a single instruction -- -1 for either one leaves that fd at its + normal default (the real console), exactly like an unpiped command's own + stdin/stdout. `joinPgid` is threadInvalidId for "become a new group + leader" (jobctlSpawn's existing default, what every other caller here + still wants) or an already-live pgid to join instead -- a pipeline's + second-and-later stage joins its first stage's own new group so a single + Ctrl-C reaches every stage at once, exactly like a real shell's pipeline + job. Already fully general for a MIDDLE stage of a longer pipeline too: + `stdinHandle`/`stdoutHandle` are independent, so a stage can have both a + piped stdin AND a piped stdout at once (shell.c's runPipelineStages does + exactly this for every interior stage of a 3+-stage pipeline) -- the only + things that ever needed generalizing for that were shell.c's own + parsing/orchestration and jobctlWaitPipelineStages below, never this + function. Never touches the terminal itself: shell.c's own pipeline + runner grants and reclaims it once, for the pipeline as a whole, via + jobctlWaitPipelineStages below -- not per stage. Returns threadInvalidId + on the same load/spawn failures as jobctlSpawnForeground. */ +uint32 jobctlSpawnPiped(const char* path, const char* const* argv, int argc, + int stdinHandle, int stdoutHandle, uint32 joinPgid); + +/* Blocks until every one of `pids[0..count-1]` (jobctlSpawnPiped's own + return values for a pipeline's stages, `pids[1..count-1]` already joined + to `pids[0]`'s group) has exited, then reclaims the terminal for the + CALLING thread's own group -- the same two-step contract + jobctlWaitForeground has for a single job, just for a whole N-stage + pipeline at once (2 <= count <= shellMaxPipelineStages, shell.h). Grants + the terminal to `pids[0]`'s group (which every other pid is already a + member of) before waiting, exactly like jobctlSpawnForeground already + does for a single job, so a Ctrl-C typed while the pipeline runs reaches + every stage. threadJoin's own real blocking semantics mean joining every + pid in this fixed order is correct regardless of which stage actually + exits first. Returns the LAST stage's exit code, matching a real + pipeline's own `$?` convention (the last command's status). */ +int64 jobctlWaitPipelineStages(const uint32* pids, int count); + +/* Installs `inPath`/`outPath`/`errPath` file redirections (vfsOpenInto, + vfs.h) into an ALREADY-SPAWNED child's fd 0/fd 1/fd 2 -- shell.c's new + `cmd [< IN] [> OUT | >> OUT] [2> ERR | 2>> ERR]` syntax. Any path may be + nullptr to leave that fd untouched (still whatever jobctlSpawn/ + jobctlSpawnPiped already gave it -- the real console by default, or a + pipe end for a pipeline stage). `outAppend`/`errAppend` select `>>`/ + `2>>` (oAppend, never truncates) over `>`/`2>` (oTrunc) when the + matching path is non-null; ignored otherwise. fd 0's default console + reader is released first, exactly like jobctlSpawnPiped's own stdin + override above, so redirecting it never leaks that reader slot; fd 1 + and fd 2 have no such resource (their own default, fdConsole, owns + nothing -- neither is ever handed a reader by vfsFdInit). Exposed as + its own function (rather than folded into jobctlSpawnPiped's already- + long parameter list) so shell.c's `runPipeline` can layer a file + redirect onto exactly one end of a pipeline stage -- the other end + already wired to the shared pipe by jobctlSpawnPiped -- without a + combinatorial explosion of pipe-handle vs. path parameters on one call. + Returns false, leaving the child alive and running with whatever fds it + already had, on a bad path (missing without oCreate's implicit + create-on-write, unwritable parent, a fifo opened with the wrong mode, + exhausted pipe/inode resources) -- the caller decides whether that + warrants killing it (every caller in this kernel currently does, via + threadKill+threadJoin, exactly like runPipeline already does for a + failed second-stage spawn). */ +bool jobctlApplyRedirects(uint32 pid, const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend); + +/* Same as jobctlSpawnForeground, but installs `inPath`/`outPath`/`errPath` + file redirections (jobctlApplyRedirects above) into the child's fd 0/ + fd 1/fd 2 BEFORE the terminal is ever granted to it -- shell.c's + `NAME [ARGS] [< IN] [> OUT | >> OUT] [2> ERR | 2>> ERR]`. On a redirect + failure the half-spawned child is killed and reaped before returning + threadInvalidId, exactly as if the load/spawn itself had failed: the + terminal must never be granted to a child whose own stdio could not be + wired up as asked. */ +uint32 jobctlSpawnForegroundRedirected(const char* path, const char* const* argv, int argc, + const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend); + +/* Same as jobctlRunBackground, but with the same file-redirection support + as jobctlSpawnForegroundRedirected above -- shell.c's + `NAME [ARGS] [< IN] [> OUT | >> OUT] [2> ERR | 2>> ERR] &`. Returns + false (touching *outPid not at all) on the same load/spawn failures as + jobctlRunBackground, or on a redirect failure (the half-spawned child is + killed and reaped first, same as the foreground case). */ +bool jobctlRunBackgroundRedirected(const char* path, const char* const* argv, int argc, + const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend, + uint32* outPid); + +/* Generalizes jobctlSpawnPiped for shell.c's new `cmd1 <<< TEXT | cmd2` + pipeline syntax, the same way jobctlSpawnPiped itself generalizes + jobctlSpawnForeground/jobctlRunBackground: spawns `path` with fd 0 wired + to a brand-new here-string pipe (`text` plus a trailing newline, written + and closed by THIS kernel thread before returning, exactly like + jobctlSpawnForegroundHereString below) instead of the console, AND + `stdoutHandle`/`joinPgid` passed straight through to jobctlSpawnPiped so + the spawned process can simultaneously be a pipeline's first stage -- + -1/threadInvalidId for either one leaves that side at jobctlSpawnPiped's + own plain defaults (console stdout, new group leader). shell.c's + runPipeline uses this for a pipeline's LEFT stage when it was written + with `<<< TEXT` instead of a plain stdin; every other here-string caller + in this file (jobctlSpawnForegroundHereString/jobctlRunBackgroundHereString + below) is a thin wrapper passing -1/threadInvalidId, matching + jobctlSpawnForeground/jobctlRunBackground's own relationship to + jobctlSpawnPiped. Returns threadInvalidId on the same load/spawn + failures as jobctlSpawnPiped, or if the pipe table itself is exhausted + (vfsPipeCreateHandle, maxPipes slots all in use) -- both leave nothing + spawned. */ +uint32 jobctlSpawnPipedHereString(const char* path, const char* const* argv, int argc, + const char* text, int stdoutHandle, uint32 joinPgid); + +/* Same as jobctlSpawnForegroundRedirected, but for shell.c's new `NAME + [ARGS] <<< TEXT [> OUT | >> OUT] [2> ERR | 2>> ERR]` here-string syntax + instead of `< IN`: `text` (plus a trailing newline, matching a real + shell's own `<<<` behavior) is fed directly into the child's fd 0 + through a real anonymous pipe (jobctlSpawnPipedHereString above) that + THIS kernel thread itself writes and closes -- unlike jobctlApplyRedirects' + inPath, there is no file opened at all, so `outPath`/`errPath` are still + applied exactly as jobctlApplyRedirects already does (fd 0 is simply + pre-wired to the here-string pipe instead of left at the console before + that call runs). The write end is closed (real EOF, not a lingering open + pipe) BEFORE this function ever returns, so by the time the child + actually gets a timeslice to read its own fd 0 it sees exactly `text` + followed by one newline followed by true end-of-file, with no + possibility of racing the shell thread for more data that will never + arrive. Returns threadInvalidId on the same load/spawn/redirect failures + as jobctlSpawnForegroundRedirected, plus a pipe-table-exhaustion failure + (vfsPipeCreateHandle, maxPipes slots all in use) -- both leave nothing + spawned. */ +uint32 jobctlSpawnForegroundHereString(const char* path, const char* const* argv, int argc, + const char* text, const char* outPath, bool outAppend, + const char* errPath, bool errAppend); + +/* Same as jobctlRunBackgroundRedirected, but with the same here-string + stdin support as jobctlSpawnForegroundHereString above -- shell.c's + `NAME [ARGS] <<< TEXT [> OUT | >> OUT] [2> ERR | 2>> ERR] &`. Returns + false (touching *outPid not at all) on the same load/spawn/redirect/ + pipe-exhaustion failures as jobctlSpawnForegroundHereString. */ +bool jobctlRunBackgroundHereString(const char* path, const char* const* argv, int argc, + const char* text, const char* outPath, bool outAppend, + const char* errPath, bool errAppend, + uint32* outPid); + +/* Runs `path` to completion with its stdout wired to a real pipe THIS + kernel thread reads back as a string -- shell.c's new `$(cmd)` command + substitution, the mirror image of jobctlSpawnForegroundHereString's + "write a fixed string into the child's stdin" above. stdin is left at + its plain default (never redirected), and the terminal is never granted + to the child (unlike every foreground job above) -- command substitution + is not itself "the" foreground job, only a synchronous helper run while + building one. Joins the child FIRST, then drains its captured stdout -- + not "read until EOF, then join": this kernel only ever releases a dead + thread's own fds (reclaimOpenFds, sched.c) from inside threadReap + (threadJoin's last step), never at threadExit/threadKill itself, so + reading first would block forever waiting for an end-of-file that only + the very threadJoin call coming after it could ever produce. Joining + first instead means `path`'s own combined stdout must fit within one + pipe buffer's worth (pipeBufferSize, pipe.h) without ever blocking on + free space while it runs -- the same margin jobctlSpawnPipedHereString + already relies on for a here-string write, applied here to a captured + read instead -- an honestly-scoped limit on how chatty a substituted + command may be, not a silent hang for anything that stays under it. + Copies at most outCap-1 captured bytes into outBuf (NUL-terminated, + truncating silently like every other fixed-capacity buffer in this + kernel), draining and discarding anything beyond that rather than + leaving it sitting in the pipe. *outStatus receives the child's real + exit code. Returns false, touching neither outBuf nor *outStatus, on + the same load/spawn/pipe-exhaustion failures as jobctlSpawnPiped/ + vfsPipeCreateHandle. */ +bool jobctlCaptureOutput(const char* path, const char* const* argv, int argc, + char* outBuf, int outCap, int64* outStatus); + +#endif diff --git a/include/keyboard.h b/include/keyboard.h index 0bd9662..1e7d7de 100644 --- a/include/keyboard.h +++ b/include/keyboard.h @@ -4,8 +4,160 @@ #include "types.h" void keyboardInit(void); + +/* The real PS/2 IRQ1 handler (isr.c): reads the scancode off the hardware + port and hands it to keyboardHandleScancode below. */ void keyboardHandler(void); -bool keyboardHasChar(void); -char keyboardReadChar(void); + +/* + * Per-reader console input queues: a fixed table of ring-buffer-backed byte + * queues (ring.h), each independently allocated and each fed a copy of + * every decoded keystroke by keyboardHandleScancode -- like several + * terminals plugged into one keyboard, every open reader sees every key. + * This replaces an earlier design with exactly one global buffer shared by + * every consumer (the kernel shell and every ring-3 process's fd 0 alike), + * which had two real problems: concurrent readers raced directly on the + * same head/tail indices with no locking, and its blocking wait + * (keyboardReadChar's raw "sti; hlt" loop) was invisible to the scheduler -- + * a thread parked there could never be woken by schedWakeChannel, never + * charged CFS-lite vruntime while "running" it, and never recovered by + * threadKill the way every other blocking read/write in this kernel can be + * (pipe.h's pipeReadBlocking/pipeWriteBlocking, vfs.h's fdFlagBlocking + * pipe/fifo ends). Every reader here uses the exact same + * schedBlockCurrent/schedWakeChannel wait-channel contract as pipe.c, wired + * into vfs.c's fdConsole read path (vfsBlockingChannel) so syscall.c's + * sysRead restart-syscall trick works on fd 0 exactly like it already does + * on a pipe end. + */ +/* Bumped from 48 (this pool's very first, never-since-revisited size, set + when sched.h's maxThreads was still 32): vfs.c's vfsFdInit grabs one + reader automatically for EVERY thread ever created (kernel or user alike, + whether it ever reads fd 0 or not, its own doc comment already says so) + at CREATION time, which happens sequentially and entirely inside kmain() + for every boot-time thread, well before schedStart() ever runs -- so the + first 48 creation-order calls always succeeded and every one after them + always silently got consoleReaderInvalid, deterministically, regardless + of scheduling. shell.c's shellRun makes a SEPARATE, later, explicit + keyboardOpenReader() call once the shell thread actually gets a + timeslice (its own real input queue, distinct from its automatic-but- + unused fd 0 slot) -- that call can only succeed if some earlier thread + has ALREADY died and been reaped (reclaimOpenFds, sched.c) by then, + freeing a slot back up. maxThreads grew from 32 to 168 across dozens of + falas' own headroom bumps (sched.c's own history comment) while this + completely separate pool never grew to match, so by FALA 33 kmain.c was + creating far more than 48 threads before schedStart() ever ran -- + shellRun's own call was surviving purely on the timing-dependent hope + that at least one of the first 48 automatic grabs belonged to an + already-exited thread by the time it ran, not on any real guarantee. + Adding one more automatically-grabbing kernel thread (hereDriverThread, + kmain.c) tipped this from "usually still works" to "shell has no input" + on a clean run -- caught live by this fala's own gate, not a review. + Sized to maxThreads (168) plus the same +8 headroom margin sched.c's own + bumps use, which is enough for every one of maxThreads' automatic + grabs to always succeed deterministically PLUS shellRun's own extra + explicit call on top, eliminating the race entirely rather than + continuing to depend on exit timing. Bumped again (176 -> 184) in lockstep + with sched.c's own maxThreads bump (168 -> 176, hcat/hsink/ + hereStringPipelineDriverThread) -- the same fixed maxThreads+8 relationship, + not re-derived from scratch each time. Bumped again (184 -> 192) in + lockstep with sched.c's own maxThreads bump (176 -> 184, pipecat/ + threeStagePipelineDriverThread), same relationship. Bumped again + (192 -> 200) in lockstep with sched.c's own maxThreads bump (184 -> 192, + cwdtest/cwdtarget), same relationship. Bumped again (200 -> 208) in + lockstep with sched.c's own maxThreads bump (192 -> 200, envprint/ + envDriverThread), same relationship. Bumped again (208 -> 216) in + lockstep with sched.c's own maxThreads bump (200 -> 208, nicetest/ + nicetarget), same relationship. Bumped again (216 -> 224) in lockstep + with sched.c's own maxThreads bump (208 -> 216, date), same + relationship. Bumped again (224 -> 232) in lockstep with sched.c's own + maxThreads bump (216 -> 224, timetest), same relationship. Bumped again + (232 -> 240) in lockstep with sched.c's own maxThreads bump (224 -> 232, + lsDriverThread), same relationship. Bumped again (240 -> 248) in + lockstep with sched.c's own maxThreads bump (232 -> 240, + andOrDriverThread), same relationship. Bumped again (248 -> 256) in + lockstep with sched.c's own maxThreads bump (240 -> 248, rmtest), same + relationship. Bumped again (256 -> 264) in lockstep with sched.c's own + maxThreads bump (248 -> 256, permtest plus its one transient runtime- + spawned child), same relationship. Bumped again (264 -> 272) in lockstep + with sched.c's own maxThreads bump (256 -> 264, dirpermtest), same + relationship. Bumped again (272 -> 280) in lockstep with sched.c's own + maxThreads bump (264 -> 272, grouptest plus its two transient runtime- + spawned children), same relationship. Bumped again (280 -> 288) in + lockstep with sched.c's own maxThreads bump (272 -> 280, pidfdtest plus + its one transient runtime-spawned killvictim), same relationship. + Bumped again (288 -> 296) in lockstep with sched.c's own maxThreads bump + (280 -> 288, mmaptest plus its one transient runtime-spawned + mmapprotvictim), same relationship. */ +#define maxConsoleReaders 296 +#define consoleReaderInvalid (-1) + +/* Allocates a fresh, empty reader queue and returns its handle, or + consoleReaderInvalid if the table is full. Every thread gets one for its + own fd 0 at creation time (vfs.c's vfsFdInit) -- one keystroke delivered + by keyboardHandleScancode reaches every thread's queue independently, so + two unrelated processes both reading fd 0 each see their own copy rather + than racing for the same byte. */ +int keyboardOpenReader(void); + +/* Releases a reader's slot for reuse. No-op on an invalid handle. */ +void keyboardCloseReader(int handle); + +/* Non-blocking: pops up to length queued bytes into buffer, in the order + they were typed. Returns the number of bytes actually copied (which may + be less than length, or 0 only when length itself was 0), or -1 for an + invalid/closed handle or when the queue is currently empty (a live + keyboard has no end-of-file the way a pipe's write end closing does -- + "nothing typed yet" and "try again later" are the same thing). */ +int64 keyboardReaderRead(int handle, void* buffer, uint64 length); + +/* Blocks (parks the calling thread and halts) until at least one byte is + available, then behaves exactly like keyboardReaderRead. Only valid from + plain kernel-thread context (see shell.c) -- calling this from inside a + syscall's interrupt-gate handler would freeze the whole machine, not just + the caller, the same hazard pipeReadBlocking's doc comment (pipe.h) + describes; a syscall handler must instead use vfsBlockingChannel (vfs.h) + with the restart-syscall pattern. Returns -1 immediately, without + blocking, for an invalid handle. */ +int64 keyboardReaderReadBlocking(int handle, void* buffer, uint64 length); + +/* The address schedBlockCurrent/schedWakeChannel use for this reader's wait + queue -- mirrors pipe.h's pipeChannel exactly, including returning + nullptr for an invalid handle so a syscall handler can tell "do not + block, this fd cannot ever produce more data" apart from "wait and + retry". */ +const void* keyboardReaderChannel(int handle); + +/* Non-consuming readiness peek for poll.h's vfsPollReady (vfs.c): true iff + keyboardReaderRead would return actual bytes right now, without popping + any of them off the ring. False for an invalid handle. */ +bool keyboardReaderHasData(int handle); + +/* The scancode-decoding half of keyboardHandler, split out so tests (and + the live boot demo in kmain.c, which has no automated way to drive a real + PS/2 device under headless QEMU) can inject a keystroke exactly the way + real hardware would, without needing to fake port I/O. Handles shift and + (now) left-Ctrl make/break codes, intercepts the real tty INTR/SUSP + combination (Ctrl-C/Ctrl-Z) as job-control signals to the foreground + process group instead of ordinary data (see the file comment in + keyboard.c), and otherwise broadcasts the decoded character to every open + reader queue, waking anyone blocked on one via schedWakeChannel. */ +void keyboardHandleScancode(uint8 scancode); + +/* Real tcsetpgrp(3): names the process group (sched.h's Thread.pgid) that + OWNS "the terminal" from this point on -- the only pgid keyboardHandleScancode's + Ctrl-C/Ctrl-Z interception (below) ever delivers sigInt/sigTstp to. + 0 (the default, before anything ever calls this -- see keyboardInit) can + never match any live user thread's own pgid (every boot-spawned process + becomes its own new group leader, sched.c's initialPgid, so the lowest + possible real pgid is 1; only the permanent idle thread, which never + reads a keyboard, is ever pgid 0), so "nobody has claimed the foreground + yet" and "deliver to no one" are naturally the same state, with no extra + sentinel needed. */ +void keyboardSetForegroundPgid(uint32 pgid); + +/* The pgid keyboardSetForegroundPgid last recorded (0 if never called since + keyboardInit) -- real tcgetpgrp(3)'s counterpart, exposed to userspace + through sysGetFgPgrp/userGetForegroundPgid. */ +uint32 keyboardForegroundPgid(void); #endif diff --git a/include/klib.h b/include/klib.h new file mode 100644 index 0000000..9deade1 --- /dev/null +++ b/include/klib.h @@ -0,0 +1,71 @@ +#ifndef FROSTBYTE_KLIB_H +#define FROSTBYTE_KLIB_H + +#include "types.h" +#include + +/* + * klib: freestanding string/number utilities that build on top of the + * primitive mem-family and str-family routines in string.h. No libc, no + * heap dependency (callers pass their own buffers), safe to use before the + * heap is set up. + */ + +/* ---- character classification -------------------------------------- */ + +bool isDigit(int c); +bool isAlpha(int c); +bool isAlnum(int c); +bool isSpace(int c); +bool isUpper(int c); +bool isLower(int c); +bool isHexDigit(int c); +int toUpper(int c); +int toLower(int c); + +/* ---- string manipulation -------------------------------------------- + Semantics match the C standard library equivalents (including the + footguns: strcpy/strcat assume a big-enough destination). */ + +char* strcpy(char* dest, const char* src); +char* strncpy(char* dest, const char* src, usize count); +char* strcat(char* dest, const char* src); +char* strncat(char* dest, const char* src, usize count); +char* strchr(const char* str, int c); +char* strrchr(const char* str, int c); +char* strstr(const char* haystack, const char* needle); +usize strspn(const char* str, const char* accept); +usize strcspn(const char* str, const char* reject); + +/* Reentrant tokenizer (no hidden static state, safe across threads). + Pass str on the first call, nullptr on subsequent calls; *savePtr + carries the position between calls (caller owns the storage). */ +char* strTokR(char* str, const char* delim, char** savePtr); + +/* ---- number parsing --------------------------------------------------- */ + +int32 atoi(const char* str); +int64 atol(const char* str); + +/* base 0 means auto-detect: "0x"/"0X" prefix -> 16, leading "0" -> 8, + otherwise 10. base must be 0 or in [2, 36] otherwise. If endPtr is + non-null, *endPtr is set to the first unconsumed character. */ +int64 strtol(const char* str, char** endPtr, int base); +uint64 strtoul(const char* str, char** endPtr, int base); + +/* ---- formatted output into a caller-owned buffer ---------------------- + Same contract as C99 snprintf: writes at most size-1 characters plus a + terminating nul (unless size == 0), and returns the number of characters + that *would* have been written given an unbounded buffer. Supports + %c %s %d %i %u %x %X %p %%, an 'l' length modifier for 64-bit args, and + a zero-padded/space-padded minimum field width. */ +int snprintf(char* buf, usize size, const char* format, ...); +int vsnprintf(char* buf, usize size, const char* format, va_list args); + +/* ---- generic sorting / searching -------------------------------------- + Same contract as C's qsort/bsearch: cmp returns <0, 0, >0. */ +void qsort(void* base, usize count, usize size, int (*cmp)(const void*, const void*)); +void* bsearch(const void* key, const void* base, usize count, usize size, + int (*cmp)(const void*, const void*)); + +#endif diff --git a/include/ktest.h b/include/ktest.h new file mode 100644 index 0000000..c3a9667 --- /dev/null +++ b/include/ktest.h @@ -0,0 +1,82 @@ +#ifndef FROSTBYTE_KTEST_H +#define FROSTBYTE_KTEST_H + +#include "types.h" + +/* + * In-kernel self-test harness (KUnit-style). + * + * A test is written as: + * + * #include "ktest.h" + * + * KTEST(mySubsystemBasics) + * { + * KT_EQ(t, 2 + 2, 4); + * KT_TRUE(t, somePredicate()); + * } + * + * The KTEST macro both declares the test body and registers a pointer to it + * into the ".ktests" linker section, so no shared registry file has to be + * edited when a new test is added. ktestRunAll() (called once from kmain + * before the scheduler starts) iterates the section, runs every test, prints + * per-test results and a final machine-readable line: + * + * KTEST SUMMARY: tests, checks, failed + * + * The boot gate (tools/ci-test.sh) requires that line to report 0 failures, + * so a broken subsystem makes the gate fail instead of silently passing. + * + * Tests run with interrupts disabled, before the scheduler starts. They must + * be deterministic and must NOT depend on timer ticks, preemption or user + * threads. They may use kmalloc/kfree, the string helpers, pmm and any + * subsystem they explicitly initialise themselves. + */ + +typedef struct KtestCtx { + const char* name; + uint64 checks; + uint64 failures; +} KtestCtx; + +typedef void (*KtestFn)(KtestCtx* t); + +typedef struct KtestEntry { + const char* name; + KtestFn fn; +} KtestEntry; + +/* Runs every registered test, prints results, returns the number of failed + checks across all tests (0 means the whole battery is green). */ +uint64 ktestRunAll(void); + +/* Records one check. On failure it prints "FAIL file:line expr" and marks + the current test context as failed. Returns the boolean result so callers + can branch (e.g. skip a dereference after a null check failed). */ +bool ktestCheck(KtestCtx* t, bool ok, const char* expr, const char* file, int line); + +/* Null-safe string comparison used by KT_STREQ. */ +bool ktestStrEq(const char* a, const char* b); + +#define KTEST(testName) \ + static void testName##KtestFn(KtestCtx* t); \ + static const KtestEntry testName##KtestEntry = { #testName, testName##KtestFn }; \ + __attribute__((section(".ktests"), used)) \ + static const KtestEntry* const testName##KtestPtr = &testName##KtestEntry; \ + static void testName##KtestFn(KtestCtx* t) + +#define KT_TRUE(t, cond) ktestCheck((t), (cond) ? true : false, #cond, __FILE__, __LINE__) +#define KT_FALSE(t, cond) ktestCheck((t), (cond) ? false : true, "!(" #cond ")", __FILE__, __LINE__) +#define KT_EQ(t, a, b) ktestCheck((t), (int64)(a) == (int64)(b), #a " == " #b, __FILE__, __LINE__) +#define KT_NE(t, a, b) ktestCheck((t), (int64)(a) != (int64)(b), #a " != " #b, __FILE__, __LINE__) +#define KT_LT(t, a, b) ktestCheck((t), (int64)(a) < (int64)(b), #a " < " #b, __FILE__, __LINE__) +#define KT_LE(t, a, b) ktestCheck((t), (int64)(a) <= (int64)(b), #a " <= " #b, __FILE__, __LINE__) +#define KT_GT(t, a, b) ktestCheck((t), (int64)(a) > (int64)(b), #a " > " #b, __FILE__, __LINE__) +#define KT_GE(t, a, b) ktestCheck((t), (int64)(a) >= (int64)(b), #a " >= " #b, __FILE__, __LINE__) +#define KT_EQU(t, a, b) ktestCheck((t), (uint64)(a) == (uint64)(b), #a " ==u " #b, __FILE__, __LINE__) +#define KT_EQP(t, a, b) ktestCheck((t), (const void*)(a) == (const void*)(b), #a " ==p " #b, __FILE__, __LINE__) +#define KT_NULL(t, p) ktestCheck((t), (const void*)(p) == nullptr, #p " == null", __FILE__, __LINE__) +#define KT_NOTNULL(t, p) ktestCheck((t), (const void*)(p) != nullptr, #p " != null", __FILE__, __LINE__) +#define KT_STREQ(t, a, b) ktestCheck((t), ktestStrEq((a), (b)), #a " streq " #b, __FILE__, __LINE__) + +#endif diff --git a/include/list.h b/include/list.h new file mode 100644 index 0000000..a01efb6 --- /dev/null +++ b/include/list.h @@ -0,0 +1,43 @@ +#ifndef FROSTBYTE_LIST_H +#define FROSTBYTE_LIST_H + +#include "types.h" +#include + +/* + * List: an intrusive circular doubly-linked list, Linux-kernel style. The + * list head and every node share the same ListNode type; a sentinel node + * (passed to listInit) acts as both head and tail so insert/remove never + * need a null check. Embed a ListNode in your own struct and use + * listEntry() to recover the containing struct pointer -- no allocation, + * no element copying, just pointer relinking. + */ + +typedef struct ListNode { + struct ListNode* next; + struct ListNode* prev; +} ListNode; + +#define listEntry(ptr, type, member) \ + ((type*)((uint8*)(ptr) - offsetof(type, member))) + +#define listForEach(cursor, head) \ + for ((cursor) = (head)->next; (cursor) != (head); (cursor) = (cursor)->next) + +void listInit(ListNode* head); +bool listEmpty(const ListNode* head); + +void listInsertAfter(ListNode* pos, ListNode* newNode); +void listInsertBefore(ListNode* pos, ListNode* newNode); +void listPushBack(ListNode* head, ListNode* newNode); +void listPushFront(ListNode* head, ListNode* newNode); + +/* Unlinks node from whatever list it is in. Safe to call again on an + already-removed node (it becomes its own trivial one-element list). */ +void listRemove(ListNode* node); + +usize listLen(const ListNode* head); +ListNode* listFront(const ListNode* head); +ListNode* listBack(const ListNode* head); + +#endif diff --git a/include/mmap.h b/include/mmap.h new file mode 100644 index 0000000..e48aa50 --- /dev/null +++ b/include/mmap.h @@ -0,0 +1,120 @@ +#ifndef FROSTBYTE_MMAP_H +#define FROSTBYTE_MMAP_H + +#include "types.h" +#include "fs.h" + +struct Thread; + +/* Cap on live mappings per thread. Small and fixed like every other + per-Thread table in this kernel (maxOpenFiles=16, sigMaxSignals=32) -- + nothing here needs more than a handful of distinct mappings at once, and + a hard cap keeps Vma[] a plain fixed-size array embedded directly in + Thread (sched.h), exactly like FileDescriptor fds[maxOpenFiles] already + is, rather than something that needs its own allocator. */ +#define maxVmasPerThread 8 + +/* One live mapping inside a single thread's own elfSlotSize slot (see + elf.h) -- mmap.c reserves [start, end) at mmapReserve time but allocates + no physical frames and reads no file data yet; mmapFaultHandler below + fills each page lazily, the first time a real #PF (isr.c) proves it was + actually touched. `fd` (meaningful only when `anonymous` is false) names + a slot in the OWNING thread's own fd table (vfs.h) -- this mapping is + only ever resolved for its own owning thread (mmapFaultHandler's own + contract), so there is no cross-thread fd ambiguity. Unlike a real + kernel's struct vm_area_struct, there is no backing inode reference + independent of that fd: closing `fd` before every page has been faulted + in leaves this mapping unable to fill the rest (mmapFaultHandler treats + a since-closed fd as a fault it cannot resolve, turning into a real + SIGSEGV rather than silently serving zeroed pages for wrong reasons) -- + an honestly scoped limitation (real mmap(2) lets a mapping outlive the + fd that created it), not a hidden bug, chosen because holding a + reference to a file independent of any thread's fd table would need a + refcounted VfsNode/inode layer this kernel does not otherwise have. */ +typedef struct Vma { + bool used; + uint64 start; + uint64 end; + uint32 prot; + bool anonymous; + int fd; + uint64 fileOffset; +} Vma; + +/* Reserves [addr, addr+length) -- or, when addr is 0, a first-fit region + of `length` bytes the kernel picks itself, real mmap(NULL, ...)-style -- + inside `thread`'s own mmap region ([userSlotBase+elfSlotStackOffset, + userSlotBase+elfSlotSize), see elf.h: the 128 MiB of every elfSlotSize + slot that sits above the small fixed-size stack and is never otherwise + touched by an ELF image or brk). No physical frame is allocated and no + byte is read yet -- see mmapFaultHandler for when that actually happens. + On success, writes the mapping's real base address to *outAddr (matters + when addr was 0) and returns true. Returns false, touching *outAddr not + at all, if: length is 0, addr or length is not frameSize-aligned, a + non-anonymous mapping's fileOffset is not frameSize-aligned, the + resulting range would fall outside the mmap region, it would overlap an + existing Vma of this same thread, no addr was given and no free region + of that size exists, or every one of maxVmasPerThread's own slots is + already in use. */ +bool mmapReserve(struct Thread* thread, uint64 addr, uint64 length, uint32 prot, + bool anonymous, int fd, uint64 fileOffset, uint64* outAddr); + +/* Real munmap(2): removes every Vma of `thread` covering any part of + [addr, addr+length), giving back to the pmm whatever physical frames + were actually faulted in for the removed range (pagingUnmapUserRange, + paging.h -- the same "only what is actually present costs anything" + contract that function already documents for itself). A length that only + overlaps one end of an existing Vma shrinks it in place; a length that + lands entirely inside one Vma's interior, touching neither end, splits + it into two (consuming one more Vma slot for the upper half) -- since + Vmas of the same thread never overlap each other (mmapReserve's own + refusal), at most one Vma can ever need splitting per call, so this + either finds the one free slot it needs or refuses the WHOLE call up + front, leaving every Vma of this thread completely untouched, rather + than leaving the caller's mapping table half-modified by a resource + failure it could not have predicted from addr/length alone. A range that + overlaps no Vma of this thread at all is a no-op returning true, real + munmap(2)'s own "unmapping already-unmapped memory is not an error" + behavior. Returns false only for a misaligned addr, a zero length, or + the above split-with-no-free-slot case. */ +bool mmapRelease(struct Thread* thread, uint64 addr, uint64 length); + +/* Looks up the (at most one, since a thread's own Vmas never overlap each + other) Vma of `thread` covering `addr`, or nullptr if none does. */ +Vma* mmapFind(struct Thread* thread, uint64 addr); + +/* The real page-fault resolver: called by isr.c for a ring-3 #PF whose + faulting address might belong to `thread`'s own mmap region. Looks up + the covering Vma (mmapFind); returns false, mapping nothing, if there is + none, or if `forWrite` is true but the Vma's own prot lacks + mmapProtWrite (isr.c turns either case into a real SIGSEGV, see its own + comment) or (mmapProtRead is missing on a read fault, refused the same + way). Otherwise allocates one physical frame, fills it -- zeroed for an + anonymous Vma; for a file-backed one, read from the Vma's own `fd` + starting at the file offset that corresponds to this page (whatever + vfsRead returns short of a full frameSize read, e.g. a page straddling + end-of-file, is left zero-padded, since the frame was already + zero-filled before the read) -- maps it at the containing page with + pageUser | (mmapProtWrite set ? pageWrite : 0), and returns true so + isr.c can simply retry the faulting instruction, exactly like any other + successfully resolved demand-paging fault. Only ever meaningful when + `thread` is the CURRENTLY RUNNING thread (it reads/restores that + thread's own fd offset through vfsRead/vfsLseek, which resolve fd + numbers against currentThread() -- true for both isr.c's real caller and + mmapTest.c's own KTEST coverage, which runs entirely on the idle thread, + itself always currentThread() while KTEST executes). */ +bool mmapFaultHandler(struct Thread* thread, uint64 faultAddr, bool forWrite); + +/* Zeroes every Vma of `thread` without touching any physical frame or + page-table entry -- callers that already reclaim frames some other way + (doExec's own pagingUnmapUserRange over the relevant range, syscall.c) + call this afterward purely to stop a later mmapReserve on the same + thread from ever finding a stale Vma left over from an image this + thread no longer runs. threadReap/threadDiscard (sched.c) do NOT need + to call this themselves: both already memset() the whole Thread struct + before a slot is handed back to allocSlot, which zeroes the embedded + Vma[] array as a side effect, exactly like every other per-thread table + in it. */ +void mmapReleaseAll(struct Thread* thread); + +#endif diff --git a/include/paging.h b/include/paging.h index 3cfce6b..62d23ca 100644 --- a/include/paging.h +++ b/include/paging.h @@ -7,8 +7,82 @@ #define pageWrite 0x2 #define pageUser 0x4 +/* Real x86_64 PTE bit 63 (XD/NX) -- refuses instruction fetch against a + page that has it set, enforced by the CPU itself once + pagingNxSupported() is true (boot.S enables IA32_EFER.NXE at boot, + gated on CPUID.80000001H:EDX.NX so this never sets a reserved bit on + hardware/hypervisors that predate it). pagingMap silently drops this + bit from the leaf entry it writes when NX is not supported -- the page + stays executable rather than risk a reserved-bit #PF, an honest + best-effort rather than a promise this bit is always enforced. */ +#define pageNoExecute 0x8000000000000000ull + +/* True once boot.S has confirmed both that the CPU reports + CPUID.80000001H:EDX.NX and that IA32_EFER.NXE was actually set -- + callers that care whether pageNoExecute is real hardware enforcement + (as opposed to a bit pagingMap quietly ignored) check this. */ +bool pagingNxSupported(void); + bool pagingMap(uint64 virtualAddr, uint64 physicalAddr, uint64 flags); uint64 pagingResolve(uint64 virtualAddr); + +/* True if virtualAddr is currently mapped AND executable (present, and + either pageNoExecute was never asked for or the CPU cannot enforce it + -- see pagingNxSupported). False for an unmapped address or one mapped + with pageNoExecute successfully applied. Introspection only, used by + pagingTest.c/mmapTest.c to verify pagingMap/pagingMapUserRangeFlags + actually wrote the bit they were asked to, without needing a live + instruction-fetch fault to prove it indirectly. */ +bool pagingIsExecutable(uint64 virtualAddr); + +/* Same shape as pagingIsExecutable, for pageWrite instead of pageNoExecute: + true if virtualAddr is currently mapped AND writable, false for an + unmapped address or one mapped without pageWrite. Introspection only, + added alongside pagingMapUserRangeExact so pagingTest.c can prove a + range came back genuinely read-only (e.g. an ELF binary's own .text/ + .rodata, elf.c's elfLoad) directly, without needing a live user-mode + write fault to prove it indirectly the way user/elfwritevictim.c's + SIGSEGV does. */ +bool pagingIsWritable(uint64 virtualAddr); + bool pagingMapUserRange(uint64 virtualAddr, uint64 size); +/* Same as pagingMapUserRange, plus extraFlags (pageNoExecute, currently + the only bit worth passing here) OR'd into every leaf PTE this call + creates -- pagingMapUserRange itself is a thin wrapper passing 0. + Callers that want a non-executable range (a stack, a brk-grown heap -- + see kmain.c/syscall.c/jobctl.c's stack setup and sched.c's + currentThreadSbrk) pass pageNoExecute. A thin wrapper itself, over + pagingMapUserRangeExact below, that always forces pageWrite on -- + every caller of this entry point (the ones just named, plus + pagingMapUserRange) wants a writable range and only ever varies whether + it is also executable. elf.c's own per-ELF-segment loader is the one + caller in this kernel that needs a range which may be READ-ONLY too (a + binary's own .text/.rodata), so it calls pagingMapUserRangeExact + directly instead. */ +bool pagingMapUserRangeFlags(uint64 virtualAddr, uint64 size, uint64 extraFlags); + +/* Lowest-level of the three pagingMapUserRange* entry points: flags is + the exact set of extra leaf PTE bits (pageWrite, pageNoExecute) OR'd + onto pageUser for every page in the range -- unlike + pagingMapUserRangeFlags, pageWrite is NOT forced on, so a caller that + omits it gets a genuinely read-only mapping. elf.c's elfLoad is the + only caller today: each ELF program header's own p_flags (via + elfSegmentPagingFlags) decides per-segment whether the range this call + maps is writable, executable, both, or (read-only, non-executable) + neither -- real per-segment W^X instead of one blanket writable+ + executable range for a whole binary's image. */ +bool pagingMapUserRangeExact(uint64 virtualAddr, uint64 size, uint64 flags); + +/* Inverse of pagingMapUserRange: walks [virtualAddr, virtualAddr+size) one + frame at a time and, for every page that is actually present, gives its + physical frame back to the pmm and clears the PTE (present bit gone, + TLB invalidated). Pages that were never mapped -- including whole page- + table levels that were never allocated -- are silently skipped rather + than treated as an error: callers (threadReap/threadDiscard in sched.c, + doExec in syscall.c) always unmap a thread's whole elfSlotSize-sized + slot, most of which a typical small user program never touches. Safe to + call on a range that overlaps no mapping at all (a no-op). */ +void pagingUnmapUserRange(uint64 virtualAddr, uint64 size); + #endif diff --git a/include/pidfd.h b/include/pidfd.h new file mode 100644 index 0000000..f6adf89 --- /dev/null +++ b/include/pidfd.h @@ -0,0 +1,67 @@ +#ifndef FROSTBYTE_PIDFD_H +#define FROSTBYTE_PIDFD_H + +#include "types.h" + +#define maxPidfds 32 + +/* + * Real Linux precedent: pidfd_open(2) (Linux 5.3+) exists for exactly the + * ABA-style race sched.c's own allocSlot doc comment describes and + * deliberately leaves unfixed for plain pid arguments -- killtest.c's own + * comment already admits it ("killing it again must fail cleanly, not + * resurrect a slot that has since been reused") without ever guarding + * against it. A raw numeric pid can always be reassigned to a completely + * unrelated process once its original holder is reaped, so a pid cached + * from an earlier spawn/lookup is fundamentally unsafe to act on later + * with no way to tell "still the same process" apart from "coincidentally + * the same number now". Real Linux's own fix was not to make pid itself + * opaque -- every existing kill(2)/waitpid(2)/setpgid(2) caller still + * takes a raw pid, completely unchanged, forever -- but to add a SEPARATE, + * opt-in handle type that pins one specific incarnation of a slot instead. + * This file mirrors that shape exactly: pidfdOpen captures the target + * thread's CURRENT sched.c Thread.generation (the same "distinguish this + * specific occupant from any future occupant of the same numeric slot" + * idea pipe.c's own pipeIncarnation already uses for pipe handles), and + * every later pidfd operation re-checks it before ever touching the + * target -- if the original thread has since been reaped (its generation + * reset to 0 by threadReap/threadDiscard's memset) or the slot has been + * handed to a brand new, unrelated thread (a different, fresh nonzero + * generation), the pidfd cleanly reports "gone" (threadInvalidId) instead + * of silently operating on whichever live thread now happens to occupy + * that slot number. sched.c's raw pid-based threadKill/threadJoin/ + * sigRaiseInfo/threadSetPgid/threadSetPriority are deliberately untouched + * by this file -- see allocSlot's own doc comment (sched.c) for why that + * remains a separate, much larger, deferred topic. + */ + +/* Opens a new pidfd handle on thread `targetId`, pinning its CURRENT + generation (sched.h). Returns a small non-negative handle for + pidfdTarget/pidfdClose below, or -1 for id 0 (idle, never a valid + target), an out-of-range id, an already-unused slot, or a full pidfd + table (maxPidfds handles system-wide, mirroring pipe.c's own fixed-size + maxPipes table). Does not itself keep the target alive or pinned in any + way -- it can still exit, be killed, and be reaped by anyone (this + kernel's pids are never ownership-scoped, see sched.h's parentId doc + comment) while a pidfd on it is open; that is exactly the case + pidfdTarget below exists to detect. */ +int pidfdOpen(uint32 targetId); + +/* Returns the live target thread id this handle still safely refers to, + or threadInvalidId if the handle itself is invalid/closed OR the + original target has been reaped or recycled since pidfdOpen -- the one + check every pidfd-based operation needs, folded into a single call so + no caller can accidentally act on a stale target by forgetting a + separate validity check. A target that has exited but not yet been + reaped (threadDead) still returns its real id here -- exactly like a + raw pid, waiting on it should still be able to observe the real exit + status once, via whatever reaps it. */ +uint32 pidfdTarget(int handle); + +/* Frees a pidfd handle. No-op on an already-invalid handle. Never affects + the target thread itself in any way -- a pidfd, like a real Linux one, + is purely an observer/signaler, not a reference that keeps anything + alive. */ +void pidfdClose(int handle); + +#endif diff --git a/include/pipe.h b/include/pipe.h new file mode 100644 index 0000000..48ba863 --- /dev/null +++ b/include/pipe.h @@ -0,0 +1,134 @@ +#ifndef FROSTBYTE_PIPE_H +#define FROSTBYTE_PIPE_H + +#include "types.h" + +#define maxPipes 32 +#define pipeBufferSize 256 + +/* + * Anonymous pipes: a fixed table of ring-buffer-backed byte queues, each + * shared between one read end and one write end. The raw pipeRead/pipeWrite + * primitives are always non-blocking, matching real O_NONBLOCK semantics: + * + * pipeRead: + * >0 bytes actually read + * 0 end-of-file: buffer drained AND the write end is closed + * -1 no data right now but the write end is still open (retry later) + * + * pipeWrite: + * >0 bytes actually written + * 0 length was 0 + * -1 broken pipe (no read end left), or no free space right now + * (retry later) + * + * pipeReadBlocking/pipeWriteBlocking are built on top: they park the calling + * kernel thread on the pipe's wait channel (sched.h) instead of returning + * "try again", and are woken as soon as the other end changes the condition + * (data arrives, space frees up, or either end closes). They only make sense + * from kernel-thread context (see the producer/consumer demo in kmain.c) - + * a single process's own fd table cannot hand one pipe end to a second, + * independent execution context by itself. + * + * pipeCreateDetached + pipeAddReader/pipeAddWriter (below) close that gap + * without needing fork(): a handle from pipeCreateDetached starts with + * NEITHER end attached (unlike pipeCreate, which assumes its caller + * immediately owns both), so it can be handed to two entirely separate, + * independently spawned processes -- typically as a decimal string in argv + * (see user/fifomaster.c) -- each of which attaches its own end via + * vfsPipeAttach (vfs.h). syscall.c's sysRead/sysWrite give a real blocking + * read()/write() to whichever of those two ends is waiting, using the same + * restart-the-syscall trick sysWait already uses for threadJoin. + */ + +int pipeCreate(void); + +/* Like pipeCreate, but neither end starts "attached": readers and writers + both begin at 0, and pipeRead/pipeWrite treat "nobody has attached that + end YET" as "try again" rather than the true EOF/broken-pipe result they + report once an end that WAS attached goes away (see everHadReader/ + everHadWriter, pipe.c) -- otherwise a writer that attaches before any + reader does would see an immediately, permanently "broken" pipe, and a + reader that attaches first would see immediate spurious EOF. */ +int pipeCreateDetached(void); + +/* Registers a new, independent reader/writer on an existing (typically + pipeCreateDetached'd) handle -- vfsPipeAttach (vfs.h) calls these when a + process attaches its end. No-op on an invalid handle. */ +void pipeAddReader(int handle); +void pipeAddWriter(int handle); + +/* True if a write to this pipe can never succeed again: either the handle + is not valid, or a reader was attached at some point and every attached + reader has since gone away. False for a pipeCreateDetached handle that + simply has not had its reader attach yet -- that is "try again", not + broken. Lets syscall.c's sysWrite tell "keep retrying" apart from "give + up and return -1" without reaching into pipe.c's own Pipe struct. */ +bool pipeWriteBroken(int handle); + +/* The address schedBlockCurrent/schedWakeChannel use for this pipe's wait + queue -- the same one pipeReadBlocking/pipeWriteBlocking already block + on. Exposed so a syscall handler (syscall.c) can park on it via the + restart-the-syscall pattern instead of calling halt() directly (which, + run from inside an interrupt-gate syscall handler with IF=0, would freeze + the whole machine rather than just the calling thread -- see sysWait's + own doc comment in syscall.c for the same hazard). Returns nullptr for + an invalid handle. */ +const void* pipeChannel(int handle); + +bool pipeValid(int handle); + +/* Opaque token identifying THIS specific pipe living at `handle`'s slot, + distinct from any other pipe that has occupied (or will ever occupy) the + same numeric handle before or after -- this fixed-size table reuses a + slot number the moment both its ends are gone (pipeCloseRead/ + pipeCloseWrite's pipeReleaseIfUnused), so pipeValid(handle) alone cannot + tell a caller that cached a handle across such a release "the same pipe I + bound to earlier" apart from "a different, unrelated pipe that happens to + have been assigned the same number since" (see vfs.c's SFS-backed named + fifo bindings, sfsFifoHandleFor, the motivating caller). Always non-zero + for any handle pipeCreate/pipeCreateDetached has ever handed out; 0 for + an invalid handle, which can never equal a real incarnation, so a naive + "compare and treat 0 as impossible" check is always safe. */ +uint32 pipeIncarnation(int handle); + +int64 pipeRead(int handle, void* buffer, uint64 length); +int64 pipeWrite(int handle, const void* buffer, uint64 length); + +/* Non-consuming readiness peeks for poll.h's vfsPollReady (vfs.c): report + exactly the same two conditions pipeRead/pipeWrite's own success paths + check (data queued or true EOF; free space in the ring), without moving + a single byte or touching readers/writers bookkeeping. False for an + invalid handle either way. */ +bool pipeHasData(int handle); +bool pipeHasSpace(int handle); + +/* Blocks (parks the calling thread and halts) until data is available or + true end-of-file is reached. Returns exactly what pipeRead would once the + wait is over; -1 only for an invalid handle (never blocks in that case). */ +int64 pipeReadBlocking(int handle, void* buffer, uint64 length); + +/* Blocks until free space is available. Returns exactly what pipeWrite would + once the wait is over; returns -1 immediately without blocking for an + invalid handle or a broken pipe (no reader left), since neither condition + can ever resolve by waiting. */ +int64 pipeWriteBlocking(int handle, const void* buffer, uint64 length); + +void pipeCloseRead(int handle); +void pipeCloseWrite(int handle); + +/* Frees a pipeCreateDetached handle that nobody has ever attached a reader + or writer to (readers==0 and writers==0) -- the one case pipeCloseRead/ + pipeCloseWrite's own release-when-both-zero check (pipeReleaseIfUnused) + can never reach on its own, since neither is ever called for an end that + was never attached in the first place. Needed by a named pipe (vfs.h's + vfsMkfifo/vfsUnlink) created but never opened by anyone: without this, its + underlying handle would sit "used" in the fixed-size pipe table forever + once the vfs node naming it is removed. No-op (and reports false) if the + handle is invalid or still has a reader or writer attached -- unlinking a + named pipe that IS currently open must leave it alone, matching real + unlink-while-open semantics (existing fds keep working off the raw handle + number, not the vfs node). */ +bool pipeDestroyIfUnattached(int handle); + +#endif diff --git a/include/poll.h b/include/poll.h new file mode 100644 index 0000000..7ad52bd --- /dev/null +++ b/include/poll.h @@ -0,0 +1,34 @@ +#ifndef FROSTBYTE_POLL_H +#define FROSTBYTE_POLL_H + +#include "types.h" + +/* + * Real poll(2): waits for one or more file descriptors to become ready + * without picking, ahead of time, which single one to block on the way + * userRead/userWrite's own retry-the-syscall path already does for exactly + * one fd at a time (vfs.h's vfsBlockingChannel). Bit values match real + * Linux (POLLIN=0x1, POLLOUT=0x4, POLLERR=0x8, POLLHUP=0x10, + * POLLNVAL=0x20) even though nothing here reads a Linux binary's struct -- + * there is no reason to invent different numbers for the same concept. + */ +#define pollIn 0x1 +#define pollOut 0x4 +#define pollErr 0x8 +#define pollHup 0x10 +#define pollNval 0x20 + +typedef struct PollFd { + int32 fd; + int16 events; + int16 revents; +} PollFd; + +/* Bounded the same way maxOpenFiles (vfs.h) bounds one process's own fd + table -- sysPoll (syscall.c) copies the caller's array into a fixed + kernel-stack buffer of exactly this size, so a request for more entries + than any one process could ever have open fds for is simply rejected + rather than silently truncated. */ +#define maxPollFds 16 + +#endif diff --git a/include/prioq.h b/include/prioq.h new file mode 100644 index 0000000..bd42b5c --- /dev/null +++ b/include/prioq.h @@ -0,0 +1,62 @@ +#ifndef FROSTBYTE_PRIOQ_H +#define FROSTBYTE_PRIOQ_H + +#include "types.h" +#include "rbt.h" + +/* + * Prioq: an ephemeral min-vruntime priority queue built on top of the + * intrusive red-black tree (rbt.h) -- the "run queue" half of a CFS-lite + * (Completely Fair Scheduler, weighted-fair) scheduler. The caller owns the + * PrioQNode storage (typically embedded in a scheduler's thread struct, + * next to the vruntime counter it mirrors) and is expected to rebuild the + * queue from scratch every time a scheduling decision is needed: insert + * every currently-eligible candidate, pop the minimum, discard the rest. + * That "ephemeral" usage pattern (rather than a queue that persists node + * membership across calls) sidesteps a real hazard for a preemptive kernel + * scheduler: a thread can become eligible again from code paths other than + * the scheduler itself (e.g. a wait-queue wakeup), and keeping a + * long-lived queue in sync with every one of those paths is exactly the + * kind of dual-source-of-truth bug this design avoids by never trying. + * + * Ordering key is (vruntime, id): id is a mandatory, unique tiebreaker + * because rbtInsert silently rejects a node whose key compares equal to one + * already present (no duplicates in the underlying tree) -- two candidates + * that happen to share a vruntime would otherwise cause one of them to be + * dropped from the queue entirely rather than merely deprioritised. + */ + +typedef struct PrioQNode { + RbtNode node; + uint64 vruntime; + uint32 id; +} PrioQNode; + +typedef struct PrioQueue { + RbTree tree; +} PrioQueue; + +void prioqInit(PrioQueue* q); + +/* (Re)inserts n into the queue, keyed by (vruntime, id). n's storage is + owned by the caller and may be reused across unrelated queue lifetimes -- + every field the tree needs is reset unconditionally by the underlying + rbtInsert. id must be unique among nodes simultaneously in the same + queue. */ +void prioqInsert(PrioQueue* q, PrioQNode* n, uint64 vruntime, uint32 id); + +/* Returns the minimum-vruntime node without removing it, or nullptr if the + queue is empty. */ +PrioQNode* prioqPeekMin(const PrioQueue* q); + +/* Removes and returns the minimum-vruntime node, or nullptr if the queue is + empty. */ +PrioQNode* prioqPopMin(PrioQueue* q); + +/* Removes an arbitrary node that is currently a member of q. */ +void prioqRemove(PrioQueue* q, PrioQNode* n); + +usize prioqCount(const PrioQueue* q); +bool prioqIsEmpty(const PrioQueue* q); + +#endif diff --git a/include/rbt.h b/include/rbt.h new file mode 100644 index 0000000..9b2f067 --- /dev/null +++ b/include/rbt.h @@ -0,0 +1,63 @@ +#ifndef FROSTBYTE_RBT_H +#define FROSTBYTE_RBT_H + +#include "types.h" + +/* + * Rbt: an intrusive red-black tree, Linux-kernel style. Embed an RbtNode in + * your own struct (next to a key field) and use rbtEntry() (mirrors + * listEntry from list.h) to recover the containing struct. No allocation, + * no element copying -- the tree only ever relinks the nodes you give it. + * + * Ordering is supplied by the caller as plain function pointers rather than + * baked into the node, so the same node type can be indexed by different + * keys in different trees (compare two full nodes for rbtInsert; compare a + * raw key against a node for rbtFind, same convention as bsearch). + */ + +typedef struct RbtNode { + struct RbtNode* left; + struct RbtNode* right; + struct RbtNode* parent; + bool red; +} RbtNode; + +#define rbtEntry(ptr, type, member) \ + ((type*)((uint8*)(ptr) - __builtin_offsetof(type, member))) + +/* Returns <0, 0, >0 as a compares below/equal/above b. Equal means "already + present" -- rbtInsert rejects the new node in that case (no duplicates; + encode a tiebreaker into the comparison if you need a multiset). */ +typedef int (*RbtCompareFn)(const RbtNode* a, const RbtNode* b); + +/* Same convention as bsearch: <0 if key sorts before node, >0 if after. */ +typedef int (*RbtLookupFn)(const void* key, const RbtNode* node); + +typedef struct RbTree { + RbtNode* root; + usize count; +} RbTree; + +void rbtInit(RbTree* t); + +/* Links node into the tree according to compare. Returns false (node left + untouched) if a node comparing equal is already present. */ +bool rbtInsert(RbTree* t, RbtNode* node, RbtCompareFn compare); + +/* Unlinks node from the tree. node must currently be a member of t. */ +void rbtRemove(RbTree* t, RbtNode* node); + +RbtNode* rbtFind(const RbTree* t, const void* key, RbtLookupFn lookup); + +RbtNode* rbtFirst(const RbTree* t); /* leftmost (minimum) */ +RbtNode* rbtLast(const RbTree* t); /* rightmost (maximum) */ + +/* In-order successor/predecessor of node. nullptr past either end. Walking + rbtFirst()..rbtNext()..nullptr visits every node in ascending order. */ +RbtNode* rbtNext(const RbtNode* node); +RbtNode* rbtPrev(const RbtNode* node); + +usize rbtCount(const RbTree* t); +bool rbtIsEmpty(const RbTree* t); + +#endif diff --git a/include/ring.h b/include/ring.h new file mode 100644 index 0000000..731acfd --- /dev/null +++ b/include/ring.h @@ -0,0 +1,39 @@ +#ifndef FROSTBYTE_RING_H +#define FROSTBYTE_RING_H + +#include "types.h" + +/* + * RingBuffer: a fixed-capacity byte circular buffer over a caller-supplied + * backing array. No heap dependency, so it is safe to use for early + * interrupt-driven queues (keyboard scancodes, serial RX) that need to + * exist before the kernel heap is initialised. + */ + +typedef struct RingBuffer { + uint8* data; + usize capacity; + usize head; /* next byte to read */ + usize count; /* bytes currently queued */ +} RingBuffer; + +void ringInit(RingBuffer* r, uint8* backingBuffer, usize capacity); +void ringClear(RingBuffer* r); + +bool ringIsEmpty(const RingBuffer* r); +bool ringIsFull(const RingBuffer* r); +usize ringCount(const RingBuffer* r); +usize ringCapacity(const RingBuffer* r); +usize ringFreeSpace(const RingBuffer* r); + +/* Single-byte push/pop. Push returns false (buffer unchanged) when full; + pop returns false (out untouched) when empty. */ +bool ringPush(RingBuffer* r, uint8 byte); +bool ringPop(RingBuffer* r, uint8* out); + +/* Bulk transfer. Writes/reads as many bytes as fit/are available (may be + fewer than requested) and returns the count actually transferred. */ +usize ringWrite(RingBuffer* r, const uint8* data, usize len); +usize ringRead(RingBuffer* r, uint8* data, usize len); + +#endif diff --git a/include/rtc.h b/include/rtc.h new file mode 100644 index 0000000..b964c1d --- /dev/null +++ b/include/rtc.h @@ -0,0 +1,65 @@ +#ifndef FROSTBYTE_RTC_H +#define FROSTBYTE_RTC_H + +#include "types.h" + +/* + * CMOS real-time clock driver (MC146818-compatible, ports 0x70/0x71) -- + * the first source of real wall-clock date/time this kernel has ever had. + * Every other notion of "time" in frostbyte (pit.h's tick counter, + * threadSleep, userAlarm) is relative to boot, not to the calendar; this + * is the piece that lets a process ask "what day is it" at all. + * + * Honest limitation, same spirit as syscall.c's own documented fork()-less + * design: the CMOS century register's index is not standardized (it is + * only discoverable via the ACPI FADT table, which this kernel does not + * parse), so rtcReadDateTime hardcodes the century to 20 -- correct for + * any date from 2000 through 2099, which covers every real boot this + * kernel will ever see, but not a documented general-purpose solution. + */ + +typedef struct RtcDateTime { + int64 year; /* full year, e.g. 2026 */ + int64 month; /* 1-12 */ + int64 day; /* 1-31 */ + int64 hour; /* 0-23, already normalized out of 12-hour/PM if needed */ + int64 minute; /* 0-59 */ + int64 second; /* 0-59 */ +} RtcDateTime; + +/* Converts a single CMOS BCD byte (e.g. 0x59) to its binary value (59). + Exposed on its own, like ata.c's ataModelString, because it is a real + piece of the conversion pipeline worth testing in isolation from any + hardware access. */ +uint8 rtcBcdToBinary(uint8 bcd); + +/* Reads the current date/time off the CMOS RTC. Polls Status Register A's + Update-In-Progress bit (bounded, like ata.c's ataPollTimeout) before + each of two back-to-back raw reads and only accepts the result once both + raw reads agree byte-for-byte, which rules out a read torn mid-tick by + the RTC's own once-a-second update. Returns false only if the two raw + reads never agreed within the bounded retry budget (never hangs). */ +bool rtcReadDateTime(RtcDateTime* out); + +/* Civil calendar date/time -> seconds since the Unix epoch + (1970-01-01T00:00:00Z), via Howard Hinnant's days_from_civil algorithm + (proleptic Gregorian, correct for any year the RtcDateTime fields above + can represent). Pure arithmetic, no hardware access -- deterministic and + testable without a real clock. */ +int64 rtcEpochSeconds(const RtcDateTime* dt); + +/* rtcReadDateTime + rtcEpochSeconds in one call, the usual entry point for + anything that just wants "now" as a plain integer (sysTime, syscall.c). + Returns 0 if the underlying read failed. */ +int64 rtcNow(void); + +/* Seconds since the Unix epoch -> civil calendar date/time, the exact + inverse of rtcEpochSeconds above (Howard Hinnant's civil_from_days, + proleptic Gregorian, same algorithm family as daysFromCivil in rtc.c). + Correct for any epochSeconds value representable in int64, including + negative ones (dates before 1970). Pure arithmetic, no hardware access + -- the piece sfs.h's on-disk mtime/ctime (raw epoch integers) needs to + become a human-readable date, e.g. for a shell `ls -l`. */ +void rtcCivilFromEpoch(int64 epochSeconds, RtcDateTime* out); + +#endif diff --git a/include/sched.h b/include/sched.h index e421e4b..d6dfc2b 100644 --- a/include/sched.h +++ b/include/sched.h @@ -3,13 +3,78 @@ #include "types.h" #include "vfs.h" +#include "prioq.h" +#include "isr.h" +#include "signalnum.h" +#include "mmap.h" + +/* Default scheduling weight (xv6/CFS-style "nice" niceWeight -- higher runs + more often, not longer). threadSetPriority scales relative to this. */ +#define schedDefaultWeight 1024u + +/* Returned by threadCreate/threadCreateUser when the thread table is full + (see threadCapacity) -- never a valid thread id, since id 0 is always the + permanent idle thread. Callers must check for this before using the + result to index into anything thread-related. */ +#define threadInvalidId 0xFFFFFFFFu + +/* Capacity of Thread.name, including the terminator. Large enough for every + name this kernel currently produces (kernel thread literals, "/bin/..." + paths passed to threadCreateUser by spawnUser/doSpawn) with room to + spare; longer names are truncated, never overflowed. */ +#define threadNameMax 32 + +/* Capacity of Thread.env below: envMaxVars is how many "NAME=VALUE" slots + a process may hold at once, envEntryMax is the max size in bytes of a + single slot INCLUDING both the '=' separator and the nul terminator + (so "NAME=VALUE\0" must fit in envEntryMax bytes). Generous enough for + anything this kernel's own shell/demos actually set (see user/envprint.c) + with room to spare, the same "small fixed table, not a growable heap + allocation" choice Thread.cwd already made for a single path. */ +#define envMaxVars 8 +#define envEntryMax 64 + +/* One bit per signal number in Thread.sigPending/sigBlocked, and the size + of Thread.sigHandlers[] below -- see signal.h for the actual signal + subsystem this supports. Defined here, not signal.h, purely to break the + include cycle (signal.h includes sched.h for the Thread type). */ +#define sigMaxSignals 32 + +/* Depth of the per-thread nested-handler context stack (Thread.sigSavedRegs/ + sigSavedBlocked below) -- how many signal handlers may genuinely interrupt + each other (handler A running, a second signal arrives and is not blocked + by A's own sa_mask, so handler B runs nested inside A, then A resumes once + B returns) before sigConsumePending (signal.c) refuses to deliver a new + one and simply leaves it pending. A real kernel bounds this too (rlimit + on the signal-frame stack it pushes into user memory); this one has no + user-memory signal frame to overflow at all (see signal.h's file comment + -- the saved context lives in the Thread struct itself), so the bound + exists purely to keep that struct's size fixed and to fail a runaway + signal-storm safely (further deliveries just wait) instead of unboundedly + growing kernel memory per thread. 4 is generous headroom for anything + this kernel's own handlers actually do (none nest more than one deep + today -- see user/nesttarget.c) without being large enough to make + Thread noticeably bigger than sigMaxSignals-worth of handlers already + makes it. */ +#define sigMaxNestDepth 4 typedef enum { threadUnused = 0, threadRunnable = 1, threadRunning = 2, threadSleeping = 3, - threadDead = 4 + threadDead = 4, + threadBlocked = 5, + /* Job-control stop (signal.h's SIGSTOP/SIGTSTP): excluded from + schedule()'s ready-queue rebuild exactly like Sleeping/Blocked + already are (only threadRunnable is ever inserted), so a stopped + thread simply never gets picked until threadContinue moves it back + to threadRunnable. Distinct from threadBlocked because it is NOT + waiting on a wait-channel condition -- schedWakeChannel must never + treat it as a match (see schedWakeChannel), only threadContinue + (an explicit SIGCONT, not "the resource became available") ever + moves a thread out of this state. */ + threadStopped = 6 } ThreadState; typedef void (*ThreadEntry)(void); @@ -19,24 +84,663 @@ typedef struct Thread { uint64 stackBase; uint64 kernelStackTop; uint64 sleepUntil; + /* Absolute pitTicks() deadline for a pending alarm(2)-style SIGALRM + (0 == no alarm pending), set by threadSetAlarm and consulted once + per timer tick by schedule() -- the exact same shape as sleepUntil + above, checked in the exact same loop, except expiry raises a real + signal (sigRaise, signal.h) against the owning thread instead of + just flipping its own ThreadState back to Runnable. Deliberately a + plain deadline, not a wait-channel/ThreadState change: unlike + threadSleep, a thread with a pending alarm keeps running (or + sleeping, or blocked on something unrelated) exactly as it already + was right up until the deadline, exactly like real alarm()'s timer + ticking down in the background regardless of what the process is + doing. */ + uint64 alarmAt; + /* poll.h's sysPoll (syscall.c) uses the same restart-the-syscall park + pattern sysRead/sysWrite/sysWait already use, but a poll call can be + woken by any one of several unrelated fds, not one -- so instead of + parking on a single fd's own wait channel (vfsBlockingChannel), + sysPoll blocks every waiting thread on ONE shared channel + (schedPollChannel below) and simply re-checks its whole fd list from + scratch on every wake, spurious or not, exactly like the retry loop + every OTHER restartable syscall already has. pollActive marks "this + thread is mid a not-yet-satisfied sysPoll call" (so a retry entry + can tell itself apart from a genuinely fresh call); pollDeadline is + the absolute pitTicks() deadline schedule()'s own expiry scan forces + a wake at if no fd ever becomes ready first (0 == wait forever, + meaningful only while pollActive is true -- the same "0 reserved" + convention alarmAt above already uses, but here 0 is a real, + legal, requested state (a timeout of -1) rather than "nothing + pending"). */ + bool pollActive; + uint64 pollDeadline; uint64 brk; + uint64 vruntime; + uint64 exitCode; + uint32 weight; uint32 id; + /* Id of whichever thread was actually running (currentIndex, sched.c) + at the moment this thread was created -- threadCreate/ + threadCreateUser both set this unconditionally, so it is always + meaningful, not just for real userspace parent/child pairs: a + boot-time spawnUser (kmain.c) call happens before schedStart() ever + runs, so every process kmain.c spawns directly gets the permanent + idle thread (id 0) as its parentId, which is deliberately never a + valid sigRaise target (see signal.c's targetId==0 rejection) -- + only a RUNTIME sysSpawn (syscall.c's doSpawn, called from inside a + live thread's own syscall) ever produces a parentId that is itself + a live, signalable process. Consulted by threadExit/threadKill + below to deliver a real SIGCHLD to whoever spawned this thread the + instant it actually dies, matching real wait()/SIGCHLD semantics -- + NOT the same thing as "whoever later calls threadJoin/sysWait on + this id", which can be anyone that knows the pid, not just the + spawner (this kernel's honest fork()-less design already documents + that distinction, see doSpawn's own file comment in syscall.c). */ + uint32 parentId; + /* Distinguishes THIS specific occupant of slot `id` from any other + thread that has occupied (or will ever occupy) the same numeric slot + before or after -- the exact same "handle number alone is not enough + once a slot is recycled" idea pipe.c's own Pipe.incarnation already + solves for pipe handles (see pipeIncarnation's own doc comment, + pipe.h). Assigned once, at creation time, from a single monotonic + counter (sched.c's nextThreadGeneration, never reused, mirroring + pipe.c's nextPipeIncarnation exactly) -- threadReap/threadDiscard's + memset resets this back to 0 when the slot is freed, and the next + threadCreate/threadCreateUser to land on that slot gets a brand new, + never-before-seen value, so 0 here always means "this exact slot is + currently unoccupied," never a live thread's own generation. Exists + purely so pidfd.h's pidfdOpen/pidfdTarget can tell "the same thread I + opened a handle on earlier" apart from "a different, unrelated thread + that has since been handed the same slot number" -- allocSlot's own + doc comment (sched.c) documents the real, previously-unguarded race + this closes for any caller that opts into pidfd.h instead of a raw + pid (kill/wait/sigSend/setpgid/nice below all still take a raw pid, + completely unchanged, exactly as that same comment explains why). */ + uint32 generation; + /* Process group id (POSIX pgid): the scope real job control and real + kill(2)/sigSend's pid<0 convention actually target, not a single + thread (signalnum.h's sigInt/sigTstp arriving from keyboard.c's real + Ctrl-C/Ctrl-Z tracking are the live proof -- see keyboard.c's + keyboardHandleScancode and signal.c's sigRaiseGroup). Set once, at + creation time, by threadCreate/threadCreateUser's own initialPgid + helper (sched.c): a thread created while the idle thread (id 0) is + "current" -- i.e. one of kmain.c's boot-time spawnUser/threadCreate + calls, the only time that can ever be true, see parentId's own + comment just above -- becomes its own new group leader (pgid == its + own id), exactly like a real init process placing each service it + starts into its own job; a thread created by a genuinely running + parent (any runtime sysSpawn, syscall.c's doSpawn) instead INHERITS + that parent's pgid, matching real fork()'s own default (a child + stays in its parent's process group until something explicitly + calls setpgid). threadSetPgid (below) is the only thing that ever + changes it again after creation -- the sysSetpgid syscall's + userspace-facing half of real setpgid(2). */ + uint32 pgid; + /* Real POSIX credential (uid(2)/setuid(2)): the identity vfs.c's + vfsPermitted consults, alongside a file's own owner uid and mode, to + decide owner-vs-other access at exactly the two enforcement choke + points fs.h's fsModeOwner and fsModeOther bits already gate + (resolveOpen's write check, loadImageByPath/jobctlSpawn's exec check) -- see + vfsPermitted's own doc comment for the full owner/other algorithm + (deliberately no uid-0-bypasses-everything superuser rule -- see that + same comment for why). Same creation-time default/copy split as pgid + immediately above (sched.c's new initialUid, mirroring initialPgid's + own "is the idle thread currently running" test): a boot-time thread + (kmain.c's spawnUser/threadCreate calls, idle currently "current") + starts as uid 0 ("root" in name only here, not in VFS privilege -- + see fs.h's fsModeOwner* doc comment), exactly like a real init + process; a genuine runtime child (syscall.c's doSpawn) instead + INHERITS its parent's uid, matching real fork()'s own default before + any setuid ever runs. currentThreadSetUid (below) is the only thing + that ever changes it again after creation -- and, matching real + setuid(2)'s own privilege rule, only ever lets uid 0 become anyone; + any other uid may only "become" itself (a no-op), so dropping root is + real and permanent for the lifetime of this thread, exactly like a + real daemon that setuid()s away from root at startup and can never + regain it. */ + uint32 uid; + /* Real POSIX group credential (getgid(2)/setgid(2)): the group identity + vfs.c's vfsPermitted consults, alongside a file's own group gid, as + the MIDDLE fallback between the owner and other checks fs.h's + fsModeGroup* bits gate. Same creation-time default/copy split as uid + immediately above (sched.c's new initialGid, mirroring initialUid's + own shape exactly): a boot-time thread starts at gid 0, a genuine + runtime child (syscall.c's doSpawn) INHERITS its parent's gid. + currentThreadSetGid (below) is the only thing that ever changes it + again -- unlike currentThreadSetUid, the privilege gate for THIS + call is the calling thread's own uid (currentThreadUid() == 0), not + its gid, matching real setgid(2): a privileged (root) PROCESS may + setgid to any group; an unprivileged one may only pass its own + current gid back in (a no-op), so dropping to a group is real and + permanent for the lifetime of a non-root thread, same shape as uid's + own one-way drop. */ + uint32 gid; + /* Real per-process working directory (POSIX chdir(2)/getcwd(2)): always + a fully resolved absolute path (vfs.h's vfsChdir resolves a relative + argument against the CALLING thread's own cwd before ever storing + here, so this field itself never needs re-resolving against + anything). Set once, at creation time, by threadCreate/ + threadCreateUser's own initialCwd helper (sched.c) -- a thread + created while the idle thread (id 0) is "current" (kmain.c's + boot-time spawns, see parentId's own comment above) starts at "/", + exactly like a real init process; a thread created by a genuinely + running parent (any runtime sysSpawn, syscall.c's doSpawn) instead + INHERITS a byte-for-byte COPY of that parent's cwd, matching real + fork()'s own copy-not-share semantics -- the two are independent + buffers from that point on, so a child's later chdir() can never + reach back and change its parent's, and vice versa. Before this + field existed, the only cwd concept anywhere in this kernel was a + single static local in shell.c shared by nothing else -- a spawned + user process calling open()/mkdir()/etc with a relative path had + never actually worked, since vfs.c's lookupNode/sfsLookup always + resolved relative to the filesystem root, never to any notion of + "current directory" (see vfs.c's vfsResolvePath, the only thing that + reads this field). */ + char cwd[fsPathMax]; + /* Real per-process environment variables (POSIX getenv(3)/setenv(3)): + up to envMaxVars "NAME=VALUE\0" strings, each at most envEntryMax-1 + bytes, plus envCount tracking how many slots are actually in use. + Same creation-time default/copy split as Thread.cwd immediately + above (sched.c's new initialEnv, mirroring initialCwd's own shape): + a boot-time thread (idle is currently running) starts with an EMPTY + table, like a real init process before anything ever calls + setenv(); a genuine runtime child (syscall.c's doSpawn) instead + inherits a byte-for-byte COPY of its parent's table, so a child's + later setenv() can never reach back and change its parent's, and + vice versa -- the exact same independent-buffer guarantee cwd + already gives. */ + char env[envMaxVars][envEntryMax]; + int envCount; ThreadState state; - const char* name; + /* Set once, permanently, the first time schedule() ever actually + switches execution to this thread. Distinguishes "freshly created, + never scheduled even once" (state threadRunnable, everRan false) from + "was running and got preempted, still Runnable" (everRan true) -- the + two look identical by state alone, but only the former is safe for + threadDiscard to tear down (see threadDiscard). */ + bool everRan; + /* Fixed buffer, not a pointer: threadCreateUser/doSpawn (syscall.c) may + be called with a name that lives in a caller's stack-local buffer + (a path just copied in from userspace), which does not outlive the + call. thread->name must remain valid for the thread's entire life + (shell.c's `ps` listing and the exit log both read it long after + creation), so the bytes are always copied in, never aliased -- see + threadSetName/copyThreadName in sched.c. */ + char name[threadNameMax]; + const void* waitChannel; + /* Base virtual address of this thread's elfSlotSize-sized user-memory + slot (image + stack + brk-heap all live within + [userSlotBase, userSlotBase+elfSlotSize), see elf.h) -- 0 for kernel + threads (threadCreate), which own no user pages to give back. + Set from the entry point by threadCreateUser at spawn time and + updated by exec() (syscall.c doExec) when a thread's image moves to + a different slot. threadReap/threadDiscard pass this to + pagingUnmapUserRange so a dead or rolled-back process's physical + frames are actually reclaimed instead of leaking forever. */ + uint64 userSlotBase; + PrioQNode schedNode; FileDescriptor fds[maxOpenFiles]; + /* mmap.h's own per-thread mapping table -- real demand-paged mmap(2)/ + munmap(2), resolved lazily by isr.c's #PF handler through + mmapFaultHandler rather than eagerly mapped like pagingMapUserRange's + image/stack/brk pages. Lives inside the same [userSlotBase, + userSlotBase+elfSlotSize) slot as everything else this thread owns + (see mmap.h's own mmapReserve doc comment for exactly which sub-range), + so threadReap/threadDiscard's existing whole-slot + pagingUnmapUserRange already reclaims every frame a mapping here ever + faulted in with zero changes of their own -- this array only ever + needs its OWN bookkeeping cleared (mmapReleaseAll), which the + memset() both of those already do to the whole struct handles for + free. */ + Vma vmas[maxVmasPerThread]; + /* Signal state (signal.h/signal.c). Explicitly zeroed by sigResetState + from threadCreate/threadCreateUser below, the same way every other + field above is explicitly reset there too -- threadReap/threadDiscard + already memset() this whole struct before any slot is ever handed + back to allocSlot, so this is belt-and-suspenders, not a fix for a + leak that could otherwise happen (unlike keyboard.c's console-reader + table or pipe.c's refcounts, which live OUTSIDE Thread and genuinely + needed their own explicit reclaim -- see reclaimOpenFds). */ + uint64 sigPending; + uint64 sigBlocked; + uint64 sigHandlers[sigMaxSignals]; + /* Per-signal sa_mask (signal.h/signal.c): the ADDITIONAL signals to + block, alongside the one being delivered, for as long as + sigHandlers[signum]'s handler is running -- set by sigSetHandler, + consulted only by sigConsumePending's custom-handler branch. Index is + the signal being handled, not the signals it blocks (mirrors + sigHandlers[] being indexed by signal number, not by handler). */ + uint64 sigHandlerMasks[sigMaxSignals]; + /* Per-signal sa_flags (signal.h/signal.c) -- today only sigFlagRestart + (signalnum.h) is a defined bit, stored here exactly like + sigHandlerMasks is: set by sigSetHandler, indexed by the signal being + handled, consulted only by sigConsumePending when it is about to + decide whether an interrupted retryable syscall (see + sigInRetryableSyscall below) resumes transparently or returns + errInterrupted. */ + uint64 sigHandlerFlags[sigMaxSignals]; + /* SigInfo (signalnum.h) attached to whichever instance of signum is + CURRENTLY pending (index == signal number, same convention as + sigHandlers/sigHandlerMasks/sigHandlerFlags above) -- set by + sigRaiseInfo (signal.c) the moment sigPending's bit for it is set, + read (and copied into the shared user-visible page) by + sigConsumePending only if that signal's handler was registered with + sigFlagSigInfo. Like sigPending itself, this does not queue: raising + the same signum again before it is ever delivered overwrites this + with the newer detail, matching real Unix's own non-queuing standard + (non-realtime) signals, where only the most recent instance's info is + ever actually observable once more than one arrives uncollected. */ + SigInfo sigPendingInfo[sigMaxSignals]; + /* True exactly while this thread is parked mid the restart-syscall + pattern sysRead/sysWrite/sysWait/sysPoll (syscall.c) use to block: rip + has already been rewound by the interrupted `int $0x80`'s 2 bytes so + re-entering ring 3 re-executes the syscall from scratch. Set by + syscall.c immediately before each of those four calls parks and + reschedules; cleared unconditionally at the top of every + syscallDispatch call (a fresh dispatch, whether this is the retry + itself or a completely unrelated syscall, is never "still blocked" by + definition) AND by sigConsumePending the moment it actually acts on a + pending signal for this thread, win or lose -- a signal delivered + while this is false (ordinary running code, a sleep, or a nested + signal arriving inside an already-running handler) never touches + rip/rax at all, exactly as if sigFlagRestart's whole mechanism did + not exist, since there is no interrupted syscall retry to redirect. */ + bool sigInRetryableSyscall; + /* How many handler contexts deep this thread currently is (0 = running + ordinary code, not inside any handler) -- sigSavedRegs[0..depth-1]/ + sigSavedBlocked[0..depth-1] are the LIFO stack of interrupted contexts + sigOnSigReturn pops from, one per still-active handler, exactly like a + real call stack of signal frames. sigConsumePending refuses to push a + new one once this reaches sigMaxNestDepth. */ + uint32 sigHandlerDepth; + uint64 sigSavedBlocked[sigMaxNestDepth]; + Registers sigSavedRegs[sigMaxNestDepth]; } Thread; void schedInit(void); + +/* Allocates a slot from the fixed-size thread table (reusing the lowest + free slot left behind by threadReap before ever growing the high-water + mark) and initializes a new thread in it. Returns threadInvalidId, + touching nothing else, if the table is completely full (threadCapacity + threads already live) -- callers MUST check for this instead of assuming + the id is always valid. */ uint32 threadCreate(const char* name, ThreadEntry entry); uint32 threadCreateUser(const char* name, uint64 entry, uint64 userStackTop); uint64 schedule(uint64 currentRsp); void schedStart(void); +/* Total number of thread-table slots (used and free). A fixed compile-time + constant, exposed so callers (and tests) can reason about exhaustion + without hardcoding the internal capacity. */ +uint32 threadCapacity(void); + +/* Releases thread `id`'s resources (kernel stack) and resets its slot back + to threadUnused so a later threadCreate/threadCreateUser can reuse it. + Only valid on a thread whose state is threadDead (i.e. it has already + called threadExit) -- reaping anything else (including the permanent + idle thread, a still-live thread, or an id that was never created / was + already reaped) is rejected and returns false with no effect. */ +bool threadReap(uint32 id); + +/* Tears down a thread that was created but has never actually been + scheduled (state threadRunnable and everRan false) -- e.g. rolling back a + spawn that failed a later setup step after threadCreate/threadCreateUser + already succeeded. Unlike threadReap this does not require the thread to + have exited: it requires the opposite, that it has not yet run even + once, since anything that has run may hold state (a partially unwound + stack, in-flight I/O) that is not safe to discard out from under it. + Returns false, changing nothing, if the thread has ever run, is not + currently Runnable (already sleeping/blocked/dead/unused), or id is out + of range. */ +bool threadDiscard(uint32 id); + +/* Blocks the calling thread (parking with schedBlockCurrent + halt(), the + same pattern pipeReadBlocking/pipeWriteBlocking use) until thread `id` + has called threadExit, then returns its exit code and reaps it (frees + the slot for reuse) in one step -- the same "wait for a child, get its + status, reclaim it" contract as a real wait()/join(). Returns -1 + immediately, without blocking, for an invalid id (0, out of range, or a + slot that is not currently a live thread) rather than parking forever on + something that will never wake it. Joining the same id a second time (or + from two different callers) is not supported: the first joiner reaps the + slot, so a second joiner sees "no such thread" and gets -1. */ +int64 threadJoin(uint32 id); + +/* Non-blocking scan for waitpid(-1)-style "reap whichever child happens to + already be dead, without caring which specific pid" (sysWait's waitAnyPid + case, syscall.c/syscall.h; user/waitanytest.c is the live demo). Unlike + threadJoin above this never calls halt() itself -- it can be (and is) + invoked from inside the interrupt-context syscall handler, where parking + directly would wedge the whole machine (see sysWait's own comment on why + its specific-pid path does its own retry-syscall dance rather than just + calling threadJoin unconditionally); the caller is responsible for + actually blocking when this returns waitScanNoneDead. + Scans every live slot for one whose parentId is `parentId`: + - none exist at all (dead or alive) -> waitScanNoChildren, the real + waitpid(-1) ECHILD case -- there is nothing to ever wait for, so the + caller must not block. + - at least one exists but none are threadDead yet -> waitScanNoneDead. + - one is already threadDead -> waitScanFoundDead, with its own id and + exit code written to *outId and *outCode; it is already fully reaped + (threadReap already ran) by the time this returns, the same contract + threadJoin(id) has for an already-dead target. Picks whichever + matching dead slot has the lowest index if more than one child + happens to be dead at once -- a real waitpid(-1) makes no promise + about which one it returns in that case either. */ +typedef enum { waitScanNoChildren, waitScanNoneDead, waitScanFoundDead } WaitScanResult; +WaitScanResult threadJoinAnyChild(uint32 parentId, uint32* outId, int64* outCode); + +/* Terminates the CALLING thread with the given exit code (sysExit's own + underlying implementation, syscall.c, and isr.c's uncaught-fatal-signal + path). Also raises a real sigChld (signal.h/signal.c) against + Thread.parentId above -- asynchronous notification, on top of (not a + replacement for) threadJoin/sysWait's synchronous "block until this pid + is Dead" contract: a parent with a sigChld handler installed and + genuinely blocked elsewhere (see user/sigchldtest.c, which spawns + user/sigchldchild.c) is woken through the same early-wake path + threadWakeForSignal already gives any other signal, rather than only + finding out the next time it happens to call sysWait. Harmless no-op on + the sigRaise side if parentId is 0 (the idle thread, boot-time spawns' + universal parent, see parentId's own comment) or already dead/unused + (an orphan whose spawner is itself gone) -- sigRaise's own target + validity check (signal.c) is what makes that safe, not anything special + here. Also wakes the parent's OWN slot as a wait channel (on top of, not + instead of, this slot's own channel below) -- the parking spot sysWait's + waitAnyPid path (syscall.c) uses via schedBlockCurrent(currentThread()), + so a parent genuinely blocked in userWaitAny wakes the instant ANY of + its children exits, not just one it named specifically. */ void threadExit(uint64 code); + +/* Exit code threadKill assigns its victim -- named after SIGKILL (9), the + nearest real-world equivalent, though this kernel has no signal delivery + at all: threadKill is a hard, unconditional termination, not something a + target thread can catch, mask, or clean up after (see threadKill). */ +#define threadKilledExitCode 9u + +/* Unconditionally terminates thread `id` from OUTSIDE it -- the only way to + ever get back a process that is genuinely stuck (e.g. blocked forever in + a syscall-level pipe read with no writer ever coming, now that + sysRead/sysWrite can really block, see pipe.h/vfs.h). Works on a thread + in any of Runnable/Sleeping/Blocked: its state is simply set straight to + threadDead with exitCode threadKilledExitCode, exactly as if it had + called threadExit(threadKilledExitCode) itself, and whoever is (or later + becomes) threadJoin()ing it wakes up and reaps it normally -- there is no + "half-dead" state and no cleanup callback, since a mid-syscall thread has + no user-mode cleanup path to run anyway. Returns false, changing nothing, + for id 0 (idle), an out-of-range id, an already-Dead/never-created slot, + or (implicitly, since only the CPU's one currently-executing thread is + ever threadRunning) the caller's own id -- a syscall handler always runs + as its OWN thread, which is Running, not Runnable/Sleeping/Blocked, at + the moment it would try to kill itself, so self-kill is naturally + rejected without a special-case check. Real termination (sysExit) is + still how a thread should ever end itself. Also raises sigChld against + the victim's parentId, exactly like threadExit does -- a killed child + is still a dead child as far as its parent's SIGCHLD is concerned, the + same way real kill(2)ing a child still generates SIGCHLD to its real + parent. Also wakes the victim's parent's OWN slot as a wait channel, + exactly like threadExit does -- a parent parked in userWaitAny does not + care whether a child exited on its own or was killed, same as real + waitpid(-1) does not distinguish the two either. */ +bool threadKill(uint32 id); + +/* Job control (signal.h): unconditionally moves thread `id` -- Runnable, + Running (including the caller itself, unlike threadKill which naturally + excludes self-targeting), Sleeping, or Blocked -- into threadStopped. + Idempotent if `id` is already Stopped (matches real kill(pid, SIGSTOP) + succeeding again on an already-stopped process). Safe on a + Sleeping/Blocked thread for the same reason threadKill already is: every + such thread is parked mid-retry-loop right after its own halt() (see + pipeReadBlocking/pipeWriteBlocking, or the restart-syscall pattern + syscall.c's sysRead/sysWrite/sysWait use), so simply not being picked by + schedule() again until threadContinue moves it back is enough -- no + wait-channel state is lost, since that retry loop re-checks its own + condition from scratch every time it runs at all. Returns false, + changing nothing, for id 0 (idle -- stopping it would wedge the whole + machine the instant nothing else was runnable), an out-of-range id, or + an already-Dead/never-created slot. */ +bool threadStop(uint32 id); + +/* Job control (signal.h): moves thread `id` from threadStopped back to + threadRunnable. Returns false, changing nothing, if `id` is out of + range or not currently Stopped (matches real SIGCONT delivered to a + process that was never stopped -- a harmless no-op at the process-state + level; signal.c's sigRaise still delivers the signal itself/runs a + handler regardless of this return value). */ +bool threadContinue(uint32 id); + +/* Real Unix's interruptible sleep/EINTR: forces thread `id` from + threadSleeping or threadBlocked straight to threadRunnable, early -- + before its sleep timer expires or its wait-channel condition is ever + satisfied -- so the NEXT schedule() eventually resumes it, letting + isr.c's deliverPendingSignals (which only ever runs at a return-to-ring3 + point) actually reach a thread that would otherwise never naturally + produce one. Called only by signal.c's sigRaise, and only for a signal it + has already determined would genuinely be delivered (not blocked, not + ignored) if this thread resumed right now -- callers must not use this to + wake a thread for an unrelated reason. Deliberately excludes + threadStopped (only threadContinue, an explicit SIGCONT, may ever move a + Stopped thread -- the same asymmetry schedWakeChannel already has, for + the same reason: job control's "stopped means stopped" must survive an + ordinary signal arriving too). Returns false, changing nothing, for an + out-of-range id or any state other than Sleeping/Blocked (Runnable/ + Running need no forcing; Stopped/Dead/Unused have nothing to wake, or + must not be woken this way at all). */ +bool threadWakeForSignal(uint32 id); + void threadSleep(uint64 milliseconds); void threadSetBrk(uint32 id, uint64 brk); uint64 currentThreadSbrk(int64 increment); +/* Real alarm(2): schedules a real SIGALRM (signalnum.h's sigAlrm) against + the CALLING thread only, `seconds` real seconds from now (converted to + pitTicks() ticks via pitFrequency(), the same conversion threadSleep + already uses for milliseconds) -- never another thread, exactly like + POSIX alarm() has no target-pid argument at all. `seconds` == 0 cancels + any alarm already pending for this thread instead of scheduling a new + one, matching real alarm(0). Returns the number of whole seconds that + remained on a PREVIOUSLY pending alarm for this thread (rounded up, so a + deadline even one tick in the future still reports at least 1, exactly + like real alarm() never reports "0 remaining" for one that has not + actually fired yet), or 0 if none was pending -- the caller's own + previous alarm is silently replaced (or cleared) either way, matching + real alarm()'s "at most one pending alarm per process" contract. Actual + delivery happens later, from schedule() (sched.c), once pitTicks() + reaches the deadline this call records. */ +uint64 threadSetAlarm(uint64 seconds); + +/* Weighted-fair scheduling (CFS-lite): every thread starts at + schedDefaultWeight. A thread with weight W accrues virtual runtime + schedDefaultWeight/W times as fast as a default-weight thread each time + it runs, so it is picked from the ready queue proportionally more often + -- not given a longer timeslice, since this scheduler's granularity is + already one timer tick. Returns false (no-op) for the same targets + threadSetPgid already refuses -- the idle thread (id 0), an out-of-range + id, or a slot that is not currently a live thread (Dead or Unused) -- + plus a zero weight, which used to be silently ignored (kmain.c-only + caller never triggered it) before this became reachable from userspace + through sysNice (syscall.c). Was a kernel-internal helper only used + once, at boot, for workerA/workerB's own priority demo (kmain.c) -- + still is, but ALSO now the mechanism sysNice's syscall.c handler calls, + so its own validation had to catch up to threadSetPgid's, not stay at + the older, weaker "just check threadTotal" bound that let a userspace + nice() call quietly reweight thread 0 (idle) or a slot no thread has + ever occupied. */ +bool threadSetPriority(uint32 id, uint32 weight); + +/* Returns thread `id`'s current scheduling weight, or 0 (never a real + weight -- threadSetPriority above always rejects weight 0, and every + thread starts at schedDefaultWeight) for an out-of-range id or a slot + that has never been a live thread (Unused) -- same "0 doubles as the + error return, since it is never a valid one" shape threadPgid below + has with threadInvalidId. A Dead-but-not-yet-reaped thread still + reports its last real weight, exactly like threadPgid still reports + its last real pgid for the same state -- see that function's own + comment for why. */ +uint32 threadPriority(uint32 id); +uint64 threadVruntime(uint32 id); + +/* Returns thread `id`'s process group id (Thread.pgid above), or + threadInvalidId for an out-of-range id or a slot that is not currently a + live thread -- the same "invalid id sentinel doubles as an error return" + convention threadInvalidId already has for allocation failure, safe here + because a real pgid is always some other live thread's own id (never + 0xFFFFFFFF, since ids never reach that high, see threadCapacity). */ +uint32 threadPgid(uint32 id); + +/* Real setpgid(2)'s underlying mechanism: unconditionally sets thread + `id`'s pgid to `pgid`, whatever value the caller resolved "0 means self"/ + "0 means use pid itself as the new group id" down to (that resolution is + the SYSCALL layer's job, syscall.c's sysSetpgid -- mirroring how + threadSetBrk/threadSetPriority/threadSetAlarm above already take a fully + resolved value, not a raw syscall argument). Deliberately does not + validate that `pgid` names any other real thread at all: this kernel has + no session concept to check membership against (real setpgid(2) would + reject joining a group outside the caller's own session), so -- like + several other omissions this codebase documents honestly rather than + fakes -- any uint32 is accepted, exactly as if every process were in one + giant session. Returns false, changing nothing, for id 0 (idle -- it has + no job-control identity to reassign) or an out-of-range/Dead/Unused id. */ +bool threadSetPgid(uint32 id, uint32 pgid); + +/* Real kill(-pgid, SIGKILL)-shaped bulk termination: calls threadKill on + every currently live thread (Runnable/Sleeping/Blocked/Stopped, per + threadKill's own accepted set) whose pgid equals `pgid`, one at a time, + through the ordinary single-target threadKill above -- so its own + per-target SIGCHLD-to-parent notification and wait-channel wakes already + apply per victim exactly as they would for a plain, non-group kill, + rather than this function duplicating any of that. A pgid whose only + member is the CALLER's own currently-Running thread naturally kills + nobody (threadKill excludes Running, i.e. self, by construction, the + same "self-kill via plain kill(2) is not really different" reasoning + userKill's own doc comment already gives) without needing a special + case here. Returns false, killing nobody, if no currently live thread + has this pgid at all -- the real ESRCH case for kill(-pgid, ...) with no + matching group. */ +bool threadKillGroup(uint32 pgid); + +/* Real getcwd(2)/chdir(2)'s underlying per-thread state (Thread.cwd above): + always the CALLING thread's own directory, never another pid's -- unlike + threadSetPgid, there is no `id` parameter here at all, since POSIX's + chdir() has no cross-process form the way setpgid(2) does for a child + still between fork() and exec(). currentThreadSetCwd does no validation + of its own (mirrors threadSetPgid's own "that is the syscall layer's + job" split) -- vfs.c's vfsChdir is what actually confirms `path` names a + real, already-resolved-to-absolute directory before ever calling this; + a `path` longer than fsPathMax-1 is rejected here (false, unchanged) + since it could never round-trip back out through currentThreadCwd(). */ +const char* currentThreadCwd(void); +bool currentThreadSetCwd(const char* path); + +/* Real getuid(2)/setuid(2)'s underlying per-thread state (Thread.uid + above): always the CALLING thread's own identity, mirroring + currentThreadCwd's own "no id parameter, no cross-process form" shape + exactly (POSIX setuid() has no cross-process form either -- a process can + only ever change its OWN credential). currentThreadSetUid enforces the + real privilege rule itself (this kernel's syscall layer does no + additional validation of its own, unlike currentThreadSetCwd's split): + uid 0 ("root") may become anyone; any other uid may only pass its own + current value back in (a no-op that still reports success, matching real + setuid(getuid())), and any other target is rejected, state unchanged. + There is deliberately no way back to uid 0 once dropped, short of + already being uid 0 at the time of the call -- the same one-way + privilege-drop shape a real setuid daemon relies on. */ +uint32 currentThreadUid(void); +bool currentThreadSetUid(uint32 newUid); + +/* Real getgid(2)/setgid(2)'s underlying per-thread state (Thread.gid above) + -- same "no id parameter, no cross-process form" shape as currentThreadUid + immediately above. currentThreadSetGid's privilege gate is the CALLING + thread's own currentThreadUid() (== 0), not its gid -- matching real + setgid(2), whose privilege check is CAP_SETGID (effectively "is this + process root"), never based on the gid being changed itself. A + non-privileged thread may only pass its own current gid back in (a + no-op success, matching real setgid(getgid())); any other target is + rejected, state unchanged. */ +uint32 currentThreadGid(void); +bool currentThreadSetGid(uint32 newGid); + +/* Real getenv(3)/setenv(3)'s underlying per-thread state (Thread.env + above): always the CALLING thread's own table, mirroring + currentThreadCwd/currentThreadSetCwd's own "no id parameter, no + cross-process form" shape exactly (POSIX setenv() has no cross-process + form either). currentThreadGetEnv returns a pointer straight into the + matching slot's own VALUE half (never a copy) or nullptr if `name` has + no entry -- that pointer stays valid only until the next + currentThreadSetEnv call on the SAME name, which may overwrite the slot + in place. currentThreadSetEnv overwrites an existing entry with a + matching `name` in place, or appends a new one if a slot is still free + under envMaxVars; returns false, table unchanged, if `name`+'='+`value` + would not fit in envEntryMax bytes, or if `name` is new and every slot + is already taken. */ +const char* currentThreadGetEnv(const char* name); +bool currentThreadSetEnv(const char* name, const char* value); + +/* Real unsetenv(3): removes the calling thread's own entry named `name`, + if any, compacting the table (memmove of every later slot down by one) + so envCount and currentThreadEnvEntry's own index space never develop a + hole -- unlike currentThreadSetEnv's in-place overwrite, this changes + WHICH index every later entry lives at. Returns true if an entry was + actually removed, false (table unchanged) if `name` had no entry -- + matching real unsetenv(3), which is a no-op success either way, but the + bool lets a caller (shell.c's `unset` builtin) tell the difference if it + ever wants to. */ +bool currentThreadUnsetEnv(const char* name); + +/* Iterates the calling thread's own environment table for listing + (shell.c's `env` builtin): currentThreadEnvCount() is how many of the + envMaxVars slots are actually in use; currentThreadEnvEntry(index) + returns the raw "NAME=VALUE" string stored at that slot, or nullptr for + an out-of-range index. */ +int currentThreadEnvCount(void); +const char* currentThreadEnvEntry(int index); + +/* Copies up to threadNameMax-1 bytes of `name` into thread `id`'s own name + buffer (truncating, always nul-terminated). Used by exec() (syscall.c) so + a process's visible identity (shell `ps`, the exit log) reflects the new + image after it replaces the old one -- the same way a real process's + name changes across execve(). Invalid id is a silent no-op. */ +void threadSetName(uint32 id, const char* name); + +/* Base of thread `id`'s user-memory slot (see Thread.userSlotBase above), + or 0 for an out-of-range id or a kernel thread that owns no user slot. + Used by exec() (syscall.c) to find the OLD slot to reclaim before it + overwrites its own image with a new one. */ +uint64 threadUserSlotBase(uint32 id); + +/* Records which elfSlotSize-sized slot thread `id` currently occupies. + Only exec() (syscall.c) needs this directly -- threadCreateUser sets it + itself from the entry point it is given. Invalid id is a silent no-op. */ +void threadSetUserSlotBase(uint32 id, uint64 base); + +/* Wait-queue primitives ("sleep on a channel", xv6-style). A channel is any + stable address the caller controls (e.g. the address of the resource being + waited on) - it is never dereferenced, only compared for identity. + + schedBlockCurrent marks the calling thread as blocked on a channel; it does + NOT itself give up the CPU (this scheduler is timer-preemptive, not + cooperative - callers park with halt() the same way threadSleep callers + already do, see kmain.c). schedWakeChannel moves every thread blocked on a + matching channel back to runnable; it is always safe to call even when no + thread is waiting (no-op) and must be called by whichever side of a + producer/consumer relationship changes the condition the other side is + waiting on. */ +void schedBlockCurrent(const void* channel); +void schedWakeChannel(const void* channel); + +/* The one wait channel every not-yet-satisfied sysPoll call blocks on (see + Thread.pollActive's own comment above) -- any code path that can make a + previously-not-ready fd become ready (pipe.c's pipeWrite/pipeRead + freeing space, pipeCloseRead/pipeCloseWrite breaking a pipe, + keyboard.c's keyboardHandleScancode enqueueing a keystroke) calls + schedWakeChannel(schedPollChannel()) in addition to whatever + fd-specific channel it already wakes, so every blocked poller gets a + chance to re-check its own fd list. Always returns the same address for + the life of the kernel -- not tied to any particular fd or pipe, unlike + pipeChannel/keyboardReaderChannel. */ +const void* schedPollChannel(void); + uint32 threadCount(void); Thread* threadAt(uint32 index); Thread* currentThread(void); diff --git a/include/sfs.h b/include/sfs.h new file mode 100644 index 0000000..67807af --- /dev/null +++ b/include/sfs.h @@ -0,0 +1,226 @@ +#ifndef FROSTBYTE_SFS_H +#define FROSTBYTE_SFS_H + +#include "types.h" + +/* + * SFS ("simple filesystem"): a real, persistent, inode-based filesystem + * living on the ATA block device (ata.h), starting at LBA sfsBaseLba. Unlike + * the VFS tmpfs tree (vfs.c), which is pure kmalloc state that evaporates on + * reboot, SFS state (superblock, bitmaps, inode table, file data) is fully + * on disk: sfsUnmount()+sfsMount() drops every in-memory structure and + * reconstructs it by reading the disk, the same way a real filesystem driver + * would after a remount. + * + * Block size equals the sector size (512 bytes), so every filesystem block + * is exactly one ATA sector transfer. sfsBaseLba (100) and the reserved + * tail (16 sectors) keep this filesystem's LBA range clear of the raw + * sector ranges ataTest.c/disktest.c already poke directly, so the two + * layers never fight over the same bytes even though they share one disk. + */ + +#define sfsBlockSize 512 +#define sfsBaseLba 100 +#define sfsReserveEnd 16 +#define sfsMaxNodes 512 +#define sfsRootInode 1 +#define sfsNameMax 55 +#define sfsMaxSymlinkHops 8 +#define sfsSymlinkTargetMax 255 + +#define sfsTypeFree 0 +#define sfsTypeFile 1 +#define sfsTypeDir 2 +#define sfsTypeSymlink 3 +#define sfsTypeFifo 4 + +/* Formats the SFS region from scratch: fresh superblock, zeroed bitmaps and + inode table, empty root directory. Destroys any existing SFS content in + that LBA range. Returns false if no drive is present or the drive is too + small to hold the fixed-size metadata. */ +bool sfsFormat(void); + +/* Mounts the existing on-disk filesystem, auto-formatting on first use (bad + magic). Loads the inode/block bitmaps into memory; everything else (inode + table, file data, directory entries) is read from disk on demand. */ +bool sfsMount(void); + +/* Flushes bitmaps/superblock to disk and frees in-memory state. A later + sfsMount() reconstructs everything purely from what is on disk. */ +void sfsUnmount(void); + +bool sfsMounted(void); + +/* Total bytes usable for file data (excludes reserved metadata blocks). */ +uint64 sfsCapacityBytes(void); + +/* Data blocks not currently allocated to any inode (bitmap free count). + Mainly useful for tests to prove truncate/remove actually gives blocks + back rather than merely detaching them from the inode. */ +uint64 sfsFreeDataBlocks(void); + +/* path is slash-separated and relative to the SFS root ("" or "/" is the + root itself). Returns the inode number (>=1) or -1 if not found. Note: + "." and ".." are not special-cased (deliberate scope limit: paths are + always resolved from the root down, no relative-to-self hops). + + A symlink encountered anywhere along the path - including the final + component - is transparently followed to its target and re-resolved from + the root (real open()/stat() semantics), up to sfsMaxSymlinkHops hops; + beyond that (a cycle, or a chain that is simply too long) resolution fails + with -1 instead of recursing forever. Use sfsLookupNoFollow to inspect a + symlink itself rather than what it points at. */ +int32 sfsLookup(const char* path); + +/* Same resolution as sfsLookup, except a symlink at the FINAL path component + is returned as itself instead of being followed (the lstat()/readlink() + convention). Symlinks at any earlier component are still followed, since + there is no other way to keep descending through them. */ +int32 sfsLookupNoFollow(const char* path); + +int32 sfsCreateFile(const char* path); +int32 sfsCreateDir(const char* path); + +/* Creates a persistent named-pipe entry at `path`: a real, on-disk directory + entry of type sfsTypeFifo that survives sfsUnmount()+sfsMount() (and a + real reboot, unlike the VFS tmpfs-tree fifos vfsMkfifo already supports + under e.g. "/tmp") exactly the way a real filesystem's mkfifo(2) special + file does. The inode carries no data blocks -- sfsReadAt/sfsWriteAt + already refuse anything that is not sfsTypeFile/Dir/Symlink, so a fifo + inode simply has size 0 forever, matching real Unix (mkfifo persists the + NAME and TYPE of the rendezvous point, never any buffered bytes: those + only ever exist in memory while some process holds the pipe open, and + are gone the moment every process does, reboot or not). vfs.c is + responsible for the actually-live half of a fifo (attaching real + pipe.h byte-queue handles to opens of this path, see vfsOpen) -- + sfs.c itself stays entirely ignorant of pipe.h, exactly as it already is + of every other higher-layer concern. Returns the new inode number, or -1 + on the same failures as sfsCreateFile/sfsCreateDir (parent missing/not a + writable dir, name already taken, tables full). */ +int32 sfsCreateFifo(const char* path); + +/* Monotonically increasing counter, bumped exactly once every time + sfsFormat() actually wipes the on-disk filesystem (whether called + directly, e.g. every sfsTest.c test's own clean-slate setup, or + implicitly by sfsMount() the first time it sees a bad magic) -- never + bumped by a plain sfsMount()/sfsUnmount() cycle that finds a valid, + unchanged filesystem, since inode numbers keep meaning the same thing + across that kind of remount. Exists so a higher layer that caches + something keyed by inode number across calls (vfs.c's SFS fifo binding + table, see vfsOpen/vfsMkfifo/vfsUnlink in vfs.c) can tell "this cached + entry still refers to the same on-disk fifo I bound it to" apart from + "the whole filesystem underneath it was wiped and reformatted since, + inode 7 might be a completely different file/dir/fifo/nothing now" -- + without sfs.c ever having to know that cache exists. */ +uint64 sfsGeneration(void); + +/* Creates a symbolic link at `path` whose target is the literal string + `target` - never validated against anything, so a symlink may point at a + path that does not exist yet (or ever), exactly like a real filesystem. + Returns the new inode number, or -1 on failure (parent missing, name + already taken, target too long, or the inode/block tables are full). */ +int32 sfsCreateSymlink(const char* path, const char* target); + +/* Reads the target string of a symlink inode into buf, null-terminated. + Fails (returns false, buf left untouched) if inodeNum is not a symlink or + if the stored target does not fit in cap bytes including the terminator - + never silently truncates. */ +bool sfsReadlink(int32 inodeNum, char* buf, uint64 cap); + +/* Creates a second directory entry (newPath) for the same inode an existing + file or symlink resolves to (existingPath is followed through symlinks, + same as open()/stat() - the new name refers to whatever it ultimately + points at, not the symlink itself). Directories cannot be hardlinked + (matches POSIX link(2): would make the directory tree no longer a tree). + Returns the shared inode number, or -1 on failure (existingPath missing, + a directory, newPath's parent missing, or newPath already taken). */ +int32 sfsLink(const char* existingPath, const char* newPath); + +/* Number of directory entries currently pointing at this inode. 0 for an + invalid/unmounted lookup, otherwise always >=1 for anything sfsLookup can + still reach. */ +uint32 sfsLinkCountOf(int32 inodeNum); + +/* Removes a file, or a directory only if it is empty. A hardlinked file's + data and inode survive until its link count reaches zero -- only the + named directory entry is guaranteed to disappear immediately. */ +bool sfsRemove(const char* path); + +uint64 sfsSizeOf(int32 inodeNum); +uint32 sfsTypeOf(int32 inodeNum); + +/* Real wall-clock timestamps (rtc.h's rtcNow(), seconds since the Unix + epoch), stored on disk in the inode itself so they survive + sfsUnmount()+sfsMount() like everything else about the inode. mtime is + last content modification (sfsWriteAt, sfsTruncate); ctime is last inode + metadata change (mtime changes are also metadata changes, so every mtime + update bumps ctime too, plus link-count changes from sfsLink/sfsRemove + bump ctime alone, matching real inode semantics). Both are stamped at + creation, so a freshly created node has mtime == ctime. Return 0 for an + invalid/unmounted lookup, same convention as sfsSizeOf/sfsTypeOf. */ +uint64 sfsMtimeOf(int32 inodeNum); +uint64 sfsCtimeOf(int32 inodeNum); + +/* Real owner rwx permission bits (fs.h's fsModeOwner*), stored on disk in + the inode like every other field above -- survives sfsUnmount()+ + sfsMount(). Stamped at creation with a type-based default (createNode: + fsModeDefaultDir for a directory, fsModeDefaultFile for anything else) + and changed only by sfsChmod. sfsModeOf returns 0 for an invalid/ + unmounted lookup, same convention as sfsSizeOf/sfsTypeOf. sfsChmod + overwrites the whole value (no symbolic +x-style merge, matching real + chmod(2)'s own numeric-mode form) and bumps ctime like any other + metadata-only mutation (sfsLink's linkCount bump does the same) -- + returns false for an invalid/unmounted inodeNum, true otherwise. */ +uint32 sfsModeOf(int32 inodeNum); +bool sfsChmod(int32 inodeNum, uint32 mode); + +/* Real owner identity (fs.h's FbStat.uid), stored on disk like every other + inode field above -- survives sfsUnmount()+sfsMount(). Stamped once, at + creation (createNode: sched.h's currentThreadUid(), the CALLER's real + identity at the moment of the create/mkdir/symlink/mkfifo call), and + afterward only ever changed by sfsChown (below). sfsUidOf returns 0 for + an invalid/unmounted lookup, same convention as sfsModeOf. */ +uint32 sfsUidOf(int32 inodeNum); + +/* Real chown(2)'s on-disk half: overwrites the inode's owner uid outright + and bumps ctime like sfsChmod's own metadata-only mutation does. + Deliberately has NO privilege check of its own -- vfsChown (vfs.c) is the + one real choke point that enforces the actual chown(2) privilege rule + (root-only, real Unix's _POSIX_CHOWN_RESTRICTED default: not even a + file's own owner may give it away), exactly like sfsChmod leaves + ownership-vs-caller policy to vfsChmod. Returns false for an invalid/ + unmounted inodeNum, same convention as sfsChmod. */ +bool sfsChown(int32 inodeNum, uint32 newUid); + +/* Real group identity (fs.h's FbStat.gid), stored on disk like uid above -- + survives sfsUnmount()+sfsMount(). Stamped once, at creation (createNode: + sched.h's currentThreadGid()), and afterward only ever changed by + sfsChgrp (below). sfsGidOf returns 0 for an invalid/unmounted lookup, + same convention as sfsUidOf. */ +uint32 sfsGidOf(int32 inodeNum); + +/* Real chgrp(2)'s on-disk half: overwrites the inode's group gid outright + and bumps ctime, mirroring sfsChown's own shape exactly. Deliberately has + NO privilege check of its own -- vfsChgrp (vfs.c) is the real choke point + (owner may set only their own current gid, root may set any gid). Returns + false for an invalid/unmounted inodeNum, same convention as sfsChown. */ +bool sfsChgrp(int32 inodeNum, uint32 newGid); + +/* Grows or shrinks a file in place, freeing/allocating data blocks as + needed. */ +bool sfsTruncate(int32 inodeNum, uint64 newSize); + +/* Reads/writes inode-relative byte ranges. Writing past the current end of + file zero-fills the gap first (no sparse holes), then writes the payload, + growing the file and allocating blocks (direct, then single indirect, + then double indirect, then triple indirect) as needed. Returns bytes + transferred, or -1 on error (inode not a file, or the file has hit the + direct+indirect+double+triple addressing limit, roughly 1 GiB). */ +int64 sfsReadAt(int32 inodeNum, uint64 offset, void* buffer, uint64 length); +int64 sfsWriteAt(int32 inodeNum, uint64 offset, const void* buffer, uint64 length); + +/* Lists directory entries by index (0, 1, 2, ...). Returns false past the + last entry. */ +bool sfsReaddirAt(int32 dirInode, uint32 index, char* nameOut, uint32 nameCap, uint32* typeOut); + +#endif diff --git a/include/shell.h b/include/shell.h index a219490..8560a07 100644 --- a/include/shell.h +++ b/include/shell.h @@ -1,6 +1,276 @@ #ifndef FROSTBYTE_SHELL_H #define FROSTBYTE_SHELL_H +#include "types.h" + void shellRun(void); +/* Mirrors elf.h's elfMaxArgs (kept as shell.c's own named copy rather than + including elf.h just for one constant, the same "each caller of this + pattern owns its own named copy" choice jobctl.c/kmain.c/syscall.c + already made for their own shared "one slot's worth of stack" constant) + -- an argv built with more tokens than this could never be handed to + elfBuildInitialStack anyway. */ +#define shellMaxArgs 16 + +/* Upper bound on how many `|`-separated stages shellSplitPipelineStages + below (and shell.c's runPipelineStages) will ever build at once -- a + generous cap for anything a human could plausibly type on one + lineCapacity-bounded (128 bytes, shell.c) command line, not a claim that + real shells have such a limit. Named analogously to shellMaxArgs (a + per-command cap) but for the orthogonal "how many commands are chained + together" axis. */ +#define shellMaxPipelineStages 4 + +/* Splits `text` in place (each run of spaces becomes a NUL terminator, + like strtok but reentrant -- no hidden static state) into at most + maxTokens pointers into that same buffer, returning how many it found. + Takes an explicit capacity rather than hardcoding shellMaxArgs + internally so a caller that already consumed some of its own argv[] + array (runProgram, shell.c: argv[0] is always the program name itself, + not something shellTokenize ever produces) can correctly pass only the + REMAINING room, not the full array size -- passing the full array size + there would let this function write one past the end of it. Exposed + here (rather than kept static in shell.c) so shellTest.c (ktest.h) can + exercise its edge cases -- leading/trailing/repeated spaces, empty + input, hitting the capacity -- as pure string manipulation with no VFS + or scheduler involved, safe to run from KTEST's pre-scheduler + context. */ +int shellTokenize(char* text, char* tokens[], int maxTokens); + +/* Splits `line` in place at every top-level '|' into up to `maxStages` + NUL-terminated stages, written into `stages[0..result-1]` -- shell.c's + `cmd1 [args] | cmd2 [args] | ... | cmdN [args]` pipeline syntax, now a + REAL N-stage chain rather than only ever the first two commands (jobctl.h's + jobctlSpawnPiped already took independent stdin/stdout pipe handles per + spawn from the day it was written, so a middle stage with BOTH ends piped + at once needed no new kernel primitive -- only this function and + shell.c's runPipelineStages were ever the limiting factor). + Whitespace directly touching each '|' is trimmed from both sides of it + (so "a | b | c" and "a|b|c" produce the same stages) -- shellTokenize's + own token splitter only ever strips LEADING spaces from whatever it is + handed, never trailing ones, so every stage but the last has its trailing + spaces trimmed here first. + Returns 0 (leaving `line` and `stages` completely untouched) if there is + no '|' anywhere in the line at all, so the caller (execute()) knows to + fall back to running a single command exactly as before. Returns -1 + (also leaving everything untouched) if the line contains MORE than + `maxStages` stages -- a real shell has no such limit, but this kernel's + per-pipeline arrays (shell.c's runPipelineStages, jobctl.c's + jobctlWaitPipelineStages) are fixed-size, so a caller must be able to + tell "malformed count" apart from "no pipe found" to report the right + error instead of silently truncating a longer chain. Otherwise returns + the real stage count (2..maxStages). Exposed here (rather than kept + static in shell.c) for the same pre-scheduler, no-VFS KTEST coverage + shellTokenize already gets (shellTest.c). */ +int shellSplitPipelineStages(char* line, char* stages[], int maxStages); + +/* Upper bound on how many `&&`/`||`-separated segments shellSplitAndOr + below (and shell.c's execute()) will ever build at once -- the exact + same "generous cap for one lineCapacity-bounded command line, not a + claim real shells have such a limit" reasoning shellMaxPipelineStages + above already gives, on the orthogonal "how many conditionally-run + commands are chained together" axis. */ +#define shellMaxAndOrSegments 4 + +/* The operator immediately BEFORE segments[i+1] in shellSplitAndOr's own + output (there is no operator before segments[0], so andOrOps[] below + only ever has count-1 meaningful entries) -- real shell `&&` + (short-circuit: run the next command only if the previous one + succeeded, exit status 0) vs `||` (run the next command only if the + previous one FAILED, exit status != 0). */ +typedef enum { + shellAndOrAnd = 1, + shellAndOrOr = 2 +} ShellAndOrOp; + +/* Splits `line` in place at every top-level `&&`/`||` into up to + `maxSegments` NUL-terminated segments, written into + `segments[0..result-1]`, with `andOrOps[0..result-2]` recording which + operator sat between each adjacent pair -- shell.c's new + `cmd1 && cmd2 || cmd3 && ...` conditional-chaining syntax, run BEFORE + pipeline splitting (`|` binds tighter than `&&`/`||`, exactly like a + real shell: `a | b && c` means "(a | b) && c", so each of THIS + function's own segments is independently handed to + shellSplitPipelineStages afterward, never the other way around). + Whitespace directly touching each operator is trimmed from both sides + of it, the same "a&&b and a && b produce the same segments" convention + shellSplitPipelineStages already established for `|`. A lone `&` + (shell.c's own trailing background marker, checked much later after + this function has already run) is never mistaken for half of `&&`, + since this function only ever matches the exact two-character sequence + -- a single `&` anywhere else in the line is simply left inside + whichever segment it falls in, untouched. + Returns 0 (leaving `line` and `segments` completely untouched) if there + is no top-level `&&`/`||` anywhere in the line, so the caller knows to + run it as a single command-or-pipeline exactly as before. Returns -1 + (also leaving everything untouched) if the line contains MORE than + `maxSegments` segments, the same "malformed count, do not silently + truncate" shape shellSplitPipelineStages already uses for its own + analogous limit. Otherwise returns the real segment count + (2..maxSegments). Exposed here (rather than kept static in shell.c) for + the same pre-scheduler, no-VFS KTEST coverage shellSplitPipelineStages + already gets (shellTest.c). */ +int shellSplitAndOr(char* line, char* segments[], ShellAndOrOp andOrOps[], int maxSegments); + +/* Result of shellExtractRedirects below -- nullptr inPath/outPath/errPath/ + hereString means "no such redirection was found, leave that fd at its + normal default". */ +typedef struct ShellRedirect { + const char* inPath; + const char* outPath; + bool appendOut; + const char* errPath; + bool appendErr; + /* `<<<` TEXT -- shell.c's new here-string syntax. Unlike inPath (a file + to open), this is the literal next token's own text, fed to the + child's fd 0 through a real anonymous pipe the SHELL itself writes + into (jobctl.h's jobctlSpawnForegroundHereString/ + jobctlRunBackgroundHereString), never opened as a path. Mutually + exclusive with inPath at the SAME fd 0 -- see shellExtractRedirects' + own doc comment below for the last-one-wins rule between the two. */ + const char* hereString; +} ShellRedirect; + +/* Scans an already-tokenized argv (shellTokenize's own output shape) for + `<`, `<<<`, `>`, `>>`, `2>`, and `2>>` tokens -- shell.c's new + `cmd [ARGS] [< IN | <<< TEXT] [> OUT | >> OUT] [2> ERR | 2>> ERR]` + redirection syntax -- and removes each one together with the token + immediately after it (a filename for every operator except `<<<`, whose + own following token is the literal here-string text instead), compacting + the remaining tokens down to a real argv with no gaps (exactly what + jobctl.h's spawn functions need to hand a program, which must never see + its own redirection operators as if they were its own arguments). + Writes the compacted count back through `argc` and fills `out` with + whichever paths/text were found (nullptr for whichever operator never + appeared). Returns false, leaving `argv`/`*argc` completely untouched, if + `<`, `<<<`, `>`, `>>`, `2>`, or `2>>` appears as the LAST token with no + filename/text after it -- the same "malformed input, caller falls back to + reporting an error" shape shellSplitPipelineStages uses for "no pipe + found" (though this one look more like an error since every OTHER token + still gets scanned first, unlike shellSplitPipelineStages's single + up-front counting pass). A later `>`/`>>` overwrites an earlier one (and vice versa) + rather than erroring, the same "last one wins" a real shell gives + `cmd > a > b` -- only ever ONE is meaningful since a single fd 1 can only + be redirected once; `2>`/`2>>` follow the exact same last-one-wins rule + independently for fd 2, since the two operator families target different + fds and never conflict with each other. `<` and `<<<` follow the SAME + last-one-wins rule for fd 0 across the two of them together (whichever + appears later wins, clearing the other field) -- a real shell gives fd 0 + to whichever of `< file`/`<<< text` was written last too, since both + target the exact same single fd. Exposed here (rather than kept static in + shell.c) for the same pre-scheduler, no-VFS KTEST coverage + shellTokenize/shellSplitPipelineStages already get (shellTest.c). */ +bool shellExtractRedirects(char** argv, int* argc, ShellRedirect* out); + +/* Recognizes shell.c's `$NAME` unquoted-parameter-expansion syntax (the + glue that actually resolves it against a real environment, shell.c's + new shellExpandArgv, is not pure -- it reads sched.h's Thread.env via + currentThreadGetEnv, off limits for KTEST's pre-scheduler context -- so + only the SYNTAX half lives here, the same "pure string logic here, the + thread/VFS-touching glue stays static in shell.c" split shellTokenize/ + shellSplitPipelineStages/shellExtractRedirects above already use). + `token` must be exactly '$' followed by one or more characters drawn + from [A-Za-z0-9_] (real shell variable-name syntax) and nothing else; + on a match, copies just the name (without the leading '$') into + `nameOut` and returns true. Returns false, `nameOut` untouched, for a + bare "$", a name that does not fit `nameCap`, or anything not starting + with '$' at all -- callers treat "false" as "not a variable reference, + leave this token exactly as typed". */ +bool shellParseVarRef(const char* token, char* nameOut, int nameCap); + +/* Recognizes shell.c's `$?` special parameter -- real POSIX shells' own + most-recently-completed command status, distinct from any `$NAME` + environment variable (shellParseVarRef above deliberately rejects it: + '?' is not in its own [A-Za-z0-9_] name-character set, so the two + token classes can never collide). `token` must be EXACTLY the two + characters "$?" and nothing else -- same "whole token or nothing" + contract shellParseVarRef already gives `$NAME`, so `$?x` or `x$?` are + both rejected, left for the caller to treat as an ordinary literal + token. The glue that resolves a match into an actual decimal status + string (shell.c's shellExpandToken, which reads shell.c's own + file-scope `lastStatus`) is not pure, so only this syntax check lives + here -- the same "pure logic here, thread/state-touching glue stays + static in shell.c" split shellParseVarRef already established. */ +bool shellIsStatusVarToken(const char* token); + +/* Classic shell wildcard matcher: '*' matches any run of characters + (including none), '?' matches exactly one character, anything else must + match `name` literally. Pure string logic (no VFS/scheduler involved), + iterative rather than recursive (a single backtrack point remembering + the most recent '*' and where in `name` it started matching from) -- + the same shape a real libc fnmatch(3) uses, and the same "pure logic + here, thread/VFS-touching glue stays static in shell.c" split + shellTokenize/shellSplitPipelineStages/shellExtractRedirects/ + shellParseVarRef above already established. Exposed here for + shellTest.c's KTEST coverage. */ +bool shellGlobMatch(const char* pattern, const char* name); + +/* True if `token` contains at least one glob metacharacter ('*' or '?') -- + the same "is this token special" gate shellParseVarRef gives '$', used by + shell.c's shellExpandGlobs to decide whether a token needs real directory + listing + matching at all, or can pass through completely untouched (the + common case for most argv words, which have no wildcard in them at + all). */ +bool shellTokenHasGlob(const char* token); + +/* Splits `token` at its LAST '/' into `dirOut` (everything up to and + including that '/', or an empty string if `token` has no '/' at all -- + meaning "the current directory", exactly like a bare `*.txt` in a real + shell) and `patternOut` (everything after it, the actual glob pattern to + match each directory entry's own name against via shellGlobMatch above). + Only this FINAL path component may itself contain glob syntax -- `dirOut` + is never re-scanned for `*`/`?` by anything in shell.c, an + honestly-scoped limitation (e.g. `/tmp/gd/ *.txt` is supported, + `/tmp/ * /file.txt` is not) matching this file's existing pattern of + documenting what its syntax does NOT attempt (see runPipelineStages' own + "no stage may be a builtin" and shellExtractRedirects' own redirect-target + scope limits). Truncates silently (like every other fixed-capacity buffer + in this shell) if either half does not fit its own capacity. Exposed here + for the same pre-scheduler, no-VFS KTEST coverage every other pure + function in this file already gets (shellTest.c). */ +void shellSplitGlobToken(const char* token, char* dirOut, int dirCap, char* patternOut, int patternCap); + +/* Recognizes shell.c's `$(cmd [args])` command-substitution syntax -- + locates the FIRST `$(` anywhere in `line` and its matching `)` (no + nesting: the very next `)` after the opening `$(`, an honestly-scoped + limitation matching shellSplitGlobToken's own "only the final path + component" scope note), and copies the text between them (the command + to run, still completely unparsed) into `cmdOut`, truncated to `cmdCap` + like every other fixed-capacity buffer in this file. + Returns true with `*startOut` set to -1 (cmdOut/`*endOut` left + untouched) if `line` contains no `$(` at all -- the caller's signal to + stop looking, exactly like shellSplitPipelineStages' own "0 means + nothing found" convention but spelled with a sentinel index instead of a + sentinel count, since a real match also needs to report WHERE in the + line it sits. On a real match, `*startOut`/`*endOut` are the byte + offsets of the leading `$` and one-past the matching `)` respectively, + so a caller can splice `line[0..*startOut)` + (whatever `cmdOut` + evaluates to) + `line[*endOut..]` into a new line the same way + shellExtractRedirects' caller already reassembles argv around removed + tokens. Returns false, leaving everything untouched, if a `$(` is found + with no matching `)` anywhere after it -- the same "malformed input, + caller reports an error" shape shellExtractRedirects already uses for an + operator with no filename. Exposed here (rather than kept static in + shell.c) for the same pre-scheduler, no-VFS KTEST coverage every other + pure syntax function in this file already gets (shellTest.c) -- the glue + that actually RUNS `cmdOut` and splices its captured output back in + (shell.c's shellExpandCommandSubst) is not pure, so only the syntax half + lives here, the same split shellParseVarRef/shellIsStatusVarToken above + already established for `$NAME`/`$?`. */ +bool shellFindCommandSubst(const char* line, int* startOut, int* endOut, char* cmdOut, int cmdCap); + +/* The pid most recently spawned by the shell's own `NAME [ARGS] [&]` + fallback (shell.c's runProgram) -- foreground or background alike, set + the instant jobctlSpawnForeground/jobctlRunBackground (jobctl.h) hand + one back, never only after a foreground job has already exited -- or + threadInvalidId if the shell has never run anything yet. Lets a + boot-time live demo (kmain.c) that drives the real interactive shell + through injected keystrokes (there being no automated way to drive a + real PS/2 keyboard under headless QEMU) find out what pid it just asked + the shell to run, so it can act on that specific job (e.g. proving a + backgrounded one stays alive across an injected Ctrl-C the same way + user/pgroupbg.c's boot-time-spawned counterpart already does). */ +uint32 shellLastSpawnedPid(void); + #endif diff --git a/include/signal.h b/include/signal.h new file mode 100644 index 0000000..cfd6db1 --- /dev/null +++ b/include/signal.h @@ -0,0 +1,368 @@ +#ifndef FROSTBYTE_SIGNAL_H +#define FROSTBYTE_SIGNAL_H + +#include "types.h" +#include "isr.h" +#include "sched.h" +#include "signalnum.h" + +/* + * Real (catchable) signal delivery on top of the single-address-space, + * ring0/ring3 kernel: a thread can install a handler for a signal number, + * mask/unmask signals, and have another thread (or itself) raise one -- + * delivery happens the moment the target thread next returns to ring3 + * through the interrupt-return path (isr.c's deliverPendingSignals), by + * rewriting its saved trapframe to enter a small shared trampoline page + * (sigInstallTrampoline below) instead of resuming where it was + * interrupted. The trampoline calls the handler, then traps back in via + * sysSigReturn, which restores the exact pre-signal register snapshot this + * kernel keeps in the Thread struct itself (sigSavedRegs) -- no signal + * frame is ever pushed onto the user stack, since there is no need to: the + * saved context already lives in kernel memory, unlike a real kernel with + * per-process address spaces where the handler must run in a stack the + * kernel does not otherwise own a copy of. + * + * Job control (SIGSTOP/SIGCONT/SIGTSTP) is real, on top of a genuine + * threadStopped ThreadState (sched.h): SIGSTOP, like SIGKILL, bypasses the + * pending/blocked/handler machinery entirely -- sigRaise delegates it + * straight to threadStop (sched.c), so it cannot be caught, blocked, or + * ignored, matching real Unix. SIGCONT is NOT bypassed the same way: it + * still sets its pending bit and runs through the normal scan (so an + * installed handler for it genuinely fires), but sigRaise ALSO + * unconditionally calls threadContinue as a side effect, independent of + * sigBlocked -- the resume itself is not something a handler or a mask can + * defer, only whether a handler additionally runs is. SIGTSTP is the + * catchable half of job control: its real-world default disposition is + * Stop rather than Terminate (sigDefaultIsStop), reported as + * sigOutcomeStop for isr.c to act on the same way it already acts on + * sigOutcomeTerminate, but -- unlike SIGSTOP -- an installed handler for it + * runs instead of stopping, and it can be blocked/ignored like any other + * catchable signal. + * + * Per-signal sa_mask (POSIX sigaction's sa_mask, not sigprocmask's whole- + * process mask): sigSetHandler takes a `mask` alongside the handler address, + * stored in Thread.sigHandlerMasks[signum] (sched.h) -- while that signum's + * handler is running, sigBlocked is the union of whatever was already + * blocked, this mask, and (unless sigFlagNoDefer -- signalnum.h -- is set + * for it) the signal itself, self-blocked like real Unix's implicit + * default. A DIFFERENT, unmasked signal arriving while a handler runs is + * therefore genuinely deliverable, not blanket-blocked the way an earlier + * version of this file (see git history) used to: sigConsumePending is + * gated by Thread.sigHandlerDepth against sigMaxNestDepth (sched.h), not by + * a single "one handler at a time" bool, so a second handler can run + * NESTED inside the first (its own context pushed onto the small + * sigSavedRegs/sigSavedBlocked stack) and the first resumes, via + * sigOnSigReturn popping that same stack, exactly where it left off once + * the nested one returns. user/nesttarget.c is the live ring-3 proof for + * two DIFFERENT signals; user/nodefertest.c is the same mechanism proving + * the SAME signal number can nest into its own still-active handler when + * registered with sigFlagNoDefer (and, by contrast, genuinely cannot + * without it); sigTest.c drives the stack directly for fully deterministic + * versions of both. + * + * Early wake of a Sleeping/Blocked target (real Unix's interruptible + * sleep/EINTR): sigRaise calls sched.c's threadWakeForSignal for any + * signal signal.c's sigWouldDeliver determines would genuinely be + * delivered (not blocked, not ignored) -- forcing the target straight to + * threadRunnable so the NEXT schedule() eventually resumes it, reaching a + * return-to-ring3 point (isr.c's deliverPendingSignals) it might otherwise + * NEVER naturally reach again (e.g. user/eintrtarget.c's read, which no + * writer will ever satisfy). A blocked or ignored signal leaves a + * Sleeping/Blocked target's wait completely undisturbed, exactly like real + * Unix -- sigWouldDeliver is checked first. threadWakeForSignal itself + * excludes threadStopped on purpose: job control's "stopped means stopped" + * must survive an ordinary signal arriving too, so only threadContinue (an + * explicit SIGCONT) may ever move a Stopped thread, the same asymmetry + * schedWakeChannel already has for the same reason. + * + * SA_RESTART / EINTR (signalnum.h's sigFlagRestart, sched.h's + * Thread.sigInRetryableSyscall): being woken early (or simply signaled + * while already Blocked) answers "does the target wake up at all", not + * "what does the interrupted syscall return once its handler is done" -- + * that second question is what sa_flags settles. Before this existed, + * every retryable syscall (sysRead/sysWrite/sysWait, syscall.c) restarted + * unconditionally once a handler returned, since sysSigReturn always + * restores the exact rewound rip sigConsumePending captured. Now + * sigConsumePending checks, at the moment it is about to push a custom + * handler's context, whether the thread is genuinely mid one of those three + * (sigInRetryableSyscall) and the signal being delivered lacks + * sigFlagRestart for its own handler; if so, it rewrites the ABOUT-TO-BE- + * SAVED context in place (rip past the interrupted `int $0x80`, rax to + * errInterrupted) before pushing it, so sigOnSigReturn's later, byte-for- + * byte-faithful restore resumes the caller having genuinely observed an + * interrupted syscall, not a silently retried one -- real EINTR, not an + * approximation of it. user/restarttarget.c is the live ring-3 proof of + * both halves (with and without the flag); sigTest.c drives + * sigConsumePending directly for the deterministic one. + * + * A thread self-targeting SIGSTOP through sysSigSend (syscall.c) is + * reasoned about and exercised by + * schedTest.c's state-machine coverage of threadStop accepting + * threadRunning, but -- unlike the external-stop/continue scenario + * user/jobtest.c demonstrates live -- has no live ring-3 demo of its own + * this session: proving it would need a second, independent process to + * race-freely learn "the target has now actually stopped" before sending + * SIGCONT, which is exactly the kind of guess-free rendezvous this file's + * OWN sigtarget.c comment already had to solve once for a different race, + * and doing it again here was judged not worth the added live-boot + * surface for a self-stop path whose actual mechanism (threadStop, called + * on the current thread) is not otherwise unusual. SIGKILL is intentionally + * NOT routed through this pending/blocked machinery at all: sigRaise + * delegates it straight to the existing threadKill (sched.c), the one + * unconditional, unmaskable, uncatchable termination path this kernel + * already had (now also accepting threadStopped, since a real + * kill(pid, SIGKILL) kills a stopped process too) -- exactly like + * real kill(pid, SIGKILL) never goes through a handler either. + */ + +/* Signal numbers, handler sentinels, sigSetFailed and the sigMask* "how" + values all live in signalnum.h, shared verbatim with userspace (see that + header for why it is split out). */ + +/* sigMaxSignals (one bit per signal number in Thread.sigPending/ + sigBlocked, and the size of Thread.sigHandlers[]) is defined in sched.h, + next to the Thread struct that actually needs it as an array bound -- + signal.h cannot define it itself without sched.h including signal.h + right back, which would be circular. */ + +/* Every signal bit set -- a plain "block literally everything" convenience + constant for callers/tests that want to exercise sigProcMask's own + sigKill/sigStop stripping, or job control's "resume happens regardless of + sigBlocked" guarantee, with the widest possible mask. Not used by + sigConsumePending's own handler-entry logic any more (see the file + comment's per-signal sa_mask paragraph) -- that is real, per-handler + Thread.sigHandlerMasks[signum] now, not this one blanket value. */ +#define sigAllBlocked (~0ull) + +/* Fixed virtual address of the one shared, read+execute, never-write, + user-accessible trampoline page every process's signal handlers are + entered through (sigInstallTrampoline maps it once at boot). Chosen + deliberately far from both the boot-time 1 GiB identity-mapped range + ([0, 0x40000000), see boot.S's bootPd -- pagingMap refuses to touch + anything inside a huge-page-mapped range anyway) and the per-binary + elfSlotSize=0x10000000 slots USER_BASE hands out one at a time starting + at 0x40000000 (syscall.c's userSpaceMin) -- at the current growth rate + (Makefile) that sequence would need roughly three thousand more user + binaries to ever reach here, and every process already shares this one + address space regardless (see the architecture note in sched.h), so one + fixed shared page for ALL of them, like a real kernel's vDSO, is exactly + as much mapping as this needs. */ +#define sigTrampolineVirtualAddr 0x0000700000000000ull + +/* Fixed virtual address of the shared, read-only (never write, never + execute), user-accessible SigInfo (signalnum.h) data page(s) every + process's sigFlagSigInfo handlers are handed a pointer into -- + sigInstallInfoPage maps it once at boot, right after the trampoline page + above, in the same reserved high range for the same reasons that page's + own comment already gives (nowhere near either the boot-time identity + map or any real per-binary elfSlotSize slot). One frameSize-sized page + holds frameSize/sizeof(SigInfo) slots; sigInstallInfoPage maps as many + consecutive pages, starting here, as threadCapacity()*sigMaxNestDepth + slots actually need (today: 96*4 = 384 slots, comfortably inside 2 + pages) -- see sigConsumePending for how a (thread id, handler-nesting + depth) pair maps to one specific slot, the same indexing scheme + sigSavedRegs/sigSavedBlocked (sched.h) already use for the exact same + pair, just kept in a user-readable page here instead of Thread itself + since a handler running in ring 3 has no way to reach into kernel + memory. */ +#define sigInfoVirtualAddr 0x0000700000010000ull + +/* How many consecutive frameSize pages, starting at sigInfoVirtualAddr, a + fixed-size static array of physical frame addresses (signal.c) can hold + -- sigInstallInfoPage only ever maps as many as threadCapacity()* + sigMaxNestDepth slots actually need (2 today), so this is deliberately + generous headroom, not a tight fit: maxThreads (sched.c) has grown by a + fixed +8 roughly once per session for a long history of falas, and this + comfortably absorbs many more such bumps (8 pages * frameSize/sizeof + (SigInfo) slots-per-page / sigMaxNestDepth == room for hundreds of + threads) before ever needing to be revisited, the same "generous + headroom, not exact" reasoning sigMaxNestDepth's own comment (sched.h) + already gives for itself. */ +#define sigInfoMaxPages 8 + +typedef enum { + sigOutcomeNone = 0, + sigOutcomeHandler = 1, + sigOutcomeTerminate = 2, + /* Default disposition of a catchable job-control signal (SIGTSTP only + -- SIGSTOP never reaches sigConsumePending at all, see the file + comment). Same contract as sigOutcomeTerminate: this function only + REPORTS it, the caller (isr.c) must actually call threadStop and + reschedule, since only schedule() can produce the next thread's own + rsp to resume with. */ + sigOutcomeStop = 3 +} SigOutcome; + +/* Resets ALL signal state on a Thread slot to fresh defaults: no pending or + blocked signals, no installed handlers or their masks, handler-nesting + depth zero. Called by threadCreate/threadCreateUser (sched.c) on every + (re)use of a table slot, + the same explicit-field-by-field convention threadCreate/threadCreateUser + already use for every other Thread field -- threadReap/threadDiscard's + own memset() of the whole struct already guarantees this before a slot + is ever reused, so this is defensive documentation of the invariant, not + a fix for a leak that could otherwise happen (unlike the console-reader/ + pipe-fd leaks this subsystem's sibling code had to actually fix, which + lived in tables OUTSIDE Thread that no memset here ever touched). */ +void sigResetState(Thread* thread); + +/* Raises signal `signum` against thread `targetId`. sigKill and sigStop are + both delegated straight to threadKill/threadStop (see file comment) and + never touch sigPending at all. sigCont ALSO calls threadContinue as an + unconditional side effect (resuming a Stopped target regardless of + sigBlocked) but, unlike sigKill/sigStop, still falls through to set its + own pending bit afterwards, so an installed handler still runs through + the normal scan. Any other signum just sets the pending bit, then -- + if sigWouldDeliver agrees this signal is not blocked or ignored -- + calls threadWakeForSignal (sched.c) to force a Sleeping/Blocked target + straight to Runnable early, real Unix's interruptible sleep/EINTR (see + file comment); actual delivery itself still only ever happens later, + off the interrupt-return path (isr.c), once that thread genuinely + resumes. Returns false, changing nothing, for an out-of-range signum, an + invalid/out-of-range targetId, or a target that is Dead/Unused (mirrors + threadKill's own validation for the non-SIGKILL path, and threadKill's/ + threadStop's own for their delegated paths). */ +bool sigRaise(uint32 targetId, int signum); + +/* Exactly sigRaise above, plus attaching a real SigInfo (signalnum.h) to + this specific raise -- `siCode`/`siPid`/`siStatus` become + target->sigPendingInfo[signum] (sched.h) the moment the pending bit + itself is set, later handed to a sigFlagSigInfo handler by + sigConsumePending. sigRaise itself is now a thin wrapper around this + (siCodeKernel, 0, 0 -- "no specific detail available/tracked", the + honest default for the many pre-existing callers, including sigTest.c's + whole KTEST suite, that only ever cared about signum/pending-bit + semantics and never plumbed real sender/child detail through). Real + production call sites needing genuine detail (sched.c's SIGALRM/SIGCHLD + raises, syscall.c's sysSigSend) call this directly instead. Same return + value, same validation, same sigKill/sigStop delegation, same early-wake + side effect as sigRaise -- see that function's own doc comment for all + of it, not repeated here. */ +bool sigRaiseInfo(uint32 targetId, int signum, int siCode, int siPid, int siStatus); + +/* Real kill(-pgid, signum)/kill(0, signum)'s group-targeted half (sched.h's + Thread.pgid; userSigSend/userKill's negative/zero-pid convention, + syscall.c's sysSigSend/sysKill) and keyboard.c's own real Ctrl-C/Ctrl-Z + (SIGINT/SIGTSTP delivered to the FOREGROUND process group only, never a + single target thread): scans every currently live thread once and calls + sigRaiseInfo above against each one whose pgid matches, independently -- + SIGKILL/SIGSTOP/SIGCONT's own special-cased bypasses, per-target + early-wake, and SigInfo attachment therefore all apply exactly as they + already do for a plain, single-target send, simply repeated once per + group member, rather than this function sharing or reimplementing any of + that delivery logic itself. A pgid with no currently live member at all + (nobody has ever claimed the foreground yet, or every process that once + shared it has since died) is a harmless no-op, matching real kill(2)'s + ESRCH for an empty target set -- reported back as false, exactly like a + single-target sigRaiseInfo already reports false for an invalid/dead + target. */ +bool sigRaiseGroup(uint32 pgid, int signum, int siCode, int siPid); + +/* Installs `handlerAddr` (a real user-code address, or sigHandlerDefault / + sigHandlerIgnore) as thread's disposition for `signum`, `mask` as its + sa_mask -- the additional signals sigConsumePending blocks for as long as + THIS handler is active (stripped of sigKill/sigStop first, same as + sigProcMask: neither can ever be blocked, so a mask cannot "achieve" it + either) -- and `flags` (signalnum.h's sigFlagRestart, or 0) as its + sa_flags. Returns the PREVIOUS disposition (like real signal(2)/ + sigaction(2)'s oldact->sa_handler) or sigSetFailed for an out-of-range + signum or an attempt to touch sigKill or sigStop (both uncatchable, like + real EINVAL on SIGKILL/SIGSTOP) -- `mask`/`flags` are stored regardless of + whether `handlerAddr` is a real handler or a sentinel, but only ever + consulted for a real one (sigHandlerDefault/sigHandlerIgnore never reach + the custom-handler branch of sigConsumePending). */ +uint64 sigSetHandler(Thread* thread, int signum, uint64 handlerAddr, uint64 mask, uint64 flags); + +/* Blocks/unblocks/replaces thread's blocked-signal mask according to `how` + (sigMaskBlock/sigMaskUnblock/sigMaskSetMask; any other value is a no-op), + returning the mask as it was BEFORE this call (like real sigprocmask's + oldset). sigKill's and sigStop's bits are silently stripped from `mask` + first -- neither can ever be blocked, matching real Unix (sigCont CAN be + blocked here, since only whether its handler runs is gated by + sigBlocked -- the resume side effect in sigRaise never consults it). */ +uint64 sigProcMask(Thread* thread, int how, uint64 mask); + +/* The delivery decision, called from isr.c's deliverPendingSignals on + every return-to-ring3 point for whichever thread is about to resume. + Refuses to deliver anything (returns sigOutcomeNone, touching nothing) + once thread->sigHandlerDepth already reached sigMaxNestDepth -- the small + fixed context stack is full, so whatever is pending simply waits for a + sigOnSigReturn to free a slot; this is the only depth-independent gate, + unlike the old single "one handler at a time" bool it replaced. Otherwise + scans sigPending & ~sigBlocked from signum 1 upward. An ignored signal + (explicit sigHandlerIgnore, or sigHandlerDefault whose real-world default + is Ignore -- see sigDefaultIsIgnore in signal.c) is dismissed and the + scan continues, so it can never mask a real one behind it. Otherwise: + default disposition -> sigOutcomeStop for sigTstp (sigDefaultIsStop) or + sigOutcomeTerminate for everything else (caller must actually stop/ + terminate the thread, e.g. via threadStop/threadExit, and reschedule -- + this function never does, since it has no way to hand back a new rsp). + Custom handler -> the interrupted context is pushed onto + thread->sigSavedRegs[depth]/sigSavedBlocked[depth] (depth being + sigHandlerDepth's value on entry, then incremented), sigBlocked becomes + the union of what it already was, sigHandlerMasks[signum] (the sa_mask + sigSetHandler stored), and signum's own bit (self-blocked, stripped of + sigKill/sigStop like every other mask operation in this file), and `regs` + is rewritten in place to enter the trampoline page (rip), call the + handler (rax) with signum as its sole argument (rdi) -- sigOutcomeHandler. + If sigHandlerFlags[signum] also has sigFlagSigInfo set, thread-> + sigPendingInfo[signum] (whatever sigRaiseInfo last stashed there for this + signum, or sigRaise's own siCodeKernel/0/0 default) is additionally + copied into this (thread id, depth) pair's slot in the shared + sigInfoVirtualAddr page (signal.h), and rsi/rdx are rewritten too -- rsi + to that slot's real user-visible address, rdx to 0 (no ucontext_t this + kernel can expose) -- so the handler genuinely receives (signum, + SigInfo*, NULL) exactly as sigaction()'s SA_SIGINFO promises. Without the + flag, rsi/rdx are left completely untouched (whatever they already held), + exactly as before this existed, since a plain one-argument handler never + reads either. Before any of that pushing happens, thread->sigInRetryableSyscall is + consumed (read, then unconditionally cleared): if it was true (this + thread is genuinely mid a sysRead/sysWrite/sysWait retry, see sched.h) + and signum's sigHandlerFlags lacks sigFlagRestart, `regs` -- the + interrupted context about to be saved -- is rewritten first (rip past + the interrupted `int $0x80`, rax to errInterrupted) so the eventual + sigOnSigReturn resumes the caller having observed a genuine EINTR-style + failure instead of transparently retrying; sigFlagRestart (or + sigInRetryableSyscall already being false, e.g. a signal that interrupts + plain running code or a nested handler) leaves `regs` untouched here, + preserving today's transparent-retry behavior exactly. + A DIFFERENT signal not covered by that union can still be delivered on a + later call while this one is active -- pushed onto depth+1 as a genuinely + NESTED handler, sigOnSigReturn popping it back off once it returns, the + interrupted (still mid-handler) context resuming exactly where it was. + Either way `*outSignum` (if non-null) is set to whichever signal was + consumed. sigStop itself never appears here at all (see file comment -- + sigRaise bypasses this whole scan for it). */ +SigOutcome sigConsumePending(Thread* thread, Registers* regs, int* outSignum); + +/* Undoes exactly what the sigOutcomeHandler branch of sigConsumePending did + for the MOST RECENTLY entered still-active handler (LIFO, like a real call + stack of signal frames): pops thread->sigHandlerDepth, restoring `*regs` + from thread->sigSavedRegs[depth] and thread->sigBlocked from + thread->sigSavedBlocked[depth] at the popped index -- resuming the + interrupted program (which may itself be a still-active OUTER handler, if + this was a nested one) at the exact instruction, with the exact + registers, it had before that signal arrived, as if it had never been + interrupted at all. + Returns false, changing nothing, if the thread was not actually inside a + handler (a user program calling the sigreturn syscall on its own, never + having been signaled, is a misuse this kernel simply ignores rather than + trusting). */ +bool sigOnSigReturn(Thread* thread, Registers* regs); + +/* Allocates one physical frame, fills it with the trampoline's machine + code (sigTrampolineStart..sigTrampolineEnd, sigTrampoline.S -- position + independent by construction, see that file), and maps it read+execute+ + user (never write) at sigTrampolineVirtualAddr. Called exactly once from + kmain.c, before any user thread can possibly receive a signal. */ +void sigInstallTrampoline(void); + +/* Allocates and maps sigInfoVirtualAddr's data page(s) -- see that + constant's own comment for the layout and sizing. Called exactly once + from kmain.c, alongside sigInstallTrampoline (before any user thread can + possibly receive a sigFlagSigInfo delivery), and before ktestRunAll so + sigTest.c's own deterministic coverage can read the same shared page a + live ring-3 handler would. */ +void sigInstallInfoPage(void); + +#endif diff --git a/include/signalnum.h b/include/signalnum.h new file mode 100644 index 0000000..ba32061 --- /dev/null +++ b/include/signalnum.h @@ -0,0 +1,158 @@ +#ifndef FROSTBYTE_SIGNALNUM_H +#define FROSTBYTE_SIGNALNUM_H + +/* + * Plain numeric constants shared verbatim between the kernel (signal.h/ + * signal.c) and userspace (user/ulib.h/ulib.c) -- exactly like syscall.h + * already is for syscall numbers. Split out from signal.h specifically so + * user code never has to include signal.h itself, which pulls in sched.h's + * Thread struct and isr.h's Registers -- kernel-only types a freestanding + * user program has no business seeing, let alone compiling against. + */ + +/* Signal numbers. A small, real (not exhaustive) POSIX-shaped subset -- + enough to exercise catchable delivery, ignore, default-terminate and the + one uncatchable case meaningfully, without pretending to implement every + job-control signal a real kernel would. Numbering matches Linux/x86-64 + for anything actually used here, so an unused gap (5, 7, 16) does too. */ +#define sigHup 1 +#define sigInt 2 +#define sigQuit 3 +#define sigIll 4 +#define sigAbrt 6 +#define sigFpe 8 +#define sigKill 9 +#define sigUsr1 10 +#define sigSegv 11 +#define sigUsr2 12 +#define sigPipe 13 +#define sigAlrm 14 +#define sigTerm 15 +#define sigChld 17 +#define sigCont 18 +#define sigStop 19 +#define sigTstp 20 + +/* Handler-slot sentinels (Thread.sigHandlers[signum] kernel-side, + userSigAction's `handler` argument userspace-side), distinct from any + real user code address since every user ELF slot starts at + userSpaceMin == 0x40000000 (syscall.c), far above 1. */ +#define sigHandlerDefault 0ull +#define sigHandlerIgnore 1ull + +/* sigSetHandler's/userSigAction's failure return -- mirrors real + signal(2)'s SIG_ERR, distinguishable from any real sentinel/handler + address a caller could have gotten back as the *previous* disposition. + Written without a uint64 cast (unlike the kernel-only constants in + signal.h) so this header stays includable from userspace, which never + sees types.h -- see fs.h's FbStat for the same convention. */ +#define sigSetFailed (~0ull) + +/* sigProcMask/userSigProcMask "how" values (mirrors POSIX SIG_BLOCK/ + SIG_UNBLOCK/SIG_SETMASK by shape, not by number). */ +#define sigMaskBlock 0 +#define sigMaskUnblock 1 +#define sigMaskSetMask 2 + +/* sigSetHandler's/userSigAction's `flags` argument (mirrors POSIX + sigaction's sa_flags by shape, not by bit position -- there is only one + flag here). Default (0, what every handler got before this bit existed) + is real POSIX's own default: a signal delivered while a thread sits + blocked inside a slow syscall (read/write/wait -- see signal.c's + sigInRetryableSyscall) does NOT transparently resume that syscall once + the handler returns, it makes the syscall return errInterrupted instead, + exactly like an EINTR on real Unix. Passing sigFlagRestart opts back into + the OTHER behavior (real SIG_RESTART/BSD's original signal(3) semantics): + the interrupted syscall is retried, invisibly to the caller, once the + handler returns. Only meaningful for a signal that actually interrupts a + genuinely retryable syscall; for anything else (plain running code, a + sleep, a signal that does not even install a handler) it is silently + irrelevant, same as real SA_RESTART. */ +#define sigFlagRestart (1ull << 0) + +/* Mirrors POSIX sigaction's SA_NODEFER: by default (this bit clear), a + signal's own number is added to sigBlocked for as long as ITS handler is + running (signal.h's sigConsumePending, "self-blocked like real Unix's + implicit default") -- so the same signal raised again while its handler + is still active stays merely pending, delivered only once that handler + returns, never nested into itself. Passing sigFlagNoDefer for a signal + disables exactly that one self-block (nothing else -- sa_mask/other + signals' self-blocks are untouched): a second raise of that same signal + while its own handler is active is then genuinely deliverable, entering + a NESTED instance of its own handler, the same mechanism a completely + different unmasked signal already uses to nest (see signal.h's + sa_mask paragraph) -- SA_NODEFER only widens which signal numbers that + mechanism applies to, it does not introduce a new one. Irrelevant for a + signal that never raises itself from inside its own handler. */ +#define sigFlagNoDefer (1ull << 1) + +/* Mirrors POSIX sigaction's SA_SIGINFO: by default (this bit clear) a + handler is called with exactly one argument, the signal number (this + kernel's ONLY shape until now, `regs->rdi = signum` in signal.c's + sigConsumePending). Passing sigFlagSigInfo for a signal tells + sigConsumePending to ALSO populate a real SigInfo (below) describing this + specific delivery -- who/what raised it, real Unix's siginfo_t.si_pid/ + si_code/si_status -- and hand the handler a pointer to it as a SECOND + argument (rsi), plus a third (rdx, always 0/NULL here: this kernel has no + ucontext_t of its own to expose, unlike a real kernel's SA_SIGINFO third + argument). A handler registered with this flag must actually be declared + to take (int, SigInfo*, void*) -- see ulib.h's SigInfoHandler/ + userSigActionInfo -- exactly like real sigaction()'s sa_sigaction union + member only being valid to read when SA_SIGINFO was actually passed. */ +#define sigFlagSigInfo (1ull << 2) + +/* si_code values SigInfo.siCode below can hold -- a small, real (not + exhaustive) subset of POSIX's SI_USER/SI_KERNEL/CLD_EXITED/CLD_KILLED/ + CLD_STOPPED/CLD_CONTINUED, numbered independently of them (this kernel's + own small namespace, not a Linux ABI match, unlike the signal numbers + above which deliberately do match). siCodeUser: signum was raised by + another process's real userSigSend/kill(2)-shaped syscall (siPid is the + SENDER's own pid). siCodeKernel: raised by the kernel itself with no + specific sending process (SIGALRM's real timer expiry; also sigRaise's + own thin-wrapper default for any caller that supplies no detail at all). + siCodeChld{Exited,Killed,Stopped,Continued}: sigChld specifically, + distinguishing WHY a child's status changed (siPid is the CHILD's pid, + siStatus its real exit code for Exited, threadKilledExitCode for Killed, + or the job-control signal number for Stopped/Continued) -- real Unix's + own WIFEXITED/WIFSIGNALED/WIFSTOPPED/WIFCONTINUED distinction, exposed + directly instead of needing a separate macro to decode a packed status + word the way real wait(2)'s plain `int status` still does. */ +#define siCodeUser 0 +#define siCodeKernel 1 +#define siCodeChldExited 2 +#define siCodeChldKilled 3 +#define siCodeChldStopped 4 +#define siCodeChldContinued 5 + +/* Real siginfo_t, cut down to exactly the fields this kernel ever actually + populates (see siCode* above for what each one means per code) -- shared + verbatim between kernel and userspace like every other type in this + header, which is exactly why it is built from plain `int`, not a + types.h-only typedef (see this header's own file comment). siSignum + duplicates the handler's own first argument (signum) -- real siginfo_t + keeps si_signo too, for the same reason: a single handler function + registered against more than one signal number can tell them apart from + *info alone, without needing its own first argument at all. Handed to a + sigFlagSigInfo handler as a real pointer (signal.c's sigConsumePending) + into a small kernel-owned, user-readable (never user-writable) page + mapped at a fixed address -- see signal.h's sigInfoVirtualAddr -- the + same "one shared page, like a vDSO" trick this kernel's trampoline page + already uses, not memory the handler owns or must free. */ +typedef struct SigInfo { + int siSignum; + int siCode; + int siPid; + int siStatus; +} SigInfo; + +/* A retryable syscall (sysRead/sysWrite/sysWait -- see syscall.c) that got + interrupted by a signal without sigFlagRestart returns this instead of + retrying. Mirrors a raw Linux syscall's own convention of returning + -errno directly (here, -EINTR, whose real errno value is 4) rather than + a separate errno variable, since this kernel has no per-thread errno of + its own to set. Written without a uint64 cast, like every other + userspace-visible constant in this header, so it stays includable from + freestanding user code. */ +#define errInterrupted (-4) + +#endif diff --git a/include/slab.h b/include/slab.h new file mode 100644 index 0000000..21d6087 --- /dev/null +++ b/include/slab.h @@ -0,0 +1,55 @@ +#ifndef FROSTBYTE_SLAB_H +#define FROSTBYTE_SLAB_H + +#include "types.h" + +/* + * Slab: a fixed-size object cache backed directly by physical frames from + * pmm, bypassing the general-purpose heap. Each slab is exactly one frame; + * the Slab descriptor lives at the start of that same frame (so freeing a + * pointer is O(1): mask it down to the enclosing frame to recover the + * descriptor, no list walk needed), and the rest of the frame is carved + * into objSize-aligned objects threaded onto a singly-linked free list + * through their own (currently-unused) storage. + * + * Intended for kernel-internal fixed-size allocations (list/tree nodes, + * PCB-sized structs, ...) where slab's O(1) alloc/free and per-slab + * locality beat the heap's general-purpose free-list search. + */ + +typedef struct KmemCache { + const char* name; + usize objSize; /* rounded up to at least sizeof(void*), 8-aligned */ + usize objsPerSlab; + struct Slab* slabs; /* every slab owned by this cache, newest first */ + usize slabCount; + usize usedObjs; +} KmemCache; + +/* Returns false if objSize (after rounding) cannot fit at least one object + per frame alongside the slab header -- the cache is left zeroed either + way, so kmemCacheDestroy is always safe to call. */ +bool kmemCacheInit(KmemCache* cache, const char* name, usize objSize); + +/* Releases every frame the cache ever grew into back to pmm. The cache is + left in a freshly-initialized (empty, growable-again) state. */ +void kmemCacheDestroy(KmemCache* cache); + +/* Grows the cache (allocates a new frame from pmm) on demand. nullptr only + if pmm is out of frames. */ +void* kmemCacheAlloc(KmemCache* cache); + +/* obj must have come from kmemCacheAlloc on this exact cache and not + already be free. */ +void kmemCacheFree(KmemCache* cache, void* obj); + +usize kmemCacheUsed(const KmemCache* cache); +/* Objects across every slab the cache has grown into so far -- 0 until the + first kmemCacheAlloc, since slabs are only ever grown lazily on demand. */ +usize kmemCacheCapacity(const KmemCache* cache); +usize kmemCacheSlabCount(const KmemCache* cache); +/* How many objects a single slab (frame) holds for this cache -- fixed at + kmemCacheInit time, independent of how many slabs have been grown. */ +usize kmemCacheObjsPerSlab(const KmemCache* cache); + +#endif diff --git a/include/spinlock.h b/include/spinlock.h new file mode 100644 index 0000000..3527550 --- /dev/null +++ b/include/spinlock.h @@ -0,0 +1,44 @@ +#ifndef FROSTBYTE_SPINLOCK_H +#define FROSTBYTE_SPINLOCK_H + +#include "types.h" + +/* + * SpinLock: an interrupt-safe mutual-exclusion lock for state shared between + * thread context and interrupt handlers (irq0/irq1 currently run with + * interrupts re-enabled per pic.c, so a naive "just disable interrupts" + * critical section is not enough once two handlers can nest). + * + * Acquire disables interrupts and spins on an atomic test-and-set before + * returning; release restores whatever the interrupt flag was at the + * matching acquire. This is correct and SMP-ready even though frostbyte is + * currently single-core: on one core, acquiring an already-held lock is a + * bug (it would spin forever with interrupts off), so spinLockTryAcquire is + * provided for call sites -- and tests -- that need to observe contention + * without deadlocking. + */ + +typedef struct SpinLock { + volatile uint32 locked; +} SpinLock; + +void spinLockInit(SpinLock* lock); + +/* Disables interrupts, then spins until the lock is acquired. Returns the + interrupt flag as it was immediately before this call, to be passed back + to spinLockRelease unchanged (callers must not interleave two different + locks' flags). */ +uint64 spinLockAcquire(SpinLock* lock); + +/* Non-blocking. On success (true), interrupts are disabled and *savedFlags + holds the pre-call flag word, exactly as spinLockAcquire would leave + things. On failure (false) nothing is touched -- interrupts are left + exactly as the caller had them. */ +bool spinLockTryAcquire(SpinLock* lock, uint64* savedFlags); + +/* flags must be the value spinLockAcquire/spinLockTryAcquire handed back. */ +void spinLockRelease(SpinLock* lock, uint64 flags); + +bool spinLockIsLocked(const SpinLock* lock); + +#endif diff --git a/include/syscall.h b/include/syscall.h index 5942c65..1e4ac2d 100644 --- a/include/syscall.h +++ b/include/syscall.h @@ -15,5 +15,59 @@ #define sysGetdents 11 #define sysMkdir 12 #define sysUnlink 13 +#define sysPipe 14 +#define sysSymlink 15 +#define sysReadlink 16 +#define sysLink 17 +#define sysExec 18 +#define sysSpawn 19 +#define sysWait 20 +#define sysPipeCreate 21 +#define sysPipeAttach 22 +#define sysKill 23 +#define sysMkfifo 24 +#define sysSigSend 25 +#define sysSigAction 26 +#define sysSigProcMask 27 +#define sysSigReturn 28 +#define sysAlarm 29 +#define sysSetpgid 30 +#define sysGetpgid 31 +#define sysSetFgPgrp 32 +#define sysGetFgPgrp 33 +#define sysPoll 34 +#define sysChdir 35 +#define sysGetcwd 36 +#define sysGetEnv 37 +#define sysSetEnv 38 +#define sysNice 39 +#define sysGetPriority 40 +#define sysTime 41 +#define sysUnsetEnv 42 +#define sysRmdir 43 +#define sysChmod 44 +#define sysGetuid 45 +#define sysSetuid 46 +#define sysChown 47 +#define sysGetgid 48 +#define sysSetgid 49 +#define sysChgrp 50 +#define sysPidfdOpen 51 +#define sysPidfdSend 52 +#define sysPidfdWait 53 +#define sysMmap 54 +#define sysMunmap 55 + +/* Sentinel pid for sysWait (syscall.c)/userWaitAny (ulib.c): real + waitpid(-1)-style "reap whichever child happens to already be dead, + without caring which specific pid" instead of the ordinary "wait for + exactly this one" contract every other sysWait id value has. + (unsigned int)-1 can never collide with a real pid, since pid 0 (idle) + and every id returned by threadCreate/threadCreateUser are always < + maxThreads (sched.c). Deliberately spelled with the plain C type, not + sched.h's uint32, so this header stays usable from userland (ulib.h/ + ulib.c) exactly like fs.h/signalnum.h's own shared definitions already + are -- see fs.h's FbStat for the same convention. */ +#define waitAnyPid ((unsigned int)-1) #endif diff --git a/include/vec.h b/include/vec.h new file mode 100644 index 0000000..dc05a82 --- /dev/null +++ b/include/vec.h @@ -0,0 +1,46 @@ +#ifndef FROSTBYTE_VEC_H +#define FROSTBYTE_VEC_H + +#include "types.h" + +/* + * Vec: a growable, type-erased array backed by the kernel heap. Elements + * are stored by value (memcpy'd in and out), sized by elemSize so a single + * implementation serves every element type without void* indirection or + * per-type code generation. + */ + +typedef struct Vec { + uint8* data; + usize elemSize; + usize length; + usize capacity; +} Vec; + +void vecInit(Vec* v, usize elemSize); +void vecFree(Vec* v); + +/* Ensures capacity for at least newCapacity elements. Returns false only + if the underlying allocation failed (existing contents are untouched). */ +bool vecReserve(Vec* v, usize newCapacity); + +bool vecPush(Vec* v, const void* elem); +/* Removes and copies out the last element. False (and *outElem untouched) + when the vector is empty; outElem may be nullptr to just drop it. */ +bool vecPop(Vec* v, void* outElem); + +/* Pointer into the backing storage; nullptr if index is out of range. + Invalidated by any push/insert/remove/reserve that reallocates. */ +void* vecAt(Vec* v, usize index); +bool vecSet(Vec* v, usize index, const void* elem); + +bool vecInsert(Vec* v, usize index, const void* elem); +bool vecRemove(Vec* v, usize index, void* outElem); + +void vecClear(Vec* v); + +usize vecLen(const Vec* v); +usize vecCap(const Vec* v); +bool vecIsEmpty(const Vec* v); + +#endif diff --git a/include/vfs.h b/include/vfs.h index e9832c8..529644f 100644 --- a/include/vfs.h +++ b/include/vfs.h @@ -9,14 +9,36 @@ struct VfsNode; typedef enum { fdFree = 0, fdConsole = 1, - fdFile = 2 + fdFile = 2, + fdPipeRead = 3, + fdPipeWrite = 4, + fdBlockDev = 5, + fdSfs = 6, + /* pidfd.h handle wrapped as an ordinary fd (FileDescriptor.pipeHandle + reused to store the pidfd.c table index, same field-reuse convention + fdPipeRead/fdPipeWrite already use for a pipe.c handle) -- unlike + anonymous pipes, a pidfd never needs the cross-process + pipeCreateDetached/vfsPipeAttach dance: it is opened by, and lives + entirely within, the SAME process that calls vfsPidfdOpen, exactly + like real Linux's pidfd_open(2). */ + fdPidfd = 7 } FdKind; +/* FileDescriptor.flags bit for a pipe end installed by vfsPipeAttach: reads/ + writes on it should really block (via syscall.c's sysRead/sysWrite retry) + rather than report "would block" (-1) the way vfsPipe's anonymous, + single-process pipe ends always have (see pipetest.c, which depends on + that -1 -- unflagged, unchanged). File-kind descriptors already store + their vfsOpen flags (oReadOnly etc, fs.h) in this same field, but never a + pipe fd, so the two uses never collide. */ +#define fdFlagBlocking 0x1 + typedef struct FileDescriptor { struct VfsNode* node; uint64 offset; uint32 kind; uint32 flags; + int32 pipeHandle; } FileDescriptor; #define maxOpenFiles 16 @@ -35,6 +57,239 @@ int vfsStat(const char* path, FbStat* out); int64 vfsGetdents(int fd, void* buffer, uint64 length); int vfsMkdir(const char* path); int vfsUnlink(const char* path); +int vfsRmdir(const char* path); + +/* Real chmod(2)/chown(2) privilege rule, factored out as a pure predicate + exactly like vfsPermitted itself (see that function's own doc comment for + why): true if `callerUid` may change `ownerUid`-owned metadata, i.e. is + either the owner itself or uid 0 ("root", administrative bypass -- unlike + vfsPermitted's read/write/exec check, which deliberately has none). Only + chmod uses this; chown is stricter still (root-only, see vfsChown) since + real Unix's default _POSIX_CHOWN_RESTRICTED denies even a file's own + owner the right to give it away. Pure and explicit-argument like + vfsPermitted so it stays testable without needing a live thread's own + uid -- KTEST always runs on the idle thread (permanently uid 0), which + could never otherwise exercise the "denied" side of this check. */ +bool vfsOwnerOrRoot(uint32 ownerUid, uint32 callerUid); + +/* Real chmod(2): overwrites `path`'s whole owner-rwx mode (fs.h's + fsModeOwner*) with `mode` -- no symbolic +x-style merge, matching real + chmod's own numeric-mode form. Refuses unless the caller owns the file or + is uid 0 (vfsOwnerOrRoot above) -- a real, previously-missing check: every + uid could chmod any file's bits until this was added. Dispatches on + isSfsPath like every other entry point above (sfsChmod for "/mnt", the + tmpfs VfsNode's own `mode` field otherwise). Returns -1 for a missing + path, or (tmpfs only) a node under a non-writable mount -- chmod is a + metadata mutation, gated by the exact same `writable` flag every other + tmpfs mutation (vfsUnlink, vfsWrite's write path) already respects, so a + ustar-loaded /bin/ *.elf file can never have its mode changed at runtime, + matching its read-only backing store. 0 on success. */ +int vfsChmod(const char* path, uint32 mode); + +/* Real chown(2): overwrites `path`'s owner uid outright. Root-only + (currentThreadUid() == 0), full stop -- unlike vfsChmod, ownership is + never delegated to the file's own owner (real Unix's + _POSIX_CHOWN_RESTRICTED default: an owner who could give a file away + could use that to defeat disk-quota accounting, so only root may). Same + isSfsPath dispatch and missing-path/non-writable-mount refusal as + vfsChmod. 0 on success. */ +int vfsChown(const char* path, uint32 newUid); + +/* Real chgrp(2): overwrites `path`'s group gid. Unlike vfsChown (root-only, + full stop), chgrp is delegated to the file's own owner too -- but only to + set the group to the CALLER's own current gid (currentThreadGid()), never + an arbitrary one: this kernel tracks exactly one gid per thread (no + supplementary-groups list), so "a group the caller belongs to" collapses + to "the caller's own current gid", matching real chgrp(2)'s own + membership requirement for a non-privileged caller. Root (uid 0) may set + any gid, same administrative bypass vfsOwnerOrRoot already grants chmod. + Same isSfsPath dispatch and missing-path/non-writable-mount refusal as + vfsChown. 0 on success. */ +int vfsChgrp(const char* path, uint32 newGid); + +/* Symlinks and hardlinks are only supported on the persistent "/mnt" + filesystem (sfs.h already has the on-disk machinery for both); any other + path (the ustar/tmpfs VfsNode tree, "/dev/disk0") rejects these with -1, + the same way a real filesystem that lacks the feature would refuse the + syscall rather than fake it. */ +int vfsSymlink(const char* path, const char* target); +int64 vfsReadlink(const char* path, char* buffer, uint64 cap); +int vfsLink(const char* existingPath, const char* newPath); + +/* Creates an anonymous pipe and installs its two ends into the current + thread's fd table. On success readFd/writeFd receive the descriptors and + 0 is returned; on failure (fd table or pipe table exhausted) -1 is + returned and neither output is written. */ +int vfsPipe(int* readFd, int* writeFd); + +/* Creates a pipeCreateDetached handle (pipe.h) with neither end attached to + anyone's fd table and returns its raw number, or -1 on exhaustion. Meant + to be handed to two independently spawned processes (typically as a + decimal argv string, see user/fifomaster.c) so they can rendezvous over a + real blocking pipe without needing fork() to share an fd -- see + vfsPipeAttach. */ +int vfsPipeCreateHandle(void); + +/* Installs `handle` (from vfsPipeCreateHandle, or in principle any live + pipe handle) into a fresh slot in the CALLING thread's own fd table, as a + read end (wantWrite false) or write end (wantWrite true), flagged + fdFlagBlocking so syscall.c's sysRead/sysWrite give it real blocking + semantics. Returns the new fd, or -1 if the handle is not a live pipe or + the fd table is full. */ +int vfsPipeAttach(int handle, bool wantWrite); + +/* Same as vfsPipeAttach, but installs into fd slot `fd` of an EXPLICIT fd + table instead of allocating a free slot in the CALLING thread's own + table -- jobctl.c's own way to bake a real pipe end into a brand new + child's stdin/stdout (fd 0/1) before that child has run a single + instruction, so there is never a window where it could be scheduled with + its default console fds instead (see jobctlSpawnPiped, jobctl.h). `fd` is + trusted to already name a valid slot (0/1/2) in `targetFds` -- unlike + vfsPipeAttach, this never allocates a fresh one, so the caller decides + exactly which fd is overwritten. Silently does nothing if `handle` is not + a live pipe. */ +void vfsPipeAttachInto(FileDescriptor* targetFds, int fd, int handle, bool wantWrite); + +/* Opens a pidfd (pidfd.h) on `targetId` and installs it as a brand new + fdPidfd descriptor in the CALLING thread's own fd table -- the + fd-table-wrapping half of pidfd.h's raw pidfdOpen, exactly the same + split vfsPipe/vfsPipeAttach already have over pipe.c's raw + pipeCreate/pipeCreateDetached. Returns the new fd, or -1 if `targetId` + is not a valid, currently-live thread (pidfdOpen's own contract) or the + calling thread's own fd table is full. */ +int vfsPidfdOpen(uint32 targetId); + +/* Resolves fd `fd` (which must be an fdPidfd this thread's own vfsPidfdOpen + returned) to the live target thread id it still safely refers to, or + threadInvalidId if `fd` is not an open pidfd at all, or the original + target has since been reaped/recycled (pidfdTarget's own contract, + pidfd.h) -- the single check syscall.c's sysPidfdSend/sysPidfdWait both + need before ever touching a target by number. */ +uint32 vfsPidfdTarget(int fd); + +/* Same as vfsOpen, but installs into fd slot `fd` of an EXPLICIT fd table + instead of allocating a free slot in the CALLING thread's own table -- + the file-redirection counterpart of vfsPipeAttachInto above, used by + jobctl.c's own jobctlApplyRedirects to bake a real file open (not a + pipe) into a freshly spawned child's stdin/stdout for shell.c's new + `cmd < file` / `cmd > file` / `cmd >> file` syntax, before that child has + run a single instruction. `fd` is trusted to already name a valid slot + (0/1/2) in `targetFds`, exactly like vfsPipeAttachInto. Returns 0 on + success, -1 on the same failures vfsOpen itself can have (missing path + without oCreate, unwritable parent directory, a fifo opened with a bad + mode, exhausted pipe/inode resources) -- leaves `targetFds[fd]` + completely untouched on failure, the same "never marks a fd used when it + fails" contract vfsOpen already has for its own newly allocated fd. */ +int vfsOpenInto(FileDescriptor* targetFds, int fd, const char* path, uint32 flags); + +/* nullptr unless `fd` is a fdFlagBlocking pipe/fifo end or a console (fd 0) + read whose next read/write would have to report "try again" for a + resolvable reason (a pipe buffer empty but a writer could still show up, + full but a reader could still drain it, or a console queue simply not + having a keystroke queued yet) -- in which case it is the channel + (sched.h) that end's condition changing will wake. syscall.c's + sysRead/sysWrite call this after a -1 from doRead/doWrite to decide + whether to park-and-retry the syscall (schedBlockCurrent + rewind rip, + same trick sysWait uses for threadJoin) or simply hand -1 back to + userspace as-is (a non-blocking pipe end, a console write, or a + permanently broken/at-EOF pipe that retrying could never fix). */ +const void* vfsBlockingChannel(int fd, bool forWrite); + +/* poll.h's sysPoll (syscall.c): fills *outRevents with the subset of + `events` (poll.h's pollIn/pollOut) `fd` could satisfy right now without + blocking -- pollNval instead if `fd` is not open at all, pollErr for a + pipe write end whose only reader has gone away (a real write there would + fail immediately, not block, so it counts as "ready" the same way a + readable EOF pipe does). A regular file, "/dev/disk0", or "/mnt" fd + always reports every requested bit ready, matching real poll(2): none of + this kernel's local-storage reads/writes ever return "would block". + Returns true iff *outRevents ended up nonzero, i.e. this fd alone would + already satisfy a poll call. */ +bool vfsPollReady(int fd, int16 events, int16* outRevents); + +/* Named pipe: creates a real filesystem entry of type fsTypeFifo at `path` + (must live under a writable directory, e.g. the "/tmp" tmpfs mount -- + same requirement makeChildFile already has for oCreate) backed by a fresh + pipeCreateDetached handle. Unlike vfsPipeCreateHandle/vfsPipeAttach, which + need SOME cooperating parent to pass the raw handle number to both sides + (typically as an argv string, see user/fifomaster.c), a fifo node lets any + two processes that simply agree on a path -- no common ancestor, no + handle to pass -- rendezvous by both calling vfsOpen on it: whichever + opens with oWriteOnly gets a blocking write end, whichever opens with + oReadOnly gets a blocking read end, exactly like vfsPipeAttach's fds. + oReadWrite (or any other flag combination) is rejected -- this kernel's + pipe end is inherently one-directional, so a dual-direction fifo fd is + out of scope, same as vfsPipeAttach's wantWrite being a single bool. + Returns 0 on success, -1 if the path already exists, its parent is not a + writable directory, or the pipe table is exhausted. Not supported under + "/dev/disk0". + + Under "/mnt" this creates a REAL, persistent on-disk entry (sfs.h's + sfsTypeFifo) that survives sfsUnmount()+sfsMount() -- only the special + file itself persists, never any buffered bytes (exactly like a real + mkfifo(2) special file survives a reboot while its pipe buffer does + not): the live pipe.h handle two openers actually exchange bytes + through is created lazily, in memory, the first time anyone opens the + path after boot (or after every previous session's ends have gone away), + same mechanism as the "/tmp"-style case above, just keyed by inode + number instead of living on a VfsNode (see vfs.c's sfsFifoHandleFor). */ +int vfsMkfifo(const char* path); + +/* "/dev/disk0" is a reserved path (not part of the mounted tree, same as + pipes) that opens the raw ATA disk as a byte-addressable device: reads + and writes at arbitrary offsets are translated to sector I/O through + ata.h, with read-modify-write for accesses that are not sector-aligned. */ + +/* "/mnt" (and everything under it) is a second reserved path, backed by the + persistent on-disk filesystem in sfs.h/sfs.c rather than the kmalloc-based + VfsNode tree: open/read/write/close/lseek/stat/mkdir/unlink/getdents on a + "/mnt..." path are all dispatched to sfs.c, which stores the data on the + same ATA disk (a different LBA range than /dev/disk0's raw byte access, + see sfs.h). Data written there survives vfsInit() being called again, + because it lives on disk, not in this process's heap. */ + +/* Real per-process chdir(2)/getcwd(2) (sched.h's Thread.cwd). Resolves + `path` against the CALLING thread's current cwd first (vfsResolvePath + below), so a relative argument works exactly like a real chdir() call, + then requires the result to already name a real directory -- under + "/mnt" (sfs.h) or in the ordinary VfsNode tree, checked the same way + vfsStat/vfsMkdir already dispatch on isSfsPath. Returns 0 on success (the + calling thread's own cwd is now that resolved absolute path) or -1, + changing nothing, if it does not name an existing directory. For a + VfsNode-tree directory (ustar/tmpfs), the stored result is canonicalized + by walking the resolved node's real parent chain (vfsPath below), so + "."/".." components the caller typed never survive into a later + vfsGetcwd(). "/mnt" (sfs.h) has no equivalent parent-walk primitive, so + THAT branch stores vfsResolvePath's own plain string join verbatim -- + every path-taking entry point below already tolerates embedded "."/".." + wherever they appear regardless (see vfs.c's lookupNode/sfsLookup), so + later relative resolution still behaves correctly either way; only + vfsGetcwd's own output could then look non-canonical (e.g. + "/mnt/foo/../bar") for an "/mnt" cwd reached that way -- an + honestly-scoped simplification, not a fake canonicalization. */ +int vfsChdir(const char* path); + +/* Copies the calling thread's current working directory (always already + absolute, see vfsChdir above) into `out`, nul-terminated, and returns its + length -- or -1, writing nothing, if it does not fit `cap` (real + getcwd(2)'s own ERANGE case). */ +int64 vfsGetcwd(char* out, uint64 cap); + +/* Resolves `path` against the CALLING thread's own cwd (sched.h's + currentThreadCwd) into `out`: copied as-is (truncated to `cap`-1 if + necessary) if it already starts with '/', otherwise joined onto the cwd + exactly like a real relative open() would -- the same "cwd + '/' + + argument" shape shell.c's own long-standing buildPath already used for + its builtins, now the ONE place that logic lives. Every path-taking + public entry point below (vfsOpen, vfsOpenInto, vfsMkdir, vfsUnlink, + vfsRmdir, vfsStat, vfsSymlink's `path` argument -- never its `target`, + which is stored verbatim like a real symlink(2) -- vfsReadlink, vfsLink, + vfsMkfifo, vfsChmod) calls this internally before doing anything else, so an + already-absolute path built by hand (shell.c's own callers, unchanged) + keeps working exactly as before. Exposed so vfsChdir can resolve ITS OWN + argument the same way before ever storing it, keeping Thread.cwd always + fully absolute. */ +void vfsResolvePath(const char* path, char* out, uint64 cap); typedef struct VfsNode VfsNode; VfsNode* vfsLookup(const char* path); @@ -44,6 +299,43 @@ const char* vfsName(VfsNode* node); bool vfsIsDir(VfsNode* node); uint64 vfsSize(VfsNode* node); const void* vfsFileData(VfsNode* node); + +/* Real owner rwx permission bits (fs.h's fsModeOwner*) for a tmpfs VfsNode + -- the exact same value vfsStat's tmpfs branch reports as FbStat.mode. + Exposed as its own accessor (VfsNode is opaque outside vfs.c, same reason + vfsSize/vfsFileData exist) so jobctl.c's jobctlSpawn can check + fsModeOwnerExec on an already-vfsLookup'd node before spawning it, + without a redundant vfsStat + path re-resolve. */ +uint32 vfsMode(VfsNode* node); + +/* Real owner identity (fs.h's FbStat.uid) for a tmpfs VfsNode -- the uid + that was currentThreadUid() at nodeAlloc time (vfs.c), mirroring + vfsMode's own "opaque outside vfs.c" exposure exactly, for the same + reason: syscall.c's loadImageByPath and jobctl.c's jobctlSpawn need this + on an already-vfsLookup'd node before deciding whether to spawn it. */ +uint32 vfsUid(VfsNode* node); + +/* Real group identity (fs.h's FbStat.gid) for a tmpfs VfsNode -- the gid + that was currentThreadGid() at nodeAlloc time, mirroring vfsUid's own + "opaque outside vfs.c" exposure for the same reason (syscall.c's + loadImageByPath, jobctl.c's jobctlSpawn). */ +uint32 vfsGid(VfsNode* node); + +/* Real Unix owner/group/other permission check (vfs.c): does the caller + (`callerUid`/`callerGid`) have `wantOwnerBit` (one of fs.h's fsModeOwner* + constants) against a node whose bits are `mode` and whose owner is + `ownerUid`/`ownerGid`? A matching uid checks the owner bit; else a + matching gid checks the corresponding "group" bit (fs.h's fsModeGroup*, + wantOwnerBit >> 3); else the corresponding "other" bit (fs.h's + fsModeOther*, wantOwnerBit >> 6) -- deliberately no uid-0-bypasses- + everything superuser rule (see vfsPermitted's own doc comment in vfs.c + for why). Exported (not just used internally by resolveOpen) so + syscall.c's loadImageByPath and jobctl.c's jobctlSpawn can run the + identical exec-bit check instead of each re-deriving this algorithm. + Pure function of its six arguments -- callers pass currentThreadUid()/ + currentThreadGid() in explicitly, this never reads a live thread + itself. */ +bool vfsPermitted(uint32 mode, uint32 ownerUid, uint32 ownerGid, uint32 callerUid, uint32 callerGid, uint32 wantOwnerBit); void vfsPath(VfsNode* node, char* out, uint64 cap); #endif diff --git a/iso/boot/initrd.img b/iso/boot/initrd.img index 2f27c77..37f8530 100644 Binary files a/iso/boot/initrd.img and b/iso/boot/initrd.img differ diff --git a/linker.ld b/linker.ld index a353f4a..49fd65b 100644 --- a/linker.ld +++ b/linker.ld @@ -19,6 +19,7 @@ SECTIONS { *(.multiboot) *(.boot.text) + *(.boot.data) . = ALIGN(4096); *(.boot.pagetables) } :boot @@ -33,6 +34,10 @@ SECTIONS .rodata ALIGN(4096) : AT(ADDR(.rodata) - KERNEL_VMA) { *(.rodata .rodata.*) + . = ALIGN(8); + ktestArrayStart = .; + KEEP(*(.ktests .ktests.*)) + ktestArrayEnd = .; } :rodata .data ALIGN(4096) : AT(ADDR(.data) - KERNEL_VMA) diff --git a/src/ata.c b/src/ata.c new file mode 100644 index 0000000..658b15c --- /dev/null +++ b/src/ata.c @@ -0,0 +1,199 @@ +#include "ata.h" +#include "ports.h" + +#define ataRegData 0x1F0 +#define ataRegError 0x1F1 +#define ataRegSectorCnt 0x1F2 +#define ataRegLbaLow 0x1F3 +#define ataRegLbaMid 0x1F4 +#define ataRegLbaHigh 0x1F5 +#define ataRegDriveHead 0x1F6 +#define ataRegStatus 0x1F7 +#define ataRegCommand 0x1F7 +#define ataRegControl 0x3F6 + +#define ataStatusErr 0x01 +#define ataStatusDrq 0x08 +#define ataStatusDf 0x20 +#define ataStatusBsy 0x80 + +#define ataCmdReadSectors 0x20 +#define ataCmdWriteSectors 0x30 +#define ataCmdCacheFlush 0xE7 +#define ataCmdIdentify 0xEC + +#define ataMaxSectorsPerCommand 255 +#define ataPollTimeout 1000000u + +static bool diskPresent; +static uint64 totalSectors; +static char modelBuffer[41]; + +static void ataIoDelay(void) +{ + /* Selecting a drive needs ~400ns to settle; reading the alternate + status register is a documented no-op read that burns exactly that + long without disturbing the main status register. */ + inb(ataRegControl); + inb(ataRegControl); + inb(ataRegControl); + inb(ataRegControl); +} + +static bool ataWaitClear(uint8 mask) +{ + for (uint32 i = 0; i < ataPollTimeout; i++) + if (!(inb(ataRegStatus) & mask)) + return true; + return false; +} + +static bool ataWaitDrqOrErr(bool* err) +{ + for (uint32 i = 0; i < ataPollTimeout; i++) { + uint8 status = inb(ataRegStatus); + if (status & (ataStatusErr | ataStatusDf)) { + *err = true; + return true; + } + if (status & ataStatusDrq) { + *err = false; + return true; + } + } + return false; +} + +static void ataSelect(uint64 lba, uint8 count) +{ + outb(ataRegDriveHead, (uint8)(0xE0 | ((lba >> 24) & 0x0F))); + ataIoDelay(); + outb(ataRegSectorCnt, count); + outb(ataRegLbaLow, (uint8)(lba & 0xFF)); + outb(ataRegLbaMid, (uint8)((lba >> 8) & 0xFF)); + outb(ataRegLbaHigh, (uint8)((lba >> 16) & 0xFF)); +} + +bool ataInit(void) +{ + diskPresent = false; + totalSectors = 0; + modelBuffer[0] = 0; + + outb(ataRegControl, 0x02); /* nIEN: this driver only ever polls */ + + outb(ataRegDriveHead, 0xA0); /* master, no chs/lba bits needed yet */ + ataIoDelay(); + outb(ataRegSectorCnt, 0); + outb(ataRegLbaLow, 0); + outb(ataRegLbaMid, 0); + outb(ataRegLbaHigh, 0); + outb(ataRegCommand, ataCmdIdentify); + + if (inb(ataRegStatus) == 0) + return false; /* floating bus: no drive wired up on this channel */ + + if (!ataWaitClear(ataStatusBsy)) + return false; + + /* A plain PATA hard disk leaves LBA mid/high at 0 after IDENTIFY; an + ATAPI or SATA-bridge device stamps a signature there instead. This + driver only supports the former. */ + if (inb(ataRegLbaMid) != 0 || inb(ataRegLbaHigh) != 0) + return false; + + bool err = false; + if (!ataWaitDrqOrErr(&err) || err) + return false; + + uint16 identifyWords[256]; + for (int i = 0; i < 256; i++) + identifyWords[i] = inw(ataRegData); + + totalSectors = (uint64)identifyWords[60] | ((uint64)identifyWords[61] << 16); + + for (int i = 0; i < 20; i++) { + uint16 word = identifyWords[27 + i]; + modelBuffer[i * 2] = (char)(word >> 8); + modelBuffer[i * 2 + 1] = (char)(word & 0xFF); + } + modelBuffer[40] = 0; + for (int i = 39; i >= 0 && modelBuffer[i] == ' '; i--) + modelBuffer[i] = 0; + + diskPresent = totalSectors > 0; + return diskPresent; +} + +bool ataPresent(void) +{ + return diskPresent; +} + +uint64 ataSectorCount(void) +{ + return totalSectors; +} + +const char* ataModelString(void) +{ + return modelBuffer; +} + +bool ataReadSectors(uint64 lba, uint32 count, void* buffer) +{ + if (count == 0) + return true; + if (!diskPresent || count > ataMaxSectorsPerCommand || lba + count > totalSectors) + return false; + + if (!ataWaitClear(ataStatusBsy)) + return false; + ataSelect(lba, (uint8)count); + outb(ataRegCommand, ataCmdReadSectors); + + uint16* dst = (uint16*)buffer; + for (uint32 s = 0; s < count; s++) { + if (!ataWaitClear(ataStatusBsy)) + return false; + bool err = false; + if (!ataWaitDrqOrErr(&err) || err) + return false; + for (int w = 0; w < 256; w++) + dst[w] = inw(ataRegData); + dst += 256; + } + return true; +} + +bool ataWriteSectors(uint64 lba, uint32 count, const void* buffer) +{ + if (count == 0) + return true; + if (!diskPresent || count > ataMaxSectorsPerCommand || lba + count > totalSectors) + return false; + + if (!ataWaitClear(ataStatusBsy)) + return false; + ataSelect(lba, (uint8)count); + outb(ataRegCommand, ataCmdWriteSectors); + + const uint16* src = (const uint16*)buffer; + for (uint32 s = 0; s < count; s++) { + if (!ataWaitClear(ataStatusBsy)) + return false; + bool err = false; + if (!ataWaitDrqOrErr(&err) || err) + return false; + for (int w = 0; w < 256; w++) + outw(ataRegData, src[w]); + src += 256; + ataIoDelay(); + } + + outb(ataRegCommand, ataCmdCacheFlush); + if (!ataWaitClear(ataStatusBsy)) + return false; + + return true; +} diff --git a/src/ataTest.c b/src/ataTest.c new file mode 100644 index 0000000..b8156e7 --- /dev/null +++ b/src/ataTest.c @@ -0,0 +1,122 @@ +#include "ktest.h" +#include "ata.h" +#include "string.h" + +/* + * Real hardware coverage for the ATA PIO driver: these tests run against + * whatever drive QEMU actually attaches on the primary IDE bus (see + * tools/ci-test.sh / Makefile for the -drive flag), so a wiring mistake in + * ata.c (wrong port, wrong status bit, off-by-one in the LBA registers) + * shows up as a real read/write mismatch here, not as a mocked assertion. + */ + +static void fillPattern(uint8* buf, uint64 seed) +{ + for (int i = 0; i < ataSectorSize; i++) + buf[i] = (uint8)((seed * 7 + (uint64)i * 13) & 0xFF); +} + +KTEST(ataDrivePresent) +{ + KT_TRUE(t, ataPresent()); + KT_GT(t, ataSectorCount(), 0); + + const char* model = ataModelString(); + KT_NOTNULL(t, model); + KT_TRUE(t, strlen(model) > 0); +} + +KTEST(ataSingleSectorRoundTrip) +{ + uint8 out[ataSectorSize]; + uint8 in[ataSectorSize]; + fillPattern(out, 0xA5); + + KT_TRUE(t, ataWriteSectors(10, 1, out)); + memset(in, 0, sizeof(in)); + KT_TRUE(t, ataReadSectors(10, 1, in)); + KT_EQ(t, memcmp(in, out, ataSectorSize), 0); +} + +KTEST(ataOverwriteReflectsLatestData) +{ + uint8 first[ataSectorSize]; + uint8 second[ataSectorSize]; + uint8 back[ataSectorSize]; + fillPattern(first, 0x11); + fillPattern(second, 0x99); + + KT_TRUE(t, ataWriteSectors(11, 1, first)); + KT_TRUE(t, ataWriteSectors(11, 1, second)); + KT_TRUE(t, ataReadSectors(11, 1, back)); + KT_EQ(t, memcmp(back, second, ataSectorSize), 0); + KT_NE(t, memcmp(back, first, ataSectorSize), 0); +} + +KTEST(ataAdjacentSectorsDoNotCrossTalk) +{ + uint8 a[ataSectorSize]; + uint8 b[ataSectorSize]; + uint8 backA[ataSectorSize]; + uint8 backB[ataSectorSize]; + fillPattern(a, 0x30); + fillPattern(b, 0x77); + + KT_TRUE(t, ataWriteSectors(20, 1, a)); + KT_TRUE(t, ataWriteSectors(21, 1, b)); + KT_TRUE(t, ataReadSectors(20, 1, backA)); + KT_TRUE(t, ataReadSectors(21, 1, backB)); + KT_EQ(t, memcmp(backA, a, ataSectorSize), 0); + KT_EQ(t, memcmp(backB, b, ataSectorSize), 0); + KT_NE(t, memcmp(backA, backB, ataSectorSize), 0); +} + +KTEST(ataMultiSectorRoundTrip) +{ + #define ataTestSectors 4 + uint8 out[ataTestSectors * ataSectorSize]; + uint8 in[ataTestSectors * ataSectorSize]; + + for (int s = 0; s < ataTestSectors; s++) + fillPattern(out + s * ataSectorSize, (uint64)(0x40 + s)); + + KT_TRUE(t, ataWriteSectors(30, ataTestSectors, out)); + memset(in, 0, sizeof(in)); + KT_TRUE(t, ataReadSectors(30, ataTestSectors, in)); + + for (int s = 0; s < ataTestSectors; s++) + KT_EQ(t, memcmp(in + s * ataSectorSize, out + s * ataSectorSize, ataSectorSize), 0); + #undef ataTestSectors +} + +KTEST(ataLastSectorIsAddressable) +{ + uint64 last = ataSectorCount() - 1; + uint8 out[ataSectorSize]; + uint8 in[ataSectorSize]; + fillPattern(out, 0xEE); + + KT_TRUE(t, ataWriteSectors(last, 1, out)); + KT_TRUE(t, ataReadSectors(last, 1, in)); + KT_EQ(t, memcmp(in, out, ataSectorSize), 0); +} + +KTEST(ataOutOfRangeRequestsAreRejected) +{ + uint8 buf[ataSectorSize]; + KT_FALSE(t, ataReadSectors(ataSectorCount(), 1, buf)); + KT_FALSE(t, ataWriteSectors(ataSectorCount(), 1, buf)); + + /* Buffer is sized for 2 sectors: if the bounds check were buggy and let + this through, it would prove itself by corrupting the canary sector + instead of silently passing. */ + uint8 twoSectors[2 * ataSectorSize]; + KT_FALSE(t, ataReadSectors(ataSectorCount() - 1, 2, twoSectors)); +} + +KTEST(ataZeroLengthRequestIsNoop) +{ + uint8 buf[ataSectorSize]; + KT_TRUE(t, ataReadSectors(0, 0, buf)); + KT_TRUE(t, ataWriteSectors(0, 0, buf)); +} diff --git a/src/bmp.c b/src/bmp.c new file mode 100644 index 0000000..f5fd04e --- /dev/null +++ b/src/bmp.c @@ -0,0 +1,90 @@ +#include "bmp.h" + +#define bmpBitsPerWord 64 + +static usize wordCountFor(usize bitCount) +{ + return (bitCount + bmpBitsPerWord - 1) / bmpBitsPerWord; +} + +void bmpInit(Bitmap* b, uint64* backingWords, usize bitCount) +{ + b->words = backingWords; + b->bitCount = bitCount; + bmpClearAll(b); +} + +void bmpSet(Bitmap* b, usize index) +{ + if (index >= b->bitCount) + return; + b->words[index / bmpBitsPerWord] |= (1UL << (index % bmpBitsPerWord)); +} + +void bmpClear(Bitmap* b, usize index) +{ + if (index >= b->bitCount) + return; + b->words[index / bmpBitsPerWord] &= ~(1UL << (index % bmpBitsPerWord)); +} + +bool bmpTest(const Bitmap* b, usize index) +{ + if (index >= b->bitCount) + return false; + return (b->words[index / bmpBitsPerWord] & (1UL << (index % bmpBitsPerWord))) != 0; +} + +void bmpSetRange(Bitmap* b, usize start, usize count) +{ + for (usize i = start; i < start + count && i < b->bitCount; i++) + bmpSet(b, i); +} + +void bmpClearRange(Bitmap* b, usize start, usize count) +{ + for (usize i = start; i < start + count && i < b->bitCount; i++) + bmpClear(b, i); +} + +void bmpSetAll(Bitmap* b) +{ + usize words = wordCountFor(b->bitCount); + for (usize i = 0; i < words; i++) + b->words[i] = ~0UL; +} + +void bmpClearAll(Bitmap* b) +{ + usize words = wordCountFor(b->bitCount); + for (usize i = 0; i < words; i++) + b->words[i] = 0; +} + +usize bmpCountSet(const Bitmap* b) +{ + usize total = 0; + for (usize i = 0; i < b->bitCount; i++) { + if (bmpTest(b, i)) + total++; + } + return total; +} + +isize bmpFindFirstClear(const Bitmap* b) +{ + for (usize i = 0; i < b->bitCount; i++) { + if (!bmpTest(b, i)) + return (isize)i; + } + return -1; +} + +isize bmpFindFirstSet(const Bitmap* b) +{ + for (usize i = 0; i < b->bitCount; i++) { + if (bmpTest(b, i)) + return (isize)i; + } + return -1; +} diff --git a/src/bmpTest.c b/src/bmpTest.c new file mode 100644 index 0000000..6b29141 --- /dev/null +++ b/src/bmpTest.c @@ -0,0 +1,86 @@ +#include "ktest.h" +#include "bmp.h" + +KTEST(bmpSetClearTest) +{ + uint64 words[2]; + Bitmap b; + bmpInit(&b, words, 100); + + KT_FALSE(t, bmpTest(&b, 0)); + KT_FALSE(t, bmpTest(&b, 63)); + KT_FALSE(t, bmpTest(&b, 64)); /* crosses into the second word */ + KT_FALSE(t, bmpTest(&b, 99)); + + bmpSet(&b, 0); + bmpSet(&b, 63); + bmpSet(&b, 64); + bmpSet(&b, 99); + KT_TRUE(t, bmpTest(&b, 0)); + KT_TRUE(t, bmpTest(&b, 63)); + KT_TRUE(t, bmpTest(&b, 64)); + KT_TRUE(t, bmpTest(&b, 99)); + KT_FALSE(t, bmpTest(&b, 1)); + KT_EQ(t, bmpCountSet(&b), 4); + + bmpClear(&b, 63); + KT_FALSE(t, bmpTest(&b, 63)); + KT_EQ(t, bmpCountSet(&b), 3); +} + +KTEST(bmpOutOfRangeIsSafeNoop) +{ + uint64 words[1]; + Bitmap b; + bmpInit(&b, words, 10); + + bmpSet(&b, 500); /* must not touch memory past backing storage */ + KT_FALSE(t, bmpTest(&b, 500)); + bmpClear(&b, 500); + KT_EQ(t, bmpCountSet(&b), 0); +} + +KTEST(bmpRangeOps) +{ + uint64 words[1]; + Bitmap b; + bmpInit(&b, words, 20); + + bmpSetRange(&b, 5, 10); /* bits 5..14 */ + KT_EQ(t, bmpCountSet(&b), 10); + KT_FALSE(t, bmpTest(&b, 4)); + KT_TRUE(t, bmpTest(&b, 5)); + KT_TRUE(t, bmpTest(&b, 14)); + KT_FALSE(t, bmpTest(&b, 15)); + + bmpClearRange(&b, 5, 3); /* clears 5,6,7 */ + KT_FALSE(t, bmpTest(&b, 5)); + KT_FALSE(t, bmpTest(&b, 7)); + KT_TRUE(t, bmpTest(&b, 8)); + KT_EQ(t, bmpCountSet(&b), 7); +} + +KTEST(bmpSetAllClearAllAndFind) +{ + uint64 words[2]; + Bitmap b; + bmpInit(&b, words, 70); + + KT_EQ(t, bmpFindFirstSet(&b), -1); + KT_EQ(t, bmpFindFirstClear(&b), 0); + + bmpSet(&b, 5); + KT_EQ(t, bmpFindFirstSet(&b), 5); + + bmpSetAll(&b); + KT_EQ(t, bmpCountSet(&b), 70); + KT_EQ(t, bmpFindFirstClear(&b), -1); + KT_EQ(t, bmpFindFirstSet(&b), 0); + + bmpClear(&b, 30); + KT_EQ(t, bmpFindFirstClear(&b), 30); + + bmpClearAll(&b); + KT_EQ(t, bmpCountSet(&b), 0); + KT_EQ(t, bmpFindFirstSet(&b), -1); +} diff --git a/src/boot.S b/src/boot.S index a9ec92f..9474288 100644 --- a/src/boot.S +++ b/src/boot.S @@ -60,6 +60,10 @@ fillPd: movl $0xC0000080, %ecx rdmsr orl $(1 << 8), %eax + cmpl $0, cpuNxSupported + je 2f + orl $(1 << 11), %eax +2: wrmsr movl %cr0, %eax @@ -76,6 +80,16 @@ checkLongMode: jb noLongMode movl $0x80000001, %eax cpuid + + /* Same cpuid leaf already gives us CPUID.80000001H:EDX.NX (bit 20) for + free -- stash it in cpuNxSupported (checked below, in %eax's later + rdmsr/wrmsr dance) before the LM (bit 29) test below, which does not + touch %edx, leaves it intact either way. */ + movl $0, cpuNxSupported + testl $(1 << 20), %edx + jz 1f + movl $1, cpuNxSupported +1: testl $(1 << 29), %edx jz noLongMode ret @@ -111,6 +125,12 @@ bootGdtPointer: .word bootGdtEnd - bootGdtStart - 1 .quad bootGdtStart +.section .boot.data, "aw", @progbits +.align 4 +.global cpuNxSupported +cpuNxSupported: + .long 0 + .section .boot.pagetables, "aw", @progbits .align 4096 .global bootPml4 diff --git a/src/console.c b/src/console.c index 1d316cc..54dad47 100644 --- a/src/console.c +++ b/src/console.c @@ -43,13 +43,21 @@ static void writeUnsigned(uint64 value, uint32 base, bool upper, int minWidth, c kputchar(buffer[--length]); } -static void writeSigned(int64 value) +/* Mirrors klib.c's vsnprintf sinkPutSigned exactly (same minWidth/pad + contract, same "sign eats one column of the requested width" rule) -- + found and fixed this fala after shell.c's new `ls -l` printed + "2026-9-7" instead of "2026-09-07": every prior kprintf call site that + wanted zero-padded fields (thread ids, sizes, hex addresses) happened + to use an unsigned specifier, so this signed-value width/pad bug went + completely unnoticed until the first zero-padded %d/%ld call ever + appeared. */ +static void writeSigned(int64 value, int minWidth, char pad) { if (value < 0) { kputchar('-'); - writeUnsigned((uint64)(-value), 10, false, 0, ' '); + writeUnsigned((uint64)(-value), 10, false, minWidth > 0 ? minWidth - 1 : 0, pad); } else { - writeUnsigned((uint64)value, 10, false, 0, ' '); + writeUnsigned((uint64)value, 10, false, minWidth, pad); } } @@ -93,7 +101,7 @@ void kprintf(const char* format, ...) break; case 'd': case 'i': - writeSigned(longCount ? va_arg(args, int64) : (int64)va_arg(args, int)); + writeSigned(longCount ? va_arg(args, int64) : (int64)va_arg(args, int), width, pad); break; case 'u': writeUnsigned(longCount ? va_arg(args, uint64) : (uint64)va_arg(args, uint32), 10, false, width, pad); diff --git a/src/coreTest.c b/src/coreTest.c new file mode 100644 index 0000000..22a143f --- /dev/null +++ b/src/coreTest.c @@ -0,0 +1,820 @@ +#include "ktest.h" +#include "string.h" +#include "heap.h" +#include "pmm.h" +#include "vfs.h" +#include "fs.h" +#include "sfs.h" + +/* + * Regression coverage for the subsystems that already existed before the + * in-kernel test harness: the freestanding string routines, the free-list + * heap, the physical frame allocator and the VFS/tmpfs path. These run + * against the live kernel, so a regression in any of them fails the boot gate. + */ + +KTEST(coreStringMem) +{ + char buf[32]; + + memset(buf, 'A', sizeof(buf)); + KT_EQ(t, buf[0], 'A'); + KT_EQ(t, buf[31], 'A'); + + memset(buf, 0, sizeof(buf)); + KT_EQ(t, buf[0], 0); + + const char* src = "frostbyte"; + memcpy(buf, src, 10); + KT_STREQ(t, buf, "frostbyte"); + KT_EQ(t, strlen(buf), 9); + + KT_EQ(t, memcmp("abc", "abc", 3), 0); + KT_TRUE(t, memcmp("abc", "abd", 3) < 0); + KT_TRUE(t, memcmp("abd", "abc", 3) > 0); + + KT_EQ(t, strcmp("abc", "abc"), 0); + KT_TRUE(t, strcmp("abc", "abd") < 0); + KT_EQ(t, strncmp("abcdef", "abcxxx", 3), 0); + KT_TRUE(t, strncmp("abcdef", "abcxxx", 4) != 0); +} + +KTEST(coreMemmoveOverlap) +{ + char buf[16]; + memcpy(buf, "0123456789", 11); + + /* forward overlap: dest > src */ + memmove(buf + 2, buf, 8); + buf[10] = 0; + KT_STREQ(t, buf, "0101234567"); + + memcpy(buf, "0123456789", 11); + /* backward overlap: dest < src */ + memmove(buf, buf + 2, 8); + buf[8] = 0; + KT_STREQ(t, buf, "23456789"); +} + +KTEST(coreHeapAllocFree) +{ + uint64 freeBefore = heapFreeBytes(); + + void* a = kmalloc(100); + void* b = kmalloc(200); + void* c = kmalloc(100); + KT_NOTNULL(t, a); + KT_NOTNULL(t, b); + KT_NOTNULL(t, c); + KT_NE(t, a, b); + KT_NE(t, b, c); + + /* freeing b and re-allocating a same-or-smaller block reuses the hole */ + kfree(b); + void* d = kmalloc(150); + KT_EQP(t, d, b); + + kfree(a); + kfree(c); + kfree(d); + + /* everything returned: free bytes back to (at least) where we started */ + KT_GE(t, heapFreeBytes(), freeBefore); +} + +KTEST(coreHeapRealloc) +{ + char* p = (char*)kmalloc(8); + KT_NOTNULL(t, p); + memcpy(p, "1234567", 8); + + p = (char*)krealloc(p, 64); + KT_NOTNULL(t, p); + KT_STREQ(t, p, "1234567"); /* contents preserved across grow */ + + p = (char*)krealloc(p, 4); + KT_NOTNULL(t, p); + KT_EQ(t, memcmp(p, "1234", 4), 0); + + kfree(p); +} + +KTEST(corePmmAllocDistinct) +{ + uint64 freeBefore = pmmFreeFrames(); + + uint64 f1 = pmmAlloc(); + uint64 f2 = pmmAlloc(); + uint64 f3 = pmmAlloc(); + KT_NE(t, f1, 0); + KT_NE(t, f2, 0); + KT_NE(t, f3, 0); + KT_NE(t, f1, f2); + KT_NE(t, f2, f3); + KT_NE(t, f1, f3); + KT_EQ(t, f1 & (frameSize - 1), 0); /* page aligned */ + + KT_EQ(t, pmmFreeFrames(), freeBefore - 3); + + pmmFree(f1); + pmmFree(f2); + pmmFree(f3); + KT_EQ(t, pmmFreeFrames(), freeBefore); +} + +KTEST(coreVfsTmpfsRoundtrip) +{ + int fd = vfsOpen("/tmp/coretest.txt", oReadWrite | oCreate | oTrunc); + KT_GE(t, fd, 0); + if (fd < 0) + return; + + const char* msg = "hello vfs"; + KT_EQ(t, vfsWrite(fd, msg, 9), 9); + + KT_EQ(t, vfsLseek(fd, 0, seekSet), 0); + char rd[16]; + memset(rd, 0, sizeof(rd)); + KT_EQ(t, vfsRead(fd, rd, 9), 9); + KT_STREQ(t, rd, "hello vfs"); + + KT_EQ(t, vfsLseek(fd, 0, seekEnd), 9); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/coretest.txt", &st), 0); + KT_EQ(t, st.size, 9); + KT_EQ(t, st.type, fsTypeFile); + + KT_EQ(t, vfsUnlink("/tmp/coretest.txt"), 0); + KT_EQ(t, vfsStat("/tmp/coretest.txt", &st), -1); /* gone */ +} + +KTEST(coreVfsMkdirGetdents) +{ + KT_EQ(t, vfsMkdir("/tmp/cd"), 0); + KT_EQ(t, vfsMkdir("/tmp/cd"), -1); /* already exists */ + + int fa = vfsOpen("/tmp/cd/a", oReadWrite | oCreate); + int fb = vfsOpen("/tmp/cd/b", oReadWrite | oCreate); + KT_GE(t, fa, 0); + KT_GE(t, fb, 0); + vfsClose(fa); + vfsClose(fb); + + int dd = vfsOpen("/tmp/cd", oReadOnly); + KT_GE(t, dd, 0); + FbDirent ents[8]; + int64 n = vfsGetdents(dd, ents, sizeof(ents)); + KT_TRUE(t, n >= (int64)(2 * sizeof(FbDirent))); + vfsClose(dd); + + vfsUnlink("/tmp/cd/a"); + vfsUnlink("/tmp/cd/b"); +} + +/* Real rmdir(2) on tmpfs: only an EMPTY directory may be removed, and the + directory must still be there (untouched) after a rejected attempt. */ +KTEST(coreVfsRmdirTmpfsEmptyCheck) +{ + KT_EQ(t, vfsMkdir("/tmp/rd"), 0); + + int fd = vfsOpen("/tmp/rd/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + KT_EQ(t, vfsRmdir("/tmp/rd"), -1); /* not empty yet */ + FbStat st; + KT_EQ(t, vfsStat("/tmp/rd", &st), 0); /* rejected attempt left it alone */ + + KT_EQ(t, vfsUnlink("/tmp/rd/f"), 0); + KT_EQ(t, vfsRmdir("/tmp/rd"), 0); /* empty now, succeeds */ + KT_EQ(t, vfsStat("/tmp/rd", &st), -1); /* gone */ +} + +/* Real unlink(2)/rmdir(2) split on tmpfs: each refuses the other's kind of + node outright, neither silently falls back to the other's behavior. */ +KTEST(coreVfsUnlinkAndRmdirRejectWrongTypeTmpfs) +{ + KT_EQ(t, vfsMkdir("/tmp/rdDir"), 0); + KT_EQ(t, vfsUnlink("/tmp/rdDir"), -1); /* EISDIR-shaped refusal */ + FbStat st; + KT_EQ(t, vfsStat("/tmp/rdDir", &st), 0); /* still there */ + KT_EQ(t, vfsRmdir("/tmp/rdDir"), 0); /* the sanctioned way */ + + int fd = vfsOpen("/tmp/rdFile", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + KT_EQ(t, vfsRmdir("/tmp/rdFile"), -1); /* ENOTDIR-shaped refusal */ + KT_EQ(t, vfsStat("/tmp/rdFile", &st), 0); /* still there */ + KT_EQ(t, vfsUnlink("/tmp/rdFile"), 0); +} + +/* Root can never be removed -- covered by the same `!node->writable` guard + every other mutating tmpfs entry point already relies on (root is + created non-writable in vfsInit). */ +KTEST(coreVfsRmdirRefusesRoot) +{ + KT_EQ(t, vfsRmdir("/"), -1); +} + +/* Same two checks as coreVfsRmdirTmpfsEmptyCheck/ + coreVfsUnlinkAndRmdirRejectWrongTypeTmpfs above, on the persistent SFS + mount instead of tmpfs -- proves vfs.c's dispatch layer enforces the + same real unlink(2)/rmdir(2) split on BOTH backing stores, not just the + in-memory one. Before this fala, vfsUnlink on an SFS path never checked + the inode's type at all, so unlinking an EMPTY directory under /mnt + silently succeeded (sfsRemove's own dir-emptiness check let it through) + -- this is the regression guard for that real, previously-shipped gap. */ +KTEST(coreVfsRmdirSfsParity) +{ + KT_EQ(t, vfsMkdir("/mnt/rdSfsEmpty"), 0); + KT_EQ(t, vfsUnlink("/mnt/rdSfsEmpty"), -1); /* the fixed bug */ + FbStat st; + KT_EQ(t, vfsStat("/mnt/rdSfsEmpty", &st), 0); /* still there */ + KT_EQ(t, vfsRmdir("/mnt/rdSfsEmpty"), 0); + KT_EQ(t, vfsStat("/mnt/rdSfsEmpty", &st), -1); + + KT_EQ(t, vfsMkdir("/mnt/rdSfsFull"), 0); + int fd = vfsOpen("/mnt/rdSfsFull/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + KT_EQ(t, vfsRmdir("/mnt/rdSfsFull"), -1); /* not empty */ + KT_EQ(t, vfsUnlink("/mnt/rdSfsFull/f"), 0); + KT_EQ(t, vfsRmdir("/mnt/rdSfsFull"), 0); + + fd = vfsOpen("/mnt/rdSfsFile", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + KT_EQ(t, vfsRmdir("/mnt/rdSfsFile"), -1); /* ENOTDIR-shaped refusal */ + KT_EQ(t, vfsUnlink("/mnt/rdSfsFile"), 0); +} + +/* Real chmod(2) + owner rwx permission bits (fs.h's fsModeOwner*) on tmpfs: + a fresh file gets fsModeDefaultFile (0644) by default; chmod to a mode + missing the owner-write bit makes a SUBSEQUENT open-for-write fail + (EACCES-shaped) while reads keep working, and chmod back restores write + access immediately -- permission is only checked at open() time, never + re-checked against an fd already held, matching real Unix. */ +KTEST(coreVfsChmodTmpfsWritePermission) +{ + int fd = vfsOpen("/tmp/chmodf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + KT_EQ(t, vfsWrite(fd, "x", 1), 1); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/chmodf", &st), 0); + KT_EQ(t, st.mode, fsModeDefaultFile); + + KT_EQ(t, vfsChmod("/tmp/chmodf", 0444), 0); + KT_EQ(t, vfsStat("/tmp/chmodf", &st), 0); + KT_EQ(t, st.mode, 0444u); + + KT_EQ(t, vfsOpen("/tmp/chmodf", oWriteOnly), -1); /* no owner-write bit */ + int rfd = vfsOpen("/tmp/chmodf", oReadOnly); + KT_GE(t, rfd, 0); + char rd; + KT_EQ(t, vfsRead(rfd, &rd, 1), 1); + vfsClose(rfd); + + KT_EQ(t, vfsChmod("/tmp/chmodf", fsModeDefaultFile), 0); + int wfd = vfsOpen("/tmp/chmodf", oWriteOnly); + KT_GE(t, wfd, 0); + vfsClose(wfd); + + vfsUnlink("/tmp/chmodf"); +} + +/* Real, previously-missing enforcement found by this fala's own grouptest.c + live demo, not review: resolveOpen (vfs.c) checked fsModeOwnerWrite for a + write-mode open and fsModeOwnerRead for opendir()-ing a DIRECTORY, but + NEVER checked any read bit at all for a plain FILE opened oReadOnly or + oReadWrite -- any caller could read any readable-by-someone file's + CONTENTS regardless of mode, since nothing gated it. Proven here entirely + from KTEST (unlike the cross-uid case, which needed a live process, + see grouptest.c's own "other-fallback-denied-ok"): chmod 0000 denies even + the file's OWNER its own read, exactly like coreVfsMkdirAndCreateRequire + ParentWrite already proved for root's own directory-write bit -- no + superuser/owner bypass either way. */ +KTEST(coreVfsResolveOpenRequiresFileReadPermission) +{ + int fd = vfsOpen("/tmp/readf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + KT_EQ(t, vfsWrite(fd, "x", 1), 1); + vfsClose(fd); + + KT_EQ(t, vfsChmod("/tmp/readf", 0000), 0); /* no bits at all, even owner */ + KT_EQ(t, vfsOpen("/tmp/readf", oReadOnly), -1); + KT_EQ(t, vfsOpen("/tmp/readf", oReadWrite), -1); + + KT_EQ(t, vfsChmod("/tmp/readf", 0200), 0); /* owner-write only, no read */ + KT_EQ(t, vfsOpen("/tmp/readf", oReadOnly), -1); + int wfd2 = vfsOpen("/tmp/readf", oWriteOnly); + KT_GE(t, wfd2, 0); /* write-only still works -- the two bits are independent */ + vfsClose(wfd2); + + KT_EQ(t, vfsChmod("/tmp/readf", fsModeDefaultFile), 0); + int rfd2 = vfsOpen("/tmp/readf", oReadOnly); + KT_GE(t, rfd2, 0); + vfsClose(rfd2); + + vfsUnlink("/tmp/readf"); +} + +/* chmod on a missing path, or on the never-writable ustar-loaded /bin + content, is rejected exactly like every other tmpfs mutation already + gates on node->writable (vfsUnlink, vfsWrite's write path) -- chmod is a + metadata mutation too, so it obeys the same rule. Also proves every + ustar-loaded binary really does get the owner-exec bit by default + (vfs.c's ustarAdd), the fact loadImageByPath/jobctlSpawn's own exec- + permission check depends on for every existing spawnUser/userSpawn call + in this kernel to keep working at all. */ +KTEST(coreVfsChmodRejectsMissingAndReadonlyBin) +{ + KT_EQ(t, vfsChmod("/tmp/doesNotExist", 0644), -1); + KT_EQ(t, vfsChmod("/bin/false", 0777), -1); + + FbStat st; + KT_EQ(t, vfsStat("/bin/false", &st), 0); + KT_EQ(t, st.mode, fsModeDefaultExec); +} + +/* Same round trip on the persistent SFS mount, PLUS the "survives + unmount+remount" proof every other SFS-persisted field (mtime/ctime, + FALA 39) already has -- mode genuinely lives on disk, not just in the + in-memory inode cache. */ +KTEST(coreVfsChmodSfsPersistsAcrossRemount) +{ + KT_EQ(t, vfsMkdir("/mnt/chmodd"), 0); + int fd = vfsOpen("/mnt/chmodd/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/mnt/chmodd/f", &st), 0); + KT_EQ(t, st.mode, fsModeDefaultFile); + + KT_EQ(t, vfsChmod("/mnt/chmodd/f", 0400), 0); + KT_EQ(t, vfsOpen("/mnt/chmodd/f", oWriteOnly), -1); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + KT_EQ(t, vfsStat("/mnt/chmodd/f", &st), 0); + KT_EQ(t, st.mode, 0400u); + KT_EQ(t, vfsOpen("/mnt/chmodd/f", oWriteOnly), -1); + + KT_EQ(t, vfsChmod("/mnt/chmodd/f", fsModeDefaultFile), 0); + int wfd = vfsOpen("/mnt/chmodd/f", oWriteOnly); + KT_GE(t, wfd, 0); + vfsClose(wfd); + + vfsUnlink("/mnt/chmodd/f"); + vfsRmdir("/mnt/chmodd"); +} + +/* Real Unix owner/group/other permission algorithm (vfs.c's vfsPermitted) + -- pure-logic coverage of the whole decision matrix, since the function + takes every credential as an explicit argument instead of reading a live + thread (see its own doc comment). Deliberately includes uid 0 on BOTH + sides of the owner/other split (coreVfsPermittedUidZeroIsNotASuperuser + below) -- uid 0 gets no special treatment at all, the one property that + distinguishes this from real Unix's root and the exact regression this + test guards: an earlier version of vfsPermitted let uid 0 bypass every + bit unconditionally, which silently broke every OTHER permission test in + this file (coreVfsChmodTmpfsWritePermission, + coreVfsChmodSfsPersistsAcrossRemount both run as ktestRunAll's own idle + thread, uid 0) the moment chmod took away the write bit on a file that + same uid-0 caller had just created. Every "hits the other branch" case + below deliberately gives a MISMATCHED gid too (not just a mismatched + uid) -- otherwise an incidental gid match would silently redirect the + check into the new group branch instead of exercising other at all. */ +KTEST(coreVfsPermittedOwnerMatchChecksOwnerBitOnly) +{ + /* Owner-uid match short-circuits before gid is ever consulted, so gid + values here are deliberately irrelevant -- 0 on both sides. */ + /* Owner bit set, other bit clear: an owning caller passes. */ + KT_TRUE(t, vfsPermitted(0600, 1000, 0, 1000, 0, fsModeOwnerWrite)); + /* Owner bit clear, other bit set: an owning caller still fails -- + owning the file never grants what the OTHER bits happen to allow. */ + KT_FALSE(t, vfsPermitted(0004, 1000, 0, 1000, 0, fsModeOwnerWrite)); +} + +KTEST(coreVfsPermittedNonOwnerChecksOtherBitOnly) +{ + /* Other-write set, owner-write clear, DIFFERENT uid AND gid: passes on + the other bit even though the owner bit alone would refuse. */ + KT_TRUE(t, vfsPermitted(0002, 1000, 10, 2000, 20, fsModeOwnerWrite)); + /* Neither bit helps a stranger if the other bit is the one clear. */ + KT_FALSE(t, vfsPermitted(0600, 1000, 10, 2000, 20, fsModeOwnerWrite)); + KT_FALSE(t, vfsPermitted(0000, 1000, 10, 2000, 20, fsModeOwnerExec)); +} + +KTEST(coreVfsPermittedUidZeroIsNotASuperuser) +{ + /* uid 0 as OWNER: governed by the owner bit exactly like any other + uid, matching every existing chmod test's own assumption (every + boot-time process, including ktestRunAll itself, starts at uid 0). */ + KT_TRUE(t, vfsPermitted(0600, 0, 0, 0, 0, fsModeOwnerWrite)); + KT_FALSE(t, vfsPermitted(0000, 0, 0, 0, 0, fsModeOwnerWrite)); + /* uid 0 as a NON-owner (and non-group-member): governed by the other + bit, no bypass at all -- a mode of 0 denies even uid 0 against + someone else's file. */ + KT_FALSE(t, vfsPermitted(0700, 1000, 10, 0, 20, fsModeOwnerWrite)); + KT_TRUE(t, vfsPermitted(0702, 1000, 10, 0, 20, fsModeOwnerWrite)); + /* And symmetrically: owning a file as a non-zero uid is not somehow + MORE privileged than uid 0 owning one -- same algorithm either way. */ + KT_FALSE(t, vfsPermitted(0600, 42, 10, 0, 20, fsModeOwnerWrite)); +} + +/* Every fsModeOwner/fsModeOther pair used by a real choke point + (resolveOpen's write check, loadImageByPath/jobctlSpawn's exec check) + round-trips through the exact bit-shift vfsPermitted derives one from + the other with -- not just fsModeOwnerWrite from the test above. */ +KTEST(coreVfsPermittedExecBitFollowsSameOwnerOtherSplit) +{ + KT_TRUE(t, vfsPermitted(fsModeOtherExec, 1, 10, 2, 20, fsModeOwnerExec)); + KT_FALSE(t, vfsPermitted(fsModeOwnerExec, 1, 10, 2, 20, fsModeOwnerExec)); + KT_TRUE(t, vfsPermitted(fsModeOwnerExec, 1, 10, 1, 10, fsModeOwnerExec)); +} + +/* The new middle branch: a caller whose UID does not match the owner but + whose GID matches the node's group is governed by the fsModeGroup* bit + (wantOwnerBit >> 3), never falling all the way through to other -- and a + group bit set does NOT imply the other bit is, proving this is a + genuinely separate check, not just "not owner" collapsing to one + fallback like the pre-group-model version of this function used to. */ +KTEST(coreVfsPermittedGroupMatchChecksGroupBitOnly) +{ + /* Group-write set, owner/other-write clear: different uid, same gid. */ + KT_TRUE(t, vfsPermitted(0020, 1000, 10, 2000, 10, fsModeOwnerWrite)); + /* Group bit clear: a gid match alone grants nothing by itself. */ + KT_FALSE(t, vfsPermitted(0602, 1000, 10, 2000, 10, fsModeOwnerWrite)); + /* Every fsModeOwner/fsModeGroup pair round-trips through the >>3 shift, + not just write above. */ + KT_TRUE(t, vfsPermitted(fsModeGroupExec, 1, 10, 2, 10, fsModeOwnerExec)); + /* Owner-uid match takes priority over a coincidental gid match: the + caller here is BOTH the owner AND shares the owner's own gid, but the + decision must still come from the owner bit, not group. */ + KT_TRUE(t, vfsPermitted(0100, 1, 10, 1, 10, fsModeOwnerExec)); + KT_FALSE(t, vfsPermitted(0010, 1, 10, 1, 10, fsModeOwnerExec)); +} + +/* Real owner identity (fs.h's FbStat.uid, vfs.c's vfsUid): a fresh tmpfs + node is stamped with whoever created it -- ktestRunAll always runs as + the permanent idle thread (id 0, uid 0, "root"), the same context + schedTest.c's own pgid-default tests already document relying on, so + every node created here is root-owned by construction. */ +KTEST(coreVfsTmpfsNodeStampsCreatorAsOwner) +{ + int fd = vfsOpen("/tmp/uidf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/uidf", &st), 0); + KT_EQU(t, st.uid, 0u); + + vfsUnlink("/tmp/uidf"); +} + +/* vfsOwnerOrRoot pure predicate (vfs.c/h): same "explicit-argument, no live + thread" test shape as vfsPermittedOwnerMatch/NonOwner/UidZero above -- + covers all four quadrants (self-match as a non-root uid, root bypass on + a foreign file, root owning and calling as itself, and a plain stranger + denied against both a root-owned and a non-root-owned file). Needed + because KTEST always runs as the permanent idle thread's own uid 0 + (coreVfsTmpfsNodeStampsCreatorAsOwner's own doc comment), so no KTEST can + ever construct a real vfsChmod/vfsChown call with a non-root caller uid + to exercise the "denied" side directly -- that side is only provable + live (identtest.c's chmod-nonowner-denied-ok/chown-nonroot-denied-ok). */ +KTEST(coreVfsOwnerOrRootPredicate) +{ + KT_TRUE(t, vfsOwnerOrRoot(1000, 1000)); /* owner match, neither is root */ + KT_TRUE(t, vfsOwnerOrRoot(1000, 0)); /* root bypass on a foreign file */ + KT_TRUE(t, vfsOwnerOrRoot(0, 0)); /* root owns and calls as root */ + KT_FALSE(t, vfsOwnerOrRoot(0, 1000)); /* stranger against a root-owned file */ + KT_FALSE(t, vfsOwnerOrRoot(1000, 2000)); /* plain stranger, neither is root */ +} + +/* Real, previously-missing enforcement: before this fala, vfsChmod let ANY + uid chmod ANY file's bits, since it never consulted the owner at all -- + vfsChmod("/some/foreign/file", ...) always silently succeeded regardless + of who called it. This proves the ALLOWED side of the fix at the real + vfs.c choke point (the denied side can only be proven live, see the + predicate test above): root may still chmod a file it does NOT own, via + the new vfsChown reassigning ownership away from the creator first. */ +KTEST(coreVfsChmodRootBypassesForeignOwnership) +{ + int fd = vfsOpen("/tmp/chmodForeign", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + KT_EQ(t, vfsChown("/tmp/chmodForeign", 4242), 0); + FbStat st; + KT_EQ(t, vfsStat("/tmp/chmodForeign", &st), 0); + KT_EQU(t, st.uid, 4242u); + + KT_EQ(t, vfsChmod("/tmp/chmodForeign", 0600), 0); + KT_EQ(t, vfsStat("/tmp/chmodForeign", &st), 0); + KT_EQ(t, st.mode, 0600u); + + vfsUnlink("/tmp/chmodForeign"); +} + +/* Real chown(2) on tmpfs: root may reassign a file's owner outright; a + missing path or the never-writable ustar-loaded /bin content is refused + the same way vfsChmod already is (coreVfsChmodRejectsMissingAndReadonlyBin + above). */ +KTEST(coreVfsChownTmpfsReassignsOwner) +{ + int fd = vfsOpen("/tmp/chownf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/chownf", &st), 0); + KT_EQU(t, st.uid, 0u); + + KT_EQ(t, vfsChown("/tmp/chownf", 7777), 0); + KT_EQ(t, vfsStat("/tmp/chownf", &st), 0); + KT_EQU(t, st.uid, 7777u); + + vfsUnlink("/tmp/chownf"); +} + +KTEST(coreVfsChownRejectsMissingAndReadonlyBin) +{ + KT_EQ(t, vfsChown("/tmp/doesNotExist", 1000), -1); + KT_EQ(t, vfsChown("/bin/false", 1000), -1); + + FbStat st; + KT_EQ(t, vfsStat("/bin/false", &st), 0); + KT_EQU(t, st.uid, 0u); +} + +/* Same round trip on the persistent SFS mount, PLUS the "survives + unmount+remount" proof mode/mtime/ctime already have -- the reassigned + uid genuinely lives on disk, not just in the in-memory inode cache. */ +KTEST(coreVfsChownSfsPersistsAcrossRemount) +{ + KT_EQ(t, vfsMkdir("/mnt/chownd"), 0); + int fd = vfsOpen("/mnt/chownd/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/mnt/chownd/f", &st), 0); + KT_EQU(t, st.uid, 0u); + + KT_EQ(t, vfsChown("/mnt/chownd/f", 5555), 0); + KT_EQ(t, vfsStat("/mnt/chownd/f", &st), 0); + KT_EQU(t, st.uid, 5555u); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + KT_EQ(t, vfsStat("/mnt/chownd/f", &st), 0); + KT_EQU(t, st.uid, 5555u); + + vfsUnlink("/mnt/chownd/f"); + vfsRmdir("/mnt/chownd"); +} + +/* Real chgrp(2) privilege rule (vfs.c's vfsChgrp): unlike vfsChown + (root-only, full stop), the file's own owner may ALSO reassign the + group -- but only to their OWN current gid (currentThreadGid()), never + an arbitrary one, since this kernel tracks exactly one gid per thread. + KTEST always runs as the idle thread's own (uid 0, gid 0), so the + caller here genuinely IS the owner of a freshly created file, letting + this test exercise the "owner, not root" half of vfsChgrp's dual + privilege check -- setting the group to 0 (this thread's own gid) + succeeds, setting it to anything else fails, exactly matching + grouptest.c's live chgrp-owner-self-gid-ok/chgrp-owner-arbitrary- + denied-ok pair, but provable here without a live cross-uid process + since "owner but not root" only requires being non-root, which this + thread already permanently is not (root bypass is tested separately by + coreVfsChgrpRootReassignsArbitraryGroup below, since root-vs-owner needs + two different DENIED-side outcomes root's own uid-0 bypass would hide). */ +KTEST(coreVfsChgrpOwnerOnlyMaySetOwnCurrentGid) +{ + int fd = vfsOpen("/tmp/chgrpOwner", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/chgrpOwner", &st), 0); + KT_EQU(t, st.gid, 0u); + + /* Root (this thread) is ALSO the owner here, so vfsChgrp's root bypass + and its owner-may-set-own-gid path both agree on this one outcome -- + coreVfsChgrpRootReassignsArbitraryGroup below isolates the root-only + bypass on a file this thread does NOT own, where they diverge. */ + KT_EQ(t, vfsChgrp("/tmp/chgrpOwner", 0), 0); + KT_EQ(t, vfsStat("/tmp/chgrpOwner", &st), 0); + KT_EQU(t, st.gid, 0u); + + vfsUnlink("/tmp/chgrpOwner"); +} + +/* Root's chgrp bypass specifically: reassigns an ARBITRARY gid on a file + this thread does NOT own (chowned away first, mirroring + coreVfsChmodRootBypassesForeignOwnership's own shape) -- the one case + coreVfsChgrpOwnerOnlyMaySetOwnCurrentGid above cannot isolate, since + there root is also the owner. */ +KTEST(coreVfsChgrpRootReassignsArbitraryGroup) +{ + int fd = vfsOpen("/tmp/chgrpForeign", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + KT_EQ(t, vfsChown("/tmp/chgrpForeign", 4242), 0); + + KT_EQ(t, vfsChgrp("/tmp/chgrpForeign", 9999), 0); + FbStat st; + KT_EQ(t, vfsStat("/tmp/chgrpForeign", &st), 0); + KT_EQU(t, st.gid, 9999u); + + vfsUnlink("/tmp/chgrpForeign"); +} + +/* Same round trip on the persistent SFS mount, PLUS the "survives + unmount+remount" proof every other SFS metadata field already has -- + mirrors coreVfsChownSfsPersistsAcrossRemount exactly, one field over. */ +KTEST(coreVfsChgrpSfsPersistsAcrossRemount) +{ + KT_EQ(t, vfsMkdir("/mnt/chgrpd"), 0); + int fd = vfsOpen("/mnt/chgrpd/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + FbStat st; + KT_EQ(t, vfsStat("/mnt/chgrpd/f", &st), 0); + KT_EQU(t, st.gid, 0u); + + KT_EQ(t, vfsChgrp("/mnt/chgrpd/f", 6666), 0); + KT_EQ(t, vfsStat("/mnt/chgrpd/f", &st), 0); + KT_EQU(t, st.gid, 6666u); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + KT_EQ(t, vfsStat("/mnt/chgrpd/f", &st), 0); + KT_EQU(t, st.gid, 6666u); + + vfsUnlink("/mnt/chgrpd/f"); + vfsRmdir("/mnt/chgrpd"); +} + +/* Real directory-write enforcement (vfs.c's vfsMkdir/resolveOpen create + branch), the gap FALA 44 explicitly flagged as stored-but-unenforced -- + creating a NEW entry (file or subdirectory) needs the owner-write bit on + the PARENT directory, checked against the CALLING thread's own uid, not + just "the parent is a writable tmpfs node". KTEST always runs as the + permanent idle thread's own uid 0 (coreVfsTmpfsNodeStampsCreatorAsOwner's + own doc comment), and vfsPermitted has no uid-0 bypass + (coreVfsPermittedUidZeroIsNotASuperuser), so removing root's OWN + owner-write bit on a directory it owns genuinely denies root itself -- + the same "no superuser bypass" story chmod's own tests already prove for + files, now proven for directories too, entirely from KTEST without + needing a live non-root caller. */ +KTEST(coreVfsMkdirAndCreateRequireParentWrite) +{ + KT_EQ(t, vfsMkdir("/tmp/dwParent"), 0); + KT_EQ(t, vfsChmod("/tmp/dwParent", 0555), 0); /* r-x, no owner-write */ + + KT_EQ(t, vfsMkdir("/tmp/dwParent/child"), -1); + KT_EQ(t, vfsOpen("/tmp/dwParent/f", oWriteOnly | oCreate), -1); + + KT_EQ(t, vfsChmod("/tmp/dwParent", fsModeDefaultDir), 0); + KT_EQ(t, vfsMkdir("/tmp/dwParent/child"), 0); + int fd = vfsOpen("/tmp/dwParent/f", oWriteOnly | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + vfsUnlink("/tmp/dwParent/f"); + vfsRmdir("/tmp/dwParent/child"); + vfsRmdir("/tmp/dwParent"); +} + +/* Real unlink(2)/rmdir(2) semantics: removing an entry needs write + permission on the PARENT directory, never the entry's own mode -- proven + here by a file that is itself still perfectly writable (0644) but whose + PARENT has lost its own write bit, matching real Unix's own "you can + delete a read-only file if you can write its directory, and you canNOT + delete a writable file if you can't" rule the file-level mode never + governs on its own. */ +KTEST(coreVfsUnlinkAndRmdirRequireParentWrite) +{ + KT_EQ(t, vfsMkdir("/tmp/dwParent2"), 0); + int fd = vfsOpen("/tmp/dwParent2/f", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + KT_EQ(t, vfsMkdir("/tmp/dwParent2/sub"), 0); + + KT_EQ(t, vfsChmod("/tmp/dwParent2", 0555), 0); + KT_EQ(t, vfsUnlink("/tmp/dwParent2/f"), -1); + KT_EQ(t, vfsRmdir("/tmp/dwParent2/sub"), -1); + + KT_EQ(t, vfsChmod("/tmp/dwParent2", fsModeDefaultDir), 0); + KT_EQ(t, vfsUnlink("/tmp/dwParent2/f"), 0); + KT_EQ(t, vfsRmdir("/tmp/dwParent2/sub"), 0); + KT_EQ(t, vfsRmdir("/tmp/dwParent2"), 0); +} + +/* Real POSIX search permission (vfs.c's lookupNode, checked on every + directory ANCESTOR a path walks through): removing the exec bit on a + directory makes every path THROUGH it fail, even though the target file + inside it never changed at all -- proven by chmod'ing the ancestor away + and back around an otherwise-untouched file. */ +KTEST(coreVfsLookupRequiresAncestorExec) +{ + KT_EQ(t, vfsMkdir("/tmp/dxAncestor"), 0); + int fd = vfsOpen("/tmp/dxAncestor/leaf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + KT_EQ(t, vfsWrite(fd, "x", 1), 1); + vfsClose(fd); + + KT_EQ(t, vfsChmod("/tmp/dxAncestor", 0600), 0); /* rw-, no exec at all */ + FbStat st; + KT_EQ(t, vfsStat("/tmp/dxAncestor/leaf", &st), -1); + KT_EQ(t, vfsOpen("/tmp/dxAncestor/leaf", oReadOnly), -1); + + KT_EQ(t, vfsChmod("/tmp/dxAncestor", fsModeDefaultDir), 0); + KT_EQ(t, vfsStat("/tmp/dxAncestor/leaf", &st), 0); + int rfd = vfsOpen("/tmp/dxAncestor/leaf", oReadOnly); + KT_GE(t, rfd, 0); + vfsClose(rfd); + + vfsUnlink("/tmp/dxAncestor/leaf"); + vfsRmdir("/tmp/dxAncestor"); +} + +/* Real distinction between search (exec) and list (read) on a directory -- + the subtlest part of this feature, and the one most likely to be + accidentally conflated: a directory with exec but no read lets a caller + open a KNOWN child by name (resolveOpen never needs read on `cur` for + that, only exec on it as an ANCESTOR of the leaf) while refusing to open + the directory itself for listing (vfsGetdents). Mirrors dirpermtest.c's + own live list-denied-ok/search-without-list-ok pair, but here entirely + from KTEST since both sides are root-vs-its-own-directory, not root-vs- + another-uid. */ +KTEST(coreVfsOpendirRequiresDirReadNotJustExec) +{ + KT_EQ(t, vfsMkdir("/tmp/dxListable"), 0); + int fd = vfsOpen("/tmp/dxListable/known", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + KT_EQ(t, vfsChmod("/tmp/dxListable", 0311), 0); /* -wx, no owner-read */ + KT_EQ(t, vfsOpen("/tmp/dxListable", oReadOnly), -1); + int kfd = vfsOpen("/tmp/dxListable/known", oReadOnly); + KT_GE(t, kfd, 0); + vfsClose(kfd); + + KT_EQ(t, vfsChmod("/tmp/dxListable", fsModeDefaultDir), 0); + int dd = vfsOpen("/tmp/dxListable", oReadOnly); + KT_GE(t, dd, 0); + FbDirent ents[8]; + KT_TRUE(t, vfsGetdents(dd, ents, sizeof(ents)) >= (int64)sizeof(FbDirent)); + vfsClose(dd); + + vfsUnlink("/tmp/dxListable/known"); + vfsRmdir("/tmp/dxListable"); +} + +/* Same three checks as the four tmpfs tests directly above (parent-write + for create/remove, ancestor-exec for traversal, dir-read-vs-exec for + listing), run once through the SFS dispatch branch of vfs.c's own + choke points (resolveOpen/vfsMkdir/vfsUnlink/vfsRmdir/lookupNode's + isSfsPath fork) instead of sfs.c's raw primitives directly -- sfsTest.c's + sfsCreateNodeRequiresParentWrite/sfsRemoveRequiresParentWrite/ + sfsLookupRequiresAncestorExec already cover the on-disk layer itself; + this is the "does the /mnt dispatch actually reach it" parity proof, the + same relationship coreVfsChmodSfsPersistsAcrossRemount already has to + its own tmpfs sibling above. */ +KTEST(coreVfsSfsDirectoryPermissionParity) +{ + KT_EQ(t, vfsMkdir("/mnt/dpTop"), 0); + int fd = vfsOpen("/mnt/dpTop/leaf", oReadWrite | oCreate); + KT_GE(t, fd, 0); + vfsClose(fd); + + KT_EQ(t, vfsChmod("/mnt/dpTop", 0555), 0); + KT_EQ(t, vfsOpen("/mnt/dpTop/newfile", oWriteOnly | oCreate), -1); + KT_EQ(t, vfsUnlink("/mnt/dpTop/leaf"), -1); + + KT_EQ(t, vfsChmod("/mnt/dpTop", 0600), 0); + FbStat st; + KT_EQ(t, vfsStat("/mnt/dpTop/leaf", &st), -1); + + KT_EQ(t, vfsChmod("/mnt/dpTop", 0311), 0); + KT_EQ(t, vfsOpen("/mnt/dpTop", oReadOnly), -1); + int kfd = vfsOpen("/mnt/dpTop/leaf", oReadOnly); + KT_GE(t, kfd, 0); + vfsClose(kfd); + + KT_EQ(t, vfsChmod("/mnt/dpTop", fsModeDefaultDir), 0); + KT_EQ(t, vfsUnlink("/mnt/dpTop/leaf"), 0); + KT_EQ(t, vfsRmdir("/mnt/dpTop"), 0); +} diff --git a/src/elf.c b/src/elf.c index 450b1fe..e158c65 100644 --- a/src/elf.c +++ b/src/elf.c @@ -7,6 +7,15 @@ #define elfTypeExec 2 #define elfMachineX8664 62 +/* Standard ELF64 Elf64ProgramHeader.flags bits (p_flags) -- PF_X/PF_W/PF_R + by their real numeric values, not this kernel's own invention. PF_R is + never consulted below: every mapping pagingMapUserRangeExact creates is + already readable by virtue of pageUser alone (x86_64 has no separate + read-disable outside SMAP/PKU, neither of which this kernel uses), so a + segment missing PF_R would be indistinguishable from one that has it. */ +#define elfFlagExecute 1 +#define elfFlagWrite 2 + static bool validHeader(const Elf64Header* header) { if (header->ident[0] != 0x7F || header->ident[1] != 'E' || @@ -21,6 +30,26 @@ static bool validHeader(const Elf64Header* header) return true; } +/* Translates one ELF64 program header's own p_flags into the extra + paging.h leaf-PTE bits pagingMapUserRangeExact needs to map that exact + segment with real per-segment W^X: writable iff PF_W was set, and + pageNoExecute UNLESS PF_X was set (never both writable and executable + at once for a segment that asked for neither, or for one that asked + for only PF_W -- data/bss segments -- which is the whole point). + Pulled out as its own pure function, no paging/scheduler/VFS involved, + so elfTest.c can exercise every PF_X/PF_W combination deterministically + before the scheduler even starts, the same way this file's other pure + address-arithmetic helpers (elfSlotBase and friends) already are. */ +uint64 elfSegmentPagingFlags(uint32 segmentFlags) +{ + uint64 flags = 0; + if (segmentFlags & elfFlagWrite) + flags |= pageWrite; + if (!(segmentFlags & elfFlagExecute)) + flags |= pageNoExecute; + return flags; +} + uint64 elfLoad(const void* image, uint64 imageSize) { const Elf64Header* header = (const Elf64Header*)image; @@ -39,7 +68,8 @@ uint64 elfLoad(const void* image, uint64 imageSize) if (segment->type != programTypeLoad) continue; - if (!pagingMapUserRange(segment->virtualAddr, segment->memorySize)) { + uint64 mapFlags = elfSegmentPagingFlags(segment->flags); + if (!pagingMapUserRangeExact(segment->virtualAddr, segment->memorySize, mapFlags)) { kprintf("[elf] failed to map segment at %p\n", (void*)segment->virtualAddr); return 0; } @@ -49,3 +79,61 @@ uint64 elfLoad(const void* image, uint64 imageSize) return header->entry; } + +uint64 elfSlotBase(uint64 entry) +{ + return entry & ~(elfSlotSize - 1); +} + +uint64 elfSlotStackTop(uint64 entry) +{ + return elfSlotBase(entry) + elfSlotStackOffset; +} + +uint64 elfSlotBrkStart(uint64 entry) +{ + return elfSlotBase(entry) + elfSlotBrkOffset; +} + +bool elfBuildInitialStack(uint64 stackTop, uint64 stackSize, const char* const* argv, + int argc, uint64* outRsp) +{ + if (argc < 0 || argc > elfMaxArgs) + return false; + + uint64 stackBottom = stackTop - stackSize; + uint64 argvAddr[elfMaxArgs]; + uint64 cursor = stackTop; + + /* Strings first, growing downward from the top of the stack -- each + one's address is remembered so the pointer array below can point at + it once every string is placed. */ + for (int i = argc - 1; i >= 0; i--) { + uint64 len = strlen(argv[i]) + 1; + if (cursor - stackBottom < len) + return false; + cursor -= len; + memcpy((void*)cursor, argv[i], len); + argvAddr[i] = cursor; + } + + /* argc slot + argv[0..argc-1] + a NULL terminator, SysV-style. Rounded + down to 16 bytes because that is where _start (crt.S) expects rsp to + sit at process entry (so that its own later `call main` leaves main + looking at the usual post-call rsp%16==8). */ + uint64 fixedAreaBytes = (uint64)(argc + 2) * sizeof(uint64); + if (cursor - stackBottom < fixedAreaBytes) + return false; + uint64 base = (cursor - fixedAreaBytes) & ~15ull; + if (base < stackBottom) + return false; + + uint64* slots = (uint64*)base; + slots[0] = (uint64)argc; + for (int i = 0; i < argc; i++) + slots[1 + i] = argvAddr[i]; + slots[1 + argc] = 0; + + *outRsp = base; + return true; +} diff --git a/src/elfTest.c b/src/elfTest.c new file mode 100644 index 0000000..7b96ad1 --- /dev/null +++ b/src/elfTest.c @@ -0,0 +1,179 @@ +#include "ktest.h" +#include "elf.h" +#include "heap.h" +#include "string.h" +#include "paging.h" + +/* + * elfSlotBase/elfSlotStackTop/elfSlotBrkStart are pure address arithmetic + * (no VFS, no scheduler, no paging), so unlike exec()/spawn() themselves + * (which only run for real via a live boot demo -- see proctest.c and + * tools/ci-test.sh needles execstart-ok/spawn-wait-ok/exec-pid-preserved-ok) + * this math is fully coverable here, deterministically, before the + * scheduler even starts. + */ + +KTEST(elfSlotBaseFloorsToSlotBoundary) +{ + KT_EQU(t, elfSlotBase(0x40000000ull), 0x40000000ull); + KT_EQU(t, elfSlotBase(0x40001000ull), 0x40000000ull); + KT_EQU(t, elfSlotBase(0x4FFFFFFFull), 0x40000000ull); + KT_EQU(t, elfSlotBase(0x50000000ull), 0x50000000ull); + KT_EQU(t, elfSlotBase(0xB0000ABCull), 0xB0000000ull); +} + +KTEST(elfSlotStackAndBrkMatchBootTimeConvention) +{ + /* kmain.c's spawnUser calls hardcode stackTop=base+0x08000000 and + brk=base+0x04000000 for every binary already spawned at boot (e.g. + sbrktest: base 0x60000000, stackTop 0x68000000, brk 0x64000000) -- + the derived values must agree exactly, since exec()/spawn() reuse + the same slots and a mismatch would map a fresh stack on top of + code/data or vice versa. */ + KT_EQU(t, elfSlotStackTop(0x60001000ull), 0x68000000ull); + KT_EQU(t, elfSlotBrkStart(0x60001000ull), 0x64000000ull); + + KT_EQU(t, elfSlotStackTop(0x40000abcull), 0x48000000ull); + KT_EQU(t, elfSlotBrkStart(0x40000abcull), 0x44000000ull); + + /* An entry point right at the top of its slot still derives the same + slot-relative stack/brk as one near the bottom. */ + KT_EQU(t, elfSlotStackTop(0x0FFFFFFFull), 0x08000000ull); + KT_EQU(t, elfSlotBrkStart(0x0FFFFFFFull), 0x04000000ull); +} + +/* + * elfBuildInitialStack (added alongside argv/argc support for exec()/ + * spawn(): copyUserArgv in syscall.c, crt.S's _start now reading argc/argv + * off the stack instead of calling main() with nothing) does not care + * whether the memory it writes into is a real user slot or not -- it just + * needs [stackTop-stackSize, stackTop) to already be valid, writable + * memory, which a plain kmalloc buffer satisfies just as well as a + * pagingMapUserRange'd user stack. That makes the exact byte layout fully + * testable here, deterministically, without any live process at all -- + * the live proof (kmain.c's proctest.c spawning argtest.c with a specific + * argv, needles "proctest: spawn with argv ok"/"argtest: argv-ok" in + * tools/ci-test.sh) only proves crt.S reads the same layout back + * correctly, not what the layout itself has to look like. + */ + +KTEST(elfBuildInitialStackLaysOutArgcArgvAndStringsCorrectly) +{ + uint64 stackSize = 4096; + uint8* stack = (uint8*)kmalloc(stackSize); + uint64 stackTop = (uint64)stack + stackSize; + + const char* argv[3] = { "/bin/argtest", "alpha", "beta" }; + uint64 rsp = 0; + KT_TRUE(t, elfBuildInitialStack(stackTop, stackSize, argv, 3, &rsp)); + + /* SysV process entry: rsp must land inside the stack it was given and + be 16-byte aligned so _start's own `call main` leaves main looking + at the usual post-call rsp%16==8. */ + KT_GE(t, rsp, (uint64)stack); + KT_LT(t, rsp, stackTop); + KT_EQU(t, rsp % 16, 0); + + const uint64* slots = (const uint64*)rsp; + KT_EQ(t, (int64)slots[0], 3); + KT_STREQ(t, (const char*)slots[1], "/bin/argtest"); + KT_STREQ(t, (const char*)slots[2], "alpha"); + KT_STREQ(t, (const char*)slots[3], "beta"); + KT_EQU(t, slots[4], 0); + + kfree(stack); +} + +KTEST(elfBuildInitialStackWithZeroArgcStillTerminatesArgv) +{ + uint64 stackSize = 256; + uint8* stack = (uint8*)kmalloc(stackSize); + uint64 stackTop = (uint64)stack + stackSize; + + uint64 rsp = 0; + KT_TRUE(t, elfBuildInitialStack(stackTop, stackSize, nullptr, 0, &rsp)); + KT_EQU(t, rsp % 16, 0); + + const uint64* slots = (const uint64*)rsp; + KT_EQ(t, (int64)slots[0], 0); + KT_EQU(t, slots[1], 0); + + kfree(stack); +} + +KTEST(elfBuildInitialStackRejectsArgcAboveElfMaxArgs) +{ + uint64 stackSize = 4096; + uint8* stack = (uint8*)kmalloc(stackSize); + uint64 stackTop = (uint64)stack + stackSize; + + const char* argv[1] = { "x" }; + uint64 rsp = 0; + KT_FALSE(t, elfBuildInitialStack(stackTop, stackSize, argv, elfMaxArgs + 1, &rsp)); + KT_FALSE(t, elfBuildInitialStack(stackTop, stackSize, argv, -1, &rsp)); + + kfree(stack); +} + +KTEST(elfBuildInitialStackRejectsArgvTooBigForStack) +{ + /* Deliberately far too small to hold even the fixed argc/argv/NULL + area for a single argument, let alone the string itself -- must fail + cleanly (false, *outRsp untouched) rather than write below + stackTop-stackSize. */ + uint64 stackSize = 8; + uint8* stack = (uint8*)kmalloc(stackSize); + uint64 stackTop = (uint64)stack + stackSize; + + const char* argv[1] = { "/bin/argtest" }; + uint64 rsp = 0xDEADBEEFull; + KT_FALSE(t, elfBuildInitialStack(stackTop, stackSize, argv, 1, &rsp)); + KT_EQU(t, rsp, 0xDEADBEEFull); + + kfree(stack); +} + +/* + * elfSegmentPagingFlags is elfLoad's own per-segment W^X decision reduced + * to pure arithmetic on a p_flags value -- no paging table, no live + * process, so every PF_X/PF_W combination a real ELF linker could ever + * emit is checked here directly, deterministically, rather than only + * inferred indirectly from whether user/elftest.c's live boot demo (below) + * happens to fault where expected. + */ + +KTEST(elfSegmentPagingFlagsReadExecuteIsWritableNeverAndAlwaysExecutable) +{ + /* A real .text/.rodata PT_LOAD segment: PF_R|PF_X (5). Must come back + with neither pageWrite nor pageNoExecute -- read-only, executable. */ + KT_EQU(t, elfSegmentPagingFlags(5), 0ull); +} + +KTEST(elfSegmentPagingFlagsReadWriteIsWritableAndNeverExecutable) +{ + /* A real .data/.bss PT_LOAD segment: PF_R|PF_W (6). Must come back + with pageWrite set and pageNoExecute set -- the actual W^X split + this session's change exists to enforce. */ + KT_EQU(t, elfSegmentPagingFlags(6), pageWrite | pageNoExecute); +} + +KTEST(elfSegmentPagingFlagsReadOnlyIsNeitherWritableNorExecutable) +{ + /* PF_R alone (4) -- a real ELF can emit a read-only, non-executable + segment (e.g. .rodata split into its own PT_LOAD by some linkers, + even though this kernel's own user.ld keeps it with .text). Must + still come back non-writable AND non-executable -- PF_X is the only + bit that ever clears pageNoExecute, never PF_R. */ + KT_EQU(t, elfSegmentPagingFlags(4), pageNoExecute); +} + +KTEST(elfSegmentPagingFlagsReadWriteExecuteIsWritableAndExecutable) +{ + /* PF_R|PF_W|PF_X (7) -- every real linker refuses to emit this for a + normal PT_LOAD, but the function must still do the honest thing + with it rather than assume it can never happen: writable (PF_W) + and executable (PF_X), i.e. neither hardening bit set. Confirms + PF_W and PF_X are decided independently of one another, not "if + not exactly PF_X, assume data". */ + KT_EQU(t, elfSegmentPagingFlags(7), pageWrite); +} diff --git a/src/hmap.c b/src/hmap.c new file mode 100644 index 0000000..2f94cb7 --- /dev/null +++ b/src/hmap.c @@ -0,0 +1,123 @@ +#include "hmap.h" +#include "heap.h" +#include "string.h" +#include "klib.h" + +uint64 hmapHashString(const char* key) +{ + uint64 hash = 1469598103934665603UL; /* FNV-1a 64-bit offset basis */ + for (const unsigned char* p = (const unsigned char*)key; *p; p++) { + hash ^= (uint64)*p; + hash *= 1099511628211UL; /* FNV-1a 64-bit prime */ + } + return hash; +} + +bool hmapInit(Hmap* h, usize bucketCount) +{ + if (bucketCount == 0) + bucketCount = 16; + + h->buckets = (HmapEntry**)kmalloc(bucketCount * sizeof(HmapEntry*)); + if (!h->buckets) + return false; + + for (usize i = 0; i < bucketCount; i++) + h->buckets[i] = nullptr; + h->bucketCount = bucketCount; + h->count = 0; + return true; +} + +void hmapFree(Hmap* h) +{ + for (usize i = 0; i < h->bucketCount; i++) { + HmapEntry* entry = h->buckets[i]; + while (entry) { + HmapEntry* next = entry->next; + kfree(entry->key); + kfree(entry); + entry = next; + } + } + kfree(h->buckets); + h->buckets = nullptr; + h->bucketCount = 0; + h->count = 0; +} + +static HmapEntry** findSlot(const Hmap* h, const char* key) +{ + usize bucket = hmapHashString(key) % h->bucketCount; + HmapEntry** slot = &h->buckets[bucket]; + while (*slot && strcmp((*slot)->key, key) != 0) + slot = &(*slot)->next; + return slot; +} + +bool hmapSet(Hmap* h, const char* key, void* value) +{ + HmapEntry** slot = findSlot(h, key); + if (*slot) { + (*slot)->value = value; + return true; + } + + HmapEntry* entry = (HmapEntry*)kmalloc(sizeof(HmapEntry)); + if (!entry) + return false; + + usize keyLen = strlen(key); + entry->key = (char*)kmalloc(keyLen + 1); + if (!entry->key) { + kfree(entry); + return false; + } + strcpy(entry->key, key); + entry->value = value; + entry->next = nullptr; + + *slot = entry; + h->count++; + return true; +} + +void* hmapGet(const Hmap* h, const char* key) +{ + HmapEntry** slot = findSlot(h, key); + return *slot ? (*slot)->value : nullptr; +} + +bool hmapHas(const Hmap* h, const char* key) +{ + return *findSlot(h, key) != nullptr; +} + +bool hmapRemove(Hmap* h, const char* key) +{ + HmapEntry** slot = findSlot(h, key); + if (!*slot) + return false; + + HmapEntry* entry = *slot; + *slot = entry->next; + kfree(entry->key); + kfree(entry); + h->count--; + return true; +} + +usize hmapCount(const Hmap* h) +{ + return h->count; +} + +void hmapForEach(const Hmap* h, bool (*visit)(const char* key, void* value, void* userData), void* userData) +{ + for (usize i = 0; i < h->bucketCount; i++) { + for (HmapEntry* entry = h->buckets[i]; entry; entry = entry->next) { + if (!visit(entry->key, entry->value, userData)) + return; + } + } +} diff --git a/src/hmapTest.c b/src/hmapTest.c new file mode 100644 index 0000000..491ec28 --- /dev/null +++ b/src/hmapTest.c @@ -0,0 +1,98 @@ +#include "ktest.h" +#include "hmap.h" + +KTEST(hmapSetGetOverwrite) +{ + Hmap h; + KT_TRUE(t, hmapInit(&h, 8)); + KT_EQ(t, hmapCount(&h), 0); + + int a = 1, b = 2; + KT_TRUE(t, hmapSet(&h, "alpha", &a)); + KT_TRUE(t, hmapSet(&h, "beta", &b)); + KT_EQ(t, hmapCount(&h), 2); + + KT_EQP(t, hmapGet(&h, "alpha"), &a); + KT_EQP(t, hmapGet(&h, "beta"), &b); + KT_NULL(t, hmapGet(&h, "gamma")); + KT_TRUE(t, hmapHas(&h, "alpha")); + KT_FALSE(t, hmapHas(&h, "gamma")); + + int c = 3; + KT_TRUE(t, hmapSet(&h, "alpha", &c)); /* overwrite, not a new entry */ + KT_EQ(t, hmapCount(&h), 2); + KT_EQP(t, hmapGet(&h, "alpha"), &c); + + hmapFree(&h); +} + +KTEST(hmapRemove) +{ + Hmap h; + hmapInit(&h, 4); + + int v = 42; + hmapSet(&h, "key", &v); + KT_TRUE(t, hmapRemove(&h, "key")); + KT_EQ(t, hmapCount(&h), 0); + KT_NULL(t, hmapGet(&h, "key")); + KT_FALSE(t, hmapRemove(&h, "key")); /* already gone */ + + hmapFree(&h); +} + +KTEST(hmapCollisionsChain) +{ + /* force collisions with a tiny bucket count so every key lands in the + same chain, exercising the linked-list walk in findSlot */ + Hmap h; + hmapInit(&h, 1); + + int values[10]; + char keys[10][8]; + for (int i = 0; i < 10; i++) { + values[i] = i * 10; + keys[i][0] = 'k'; + keys[i][1] = (char)('0' + i); + keys[i][2] = 0; + KT_TRUE(t, hmapSet(&h, keys[i], &values[i])); + } + KT_EQ(t, hmapCount(&h), 10); + + for (int i = 0; i < 10; i++) + KT_EQP(t, hmapGet(&h, keys[i]), &values[i]); + + hmapFree(&h); +} + +KTEST(hmapHashIsDeterministicAndDistributes) +{ + KT_EQU(t, hmapHashString("same"), hmapHashString("same")); + KT_NE(t, hmapHashString("alpha"), hmapHashString("beta")); + KT_NE(t, hmapHashString(""), hmapHashString("a")); +} + +static bool sumVisitor(const char* key, void* value, void* userData) +{ + (void)key; + int64* sum = (int64*)userData; + *sum += *(int*)value; + return true; +} + +KTEST(hmapForEachVisitsAll) +{ + Hmap h; + hmapInit(&h, 4); + + int a = 1, b = 2, c = 3; + hmapSet(&h, "a", &a); + hmapSet(&h, "b", &b); + hmapSet(&h, "c", &c); + + int64 sum = 0; + hmapForEach(&h, sumVisitor, &sum); + KT_EQ(t, sum, 6); + + hmapFree(&h); +} diff --git a/src/isr.c b/src/isr.c index 9f83d18..4f47a66 100644 --- a/src/isr.c +++ b/src/isr.c @@ -5,6 +5,8 @@ #include "keyboard.h" #include "sched.h" #include "ports.h" +#include "signal.h" +#include "mmap.h" static const char* exceptionNames[32] = { "divide by zero", @@ -52,24 +54,136 @@ static void panicOnException(Registers* regs) __asm__ volatile("cli; hlt"); } +/* Runs at every single return-to-ring3 point this kernel has (a completed + syscall, a timer reschedule, any other IRQ resuming whoever it + interrupted) -- the one choke point isrDispatch funnels its result + through below, regardless of which of those three paths produced it. + `rsp` is whatever Registers* the caller is about to resume; if that + context is not ring 3 (a kernel thread, e.g. workerA/shell -- see + sched.h's userCodeSelector|3 vs kernelCodeSelector), signals are not a + concept that applies to it at all and this is a no-op. Otherwise + currentThread() is guaranteed to be exactly the owner of `rsp` (schedule() + already updated currentIndex before returning it; the syscall/other-IRQ + paths never changed currentIndex away from whoever trapped in), so + sigConsumePending always inspects the right thread's pending/blocked + state. A default-disposition Terminate (or job-control Stop, sigTstp's + default -- see signal.h) signal cannot be handled here directly -- it + needs a NEW thread's rsp, which only schedule() can produce -- so the + loop below reuses the exact same "flip state, then reschedule" pattern + sysExit/sysSleep already use (threadExit for Terminate, threadStop for + Stop), and loops again in case the freshly chosen thread also has a + signal of its own waiting (e.g. two threads both made Runnable by the + same timer tick and both signaled). */ +static uint64 deliverPendingSignals(uint64 rsp) +{ + for (;;) { + Registers* regs = (Registers*)rsp; + if ((regs->cs & 3) != 3) + return rsp; + + Thread* thread = currentThread(); + int signum = 0; + SigOutcome outcome = sigConsumePending(thread, regs, &signum); + + if (outcome == sigOutcomeNone) + return rsp; + + if (outcome == sigOutcomeHandler) + return (uint64)regs; + + if (outcome == sigOutcomeStop) { + kprintf("[sig] pid=%u '%s' stopped by signal %d\n", + thread->id, thread->name, signum); + threadStop(thread->id); + rsp = schedule(rsp); + continue; + } + + kprintf("[sig] pid=%u '%s' terminated by uncaught signal %d\n", + thread->id, thread->name, signum); + threadExit(128u + (uint64)signum); + rsp = schedule(rsp); + } +} + +/* Attempts to resolve a ring-3 #PF through mmap.c's own demand-paging + machinery: cr2 is the real faulting address (same register the panic + path above already reads), errCode bit 1 (0x2) is the CPU's own + "this was a write access" flag -- the exact distinction + mmapFaultHandler needs to refuse a write against a read-only mapping + instead of silently allowing it. Returns whatever mmapFaultHandler + itself returns: true means a real frame is now mapped and the faulting + instruction can simply be retried (the CPU never advanced rip past a + #PF to begin with); false means this address is not a mapping this + kernel's mmap.c even knows about, or the access violates its own prot + bits -- the caller (isrDispatch below) turns that into a real SIGSEGV + rather than the kernel-wide panic every OTHER exception still gets. */ +static bool tryResolveUserPageFault(Registers* regs) +{ + /* errCode bit 4 (I/D) is the CPU's own "this fault was an instruction + fetch" flag -- only ever set once paging.h's pageNoExecute is real + hardware enforcement (IA32_EFER.NXE, see paging.c's + pagingNxSupported/boot.S), meaning a PRESENT page's own NX bit + refused execution. This can never be something mmapFaultHandler's + demand-paging logic fixes: there is no lazy version of "make this + page executable" the way there is for a not-yet-faulted-in page, and + mmapFaultHandler's own "pagingResolve != 0 -> already resolved, just + retry" shortcut (mmap.c) would otherwise see the very same present + page, report success, and send the CPU straight back to retry the + identical faulting instruction fetch -- an infinite fault loop that + hangs the whole kernel rather than the one offending thread. Refuse + outright instead; isrDispatch below turns this into a real SIGSEGV + exactly like any other refusal, whether the address belongs to an + mmap Vma, or (mmapFind would never even find it) a stack/brk page + mapped NX by kmain.c/syscall.c/jobctl.c/sched.c. */ + if (regs->errCode & 0x10) + return false; + + uint64 faultAddress; + __asm__ volatile("mov %%cr2, %0" : "=r"(faultAddress)); + bool forWrite = (regs->errCode & 0x2) != 0; + return mmapFaultHandler(currentThread(), faultAddress, forWrite); +} + uint64 isrDispatch(Registers* regs) { + /* A ring-3 (user-mode) page fault gets one real chance at recovery + before falling through to the unconditional panic below: an + instruction inside SOME OTHER thread's buggy user program should + never be able to take the whole machine down with it, exactly like + a real kernel's own per-process fault isolation -- this kernel's + flat, single-shared-address-space design (see paging.c's own file + comments) cannot isolate a bad ACCESS the way separate page tables + would, but it can still isolate the CONSEQUENCE, by killing only the + one thread that caused it. A kernel-mode fault (ring 0, cs&3==0 -- + this kernel's own bug, not a user program's) still panics + unconditionally below, exactly as before this existed. */ + if (regs->intNo == 14 && (regs->cs & 3) == 3) { + if (!tryResolveUserPageFault(regs)) + sigRaiseInfo(currentThreadId(), sigSegv, siCodeKernel, 0, 0); + return deliverPendingSignals((uint64)regs); + } + if (regs->intNo < 32) panicOnException(regs); - if (regs->intNo == 128) - return syscallDispatch(regs); + uint64 nextRsp; - uint8 irq = (uint8)(regs->intNo - 32); - uint64 nextRsp = (uint64)regs; + if (regs->intNo == 128) { + nextRsp = syscallDispatch(regs); + } else { + uint8 irq = (uint8)(regs->intNo - 32); + nextRsp = (uint64)regs; - if (irq == 0) { - pitTick(); - nextRsp = schedule((uint64)regs); - } else if (irq == 1) { - keyboardHandler(); + if (irq == 0) { + pitTick(); + nextRsp = schedule((uint64)regs); + } else if (irq == 1) { + keyboardHandler(); + } + + picSendEoi(irq); } - picSendEoi(irq); - return nextRsp; + return deliverPendingSignals(nextRsp); } diff --git a/src/jobctl.c b/src/jobctl.c new file mode 100644 index 0000000..9b04b4e --- /dev/null +++ b/src/jobctl.c @@ -0,0 +1,429 @@ +#include "jobctl.h" +#include "sched.h" +#include "vfs.h" +#include "fs.h" +#include "elf.h" +#include "paging.h" +#include "keyboard.h" +#include "pipe.h" +#include "string.h" + +/* Matches kmain.c's userStackSize and syscall.c's execStackSize -- all + three name the exact same "one slot's worth of stack" constant (elf.h's + elfSlotStackOffset leaves 0x8000000 bytes below the stack top before the + next thing in the slot, brk, starts; nothing in this kernel has ever + needed anywhere near that much real stack). Kept as jobctl.c's own copy + rather than sharing a header-level define with the other two call sites, + the same "each caller of this pattern owns its own named copy" choice + kmain.c and syscall.c already made independently of each other. */ +#define jobctlStackSize 0x4000 + +/* Shared by both functions below: resolves `path` through the VFS, loads + it at its own fixed ELF-linked slot (elfLoad picks the address, same as + syscall.c's loadImageByPath), and spawns it as a brand new process group + leader -- the one behavioral difference from doSpawn (syscall.c), which + leaves a freshly spawned child in its parent's existing group instead. + Returns threadInvalidId, having created nothing, on any failure (missing + binary, no execute permission, bad ELF, stack mapping failure, or a full + thread table). */ +static uint32 jobctlSpawn(const char* path, const char* const* argv, int argc) +{ + VfsNode* node = vfsLookup(path); + if (!node) + return threadInvalidId; + /* Real EACCES-shaped refusal, same check and same reasoning as + syscall.c's loadImageByPath (sysExec/sysSpawn's own parallel loading + path) -- a file the CALLING thread's identity is not permitted to + exec (vfsPermitted, owner-vs-other-vs-root) can never be spawned this + way either. Collapsed into the same undifferentiated threadInvalidId + failure as every other reason jobctlSpawn already refuses (missing + binary, bad ELF, ...), matching this function's own existing doc + comment. */ + if (!vfsPermitted(vfsMode(node), vfsUid(node), vfsGid(node), currentThreadUid(), currentThreadGid(), fsModeOwnerExec)) + return threadInvalidId; + + const void* image = vfsFileData(node); + uint64 size = vfsSize(node); + uint64 entry = elfLoad(image, size); + if (entry == 0) + return threadInvalidId; + + uint64 stackTop = elfSlotStackTop(entry); + if (!pagingMapUserRangeFlags(stackTop - jobctlStackSize, jobctlStackSize, pageNoExecute)) + return threadInvalidId; + + uint64 initialRsp; + if (!elfBuildInitialStack(stackTop, jobctlStackSize, argv, argc, &initialRsp)) + return threadInvalidId; + + uint32 pid = threadCreateUser(path, entry, initialRsp); + if (pid == threadInvalidId) + return threadInvalidId; + + threadSetBrk(pid, elfSlotBrkStart(entry)); + + /* A real shell's fork()+setpgid(child, child): every job started + through jobctl gets its own fresh group regardless of the caller's + own pgid, so a later Ctrl-C aimed at THIS job (via + keyboardSetForegroundPgid below) can never also reach some earlier, + unrelated job that happened to share the caller's group. */ + threadSetPgid(pid, pid); + return pid; +} + +uint32 jobctlSpawnPiped(const char* path, const char* const* argv, int argc, + int stdinHandle, int stdoutHandle, uint32 joinPgid) +{ + uint32 pid = jobctlSpawn(path, argv, argc); + if (pid == threadInvalidId) + return threadInvalidId; + + /* jobctlSpawn above already made this its own new group leader -- + overridden here, after the fact, only for a pipeline's second-and- + later stage, which needs to join the FIRST stage's group instead so + the two are scoped together for signals (threadKillGroup/ + sigRaiseGroup, sched.h/signal.h) exactly like a real shell's + fork()+setpgid(child, leaderPid) for every stage after the first. */ + if (joinPgid != threadInvalidId) + threadSetPgid(pid, joinPgid); + + /* Bakes the requested pipe ends into fd 0/fd 1 of a thread that has not + run a single instruction yet (threadCreateUser, inside jobctlSpawn + above, already set its state Runnable -- see that function's own + field-by-field ordering -- but nothing yields between here and + jobctlSpawn's return, the same timing this file's own + threadSetBrk/threadSetPgid calls already rely on). fd 0's default + console reader (vfsFdInit, vfs.c) is released first so redirecting it + never leaks that reader slot -- exactly the resource reclaimOpenFds + (sched.c) already frees for a thread that dies still holding it, + just done here up front instead of at exit. fd 1 has no such + resource to release: its own default (fdConsole, no reader) owns + nothing. */ + FileDescriptor* fds = threadAt(pid)->fds; + if (stdinHandle >= 0) { + keyboardCloseReader(fds[0].pipeHandle); + vfsPipeAttachInto(fds, 0, stdinHandle, false); + } + if (stdoutHandle >= 0) + vfsPipeAttachInto(fds, 1, stdoutHandle, true); + + return pid; +} + +uint32 jobctlSpawnForeground(const char* path, const char* const* argv, int argc) +{ + uint32 pid = jobctlSpawnPiped(path, argv, argc, -1, -1, threadInvalidId); + if (pid == threadInvalidId) + return threadInvalidId; + + keyboardSetForegroundPgid(pid); + return pid; +} + +int64 jobctlWaitForeground(uint32 pid) +{ + /* Read BEFORE threadJoin, not after: this is the WAITER's own group + (the shell, still very much alive), not the job's -- reading it + after threadJoin would still be correct today since nothing else + changes the calling thread's own pgid in between, but reading it + first keeps this function correct even if that ever changes, and + costs nothing. */ + uint32 callerPgid = threadPgid(currentThreadId()); + + int64 code = threadJoin(pid); + + /* Reclaim the terminal for the CALLER's own group the instant the job + exits -- deliberately not "whatever pgid happened to own the + terminal right before jobctlSpawnForeground ran" (which could itself + be some earlier, now-dead job's stale id if this is the very first + foreground job the caller has ever run): a real shell always gets + its own prompt's terminal back when a foreground job quits, so a + later Ctrl-C typed at the prompt reaches the shell's NEXT job, not a + group nothing will ever answer for again. */ + keyboardSetForegroundPgid(callerPgid); + + return code; +} + +int64 jobctlWaitPipelineStages(const uint32* pids, int count) +{ + /* Same "read before threadJoin" reasoning as jobctlWaitForeground + above. */ + uint32 callerPgid = threadPgid(currentThreadId()); + + /* Grants the terminal to the pipeline's shared group (pids[0] IS that + group's id -- jobctlSpawnPiped's own joinPgid contract already made + every other pid a member of it) once, for the whole chain together, + rather than per-spawn -- a Ctrl-C typed while the pipeline runs + reaches every stage at once, exactly like jobctlSpawnForeground + already does for a single job. */ + keyboardSetForegroundPgid(pids[0]); + + /* threadJoin blocks on a SPECIFIC pid regardless of which stage + actually exits first -- joining every one, in this fixed order, + reaps all of them correctly no matter the real completion order, the + same way a real shell's own wait() for each of a pipeline's known + children would. */ + int64 lastCode = 0; + for (int i = 0; i < count; i++) + lastCode = threadJoin(pids[i]); + + keyboardSetForegroundPgid(callerPgid); + + return lastCode; +} + +bool jobctlRunBackground(const char* path, const char* const* argv, int argc, + uint32* outPid) +{ + uint32 pid = jobctlSpawnPiped(path, argv, argc, -1, -1, threadInvalidId); + if (pid == threadInvalidId) + return false; + + /* Deliberately no keyboardSetForegroundPgid call at all -- a + backgrounded job never owns the terminal, so a Ctrl-C typed while it + runs reaches whatever WAS already in the foreground instead (the + shell itself, if nothing else is running in the foreground), never + this job. */ + *outPid = pid; + return true; +} + +bool jobctlApplyRedirects(uint32 pid, const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend) +{ + FileDescriptor* fds = threadAt(pid)->fds; + + if (inPath) { + /* Same "release the default console reader before overwriting fd 0" + step jobctlSpawnPiped already does for a piped stdin above -- + keyboardCloseReader is a safe no-op on an already-closed/invalid + handle (readerAt's own bounds+used check, keyboard.c), so this + is correct even if fd 0 already holds something else entirely + (e.g. a pipe end from an earlier jobctlSpawnPiped call on this + same pid, in runPipeline's left-side case below). */ + keyboardCloseReader(fds[0].pipeHandle); + if (vfsOpenInto(fds, 0, inPath, oReadOnly) < 0) + return false; + } + + if (outPath) { + uint32 flags = oCreate | oWriteOnly | (outAppend ? oAppend : oTrunc); + if (vfsOpenInto(fds, 1, outPath, flags) < 0) + return false; + } + + if (errPath) { + uint32 flags = oCreate | oWriteOnly | (errAppend ? oAppend : oTrunc); + if (vfsOpenInto(fds, 2, errPath, flags) < 0) + return false; + } + + return true; +} + +uint32 jobctlSpawnForegroundRedirected(const char* path, const char* const* argv, int argc, + const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend) +{ + uint32 pid = jobctlSpawnPiped(path, argv, argc, -1, -1, threadInvalidId); + if (pid == threadInvalidId) + return threadInvalidId; + + if (!jobctlApplyRedirects(pid, inPath, outPath, outAppend, errPath, errAppend)) { + threadKill(pid); + threadJoin(pid); + return threadInvalidId; + } + + keyboardSetForegroundPgid(pid); + return pid; +} + +bool jobctlRunBackgroundRedirected(const char* path, const char* const* argv, int argc, + const char* inPath, const char* outPath, bool outAppend, + const char* errPath, bool errAppend, + uint32* outPid) +{ + uint32 pid = jobctlSpawnPiped(path, argv, argc, -1, -1, threadInvalidId); + if (pid == threadInvalidId) + return false; + + if (!jobctlApplyRedirects(pid, inPath, outPath, outAppend, errPath, errAppend)) { + threadKill(pid); + threadJoin(pid); + return false; + } + + *outPid = pid; + return true; +} + +/* Generalizes jobctlSpawnPiped's own fd-baking-before-first-instruction + machinery to also here-string-feed stdin: spawns `path` with fd 0 wired + to a brand-new pipe AND fd 1/pgid wired exactly as `stdoutHandle`/ + `joinPgid` ask (passed straight through to jobctlSpawnPiped, unlike the + fixed -1/threadInvalidId every earlier here-string caller in this file + used), then feeds `text` plus a trailing newline into the new pipe's + write end and closes it -- all BEFORE returning, so the child can never + observe the write end still open. Ordering matters: the write end is + only added/used/closed AFTER jobctlSpawnPiped has already attached the + read end (pipeAddReader, via vfsPipeAttachInto in vfs.c) -- pipe.c's + pipeReleaseIfUnused only frees a pipe's slot once BOTH readers and + writers reach 0, so closing the write end while the read end is still + attached (readers==1) leaves the pipe, and the data already sitting in + its ring buffer, alive for the child to actually read; closing it + BEFORE the read end attached (readers==0) would instead free the whole + slot out from under the still-buffered payload the instant this + function's own pipeCloseWrite ran, handing the child a dangling handle. + `text`'s own length plus one newline byte is always far under + pipeBufferSize (256, pipe.h) for any line this shell's own lineCapacity + (128, shell.c) could ever have typed it from, so the non-blocking + pipeWrite below can never see "buffer full" and needs no retry loop the + way a general-purpose writer would. Returns threadInvalidId, having + spawned and written nothing, on the same load/spawn failures as + jobctlSpawnPiped, or if the pipe table itself is exhausted + (vfsPipeCreateHandle). */ +uint32 jobctlSpawnPipedHereString(const char* path, const char* const* argv, int argc, + const char* text, int stdoutHandle, uint32 joinPgid) +{ + int handle = vfsPipeCreateHandle(); + if (handle < 0) + return threadInvalidId; + + uint32 pid = jobctlSpawnPiped(path, argv, argc, handle, stdoutHandle, joinPgid); + if (pid == threadInvalidId) { + pipeDestroyIfUnattached(handle); + return threadInvalidId; + } + + pipeAddWriter(handle); + uint64 length = strlen(text); + if (length > 0) + pipeWrite(handle, text, length); + pipeWrite(handle, "\n", 1); + pipeCloseWrite(handle); + + return pid; +} + +uint32 jobctlSpawnForegroundHereString(const char* path, const char* const* argv, int argc, + const char* text, const char* outPath, bool outAppend, + const char* errPath, bool errAppend) +{ + uint32 pid = jobctlSpawnPipedHereString(path, argv, argc, text, -1, threadInvalidId); + if (pid == threadInvalidId) + return threadInvalidId; + + /* inPath is always nullptr here: fd 0 is already the here-string pipe + jobctlSpawnPipedHereString just wired up, exactly like a piped + stdin's slot in runPipeline (shell.c) is never ALSO given a file + redirect on the same fd. */ + if (!jobctlApplyRedirects(pid, nullptr, outPath, outAppend, errPath, errAppend)) { + threadKill(pid); + threadJoin(pid); + return threadInvalidId; + } + + keyboardSetForegroundPgid(pid); + return pid; +} + +bool jobctlRunBackgroundHereString(const char* path, const char* const* argv, int argc, + const char* text, const char* outPath, bool outAppend, + const char* errPath, bool errAppend, + uint32* outPid) +{ + uint32 pid = jobctlSpawnPipedHereString(path, argv, argc, text, -1, threadInvalidId); + if (pid == threadInvalidId) + return false; + + if (!jobctlApplyRedirects(pid, nullptr, outPath, outAppend, errPath, errAppend)) { + threadKill(pid); + threadJoin(pid); + return false; + } + + *outPid = pid; + return true; +} + +/* The mirror image of jobctlSpawnPipedHereString above: that one feeds a + fixed string INTO a freshly spawned child's stdin (the shell is the + writer), this one reads a freshly spawned child's stdout back OUT as a + string (the shell is the reader) -- shell.c's new `$(cmd)` command + substitution. Attaches this thread as the pipe's reader (pipeAddReader) + BEFORE jobctlSpawnPiped ever attaches the child as its writer, the exact + opposite order of jobctlSpawnPipedHereString's own writer-after-reader + ordering but for the same reason: whichever end never gets attached at + all must not let pipeCloseRead/pipeCloseWrite release the slot out from + under the other end while it is still in use. stdin is left at its plain + default (whatever fd 0 already is for this kernel thread, exactly like + jobctlRunBackground's own unpiped stdin) -- an honestly-scoped limitation + matching runPipelineStages' "no stage may be a builtin": a substituted + command that genuinely needs to read stdin itself has nothing useful + wired to it here, since command substitution has no argv-supplied input + of its own to give it. + Joins the child (threadJoin) BEFORE ever reading a single byte, then + drains whatever it wrote with plain non-blocking pipeRead calls -- the + opposite order of "read until EOF, then join" would deadlock in this + kernel every time: reclaimOpenFds (sched.c), which closes a dead + thread's own fd 1 and so is what actually makes pipeRead ever report + real end-of-file (writers reaching 0), runs ONLY from inside threadReap + (threadJoin's own last step), never from threadExit/threadKill + themselves -- a thread that has already died but not yet been reaped + still counts, from the pipe's own point of view, as holding fd 1 open. + Reading first would therefore block forever waiting for an EOF that can + only ever arrive AFTER the very threadJoin call that would come next. + Joining first instead means the child's own write(s) must already have + completed (and fit in the pipe's ring buffer without blocking on free + space, exactly like jobctlSpawnPipedHereString's own here-string write + already assumes for the identical pipeBufferSize=256 constant) by the + time it actually exits -- an honestly-scoped limitation this function + shares with that one: a substituted command whose own combined stdout + exceeds pipeBufferSize bytes would block on a full buffer waiting for a + reader that will not start draining until this function's own + threadJoin below returns, which itself requires the write to unblock + first -- the same ordering deadlock in the opposite direction, avoided + here simply by staying within the same generous margin every other + pipeBufferSize-sized fixed buffer in this kernel already relies on. + Copies at most outCap-1 bytes into outBuf (NUL-terminated); any further + buffered bytes past that are still drained (into a small scratch + buffer, discarded) rather than left sitting in the pipe. *outStatus + receives the child's real exit code. Returns false, touching neither + outBuf nor *outStatus, on the same load/spawn failures as + jobctlSpawnPiped, or if the pipe table itself is exhausted + (vfsPipeCreateHandle). */ +bool jobctlCaptureOutput(const char* path, const char* const* argv, int argc, + char* outBuf, int outCap, int64* outStatus) +{ + int handle = vfsPipeCreateHandle(); + if (handle < 0) + return false; + + pipeAddReader(handle); + + uint32 pid = jobctlSpawnPiped(path, argv, argc, -1, handle, threadInvalidId); + if (pid == threadInvalidId) { + pipeCloseRead(handle); + return false; + } + + *outStatus = threadJoin(pid); + + int used = 0; + char scratch[64]; + for (;;) { + int64 n = (used < outCap - 1) + ? pipeRead(handle, outBuf + used, (uint64)(outCap - 1 - used)) + : pipeRead(handle, scratch, sizeof(scratch)); + if (n <= 0) + break; + if (used < outCap - 1) + used += (int)n; + } + outBuf[used] = 0; + + pipeCloseRead(handle); + return true; +} diff --git a/src/keyboard.c b/src/keyboard.c index 7a946d7..b937a22 100644 --- a/src/keyboard.c +++ b/src/keyboard.c @@ -1,8 +1,26 @@ #include "keyboard.h" #include "ports.h" +#include "ring.h" +#include "sched.h" +#include "signal.h" #define keyboardData 0x60 +/* Left-Ctrl's own PS/2 Set 1 make/break scancodes -- exactly the same + convention keyboardHandleScancode already had for Shift (0x2A/0x36 make, + 0xAA/0xB6 break): held state tracked in a single bool, never forwarded + to scancodeLower/scancodeUpper (there is no printable character for a + modifier key on its own). */ +#define scancodeLeftCtrlMake 0x1D +#define scancodeLeftCtrlBreak 0x9D + +static bool ctrlHeld; + +/* Real tcsetpgrp(3)'s target -- see keyboard.h's own doc comment for why 0 + (keyboardInit's initial value) safely means "nobody yet" with no extra + sentinel needed. */ +static uint32 foregroundPgid; + static const char scancodeLower[128] = { 0, 27, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', '\b', '\t', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\n', @@ -19,32 +37,138 @@ static const char scancodeUpper[128] = { '*', 0, ' ', 0 }; -#define keyBufferSize 128 -static volatile char keyBuffer[keyBufferSize]; -static volatile uint32 bufferHead; -static volatile uint32 bufferTail; static bool shiftHeld; +#define consoleReaderBufferSize 32 + +typedef struct ConsoleReader { + RingBuffer ring; + uint8 backing[consoleReaderBufferSize]; + bool used; +} ConsoleReader; + +static ConsoleReader consoleReaders[maxConsoleReaders]; + void keyboardInit(void) { - bufferHead = 0; - bufferTail = 0; shiftHeld = false; + ctrlHeld = false; + foregroundPgid = 0; + for (int i = 0; i < maxConsoleReaders; i++) + consoleReaders[i].used = false; } -static void pushChar(char c) +void keyboardSetForegroundPgid(uint32 pgid) { - uint32 next = (bufferHead + 1) % keyBufferSize; - if (next != bufferTail) { - keyBuffer[bufferHead] = c; - bufferHead = next; + foregroundPgid = pgid; +} + +uint32 keyboardForegroundPgid(void) +{ + return foregroundPgid; +} + +int keyboardOpenReader(void) +{ + for (int i = 0; i < maxConsoleReaders; i++) { + if (!consoleReaders[i].used) { + ringInit(&consoleReaders[i].ring, consoleReaders[i].backing, consoleReaderBufferSize); + consoleReaders[i].used = true; + return i; + } + } + return consoleReaderInvalid; +} + +static ConsoleReader* readerAt(int handle) +{ + if (handle < 0 || handle >= maxConsoleReaders) + return nullptr; + if (!consoleReaders[handle].used) + return nullptr; + return &consoleReaders[handle]; +} + +void keyboardCloseReader(int handle) +{ + ConsoleReader* r = readerAt(handle); + if (!r) + return; + r->used = false; +} + +int64 keyboardReaderRead(int handle, void* buffer, uint64 length) +{ + ConsoleReader* r = readerAt(handle); + if (!r) + return -1; + if (length == 0) + return 0; + if (ringIsEmpty(&r->ring)) + return -1; + + usize got = ringRead(&r->ring, (uint8*)buffer, (usize)length); + return (int64)got; +} + +int64 keyboardReaderReadBlocking(int handle, void* buffer, uint64 length) +{ + ConsoleReader* r = readerAt(handle); + if (!r) + return -1; + + for (;;) { + int64 result = keyboardReaderRead(handle, buffer, length); + if (result != -1) + return result; + + schedBlockCurrent((const void*)r); + halt(); } } -void keyboardHandler(void) +const void* keyboardReaderChannel(int handle) { - uint8 scancode = inb(keyboardData); + return (const void*)readerAt(handle); +} +bool keyboardReaderHasData(int handle) +{ + ConsoleReader* r = readerAt(handle); + if (!r) + return false; + return !ringIsEmpty(&r->ring); +} + +/* + * Real cooked-mode tty line discipline's INTR/SUSP characters (termios's + * VINTR/VSUSP, both Ctrl- by default): keyboardHandleScancode now + * tracks left-Ctrl as a THIRD modifier alongside Shift, and when it is held + * for a 'c'/'C' or 'z'/'Z' keypress, the resulting sigInt/sigTstp is + * delivered to the WHOLE foreground process group (sigRaiseGroup, signal.h) + * -- keyboardSetForegroundPgid's target, real tcsetpgrp(3)'s own job -- + * instead of the raw byte ever reaching any consoleReaders fan-out below. + * This is the scope real Ctrl-C actually has: every process in the + * foreground job, not "whichever process happens to be reading fd 0 right + * now" -- a BACKGROUND job (a different pgid, one keyboardSetForegroundPgid + * was never told about) must never be disturbed by a keypress aimed at + * whatever IS currently in the foreground, the same isolation a real shell's + * job control gives. kmain.c's pgroupfg.c/pgroupbg.c is the live ring-3 + * proof: pgroupfg (foreground, no handler installed) actually dies to the + * injected Ctrl-C's default-Terminate disposition, while pgroupbg + * (deliberately left in a DIFFERENT group) proves itself still alive and + * responsive afterwards. + */ +void keyboardHandleScancode(uint8 scancode) +{ + if (scancode == scancodeLeftCtrlMake) { + ctrlHeld = true; + return; + } + if (scancode == scancodeLeftCtrlBreak) { + ctrlHeld = false; + return; + } if (scancode == 0x2A || scancode == 0x36) { shiftHeld = true; return; @@ -57,21 +181,39 @@ void keyboardHandler(void) return; char c = shiftHeld ? scancodeUpper[scancode] : scancodeLower[scancode]; - if (c) - pushChar(c); + if (!c) + return; + + /* Consumed here, never forwarded as data below -- exactly like a real + cooked-mode terminal driver never hands a reading program the raw + 0x03/0x1A byte either. siCodeKernel/0: no sending PROCESS raised + this, the keyboard itself did, the same "kernel, not a process, is + the source" reasoning schedule()'s own SIGALRM raise already uses + for siCodeKernel. Harmless no-op (sigRaiseGroup's own contract) if + foregroundPgid currently names no live group at all. */ + if (ctrlHeld && (c == 'c' || c == 'C')) { + sigRaiseGroup(foregroundPgid, sigInt, siCodeKernel, 0); + return; + } + if (ctrlHeld && (c == 'z' || c == 'Z')) { + sigRaiseGroup(foregroundPgid, sigTstp, siCodeKernel, 0); + return; + } + + for (int i = 0; i < maxConsoleReaders; i++) { + if (!consoleReaders[i].used) + continue; + ringPush(&consoleReaders[i].ring, (uint8)c); + /* Mirrors pipeWrite's wake-after-write: harmless no-op (schedWakeChannel's + own contract) if nobody is currently blocked on this particular + reader's channel. */ + schedWakeChannel((const void*)&consoleReaders[i]); + } + schedWakeChannel(schedPollChannel()); } -bool keyboardHasChar(void) +void keyboardHandler(void) { - return bufferHead != bufferTail; -} - -char keyboardReadChar(void) -{ - while (bufferHead == bufferTail) - __asm__ volatile("sti; hlt"); - - char c = keyBuffer[bufferTail]; - bufferTail = (bufferTail + 1) % keyBufferSize; - return c; + uint8 scancode = inb(keyboardData); + keyboardHandleScancode(scancode); } diff --git a/src/keyboardTest.c b/src/keyboardTest.c new file mode 100644 index 0000000..065bb91 --- /dev/null +++ b/src/keyboardTest.c @@ -0,0 +1,216 @@ +#include "ktest.h" +#include "keyboard.h" +#include "string.h" + +/* + * Coverage for keyboard.c's per-reader console input queues (keyboard.h) -- + * the fan-out/blocking-channel machinery added this wave to close the gap + * called out at the end of the previous one: a single global keyboard + * buffer shared by every consumer, with no per-process queueing and no + * scheduler-visible blocking. What is pure state machine (open/close, + * scancode decoding, fan-out to N independent queues, exhaustion/reuse) + * lives here, driven directly through keyboardHandleScancode -- the same + * seam the live PS/2 IRQ handler (keyboardHandler) calls into, just fed a + * synthetic scancode instead of a real inb() -- exactly the way + * pipeShareTest.c drives pipe.c's state machine without a live scheduler. + * The genuine cross-process park/wake handshake (real blocking read() on + * fd 0 from two independent ring-3 processes, woken by a real keystroke) + * only runs for real at boot (kmain.c's consolereader1/consolereader2 demo, + * needles "consolereader1: got byte" / "consolereader2: got byte" in + * tools/ci-test.sh), matching how every other blocking-I/O subsystem in + * this kernel splits its test coverage. + * + * Scancode reference (set 1, US QWERTY, keyboard.c's own tables): 0x25 is + * 'k'/'K', 0x2A/0xAA are left-shift make/break. + */ +#define scancodeK 0x25 +#define scancodeLeftShiftMake 0x2A +#define scancodeLeftShiftBreak 0xAA + +KTEST(keyboardOpenReaderReturnsDistinctHandlesAndEmptyReadWouldBlock) +{ + int a = keyboardOpenReader(); + int b = keyboardOpenReader(); + KT_NE(t, a, consoleReaderInvalid); + KT_NE(t, b, consoleReaderInvalid); + KT_NE(t, a, b); + + char buffer[4]; + KT_EQ(t, keyboardReaderRead(a, buffer, sizeof(buffer)), -1); + KT_EQ(t, keyboardReaderRead(b, buffer, sizeof(buffer)), -1); + + keyboardCloseReader(a); + keyboardCloseReader(b); +} + +KTEST(keyboardReaderReadRejectsInvalidHandlesAndZeroLength) +{ + KT_EQ(t, keyboardReaderRead(consoleReaderInvalid, nullptr, 4), -1); + KT_EQ(t, keyboardReaderRead(9999, nullptr, 4), -1); + + int h = keyboardOpenReader(); + KT_NE(t, h, consoleReaderInvalid); + KT_EQ(t, keyboardReaderRead(h, nullptr, 0), 0); + keyboardCloseReader(h); + + /* Once closed, the handle is dead -- not silently reusable by the + caller who just gave it up. */ + KT_EQ(t, keyboardReaderRead(h, nullptr, 0), -1); +} + +KTEST(keyboardHandleScancodeBroadcastsToEveryOpenReaderIndependently) +{ + int a = keyboardOpenReader(); + int b = keyboardOpenReader(); + KT_NE(t, a, consoleReaderInvalid); + KT_NE(t, b, consoleReaderInvalid); + + keyboardHandleScancode(scancodeK); + + char ca = 0, cb = 0; + KT_EQ(t, keyboardReaderRead(a, &ca, 1), 1); + KT_EQ(t, keyboardReaderRead(b, &cb, 1), 1); + KT_EQ(t, ca, 'k'); + KT_EQ(t, cb, 'k'); + + /* Each reader's copy is independent: draining a's queue must not touch + b's, and vice versa -- proven above by both still having their own + byte to read rather than one starving the other. Both are empty now. */ + KT_EQ(t, keyboardReaderRead(a, &ca, 1), -1); + KT_EQ(t, keyboardReaderRead(b, &cb, 1), -1); + + keyboardCloseReader(a); + keyboardCloseReader(b); +} + +KTEST(keyboardHandleScancodeAppliesShiftMakeAndBreak) +{ + int h = keyboardOpenReader(); + KT_NE(t, h, consoleReaderInvalid); + + keyboardHandleScancode(scancodeK); + char c = 0; + KT_EQ(t, keyboardReaderRead(h, &c, 1), 1); + KT_EQ(t, c, 'k'); + + keyboardHandleScancode(scancodeLeftShiftMake); + keyboardHandleScancode(scancodeK); + KT_EQ(t, keyboardReaderRead(h, &c, 1), 1); + KT_EQ(t, c, 'K'); + + keyboardHandleScancode(scancodeLeftShiftBreak); + keyboardHandleScancode(scancodeK); + KT_EQ(t, keyboardReaderRead(h, &c, 1), 1); + KT_EQ(t, c, 'k'); + + keyboardCloseReader(h); +} + +KTEST(keyboardHandleScancodeIgnoresKeyReleaseAndUnmappedCodes) +{ + int h = keyboardOpenReader(); + KT_NE(t, h, consoleReaderInvalid); + + /* Key-release (high bit set) for 'k' must queue nothing. */ + keyboardHandleScancode(scancodeK | 0x80); + char c; + KT_EQ(t, keyboardReaderRead(h, &c, 1), -1); + + /* Scancode 0 maps to 0 (no character) in both tables. */ + keyboardHandleScancode(0); + KT_EQ(t, keyboardReaderRead(h, &c, 1), -1); + + keyboardCloseReader(h); +} + +KTEST(keyboardReaderReadDeliversQueuedBytesInFifoOrderAcrossPartialReads) +{ + int h = keyboardOpenReader(); + KT_NE(t, h, consoleReaderInvalid); + + /* "kkk" via three separate scancode events, like three real keystrokes. */ + keyboardHandleScancode(scancodeK); + keyboardHandleScancode(scancodeK); + keyboardHandleScancode(scancodeK); + + char buffer[8]; + memset(buffer, 0, sizeof(buffer)); + + /* Ask for only 2 of the 3 queued bytes -- must not block or fail, and + must leave the third for the next call, same partial-transfer + contract ring.h's ringRead already documents. */ + int64 got = keyboardReaderRead(h, buffer, 2); + KT_EQ(t, got, 2); + KT_EQ(t, buffer[0], 'k'); + KT_EQ(t, buffer[1], 'k'); + + got = keyboardReaderRead(h, buffer, 8); + KT_EQ(t, got, 1); + KT_EQ(t, buffer[0], 'k'); + + KT_EQ(t, keyboardReaderRead(h, buffer, 1), -1); + + keyboardCloseReader(h); +} + +KTEST(keyboardReaderChannelIsNullForInvalidAndDistinctPerReader) +{ + KT_NULL(t, keyboardReaderChannel(consoleReaderInvalid)); + KT_NULL(t, keyboardReaderChannel(9999)); + + int a = keyboardOpenReader(); + int b = keyboardOpenReader(); + KT_NE(t, a, consoleReaderInvalid); + KT_NE(t, b, consoleReaderInvalid); + + const void* channelA = keyboardReaderChannel(a); + const void* channelB = keyboardReaderChannel(b); + KT_NOTNULL(t, channelA); + KT_NOTNULL(t, channelB); + KT_NE(t, (int64)(uint64)channelA, (int64)(uint64)channelB); + + /* Stable across repeated calls -- schedBlockCurrent/schedWakeChannel + depend on the same reader always producing the same identity. */ + KT_EQP(t, keyboardReaderChannel(a), channelA); + + /* A closed reader's channel must go back to null immediately -- a + syscall handler using this as its retry condition must never park a + thread on a channel nobody can ever wake again. */ + keyboardCloseReader(a); + KT_NULL(t, keyboardReaderChannel(a)); + + keyboardCloseReader(b); +} + +KTEST(keyboardOpenReaderExhaustionThenCloseFreesSlotForReuse) +{ + /* Does not assume the table starts fully empty -- by the time ktests + run, schedInit() has already given the idle thread (id 0) its own fd + 0 reader (vfs.c's vfsFdInit), same convention vfsFifoTest.c's own + pipe-table exhaustion test uses for the shared pipes[] table. */ + int handles[maxConsoleReaders]; + int madeCount = 0; + while (madeCount < maxConsoleReaders) { + int h = keyboardOpenReader(); + if (h == consoleReaderInvalid) + break; + handles[madeCount++] = h; + } + KT_GT(t, madeCount, 0); + + /* Table is now genuinely full: one more must fail cleanly. */ + KT_EQ(t, keyboardOpenReader(), consoleReaderInvalid); + + keyboardCloseReader(handles[0]); + int reused = keyboardOpenReader(); + KT_NE(t, reused, consoleReaderInvalid); + KT_EQ(t, reused, handles[0]); + + /* Leave the shared reader table exactly as free as it was found, so + later boot-time console users (the shell, and the + consolereader1/consolereader2 live demo) are never starved by this + test having run. */ + keyboardCloseReader(reused); + for (int i = 1; i < madeCount; i++) + keyboardCloseReader(handles[i]); +} diff --git a/src/klib.c b/src/klib.c new file mode 100644 index 0000000..f840c1d --- /dev/null +++ b/src/klib.c @@ -0,0 +1,362 @@ +#include "klib.h" +#include "string.h" + +/* ---- character classification -------------------------------------- */ + +bool isDigit(int c) { return c >= '0' && c <= '9'; } +bool isUpper(int c) { return c >= 'A' && c <= 'Z'; } +bool isLower(int c) { return c >= 'a' && c <= 'z'; } +bool isAlpha(int c) { return isUpper(c) || isLower(c); } +bool isAlnum(int c) { return isAlpha(c) || isDigit(c); } +bool isSpace(int c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'; } +bool isHexDigit(int c) { return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } + +int toUpper(int c) { return isLower(c) ? c - 'a' + 'A' : c; } +int toLower(int c) { return isUpper(c) ? c - 'A' + 'a' : c; } + +/* ---- string manipulation --------------------------------------------- */ + +char* strcpy(char* dest, const char* src) +{ + char* out = dest; + while ((*out++ = *src++) != 0) + ; + return dest; +} + +char* strncpy(char* dest, const char* src, usize count) +{ + usize i = 0; + for (; i < count && src[i]; i++) + dest[i] = src[i]; + for (; i < count; i++) + dest[i] = 0; + return dest; +} + +char* strcat(char* dest, const char* src) +{ + strcpy(dest + strlen(dest), src); + return dest; +} + +char* strncat(char* dest, const char* src, usize count) +{ + char* end = dest + strlen(dest); + usize i = 0; + for (; i < count && src[i]; i++) + end[i] = src[i]; + end[i] = 0; + return dest; +} + +char* strchr(const char* str, int c) +{ + for (;; str++) { + if (*str == (char)c) + return (char*)str; + if (*str == 0) + return nullptr; + } +} + +char* strrchr(const char* str, int c) +{ + const char* last = nullptr; + for (;; str++) { + if (*str == (char)c) + last = str; + if (*str == 0) + return (char*)last; + } +} + +char* strstr(const char* haystack, const char* needle) +{ + if (!*needle) + return (char*)haystack; + usize needleLen = strlen(needle); + for (; *haystack; haystack++) { + if (memcmp(haystack, needle, needleLen) == 0) + return (char*)haystack; + } + return nullptr; +} + +usize strspn(const char* str, const char* accept) +{ + usize count = 0; + for (; str[count]; count++) { + if (!strchr(accept, str[count])) + break; + } + return count; +} + +usize strcspn(const char* str, const char* reject) +{ + usize count = 0; + for (; str[count]; count++) { + if (strchr(reject, str[count])) + break; + } + return count; +} + +char* strTokR(char* str, const char* delim, char** savePtr) +{ + char* cursor = str ? str : *savePtr; + if (cursor == nullptr) + return nullptr; + + cursor += strspn(cursor, delim); + if (*cursor == 0) { + *savePtr = nullptr; + return nullptr; + } + + char* tokenEnd = cursor + strcspn(cursor, delim); + if (*tokenEnd) { + *tokenEnd = 0; + *savePtr = tokenEnd + 1; + } else { + *savePtr = nullptr; + } + return cursor; +} + +/* ---- number parsing ---------------------------------------------------- */ + +static int digitValue(int c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 10; + if (c >= 'A' && c <= 'Z') + return c - 'A' + 10; + return -1; +} + +uint64 strtoul(const char* str, char** endPtr, int base) +{ + const char* cursor = str; + while (isSpace((unsigned char)*cursor)) + cursor++; + + bool negative = false; + if (*cursor == '+' || *cursor == '-') { + negative = (*cursor == '-'); + cursor++; + } + + if ((base == 0 || base == 16) && cursor[0] == '0' && (cursor[1] == 'x' || cursor[1] == 'X')) { + cursor += 2; + base = 16; + } else if (base == 0 && cursor[0] == '0') { + base = 8; + } else if (base == 0) { + base = 10; + } + + uint64 value = 0; + const char* firstDigit = cursor; + for (;; cursor++) { + int digit = digitValue((unsigned char)*cursor); + if (digit < 0 || digit >= base) + break; + value = value * (uint64)base + (uint64)digit; + } + + if (endPtr) + *endPtr = (char*)(cursor == firstDigit ? str : cursor); + return negative ? (uint64)(-(int64)value) : value; +} + +int64 strtol(const char* str, char** endPtr, int base) +{ + /* strtoul already skips whitespace, consumes an optional sign and + applies it (via unsigned two's-complement negation) before we ever + see the result, so reinterpreting its bits as signed is correct + on its own -- negating again here would flip the sign twice. */ + return (int64)strtoul(str, endPtr, base); +} + +int32 atoi(const char* str) { return (int32)strtol(str, nullptr, 10); } +int64 atol(const char* str) { return strtol(str, nullptr, 10); } + +/* ---- formatted output --------------------------------------------------- */ + +typedef struct SnprintfSink { + char* buf; + usize size; /* capacity including the room for a nul terminator */ + usize written; /* total chars that would be written, unbounded */ +} SnprintfSink; + +static void sinkPutChar(SnprintfSink* sink, char c) +{ + if (sink->written + 1 < sink->size) + sink->buf[sink->written] = c; + sink->written++; +} + +static void sinkPutString(SnprintfSink* sink, const char* str) +{ + while (*str) + sinkPutChar(sink, *str++); +} + +static void sinkPutUnsigned(SnprintfSink* sink, uint64 value, uint32 base, bool upper, int minWidth, char pad) +{ + static const char* lower = "0123456789abcdef"; + static const char* higher = "0123456789ABCDEF"; + const char* digits = upper ? higher : lower; + char buffer[32]; + int length = 0; + + if (value == 0) + buffer[length++] = '0'; + while (value > 0) { + buffer[length++] = digits[value % base]; + value /= base; + } + while (length < minWidth && length < (int)sizeof(buffer)) + buffer[length++] = pad; + + while (length > 0) + sinkPutChar(sink, buffer[--length]); +} + +static void sinkPutSigned(SnprintfSink* sink, int64 value, int minWidth, char pad) +{ + if (value < 0) { + sinkPutChar(sink, '-'); + sinkPutUnsigned(sink, (uint64)(-value), 10, false, minWidth > 0 ? minWidth - 1 : 0, pad); + } else { + sinkPutUnsigned(sink, (uint64)value, 10, false, minWidth, pad); + } +} + +int vsnprintf(char* buf, usize size, const char* format, va_list args) +{ + SnprintfSink sink = { buf, size, 0 }; + + for (usize i = 0; format[i]; i++) { + if (format[i] != '%') { + sinkPutChar(&sink, format[i]); + continue; + } + + i++; + int width = 0; + char pad = ' '; + if (format[i] == '0') { + pad = '0'; + i++; + } + while (format[i] >= '0' && format[i] <= '9') { + width = width * 10 + (format[i] - '0'); + i++; + } + + int longCount = 0; + while (format[i] == 'l') { + longCount++; + i++; + } + + switch (format[i]) { + case 'c': + sinkPutChar(&sink, (char)va_arg(args, int)); + break; + case 's': + sinkPutString(&sink, va_arg(args, const char*)); + break; + case 'd': + case 'i': + sinkPutSigned(&sink, longCount ? va_arg(args, int64) : (int64)va_arg(args, int), width, pad); + break; + case 'u': + sinkPutUnsigned(&sink, longCount ? va_arg(args, uint64) : (uint64)va_arg(args, uint32), 10, false, width, pad); + break; + case 'x': + sinkPutUnsigned(&sink, longCount ? va_arg(args, uint64) : (uint64)va_arg(args, uint32), 16, false, width, pad); + break; + case 'X': + sinkPutUnsigned(&sink, longCount ? va_arg(args, uint64) : (uint64)va_arg(args, uint32), 16, true, width, pad); + break; + case 'p': + sinkPutString(&sink, "0x"); + sinkPutUnsigned(&sink, (uint64)va_arg(args, void*), 16, false, 16, '0'); + break; + case '%': + sinkPutChar(&sink, '%'); + break; + default: + sinkPutChar(&sink, '%'); + if (format[i]) + sinkPutChar(&sink, format[i]); + break; + } + } + + if (size > 0) + buf[sink.written < size ? sink.written : size - 1] = 0; + return (int)sink.written; +} + +int snprintf(char* buf, usize size, const char* format, ...) +{ + va_list args; + va_start(args, format); + int result = vsnprintf(buf, size, format, args); + va_end(args); + return result; +} + +/* ---- generic sorting / searching ---------------------------------------- */ + +static void rawSwap(uint8* a, uint8* b, usize size) +{ + for (usize i = 0; i < size; i++) { + uint8 tmp = a[i]; + a[i] = b[i]; + b[i] = tmp; + } +} + +/* Insertion sort: O(n^2) but allocation-free and simple to prove correct; + fine for the small, bounded arrays a freestanding kernel actually sorts + (dirents in a directory, a handful of scheduler entries, ...). */ +void qsort(void* base, usize count, usize size, int (*cmp)(const void*, const void*)) +{ + uint8* items = (uint8*)base; + for (usize i = 1; i < count; i++) { + usize j = i; + while (j > 0 && cmp(items + j * size, items + (j - 1) * size) < 0) { + rawSwap(items + j * size, items + (j - 1) * size, size); + j--; + } + } +} + +void* bsearch(const void* key, const void* base, usize count, usize size, + int (*cmp)(const void*, const void*)) +{ + const uint8* items = (const uint8*)base; + usize low = 0; + usize high = count; + + while (low < high) { + usize mid = low + (high - low) / 2; + const uint8* candidate = items + mid * size; + int result = cmp(key, candidate); + if (result == 0) + return (void*)candidate; + if (result < 0) + high = mid; + else + low = mid + 1; + } + return nullptr; +} diff --git a/src/klibTest.c b/src/klibTest.c new file mode 100644 index 0000000..2a83e3c --- /dev/null +++ b/src/klibTest.c @@ -0,0 +1,166 @@ +#include "ktest.h" +#include "klib.h" +#include "string.h" + +KTEST(klibCtype) +{ + KT_TRUE(t, isDigit('0')); + KT_TRUE(t, isDigit('9')); + KT_FALSE(t, isDigit('a')); + KT_TRUE(t, isAlpha('a')); + KT_TRUE(t, isAlpha('Z')); + KT_FALSE(t, isAlpha('5')); + KT_TRUE(t, isAlnum('7')); + KT_TRUE(t, isAlnum('q')); + KT_TRUE(t, isSpace(' ')); + KT_TRUE(t, isSpace('\t')); + KT_FALSE(t, isSpace('x')); + KT_TRUE(t, isHexDigit('f')); + KT_TRUE(t, isHexDigit('F')); + KT_TRUE(t, isHexDigit('9')); + KT_FALSE(t, isHexDigit('g')); + KT_EQ(t, toUpper('a'), 'A'); + KT_EQ(t, toUpper('A'), 'A'); + KT_EQ(t, toLower('Z'), 'z'); + KT_EQ(t, toLower('5'), '5'); +} + +KTEST(klibStrCopyCat) +{ + char buf[32]; + strcpy(buf, "frost"); + KT_STREQ(t, buf, "frost"); + strcat(buf, "byte"); + KT_STREQ(t, buf, "frostbyte"); + + char nbuf[8]; + memset(nbuf, 'X', sizeof(nbuf)); + strncpy(nbuf, "hi", sizeof(nbuf)); + KT_EQ(t, nbuf[0], 'h'); + KT_EQ(t, nbuf[1], 'i'); + KT_EQ(t, nbuf[2], 0); + KT_EQ(t, nbuf[7], 0); /* strncpy pads the remainder with nul */ + + char abuf[16]; + strcpy(abuf, "ab"); + strncat(abuf, "cdefgh", 3); + KT_STREQ(t, abuf, "abcde"); +} + +KTEST(klibStrSearch) +{ + const char* s = "the quick brown fox"; + KT_EQP(t, strchr(s, 'q'), s + 4); + KT_NULL(t, strchr(s, 'Z')); + KT_EQP(t, strrchr(s, 'o'), s + 17); + KT_NOTNULL(t, strstr(s, "brown")); + KT_STREQ(t, strstr(s, "brown"), "brown fox"); + KT_NULL(t, strstr(s, "purple")); + KT_STREQ(t, strstr(s, ""), s); /* empty needle matches at start */ + + KT_EQ(t, strspn("aaabbbccc", "ab"), 6); + KT_EQ(t, strcspn("hello, world", ", "), 5); +} + +KTEST(klibStrTokR) +{ + char input[32]; + strcpy(input, "one,two,,four"); + char* save = nullptr; + + char* tok = strTokR(input, ",", &save); + KT_STREQ(t, tok, "one"); + tok = strTokR(nullptr, ",", &save); + KT_STREQ(t, tok, "two"); + tok = strTokR(nullptr, ",", &save); + KT_STREQ(t, tok, "four"); /* empty field between two commas is skipped */ + tok = strTokR(nullptr, ",", &save); + KT_NULL(t, tok); +} + +KTEST(klibNumberParsing) +{ + KT_EQ(t, atoi("42"), 42); + KT_EQ(t, atoi("-42"), -42); + KT_EQ(t, atoi(" 7"), 7); + KT_EQ(t, atol("123456789012"), 123456789012LL); + + char* end = nullptr; + KT_EQ(t, strtol("2026", &end, 10), 2026); + KT_EQ(t, *end, 0); + + KT_EQ(t, strtol("-15", nullptr, 10), -15); + KT_EQ(t, strtol("ff", nullptr, 16), 255); + KT_EQ(t, strtol("0x1A", nullptr, 0), 26); + KT_EQ(t, strtol("010", nullptr, 0), 8); /* auto-detected octal */ + KT_EQ(t, strtol("42", nullptr, 0), 42); + + KT_EQU(t, strtoul("ffff", nullptr, 16), 65535); + + end = nullptr; + strtol("nope", &end, 10); + KT_STREQ(t, end, "nope"); /* no digits consumed: endptr == start */ +} + +static int intCmp(const void* a, const void* b) +{ + int ia = *(const int*)a; + int ib = *(const int*)b; + return (ia > ib) - (ia < ib); +} + +KTEST(klibQsortBsearch) +{ + int values[8] = { 5, 3, 8, 1, 9, 2, 7, 4 }; + qsort(values, 8, sizeof(int), intCmp); + for (int i = 0; i < 7; i++) + KT_LE(t, values[i], values[i + 1]); + KT_EQ(t, values[0], 1); + KT_EQ(t, values[7], 9); + + int key = 7; + int* found = (int*)bsearch(&key, values, 8, sizeof(int), intCmp); + KT_NOTNULL(t, found); + KT_EQ(t, *found, 7); + + int missing = 42; + KT_NULL(t, bsearch(&missing, values, 8, sizeof(int), intCmp)); +} + +KTEST(klibSnprintf) +{ + char buf[64]; + + int n = snprintf(buf, sizeof(buf), "%s=%d", "answer", 42); + KT_STREQ(t, buf, "answer=42"); + KT_EQ(t, n, 9); + + snprintf(buf, sizeof(buf), "%05d", 42); + KT_STREQ(t, buf, "00042"); + + snprintf(buf, sizeof(buf), "%05d", -42); + KT_STREQ(t, buf, "-0042"); + + snprintf(buf, sizeof(buf), "%x-%X", 255, 255); + KT_STREQ(t, buf, "ff-FF"); + + snprintf(buf, sizeof(buf), "%lu", 9876543210ULL); + KT_STREQ(t, buf, "9876543210"); + + snprintf(buf, sizeof(buf), "%c%c%c", 'a', 'b', 'c'); + KT_STREQ(t, buf, "abc"); + + snprintf(buf, sizeof(buf), "100%%"); + KT_STREQ(t, buf, "100%"); + + /* truncation: buffer smaller than the formatted result still nul + terminates, and reports the length that would have been written */ + char small[4]; + int wanted = snprintf(small, sizeof(small), "hello"); + KT_STREQ(t, small, "hel"); + KT_EQ(t, wanted, 5); + + /* size == 0 must not touch the buffer at all, and still reports length */ + int wantedZero = snprintf(nullptr, 0, "hello"); + KT_EQ(t, wantedZero, 5); +} diff --git a/src/kmain.c b/src/kmain.c index a2ef552..be96dc4 100644 --- a/src/kmain.c +++ b/src/kmain.c @@ -15,9 +15,16 @@ #include "initrd.h" #include "vfs.h" #include "elf.h" +#include "ktest.h" +#include "pipe.h" +#include "klib.h" +#include "signal.h" +#include "ports.h" +#include "ata.h" +#include "sfs.h" #define multibootBootMagic 0x36D76289 -#define heapSize (8 * 1024 * 1024) +#define heapSize (48 * 1024 * 1024) #define timerFrequency 100 #define userStackSize 0x4000 @@ -37,14 +44,20 @@ static void shellThread(void) shellRun(); } +/* Exposed at file scope (rather than local to workerA/workerB) so + schedPriorityVerifierThread can read them back and turn "the scheduler + favors higher weight" into an automated, gate-checked assertion instead + of something a human has to eyeball in the boot log. */ +static volatile uint64 workerAIterations; +static volatile uint64 workerBIterations; + static void workerA(void) { - uint64 iterations = 0; uint64 nextLog = 50; for (;;) { - iterations++; + workerAIterations++; if (pitTicks() >= nextLog) { - kprintf("[workerA] tick=%lu iterations=%lu\n", pitTicks(), iterations); + kprintf("[workerA] tick=%lu iterations=%lu\n", pitTicks(), workerAIterations); nextLog += 100; } } @@ -52,17 +65,179 @@ static void workerA(void) static void workerB(void) { - uint64 iterations = 0; uint64 nextLog = 90; for (;;) { - iterations++; + workerBIterations++; if (pitTicks() >= nextLog) { - kprintf("[workerB] tick=%lu iterations=%lu\n", pitTicks(), iterations); + kprintf("[workerB] tick=%lu iterations=%lu\n", pitTicks(), workerBIterations); nextLog += 100; } } } +#define schedPriorityCheckDelayMs 15000 +#define schedPriorityWeightRatio 3 + +static void schedPriorityVerifierThread(void) +{ + /* threadSleep() only flips this thread's own state to Sleeping; unlike + the sysSleep syscall path (syscall.c), which forces a reschedule + synchronously in the same interrupt frame right after calling it, + a plain kernel thread body must yield for itself or it simply falls + through to the next line on its own timeslice, sleep or not. Loop on + halt() until schedule()'s "wake sleeping threads whose time has come" + pass (sched.c) actually flips the state back, the same wait pattern + pipeReadBlocking/pipeWriteBlocking (pipe.c) use for wait-queue waits: + any spurious wake (e.g. a keyboard IRQ, which never calls schedule() + and so never touches this state) just loops back into another + halt() instead of proceeding early. */ + threadSleep(schedPriorityCheckDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + uint64 a = workerAIterations; + uint64 b = workerBIterations; + /* workerA runs at schedPriorityWeightRatio times workerB's weight, so + over any sufficiently long stretch where both are always runnable + (true here: both are pure CPU hogs, never sleeping or blocking) the + CFS-lite scheduler should give workerA that many times as many turns. + Allow a wide [1.5x, 6x] band rather than pinning to exactly 3x: this + is a real preemptive scheduler racing against wall-clock QEMU + performance, not a deterministic simulation, so the achieved ratio + will vary run to run - the assertion that matters is "meaningfully + and consistently favors the higher-weight thread", not an exact + number. */ + bool ok = b > 0 && a > b + b / 2 && a < b * (schedPriorityWeightRatio * 2); + kprintf("[sched] priority check: workerA=%lu workerB=%lu (weight ratio %u) -> %s\n", + a, b, schedPriorityWeightRatio, ok ? "sched-priority-ok" : "sched-priority-FAIL"); + + for (;;) + halt(); +} + +#define pipeDemoMessages 10 + +static int pipeDemoHandle = -1; + +static void pipeProducerThread(void) +{ + for (int i = 0; i < pipeDemoMessages; i++) { + char line[32]; + int len = snprintf(line, sizeof(line), "msg %d\n", i); + + uint64 offset = 0; + while (offset < (uint64)len) { + int64 n = pipeWriteBlocking(pipeDemoHandle, line + offset, (uint64)len - offset); + if (n <= 0) + break; + offset += (uint64)n; + } + } + pipeCloseWrite(pipeDemoHandle); + + /* Kernel thread entries run atop a synthetic iret frame, not a normal + call stack: falling off the end here would pop garbage as the next + instruction pointer. Every kernel thread in this scheduler must park + itself forever instead of returning (see workerA/workerB). */ + for (;;) + halt(); +} + +static void pipeConsumerThread(void) +{ + char lineBuffer[32]; + uint64 lineLength = 0; + int received = 0; + + for (;;) { + char chunk[16]; + int64 n = pipeReadBlocking(pipeDemoHandle, chunk, sizeof(chunk)); + + if (n > 0) { + for (int64 i = 0; i < n; i++) { + char c = chunk[i]; + if (c == '\n') { + lineBuffer[lineLength] = 0; + kprintf("[pipe] consumer got: %s\n", lineBuffer); + received++; + lineLength = 0; + } else if (lineLength + 1 < sizeof(lineBuffer)) { + lineBuffer[lineLength++] = c; + } + } + } else { + /* 0 = true EOF, -1 = invalid handle: either way there is + nothing left to wait for. */ + break; + } + } + + pipeCloseRead(pipeDemoHandle); + kprintf("pipe demo: eof, count=%d\n", received); + + for (;;) + halt(); +} + +/* Demonstrates threadJoin/threadReap's real, live blocking path (sched.c, + sched.h) end to end -- the one path schedTest.c cannot exercise, since + ktestRunAll() runs before the scheduler is live and there is no second + thread to actually park on and be woken by. shortLivedWorkerThread does a + little busy-work then calls threadExit() directly (a kernel thread may + call it as a plain function -- no syscall needed, unlike a ring-3 + program) -- joinerThread almost certainly starts first (both are created + with default weight, and the worker has real work to do before it can + exit), so this also proves the schedBlockCurrent+halt() park actually + happens and schedWakeChannel actually wakes it, not just the fast "already + dead" path. */ +#define joinDemoBusyIterations 20000000ull +#define joinDemoExitCode 42ull + +static volatile uint32 joinDemoWorkerId = threadInvalidId; + +static void shortLivedWorkerThread(void) +{ + for (volatile uint64 i = 0; i < joinDemoBusyIterations; i++) + ; + threadExit(joinDemoExitCode); + + /* Marked Dead above; the CPU is still executing this thread's code + until the next timer tick preempts it into schedule(), which will + never pick a Dead thread again. Must park, not return (see + workerA/workerB). */ + for (;;) + halt(); +} + +static void joinProbeThread(void) +{ + for (;;) + halt(); +} + +static void joinerThread(void) +{ + int64 code = threadJoin(joinDemoWorkerId); + bool codeOk = (code == (int64)joinDemoExitCode); + + /* threadJoin already reaped the worker's slot -- prove it is genuinely + reusable, not just marked dead, by immediately creating a new thread + and checking it lands in that exact freed slot rather than growing + the table further. */ + uint32 reused = threadCreate("joinReuseProbe", joinProbeThread); + bool reuseOk = (reused == joinDemoWorkerId); + if (reused != threadInvalidId) + threadDiscard(reused); + + kprintf("[sched] join demo: worker pid=%u exitCode=%ld (expected %lu), " + "freed slot reused=%s -> %s\n", + joinDemoWorkerId, code, joinDemoExitCode, reuseOk ? "yes" : "no", + (codeOk && reuseOk) ? "join-reap-ok" : "join-reap-FAIL"); + + for (;;) + halt(); +} + static void runHeapTest(void) { kprintf("[heap] free-list allocator self-test:\n"); @@ -116,12 +291,12 @@ static void runPagingTest(void) kprintf("[paging] readback FAILED (value=%lx resolved=%lx)\n", *probe, resolved); } -static void spawnUser(const char* path, const char* name, uint64 stackTop, uint64 brkStart) +static uint32 spawnUser(const char* path, const char* name, uint64 stackTop, uint64 brkStart) { VfsNode* node = vfsLookup(path); if (!node) { kprintf("[user] '%s' not found in vfs\n", path); - return; + return threadInvalidId; } const void* image = vfsFileData(node); @@ -129,16 +304,1537 @@ static void spawnUser(const char* path, const char* name, uint64 stackTop, uint6 uint64 entry = elfLoad(image, size); if (entry == 0) { kprintf("[user] '%s' failed to load\n", path); - return; + return threadInvalidId; } - pagingMapUserRange(stackTop - userStackSize, userStackSize); - uint32 pid = threadCreateUser(name, entry, stackTop); + pagingMapUserRangeFlags(stackTop - userStackSize, userStackSize, pageNoExecute); + + /* Every boot-launched program gets a real, if minimal, argv: just its + own path, matching what a real OS would put in argv[0]. Nothing in + this kernel's boot sequence needs more than that -- programs wanting + specific arguments are spawned at runtime instead (doSpawn, + syscall.c), where the caller controls argv directly. */ + const char* argv[1] = { path }; + uint64 initialRsp; + if (!elfBuildInitialStack(stackTop, userStackSize, argv, 1, &initialRsp)) { + kprintf("[user] '%s' not started: argv did not fit its stack\n", path); + return threadInvalidId; + } + + uint32 pid = threadCreateUser(name, entry, initialRsp); + if (pid == threadInvalidId) { + kprintf("[user] '%s' not started: thread table full\n", path); + return threadInvalidId; + } if (brkStart) threadSetBrk(pid, brkStart); kprintf("[user] loaded '%s' pid=%u entry=%p stackTop=%p (%lu bytes)\n", name, pid, (void*)entry, (void*)stackTop, size); + return pid; +} + +/* proctest.c's very last action on its success path is userExec(), which + replaces its own image with exectarget.c's -- so the only way to observe + from OUTSIDE that process that exec() genuinely happened (rather than + proctest simply exiting cleanly on its own) is to threadJoin() the exact + pid kmain.c originally gave proctest and check that its eventual exit + code is exectarget's (7), not proctest's own success path (which never + returns 7 -- it either exits 1 on a self-detected FAIL or never exits at + all once exec succeeds). A mismatch or an exit code belonging to neither + program both surface as a plain FAIL below. */ +#define execDemoExitCode 7ull + +static uint32 execDemoPid = threadInvalidId; + +static void execReaperThread(void) +{ + if (execDemoPid == threadInvalidId) { + kprintf("[user] exec demo skipped: proctest never started\n"); + for (;;) + halt(); + } + + int64 code = threadJoin(execDemoPid); + bool ok = (code == (int64)execDemoExitCode); + kprintf("[user] exec demo: pid=%u final exitCode=%ld (expected %lu) -> %s\n", + execDemoPid, code, execDemoExitCode, ok ? "exec-pid-preserved-ok" : "exec-pid-preserved-FAIL"); + + for (;;) + halt(); +} + +/* Headless QEMU has no automated way to drive a real PS/2 keyboard device -- + ci-test.sh only captures the COM1 serial log, and scripts no QEMU monitor + "sendkey" command -- so this thread simulates exactly one keystroke the + same way a real IRQ1 handler would: by calling keyboardHandleScancode + (keyboard.h) directly with the scancode a real 'k' keypress generates. + consolereader1.c and consolereader2.c are both already parked in a real, + blocking userRead(0, ...) well before this fires (the sleep below gives + every boot-time thread, including both of them, at least one CFS-lite + timeslice first) -- keyboard.c's per-reader fan-out then delivers each of + them its own independent copy of the same byte, proving two entirely + unrelated ring-3 processes can share "the keyboard" without racing each + other for it, the gap the previous wave's own notes called out as the + next logical step after pipes. */ +#define consoleDemoScancodeK 0x25 +#define consoleInjectDelayMs 250 + +static void consoleInjectorThread(void) +{ + threadSleep(consoleInjectDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] injecting synthetic keypress 'k' for the console fan-out demo\n"); + keyboardHandleScancode(consoleDemoScancodeK); + + for (;;) + halt(); +} + +/* Same "headless QEMU cannot drive a real PS/2 device" reasoning as + consoleInjectorThread above, this time simulating a real Ctrl-C + keypress (left-Ctrl held, 'c' pressed and released, left-Ctrl released) + by calling keyboardHandleScancode with exactly the four scancodes a + physical keyboard would generate for it -- the live ring-3 proof that + sched.h's new Thread.pgid and keyboard.c's own new INTR interception + actually scope a terminal-generated signal to ONE process group + (pgroupfg.c, made the foreground below) and leave a completely + different one (pgroupbg.c, deliberately left as its own default group) + untouched. pgroupfgPid/pgroupbgPid are threadInvalidId if either + program failed to load (thread table full, missing from the ramdisk) -- + this thread degrades to a silent no-op rather than acting on a bogus id, + the same "skipped" pattern execReaperThread already uses for its own + demo. */ +#define scancodeLeftCtrlMake 0x1D +#define scancodeLeftCtrlBreak 0x9D +#define scancodeCPressMake 0x2E +#define scancodeCPressBreak 0xAE +#define pgroupInjectDelayMs 400 +#define pgroupSettleDelayMs 100 + +static uint32 pgroupfgPid = threadInvalidId; +static uint32 pgroupbgPid = threadInvalidId; + +static void pgroupInjectorThread(void) +{ + if (pgroupfgPid == threadInvalidId || pgroupbgPid == threadInvalidId) { + kprintf("[kbd] process-group demo skipped: pgroupfg/pgroupbg never started\n"); + for (;;) + halt(); + } + + threadSleep(pgroupInjectDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] injecting synthetic ctrl-c for the process-group demo\n"); + keyboardHandleScancode(scancodeLeftCtrlMake); + keyboardHandleScancode(scancodeCPressMake); + keyboardHandleScancode(scancodeCPressBreak); + keyboardHandleScancode(scancodeLeftCtrlBreak); + + /* Give the scheduler a real timeslice to actually deliver the + resulting sigInt to pgroupfg (early-wake + return-to-ring3, not + instantaneous) before this thread moves on to check anything. */ + threadSleep(pgroupSettleDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + int64 fgCode = threadJoin(pgroupfgPid); + bool fgOk = (fgCode == (int64)(128 + sigInt)); + kprintf("[user] pgroup demo: fg pid=%u final exitCode=%ld (expected 128+sigInt=%d) -> %s\n", + pgroupfgPid, fgCode, 128 + sigInt, fgOk ? "pgrouptest-fg-terminated-by-sigint-ok" : "FAIL"); + + /* Proves pgroupbg (a DIFFERENT process group, never named as + foreground) is still genuinely alive and running real code after + the Ctrl-C above -- if keyboard.c's group scoping were ever broken + to reach it too, pgroupbg would already be Dead by now and this + sigRaise (signal.h) would silently do nothing, so pgroupbg.c's own + "still-alive-after-ctrlc-ok" needle would simply never appear in + the boot log, failing the gate. */ + sigRaise(pgroupbgPid, sigUsr1); + + for (;;) + halt(); +} + +/* + * The live ring-3 proof for shell.c's new `NAME [ARGS] [&]` job control + * (jobctl.h does the actual spawning/terminal-ownership work): rather than + * spawning jobctlfg.c/jobctlbg.c directly the way every demo above does, + * this thread TYPES commands at the real interactive shell (shellRun, + * already running as its own boot-time thread) through the same + * injected-keystroke mechanism consoleInjectorThread/pgroupInjectorThread + * above already use for the same "no automated way to drive a real PS/2 + * device under headless QEMU" reason -- proving the shell's own runtime + * decision of what to load and whether to grant it the terminal, not just + * that jobctl.c's functions work when called directly. + * + * Scancode values below are read straight off keyboard.c's own + * scancodeLower table (index N there is scancode N here -- already cross- + * checked against that file's own worked examples: index 0x25 is 'k', + * exactly consoleDemoScancodeK above; index 0x2E is 'c', exactly + * scancodeCPressMake above), not guessed. + */ +#define scancodeMakeJ 0x24 +#define scancodeMakeO 0x18 +#define scancodeMakeB 0x30 +#define scancodeMakeT 0x14 +#define scancodeMakeL 0x26 +#define scancodeMakeG 0x22 +#define scancodeMakeF 0x21 +#define scancodeMakeSpace 0x39 +#define scancodeMakeSeven 0x08 +#define scancodeMakeEnter 0x1C +#define scancodeLeftShiftMake 0x2A +#define scancodeLeftShiftBreak 0xAA +#define scancodeBreakBit 0x80 + +/* Additional letters/symbol needed only by pipelineDriverThread's own + "pipesrc | pipesink" command below -- same cross-checked-against- + scancodeLower sourcing as the block above (index 0x19 is 'p', 0x17 is + 'i', 0x12 is 'e', 0x1F is 's', 0x13 is 'r', 0x31 is 'n', 0x25 is 'k' + (equal to consoleDemoScancodeK above, kept as this file's own named copy + for this demo, the same "each caller of this pattern owns its own named + copy" choice jobctl.c/kmain.c/syscall.c already make elsewhere), 0x2B is + '\\', shifted into '|' by scancodeUpper the same way scancodeMakeSeven + is shifted into '&' above). */ +#define scancodeMakeP 0x19 +#define scancodeMakeI 0x17 +#define scancodeMakeE 0x12 +#define scancodeMakeS 0x1F +#define scancodeMakeR 0x13 +#define scancodeMakeN 0x31 +#define scancodeMakeK 0x25 +#define scancodeMakeBackslash 0x2B + +static void injectKeyPress(uint8 makeCode) +{ + keyboardHandleScancode(makeCode); + keyboardHandleScancode(makeCode | scancodeBreakBit); +} + +/* '&' is shift+7 on a real US keyboard -- keyboard.c's scancodeUpper table + maps index scancodeMakeSeven to '&' only while shiftHeld is true. */ +static void injectShiftedKeyPress(uint8 makeCode) +{ + keyboardHandleScancode(scancodeLeftShiftMake); + keyboardHandleScancode(makeCode); + keyboardHandleScancode(makeCode | scancodeBreakBit); + keyboardHandleScancode(scancodeLeftShiftBreak); +} + +/* A single bare Enter, unconditionally, before typing anything this thread + actually cares about: every keystroke keyboardHandleScancode decodes is + broadcast to EVERY open console reader (keyboard.c), the shell's own + included, so consoleInjectorThread's single synthetic 'k' (well before + this thread ever runs) is still sitting unflushed in the shell's own + line buffer -- there is no way for this thread to tell "a stray + character from an earlier, unrelated demo" apart from "something a real + user typed". One Enter here executes that "k" as a harmless unknown + command (shell.c's execute()) and guarantees an empty line buffer before + the real commands below, exactly like pressing Enter at a real prompt to + clear a garbled line. */ +static void jobctlTypeFlushLine(void) +{ + injectKeyPress(scancodeMakeEnter); +} + +static void jobctlTypeBackgroundCommand(void) +{ + injectKeyPress(scancodeMakeJ); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeSeven); + injectKeyPress(scancodeMakeEnter); +} + +static void jobctlTypeForegroundCommand(void) +{ + injectKeyPress(scancodeMakeJ); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeF); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeEnter); +} + +#define jobctlDriverInitialDelayMs 900 +#define jobctlDriverTypeSettleMs 150 +#define jobctlDriverForegroundSettleMs 350 +#define jobctlDriverSignalSettleMs 150 + +static void jobctlDriverThread(void) +{ + threadSleep(jobctlDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the on-demand job-control demo\n"); + jobctlTypeFlushLine(); + + /* "jobctlbg &" -- backgrounded, so shell.c's runProgram never blocks + on it: jobctlSpawnForeground/jobctlRunBackground's own load+spawn + work is synchronous kernel code, so by the time the shell has + actually consumed all of these buffered bytes (needs its own + timeslice; keyboardHandleScancode above only enqueues them) its + shellLastSpawnedPid() is already this job's real pid, not stale. */ + jobctlTypeBackgroundCommand(); + threadSleep(jobctlDriverTypeSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + uint32 jobctlbgPid = shellLastSpawnedPid(); + + /* "jobctlfg" -- foreground, so the shell thread itself parks inside + jobctlWaitForeground (threadJoin) the moment it processes this + line's own trailing Enter. shellLastSpawnedPid() is still safe to + read afterward: shell.c's runProgram sets it right after + jobctlSpawnForeground returns, BEFORE ever calling + jobctlWaitForeground, precisely so a live demo like this one is + never stuck waiting for a foreground job to exit before it can even + learn that job's own pid. The longer settle here (vs. the + background command above) gives jobctlfg.c itself real scheduler + time to reach its own blocking pipe read before Ctrl-C below -- + the same margin ratio pgroupInjectDelayMs/pgroupSettleDelayMs above + already established for an equivalent single-process case. */ + jobctlTypeForegroundCommand(); + threadSleep(jobctlDriverForegroundSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + uint32 jobctlfgPid = shellLastSpawnedPid(); + + if (jobctlbgPid == threadInvalidId || jobctlfgPid == threadInvalidId || + jobctlbgPid == jobctlfgPid) { + kprintf("[kbd] on-demand job-control demo skipped: jobctlbg/jobctlfg never started\n"); + for (;;) + halt(); + } + + kprintf("[kbd] injecting synthetic ctrl-c for the on-demand job-control demo\n"); + keyboardHandleScancode(scancodeLeftCtrlMake); + keyboardHandleScancode(scancodeCPressMake); + keyboardHandleScancode(scancodeCPressBreak); + keyboardHandleScancode(scancodeLeftCtrlBreak); + + /* Give the scheduler a real timeslice to actually deliver the + resulting sigInt to jobctlfg (early-wake + return-to-ring3), let + the now-parked shell thread's own threadJoin notice it Dead, and let + jobctlWaitForeground's reclaim (jobctl.c) run -- all real scheduled + work, not instantaneous. */ + threadSleep(jobctlDriverSignalSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + /* The direct, causal proof that shell.c's runProgram/jobctl.c's + jobctlWaitForeground actually reclaimed the terminal for the shell's + OWN group the instant jobctlfg died, rather than leaving it + permanently pointed at a now-dead pgid the way nothing in this + kernel ever un-claimed it before this subsystem existed: if the + reclaim never ran (or ran against the wrong pgid), the foreground + would still equal jobctlfg's own now-meaningless pid forever. */ + uint32 fgAfterReclaim = keyboardForegroundPgid(); + bool reclaimed = (fgAfterReclaim != jobctlfgPid); + kprintf("[user] jobctl demo: jobctlfg pid=%u foreground pgid now=%u -> %s\n", + jobctlfgPid, fgAfterReclaim, + reclaimed ? "shell-foreground-reclaimed-ok" : "FAIL"); + + /* Proves jobctlbg (never granted the terminal at all, per shell.c's + `&` handling) is still genuinely alive and running real code after + the Ctrl-C above -- if the shell's background path ever put it in + jobctlfg's group (or granted it the foreground itself), it would + already be Dead by now and this sigRaise would silently do nothing, + so jobctlbg.c's own "still-alive-after-ctrlc-ok" needle would simply + never appear in the boot log, failing the gate. */ + sigRaise(jobctlbgPid, sigUsr1); + + for (;;) + halt(); +} + +#define pipelineDriverInitialDelayMs 2500 +#define pipelineDriverSettleMs 500 + +static void pipelineTypeCommand(void) +{ + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeBackslash); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeEnter); +} + +/* + * The live ring-3 proof for shell.c's new `cmd1 | cmd2` pipeline syntax + * (jobctl.h's jobctlSpawnPiped/jobctlWaitPipelineStages do the actual + * fd-baking and multi-wait work): types "pipesrc | pipesink" at the real interactive + * shell through the same injected-keystroke mechanism jobctlDriverThread + * above already uses, well after that thread's own demo has fully + * finished with the shell's shared line buffer (both threads' typed + * characters go through the exact same broadcast keyboardHandleScancode + * path, so they cannot safely interleave -- pipelineDriverInitialDelayMs + * is chosen with a comfortable margin over jobctlDriverThread's own total + * ~1550ms explicit sleep budget above to guarantee that). The actual + * pass/fail proof is entirely pipesink.c's own doing (its "payload-ok" + * needle only prints once it has verified, byte for byte, the exact + * payload pipesrc.c wrote through the real pipe) -- this thread's only job + * is to type the command and give the two spawned processes (neither of + * which ever sleeps or waits on a real timer, unlike alarmtest/polltest) a + * comfortable amount of real scheduled time to run to completion. + */ +static void pipelineDriverThread(void) +{ + threadSleep(pipelineDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell command for the pipeline demo\n"); + jobctlTypeFlushLine(); + pipelineTypeCommand(); + + threadSleep(pipelineDriverSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* Additional letters/symbols needed only by redirDriverThread's own three + commands below -- same cross-checked-against-scancodeLower sourcing as + every block above (index 0x20 is 'd', 0x11 is 'w', 0x32 is 'm', 0x2D is + 'x', 0x34 is '.', 0x35 is '/', 0x1E is 'a', 0x33 is ',' -- '>' is + shift+'.' and '<' is shift+',' on a real US keyboard, scancodeUpper's + own mapping for those two indices, the same way scancodeMakeSeven is + shifted into '&' and scancodeMakeBackslash into '|' above). */ +#define scancodeMakeD 0x20 +#define scancodeMakeW 0x11 +#define scancodeMakeM 0x32 +#define scancodeMakeX 0x2D +#define scancodeMakeDot 0x34 +#define scancodeMakeSlash 0x35 +#define scancodeMakeA 0x1E +#define scancodeMakeComma 0x33 + +static void redirTypePath(void) +{ + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeT); +} + +static void redirTypeWriterName(void) +{ + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeW); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); +} + +static void redirTypeReaderName(void) +{ + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); +} + +static void redirTypeWriteTruncateCommand(void) +{ + redirTypeWriterName(); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + redirTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +static void redirTypeWriteAppendCommand(void) +{ + redirTypeWriterName(); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeDot); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + redirTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +static void redirTypeReadCommand(void) +{ + redirTypeReaderName(); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeComma); + injectKeyPress(scancodeMakeSpace); + redirTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +#define redirDriverInitialDelayMs 3600 +#define redirDriverStepSettleMs 300 + +/* + * The live ring-3 proof for shell.c's new `cmd < IN` / `cmd > OUT` / + * `cmd >> OUT` file redirection (jobctlApplyRedirects/ + * jobctlSpawnForegroundRedirected, jobctl.h): types three commands, one at + * a time, at the real interactive shell through the same injected- + * keystroke mechanism jobctlDriverThread/pipelineDriverThread above + * already use -- redirDriverInitialDelayMs gives pipelineDriverThread's + * own total ~3000ms budget (2500ms initial + 500ms settle) a comfortable + * margin before this thread ever touches the same shared keyboard buffer. + * + * Unlike pipelineDriverThread's single typed line, this is THREE separate + * commands, each one run to completion in the FOREGROUND (the shell + * genuinely blocks inside jobctlWaitForeground for each), so + * redirDriverStepSettleMs between them is not just cosmetic pacing -- + * typing the next command's keystrokes while the shell is still mid-line + * on the previous one would queue them in the same 32-byte console-reader + * ring buffer (keyboard.c) that a real user's own typing shares, with no + * guarantee they would not overflow it or be echoed against the wrong + * still-open line. Each settle is comfortably longer than + * redirwriter/redirreader's own trivial one-shot work (a single write or + * read-to-EOF, neither ever sleeps or blocks on a timer) needs to spawn, + * run, exit, and be reaped, the same safety margin philosophy every + * other injected multi-step demo above already uses. + * + * "redirwriter > /tmp/redir.txt" (create/truncate) followed by + * "redirwriter >> /tmp/redir.txt" (append, same path) leaves a real tmpfs + * file holding two back-to-back copies of redirwriter.c's own payload; + * "redirreader < /tmp/redir.txt" then reads that file back through a + * REAL redirected stdin and compares it byte-for-byte against exactly + * that doubled payload. The actual pass/fail proof is entirely + * redirreader.c's own doing (its "payload-ok" needle only prints on an + * exact match) -- this thread's only job is to type the three commands + * and give the scheduler real time between them. + */ +static void redirDriverThread(void) +{ + threadSleep(redirDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the file redirection demo\n"); + jobctlTypeFlushLine(); + + redirTypeWriteTruncateCommand(); + threadSleep(redirDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + redirTypeWriteAppendCommand(); + threadSleep(redirDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + redirTypeReadCommand(); + threadSleep(redirDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* Digit needed only by stderrDriverThread's own three commands below -- + same cross-checked-against-scancodeLower sourcing as every block above + (index 0x03 is '2', unshifted -- scancodeLower[3] == '2' in keyboard.c's + own table, no shift key involved unlike the '>'/'<' operators reused + from redirDriverThread's own scancodeMakeDot/scancodeMakeComma below). */ +#define scancodeMakeTwo 0x03 + +/* "/tmp/err.txt" -- deliberately SHORTER than redirDriverThread's own + "/tmp/redir.txt" (12 vs 14 chars): "stderrwriter"/"stderrreader" are + already one character longer than "redirwriter"/"redirreader", and the + `2>`/`2>>` operators are one/two characters longer than `>`/`>>` -- with + the same path length as the file-redirection demo, the LONGEST typed + line here ("stderrwriter 2>> /tmp/err.txt\n", 30 bytes incl. newline) + would land right at keyboard.c's 32-byte per-thread console reader ring + buffer (consoleReaderBufferSize) with almost no margin left. A first + attempt at this demo used "/tmp/stderr.txt" (15 chars) and landed + EXACTLY at (and, for the `2>>` line, one byte OVER) that 32-byte + capacity -- ringPush (ring.c) silently drops a byte on a full buffer + with no retry, so the trailing '\n' itself was lost, and the shell's + line reader never saw a line terminator until the NEXT typed command's + bytes arrived, silently concatenating three separate typed commands + into one garbled line. Shortening the path restores the same few-bytes- + of-headroom margin redirDriverThread's own commands already prove works + (its longest line is 30 bytes incl. newline against the same 32-byte + capacity) instead of running right up against the boundary. */ +static void stderrTypePath(void) +{ + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeT); +} + +static void stderrTypeWriterName(void) +{ + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeW); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); +} + +static void stderrTypeReaderName(void) +{ + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); +} + +static void stderrTypeWriteTruncateCommand(void) +{ + stderrTypeWriterName(); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeTwo); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + stderrTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +static void stderrTypeWriteAppendCommand(void) +{ + stderrTypeWriterName(); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeTwo); + injectShiftedKeyPress(scancodeMakeDot); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + stderrTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +static void stderrTypeReadCommand(void) +{ + stderrTypeReaderName(); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeComma); + injectKeyPress(scancodeMakeSpace); + stderrTypePath(); + injectKeyPress(scancodeMakeEnter); +} + +#define stderrDriverInitialDelayMs 5500 +#define stderrDriverStepSettleMs 300 + +/* Letter/symbol needed only by hereDriverThread's own command below -- same + cross-checked-against-scancodeLower sourcing as every block above (index + 0x23 is 'h', 0x0C is '-', both unshifted -- keyboard.c's own + scancodeLower[0x23] == 'h' and scancodeLower[0x0C] == '-'). Every other + letter the command needs (e,r,s,t,o,k) is already defined by an earlier + block above. */ +#define scancodeMakeH 0x23 +#define scancodeMakeHyphen 0x0C + +static void hereTypeCommand(void) +{ + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeComma); + injectShiftedKeyPress(scancodeMakeComma); + injectShiftedKeyPress(scancodeMakeComma); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeHyphen); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeEnter); +} + +#define hereDriverInitialDelayMs 7500 +#define hereDriverSettleMs 300 + +/* + * The live ring-3 proof for shell.c's new `cmd <<< TEXT` here-string syntax + * (jobctl.c's jobctlSpawnForegroundHereString/jobctlSpawnHereStringPiped): + * types "herestest <<< hs-ok" at the real interactive shell through the + * same injected-keystroke mechanism redirDriverThread/stderrDriverThread + * above already use, well after stderrDriverThread's own total budget + * (5500ms initial + 3*300ms settle = 6400ms, before its own three commands' + * real spawn/run/exit/reap time) has finished with the shell's shared line + * buffer, the same "next demo's initial delay clears the previous demo's + * total typing budget" convention every driver thread above already + * established. Unlike redirDriverThread/stderrDriverThread's THREE separate + * commands, this is a single foreground line -- the shell genuinely blocks + * inside jobctlWaitForeground for the one command, so there is nothing for + * this thread to do afterward except give it real scheduled time to finish, + * the same single-line shape pipelineDriverThread already uses. + * + * herestest.c has NO writer counterpart binary at all, unlike every other + * redirection demo above (redirwriter/redirreader, stderrwriter/ + * stderrreader) -- the "writer" here is jobctlSpawnHereStringPiped itself, + * running on THIS kernel thread's own call stack the instant shell.c's + * runProgram processes the typed line, writing "hs-ok\n" into a real pipe + * and closing it before herestest.c ever gets a timeslice to read fd 0. The + * actual pass/fail proof is entirely herestest.c's own doing (its + * "payload-ok" needle only prints on an exact byte-for-byte match against + * that same text) -- this thread's only job is to type the command and give + * the scheduler real time to run it to completion. + */ +static void hereDriverThread(void) +{ + threadSleep(hereDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell command for the here-string demo\n"); + jobctlTypeFlushLine(); + hereTypeCommand(); + + threadSleep(hereDriverSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* Every letter/symbol "hcat <<< hpipe-ok | hsink" needs (h,c,a,t,<,p,i,e,-, + o,k,|,s,n) was already defined by an earlier block above -- see + pipelineTypeCommand's own '|' (scancodeMakeBackslash, shifted), + hereTypeCommand's own '<<<' (scancodeMakeComma, shifted) and '-' + (scancodeMakeHyphen, unshifted), and every plain letter from either one + or stderrTypeReadCommand/redirTypeReadCommand's own blocks. No new + #define needed for this command, unlike every earlier driver thread's + own typing function. */ +static void hereStringPipelineTypeCommand(void) +{ + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeComma); + injectShiftedKeyPress(scancodeMakeComma); + injectShiftedKeyPress(scancodeMakeComma); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeHyphen); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeBackslash); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeEnter); +} + +/* 9000ms, the same "clear the previous demo's own typing budget with a + comfortable margin" convention every earlier driver thread's own initial + delay already uses (hereDriverThread's own total budget is + 7500+300=7800ms). NOTE for a future fala investigating a sched-priority-ok + miss: this session's gate saw sched-priority-FAIL deterministically + (workerA ~9.2x workerB, over the [1.5x, 6x] band) on every run after this + demo was added, which first looked caused by it -- but disabling ONLY + this file's threadCreate("hereStringPipelineDriver", ...) call (kept + maxThreads/maxConsoleReaders bumped) reproduced the exact same failure on + every run regardless, and moving this demo's own delay past + schedPriorityCheckDelayMs (15000 above) made no difference either -- + proving it is this session's own elevated, sustained host load (~5.4-6.2, + well above the documented ~4.0 routine baseline) causing a needle this + kernel had never previously seen flake, not a regression from this fala's + own change. Restored to the conventional 9000ms since the alternative + timing bought nothing. */ +#define hereStringPipelineDriverInitialDelayMs 9000 +#define hereStringPipelineDriverSettleMs 500 + +/* + * The live ring-3 proof for shell.c's new `cmd1 <<< TEXT | cmd2` here- + * string-into-pipeline syntax (jobctl.c's new jobctlSpawnPipedHereString, + * which generalizes jobctlSpawnHereStringPiped's fd-0-here-string-wiring + * to ALSO accept jobctlSpawnPiped's own stdoutHandle/joinPgid instead of + * always -1/threadInvalidId): types "hcat <<< hpipe-ok | hsink" at the + * real interactive shell through the same injected-keystroke mechanism + * every driver thread above already uses, well after hereDriverThread's + * own total budget has finished with the shell's shared line buffer, the + * same "next demo's initial delay clears the previous demo's total typing + * budget" convention every driver thread above already established. Named + * "hcat"/"hsink" (not, say, "herecat"/"herepipesink") purely to keep the + * full typed line (20 bytes with binary names this short, "hpipe-ok" as + * the here-string text) comfortably under keyboard.c's 32-byte + * consoleReaderBufferSize per line -- see kmain.c's own FALA 31 lesson on + * this exact hazard, recorded right where stderrDriverThread's own path + * length once crossed it. + * + * hcat.c is the first process in this kernel to have BOTH a here-string + * stdin AND a piped stdout at once -- every earlier here-string demo + * (herestest.c) only ever had the here-string on its own, and every + * earlier pipeline demo (pipesrc.c/pipesink.c) only ever had plain piped + * fds, never a here-string. The actual pass/fail proof is entirely + * hsink.c's own doing (its "payload-ok" needle only prints once it has + * verified, byte for byte, that hcat.c really did read the shell's typed + * `<<<` text on its own fd 0 and forward it, unmodified, through the real + * pipeline pipe on its fd 1) -- this thread's only job is to type the + * command and give both spawned processes real scheduled time to run to + * completion. + */ +static void hereStringPipelineDriverThread(void) +{ + threadSleep(hereStringPipelineDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell command for the here-string pipeline demo\n"); + jobctlTypeFlushLine(); + hereStringPipelineTypeCommand(); + + threadSleep(hereStringPipelineDriverSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* + * The live ring-3 proof for shell.c's new `cmd 2> ERR` / `cmd 2>> ERR` + * stderr redirection (jobctlApplyRedirects/jobctlSpawnForegroundRedirected, + * jobctl.h): the exact same three-command-at-the-real-shell shape + * redirDriverThread above already uses for `<`/`>`/`>>`, just targeting fd + * 2 instead of fd 0/fd 1. stderrDriverInitialDelayMs gives redirDriverThread's + * own total budget (3600ms initial + 3*300ms settle = 4500ms, before its + * own three commands' real spawn/run/exit/reap time) a comfortable margin + * before this thread ever touches the same shared keyboard buffer, the + * same "next demo's initial delay clears the previous demo's total typing + * budget" convention pipelineDriverThread/redirDriverThread's own doc + * comments already established. + * + * "stderrwriter 2> /tmp/stderr.txt" (create/truncate) followed by + * "stderrwriter 2>> /tmp/stderr.txt" (append, same path) leaves a real + * tmpfs file holding two back-to-back copies of stderrwriter.c's own + * payload; "stderrreader < /tmp/stderr.txt" then reads that file back + * through a REAL redirected stdin and compares it byte-for-byte against + * exactly that doubled payload. The actual pass/fail proof is entirely + * stderrreader.c's own doing (its "payload-ok" needle only prints on an + * exact match) -- this thread's only job is to type the three commands and + * give the scheduler real time between them. + */ +static void stderrDriverThread(void) +{ + threadSleep(stderrDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the stderr redirection demo\n"); + jobctlTypeFlushLine(); + + stderrTypeWriteTruncateCommand(); + threadSleep(stderrDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + stderrTypeWriteAppendCommand(); + threadSleep(stderrDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + stderrTypeReadCommand(); + threadSleep(stderrDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* 10700ms gives hereStringPipelineDriverThread's own total budget + (9000+500=9500ms, above its own real hcat/hsink spawn/run/reap time) a + comfortable margin before this thread ever touches the same shared + keyboard buffer, the same "next demo's initial delay clears the previous + demo's total typing budget" convention every driver thread above already + established. Settle is 600ms (slightly more than pipelineDriverThread's + own 500ms for a 2-stage pipe) since this demo's pipe has one more real + process (pipesrc, pipecat, AND pipesink) to spawn, run, and reap before + the final needle can print. */ +#define threeStagePipelineDriverInitialDelayMs 10700 +#define threeStagePipelineDriverSettleMs 600 + +static void threeStagePipelineTypeCommand(void) +{ + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeBackslash); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeBackslash); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeEnter); +} + +/* + * The live ring-3 proof for shell.c's new REAL N-stage pipeline + * (runPipelineStages/jobctlWaitPipelineStages) going past two stages: types + * "pipesrc | pipecat | pipesink" at the real interactive shell through the + * same injected-keystroke mechanism every driver thread above already + * uses, well after hereStringPipelineDriverThread's own total budget has + * finished with the shell's shared line buffer. + * + * pipesrc.c and pipesink.c are reused completely unchanged from the + * two-stage pipeline demo (FALA 29) -- their own narration ("wrote + * payload", "blocking on read, waiting for pipesrc", "payload-ok") is still + * literally true here, just relayed through one more real process instead + * of being directly adjacent. The new thing this demo actually proves is + * user/pipecat.c in the middle: jobctlSpawnPiped bakes a real pipe into + * BOTH its fd 0 (from pipesrc's pipe) and its fd 1 (to pipesink's pipe) + * before it ever runs a single instruction -- the first process in this + * kernel with two DIFFERENT real inter-process pipes on its stdio at once + * (hcat.c's own simultaneous piped-stdin-and-stdout, FALA 34, had a + * shell-fed here-string on the stdin side, never a second real pipe). The + * actual pass/fail proof is still entirely pipesink.c's own doing (its + * "payload-ok" needle only prints on an exact byte-for-byte match of + * whatever pipesrc originally wrote) -- this thread's only job is to type + * the command and give all three spawned processes (none of which ever + * sleeps or waits on a real timer) a comfortable amount of real scheduled + * time to run to completion. + */ +static void threeStagePipelineDriverThread(void) +{ + threadSleep(threeStagePipelineDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell command for the three-stage pipeline demo\n"); + jobctlTypeFlushLine(); + threeStagePipelineTypeCommand(); + + threadSleep(threeStagePipelineDriverSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* '=' (sysGetEnv/sysSetEnv's own operator, shell.c's `export N=V` + builtin), 'y'/'Y', and 'v'/'V' are the only three make codes no earlier + driver thread above ever needed -- every other letter "export", + "GREETING", "envprint", and "env" use (e,x,p,o,r,t,g,i,n,f,s,b) was + already defined by an earlier block. Cross-checked against + keyboard.c's own scancodeLower/scancodeUpper tables the same way every + block above documents for its own new codes. */ +#define scancodeMakeEquals 0x0D +#define scancodeMakeY 0x15 +#define scancodeMakeV 0x2F + +static void envTypeExportCommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeG); + injectShiftedKeyPress(scancodeMakeR); + injectShiftedKeyPress(scancodeMakeE); + injectShiftedKeyPress(scancodeMakeE); + injectShiftedKeyPress(scancodeMakeT); + injectShiftedKeyPress(scancodeMakeI); + injectShiftedKeyPress(scancodeMakeN); + injectShiftedKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeEquals); + injectKeyPress(scancodeMakeF); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeMakeY); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeEnter); +} + +static void envTypeEnvprintCommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeV); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeEnter); +} + +static void envTypeEnvCommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeV); + injectKeyPress(scancodeMakeEnter); +} + +/* 12500ms gives threeStagePipelineDriverThread's own total budget + (10700+600=11300ms, above its own real 3-process pipeline run time) a + comfortable margin before this thread ever touches the same shared + keyboard buffer, the same "next demo's initial delay clears the + previous demo's total budget" convention every driver thread above + already established. Settle is 400ms per step -- envprint (unlike most + earlier demos) never blocks on anything (no pipe, no fifo, no signal), + it just runs two syscalls and prints, so this is comfortable headroom + rather than a tightly reasoned minimum. */ +#define envDriverInitialDelayMs 12500 +#define envDriverStepSettleMs 400 + +/* + * Live ring-3 proof for the new real environment-variable subsystem + * (sched.h's Thread.env, syscall.c's sysGetEnv/sysSetEnv, shell.c's new + * `export`/`env` builtins and `$NAME` expansion): types "export + * GREETING=frostbyte" at the real interactive shell (sets it in the + * SHELL's own Thread.env, currentThreadSetEnv), then "envprint" (loaded + * on demand, /bin/envprint -- proves the freshly spawned child inherited + * a real copy via initialEnv and that its own later userSetEnv is visible + * to itself), then a final bare "env" (shell.c's listing builtin) whose + * output is the actual proof of isolation: if envprint.c's own + * userSetEnv("GREETING", "child-mutated") had somehow reached back into + * the PARENT shell's table (a shared-pointer bug, not the real + * independent-copy semantics initialEnv gives), this "env" would print + * "child-mutated" instead of the original "frostbyte" -- the same + * "child's own later mutation is proven not to leak back" shape + * cwdtest.c's post-userWait getcwd() check already established for + * per-process cwd. The actual pass/fail proof for the first two commands + * is entirely envprint.c's own doing; this thread's only job is to type + * the three commands and give the scheduler real time between them. + */ +static void envDriverThread(void) +{ + threadSleep(envDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the environment variable demo\n"); + jobctlTypeFlushLine(); + + envTypeExportCommand(); + threadSleep(envDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + envTypeEnvprintCommand(); + threadSleep(envDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + envTypeEnvCommand(); + threadSleep(envDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* '*' is shift+8 on a real US keyboard -- keyboard.c's scancodeUpper table + maps index scancodeMakeEight (the same physical key as scancodeLower's + plain '8') to '*' only while shiftHeld is true, the same convention + scancodeMakeDot/scancodeMakeComma above already use for '>'/'<'. No + other new letter is needed for this demo's five commands -- every letter + "mkdir"/"echo"/"globcat"/"tmp"/"gd"/"txt"/"log" needs (m,k,d,i,r,e,c,h,o, + g,l,b,a,t,p,x,s) was already defined by an earlier block. */ +#define scancodeMakeEight 0x09 + +static void globTypeMkdirCommand(void) +{ + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeK); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeR); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeEnter); +} + +static void globTypeEchoACommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeEnter); +} + +static void globTypeEchoBCommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeEnter); +} + +/* Writes /tmp/gd/c.log (NOT .txt) -- the sibling file globTypeGlobcatCommand + below's own "*.txt" pattern must NOT match, proving shellExpandGlobs + really filters by pattern instead of just listing the whole directory. */ +static void globTypeEchoSkipCommand(void) +{ + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeEnter); +} + +static void globTypeGlobcatCommand(void) +{ + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeB); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeP); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeG); + injectKeyPress(scancodeMakeD); + injectKeyPress(scancodeMakeSlash); + injectShiftedKeyPress(scancodeMakeEight); + injectKeyPress(scancodeMakeDot); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeEnter); +} + +/* 15000ms clears envDriverThread's own total budget (12500 initial + + 3*400 settle = 13700ms, above its own real spawn/run/reap time), the same + "next demo's initial delay clears the previous demo's total budget" + convention every driver thread above already establishes. Settle is + 300ms per step, matching redirDriverThread's own choice for similarly + trivial commands (a single mkdir or a single short file write, none of + which ever sleeps or blocks on a timer) -- comfortable headroom to let + the shell finish running each FOREGROUND command before the next one's + keystrokes are typed into the same shared 32-byte console-reader ring + buffer (keyboard.c). */ +#define globDriverInitialDelayMs 15000 +#define globDriverStepSettleMs 300 + +/* + * Live ring-3 proof for shell.c's new wildcard glob expansion + * (shellExpandGlobs, shellGlobMatch/shellTokenHasGlob/shellSplitGlobToken): + * types four setup commands at the real interactive shell (mkdir, then + * three "echo X > /tmp/gd/NAME" writes -- two matching *.txt, one + * deliberately a *.log that must NOT match), then "globcat /tmp/gd/ *.txt". + * shell.c resolves that last argv token against the real tmpfs directory + * /tmp/gd (vfsChild/vfsNext, the exact traversal the `ls` builtin already + * uses) and replaces it with however many real paths matched BEFORE + * globcat.c (loaded on demand, like envprint/hcat/pipecat above) ever runs + * a single instruction -- so globcat.c's own argc/argv and each file's + * content are the actual proof: exactly 2 files (never 3 -- c.log must be + * filtered out by pattern, not just "everything in the directory"), each + * one's real resolved path and its own distinct content. The actual + * pass/fail proof is entirely globcat.c's own doing; this thread's only job + * is to type the five commands and give the scheduler real time between + * them. + */ +static void globDriverThread(void) +{ + threadSleep(globDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the wildcard glob demo\n"); + jobctlTypeFlushLine(); + + globTypeMkdirCommand(); + threadSleep(globDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + globTypeEchoACommand(); + threadSleep(globDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + globTypeEchoBCommand(); + threadSleep(globDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + globTypeEchoSkipCommand(); + threadSleep(globDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + globTypeGlobcatCommand(); + threadSleep(globDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* 19500ms clears globDriverThread's own total budget (15000 initial + + 5*300 settle = 16500ms, plus the real time globcat.c itself needs to + spawn/run/read two files/print/exit after that last keystroke) -- the + same "next demo's initial delay clears the previous demo's total budget + with margin" convention every driver thread above establishes. A single + settle step is enough -- see the file comment on lsDriverThread below + for why this demo types only ONE command, unlike every prior driver + thread's multi-command setup sequence: tools/ci-test.sh's QEMU timeout + is a fixed 25 REAL (host wall-clock) seconds shared by the entire boot, + and this is already the last of well over a dozen shell-driven demos + stacked at increasing absolute timestamps in that same fixed window, so + minimizing this demo's own keystroke count directly buys back margin + for the whole chain instead of spending it. */ +#define lsDriverInitialDelayMs 19500 +#define lsDriverStepSettleMs 300 + +static void lsTypeLongListCommand(void) +{ + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeHyphen); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeN); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeSlash); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeI); + injectKeyPress(scancodeMakeM); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeT); + injectKeyPress(scancodeMakeEnter); +} + +/* + * Live proof for shell.c's new generalized `ls`/`ls -l` (listDir/ + * printLsEntry): types `ls -l /mnt/timetest` at the real interactive + * shell. `/mnt/timetest` is deliberately NOT created by this demo -- + * timetest.c (FALA 39, boot-time synchronous, long finished by the time + * any shell-driven demo runs) already left it behind with two real files + * (data.txt, and alias.txt, a hardlink sharing the same inode/mtime) once + * `timetest: PASS` prints, so this demo needs exactly ONE typed command + * instead of the usual "mkdir + write setup, then the command under test" + * shape every earlier shell demo used -- both a deliberate budget saving + * (see lsDriverInitialDelayMs's own comment) and, incidentally, a second + * independent proof that `ls -l`'s real stat data (size, mtime) matches + * what timetest.c's own syscall-level userStat already established for + * the exact same on-disk inode, through a completely different code path + * (the interactive shell builtin instead of a ring-3 program's syscalls). + * `/mnt`, not `/tmp`, is the whole point -- the OLD `ls` only ever + * understood the tmpfs VfsNode tree via vfsLookup/vfsChild/vfsNext, so any + * `/mnt` directory silently reported "no such path" no matter what it + * actually held (fstest.c/linktest.c could only ever prove SFS directory + * listing through a raw userGetdents syscall, never through this + * builtin). The needles tools/ci-test.sh checks for are the deterministic + * type+size+name prefix of each `-l` output line ("f data.txt " and + * "f alias.txt ", both files' real on-disk size) plus a "20" year- + * prefix right after one of them, proving a real mtime got formatted and + * printed (sfs.h's on-disk timestamp, rtc.c's new rtcCivilFromEpoch) + * rather than silently falling back to the "no timestamp" dash this same + * code path prints for tmpfs/dev entries. + */ +static void lsDriverThread(void) +{ + threadSleep(lsDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the ls -l demo\n"); + jobctlTypeFlushLine(); + + lsTypeLongListCommand(); + threadSleep(lsDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); +} + +/* 20500ms clears lsDriverThread's own total budget (19500 initial + 300 + settle = 19800ms) with 700ms margin -- smaller than most prior driver + threads' own margin since `ls -l` is a plain builtin (listDir, shell.c) + with no process spawn/VFS-blocking wait after its own settle delay, + unlike e.g. globDriverThread's globcat.c spawn+read+compare, so there is + no extra real execution time to wait out beyond the typing itself. Same + "next demo's initial delay clears the previous demo's total budget with + margin" convention every driver thread above establishes. Only ONE + command, ONE settle step -- false.c is the smallest possible spawned + program in this kernel (immediate userExit, no VFS/pipe/signal work at + all), and this fala's own live demo needs only one line of it (see + andOrTypeFailChainCommand's own comment for why one line already proves + the whole feature), the same budget-consciousness + lsDriverInitialDelayMs's own comment established: an EARLIER version of + this demo also typed a second line ("true && echo aoy", proving `&&` + fires on a real SUCCESS too) and was cut after it measurably pushed one + real re-run over tools/ci-test.sh's fixed 25-real-second QEMU ceiling + under this session's own elevated host load -- not worth the extra + margin lost for a case the KTEST-level shellSplitAndOr tests (which + assert both shellAndOrAnd and shellAndOrOr are parsed and recorded + correctly) and this project's dozens of pre-existing successful + `cmd && cmd`-free typed commands already make low-risk. */ +#define andOrDriverInitialDelayMs 20500 +#define andOrDriverStepSettleMs 300 + +/* "false && echo X || echo ao2" -- the classic shell if-then-else idiom in + one line. false.c genuinely spawns and exits 1 (see its own file + comment); `&&` sees that real nonzero status and SKIPS "echo X" (a + builtin, would otherwise print instantly); the `||` check right after + it is then evaluated against the SAME status "echo X" never changed + (shellSplitAndOr/execute()'s own "a skipped segment leaves status + completely unchanged" contract, shell.h) -- still nonzero, so it DOES + run "echo ao2". The needle `ao2` can only ever appear if both the skip + and the carry-through past that skip worked correctly; "X" is + deliberately never checked for (this kernel's tools/ci-test.sh only + ever asserts PRESENCE of a needle, never absence), so this line's own + affirmative proof is entirely in `ao2` actually appearing at all. Every + character this line needs (f/a/l/s/e/space/&/h/c/o/x/|/a/o/2) is already + defined by an earlier demo's own scancode block in this file. */ +static void andOrTypeFailChainCommand(void) +{ + injectKeyPress(scancodeMakeF); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeL); + injectKeyPress(scancodeMakeS); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeSeven); + injectShiftedKeyPress(scancodeMakeSeven); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeX); + injectKeyPress(scancodeMakeSpace); + injectShiftedKeyPress(scancodeMakeBackslash); + injectShiftedKeyPress(scancodeMakeBackslash); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeE); + injectKeyPress(scancodeCPressMake); + injectKeyPress(scancodeMakeH); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeSpace); + injectKeyPress(scancodeMakeA); + injectKeyPress(scancodeMakeO); + injectKeyPress(scancodeMakeTwo); + injectKeyPress(scancodeMakeEnter); +} + +/* + * Live ring-3 proof for shell.c's new `cmd1 && cmd2 || cmd3 ...` + * conditional chaining (shellSplitAndOr, executeSingle/execute) -- types + * the command above at the real interactive shell, the same + * injected-keystroke mechanism every shell-driven demo above already + * uses. Unlike every earlier shell-syntax demo (pipelines, redirects, + * here-strings, globs), the actual subject under test here is false.c -- + * a brand-new, deliberately trivial user program whose entire job is to + * hand back a real, predictable, nonzero exit code from a genuine ring-3 + * spawn (see its own file comment) -- proving the new chaining logic + * reacts to a REAL process's own exit status, not just a builtin's + * in-kernel int return (which listDir/catFile/makeDir/changeDir/ + * doExport/doUnset already exercise on every OTHER typed command in this + * whole boot sequence, just never through `&&`/`||` before this fala). + */ +static void andOrDriverThread(void) +{ + threadSleep(andOrDriverInitialDelayMs); + while (currentThread()->state == threadSleeping) + halt(); + + kprintf("[kbd] typing shell commands for the &&/|| chaining demo\n"); + jobctlTypeFlushLine(); + + andOrTypeFailChainCommand(); + threadSleep(andOrDriverStepSettleMs); + while (currentThread()->state == threadSleeping) + halt(); + + for (;;) + halt(); } void kmain(uint32 magic, uint64 multibootInfo) @@ -180,6 +1876,12 @@ void kmain(uint32 magic, uint64 multibootInfo) runHeapTest(); runPagingTest(); + sigInstallTrampoline(); + kprintf("[sig] shared signal trampoline mapped at %p\n", (void*)sigTrampolineVirtualAddr); + + sigInstallInfoPage(); + kprintf("[sig] shared siginfo page(s) mapped at %p\n", (void*)sigInfoVirtualAddr); + pitInit(timerFrequency); kprintf("[pit] timer at %u Hz\n", timerFrequency); @@ -197,17 +1899,174 @@ void kmain(uint32 magic, uint64 multibootInfo) kprintf("[vfs] no multiboot2 module present\n"); vfsMountTmpfs("/tmp"); + /* namedreader/namedwriter (spawned below, after userland launch) are two + INDEPENDENT boot programs with no parent-child relationship and no + shared argv -- unlike fifomaster.c's children, which get a pipe.h + handle number handed to them, these two only agree on this path. The + fifo has to exist before either one can open it. */ + if (vfsMkfifo("/tmp/rendezvous") == 0) + kprintf("[vfs] mkfifo /tmp/rendezvous ready\n"); + else + kprintf("[vfs] mkfifo /tmp/rendezvous FAILED\n"); + + if (ataInit()) + kprintf("[ata] primary master '%s', %lu sectors (%lu MiB), /dev/disk0 ready\n", + ataModelString(), ataSectorCount(), + (ataSectorCount() * ataSectorSize) / (1024 * 1024)); + else + kprintf("[ata] no primary master drive detected, /dev/disk0 will read as empty\n"); + + if (sfsMount()) + kprintf("[sfs] mounted persistent filesystem at /mnt (%lu KiB usable)\n", + sfsCapacityBytes() / 1024); + else + kprintf("[sfs] no usable disk for /mnt (drive missing or too small)\n"); + + ktestRunAll(); + threadCreate("shell", shellThread); - threadCreate("workerA", workerA); - threadCreate("workerB", workerB); + uint32 workerAId = threadCreate("workerA", workerA); + uint32 workerBId = threadCreate("workerB", workerB); + + /* Both workers are pure CPU hogs (busy loops, never sleep or block), so + they are the cleanest possible demonstration that the CFS-lite + scheduler (sched.c/prioq.h) actually enforces weight, not just that it + compiles: workerA is given 3x workerB's weight, so it should accrue + vruntime three times slower and therefore win the ready-queue's + min-vruntime pick roughly three times as often. Compare the two + threads' printed iteration counts at similar tick values to see it. */ + threadSetPriority(workerAId, schedDefaultWeight * schedPriorityWeightRatio); + kprintf("[sched] workerA pid=%u weight=%u, workerB pid=%u weight=%u " + "(CFS-lite: higher weight runs more often)\n", + workerAId, schedDefaultWeight * schedPriorityWeightRatio, workerBId, schedDefaultWeight); + threadCreate("schedVerify", schedPriorityVerifierThread); + + pipeDemoHandle = pipeCreate(); + if (pipeDemoHandle >= 0) { + threadCreate("pipeProducer", pipeProducerThread); + threadCreate("pipeConsumer", pipeConsumerThread); + } else { + kprintf("[pipe] demo pipe creation failed\n"); + } + + joinDemoWorkerId = threadCreate("joinWorker", shortLivedWorkerThread); + if (joinDemoWorkerId != threadInvalidId) + threadCreate("joiner", joinerThread); + else + kprintf("[sched] join demo worker not started: thread table full\n"); kprintf("\n[boot] launching userland programs from the ramdisk into ring 3\n"); spawnUser("/bin/hello", "hello", 0x48000000, 0); spawnUser("/bin/counter", "counter", 0x58000000, 0); spawnUser("/bin/sbrktest", "sbrktest", 0x68000000, 0x64000000); spawnUser("/bin/fileread", "fileread", 0x78000000, 0); + spawnUser("/bin/pipetest", "pipetest", 0x88000000, 0); + spawnUser("/bin/disktest", "disktest", 0x98000000, 0); + spawnUser("/bin/fstest", "fstest", 0xA8000000, 0); + spawnUser("/bin/linktest", "linktest", 0xB8000000, 0); + spawnUser("/bin/fifomaster", "fifomaster", 0x118000000, 0); + spawnUser("/bin/killtest", "killtest", 0x148000000, 0); + spawnUser("/bin/namedreader", "namedreader", 0x168000000, 0); + spawnUser("/bin/namedwriter", "namedwriter", 0x178000000, 0); + spawnUser("/bin/consolereader1", "consolereader1", 0x188000000, 0); + spawnUser("/bin/consolereader2", "consolereader2", 0x198000000, 0); + threadCreate("consoleInjector", consoleInjectorThread); + spawnUser("/bin/sigtest", "sigtest", 0x1A8000000, 0); + spawnUser("/bin/jobtest", "jobtest", 0x1D8000000, 0); + spawnUser("/bin/nesttest", "nesttest", 0x1F8000000, 0); + spawnUser("/bin/eintrtest", "eintrtest", 0x218000000, 0); + spawnUser("/bin/restarttest", "restarttest", 0x238000000, 0); + spawnUser("/bin/masktest", "masktest", 0x258000000, 0); + spawnUser("/bin/sigchldtest", "sigchldtest", 0x278000000, 0); + spawnUser("/bin/waitanytest", "waitanytest", 0x298000000, 0); + spawnUser("/bin/stopchldtest", "stopchldtest", 0x2C8000000, 0); + spawnUser("/bin/nodefertest", "nodefertest", 0x2E8000000, 0); + spawnUser("/bin/alarmtest", "alarmtest", 0x2F8000000, 0); + spawnUser("/bin/siginfotest", "siginfotest", 0x318000000, 0); + spawnUser("/bin/selfstoptest", "selfstoptest", 0x3F8000000, 0); + spawnUser("/bin/cwdtest", "cwdtest", 0x458000000, 0); + spawnUser("/bin/nicetest", "nicetest", 0x4A8000000, 0); + spawnUser("/bin/date", "date", 0x4B8000000, 0); + spawnUser("/bin/timetest", "timetest", 0x4D8000000, 0); + spawnUser("/bin/rmtest", "rmtest", 0x508000000, 0); + spawnUser("/bin/permtest", "permtest", 0x518000000, 0); + spawnUser("/bin/identtest", "identtest", 0x528000000, 0); + spawnUser("/bin/dirpermtest", "dirpermtest", 0x538000000, 0); + spawnUser("/bin/grouptest", "grouptest", 0x548000000, 0); + spawnUser("/bin/pidfdtest", "pidfdtest", 0x558000000, 0); + /* mmapprotvictim and mmapexecvictim are deliberately NOT spawned here + -- like killvictim (killtest's own runtime-spawned target, elsewhere + in this function), both are only ever loaded at runtime, by + mmaptest itself (userSpawn), never directly by kmain.c. */ + spawnUser("/bin/mmaptest", "mmaptest", 0x568000000, 0); + spawnUser("/bin/polltest", "polltest", 0x378000000, 0); + pgroupfgPid = spawnUser("/bin/pgroupfg", "pgroupfg", 0x338000000, 0); + pgroupbgPid = spawnUser("/bin/pgroupbg", "pgroupbg", 0x348000000, 0); + if (pgroupfgPid != threadInvalidId) { + /* Real shell/tcsetpgrp behavior: whichever job was just launched in + the foreground claims the terminal explicitly -- pgroupbg is + deliberately left alone (its own default group, never named + here), the same way a background job never touches the + controlling terminal at all. */ + keyboardSetForegroundPgid(pgroupfgPid); + } + if (pgroupfgPid != threadInvalidId && pgroupbgPid != threadInvalidId) + threadCreate("pgroupInjector", pgroupInjectorThread); + /* jobctlfg/jobctlbg are deliberately NOT spawned here -- unlike every + program above, they are only ever loaded on demand, at runtime, by + the real interactive shell (shellRun, threadCreate'd near the top of + this function) once jobctlDriverThread types their names at it. */ + threadCreate("jobctlDriver", jobctlDriverThread); + /* pipesrc/pipesink are, like jobctlfg/jobctlbg just above, deliberately + NOT spawned here -- loaded on demand by the real shell once + pipelineDriverThread types "pipesrc | pipesink" at it. */ + threadCreate("pipelineDriver", pipelineDriverThread); + /* redirwriter/redirreader are, like pipesrc/pipesink just above, + deliberately NOT spawned here -- loaded on demand by the real shell + once redirDriverThread types the redirected commands at it. */ + threadCreate("redirDriver", redirDriverThread); + /* stderrwriter/stderrreader are, like redirwriter/redirreader just + above, deliberately NOT spawned here -- loaded on demand by the real + shell once stderrDriverThread types the stderr-redirected commands + at it. */ + threadCreate("stderrDriver", stderrDriverThread); + /* herestest is, like stderrwriter/stderrreader just above, deliberately + NOT spawned here -- loaded on demand by the real shell once + hereDriverThread types the here-string command at it. */ + threadCreate("hereDriver", hereDriverThread); + /* hcat/hsink are, like herestest just above, deliberately NOT spawned + here -- loaded on demand by the real shell once + hereStringPipelineDriverThread types the here-string-into-pipeline + command at it. */ + threadCreate("hereStringPipelineDriver", hereStringPipelineDriverThread); + /* pipecat is, like hcat/hsink just above, deliberately NOT spawned here + -- loaded on demand by the real shell once + threeStagePipelineDriverThread types the three-stage pipeline + command at it (pipesrc/pipesink themselves are also on-demand-only, + unchanged since FALA 29). */ + threadCreate("threeStagePipelineDriver", threeStagePipelineDriverThread); + /* envprint is, like pipecat just above, deliberately NOT spawned here + -- loaded on demand by the real shell once envDriverThread types + "export GREETING=frostbyte" then "envprint" at it. */ + threadCreate("envDriver", envDriverThread); + /* globcat is, like envprint just above, deliberately NOT spawned here + -- loaded on demand by the real shell once globDriverThread types + "globcat /tmp/gd/ *.txt" at it (shell.c's new wildcard glob + expansion). */ + threadCreate("globDriver", globDriverThread); + /* No new /bin binary here -- lsDriverThread only exercises shell.c's + existing mkdir/echo/ls builtins, never spawns anything. */ + threadCreate("lsDriver", lsDriverThread); + /* false is, like globcat above, deliberately NOT spawned here -- + loaded on demand by the real shell once andOrDriverThread types the + `&&`/`||`-chained command at it (shell.c's new conditional + chaining). */ + threadCreate("andOrDriver", andOrDriverThread); + execDemoPid = spawnUser("/bin/proctest", "proctest", 0xF8000000, 0); + if (execDemoPid != threadInvalidId) + threadCreate("execReaper", execReaperThread); - kprintf("[sched] %u threads total (idle, shell, 2 workers, user programs)\n", + kprintf("[sched] %u threads total (idle, shell, 2 workers, priority verifier, user programs)\n", threadCount()); kprintf("\n[boot] enabling interrupts, starting preemptive scheduler\n\n"); diff --git a/src/ktest.c b/src/ktest.c new file mode 100644 index 0000000..eb40870 --- /dev/null +++ b/src/ktest.c @@ -0,0 +1,59 @@ +#include "ktest.h" +#include "console.h" + +/* Bracketed by the linker script (see linker.ld, .rodata output section): + the ".ktests" input section holds one const KtestEntry* per registered + test. Iterating [start, end) visits every test in link order. */ +extern const KtestEntry* const ktestArrayStart[]; +extern const KtestEntry* const ktestArrayEnd[]; + +bool ktestStrEq(const char* a, const char* b) +{ + if (a == nullptr || b == nullptr) + return a == b; + while (*a && (*a == *b)) { + a++; + b++; + } + return *a == *b; +} + +bool ktestCheck(KtestCtx* t, bool ok, const char* expr, const char* file, int line) +{ + t->checks++; + if (!ok) { + t->failures++; + kprintf(" FAIL %s:%d %s\n", file, line, expr); + } + return ok; +} + +uint64 ktestRunAll(void) +{ + uint64 totalTests = 0; + uint64 totalChecks = 0; + uint64 totalFailures = 0; + uint64 failedTests = 0; + + kprintf("\n==== ktest: running in-kernel self-tests ====\n"); + + for (const KtestEntry* const* p = ktestArrayStart; p < ktestArrayEnd; p++) { + const KtestEntry* e = *p; + KtestCtx t = { e->name, 0, 0 }; + e->fn(&t); + totalTests++; + totalChecks += t.checks; + totalFailures += t.failures; + if (t.failures) { + failedTests++; + kprintf(" [FAIL] %s (%lu/%lu checks failed)\n", e->name, t.failures, t.checks); + } else { + kprintf(" [ ok ] %s (%lu checks)\n", e->name, t.checks); + } + } + + kprintf("==== ktest done: %lu of %lu tests failed ====\n", failedTests, totalTests); + kprintf("KTEST SUMMARY: %lu tests, %lu checks, %lu failed\n", + totalTests, totalChecks, totalFailures); + return totalFailures; +} diff --git a/src/list.c b/src/list.c new file mode 100644 index 0000000..209357e --- /dev/null +++ b/src/list.c @@ -0,0 +1,61 @@ +#include "list.h" + +void listInit(ListNode* head) +{ + head->next = head; + head->prev = head; +} + +bool listEmpty(const ListNode* head) +{ + return head->next == head; +} + +void listInsertAfter(ListNode* pos, ListNode* newNode) +{ + newNode->prev = pos; + newNode->next = pos->next; + pos->next->prev = newNode; + pos->next = newNode; +} + +void listInsertBefore(ListNode* pos, ListNode* newNode) +{ + listInsertAfter(pos->prev, newNode); +} + +void listPushBack(ListNode* head, ListNode* newNode) +{ + listInsertBefore(head, newNode); +} + +void listPushFront(ListNode* head, ListNode* newNode) +{ + listInsertAfter(head, newNode); +} + +void listRemove(ListNode* node) +{ + node->prev->next = node->next; + node->next->prev = node->prev; + node->next = node; + node->prev = node; +} + +usize listLen(const ListNode* head) +{ + usize count = 0; + for (const ListNode* cursor = head->next; cursor != head; cursor = cursor->next) + count++; + return count; +} + +ListNode* listFront(const ListNode* head) +{ + return listEmpty(head) ? nullptr : head->next; +} + +ListNode* listBack(const ListNode* head) +{ + return listEmpty(head) ? nullptr : head->prev; +} diff --git a/src/listTest.c b/src/listTest.c new file mode 100644 index 0000000..4b78a61 --- /dev/null +++ b/src/listTest.c @@ -0,0 +1,112 @@ +#include "ktest.h" +#include "list.h" + +typedef struct TestItem { + int value; + ListNode node; +} TestItem; + +KTEST(listEmptyInit) +{ + ListNode head; + listInit(&head); + KT_TRUE(t, listEmpty(&head)); + KT_EQ(t, listLen(&head), 0); + KT_NULL(t, listFront(&head)); + KT_NULL(t, listBack(&head)); +} + +KTEST(listPushBackOrder) +{ + ListNode head; + listInit(&head); + + TestItem items[4]; + for (int i = 0; i < 4; i++) { + items[i].value = i; + listPushBack(&head, &items[i].node); + } + + KT_FALSE(t, listEmpty(&head)); + KT_EQ(t, listLen(&head), 4); + + int expected = 0; + ListNode* cursor; + listForEach(cursor, &head) { + TestItem* item = listEntry(cursor, TestItem, node); + KT_EQ(t, item->value, expected); + expected++; + } + KT_EQ(t, expected, 4); + + TestItem* front = listEntry(listFront(&head), TestItem, node); + TestItem* back = listEntry(listBack(&head), TestItem, node); + KT_EQ(t, front->value, 0); + KT_EQ(t, back->value, 3); +} + +KTEST(listPushFrontOrder) +{ + ListNode head; + listInit(&head); + + TestItem items[3]; + for (int i = 0; i < 3; i++) { + items[i].value = i; + listPushFront(&head, &items[i].node); + } + /* pushing 0,1,2 to the front in order yields 2,1,0 */ + int expected[3] = { 2, 1, 0 }; + int idx = 0; + ListNode* cursor; + listForEach(cursor, &head) { + TestItem* item = listEntry(cursor, TestItem, node); + KT_EQ(t, item->value, expected[idx]); + idx++; + } +} + +KTEST(listRemoveMiddle) +{ + ListNode head; + listInit(&head); + + TestItem items[3]; + for (int i = 0; i < 3; i++) { + items[i].value = i; + listPushBack(&head, &items[i].node); + } + + listRemove(&items[1].node); + KT_EQ(t, listLen(&head), 2); + + int idx = 0; + int expected[2] = { 0, 2 }; + ListNode* cursor; + listForEach(cursor, &head) { + TestItem* item = listEntry(cursor, TestItem, node); + KT_EQ(t, item->value, expected[idx]); + idx++; + } + + /* a removed node becomes its own trivial list */ + KT_TRUE(t, listEmpty(&items[1].node)); +} + +KTEST(listRemoveAllEmptiesList) +{ + ListNode head; + listInit(&head); + + TestItem items[2]; + items[0].value = 10; + items[1].value = 20; + listPushBack(&head, &items[0].node); + listPushBack(&head, &items[1].node); + + listRemove(&items[0].node); + listRemove(&items[1].node); + + KT_TRUE(t, listEmpty(&head)); + KT_EQ(t, listLen(&head), 0); +} diff --git a/src/mmap.c b/src/mmap.c new file mode 100644 index 0000000..ebe91e0 --- /dev/null +++ b/src/mmap.c @@ -0,0 +1,259 @@ +#include "mmap.h" +#include "sched.h" +#include "elf.h" +#include "paging.h" +#include "pmm.h" +#include "vfs.h" +#include "string.h" + +/* + * Real demand-paged mmap(2)/munmap(2) on top of this kernel's single + * shared page table: a Vma only ever RESERVES address space (mmapReserve) + * -- no physical frame is allocated and no file byte is read until the + * very first access to a given page actually traps into isr.c's #PF + * handler, which resolves it here (mmapFaultHandler). This is the same + * "lazy" contract pagingMapUserRange's own eager sibling does NOT have + * (that one maps and zeroes every page up front, for an ELF image/stack + * that is about to be fully used anyway) -- mmap's whole point is a + * mapping the caller may only ever touch a fraction of. + * + * Every mapping is placed inside the calling thread's own elfSlotSize + * slot (elf.h), specifically the sub-range [userSlotBase + + * elfSlotStackOffset, userSlotBase + elfSlotSize) -- everything from the + * top of the small, fixed-size user stack up to the end of the slot, 128 + * MiB that no ELF LOAD segment or brk-growth ever reaches in this + * kernel's own binaries (elfSlotBrkOffset caps brk's own growth at 64 + * MiB in, elfSlotStackOffset itself at 128 MiB in -- see elf.h). There is + * no address-space-wide mmap arena the way a real kernel's own mm_struct + * has one: with a single page table shared by every thread in the + * system, "the whole address space" is not a per-thread concept to begin + * with, so mmap here is scoped exactly the way brk/the stack already are. + */ + +static uint64 pageAlignDown(uint64 x) +{ + return x & ~(uint64)(frameSize - 1); +} + +static uint64 pageAlignUp(uint64 x) +{ + return (x + frameSize - 1) & ~(uint64)(frameSize - 1); +} + +static uint64 mmapRegionStart(struct Thread* thread) +{ + return thread->userSlotBase + elfSlotStackOffset; +} + +static uint64 mmapRegionEnd(struct Thread* thread) +{ + return thread->userSlotBase + elfSlotSize; +} + +static bool rangesOverlap(uint64 aStart, uint64 aEnd, uint64 bStart, uint64 bEnd) +{ + return aStart < bEnd && bStart < aEnd; +} + +Vma* mmapFind(struct Thread* thread, uint64 addr) +{ + for (int i = 0; i < maxVmasPerThread; i++) { + Vma* v = &thread->vmas[i]; + if (v->used && addr >= v->start && addr < v->end) + return v; + } + return nullptr; +} + +static Vma* findFreeVmaSlot(struct Thread* thread) +{ + for (int i = 0; i < maxVmasPerThread; i++) + if (!thread->vmas[i].used) + return &thread->vmas[i]; + return nullptr; +} + +static bool regionFree(struct Thread* thread, uint64 start, uint64 end) +{ + for (int i = 0; i < maxVmasPerThread; i++) { + Vma* v = &thread->vmas[i]; + if (v->used && rangesOverlap(start, end, v->start, v->end)) + return false; + } + return true; +} + +/* First-fit search for `length` free bytes inside thread's own mmap + region: candidate gaps are the region's own start plus every existing + Vma's own end, sorted into address order (a plain insertion sort -- + maxVmasPerThread's small fixed bound makes anything fancier pointless), + then the first candidate `length` bytes actually fits at wins. */ +static uint64 findFreeRegion(struct Thread* thread, uint64 length) +{ + uint64 regionStart = mmapRegionStart(thread); + uint64 regionEnd = mmapRegionEnd(thread); + + uint64 candidates[maxVmasPerThread + 1]; + int candidateCount = 0; + candidates[candidateCount++] = regionStart; + for (int i = 0; i < maxVmasPerThread; i++) + if (thread->vmas[i].used) + candidates[candidateCount++] = thread->vmas[i].end; + + for (int i = 1; i < candidateCount; i++) { + uint64 key = candidates[i]; + int j = i - 1; + while (j >= 0 && candidates[j] > key) { + candidates[j + 1] = candidates[j]; + j--; + } + candidates[j + 1] = key; + } + + for (int i = 0; i < candidateCount; i++) { + uint64 candidate = candidates[i]; + if (candidate < regionStart) + continue; + if (candidate + length < candidate || candidate + length > regionEnd) + continue; + if (regionFree(thread, candidate, candidate + length)) + return candidate; + } + return 0; +} + +bool mmapReserve(struct Thread* thread, uint64 addr, uint64 length, uint32 prot, + bool anonymous, int fd, uint64 fileOffset, uint64* outAddr) +{ + if (length == 0) + return false; + if (addr % frameSize != 0 || length % frameSize != 0) + return false; + if (!anonymous && fileOffset % frameSize != 0) + return false; + + uint64 start = addr; + if (start == 0) { + start = findFreeRegion(thread, length); + if (start == 0) + return false; + } + + uint64 end = start + length; + if (end < start) + return false; + if (start < mmapRegionStart(thread) || end > mmapRegionEnd(thread)) + return false; + if (!regionFree(thread, start, end)) + return false; + + Vma* slot = findFreeVmaSlot(thread); + if (!slot) + return false; + + slot->used = true; + slot->start = start; + slot->end = end; + slot->prot = prot; + slot->anonymous = anonymous; + slot->fd = anonymous ? -1 : fd; + slot->fileOffset = anonymous ? 0 : fileOffset; + + *outAddr = start; + return true; +} + +bool mmapRelease(struct Thread* thread, uint64 addr, uint64 length) +{ + if (length == 0 || addr % frameSize != 0) + return false; + + uint64 unmapStart = addr; + uint64 unmapEnd = addr + pageAlignUp(length); + if (unmapEnd < unmapStart) + return false; + + /* Up-front check: refuse the WHOLE call if releasing this range would + need to split a Vma in the middle (touching neither end) and no free + slot exists to hold the resulting upper half. A thread's own Vmas + never overlap each other, so at most one can ever need splitting. */ + for (int i = 0; i < maxVmasPerThread; i++) { + Vma* v = &thread->vmas[i]; + if (v->used && v->start < unmapStart && v->end > unmapEnd && !findFreeVmaSlot(thread)) + return false; + } + + for (int i = 0; i < maxVmasPerThread; i++) { + Vma* v = &thread->vmas[i]; + if (!v->used || !rangesOverlap(v->start, v->end, unmapStart, unmapEnd)) + continue; + + uint64 freeFrom = v->start > unmapStart ? v->start : unmapStart; + uint64 freeTo = v->end < unmapEnd ? v->end : unmapEnd; + pagingUnmapUserRange(freeFrom, freeTo - freeFrom); + + if (v->start >= unmapStart && v->end <= unmapEnd) { + v->used = false; + } else if (v->start < unmapStart && v->end > unmapEnd) { + Vma* upper = findFreeVmaSlot(thread); + *upper = *v; + upper->start = unmapEnd; + v->end = unmapStart; + } else if (v->start < unmapStart) { + v->end = unmapStart; + } else { + uint64 trimmed = unmapEnd - v->start; + if (!v->anonymous) + v->fileOffset += trimmed; + v->start = unmapEnd; + } + } + return true; +} + +bool mmapFaultHandler(struct Thread* thread, uint64 faultAddr, bool forWrite) +{ + Vma* v = mmapFind(thread, faultAddr); + if (!v) + return false; + if (forWrite && !(v->prot & mmapProtWrite)) + return false; + if (!forWrite && !(v->prot & mmapProtRead)) + return false; + + uint64 pageAddr = pageAlignDown(faultAddr); + if (pagingResolve(pageAddr) != 0) + return true; + + uint64 frame = pmmAlloc(); + if (frame == 0) + return false; + memset((void*)frame, 0, frameSize); + + if (!v->anonymous) { + FileDescriptor* fdEntry = &thread->fds[v->fd]; + if (fdEntry->kind == fdFree) { + pmmFree(frame); + return false; + } + + uint64 savedOffset = fdEntry->offset; + fdEntry->offset = v->fileOffset + (pageAddr - v->start); + vfsRead(v->fd, (void*)frame, frameSize); + fdEntry->offset = savedOffset; + } + + uint64 flags = pageUser | ((v->prot & mmapProtWrite) ? pageWrite : 0); + if (!(v->prot & mmapProtExec)) + flags |= pageNoExecute; + if (!pagingMap(pageAddr, frame, flags)) { + pmmFree(frame); + return false; + } + return true; +} + +void mmapReleaseAll(struct Thread* thread) +{ + memset(thread->vmas, 0, sizeof(thread->vmas)); +} diff --git a/src/mmapTest.c b/src/mmapTest.c new file mode 100644 index 0000000..669f487 --- /dev/null +++ b/src/mmapTest.c @@ -0,0 +1,320 @@ +#include "ktest.h" +#include "mmap.h" +#include "sched.h" +#include "elf.h" +#include "paging.h" +#include "pmm.h" +#include "vfs.h" +#include "fs.h" +#include "string.h" + +/* + * Coverage for mmap.c's real demand-paged mmap(2)/munmap(2) -- run entirely + * on the idle thread (KTEST always runs there, before schedStart(), see + * ktest.h's own file comment), whose real userSlotBase is 0 (a kernel + * thread owns no user slot). Every test below temporarily repoints it at + * mmapTestSlotBase, a 256 MiB (elfSlotSize)-aligned scratch address far + * above any real binary's USER_BASE (Makefile's highest is currently under + * 0x600000000, ~24 GiB) and far above pagingTest.c's own scratch range + * (0x200000000-0x202000000, ~8 GiB) so the two files can never collide even + * if their tests interleave, then restores it to 0 before returning -- + * exactly the "explicit, restorable state" every other KTEST here already + * insists on rather than depending on hidden global setup. + */ + +#define mmapTestSlotBase 0x300000000000ull + +static uint64 setupSlot(Thread* t) +{ + uint64 previous = t->userSlotBase; + t->userSlotBase = mmapTestSlotBase; + mmapReleaseAll(t); + return previous; +} + +static void teardownSlot(Thread* t, uint64 previous) +{ + pagingUnmapUserRange(mmapTestSlotBase, elfSlotSize); + mmapReleaseAll(t); + t->userSlotBase = previous; +} + +KTEST(mmapReserveRejectsMisalignedZeroAndOverlap) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + KT_FALSE(t, mmapReserve(self, 1, frameSize, mmapProtRead, true, -1, 0, &addr)); + KT_FALSE(t, mmapReserve(self, 0, 0, mmapProtRead, true, -1, 0, &addr)); + KT_FALSE(t, mmapReserve(self, mmapTestSlotBase + elfSlotStackOffset, frameSize + 1, mmapProtRead, true, -1, 0, &addr)); + + uint64 base = mmapTestSlotBase + elfSlotStackOffset; + KT_TRUE(t, mmapReserve(self, base, 2 * frameSize, mmapProtRead | mmapProtWrite, true, -1, 0, &addr)); + KT_EQU(t, addr, base); + + /* Overlapping the just-reserved [base, base+2*frameSize) from either + side must be refused -- mmapReserve never silently merges/replaces. */ + KT_FALSE(t, mmapReserve(self, base + frameSize, frameSize, mmapProtRead, true, -1, 0, &addr)); + KT_FALSE(t, mmapReserve(self, base - frameSize, 2 * frameSize, mmapProtRead, true, -1, 0, &addr)); + + /* Outside the mmap region entirely (below stackTop, where the real + stack itself lives) is refused regardless of alignment. */ + KT_FALSE(t, mmapReserve(self, mmapTestSlotBase, frameSize, mmapProtRead, true, -1, 0, &addr)); + + teardownSlot(self, previous); +} + +KTEST(mmapReserveAutoPlacementIsFirstFitAndNonOverlapping) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 firstAddr, secondAddr; + KT_TRUE(t, mmapReserve(self, 0, 3 * frameSize, mmapProtRead, true, -1, 0, &firstAddr)); + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &secondAddr)); + + KT_EQU(t, firstAddr, mmapTestSlotBase + elfSlotStackOffset); + KT_EQU(t, secondAddr, firstAddr + 3 * frameSize); + + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerAnonymousDemandZeroFillThenWritable) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead | mmapProtWrite, true, -1, 0, &addr)); + + /* Nothing mapped yet -- mmapReserve never eagerly allocates. */ + KT_EQ(t, pagingResolve(addr), 0); + + KT_TRUE(t, mmapFaultHandler(self, addr + 42, false)); + KT_NE(t, pagingResolve(addr), 0); + + /* Demand-zero: untouched content must actually be zero, not whatever + garbage the frame's previous owner (if any) left behind. */ + uint8* bytes = (uint8*)addr; + for (int i = 0; i < 64; i++) + KT_EQ(t, bytes[i], 0); + + /* A write fault on an already-present page (mmapProtWrite granted at + reserve time) is simply a no-op success -- the page was already + mapped pageWrite by the read fault above. */ + KT_TRUE(t, mmapFaultHandler(self, addr, true)); + bytes[0] = 0xAB; + KT_EQ(t, bytes[0], 0xAB); + + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerSetsNoExecuteUnlessProtExecGranted) +{ + if (!pagingNxSupported()) + return; + + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 noExecAddr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead | mmapProtWrite, true, -1, 0, &noExecAddr)); + KT_TRUE(t, mmapFaultHandler(self, noExecAddr, false)); + KT_FALSE(t, pagingIsExecutable(noExecAddr)); + + uint64 execAddr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead | mmapProtWrite | mmapProtExec, true, -1, 0, &execAddr)); + KT_TRUE(t, mmapFaultHandler(self, execAddr, false)); + KT_TRUE(t, pagingIsExecutable(execAddr)); + + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerRefusesWriteAgainstReadOnlyMapping) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &addr)); + + /* A read fault is fine (mmapProtRead granted)... */ + KT_TRUE(t, mmapFaultHandler(self, addr, false)); + /* ...but a write fault against the SAME already-reserved range, on a + fresh page that never got mapped by the read above, must be refused + -- this is exactly the case isr.c turns into a real SIGSEGV. */ + uint64 secondAddr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &secondAddr)); + KT_FALSE(t, mmapFaultHandler(self, secondAddr, true)); + KT_EQ(t, pagingResolve(secondAddr), 0); + + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerRefusesAddressOutsideAnyVma) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + KT_FALSE(t, mmapFaultHandler(self, mmapTestSlotBase + elfSlotStackOffset + 5 * frameSize, false)); + + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerFileBackedReadsRealFileContent) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + const char* payload = "mmapTest file-backed page content, byte-for-byte."; + int fd = vfsOpen("/tmp/mmaptest-src.txt", oReadWrite | oCreate | oTrunc); + KT_TRUE(t, fd >= 0); + KT_EQ(t, vfsWrite(fd, payload, strlen(payload)), (int64)strlen(payload)); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, false, fd, 0, &addr)); + KT_TRUE(t, mmapFaultHandler(self, addr, false)); + + KT_TRUE(t, memcmp((const void*)addr, payload, strlen(payload)) == 0); + /* Past end-of-file within the same page: zero-padded, not garbage. */ + KT_EQ(t, ((const uint8*)addr)[strlen(payload)], 0); + + /* The fd's own seek offset must be exactly what it was before the + fault -- mmapFaultHandler saves/restores it around its own read, so + a caller's ordinary vfsRead/vfsLseek use of the same fd is never + disturbed by an unrelated page fault. */ + KT_EQ(t, vfsLseek(fd, 0, seekCur), (int64)strlen(payload)); + + vfsClose(fd); + teardownSlot(self, previous); +} + +KTEST(mmapFaultHandlerRefusesFileBackedReadAfterFdClosed) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + int fd = vfsOpen("/tmp/mmaptest-closed.txt", oReadWrite | oCreate | oTrunc); + KT_TRUE(t, fd >= 0); + KT_EQ(t, vfsWrite(fd, "x", 1), 1); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, false, fd, 0, &addr)); + vfsClose(fd); + + /* Honestly scoped limitation (see mmap.h's own Vma doc comment): this + mapping cannot outlive the fd that backs it. A fault against it once + the fd is gone must fail cleanly, never silently serve zeroed or + stale data as if it had succeeded. */ + KT_FALSE(t, mmapFaultHandler(self, addr, false)); + KT_EQ(t, pagingResolve(addr), 0); + + teardownSlot(self, previous); +} + +KTEST(mmapReleaseFreesFramesAndAllowsReReservation) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + uint64 framesBefore = pmmFreeFrames(); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, 2 * frameSize, mmapProtRead | mmapProtWrite, true, -1, 0, &addr)); + KT_TRUE(t, mmapFaultHandler(self, addr, false)); + KT_TRUE(t, mmapFaultHandler(self, addr + frameSize, false)); + KT_LE(t, pmmFreeFrames(), framesBefore - 2); + + KT_TRUE(t, mmapRelease(self, addr, 2 * frameSize)); + KT_EQU(t, pmmFreeFrames(), framesBefore); + KT_EQ(t, pagingResolve(addr), 0); + KT_EQ(t, pagingResolve(addr + frameSize), 0); + KT_NULL(t, mmapFind(self, addr)); + + /* The address range is genuinely free again, not just unmapped. */ + uint64 secondAddr; + KT_TRUE(t, mmapReserve(self, addr, 2 * frameSize, mmapProtRead, true, -1, 0, &secondAddr)); + KT_EQU(t, secondAddr, addr); + + teardownSlot(self, previous); +} + +KTEST(mmapReleaseOnNeverMappedRangeIsSafeNoop) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + uint64 framesBefore = pmmFreeFrames(); + + uint64 probe = mmapTestSlotBase + elfSlotStackOffset + 10 * frameSize; + KT_TRUE(t, mmapRelease(self, probe, 3 * frameSize)); + KT_EQU(t, pmmFreeFrames(), framesBefore); + + teardownSlot(self, previous); +} + +KTEST(mmapReleaseSplitsMiddleOfAVmaIntoTwo) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, 3 * frameSize, mmapProtRead | mmapProtWrite, true, -1, 0, &addr)); + KT_TRUE(t, mmapFaultHandler(self, addr, false)); + KT_TRUE(t, mmapFaultHandler(self, addr + frameSize, false)); + KT_TRUE(t, mmapFaultHandler(self, addr + 2 * frameSize, false)); + + /* Unmap only the middle page -- the low and high pages must survive, + untouched, as two now-independent Vmas. */ + KT_TRUE(t, mmapRelease(self, addr + frameSize, frameSize)); + + KT_NE(t, pagingResolve(addr), 0); + KT_EQ(t, pagingResolve(addr + frameSize), 0); + KT_NE(t, pagingResolve(addr + 2 * frameSize), 0); + + KT_NOTNULL(t, mmapFind(self, addr)); + KT_NULL(t, mmapFind(self, addr + frameSize)); + KT_NOTNULL(t, mmapFind(self, addr + 2 * frameSize)); + + /* The middle gap is genuinely free address space again. */ + uint64 refilled; + KT_TRUE(t, mmapReserve(self, addr + frameSize, frameSize, mmapProtRead, true, -1, 0, &refilled)); + + teardownSlot(self, previous); +} + +KTEST(mmapReserveFailsOnceVmaTableIsFull) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + for (int i = 0; i < maxVmasPerThread; i++) + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &addr)); + + KT_FALSE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &addr)); + + teardownSlot(self, previous); +} + +KTEST(mmapReleaseAllClearsBookkeepingOnly) +{ + Thread* self = currentThread(); + uint64 previous = setupSlot(self); + + uint64 addr; + KT_TRUE(t, mmapReserve(self, 0, frameSize, mmapProtRead, true, -1, 0, &addr)); + KT_TRUE(t, mmapFaultHandler(self, addr, false)); + KT_NE(t, pagingResolve(addr), 0); + + mmapReleaseAll(self); + + KT_NULL(t, mmapFind(self, addr)); + /* mmapReleaseAll deliberately does not touch the page table -- callers + that need the frame back reclaim it their own way first (doExec). + The frame is still really mapped; teardownSlot's own whole-region + pagingUnmapUserRange (not mmapReleaseAll again) is what reclaims it + for this test. */ + KT_NE(t, pagingResolve(addr), 0); + + teardownSlot(self, previous); +} diff --git a/src/paging.c b/src/paging.c index 899dd0d..4593be0 100644 --- a/src/paging.c +++ b/src/paging.c @@ -6,6 +6,20 @@ #define entryPageSize 0x80 #define addressMask 0x000FFFFFFFFFF000ull +/* Set by boot.S once, before kmain ever runs: 1 if CPUID.80000001H:EDX.NX + was reported AND IA32_EFER.NXE was actually written, 0 otherwise (either + the CPU never claimed the feature, or -- unreachable on any CPU that + passed this kernel's own checkLongMode, but checked independently and + honestly anyway -- some hypervisor quirk refused it). A plain uint32, + not this codebase's own enum bool, so boot.S's 32-bit `movl` store has + an unambiguous, non-implementation-defined width to write into. */ +extern uint32 cpuNxSupported; + +bool pagingNxSupported(void) +{ + return cpuNxSupported != 0; +} + static uint64 readCr3(void) { uint64 value; @@ -65,11 +79,72 @@ bool pagingMap(uint64 virtualAddr, uint64 physicalAddr, uint64 flags) if (!pt) return false; - pt[ptIndex] = (physicalAddr & addressMask) | (flags & 0xFFF) | pagePresent; + /* flags & 0xFFF keeps the low-12-bits PTE flags (pagePresent/pageWrite/ + pageUser, plus whatever hardware-ignored bits a caller might pass); + pageNoExecute (bit 63) is a completely separate mask so it survives + that truncation -- silently dropped rather than written when + pagingNxSupported() is false, since bit 63 of a PTE is architecturally + reserved (must be 0) whenever IA32_EFER.NXE is clear, and setting a + reserved bit faults. */ + uint64 leafFlags = (flags & 0xFFF) | pagePresent; + if ((flags & pageNoExecute) && pagingNxSupported()) + leafFlags |= pageNoExecute; + + pt[ptIndex] = (physicalAddr & addressMask) | leafFlags; invalidatePage(virtualAddr); return true; } +bool pagingIsExecutable(uint64 virtualAddr) +{ + uint64 pml4Index = (virtualAddr >> 39) & 0x1FF; + uint64 pdptIndex = (virtualAddr >> 30) & 0x1FF; + uint64 pdIndex = (virtualAddr >> 21) & 0x1FF; + uint64 ptIndex = (virtualAddr >> 12) & 0x1FF; + + uint64* pml4 = tableFrom(readCr3()); + uint64* pdpt = nextTable(pml4, pml4Index, 0, false); + if (!pdpt) + return false; + uint64* pd = nextTable(pdpt, pdptIndex, 0, false); + if (!pd) + return false; + if (pd[pdIndex] & entryPageSize) + return (pd[pdIndex] & entryPresent) && !(pd[pdIndex] & pageNoExecute); + + uint64* pt = nextTable(pd, pdIndex, 0, false); + if (!pt) + return false; + if (!(pt[ptIndex] & entryPresent)) + return false; + return !(pt[ptIndex] & pageNoExecute); +} + +bool pagingIsWritable(uint64 virtualAddr) +{ + uint64 pml4Index = (virtualAddr >> 39) & 0x1FF; + uint64 pdptIndex = (virtualAddr >> 30) & 0x1FF; + uint64 pdIndex = (virtualAddr >> 21) & 0x1FF; + uint64 ptIndex = (virtualAddr >> 12) & 0x1FF; + + uint64* pml4 = tableFrom(readCr3()); + uint64* pdpt = nextTable(pml4, pml4Index, 0, false); + if (!pdpt) + return false; + uint64* pd = nextTable(pdpt, pdptIndex, 0, false); + if (!pd) + return false; + if (pd[pdIndex] & entryPageSize) + return (pd[pdIndex] & entryPresent) && (pd[pdIndex] & pageWrite); + + uint64* pt = nextTable(pd, pdIndex, 0, false); + if (!pt) + return false; + if (!(pt[ptIndex] & entryPresent)) + return false; + return (pt[ptIndex] & pageWrite) != 0; +} + uint64 pagingResolve(uint64 virtualAddr) { uint64 pml4Index = (virtualAddr >> 39) & 0x1FF; @@ -95,7 +170,7 @@ uint64 pagingResolve(uint64 virtualAddr) return (pt[ptIndex] & addressMask) + (virtualAddr & 0xFFF); } -bool pagingMapUserRange(uint64 virtualAddr, uint64 size) +bool pagingMapUserRangeExact(uint64 virtualAddr, uint64 size, uint64 flags) { uint64 first = virtualAddr & ~(uint64)(frameSize - 1); uint64 last = (virtualAddr + size + frameSize - 1) & ~(uint64)(frameSize - 1); @@ -106,9 +181,92 @@ bool pagingMapUserRange(uint64 virtualAddr, uint64 size) uint64 frame = pmmAlloc(); if (frame == 0) return false; - if (!pagingMap(page, frame, pageUser | pageWrite)) + if (!pagingMap(page, frame, pageUser | flags)) return false; memset((void*)page, 0, frameSize); } return true; } + +bool pagingMapUserRangeFlags(uint64 virtualAddr, uint64 size, uint64 extraFlags) +{ + return pagingMapUserRangeExact(virtualAddr, size, pageWrite | extraFlags); +} + +bool pagingMapUserRange(uint64 virtualAddr, uint64 size) +{ + return pagingMapUserRangeFlags(virtualAddr, size, 0); +} + +/* True if every one of a table's 512 entries is not-present -- used to + decide whether a page-table page that unmapping just emptied out can be + given back to the pmm itself, not just the leaf data frame it pointed + to. Must scan the whole table, not just the entry this call touched: a + PD table in particular can be shared by several unrelated elfSlotSize + slots at once (a PDPT entry spans 1GB, four times a 256 MiB slot), so + only a table that is genuinely empty end-to-end is safe to free. */ +static bool tableEmpty(const uint64* table) +{ + for (uint64 i = 0; i < 512; i++) + if (table[i] & entryPresent) + return false; + return true; +} + +static void unmapOnePage(uint64 virtualAddr) +{ + uint64 pml4Index = (virtualAddr >> 39) & 0x1FF; + uint64 pdptIndex = (virtualAddr >> 30) & 0x1FF; + uint64 pdIndex = (virtualAddr >> 21) & 0x1FF; + uint64 ptIndex = (virtualAddr >> 12) & 0x1FF; + + uint64* pml4 = tableFrom(readCr3()); + uint64* pdpt = nextTable(pml4, pml4Index, 0, false); + if (!pdpt) + return; + uint64* pd = nextTable(pdpt, pdptIndex, 0, false); + if (!pd) + return; + if (pd[pdIndex] & entryPageSize) + return; + + uint64* pt = nextTable(pd, pdIndex, 0, false); + if (!pt) + return; + if (!(pt[ptIndex] & entryPresent)) + return; + + pmmFree(pt[ptIndex] & addressMask); + pt[ptIndex] = 0; + invalidatePage(virtualAddr); + + /* Bottom-up structural cleanup: a PT/PD page-table page is itself just + a pmm frame (see nextTable's create path), and every page + pagingMapUserRange ever maps allocates one on demand -- leaving them + behind once the last leaf entry they held is gone would be a second, + smaller leak underneath the one this function exists to fix. + Deliberately stops at PD: reclaiming a PDPT or the PML4 itself would + mean tearing down a structure shared by up to 512GB / 1GB of + unrelated address space respectively, which nothing in this kernel's + single shared page table can ever actually make fully empty (the + kernel's own mappings always live somewhere under the same PML4 + entry as every user slot). */ + if (tableEmpty(pt)) { + pmmFree((uint64)pt); + pd[pdIndex] = 0; + + if (tableEmpty(pd)) { + pmmFree((uint64)pd); + pdpt[pdptIndex] = 0; + } + } +} + +void pagingUnmapUserRange(uint64 virtualAddr, uint64 size) +{ + uint64 first = virtualAddr & ~(uint64)(frameSize - 1); + uint64 last = (virtualAddr + size + frameSize - 1) & ~(uint64)(frameSize - 1); + + for (uint64 page = first; page < last; page += frameSize) + unmapOnePage(page); +} diff --git a/src/pagingTest.c b/src/pagingTest.c new file mode 100644 index 0000000..7d00a22 --- /dev/null +++ b/src/pagingTest.c @@ -0,0 +1,150 @@ +#include "ktest.h" +#include "paging.h" +#include "pmm.h" + +/* + * Coverage for pagingUnmapUserRange (paging.c) -- the inverse of + * pagingMapUserRange, added so threadReap/threadDiscard (sched.c) and + * doExec (syscall.c) can actually give a dead or replaced process's + * physical frames back to the pmm instead of leaking them forever (every + * spawn/exec before this used to permanently consume frames, since + * frostbyte's single shared page table is never touched on thread death). + * + * Addresses here are picked far above the highest real USER_BASE slot any + * binary is linked at (0x40000000..0xF8000000, see Makefile/kmain.c) so + * these tests can never collide with a real program's mapping, and each + * test uses its own base so they cannot interfere with one another even + * if run out of link order. + */ + +#define pagingTestBaseA 0x200000000ull +#define pagingTestBaseB 0x201000000ull +#define pagingTestBaseC 0x202000000ull +#define pagingTestBaseD 0x203000000ull +#define pagingTestBaseE 0x204000000ull + +KTEST(pagingMapUserRangeThenUnmapUserRangeReturnsFramesToPmm) +{ + uint64 before = pmmFreeFrames(); + + /* At least 3 frames for the data pages themselves -- pagingTestBaseA is + virgin address space, so the map also has to pay for whatever PDPT/ + PD/PT page-table pages do not exist yet along the way (nextTable's + create path, paging.c), which this test does not try to predict the + exact count of. The unmap side below is the precise assertion: every + frame spent here, data or structural, must come all the way back. */ + KT_TRUE(t, pagingMapUserRange(pagingTestBaseA, 3 * frameSize)); + KT_LE(t, pmmFreeFrames(), before - 3); + for (uint64 i = 0; i < 3; i++) + KT_NE(t, pagingResolve(pagingTestBaseA + i * frameSize), 0); + + pagingUnmapUserRange(pagingTestBaseA, 3 * frameSize); + KT_EQU(t, pmmFreeFrames(), before); + for (uint64 i = 0; i < 3; i++) + KT_EQ(t, pagingResolve(pagingTestBaseA + i * frameSize), 0); +} + +KTEST(pagingUnmapUserRangeOnNeverMappedRangeIsSafeNoop) +{ + uint64 before = pmmFreeFrames(); + + pagingUnmapUserRange(pagingTestBaseB, 5 * frameSize); + + KT_EQU(t, pmmFreeFrames(), before); + for (uint64 i = 0; i < 5; i++) + KT_EQ(t, pagingResolve(pagingTestBaseB + i * frameSize), 0); +} + +KTEST(pagingUnmapUserRangeOnlyFreesPagesActuallyPresent) +{ + uint64 before = pmmFreeFrames(); + + uint64 frame0 = pmmAlloc(); + uint64 frame2 = pmmAlloc(); + KT_NE(t, frame0, 0); + KT_NE(t, frame2, 0); + KT_TRUE(t, pagingMap(pagingTestBaseC, frame0, pageUser | pageWrite)); + KT_TRUE(t, pagingMap(pagingTestBaseC + 2 * frameSize, frame2, pageUser | pageWrite)); + /* pagingTestBaseC + 1*frameSize and +3*frameSize deliberately left + unmapped -- a stand-in for the gaps a real elfSlotSize slot is full + of (a small program's image plus its stack cover only a handful of + the 65536 pages a whole slot spans). before already accounts for the + two explicit pmmAlloc calls above; pagingMap may spend a couple more + on fresh PD/PT structure for this virgin address, same caveat as the + first test in this file -- the unmap side is the exact assertion. */ + + KT_LE(t, pmmFreeFrames(), before - 2); + + pagingUnmapUserRange(pagingTestBaseC, 4 * frameSize); + + KT_EQU(t, pmmFreeFrames(), before); + for (uint64 i = 0; i < 4; i++) + KT_EQ(t, pagingResolve(pagingTestBaseC + i * frameSize), 0); +} + +/* + * Coverage for pageNoExecute (paging.h) and its plumbing: pagingMap's own + * fix to stop masking bit 63 out of the leaf PTE it writes, and + * pagingMapUserRangeFlags/pagingIsExecutable, the two entry points + * mmap.c/kmain.c/syscall.c/jobctl.c/sched.c actually use. Only meaningful + * when pagingNxSupported() is true (boot.S's IA32_EFER.NXE, gated on + * CPUID.80000001H:EDX.NX) -- every QEMU/TCG target this project's own gate + * (tools/ci-test.sh) boots under reports the feature, but a KTEST skipping + * itself on hardware that genuinely lacks it is more honest than a KTEST + * that would otherwise assert a bit was set when pagingMap deliberately, + * correctly, refused to write it. + */ +KTEST(pagingMapUserRangeFlagsAppliesNoExecuteWhenNxSupported) +{ + if (!pagingNxSupported()) + return; + + KT_TRUE(t, pagingMapUserRangeFlags(pagingTestBaseD, frameSize, 0)); + KT_TRUE(t, pagingIsExecutable(pagingTestBaseD)); + + pagingUnmapUserRange(pagingTestBaseD, frameSize); + KT_TRUE(t, pagingMapUserRangeFlags(pagingTestBaseD, frameSize, pageNoExecute)); + KT_FALSE(t, pagingIsExecutable(pagingTestBaseD)); + + pagingUnmapUserRange(pagingTestBaseD, frameSize); +} + +KTEST(pagingIsExecutableIsFalseForUnmappedAddress) +{ + KT_FALSE(t, pagingIsExecutable(pagingTestBaseD + 7 * frameSize)); +} + +/* + * Coverage for pagingMapUserRangeExact and pagingIsWritable: unlike + * pagingMapUserRangeFlags (which always forces pageWrite on), Exact is the + * one entry point that can produce a genuinely READ-ONLY user mapping -- + * the primitive elf.c's elfLoad needs for a binary's own .text/.rodata + * PT_LOAD segment. Proven directly here via pagingIsWritable, the same + * "deterministic introspection instead of only a live fault" approach + * pagingIsExecutable already established above; user/elfwritevictim.c is + * the live, real-SIGSEGV proof that a genuinely read-only mapping refuses + * a ring-3 write, not just that this bit got written. + */ +KTEST(pagingMapUserRangeExactOmitsWriteWhenNotRequested) +{ + KT_TRUE(t, pagingMapUserRangeExact(pagingTestBaseE, frameSize, 0)); + KT_FALSE(t, pagingIsWritable(pagingTestBaseE)); + KT_TRUE(t, pagingIsExecutable(pagingTestBaseE)); + + pagingUnmapUserRange(pagingTestBaseE, frameSize); +} + +KTEST(pagingMapUserRangeExactAppliesWriteAndNoExecuteTogether) +{ + KT_TRUE(t, pagingMapUserRangeExact(pagingTestBaseE, frameSize, pageWrite | pageNoExecute)); + KT_TRUE(t, pagingIsWritable(pagingTestBaseE)); + if (pagingNxSupported()) + KT_FALSE(t, pagingIsExecutable(pagingTestBaseE)); + + pagingUnmapUserRange(pagingTestBaseE, frameSize); +} + +KTEST(pagingIsWritableIsFalseForUnmappedAddress) +{ + KT_FALSE(t, pagingIsWritable(pagingTestBaseE + 7 * frameSize)); +} diff --git a/src/pidfd.c b/src/pidfd.c new file mode 100644 index 0000000..5e3c7de --- /dev/null +++ b/src/pidfd.c @@ -0,0 +1,67 @@ +#include "pidfd.h" +#include "sched.h" + +typedef struct PidfdEntry { + bool used; + uint32 targetId; + uint32 targetGeneration; +} PidfdEntry; + +static PidfdEntry pidfds[maxPidfds]; + +static PidfdEntry* pidfdAt(int handle) +{ + if (handle < 0 || handle >= (int)maxPidfds) + return nullptr; + if (!pidfds[handle].used) + return nullptr; + return &pidfds[handle]; +} + +static int allocPidfdSlot(void) +{ + for (int i = 0; i < (int)maxPidfds; i++) + if (!pidfds[i].used) + return i; + return -1; +} + +int pidfdOpen(uint32 targetId) +{ + if (targetId == 0 || targetId >= threadCapacity()) + return -1; + if (threadAt(targetId)->state == threadUnused) + return -1; + + int slot = allocPidfdSlot(); + if (slot < 0) + return -1; + + pidfds[slot].used = true; + pidfds[slot].targetId = targetId; + pidfds[slot].targetGeneration = threadAt(targetId)->generation; + return slot; +} + +uint32 pidfdTarget(int handle) +{ + PidfdEntry* p = pidfdAt(handle); + if (!p) + return threadInvalidId; + /* targetId was range/liveness-checked once, at pidfdOpen time, and + never changes afterward -- threadAt is safe here without repeating + that check. A mismatch means the slot has since been freed + (threadReap/threadDiscard's memset zeroes Thread.generation back to + 0) or handed to an entirely different, unrelated thread. */ + if (threadAt(p->targetId)->generation != p->targetGeneration) + return threadInvalidId; + return p->targetId; +} + +void pidfdClose(int handle) +{ + PidfdEntry* p = pidfdAt(handle); + if (!p) + return; + p->used = false; +} diff --git a/src/pidfdTest.c b/src/pidfdTest.c new file mode 100644 index 0000000..bb224c6 --- /dev/null +++ b/src/pidfdTest.c @@ -0,0 +1,167 @@ +#include "ktest.h" +#include "sched.h" +#include "pidfd.h" +#include "ports.h" + +/* + * Pure logic tests for pidfd.h/pidfd.c: no live scheduler needed (same + * "runs before schedStart(), current thread is idle, every created thread + * stays Runnable/never-run for its whole life" setup schedTest.c's own + * threadCreate/threadDiscard tests already use), since every property here + * is about sched.c's Thread.generation bookkeeping, not about actually + * scheduling anything. + * + * The marquee test below (pidfdTargetDetectsSlotRecycledByAnUnrelatedThread) + * is the deterministic, pure-logic reproduction of the exact scenario + * killtest.c's own long-standing comment warns about but never guards + * against ("killing it again must fail cleanly, not resurrect a slot that + * has since been reused") and sched.c's own allocSlot doc comment + * documents as a real, previously-unfixed race: a stale handle must never + * silently act on whichever unrelated, live thread has since been handed + * the exact same numeric slot. A live boot cannot reliably force this + * exact collision on demand (allocSlot's round-robin cursor needs the + * WHOLE table full before the very next create is forced back onto one + * specific freed slot, see its own doc comment) -- exactly why this test + * uses the same fill-the-table technique schedTest.c's own + * threadCreateReusesTheOnlyFreedSlotOnceTheTableIsFull already established + * for proving deterministic slot reuse, rather than a live demo (see + * user/pidfdtest.c instead for the real ring-3 syscall-path proof of + * post-reap staleness detection alone). + */ + +static void noopThreadEntry(void) +{ + for (;;) + halt(); +} + +KTEST(pidfdOpenRejectsIdleOutOfRangeAndUnusedTargets) +{ + KT_EQ(t, pidfdOpen(0), -1); + KT_EQ(t, pidfdOpen(threadCapacity()), -1); + KT_EQ(t, pidfdOpen(threadCapacity() + 1000), -1); + + /* Same "freed slot stays genuinely Unused until the round-robin cursor + wraps back around to it" setup as schedTest.c's own + threadCreateSkipsAFreshlyFreedLowSlotInFavorOfMovingForward. */ + uint32 a = threadCreate("pfA", noopThreadEntry); + uint32 b = threadCreate("pfB", noopThreadEntry); + uint32 c = threadCreate("pfC", noopThreadEntry); + KT_TRUE(t, threadDiscard(b)); + + KT_EQ(t, pidfdOpen(b), -1); + + KT_TRUE(t, threadDiscard(a)); + KT_TRUE(t, threadDiscard(c)); +} + +KTEST(pidfdOpenAndTargetRoundtripThenClose) +{ + uint32 x = threadCreate("pfX", noopThreadEntry); + + int handle = pidfdOpen(x); + KT_GE(t, handle, 0); + KT_EQU(t, pidfdTarget(handle), x); + + pidfdClose(handle); + KT_EQU(t, pidfdTarget(handle), threadInvalidId); + + /* Closing twice must not corrupt anything else's slot. */ + pidfdClose(handle); + KT_EQU(t, pidfdTarget(handle), threadInvalidId); + + KT_TRUE(t, threadDiscard(x)); +} + +KTEST(pidfdTargetDetectsDiscardedTarget) +{ + uint32 x = threadCreate("pfDiscarded", noopThreadEntry); + int handle = pidfdOpen(x); + KT_GE(t, handle, 0); + + KT_TRUE(t, threadDiscard(x)); + + /* threadDiscard's memset reset Thread.generation for slot x back to 0 + -- pidfdTarget must notice immediately, with no new thread ever + having claimed the slot yet. */ + KT_EQU(t, pidfdTarget(handle), threadInvalidId); + + pidfdClose(handle); +} + +KTEST(pidfdTargetDetectsSlotRecycledByAnUnrelatedThread) +{ + uint32 capacity = threadCapacity(); + uint32 created[capacity]; + uint32 count = 0; + + while (count < capacity) { + uint32 id = threadCreate("pfFill", noopThreadEntry); + if (id == threadInvalidId) + break; + created[count++] = id; + } + KT_EQU(t, threadCreate("pfShouldFail", noopThreadEntry), threadInvalidId); + + uint32 victim = created[count / 2]; + int handle = pidfdOpen(victim); + KT_GE(t, handle, 0); + KT_EQU(t, pidfdTarget(handle), victim); + + /* victim is the ONLY free slot afterward -- the very next create is + forced onto it regardless of allocSlot's own cursor position, the + exact same proof schedTest.c's + threadCreateReusesTheOnlyFreedSlotOnceTheTableIsFull already + establishes for the underlying thread-table mechanics alone. */ + KT_TRUE(t, threadDiscard(victim)); + uint32 impostor = threadCreate("pfImpostor", noopThreadEntry); + KT_EQ(t, impostor, victim); + + /* The old handle must NOT follow the slot to its new, completely + unrelated occupant -- this is the entire point of pidfd.h over a + raw cached pid, which would now (harmlessly, in this test, but not + in general) apply any further kill/wait/sigSend straight to + impostor while believing it was still acting on victim. */ + KT_EQU(t, pidfdTarget(handle), threadInvalidId); + + /* A FRESH pidfd opened on the same numeric id now correctly targets + the new occupant -- the slot itself is not broken, only the OLD + handle's own pinned incarnation is (rightly) stale. */ + int freshHandle = pidfdOpen(impostor); + KT_GE(t, freshHandle, 0); + KT_EQU(t, pidfdTarget(freshHandle), impostor); + + pidfdClose(handle); + pidfdClose(freshHandle); + for (uint32 i = 0; i < count; i++) { + if (created[i] != victim) + KT_TRUE(t, threadDiscard(created[i])); + } + KT_TRUE(t, threadDiscard(impostor)); +} + +KTEST(pidfdOpenExhaustsThenClosesFreeSlotsBackForReuse) +{ + uint32 x = threadCreate("pfTableFill", noopThreadEntry); + + int handles[maxPidfds]; + int count = 0; + while (count < maxPidfds) { + int h = pidfdOpen(x); + if (h < 0) + break; + handles[count++] = h; + } + KT_EQ(t, count, maxPidfds); + KT_EQ(t, pidfdOpen(x), -1); + + pidfdClose(handles[0]); + int reused = pidfdOpen(x); + KT_GE(t, reused, 0); + KT_EQU(t, pidfdTarget(reused), x); + + pidfdClose(reused); + for (int i = 1; i < count; i++) + pidfdClose(handles[i]); + KT_TRUE(t, threadDiscard(x)); +} diff --git a/src/pipe.c b/src/pipe.c new file mode 100644 index 0000000..2de2f73 --- /dev/null +++ b/src/pipe.c @@ -0,0 +1,291 @@ +#include "pipe.h" +#include "ring.h" +#include "sched.h" +#include "ports.h" + +typedef struct Pipe { + RingBuffer ring; + uint8 backing[pipeBufferSize]; + uint32 readers; + uint32 writers; + bool used; + /* Set once, permanently (mirrors Thread.everRan, sched.h), the first + time a reader/writer is ever attached. A pipeCreateDetached handle + starts with both counts AND both of these false, so pipeRead/ + pipeWrite can tell "the other end just has not attached yet" (keep + retrying) apart from "it attached and then went away" (true EOF / + broken pipe) -- pipeCreate sets both true immediately, since its + caller always installs both ends atomically, so its behavior is + completely unchanged. */ + bool everHadReader; + bool everHadWriter; + /* Distinguishes THIS specific pipe living at a given slot from any other + pipe that has occupied (or will ever occupy) the same numeric handle + before or after -- pipeReleaseIfUnused frees a slot's `used` flag the + moment both ends are gone, so allocPipeSlot happily reuses that same + number for a completely unrelated pipeCreate/pipeCreateDetached right + after. A caller that caches a handle across time spanning such a + release (vfs.c's SFS-backed named-fifo bindings, see sfsFifoHandleFor + in vfs.c) needs more than pipeValid(handle) to tell "the same pipe I + bound to earlier" apart from "a different pipe that happens to have + been assigned the same number since" -- see pipeIncarnation. */ + uint32 incarnation; +} Pipe; + +static Pipe pipes[maxPipes]; + +/* Never 0 (see pipeIncarnation's "0 always means invalid" contract) -- next + value handed to whichever pipe allocPipeSlot's caller creates next, + regardless of which slot index it lands in. */ +static uint32 nextPipeIncarnation = 1; + +static Pipe* pipeAt(int handle) +{ + if (handle < 0 || handle >= (int)maxPipes) + return nullptr; + if (!pipes[handle].used) + return nullptr; + return &pipes[handle]; +} + +static int allocPipeSlot(void) +{ + for (int i = 0; i < (int)maxPipes; i++) + if (!pipes[i].used) + return i; + return -1; +} + +int pipeCreate(void) +{ + int i = allocPipeSlot(); + if (i < 0) + return -1; + + Pipe* p = &pipes[i]; + ringInit(&p->ring, p->backing, pipeBufferSize); + p->readers = 1; + p->writers = 1; + p->everHadReader = true; + p->everHadWriter = true; + p->used = true; + p->incarnation = nextPipeIncarnation++; + return i; +} + +int pipeCreateDetached(void) +{ + int i = allocPipeSlot(); + if (i < 0) + return -1; + + Pipe* p = &pipes[i]; + ringInit(&p->ring, p->backing, pipeBufferSize); + p->readers = 0; + p->writers = 0; + p->everHadReader = false; + p->everHadWriter = false; + p->used = true; + p->incarnation = nextPipeIncarnation++; + return i; +} + +void pipeAddReader(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return; + p->readers++; + p->everHadReader = true; +} + +void pipeAddWriter(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return; + p->writers++; + p->everHadWriter = true; +} + +bool pipeWriteBroken(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return true; + return p->readers == 0 && p->everHadReader; +} + +const void* pipeChannel(int handle) +{ + return (const void*)pipeAt(handle); +} + +bool pipeValid(int handle) +{ + return pipeAt(handle) != nullptr; +} + +uint32 pipeIncarnation(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return 0; + return p->incarnation; +} + +int64 pipeRead(int handle, void* buffer, uint64 length) +{ + Pipe* p = pipeAt(handle); + if (!p) + return -1; + if (length == 0) + return 0; + + if (!ringIsEmpty(&p->ring)) { + usize got = ringRead(&p->ring, (uint8*)buffer, (usize)length); + /* Reading frees ring space: any writer blocked on "buffer full" may + now be able to proceed, so give it a chance to recheck. */ + schedWakeChannel((const void*)p); + schedWakeChannel(schedPollChannel()); + return (int64)got; + } + + /* Only a writer that WAS attached and has since gone away means true + EOF -- a pipeCreateDetached handle nobody has written to yet (readers + 0, everHadWriter false) has to keep reporting "try again" instead, + or a reader that races ahead of the writer's own vfsPipeAttach would + see spurious end-of-file on an otherwise perfectly live pipe. */ + if (p->writers == 0 && p->everHadWriter) + return 0; + + return -1; +} + +int64 pipeWrite(int handle, const void* buffer, uint64 length) +{ + Pipe* p = pipeAt(handle); + if (!p) + return -1; + /* Symmetric with pipeRead's EOF check above: only a reader that WAS + attached and left counts as a broken pipe. */ + if (p->readers == 0 && p->everHadReader) + return -1; + if (length == 0) + return 0; + + if (ringIsFull(&p->ring)) + return -1; + + usize put = ringWrite(&p->ring, (const uint8*)buffer, (usize)length); + /* Writing adds ring data: any reader blocked on "buffer empty" may now + be able to proceed, so give it a chance to recheck. */ + schedWakeChannel((const void*)p); + schedWakeChannel(schedPollChannel()); + return (int64)put; +} + +bool pipeHasData(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return false; + return !ringIsEmpty(&p->ring) || (p->writers == 0 && p->everHadWriter); +} + +bool pipeHasSpace(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return false; + return !ringIsFull(&p->ring); +} + +int64 pipeReadBlocking(int handle, void* buffer, uint64 length) +{ + Pipe* p = pipeAt(handle); + if (!p) + return -1; + + for (;;) { + int64 result = pipeRead(handle, buffer, length); + if (result != -1) + return result; + + schedBlockCurrent((const void*)p); + halt(); + } +} + +int64 pipeWriteBlocking(int handle, const void* buffer, uint64 length) +{ + Pipe* p = pipeAt(handle); + if (!p) + return -1; + + for (;;) { + int64 result = pipeWrite(handle, buffer, length); + if (result != -1) + return result; + + /* A reader that attached and then left is a broken pipe: a terminal + condition no amount of waiting will ever resolve, unlike a merely + full buffer (or a pipeCreateDetached handle whose reader simply + has not attached yet). */ + if (pipeWriteBroken(handle)) + return -1; + + schedBlockCurrent((const void*)p); + halt(); + } +} + +static void pipeReleaseIfUnused(int handle) +{ + Pipe* p = &pipes[handle]; + if (p->readers == 0 && p->writers == 0) + p->used = false; +} + +void pipeCloseRead(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return; + if (p->readers > 0) + p->readers--; + /* A writer parked on "buffer full" must be woken to discover the pipe is + now broken instead of waiting forever for space that will never free + up on its own. */ + if (p->readers == 0) { + schedWakeChannel((const void*)p); + schedWakeChannel(schedPollChannel()); + } + pipeReleaseIfUnused(handle); +} + +void pipeCloseWrite(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p) + return; + if (p->writers > 0) + p->writers--; + /* A reader parked on "buffer empty" must be woken to discover true + end-of-file instead of waiting forever for data that will never + arrive. */ + if (p->writers == 0) { + schedWakeChannel((const void*)p); + schedWakeChannel(schedPollChannel()); + } + pipeReleaseIfUnused(handle); +} + +bool pipeDestroyIfUnattached(int handle) +{ + Pipe* p = pipeAt(handle); + if (!p || p->readers != 0 || p->writers != 0) + return false; + p->used = false; + return true; +} diff --git a/src/pipeBlockTest.c b/src/pipeBlockTest.c new file mode 100644 index 0000000..512432e --- /dev/null +++ b/src/pipeBlockTest.c @@ -0,0 +1,113 @@ +#include "ktest.h" +#include "pipe.h" +#include "string.h" + +/* + * pipeReadBlocking/pipeWriteBlocking only return without parking the thread + * when the very first attempt already resolves (data/space/EOF/broken-pipe + * already true) - any scenario that would genuinely need to park calls + * halt(), which requires a live, interrupt-driven scheduler to ever resume + * from. ktestRunAll() runs before schedStart() (see ktest.h) on the sole + * execution context there is, so these tests deliberately only cover the + * "resolves immediately, never blocks" paths. The genuine cross-thread + * park/wake handshake is exercised for real at boot by the producer/consumer + * demo in kmain.c (needle "pipe demo: eof, count=10" in tools/ci-test.sh). + */ + +KTEST(pipeReadBlockingReturnsQueuedDataImmediately) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + const char* msg = "already here"; + KT_EQ(t, pipeWrite(h, msg, strlen(msg)), (int64)strlen(msg)); + + char buffer[32]; + memset(buffer, 0, sizeof(buffer)); + int64 got = pipeReadBlocking(h, buffer, sizeof(buffer)); + KT_EQ(t, got, (int64)strlen(msg)); + KT_TRUE(t, memcmp(buffer, msg, strlen(msg)) == 0); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeReadBlockingReturnsEofWithoutParking) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + pipeCloseWrite(h); + + char buffer[8]; + KT_EQ(t, pipeReadBlocking(h, buffer, sizeof(buffer)), 0); + + pipeCloseRead(h); +} + +KTEST(pipeReadBlockingRejectsInvalidHandleWithoutParking) +{ + char buffer[8]; + KT_EQ(t, pipeReadBlocking(-1, buffer, sizeof(buffer)), -1); + KT_EQ(t, pipeReadBlocking(999, buffer, sizeof(buffer)), -1); +} + +KTEST(pipeWriteBlockingSucceedsImmediatelyWhenRoomExists) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + const char* msg = "room to spare"; + int64 written = pipeWriteBlocking(h, msg, strlen(msg)); + KT_EQ(t, written, (int64)strlen(msg)); + + char buffer[32]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), (int64)strlen(msg)); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeWriteBlockingOnBrokenPipeReturnsWithoutParking) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + pipeCloseRead(h); + + const char* msg = "nobody listening"; + KT_EQ(t, pipeWriteBlocking(h, msg, strlen(msg)), -1); + + pipeCloseWrite(h); +} + +KTEST(pipeWriteBlockingRejectsInvalidHandleWithoutParking) +{ + char buffer[4] = { 0 }; + KT_EQ(t, pipeWriteBlocking(-1, buffer, sizeof(buffer)), -1); + KT_EQ(t, pipeWriteBlocking(999, buffer, sizeof(buffer)), -1); +} + +KTEST(pipeWriteBlockingSucceedsRightAfterDrainFreesRoom) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + uint8 chunk[pipeBufferSize]; + memset(chunk, 0x5A, sizeof(chunk)); + + KT_EQ(t, pipeWrite(h, chunk, sizeof(chunk)), (int64)sizeof(chunk)); + + /* buffer is completely full: a blocking write would have to park here if + nothing ever drained it, so proving this call returns at all (rather + than hanging the whole test run) is exactly the point. */ + uint8 drained[16]; + KT_EQ(t, pipeRead(h, drained, sizeof(drained)), (int64)sizeof(drained)); + + int64 written = pipeWriteBlocking(h, chunk, sizeof(drained)); + KT_EQ(t, written, (int64)sizeof(drained)); + + pipeCloseRead(h); + pipeCloseWrite(h); +} diff --git a/src/pipeShareTest.c b/src/pipeShareTest.c new file mode 100644 index 0000000..f176e84 --- /dev/null +++ b/src/pipeShareTest.c @@ -0,0 +1,171 @@ +#include "ktest.h" +#include "pipe.h" +#include "vfs.h" +#include "string.h" + +/* + * Coverage for the shared-pipe machinery added this wave (pipe.h's + * pipeCreateDetached/pipeAddReader/pipeAddWriter/pipeWriteBroken/ + * pipeChannel, plus vfs.h's vfsPipeCreateHandle/vfsPipeAttach/ + * vfsBlockingChannel) -- everything two independently spawned processes + * need to rendezvous over a real blocking pipe without fork(). The genuine + * cross-process park/wake handshake itself only runs for real at boot + * (fifomaster.c spawning fifowriter.c/fiforeader.c, needles + * "fifomaster: pipeshare ok"/"fiforeader: payload-ok" in tools/ci-test.sh), + * the same division of labor pipeBlockTest.c already uses for + * pipeReadBlocking/pipeWriteBlocking: what is deterministic and + * scheduler-free lives here, what genuinely needs two live execution + * contexts is proven live. + */ + +KTEST(pipeCreateDetachedStartsWithNeitherEndAttached) +{ + int h = pipeCreateDetached(); + KT_GE(t, h, 0); + + /* Nobody has attached a writer yet: a read must report "try again" + (-1), not spurious end-of-file (0) the way a writers-already-0 + pipeCreate() pipe correctly would. */ + char buffer[8]; + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), -1); + + /* Nobody has attached a reader yet either, but a write must still + succeed (buffered for whenever a reader shows up), not report a + broken pipe the way a readers-already-0 pipeCreate() pipe would. */ + KT_FALSE(t, pipeWriteBroken(h)); + KT_EQ(t, pipeWrite(h, "hi", 2), 2); + + char got[8]; + KT_EQ(t, pipeRead(h, got, sizeof(got)), 2); + KT_TRUE(t, memcmp(got, "hi", 2) == 0); + + pipeAddReader(h); + pipeAddWriter(h); + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeAddWriterThenCloseMakesRealEofNotBeforehand) +{ + int h = pipeCreateDetached(); + KT_GE(t, h, 0); + pipeAddReader(h); + + /* Still no writer attached: not EOF. */ + char buffer[4]; + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), -1); + + pipeAddWriter(h); + KT_EQ(t, pipeWrite(h, "z", 1), 1); + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), 1); + + /* Now that a writer really was attached and has left, an empty ring + means true end-of-file. */ + pipeCloseWrite(h); + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), 0); + + pipeCloseRead(h); +} + +KTEST(pipeAddReaderThenCloseMakesRealBrokenPipeNotBeforehand) +{ + int h = pipeCreateDetached(); + KT_GE(t, h, 0); + pipeAddWriter(h); + + /* No reader attached yet: writing still succeeds, not "broken". */ + KT_FALSE(t, pipeWriteBroken(h)); + KT_EQ(t, pipeWrite(h, "a", 1), 1); + + pipeAddReader(h); + char buffer[4]; + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), 1); + + /* A reader really was attached and has now left: genuinely broken. */ + pipeCloseRead(h); + KT_TRUE(t, pipeWriteBroken(h)); + KT_EQ(t, pipeWrite(h, "b", 1), -1); + + pipeCloseWrite(h); +} + +KTEST(pipeWriteBrokenAndChannelRejectInvalidHandles) +{ + KT_TRUE(t, pipeWriteBroken(-1)); + KT_TRUE(t, pipeWriteBroken(999)); + KT_NULL(t, pipeChannel(-1)); + KT_NULL(t, pipeChannel(999)); + + int h = pipeCreateDetached(); + KT_GE(t, h, 0); + KT_NOTNULL(t, pipeChannel(h)); + + int h2 = pipeCreateDetached(); + KT_GE(t, h2, 0); + KT_NE(t, pipeChannel(h), pipeChannel(h2)); + + pipeAddReader(h); + pipeAddWriter(h); + pipeCloseRead(h); + pipeCloseWrite(h); + pipeAddReader(h2); + pipeAddWriter(h2); + pipeCloseRead(h2); + pipeCloseWrite(h2); +} + +KTEST(vfsPipeCreateHandleAndAttachInstallBlockingFds) +{ + int handle = vfsPipeCreateHandle(); + KT_GE(t, handle, 0); + + int readFd = vfsPipeAttach(handle, false); + int writeFd = vfsPipeAttach(handle, true); + KT_GE(t, readFd, 0); + KT_GE(t, writeFd, 0); + KT_NE(t, readFd, writeFd); + + /* An empty, both-ends-attached shared pipe is exactly the "would + block, but retrying could resolve it" state sysRead's retry path + (syscall.c) depends on. */ + KT_NOTNULL(t, vfsBlockingChannel(readFd, false)); + + KT_EQ(t, vfsWrite(writeFd, "shared", 6), 6); + char buffer[8]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(readFd, buffer, sizeof(buffer)), 6); + KT_TRUE(t, memcmp(buffer, "shared", 6) == 0); + + /* Wrong direction / wrong fd kind must never hand back a channel. */ + KT_NULL(t, vfsBlockingChannel(readFd, true)); + KT_NULL(t, vfsBlockingChannel(writeFd, false)); + KT_NULL(t, vfsBlockingChannel(-1, false)); + + vfsClose(readFd); + vfsClose(writeFd); +} + +KTEST(vfsPipeAttachRejectsInvalidHandle) +{ + KT_EQ(t, vfsPipeAttach(-1, false), -1); + KT_EQ(t, vfsPipeAttach(999, true), -1); +} + +KTEST(vfsBlockingChannelIsNullForOrdinaryNonBlockingPipe) +{ + /* vfsPipe's anonymous pipe (both ends in the caller's own table) must + keep reporting "try again" (-1) to the caller directly, the way + pipetest.c depends on -- vfsBlockingChannel must never treat it as + blockable, or sysRead/sysWrite would start parking a single-process + pipe reader/writer that expects an immediate -1 back. */ + int readFd, writeFd; + KT_EQ(t, vfsPipe(&readFd, &writeFd), 0); + + char probe[1]; + KT_EQ(t, vfsRead(readFd, probe, 0), 0); + KT_NULL(t, vfsBlockingChannel(readFd, false)); + KT_NULL(t, vfsBlockingChannel(writeFd, true)); + + vfsClose(readFd); + vfsClose(writeFd); +} diff --git a/src/pipeTest.c b/src/pipeTest.c new file mode 100644 index 0000000..ae5803e --- /dev/null +++ b/src/pipeTest.c @@ -0,0 +1,183 @@ +#include "ktest.h" +#include "pipe.h" +#include "string.h" + +KTEST(pipeWriteThenReadRoundTrips) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + const char* msg = "hello pipe"; + int64 written = pipeWrite(h, msg, strlen(msg)); + KT_EQ(t, written, (int64)strlen(msg)); + + char buffer[32]; + memset(buffer, 0, sizeof(buffer)); + int64 got = pipeRead(h, buffer, sizeof(buffer)); + KT_EQ(t, got, (int64)strlen(msg)); + KT_TRUE(t, memcmp(buffer, msg, strlen(msg)) == 0); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeReadOnEmptyOpenPipeWouldBlock) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + char buffer[8]; + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), -1); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeReadZeroLengthIsNoop) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + char buffer[1]; + KT_EQ(t, pipeRead(h, buffer, 0), 0); + KT_EQ(t, pipeWrite(h, buffer, 0), 0); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeFullWriteWouldBlockThenDrainsAndAcceptsMore) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + uint8 chunk[pipeBufferSize]; + memset(chunk, 0xAB, sizeof(chunk)); + + /* this exactly fills the ring. */ + int64 filled = pipeWrite(h, chunk, sizeof(chunk)); + KT_EQ(t, filled, (int64)sizeof(chunk)); + + /* buffer is full: further write must report "would block" */ + KT_EQ(t, pipeWrite(h, chunk, 1), -1); + + /* drain some bytes, then more space is available */ + uint8 drained[64]; + int64 gotDrain = pipeRead(h, drained, sizeof(drained)); + KT_EQ(t, gotDrain, (int64)sizeof(drained)); + for (uint64 i = 0; i < sizeof(drained); i++) + KT_EQU(t, drained[i], 0xAB); + + int64 moreWritten = pipeWrite(h, chunk, sizeof(drained)); + KT_EQ(t, moreWritten, (int64)sizeof(drained)); + + pipeCloseRead(h); + pipeCloseWrite(h); +} + +KTEST(pipeEofAfterWriterClosedAndBufferDrained) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + const char* msg = "last bytes"; + KT_EQ(t, pipeWrite(h, msg, strlen(msg)), (int64)strlen(msg)); + + pipeCloseWrite(h); + + /* data queued before close is still readable */ + char buffer[32]; + int64 got = pipeRead(h, buffer, sizeof(buffer)); + KT_EQ(t, got, (int64)strlen(msg)); + + /* now drained and no writer left: true end-of-file, not "would block" */ + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), 0); + KT_EQ(t, pipeRead(h, buffer, sizeof(buffer)), 0); + + pipeCloseRead(h); +} + +KTEST(pipeWriteAfterReaderClosedIsBrokenPipe) +{ + int h = pipeCreate(); + KT_GE(t, h, 0); + + pipeCloseRead(h); + + const char* msg = "nobody listening"; + KT_EQ(t, pipeWrite(h, msg, strlen(msg)), -1); + + pipeCloseWrite(h); +} + +KTEST(pipeInvalidHandleIsRejected) +{ + KT_FALSE(t, pipeValid(-1)); + KT_FALSE(t, pipeValid(999)); + + char buffer[4]; + KT_EQ(t, pipeRead(-1, buffer, sizeof(buffer)), -1); + KT_EQ(t, pipeWrite(-1, buffer, sizeof(buffer)), -1); + + /* closing an invalid handle must not crash */ + pipeCloseRead(-1); + pipeCloseWrite(999); +} + +KTEST(pipeIndependentPipesDoNotInterfere) +{ + int a = pipeCreate(); + int b = pipeCreate(); + KT_GE(t, a, 0); + KT_GE(t, b, 0); + KT_NE(t, a, b); + + KT_EQ(t, pipeWrite(a, "AAAA", 4), 4); + KT_EQ(t, pipeWrite(b, "BB", 2), 2); + + char bufA[8]; + char bufB[8]; + memset(bufA, 0, sizeof(bufA)); + memset(bufB, 0, sizeof(bufB)); + + KT_EQ(t, pipeRead(b, bufB, sizeof(bufB)), 2); + KT_TRUE(t, memcmp(bufB, "BB", 2) == 0); + + KT_EQ(t, pipeRead(a, bufA, sizeof(bufA)), 4); + KT_TRUE(t, memcmp(bufA, "AAAA", 4) == 0); + + pipeCloseRead(a); + pipeCloseWrite(a); + pipeCloseRead(b); + pipeCloseWrite(b); +} + +KTEST(pipeTableExhaustionAndReuse) +{ + int handles[maxPipes + 1]; + int created = 0; + + while (created < maxPipes + 1) { + int h = pipeCreate(); + if (h < 0) + break; + handles[created++] = h; + } + + /* the table must run out before maxPipes+1 succeed */ + KT_LT(t, created, maxPipes + 1); + KT_EQ(t, pipeCreate(), -1); + + /* fully closing one pipe frees its slot for reuse */ + pipeCloseRead(handles[0]); + pipeCloseWrite(handles[0]); + int reused = pipeCreate(); + KT_GE(t, reused, 0); + + pipeCloseRead(reused); + pipeCloseWrite(reused); + for (int i = 1; i < created; i++) { + pipeCloseRead(handles[i]); + pipeCloseWrite(handles[i]); + } +} diff --git a/src/pmm.c b/src/pmm.c index 0a670b5..4a03bf1 100644 --- a/src/pmm.c +++ b/src/pmm.c @@ -96,9 +96,30 @@ void pmmInit(uint64 multibootInfo, uint64 protectedEnd) frameCount = highestAddr / frameSize; + /* The bitmap itself must never overlap live data this function still + needs to read (or that anything else placed physically-here still + needs): the loaded kernel image, the multiboot module the caller has + already claimed via protectedEnd (typically the initrd, see + kmain.c), AND the multiboot info structure this very function is + reading `mmap` out of via endAddr, computed above. That last one is + not academic -- GRUB places the multiboot2 info block immediately + after the last module it loads, so protectedEnd (the module's own + end) and multibootInfo's start can legitimately land on the exact + same address. Anchoring on protectedEnd alone would then place the + bitmap ON TOP of the live mmap tag, and the memset just below would + zero out every entry's baseAddr/length/type with 0xFF *before* the + markFree pass a few lines down ever reads them -- silently losing + the entire usable-memory picture and leaving every frame "used" + forever. Growing the initrd (adding another user program, say) is + exactly what nudges protectedEnd into colliding with endAddr, so + this is not a hypothetical: it reproduced on the very next boot after + tools/mktar's ramdisk grew past whatever margin happened to exist + before. */ uint64 bitmapAnchor = (uint64)kernelPhysEnd; if (protectedEnd > bitmapAnchor) bitmapAnchor = protectedEnd; + if (endAddr > bitmapAnchor) + bitmapAnchor = endAddr; bitmap = (uint8*)alignUp(bitmapAnchor, frameSize); uint64 bitmapBytes = (frameCount + 7) / 8; memset(bitmap, 0xFF, bitmapBytes); diff --git a/src/pollTest.c b/src/pollTest.c new file mode 100644 index 0000000..d4a2840 --- /dev/null +++ b/src/pollTest.c @@ -0,0 +1,146 @@ +#include "ktest.h" +#include "poll.h" +#include "vfs.h" +#include "pipe.h" + +/* + * Pure, scheduler-free coverage for vfsPollReady (vfs.c) and its two + * non-consuming peek helpers (pipeHasData/pipeHasSpace, pipe.h; + * keyboardReaderHasData, keyboard.h) -- everything that can be driven + * single-threaded, exactly like pipeShareTest.c/vfsFifoTest.c already do + * for the machinery poll.h's sysPoll is built on top of. The genuine + * cross-process block-then-wake handshake (schedule()'s own pollDeadline + * expiry scan, sched.c; the shared schedPollChannel wake from a SECOND, + * independently scheduled writer) only runs for real at boot + * (user/polltest.c + user/pollwriter.c, needles in tools/ci-test.sh) -- + * the same division of labor this file's neighbors already use, and + * schedTest.c's own doc comment explains why: ktestRunAll() runs before + * schedStart(), with no second execution context to switch into. + */ + +KTEST(vfsPollReadyReportsNvalForClosedOrOutOfRangeFd) +{ + int16 revents = 99; + KT_TRUE(t, vfsPollReady(-1, pollIn, &revents)); + KT_EQ(t, revents, pollNval); + + revents = 99; + KT_TRUE(t, vfsPollReady(9999, pollIn | pollOut, &revents)); + KT_EQ(t, revents, pollNval); +} + +KTEST(vfsPollReadyPipeReadEndTracksRealData) +{ + int readFd, writeFd; + KT_EQ(t, vfsPipe(&readFd, &writeFd), 0); + + int16 revents = 99; + KT_FALSE(t, vfsPollReady(readFd, pollIn, &revents)); + KT_EQ(t, revents, 0); + + KT_EQ(t, vfsWrite(writeFd, "hi", 2), 2); + + revents = 0; + KT_TRUE(t, vfsPollReady(readFd, pollIn, &revents)); + KT_EQ(t, revents, pollIn); + + /* Asking for pollOut on a read-only end is never satisfiable, no + matter how much data is queued. */ + revents = 99; + KT_FALSE(t, vfsPollReady(readFd, pollOut, &revents)); + KT_EQ(t, revents, 0); + + char buffer[8]; + KT_EQ(t, vfsRead(readFd, buffer, sizeof(buffer)), 2); + vfsClose(readFd); + vfsClose(writeFd); +} + +KTEST(vfsPollReadyPipeWriteEndTracksFreeSpaceThenFull) +{ + int readFd, writeFd; + KT_EQ(t, vfsPipe(&readFd, &writeFd), 0); + + int16 revents = 0; + KT_TRUE(t, vfsPollReady(writeFd, pollOut, &revents)); + KT_EQ(t, revents, pollOut); + + /* Fill the ring completely (pipeBufferSize, pipe.h) with non-blocking + writes -- vfsPipe's ends are exactly the unshared, "-1 means try + again" kind pipetest.c already depends on, so this can never park + the calling (only) thread the way a fdFlagBlocking end would. */ + char chunk[64]; + for (int i = 0; i < 64; i++) + chunk[i] = (char)i; + int64 written = 0; + for (int guard = 0; guard < 16; guard++) { + int64 got = vfsWrite(writeFd, chunk, sizeof(chunk)); + if (got <= 0) + break; + written += got; + } + KT_TRUE(t, written > 0); + KT_EQ(t, vfsWrite(writeFd, chunk, sizeof(chunk)), -1); + + revents = 99; + KT_FALSE(t, vfsPollReady(writeFd, pollOut, &revents)); + KT_EQ(t, revents, 0); + + /* Draining even a little frees space back up. */ + char sink[16]; + KT_EQ(t, vfsRead(readFd, sink, sizeof(sink)), 16); + revents = 0; + KT_TRUE(t, vfsPollReady(writeFd, pollOut, &revents)); + KT_EQ(t, revents, pollOut); + + vfsClose(readFd); + vfsClose(writeFd); +} + +KTEST(vfsPollReadyReadEndReportsEofAfterWriterCloses) +{ + int readFd, writeFd; + KT_EQ(t, vfsPipe(&readFd, &writeFd), 0); + + int16 revents = 0; + KT_FALSE(t, vfsPollReady(readFd, pollIn, &revents)); + + vfsClose(writeFd); + + /* A true EOF pipe is "readable" in real poll(2) too -- the next read + call returning 0 immediately is exactly what pollIn promises here. */ + revents = 0; + KT_TRUE(t, vfsPollReady(readFd, pollIn, &revents)); + KT_EQ(t, revents, pollIn); + char probe[4]; + KT_EQ(t, vfsRead(readFd, probe, sizeof(probe)), 0); + + vfsClose(readFd); +} + +KTEST(vfsPollReadyConsoleWriteAlwaysReadyReadNeverWithoutInput) +{ + /* fd 0 (console) is already live for the current (idle) thread by the + time ktestRunAll() runs -- schedInit() calls vfsFdInit before + kmain.c ever reaches ktestRunAll(). Every open reader (idle's fd 0 + included) gets a copy of every keystroke keyboardHandleScancode ever + broadcasts (keyboard.h), including the synthetic ones OTHER test + files inject (keyboardTest.c's own coverage) -- KTEST execution + order across files is whatever the ".ktests" linker section happens + to lay them out in, so idle's fd 0 must be drained here rather than + assumed empty, or this test's own result would depend on that order. */ + char drain[16]; + while (vfsRead(0, drain, sizeof(drain)) > 0) { } + + int16 revents = 99; + KT_FALSE(t, vfsPollReady(0, pollIn, &revents)); + KT_EQ(t, revents, 0); + + revents = 0; + KT_TRUE(t, vfsPollReady(0, pollOut, &revents)); + KT_EQ(t, revents, pollOut); + + revents = 0; + KT_TRUE(t, vfsPollReady(0, pollIn | pollOut, &revents)); + KT_EQ(t, revents, pollOut); +} diff --git a/src/prioq.c b/src/prioq.c new file mode 100644 index 0000000..bd6c2b5 --- /dev/null +++ b/src/prioq.c @@ -0,0 +1,55 @@ +#include "prioq.h" + +static int compareNodes(const RbtNode* a, const RbtNode* b) +{ + const PrioQNode* pa = rbtEntry(a, PrioQNode, node); + const PrioQNode* pb = rbtEntry(b, PrioQNode, node); + + if (pa->vruntime != pb->vruntime) + return pa->vruntime < pb->vruntime ? -1 : 1; + if (pa->id != pb->id) + return pa->id < pb->id ? -1 : 1; + return 0; +} + +void prioqInit(PrioQueue* q) +{ + rbtInit(&q->tree); +} + +void prioqInsert(PrioQueue* q, PrioQNode* n, uint64 vruntime, uint32 id) +{ + n->vruntime = vruntime; + n->id = id; + bool inserted = rbtInsert(&q->tree, &n->node, compareNodes); + (void)inserted; /* unique id guarantees the key can never already exist */ +} + +PrioQNode* prioqPeekMin(const PrioQueue* q) +{ + RbtNode* n = rbtFirst(&q->tree); + return n ? rbtEntry(n, PrioQNode, node) : nullptr; +} + +PrioQNode* prioqPopMin(PrioQueue* q) +{ + PrioQNode* min = prioqPeekMin(q); + if (min) + rbtRemove(&q->tree, &min->node); + return min; +} + +void prioqRemove(PrioQueue* q, PrioQNode* n) +{ + rbtRemove(&q->tree, &n->node); +} + +usize prioqCount(const PrioQueue* q) +{ + return rbtCount(&q->tree); +} + +bool prioqIsEmpty(const PrioQueue* q) +{ + return rbtIsEmpty(&q->tree); +} diff --git a/src/prioqTest.c b/src/prioqTest.c new file mode 100644 index 0000000..e427aad --- /dev/null +++ b/src/prioqTest.c @@ -0,0 +1,182 @@ +#include "ktest.h" +#include "prioq.h" +#include "heap.h" + +/* Fisher-Yates over a small LCG so insertion order is scrambled but + reproducible across runs (same generator as rbtTest.c's shuffle). */ +static void shuffle(uint32* arr, int32 n, uint32 seed) +{ + uint32 state = seed; + for (int32 i = n - 1; i > 0; i--) { + state = state * 1664525u + 1013904223u; + int32 j = (int32)(state % (uint32)(i + 1)); + uint32 tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } +} + +KTEST(prioqEmptyQueue) +{ + PrioQueue q; + prioqInit(&q); + + KT_TRUE(t, prioqIsEmpty(&q)); + KT_EQ(t, prioqCount(&q), 0); + KT_NULL(t, prioqPeekMin(&q)); + KT_NULL(t, prioqPopMin(&q)); +} + +KTEST(prioqOrdersByVruntimeAscending) +{ + PrioQueue q; + prioqInit(&q); + + PrioQNode nodes[5]; + uint64 vruntimes[] = { 300, 100, 500, 200, 400 }; + for (uint32 i = 0; i < 5; i++) + prioqInsert(&q, &nodes[i], vruntimes[i], i); + + KT_EQ(t, prioqCount(&q), 5); + + uint64 expected[] = { 100, 200, 300, 400, 500 }; + for (uint32 i = 0; i < 5; i++) { + PrioQNode* min = prioqPopMin(&q); + KT_NOTNULL(t, min); + if (min) + KT_EQU(t, min->vruntime, expected[i]); + } + KT_TRUE(t, prioqIsEmpty(&q)); +} + +KTEST(prioqPeekDoesNotRemove) +{ + PrioQueue q; + prioqInit(&q); + + PrioQNode a, b; + prioqInsert(&q, &a, 50, 1); + prioqInsert(&q, &b, 10, 2); + + PrioQNode* peeked = prioqPeekMin(&q); + KT_NOTNULL(t, peeked); + if (peeked) + KT_EQ(t, peeked->id, 2); + KT_EQ(t, prioqCount(&q), 2); + + PrioQNode* popped = prioqPopMin(&q); + KT_EQP(t, popped, peeked); + KT_EQ(t, prioqCount(&q), 1); +} + +KTEST(prioqTiesBreakByIdAscending) +{ + PrioQueue q; + prioqInit(&q); + + PrioQNode nodes[4]; + uint32 ids[] = { 7, 3, 9, 1 }; + for (uint32 i = 0; i < 4; i++) + prioqInsert(&q, &nodes[i], 1000, ids[i]); /* identical vruntime */ + + uint32 expected[] = { 1, 3, 7, 9 }; + for (uint32 i = 0; i < 4; i++) { + PrioQNode* min = prioqPopMin(&q); + KT_NOTNULL(t, min); + if (min) + KT_EQ(t, min->id, expected[i]); + } +} + +KTEST(prioqRemoveArbitraryNodePreservesOrder) +{ + PrioQueue q; + prioqInit(&q); + + PrioQNode nodes[4]; + uint64 vruntimes[] = { 10, 20, 30, 40 }; + for (uint32 i = 0; i < 4; i++) + prioqInsert(&q, &nodes[i], vruntimes[i], i); + + prioqRemove(&q, &nodes[1]); /* drop vruntime=20 before it is ever popped */ + KT_EQ(t, prioqCount(&q), 3); + + uint64 expected[] = { 10, 30, 40 }; + for (uint32 i = 0; i < 3; i++) { + PrioQNode* min = prioqPopMin(&q); + KT_NOTNULL(t, min); + if (min) + KT_EQU(t, min->vruntime, expected[i]); + } + KT_TRUE(t, prioqIsEmpty(&q)); +} + +KTEST(prioqNodeStorageIsReusableAcrossQueueLifetimes) +{ + PrioQNode node; + + PrioQueue first; + prioqInit(&first); + prioqInsert(&first, &node, 500, 42); + PrioQNode* popped = prioqPopMin(&first); + KT_EQP(t, popped, &node); + + /* Same PrioQNode storage, brand new queue: rbtInsert must not be + tripped up by stale left/right/parent pointers left over from the + first tree. */ + PrioQueue second; + prioqInit(&second); + prioqInsert(&second, &node, 5, 1); + KT_EQ(t, prioqCount(&second), 1); + PrioQNode* min = prioqPeekMin(&second); + KT_NOTNULL(t, min); + if (min) + KT_EQU(t, min->vruntime, 5); +} + +#define prioqStressN 300 + +KTEST(prioqStressDrainsInFullyAscendingOrder) +{ + PrioQNode* pool = (PrioQNode*)kmalloc(sizeof(PrioQNode) * prioqStressN); + uint32* order = (uint32*)kmalloc(sizeof(uint32) * prioqStressN); + KT_NOTNULL(t, pool); + KT_NOTNULL(t, order); + if (!pool || !order) { + kfree(pool); + kfree(order); + return; + } + + for (uint32 i = 0; i < prioqStressN; i++) + order[i] = i; + shuffle(order, prioqStressN, 0xFEEDFACEu); + + PrioQueue q; + prioqInit(&q); + for (uint32 i = 0; i < prioqStressN; i++) { + uint32 id = order[i]; + /* vruntime = id * 3 keeps a strict, checkable total order while + still exercising ids that arrive in scrambled sequence. */ + prioqInsert(&q, &pool[id], (uint64)id * 3, id); + } + KT_EQ(t, prioqCount(&q), prioqStressN); + + uint64 lastVruntime = 0; + uint32 visited = 0; + for (;;) { + PrioQNode* min = prioqPopMin(&q); + if (!min) + break; + if (visited > 0) + KT_TRUE(t, min->vruntime >= lastVruntime); + lastVruntime = min->vruntime; + KT_EQU(t, min->vruntime, (uint64)min->id * 3); + visited++; + } + KT_EQ(t, visited, prioqStressN); + KT_TRUE(t, prioqIsEmpty(&q)); + + kfree(pool); + kfree(order); +} diff --git a/src/rbt.c b/src/rbt.c new file mode 100644 index 0000000..81fb69d --- /dev/null +++ b/src/rbt.c @@ -0,0 +1,313 @@ +#include "rbt.h" + +static bool isRed(const RbtNode* n) +{ + return n != nullptr && n->red; +} + +static void setChild(RbTree* t, RbtNode* parent, RbtNode* oldChild, RbtNode* newChild) +{ + if (parent == nullptr) + t->root = newChild; + else if (parent->left == oldChild) + parent->left = newChild; + else + parent->right = newChild; +} + +static void rotateLeft(RbTree* t, RbtNode* x) +{ + RbtNode* y = x->right; + x->right = y->left; + if (y->left != nullptr) + y->left->parent = x; + y->parent = x->parent; + setChild(t, x->parent, x, y); + y->left = x; + x->parent = y; +} + +static void rotateRight(RbTree* t, RbtNode* x) +{ + RbtNode* y = x->left; + x->left = y->right; + if (y->right != nullptr) + y->right->parent = x; + y->parent = x->parent; + setChild(t, x->parent, x, y); + y->right = x; + x->parent = y; +} + +static void insertFixup(RbTree* t, RbtNode* z) +{ + while (isRed(z->parent)) { + RbtNode* parent = z->parent; + RbtNode* grandparent = parent->parent; + + if (parent == grandparent->left) { + RbtNode* uncle = grandparent->right; + if (isRed(uncle)) { + parent->red = false; + uncle->red = false; + grandparent->red = true; + z = grandparent; + } else { + if (z == parent->right) { + z = parent; + rotateLeft(t, z); + parent = z->parent; + } + parent->red = false; + grandparent->red = true; + rotateRight(t, grandparent); + } + } else { + RbtNode* uncle = grandparent->left; + if (isRed(uncle)) { + parent->red = false; + uncle->red = false; + grandparent->red = true; + z = grandparent; + } else { + if (z == parent->left) { + z = parent; + rotateRight(t, z); + parent = z->parent; + } + parent->red = false; + grandparent->red = true; + rotateLeft(t, grandparent); + } + } + } + t->root->red = false; +} + +static void removeFixup(RbTree* t, RbtNode* x, RbtNode* xParent) +{ + while (x != t->root && !isRed(x)) { + if (x == xParent->left) { + RbtNode* w = xParent->right; + if (isRed(w)) { + w->red = false; + xParent->red = true; + rotateLeft(t, xParent); + w = xParent->right; + } + if (!isRed(w->left) && !isRed(w->right)) { + w->red = true; + x = xParent; + xParent = x->parent; + } else { + if (!isRed(w->right)) { + if (w->left != nullptr) + w->left->red = false; + w->red = true; + rotateRight(t, w); + w = xParent->right; + } + w->red = xParent->red; + xParent->red = false; + if (w->right != nullptr) + w->right->red = false; + rotateLeft(t, xParent); + x = t->root; + xParent = nullptr; + } + } else { + RbtNode* w = xParent->left; + if (isRed(w)) { + w->red = false; + xParent->red = true; + rotateRight(t, xParent); + w = xParent->left; + } + if (!isRed(w->left) && !isRed(w->right)) { + w->red = true; + x = xParent; + xParent = x->parent; + } else { + if (!isRed(w->left)) { + if (w->right != nullptr) + w->right->red = false; + w->red = true; + rotateLeft(t, w); + w = xParent->left; + } + w->red = xParent->red; + xParent->red = false; + if (w->left != nullptr) + w->left->red = false; + rotateRight(t, xParent); + x = t->root; + xParent = nullptr; + } + } + } + if (x != nullptr) + x->red = false; +} + +void rbtInit(RbTree* t) +{ + t->root = nullptr; + t->count = 0; +} + +bool rbtInsert(RbTree* t, RbtNode* node, RbtCompareFn compare) +{ + RbtNode* parent = nullptr; + RbtNode* cur = t->root; + bool goLeft = false; + + while (cur != nullptr) { + int c = compare(node, cur); + if (c == 0) + return false; + parent = cur; + goLeft = c < 0; + cur = goLeft ? cur->left : cur->right; + } + + node->left = nullptr; + node->right = nullptr; + node->parent = parent; + node->red = true; + + if (parent == nullptr) + t->root = node; + else if (goLeft) + parent->left = node; + else + parent->right = node; + + t->count++; + insertFixup(t, node); + return true; +} + +void rbtRemove(RbTree* t, RbtNode* z) +{ + RbtNode* y = z; + bool yOriginalRed = y->red; + RbtNode* x; + RbtNode* xParent; + + if (z->left == nullptr) { + x = z->right; + xParent = z->parent; + setChild(t, z->parent, z, z->right); + if (z->right != nullptr) + z->right->parent = z->parent; + } else if (z->right == nullptr) { + x = z->left; + xParent = z->parent; + setChild(t, z->parent, z, z->left); + if (z->left != nullptr) + z->left->parent = z->parent; + } else { + y = z->right; + while (y->left != nullptr) + y = y->left; + yOriginalRed = y->red; + x = y->right; + + if (y->parent == z) { + xParent = y; + } else { + xParent = y->parent; + setChild(t, y->parent, y, y->right); + if (y->right != nullptr) + y->right->parent = y->parent; + y->right = z->right; + y->right->parent = y; + } + + setChild(t, z->parent, z, y); + y->parent = z->parent; + y->left = z->left; + y->left->parent = y; + y->red = z->red; + } + + t->count--; + if (!yOriginalRed) + removeFixup(t, x, xParent); +} + +RbtNode* rbtFind(const RbTree* t, const void* key, RbtLookupFn lookup) +{ + RbtNode* cur = t->root; + while (cur != nullptr) { + int c = lookup(key, cur); + if (c == 0) + return cur; + cur = c < 0 ? cur->left : cur->right; + } + return nullptr; +} + +RbtNode* rbtFirst(const RbTree* t) +{ + RbtNode* n = t->root; + if (n == nullptr) + return nullptr; + while (n->left != nullptr) + n = n->left; + return n; +} + +RbtNode* rbtLast(const RbTree* t) +{ + RbtNode* n = t->root; + if (n == nullptr) + return nullptr; + while (n->right != nullptr) + n = n->right; + return n; +} + +RbtNode* rbtNext(const RbtNode* node) +{ + if (node->right != nullptr) { + RbtNode* n = node->right; + while (n->left != nullptr) + n = n->left; + return n; + } + RbtNode* n = (RbtNode*)node; + RbtNode* parent = n->parent; + while (parent != nullptr && n == parent->right) { + n = parent; + parent = parent->parent; + } + return parent; +} + +RbtNode* rbtPrev(const RbtNode* node) +{ + if (node->left != nullptr) { + RbtNode* n = node->left; + while (n->right != nullptr) + n = n->right; + return n; + } + RbtNode* n = (RbtNode*)node; + RbtNode* parent = n->parent; + while (parent != nullptr && n == parent->left) { + n = parent; + parent = parent->parent; + } + return parent; +} + +usize rbtCount(const RbTree* t) +{ + return t->count; +} + +bool rbtIsEmpty(const RbTree* t) +{ + return t->root == nullptr; +} diff --git a/src/rbtTest.c b/src/rbtTest.c new file mode 100644 index 0000000..d93db84 --- /dev/null +++ b/src/rbtTest.c @@ -0,0 +1,299 @@ +#include "ktest.h" +#include "rbt.h" +#include "heap.h" + +typedef struct IntNode { + RbtNode node; + int32 key; +} IntNode; + +static int compareIntNode(const RbtNode* a, const RbtNode* b) +{ + const IntNode* ia = rbtEntry(a, IntNode, node); + const IntNode* ib = rbtEntry(b, IntNode, node); + if (ia->key < ib->key) + return -1; + if (ia->key > ib->key) + return 1; + return 0; +} + +static int lookupIntKey(const void* key, const RbtNode* node) +{ + int32 k = *(const int32*)key; + const IntNode* in = rbtEntry(node, IntNode, node); + if (k < in->key) + return -1; + if (k > in->key) + return 1; + return 0; +} + +/* Fisher-Yates over a small LCG so insertion/removal order is scrambled but + reproducible across runs -- the point is to exercise every rotation case, + not to be a real source of randomness. */ +static void shuffle(int32* arr, int32 n, uint32 seed) +{ + uint32 state = seed; + for (int32 i = n - 1; i > 0; i--) { + state = state * 1664525u + 1013904223u; + int32 j = (int32)(state % (uint32)(i + 1)); + int32 tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } +} + +/* Returns the black-height of node (counting nullptr leaves as height 1) and + clears *valid if either red-black rule is broken anywhere in the subtree: + a red node with a red child, or unequal black-height between the two + sides of some node. */ +static int32 checkSubtree(RbtNode* node, bool* valid) +{ + if (node == nullptr) + return 1; + + if (node->red) { + if ((node->left != nullptr && node->left->red) || + (node->right != nullptr && node->right->red)) + *valid = false; + } + if (node->left != nullptr && node->left->parent != node) + *valid = false; + if (node->right != nullptr && node->right->parent != node) + *valid = false; + + int32 leftHeight = checkSubtree(node->left, valid); + int32 rightHeight = checkSubtree(node->right, valid); + if (leftHeight != rightHeight) + *valid = false; + + return leftHeight + (node->red ? 0 : 1); +} + +static bool checkInvariants(RbTree* tree) +{ + if (tree->root != nullptr && tree->root->red) + return false; + bool valid = true; + checkSubtree(tree->root, &valid); + return valid; +} + +KTEST(rbtEmptyTree) +{ + RbTree tree; + rbtInit(&tree); + + KT_TRUE(t, rbtIsEmpty(&tree)); + KT_EQ(t, rbtCount(&tree), 0); + KT_NULL(t, rbtFirst(&tree)); + KT_NULL(t, rbtLast(&tree)); + + int32 key = 42; + KT_NULL(t, rbtFind(&tree, &key, lookupIntKey)); +} + +KTEST(rbtBasicInsertFindOrder) +{ + RbTree tree; + rbtInit(&tree); + + int32 values[] = { 5, 3, 8, 1, 4, 7, 9 }; + usize n = sizeof(values) / sizeof(values[0]); + IntNode nodes[7]; + + for (usize i = 0; i < n; i++) { + nodes[i].key = values[i]; + KT_TRUE(t, rbtInsert(&tree, &nodes[i].node, compareIntNode)); + } + KT_EQ(t, rbtCount(&tree), n); + KT_TRUE(t, checkInvariants(&tree)); + + int32 expected[] = { 1, 3, 4, 5, 7, 8, 9 }; + usize i = 0; + for (RbtNode* c = rbtFirst(&tree); c != nullptr; c = rbtNext(c), i++) + KT_EQ(t, rbtEntry(c, IntNode, node)->key, expected[i]); + KT_EQ(t, i, n); + + /* walking backwards from the max visits the same keys in reverse */ + i = n; + for (RbtNode* c = rbtLast(&tree); c != nullptr; c = rbtPrev(c)) + KT_EQ(t, rbtEntry(c, IntNode, node)->key, expected[--i]); + KT_EQ(t, i, 0); + + int32 hitKey = 8; + RbtNode* found = rbtFind(&tree, &hitKey, lookupIntKey); + KT_NOTNULL(t, found); + if (found) + KT_EQ(t, rbtEntry(found, IntNode, node)->key, 8); + + int32 missKey = 100; + KT_NULL(t, rbtFind(&tree, &missKey, lookupIntKey)); + + KT_EQ(t, rbtEntry(rbtFirst(&tree), IntNode, node)->key, 1); + KT_EQ(t, rbtEntry(rbtLast(&tree), IntNode, node)->key, 9); +} + +KTEST(rbtRejectsDuplicateInsert) +{ + RbTree tree; + rbtInit(&tree); + + IntNode a, b; + a.key = 5; + b.key = 5; + + KT_TRUE(t, rbtInsert(&tree, &a.node, compareIntNode)); + KT_FALSE(t, rbtInsert(&tree, &b.node, compareIntNode)); + KT_EQ(t, rbtCount(&tree), 1); +} + +KTEST(rbtSingleRemoveRelinksNeighbors) +{ + RbTree tree; + rbtInit(&tree); + + IntNode nodes[5]; + int32 values[] = { 10, 20, 30, 40, 50 }; + for (int i = 0; i < 5; i++) { + nodes[i].key = values[i]; + rbtInsert(&tree, &nodes[i].node, compareIntNode); + } + + rbtRemove(&tree, &nodes[2].node); /* remove 30, the middle */ + KT_EQ(t, rbtCount(&tree), 4); + KT_TRUE(t, checkInvariants(&tree)); + + int32 key30 = 30; + KT_NULL(t, rbtFind(&tree, &key30, lookupIntKey)); + + int32 expected[] = { 10, 20, 40, 50 }; + usize i = 0; + for (RbtNode* c = rbtFirst(&tree); c != nullptr; c = rbtNext(c), i++) + KT_EQ(t, rbtEntry(c, IntNode, node)->key, expected[i]); + KT_EQ(t, i, 4); +} + +#define rbtStressN 500 + +KTEST(rbtStressInsertMaintainsInvariants) +{ + IntNode* pool = (IntNode*)kmalloc(sizeof(IntNode) * rbtStressN); + int32* order = (int32*)kmalloc(sizeof(int32) * rbtStressN); + KT_NOTNULL(t, pool); + KT_NOTNULL(t, order); + if (!pool || !order) { + kfree(pool); + kfree(order); + return; + } + + for (int32 i = 0; i < rbtStressN; i++) + order[i] = i; + shuffle(order, rbtStressN, 0xC0FFEEu); + + RbTree tree; + rbtInit(&tree); + for (int32 i = 0; i < rbtStressN; i++) { + int32 key = order[i]; + pool[key].key = key; + KT_TRUE(t, rbtInsert(&tree, &pool[key].node, compareIntNode)); + } + KT_EQ(t, rbtCount(&tree), rbtStressN); + KT_TRUE(t, checkInvariants(&tree)); + + int32 expect = 0; + usize visited = 0; + for (RbtNode* c = rbtFirst(&tree); c != nullptr; c = rbtNext(c)) { + KT_EQ(t, rbtEntry(c, IntNode, node)->key, expect); + expect++; + visited++; + } + KT_EQ(t, visited, rbtStressN); + + kfree(pool); + kfree(order); +} + +KTEST(rbtStressRemoveMaintainsInvariants) +{ + IntNode* pool = (IntNode*)kmalloc(sizeof(IntNode) * rbtStressN); + int32* order = (int32*)kmalloc(sizeof(int32) * rbtStressN); + int32* removeOrder = (int32*)kmalloc(sizeof(int32) * rbtStressN); + KT_NOTNULL(t, pool); + KT_NOTNULL(t, order); + KT_NOTNULL(t, removeOrder); + if (!pool || !order || !removeOrder) { + kfree(pool); + kfree(order); + kfree(removeOrder); + return; + } + + for (int32 i = 0; i < rbtStressN; i++) { + order[i] = i; + removeOrder[i] = i; + } + shuffle(order, rbtStressN, 0xDEADBEEFu); + shuffle(removeOrder, rbtStressN, 0x1234567u); + + RbTree tree; + rbtInit(&tree); + for (int32 i = 0; i < rbtStressN; i++) { + int32 key = order[i]; + pool[key].key = key; + rbtInsert(&tree, &pool[key].node, compareIntNode); + } + + /* remove every even key, in scrambled order */ + int32 removed = 0; + for (int32 i = 0; i < rbtStressN; i++) { + int32 key = removeOrder[i]; + if (key % 2 != 0) + continue; + RbtNode* found = rbtFind(&tree, &key, lookupIntKey); + KT_NOTNULL(t, found); + if (found) { + rbtRemove(&tree, found); + removed++; + } + } + KT_EQ(t, rbtCount(&tree), rbtStressN - removed); + KT_TRUE(t, checkInvariants(&tree)); + + for (int32 key = 0; key < rbtStressN; key++) { + RbtNode* found = rbtFind(&tree, &key, lookupIntKey); + if (key % 2 == 0) + KT_NULL(t, found); + else + KT_NOTNULL(t, found); + } + + int32 expect = 1; + usize visited = 0; + for (RbtNode* c = rbtFirst(&tree); c != nullptr; c = rbtNext(c)) { + KT_EQ(t, rbtEntry(c, IntNode, node)->key, expect); + expect += 2; + visited++; + } + KT_EQ(t, visited, rbtStressN - removed); + + /* drain the rest, in the same scrambled order, down to nothing */ + for (int32 i = 0; i < rbtStressN; i++) { + int32 key = removeOrder[i]; + if (key % 2 == 0) + continue; + RbtNode* found = rbtFind(&tree, &key, lookupIntKey); + if (found) + rbtRemove(&tree, found); + } + KT_TRUE(t, rbtIsEmpty(&tree)); + KT_EQ(t, rbtCount(&tree), 0); + KT_NULL(t, rbtFirst(&tree)); + KT_NULL(t, rbtLast(&tree)); + + kfree(pool); + kfree(order); + kfree(removeOrder); +} diff --git a/src/ring.c b/src/ring.c new file mode 100644 index 0000000..3b2bb6e --- /dev/null +++ b/src/ring.c @@ -0,0 +1,58 @@ +#include "ring.h" + +void ringInit(RingBuffer* r, uint8* backingBuffer, usize capacity) +{ + r->data = backingBuffer; + r->capacity = capacity; + r->head = 0; + r->count = 0; +} + +void ringClear(RingBuffer* r) +{ + r->head = 0; + r->count = 0; +} + +bool ringIsEmpty(const RingBuffer* r) { return r->count == 0; } +bool ringIsFull(const RingBuffer* r) { return r->count == r->capacity; } +usize ringCount(const RingBuffer* r) { return r->count; } +usize ringCapacity(const RingBuffer* r) { return r->capacity; } +usize ringFreeSpace(const RingBuffer* r) { return r->capacity - r->count; } + +bool ringPush(RingBuffer* r, uint8 byte) +{ + if (ringIsFull(r)) + return false; + usize tail = (r->head + r->count) % r->capacity; + r->data[tail] = byte; + r->count++; + return true; +} + +bool ringPop(RingBuffer* r, uint8* out) +{ + if (ringIsEmpty(r)) + return false; + if (out) + *out = r->data[r->head]; + r->head = (r->head + 1) % r->capacity; + r->count--; + return true; +} + +usize ringWrite(RingBuffer* r, const uint8* data, usize len) +{ + usize written = 0; + while (written < len && ringPush(r, data[written])) + written++; + return written; +} + +usize ringRead(RingBuffer* r, uint8* data, usize len) +{ + usize readCount = 0; + while (readCount < len && ringPop(r, &data[readCount])) + readCount++; + return readCount; +} diff --git a/src/ringTest.c b/src/ringTest.c new file mode 100644 index 0000000..e841e05 --- /dev/null +++ b/src/ringTest.c @@ -0,0 +1,117 @@ +#include "ktest.h" +#include "ring.h" + +KTEST(ringBasicPushPop) +{ + uint8 backing[4]; + RingBuffer r; + ringInit(&r, backing, sizeof(backing)); + + KT_TRUE(t, ringIsEmpty(&r)); + KT_FALSE(t, ringIsFull(&r)); + KT_EQ(t, ringCapacity(&r), 4); + KT_EQ(t, ringFreeSpace(&r), 4); + + KT_TRUE(t, ringPush(&r, 'a')); + KT_TRUE(t, ringPush(&r, 'b')); + KT_EQ(t, ringCount(&r), 2); + KT_EQ(t, ringFreeSpace(&r), 2); + + uint8 out; + KT_TRUE(t, ringPop(&r, &out)); + KT_EQ(t, out, 'a'); + KT_TRUE(t, ringPop(&r, &out)); + KT_EQ(t, out, 'b'); + KT_TRUE(t, ringIsEmpty(&r)); +} + +KTEST(ringFillsAndRejects) +{ + uint8 backing[3]; + RingBuffer r; + ringInit(&r, backing, sizeof(backing)); + + KT_TRUE(t, ringPush(&r, 1)); + KT_TRUE(t, ringPush(&r, 2)); + KT_TRUE(t, ringPush(&r, 3)); + KT_TRUE(t, ringIsFull(&r)); + KT_FALSE(t, ringPush(&r, 4)); /* rejected, buffer unchanged */ + KT_EQ(t, ringCount(&r), 3); + + uint8 out; + ringPop(&r, &out); + KT_EQ(t, out, 1); + KT_FALSE(t, ringIsFull(&r)); + KT_TRUE(t, ringPush(&r, 4)); /* room again after the pop */ +} + +KTEST(ringWrapsAroundCorrectly) +{ + uint8 backing[4]; + RingBuffer r; + ringInit(&r, backing, sizeof(backing)); + + /* fill, drain two, push two more so the write wraps past the array end */ + ringPush(&r, 1); + ringPush(&r, 2); + ringPush(&r, 3); + ringPush(&r, 4); + uint8 out; + ringPop(&r, &out); + KT_EQ(t, out, 1); + ringPop(&r, &out); + KT_EQ(t, out, 2); + ringPush(&r, 5); + ringPush(&r, 6); + + ringPop(&r, &out); + KT_EQ(t, out, 3); + ringPop(&r, &out); + KT_EQ(t, out, 4); + ringPop(&r, &out); + KT_EQ(t, out, 5); + ringPop(&r, &out); + KT_EQ(t, out, 6); + KT_TRUE(t, ringIsEmpty(&r)); +} + +KTEST(ringBulkWriteRead) +{ + uint8 backing[8]; + RingBuffer r; + ringInit(&r, backing, sizeof(backing)); + + const uint8 msg[] = { 10, 20, 30, 40, 50 }; + usize written = ringWrite(&r, msg, sizeof(msg)); + KT_EQ(t, written, 5); + KT_EQ(t, ringCount(&r), 5); + + /* only 3 more bytes fit before capacity 8 is reached */ + const uint8 more[] = { 1, 2, 3, 4 }; + usize written2 = ringWrite(&r, more, sizeof(more)); + KT_EQ(t, written2, 3); + KT_TRUE(t, ringIsFull(&r)); + + uint8 outBuf[16]; + usize readCount = ringRead(&r, outBuf, sizeof(outBuf)); + KT_EQ(t, readCount, 8); + KT_EQ(t, outBuf[0], 10); + KT_EQ(t, outBuf[4], 50); + KT_EQ(t, outBuf[5], 1); + KT_EQ(t, outBuf[7], 3); + KT_TRUE(t, ringIsEmpty(&r)); +} + +KTEST(ringClearResets) +{ + uint8 backing[4]; + RingBuffer r; + ringInit(&r, backing, sizeof(backing)); + ringPush(&r, 1); + ringPush(&r, 2); + + ringClear(&r); + KT_TRUE(t, ringIsEmpty(&r)); + KT_EQ(t, ringCount(&r), 0); + KT_EQ(t, ringFreeSpace(&r), 4); +} diff --git a/src/rtc.c b/src/rtc.c new file mode 100644 index 0000000..d994637 --- /dev/null +++ b/src/rtc.c @@ -0,0 +1,192 @@ +#include "rtc.h" +#include "ports.h" +#include "string.h" + +#define rtcPortIndex 0x70 +#define rtcPortData 0x71 + +#define rtcRegSeconds 0x00 +#define rtcRegMinutes 0x02 +#define rtcRegHours 0x04 +#define rtcRegDay 0x07 +#define rtcRegMonth 0x08 +#define rtcRegYear 0x09 +#define rtcRegStatusA 0x0A +#define rtcRegStatusB 0x0B + +#define rtcStatusAUip 0x80 + +#define rtcStatusBHourFormat24 0x02 +#define rtcStatusBBinaryMode 0x04 +#define rtcHourPmBit 0x80 + +/* Bounded like ata.c's ataPollTimeout -- a wedged or absent RTC (never + observed on real hardware or under QEMU, but this driver has no way to + independently confirm the chip exists the way ataInit's IDENTIFY does) + returns false/0 instead of spinning forever with interrupts disabled. */ +#define rtcUipTimeout 100000u +#define rtcReadAttempts 8u + +#define rtcAssumedCentury 20 + +static uint8 cmosRead(uint8 reg) +{ + /* Bit 0x80 on the index port disables NMI for the duration of the + read, the same convention the OSDev wiki's own RTC driver uses -- + harmless if the platform ignores it, correct if it does not. */ + outb(rtcPortIndex, (uint8)(reg | 0x80)); + ioWait(); + return inb(rtcPortData); +} + +static bool waitWhileUpdateInProgress(void) +{ + for (uint32 i = 0; i < rtcUipTimeout; i++) { + if ((cmosRead(rtcRegStatusA) & rtcStatusAUip) == 0) + return true; + } + return false; +} + +typedef struct RtcRaw { + uint8 seconds; + uint8 minutes; + uint8 hours; + uint8 day; + uint8 month; + uint8 year; +} RtcRaw; + +static void readRawOnce(RtcRaw* out) +{ + out->seconds = cmosRead(rtcRegSeconds); + out->minutes = cmosRead(rtcRegMinutes); + out->hours = cmosRead(rtcRegHours); + out->day = cmosRead(rtcRegDay); + out->month = cmosRead(rtcRegMonth); + out->year = cmosRead(rtcRegYear); +} + +uint8 rtcBcdToBinary(uint8 bcd) +{ + return (uint8)(((bcd >> 4) & 0x0F) * 10 + (bcd & 0x0F)); +} + +bool rtcReadDateTime(RtcDateTime* out) +{ + RtcRaw a; + RtcRaw b; + bool agree = false; + + for (uint32 attempt = 0; attempt < rtcReadAttempts && !agree; attempt++) { + if (!waitWhileUpdateInProgress()) + return false; + readRawOnce(&a); + if (!waitWhileUpdateInProgress()) + return false; + readRawOnce(&b); + agree = memcmp(&a, &b, sizeof(RtcRaw)) == 0; + } + if (!agree) + return false; + + uint8 statusB = cmosRead(rtcRegStatusB); + bool binaryMode = (statusB & rtcStatusBBinaryMode) != 0; + bool is24Hour = (statusB & rtcStatusBHourFormat24) != 0; + + bool pm = (a.hours & rtcHourPmBit) != 0; + uint8 rawHours = (uint8)(a.hours & (uint8)~rtcHourPmBit); + + uint8 seconds = binaryMode ? a.seconds : rtcBcdToBinary(a.seconds); + uint8 minutes = binaryMode ? a.minutes : rtcBcdToBinary(a.minutes); + uint8 hours = binaryMode ? rawHours : rtcBcdToBinary(rawHours); + uint8 day = binaryMode ? a.day : rtcBcdToBinary(a.day); + uint8 month = binaryMode ? a.month : rtcBcdToBinary(a.month); + uint8 year = binaryMode ? a.year : rtcBcdToBinary(a.year); + + if (!is24Hour) { + if (pm && hours != 12) + hours = (uint8)(hours + 12); + else if (!pm && hours == 12) + hours = 0; + } + + out->year = rtcAssumedCentury * 100 + year; + out->month = month; + out->day = day; + out->hour = hours; + out->minute = minutes; + out->second = seconds; + return true; +} + +/* Howard Hinnant's days_from_civil (proleptic Gregorian calendar), see + http://howardhinnant.github.io/date_algorithms.html -- converts a civil + calendar date into a day count relative to 1970-01-01 with no lookup + tables, correct for the whole int64 range this kernel's RtcDateTime.year + field can carry, including leap years computed structurally (divisible + by 4, not by 100 unless also by 400) rather than by a hand-rolled rule. */ +static int64 daysFromCivil(int64 y, int64 m, int64 d) +{ + y -= (m <= 2) ? 1 : 0; + int64 era = (y >= 0 ? y : y - 399) / 400; + int64 yoe = y - era * 400; /* [0, 399] */ + int64 doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; /* [0, 365] */ + int64 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; /* [0, 146096] */ + return era * 146097 + doe - 719468; +} + +int64 rtcEpochSeconds(const RtcDateTime* dt) +{ + int64 days = daysFromCivil(dt->year, dt->month, dt->day); + return days * 86400 + dt->hour * 3600 + dt->minute * 60 + dt->second; +} + +int64 rtcNow(void) +{ + RtcDateTime dt; + if (!rtcReadDateTime(&dt)) + return 0; + return rtcEpochSeconds(&dt); +} + +/* Howard Hinnant's civil_from_days, the exact inverse of daysFromCivil + above (same source) -- given a day count relative to 1970-01-01, returns + the proleptic Gregorian year/month/day it names. Structurally mirrors + daysFromCivil's era/yoe/doy decomposition in reverse, so the two stay + easy to cross-check against each other by inspection. */ +static void civilFromDays(int64 z, int64* year, int64* month, int64* day) +{ + z += 719468; + int64 era = (z >= 0 ? z : z - 146096) / 146097; + int64 doe = z - era * 146097; /* [0, 146096] */ + int64 yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; /* [0, 399] */ + int64 y = yoe + era * 400; + int64 doy = doe - (365 * yoe + yoe / 4 - yoe / 100); /* [0, 365] */ + int64 mp = (5 * doy + 2) / 153; /* [0, 11] */ + int64 d = doy - (153 * mp + 2) / 5 + 1; /* [1, 31] */ + int64 m = mp + (mp < 10 ? 3 : -9); /* [1, 12] */ + *year = y + (m <= 2 ? 1 : 0); + *month = m; + *day = d; +} + +void rtcCivilFromEpoch(int64 epochSeconds, RtcDateTime* out) +{ + int64 days = epochSeconds / 86400; + int64 secondsOfDay = epochSeconds % 86400; + /* C's integer division truncates toward zero, not toward negative + infinity -- for a negative epochSeconds that does not land exactly + on a day boundary, that rounds `days` the wrong way (toward 1970) + and leaves secondsOfDay negative. Both need shifting back by + exactly one day/86400s together, never one without the other. */ + if (secondsOfDay < 0) { + secondsOfDay += 86400; + days -= 1; + } + + civilFromDays(days, &out->year, &out->month, &out->day); + out->hour = secondsOfDay / 3600; + out->minute = (secondsOfDay % 3600) / 60; + out->second = secondsOfDay % 60; +} diff --git a/src/rtcTest.c b/src/rtcTest.c new file mode 100644 index 0000000..1756c20 --- /dev/null +++ b/src/rtcTest.c @@ -0,0 +1,215 @@ +#include "ktest.h" +#include "rtc.h" + +/* + * rtc.c gets two different kinds of coverage here, the same split + * ata.c/ataTest.c already established: pure arithmetic (the BCD conversion + * and the civil-date-to-epoch math) is tested with known, hand-verified + * values that need no hardware at all, while rtcReadDateTime/rtcNow are + * exercised against whatever CMOS RTC QEMU actually emulates, so a wiring + * mistake (wrong port, wrong status bit, missed BCD/24-hour branch) shows + * up as an implausible or non-monotonic reading here, not a mocked + * assertion. + */ + +KTEST(rtcBcdToBinaryKnownValues) +{ + KT_EQ(t, rtcBcdToBinary(0x00), 0); + KT_EQ(t, rtcBcdToBinary(0x09), 9); + KT_EQ(t, rtcBcdToBinary(0x10), 10); + KT_EQ(t, rtcBcdToBinary(0x23), 23); + KT_EQ(t, rtcBcdToBinary(0x59), 59); +} + +KTEST(rtcEpochSecondsUnixEpochIsZero) +{ + RtcDateTime dt = { .year = 1970, .month = 1, .day = 1, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&dt), 0); +} + +KTEST(rtcEpochSecondsTimeOfDayComponent) +{ + RtcDateTime dt = { .year = 1970, .month = 1, .day = 1, .hour = 1, .minute = 2, .second = 3 }; + KT_EQ(t, rtcEpochSeconds(&dt), 1 * 3600 + 2 * 60 + 3); +} + +KTEST(rtcEpochSecondsOrdinaryDayIsOneDayApart) +{ + RtcDateTime a = { .year = 2026, .month = 9, .day = 7, .hour = 0, .minute = 0, .second = 0 }; + RtcDateTime b = { .year = 2026, .month = 9, .day = 8, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&b) - rtcEpochSeconds(&a), 86400); +} + +/* The single most common bug in hand-rolled calendar math: getting the + leap-year rule wrong. 2024 is a leap year (divisible by 4, not by a + century that isn't also divisible by 400), so February has 29 days and + Feb 28 -> Mar 1 must be a two-day gap, not one. */ +KTEST(rtcEpochSecondsLeapYearFebruaryHas29Days) +{ + RtcDateTime feb28 = { .year = 2024, .month = 2, .day = 28, .hour = 0, .minute = 0, .second = 0 }; + RtcDateTime mar1 = { .year = 2024, .month = 3, .day = 1, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&mar1) - rtcEpochSeconds(&feb28), 2 * 86400); +} + +/* 2023 is not a leap year (not divisible by 4): the same Feb28 -> Mar1 + comparison must collapse to exactly one day, proving the non-leap branch + is not just always adding the leap-year's extra day. */ +KTEST(rtcEpochSecondsNonLeapYearFebruaryHas28Days) +{ + RtcDateTime feb28 = { .year = 2023, .month = 2, .day = 28, .hour = 0, .minute = 0, .second = 0 }; + RtcDateTime mar1 = { .year = 2023, .month = 3, .day = 1, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&mar1) - rtcEpochSeconds(&feb28), 86400); +} + +/* A century year is a leap year only when also divisible by 400: 2000 is + (400 | 2000), 1900 is not. This is the structural part of the rule the + simpler "divisible by 4" tests above cannot distinguish on their own. */ +KTEST(rtcEpochSecondsCenturyLeapRule) +{ + RtcDateTime feb28_2000 = { .year = 2000, .month = 2, .day = 28, .hour = 0, .minute = 0, .second = 0 }; + RtcDateTime mar1_2000 = { .year = 2000, .month = 3, .day = 1, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&mar1_2000) - rtcEpochSeconds(&feb28_2000), 2 * 86400); + + RtcDateTime feb28_1900 = { .year = 1900, .month = 2, .day = 28, .hour = 0, .minute = 0, .second = 0 }; + RtcDateTime mar1_1900 = { .year = 1900, .month = 3, .day = 1, .hour = 0, .minute = 0, .second = 0 }; + KT_EQ(t, rtcEpochSeconds(&mar1_1900) - rtcEpochSeconds(&feb28_1900), 86400); +} + +KTEST(rtcCivilFromEpochUnixEpochIsZero) +{ + RtcDateTime dt; + rtcCivilFromEpoch(0, &dt); + KT_EQ(t, dt.year, 1970); + KT_EQ(t, dt.month, 1); + KT_EQ(t, dt.day, 1); + KT_EQ(t, dt.hour, 0); + KT_EQ(t, dt.minute, 0); + KT_EQ(t, dt.second, 0); +} + +KTEST(rtcCivilFromEpochTimeOfDayComponent) +{ + RtcDateTime dt; + rtcCivilFromEpoch(1 * 3600 + 2 * 60 + 3, &dt); + KT_EQ(t, dt.year, 1970); + KT_EQ(t, dt.month, 1); + KT_EQ(t, dt.day, 1); + KT_EQ(t, dt.hour, 1); + KT_EQ(t, dt.minute, 2); + KT_EQ(t, dt.second, 3); +} + +/* Negative epoch (a date before 1970): -1 second must land on the very + last second of 1969, not silently truncate toward 1970-01-01 the way a + naive C division (which rounds toward zero) would. */ +KTEST(rtcCivilFromEpochNegativeBeforeEpoch) +{ + RtcDateTime dt; + rtcCivilFromEpoch(-1, &dt); + KT_EQ(t, dt.year, 1969); + KT_EQ(t, dt.month, 12); + KT_EQ(t, dt.day, 31); + KT_EQ(t, dt.hour, 23); + KT_EQ(t, dt.minute, 59); + KT_EQ(t, dt.second, 59); + + rtcCivilFromEpoch(-86400, &dt); + KT_EQ(t, dt.year, 1969); + KT_EQ(t, dt.month, 12); + KT_EQ(t, dt.day, 31); + KT_EQ(t, dt.hour, 0); + KT_EQ(t, dt.minute, 0); + KT_EQ(t, dt.second, 0); +} + +/* The exact leap-year civil date rtcEpochSecondsLeapYearFebruaryHas29Days + above already proves is one apart from March 1st -- rtcCivilFromEpoch + must recover 2024-02-29 exactly, not silently normalize it into + 2024-03-01 (the classic off-by-one a wrong leap rule would produce). */ +KTEST(rtcCivilFromEpochLeapDayRoundTrip) +{ + RtcDateTime leapDay = { .year = 2024, .month = 2, .day = 29, .hour = 12, .minute = 30, .second = 45 }; + int64 epoch = rtcEpochSeconds(&leapDay); + + RtcDateTime back; + rtcCivilFromEpoch(epoch, &back); + KT_EQ(t, back.year, 2024); + KT_EQ(t, back.month, 2); + KT_EQ(t, back.day, 29); + KT_EQ(t, back.hour, 12); + KT_EQ(t, back.minute, 30); + KT_EQ(t, back.second, 45); +} + +/* Round trip through both directions (rtcEpochSeconds then + rtcCivilFromEpoch) across a spread of dates spanning several years, + month/year boundaries, and a century-leap year -- proves the two + functions are genuine inverses of each other over a real range, not + just individually correct at a handful of hand-picked points. */ +KTEST(rtcCivilFromEpochRoundTripsAcrossManyDates) +{ + RtcDateTime samples[] = { + { 1970, 1, 1, 0, 0, 0 }, + { 1999, 12, 31, 23, 59, 59 }, + { 2000, 1, 1, 0, 0, 1 }, + { 2000, 2, 29, 6, 7, 8 }, + { 2023, 2, 28, 18, 0, 0 }, + { 2023, 3, 1, 0, 0, 0 }, + { 2026, 9, 7, 17, 30, 0 }, + { 2099, 12, 31, 23, 59, 59 }, + }; + for (uint64 i = 0; i < sizeof(samples) / sizeof(samples[0]); i++) { + int64 epoch = rtcEpochSeconds(&samples[i]); + RtcDateTime back; + rtcCivilFromEpoch(epoch, &back); + KT_EQ(t, back.year, samples[i].year); + KT_EQ(t, back.month, samples[i].month); + KT_EQ(t, back.day, samples[i].day); + KT_EQ(t, back.hour, samples[i].hour); + KT_EQ(t, back.minute, samples[i].minute); + KT_EQ(t, back.second, samples[i].second); + } +} + +KTEST(rtcReadDateTimeReturnsPlausibleFields) +{ + RtcDateTime dt; + KT_TRUE(t, rtcReadDateTime(&dt)); + KT_GE(t, dt.year, 2020); + KT_LE(t, dt.year, 2099); + KT_GE(t, dt.month, 1); + KT_LE(t, dt.month, 12); + KT_GE(t, dt.day, 1); + KT_LE(t, dt.day, 31); + KT_GE(t, dt.hour, 0); + KT_LT(t, dt.hour, 24); + KT_GE(t, dt.minute, 0); + KT_LT(t, dt.minute, 60); + KT_GE(t, dt.second, 0); + KT_LT(t, dt.second, 60); +} + +KTEST(rtcNowIsNeverNegativeOrObviouslyWrong) +{ + /* rtcEpochSecondsUnixEpochIsZero above already proves the math side + hits exactly 0 for 1970-01-01; a real read of a QEMU-emulated CMOS + clock in 2026 should be nowhere near that, so a driver that + accidentally reads all-zero registers (unplugged/uninitialized + hardware, a wrong port) is easy to tell apart from a real one here. */ + int64 now = rtcNow(); + KT_GT(t, now, 0); + /* 2020-01-01T00:00:00Z, a fixed sanity floor well before this test can + ever plausibly run. */ + KT_GT(t, now, 1577836800); +} + +KTEST(rtcNowIsMonotonicAcrossConsecutiveReads) +{ + int64 first = rtcNow(); + /* Real port I/O, not a busy-loop the compiler could fold away: two + fresh hardware reads a few microseconds apart must never see time + run backwards. */ + for (volatile int i = 0; i < 20000; i++) { } + int64 second = rtcNow(); + KT_GE(t, second, first); +} diff --git a/src/sched.c b/src/sched.c index ad6c639..7faf2bb 100644 --- a/src/sched.c +++ b/src/sched.c @@ -9,35 +9,483 @@ #include "console.h" #include "paging.h" #include "vfs.h" +#include "prioq.h" +#include "elf.h" +#include "keyboard.h" +#include "pipe.h" +#include "pidfd.h" +#include "signal.h" -#define maxThreads 16 +/* Bumped from 24: boot now spawns 22 kernel/user threads before the + scheduler even starts (kmain.c) -- several of which (workerA/workerB, + schedVerify, pipeProducer/pipeConsumer) park in an infinite halt() loop + rather than ever calling threadExit, so their slots are never freed for + reuse -- plus a handful more spawned at runtime by those boot programs + themselves (fifomaster's fiforeader/fifowriter, killtest's killvictim, + proctest's spawntarget/argtest) that can be alive at the same time as + their still-running parent. 24 was exactly enough before namedreader/ + namedwriter's fifo-rendezvous demo added two more boot-time processes; + with them, every one of those later spawns (fifomaster's, killtest's, + proctest's own) started failing with the table genuinely full. The + free-slot reuse machinery below (allocSlot/threadReap) also needs + headroom above whatever boot needs to demonstrate exhaustion+reuse in + tests without permanently eating into threads real boot needs. + consolereader1/2+their injector and sigtest+its two short-lived, + promptly-reaped children (signal.h's demo) landed since without needing + another bump -- 32 held across dozens of stress runs, but with exactly + zero slots of headroom left over: boot-time spawns alone (kmain.c) had + already reached the full 32 the moment sigtest's own spawnUser call + returned, before any of killtest's/fifomaster's/proctest's own + runtime-spawned children (killvictim, fifowriter, spawntarget, argtest) + ever got a chance to exist. That razor-thin margin turned into a real, + observed race the moment jobtest+jobtarget (job control's live demo, + signal.h) landed as two more boot/runtime processes: every one of those + OTHER programs' children now had to wait for exactly one freed slot + (killvictim's, once threadReap'd) to fight over, and fifomaster's + fifowriter won that fight at the exact moment killtest was mid-way + through its own "kill an already-reaped pid must fail" check -- + recycling killvictim's just-freed pid number into fifowriter's very much + alive one, so killtest's second userKill(victim) call genuinely + succeeded (against fifowriter, not "nothing") when it expected -1. + Not a scheduling-timing flake (see ci-test.sh's documented host-load + flakiness elsewhere): a deterministic consequence of table pressure, + reproduced identically across repeated runs until this bump. Restored + real headroom (bump to 40, not just the 2 jobtest/jobtarget need) so a + future addition does not immediately re-create the same zero-margin + situation. nesttest/nesttarget and eintrtest/eintrtarget (4 more + boot/runtime processes, signal.h's nested-delivery and early-wake demos) + fit under 40 with zero bump needed -- confirmed as the exact high-water + mark (`[sched] 40 threads total`), i.e. zero slots of headroom left + over again. restarttest/restarttarget (2 more, signal.h's SA_RESTART/ + EINTR demo) would immediately hit the same razor-thin-margin class of + bug FALA 16's maxThreads bump above already fixed once, so bumped ahead + of that this time (40 -> 48) instead of waiting to observe it. + Confirmed as the exact high-water mark yet again once restarttest/ + restarttarget actually landed (`[sched] 48 threads total`, checked live + before touching anything this fala) -- zero slots of headroom left over, + same story as 40 was before it. masktest/masktarget (2 more, signal.h's + sa_mask-defers-then-delivers live demo) would immediately re-create the + exact same class of bug, so bumped ahead of that again (48 -> 56) + instead of waiting to observe it, same reasoning as every bump above. + Confirmed as the exact high-water mark yet again once masktest/ + masktarget landed (`[sched] 56 threads total`, checked live before + touching anything this fala) -- zero slots of headroom left over. + sigchldtest/sigchldchild (2 more, sched.c's new parentId/SIGCHLD-on-exit + live demo) would immediately re-create the exact same class of bug, so + bumped ahead of that again (56 -> 64) instead of waiting to observe it, + same reasoning as every bump above. Confirmed as the exact high-water + mark yet again once sigchldtest/sigchldchild landed (`[sched] 64 threads + total`, checked live before touching anything this fala) -- zero slots + of headroom left over. waitanytest/waitanyfast/waitanyslow (3 more, + sched.c's new threadJoinAnyChild/userWaitAny live demo) would + immediately re-create the exact same class of bug, so bumped ahead of + that again (64 -> 72) instead of waiting to observe it, same reasoning + as every bump above. Confirmed as the exact high-water mark yet again + once waitanytest/waitanyfast/waitanyslow landed (`[sched] 72 threads + total`, checked live before touching anything this fala) -- zero slots + of headroom left over. stopchldtest/stopchldtarget (2 more, sched.c's + new threadStop/threadContinue SIGCHLD-on-stop/SIGCHLD-on-continue live + demo) would immediately re-create the exact same class of bug, so + bumped ahead of that again (72 -> 80) instead of waiting to observe it, + same reasoning as every bump above. Confirmed as the exact high-water + mark yet again once stopchldtest/stopchldtarget landed (`[sched] 80 + threads total`, checked live before touching anything this fala) -- + zero slots of headroom left over. nodefertest (1 more, signal.h's new + sigFlagNoDefer live demo -- unlike every pair above, self-contained, no + spawned counterpart) would immediately re-create the exact same class + of bug, so bumped ahead of that again (80 -> 88) instead of waiting to + observe it, same reasoning as every bump above. alarmtest/alarmdefault + (2 more, sched.c's new threadSetAlarm/schedule()-expiry-scan real + SIGALRM live demo) would immediately re-create the exact same class of + bug, so bumped ahead of that again (88 -> 96) instead of waiting to + observe it, same reasoning as every bump above. siginfotest/ + siginfotarget (2 more, signal.h's new sigFlagSigInfo/SigInfo live demo) + would immediately re-create the exact same class of bug, so bumped ahead + of that again (96 -> 104) instead of waiting to observe it, same + reasoning as every bump above. Confirmed as the exact high-water mark + yet again once siginfotest/siginfotarget landed (`[sched] 104 threads + total`, checked live before touching anything this fala) -- zero slots + of headroom left over. pgroupfg/pgroupbg (2 more, sched.c's new pgid/ + process-group live demo, plus a third kernel-only injector thread, + kmain.c's pgroupInjectorThread) would immediately re-create the exact + same class of bug, so bumped ahead of that again (104 -> 112) instead of + waiting to observe it, same reasoning as every bump above. Confirmed as + the exact high-water mark yet again once pgroupfg/pgroupbg landed + (`[sched] 112 threads total`, checked live before touching anything this + fala) -- zero slots of headroom left over. jobctlfg/jobctlbg (2 more, + jobctl.c's new generic by-path spawn+real-job-control helper, loaded on + demand by the shell's own `NAME [ARGS] [&]` fallback rather than eagerly + at boot like everything above -- the first two user programs in this + kernel never once named in a kmain.c spawnUser call), plus a third + kernel-only driver thread (kmain.c's jobctlDriverThread, which types + commands at the real shell through the same injected-keystroke mechanism + pgroupInjectorThread already uses) would immediately re-create the exact + same class of bug, so bumped ahead of that again (112 -> 120) instead of + waiting to observe it, same reasoning as every bump above. polltest (1 + more, boot-time driver for the new sysPoll multiplexed-fd wait) plus + pollwriter (1 more, runtime-spawned by polltest itself, mirroring + alarmtest/alarmdefault's shape) would immediately re-create the exact + same class of bug, so bumped ahead of that again (120 -> 128) instead of + waiting to observe it, same reasoning as every bump above. pipesrc/ + pipesink (2 more, jobctl.c's new real shell pipeline demo, loaded on + demand exactly like jobctlfg/jobctlbg rather than boot-time spawnUser), + plus a third kernel-only driver thread (kmain.c's pipelineDriverThread, + which types the pipeline command at the real shell through the same + injected-keystroke mechanism jobctlDriverThread already uses) would + immediately re-create the exact same class of bug, so bumped ahead of + that again (128 -> 136) instead of waiting to observe it, same reasoning + as every bump above. redirwriter (1 more, jobctl.c's new file + redirection demo, run TWICE by kmain.c's redirDriverThread against the + same on-demand-loaded binary -- but each run is its own spawn+exit, so + only ONE of its process-table slots is ever live at a time) plus + redirreader (1 more, same on-demand-loading pattern as pipesrc/pipesink + above), plus a third kernel-only driver thread (kmain.c's + redirDriverThread, which types the redirected commands at the real + shell through the same injected-keystroke mechanism jobctlDriverThread/ + pipelineDriverThread already use) would immediately re-create the exact + same class of bug, so bumped ahead of that again (136 -> 144) instead of + waiting to observe it, same reasoning as every bump above. stderrwriter + (1 more, jobctl.c's new stderr (fd 2) redirection demo, run TWICE by + kmain.c's stderrDriverThread against the same on-demand-loaded binary -- + the exact same "only ONE of its process-table slots is ever live at a + time" shape redirwriter already has for `>`/`>>`) plus stderrreader (1 + more, same on-demand-loading pattern as redirreader above), plus a third + kernel-only driver thread (kmain.c's stderrDriverThread, which types the + `2>`/`2>>`-redirected commands at the real shell through the same + injected-keystroke mechanism redirDriverThread already uses) would + immediately re-create the exact same class of bug, so bumped ahead of + that again (144 -> 152) instead of waiting to observe it, same reasoning + as every bump above. selfstoptest (boot-time spawned, self-directed + SIGSTOP live demo) plus selfstoptarget (1 more, runtime-spawned by + selfstoptest, the same *test / *target relationship jobtest/jobtarget + already have) are two more concurrently live threads, so bumped again + (152 -> 160) for the same headroom reason as every bump above. herestest + (1 more, jobctl.c's new here-string `<<<` stdin demo, on-demand-loaded + exactly like redirreader/stderrreader above -- no writer counterpart + binary at all, since the "writer" is the shell's own kernel thread, not + another spawned process) plus a third kernel-only driver thread (kmain.c's + hereDriverThread, which types the here-string command at the real shell + through the same injected-keystroke mechanism redirDriverThread/ + stderrDriverThread already use) would immediately re-create the exact + same class of bug, so bumped ahead of that again (160 -> 168) instead of + waiting to observe it, same reasoning as every bump above. hcat (1 more, + jobctl.c's new jobctlSpawnPipedHereString demo -- a here-string stdin AND + a piped stdout on the same process at once, on-demand-loaded exactly like + herestest above) plus hsink (1 more, same on-demand-loading pattern as + pipesink above), plus a third kernel-only driver thread (kmain.c's + hereStringPipelineDriverThread, which types the here-string-into-pipeline + command at the real shell through the same injected-keystroke mechanism + pipelineDriverThread/hereDriverThread already use) would immediately + re-create the exact same class of bug, so bumped ahead of that again + (168 -> 176) instead of waiting to observe it, same reasoning as every + bump above. pipecat (1 more, shell.c's new runPipelineStages demo -- the + first process in this kernel with two DIFFERENT real inter-process pipes + on its own stdio at once, a genuine middle pipeline stage rather than a + here-string-plus-pipe like hcat above), plus a third kernel-only driver + thread (kmain.c's threeStagePipelineDriverThread, which types the + three-stage pipeline command at the real shell through the same + injected-keystroke mechanism pipelineDriverThread/hereStringPipelineDriverThread + already use) would immediately re-create the exact same class of bug, so + bumped ahead of that again (176 -> 184) instead of waiting to observe it, + same reasoning as every bump above. cwdtest (boot-time spawned, real + per-process working directory live demo -- sched.h's new Thread.cwd) + plus cwdtarget (1 more, runtime-spawned by cwdtest itself, the same + *test / *target relationship jobtest/jobtarget already have) are two + more concurrently live threads, so bumped again (184 -> 192) for the + same headroom reason as every bump above. envprint (loaded on demand by + the real shell, like jobctlfg/hcat/pipecat above, never boot-spawned) + plus a kernel-only driver thread (kmain.c's envDriverThread, which types + `export`/`envprint`/`env` at the real shell through the same + injected-keystroke mechanism every driver thread above already uses) + would immediately re-create the exact same class of bug, so bumped + ahead of that again (192 -> 200) instead of waiting to observe it, same + reasoning as every bump above. nicetest (boot-time spawned, real + userspace-triggered sysNice/sysGetPriority live demo) plus nicetarget + (1 more, runtime-spawned by nicetest itself while sleeping so it can be + reniced while blocked, the same *test / *target relationship + jobtest/jobtarget and cwdtest/cwdtarget already have) are two more + concurrently live threads, so bumped again (200 -> 208) for the same + headroom reason as every bump above. date (boot-time spawned, real + sysTime/rtc.c live demo, fully synchronous like nicetest with no + runtime-spawned partner of its own) is one more, so bumped again + (208 -> 216) for the same headroom reason as every bump above. timetest + (boot-time spawned, real SFS inode mtime/ctime live demo through userOpen/ + userWrite/userStat/userLink, fully synchronous like nicetest/date with no + runtime-spawned partner) is one more, so bumped again (216 -> 224) for the + same headroom reason as every bump above. lsDriverThread (kmain.c, boot- + time kernel thread that types `mkdir`/`echo`/`ls -l` at the real shell + for shell.c's new generalized `ls` builtin) is one more, so bumped again + (224 -> 232) for the same headroom reason as every bump above. + andOrDriverThread (kmain.c, boot-time kernel thread that types + `false && echo X || echo ao2` at the real shell for shell.c's new + `&&`/`||` conditional chaining) is one more, so bumped again + (232 -> 240) for the same headroom reason as every bump above. rmtest + (boot-time spawned, real vfsRmdir/sysRmdir unlink(2)-vs-rmdir(2) split + live demo on both tmpfs and SFS, fully synchronous like nicetest/date/ + timetest with no runtime-spawned partner) is one more, so bumped again + (240 -> 248) for the same headroom reason as every bump above. permtest + (boot-time spawned, real chmod(2)/owner-rwx-permission-bits live demo) + plus the one short-lived runtime-spawned copy of /bin/false it chmods and + spawns to prove the exec-permission check, are two more concurrently + live threads, so bumped again (248 -> 256) for the same headroom reason + as every bump above. dirpermtest (boot-time spawned, real directory-mode + enforcement live demo -- vfsMkdir/resolveOpen/vfsUnlink/vfsRmdir's new + parent-write and ancestor-search checks, lookupNode/lookupFrom's new + exec-on-traversal check -- fully synchronous like identtest with no + runtime-spawned partner) is one more, so bumped again (256 -> 264) for + the same headroom reason as every bump above. grouptest (boot-time + spawned, real group-credential/vfsPermitted-group-branch live demo plus + the two short-lived runtime-spawned copies of /bin/false it group-execs + to prove the exec-permission check across matching/mismatched groups) + are two more concurrently live threads, so bumped again (264 -> 272) for + the same headroom reason as every bump above. pidfdtest (boot-time + spawned, real pidfd.h live demo through sysPidfdOpen/sysPidfdSend/ + sysPidfdWait) plus the one runtime-spawned copy of /bin/killvictim it + reuses as its target (same proven "genuinely stuck" shape killtest.c + already established) are two more concurrently live threads, so bumped + again (272 -> 280) for the same headroom reason as every bump above. + mmaptest (boot-time spawned, real mmap(2)/munmap(2) demand-paging live + demo through sysMmap/sysMunmap -- anonymous demand-zero, file-backed + read-through, and a real SIGSEGV proven against a PROT_READ-only + mapping) plus the one runtime-spawned copy of /bin/mmapprotvictim it + uses to prove that last case are two more concurrently live threads, so + bumped again (280 -> 288) for the same headroom reason as every bump + above. */ +#define maxThreads 288 #define threadStackSize 16384 +/* Virtual-runtime charge for one timer tick's worth of execution at + schedDefaultWeight; scaled by schedDefaultWeight/weight for other + weights (see threadSetPriority). Purely an internal unit -- only ever + compared against other vruntime values, never against wall-clock time. */ +#define schedTickCharge 1000000ull + static Thread threads[maxThreads]; static uint32 threadTotal; static uint32 currentIndex; static bool schedulerActive; +/* allocSlot's own round-robin cursor -- see that function's doc comment + for why this exists at all, not just "the lowest free index" the way + threadTotal's own high-water-mark growth already reads left to right. */ +static uint32 nextSlotHint = 1; + +/* Thread.generation's own monotonic source (see that field's doc comment, + sched.h) -- never 0 (0 is reserved to mean "this slot is currently + unoccupied", the same convention pipe.c's nextPipeIncarnation already + uses for the identical reason), never reused, incremented once per + threadCreate/threadCreateUser regardless of which slot the new thread + lands in. */ +static uint32 nextThreadGeneration = 1; + +/* Bounded copy into a Thread's own name buffer (see threadNameMax / + sched.h) -- never aliases the caller's storage, since threadCreateUser + is called from syscall.c with a path that lives in a stack-local buffer + for the duration of one syscall. */ +static void copyThreadName(Thread* thread, const char* name) +{ + uint64 len = strlen(name); + if (len >= threadNameMax) + len = threadNameMax - 1; + memcpy(thread->name, name, len); + thread->name[len] = 0; +} + +void threadSetName(uint32 id, const char* name) +{ + if (id >= threadTotal) + return; + copyThreadName(&threads[id], name); +} + void schedInit(void) { threadTotal = 1; currentIndex = 0; threads[0].id = 0; - threads[0].name = "idle"; + threads[0].parentId = 0; + /* Reserved, permanently: idle is never reaped/recreated, and pid 0 is + already rejected as a pidfd/kill/wait target everywhere else, so it + never needs a real incarnation of its own (see Thread.generation's + own doc comment). */ + threads[0].generation = 0; + threads[0].pgid = 0; + threads[0].uid = 0; + threads[0].gid = 0; + threads[0].cwd[0] = '/'; + threads[0].cwd[1] = 0; + threads[0].envCount = 0; + copyThreadName(&threads[0], "idle"); threads[0].state = threadRunning; threads[0].rsp = 0; threads[0].stackBase = 0; threads[0].kernelStackTop = 0; threads[0].sleepUntil = 0; threads[0].brk = 0; + threads[0].vruntime = 0; + threads[0].exitCode = 0; + threads[0].weight = schedDefaultWeight; + threads[0].everRan = true; + threads[0].waitChannel = nullptr; vfsFdInit(threads[0].fds); } +/* Finds a slot for a new thread: a threadUnused slot left by a prior + threadReap, or (if none is free) grows the high-water mark by one as + long as there is room under maxThreads. Returns threadInvalidId, + touching nothing, if the table is completely full -- the caller must not + write into threads[] in that case (this is the bounds-check the original + version of this table was missing: threadTotal used to grow unconditionally + on every create, silently writing past the array once 16 threads existed). + Scans round-robin from nextSlotHint (wrapping through 1..threadTotal-1) + rather than always restarting from the lowest index: a real, + reproducibly observed bug this fala's own gate caught fresh on BOTH + killtest.c (a pre-existing, years-old check) and the new nicetest.c -- + "kill/nice a pid this process itself just userWait()-reaped must fail" + occasionally failed for real, because some COMPLETELY UNRELATED thread + elsewhere in the system (in the reproduced case, jobctlfg being loaded + by the shell) called threadCreate/threadCreateUser in the tiny window + between the reap and the recheck and got handed that exact just-freed + LOW index straight back, since the old scan always tried index 1 first. + That is table pressure racing against a single process's own two + syscalls, not a scheduling-timing flake -- ci-test.sh's needle for it + (killtest: kill-ok) had simply never been unlucky enough to hit it + before this fala's elevated host load made the race common enough to + reproduce in a handful of runs. A round-robin cursor does not make the + race impossible (a table sustained at 100% occupancy for a full sweep + could still wrap back around to a freshly-freed slot), but with this + project's real, generous headroom margins (maxThreads far above actual + concurrent occupancy at any point in the historical record, sched.h's + own bump history) it means dozens-to-hundreds of OTHER allocations have + to happen before the cursor can return to any one just-freed slot -- + the same practical-elimination-via-headroom shape every other table- + pressure fix in this file's history already has, not a new kind of + guarantee. A real generation/incarnation counter on pid itself (the + pipeIncarnation/sfsGeneration shape vfs.c/sfs.c already use for pipes + and the on-disk filesystem) would close it completely regardless of + occupancy, but reshaping what a "pid" IS everywhere kill/wait/sigSend/ + setpgid/nice already accept it as a raw table index is the large, + deferred, cross-cutting change this project's own history has flagged + many times over (killtest.c's own doc comment, frostbyte-budowa.md) -- + out of scope for this fix. + + That generation counter now exists (Thread.generation, sched.h; this + file's own nextThreadGeneration below) -- but deliberately NOT wired + into kill/wait/sigSend/setpgid/nice's raw pid argument, matching real + Linux's own resolution of this exact, well-known race: pidfd_open(2) + added a SEPARATE, opt-in handle type rather than ever making a plain + pid argument itself generation-checked. See pidfd.h for the additive + primitive built on Thread.generation, and schedTest.c's pidfd tests for + a deterministic (create/discard/recreate, no live scheduling involved) + reproduction of exactly the stale-handle-hits-an-unrelated-live-thread + scenario killtest.c's own comment warns about but never guards against. */ +static uint32 allocSlot(void) +{ + if (threadTotal > 1) { + uint32 start = (nextSlotHint >= 1 && nextSlotHint < threadTotal) ? nextSlotHint : 1; + uint32 i = start; + do { + if (threads[i].state == threadUnused) { + nextSlotHint = i + 1; + return i; + } + i = (i + 1 < threadTotal) ? i + 1 : 1; + } while (i != start); + } + if (threadTotal < maxThreads) { + uint32 slot = threadTotal++; + nextSlotHint = slot + 1; + return slot; + } + return threadInvalidId; +} + +/* Thread.pgid's own creation-time default (see that field's doc comment, + sched.h): a thread created while the idle thread (id 0) is "current" can + only be one of kmain.c's boot-time calls (parentId's own comment already + establishes idle never issues a syscall, so it can never be "current" + again once real processes exist) -- exactly like a real init spawning + each service into its own job, it becomes its own new group leader. + Anything else is a genuine runtime child (syscall.c's doSpawn) of a + currently-Running parent, and inherits that parent's pgid, matching real + fork()'s own default before any setpgid ever runs. */ +static uint32 initialPgid(uint32 newId) +{ + if (currentIndex == 0) + return newId; + return threads[currentIndex].pgid; +} + +/* Thread.uid's own creation-time default, same "is the idle thread + currently running" test as initialPgid immediately above but with + pgid's own conclusion inverted: a boot-time thread starts as uid 0 + ("root"), like a real init process running as the superuser, rather than + becoming its own new anything; a genuine runtime child instead INHERITS + its parent's uid outright (not derived from newId at all), matching real + fork()'s own default before any setuid ever runs -- the same "copy the + parent's value" shape initialCwd/initialEnv below already use for their + own fields, just a scalar instead of a buffer so it returns a value + like initialPgid does. */ +static uint32 initialUid(uint32 newId) +{ + (void)newId; + if (currentIndex == 0) + return 0; + return threads[currentIndex].uid; +} + +/* Thread.gid's own creation-time default, byte-for-byte the same shape as + initialUid immediately above: a boot-time thread starts at gid 0, a + genuine runtime child INHERITS its parent's gid outright. */ +static uint32 initialGid(uint32 newId) +{ + (void)newId; + if (currentIndex == 0) + return 0; + return threads[currentIndex].gid; +} + +/* Thread.cwd's own creation-time default, mirroring initialPgid's own + reasoning immediately above exactly (same "is the idle thread currently + running" test, same conclusion for the same reason): a boot-time thread + starts at the filesystem root, like a real init process; a genuine + runtime child instead gets a byte-for-byte COPY of its parent's cwd, + matching real fork()'s own copy-not-share semantics -- writes into + threads[newId].cwd directly (rather than returning a value the way + initialPgid does) since a path is a buffer, not a scalar. */ +static void initialCwd(uint32 newId) +{ + if (currentIndex == 0) { + threads[newId].cwd[0] = '/'; + threads[newId].cwd[1] = 0; + return; + } + memcpy(threads[newId].cwd, threads[currentIndex].cwd, fsPathMax); +} + +/* Thread.env's own creation-time default, mirroring initialCwd's own + reasoning immediately above exactly (same "is the idle thread currently + running" test, same conclusion for the same reason): a boot-time thread + starts with an empty table, like a real init process before anything + ever calls setenv(); a genuine runtime child instead gets a byte-for-byte + COPY of its parent's whole table (both the entries and envCount), never + a shared pointer. */ +static void initialEnv(uint32 newId) +{ + if (currentIndex == 0) { + threads[newId].envCount = 0; + return; + } + memcpy(threads[newId].env, threads[currentIndex].env, sizeof(threads[newId].env)); + threads[newId].envCount = threads[currentIndex].envCount; +} + uint32 threadCreate(const char* name, ThreadEntry entry) { - uint32 slot = threadTotal; - threadTotal++; + uint32 slot = allocSlot(); + if (slot == threadInvalidId) + return threadInvalidId; uint64 stackBase = (uint64)kmalloc(threadStackSize); uint64 stackTop = (stackBase + threadStackSize) & ~15ull; @@ -51,22 +499,40 @@ uint32 threadCreate(const char* name, ThreadEntry entry) frame->ss = kernelDataSelector; threads[slot].id = slot; - threads[slot].name = name; + threads[slot].generation = nextThreadGeneration++; + threads[slot].parentId = currentIndex; + threads[slot].pgid = initialPgid(slot); + threads[slot].uid = initialUid(slot); + threads[slot].gid = initialGid(slot); + initialCwd(slot); + initialEnv(slot); + copyThreadName(&threads[slot], name); threads[slot].state = threadRunnable; threads[slot].stackBase = stackBase; threads[slot].kernelStackTop = stackTop; threads[slot].rsp = (uint64)frame; threads[slot].sleepUntil = 0; + threads[slot].alarmAt = 0; + threads[slot].pollActive = false; + threads[slot].pollDeadline = 0; threads[slot].brk = 0; + threads[slot].vruntime = 0; + threads[slot].exitCode = 0; + threads[slot].weight = schedDefaultWeight; + threads[slot].everRan = false; + threads[slot].waitChannel = nullptr; + threads[slot].userSlotBase = 0; vfsFdInit(threads[slot].fds); + sigResetState(&threads[slot]); return slot; } uint32 threadCreateUser(const char* name, uint64 entry, uint64 userStackTop) { - uint32 slot = threadTotal; - threadTotal++; + uint32 slot = allocSlot(); + if (slot == threadInvalidId) + return threadInvalidId; uint64 stackBase = (uint64)kmalloc(threadStackSize); uint64 kernelStackTop = (stackBase + threadStackSize) & ~15ull; @@ -80,43 +546,106 @@ uint32 threadCreateUser(const char* name, uint64 entry, uint64 userStackTop) frame->ss = userDataSelector | 3; threads[slot].id = slot; - threads[slot].name = name; + threads[slot].generation = nextThreadGeneration++; + threads[slot].parentId = currentIndex; + threads[slot].pgid = initialPgid(slot); + threads[slot].uid = initialUid(slot); + threads[slot].gid = initialGid(slot); + initialCwd(slot); + initialEnv(slot); + copyThreadName(&threads[slot], name); threads[slot].state = threadRunnable; threads[slot].stackBase = stackBase; threads[slot].kernelStackTop = kernelStackTop; threads[slot].rsp = (uint64)frame; threads[slot].sleepUntil = 0; + threads[slot].alarmAt = 0; + threads[slot].pollActive = false; + threads[slot].pollDeadline = 0; threads[slot].brk = 0; + threads[slot].vruntime = 0; + threads[slot].exitCode = 0; + threads[slot].weight = schedDefaultWeight; + threads[slot].everRan = false; + threads[slot].waitChannel = nullptr; + threads[slot].userSlotBase = elfSlotBase(entry); vfsFdInit(threads[slot].fds); + sigResetState(&threads[slot]); return slot; } +/* CFS-lite selection: every reschedule rebuilds a min-vruntime priority + queue (prioq.h, backed by the intrusive rbt) from every currently + Runnable thread and picks the minimum. The queue is deliberately + ephemeral (built fresh each call, discarded after one pop) rather than a + run-queue whose membership persists across calls -- a thread can become + Runnable from code paths other than schedule() itself (schedWakeChannel, + called from pipe.c on the producer/consumer side of a wait-queue), and + rebuilding from the authoritative `state` field every time means there is + never a second copy of "who is runnable" to fall out of sync. */ uint64 schedule(uint64 currentRsp) { if (!schedulerActive) return currentRsp; - threads[currentIndex].rsp = currentRsp; - if (threads[currentIndex].state == threadRunning) - threads[currentIndex].state = threadRunnable; + Thread* current = &threads[currentIndex]; + current->rsp = currentRsp; + if (current->state == threadRunning) { + current->vruntime += schedTickCharge * schedDefaultWeight / current->weight; + current->state = threadRunnable; + } uint64 now = pitTicks(); for (uint32 i = 0; i < threadTotal; i++) if (threads[i].state == threadSleeping && now >= threads[i].sleepUntil) threads[i].state = threadRunnable; - uint32 next = 0; - for (uint32 step = 1; step <= threadTotal; step++) { - uint32 candidate = (currentIndex + step) % threadTotal; - if (threads[candidate].state == threadRunnable) { - next = candidate; - break; + /* sysPoll's own timeout, forced the exact same way sleepUntil's expiry + is above: a thread parked on schedPollChannel because none of its + requested fds were ready yet still needs to wake up on its own even + if NONE of them ever becomes ready (pollDeadline == 0 means "wait + forever", i.e. a real timeout of -1, and is deliberately never + forced here). The retried sysPoll call (syscall.c) re-checks every + fd from scratch on wake regardless of why it woke, so forcing + Runnable here without clearing waitChannel first (same as the + sleepUntil scan just above) is enough -- there is no separate + "why did I wake" state to reconcile. */ + for (uint32 i = 0; i < threadTotal; i++) + if (threads[i].state == threadBlocked && threads[i].pollActive && + threads[i].pollDeadline != 0 && now >= threads[i].pollDeadline) + threads[i].state = threadRunnable; + + /* Real alarm(2) delivery: a deadline set by threadSetAlarm fires here, + once per timer tick, regardless of what state its owning thread is + currently in (Running, Sleeping, Blocked -- a real alarm keeps + ticking no matter what the process is doing). Cleared BEFORE + raising, not after, so one expiry fires sigAlrm exactly once, not + once per tick until something else clears it; sigRaiseInfo (signal.h) + already refuses a Dead/Unused target harmlessly, so a thread that + exited with an alarm still pending needs no special case here. + siCodeKernel/0/0: a real SIGALRM has no sending process and no exit + status of its own to report (real Unix's own SIGALRM si_code is + SI_KERNEL/SI_TIMER for exactly this reason -- it is the kernel's + timer, not another process, that raised it). */ + for (uint32 i = 0; i < threadTotal; i++) + if (threads[i].alarmAt != 0 && now >= threads[i].alarmAt) { + threads[i].alarmAt = 0; + sigRaiseInfo(threads[i].id, sigAlrm, siCodeKernel, 0, 0); } - } + + PrioQueue ready; + prioqInit(&ready); + for (uint32 i = 0; i < threadTotal; i++) + if (threads[i].state == threadRunnable) + prioqInsert(&ready, &threads[i].schedNode, threads[i].vruntime, threads[i].id); + + PrioQNode* winner = prioqPopMin(&ready); + uint32 next = winner ? winner->id : currentIndex; currentIndex = next; threads[next].state = threadRunning; + threads[next].everRan = true; tssSetRsp0(threads[next].kernelStackTop); return threads[next].rsp; } @@ -132,8 +661,468 @@ void schedStart(void) void threadExit(uint64 code) { Thread* thread = &threads[currentIndex]; + thread->exitCode = code; thread->state = threadDead; kprintf("[user] pid=%u '%s' exited with code %lu\n", thread->id, thread->name, code); + + /* Real, asynchronous SIGCHLD (signal.h/signal.c) against whoever + spawned this thread (Thread.parentId, sched.h) -- see threadExit's + own doc comment for why this matters beyond threadJoin/sysWait's + already-existing synchronous reap. sigRaiseInfo itself is what makes + this safe against parentId 0 (idle) or an already-gone parent. + siCodeChldExited/this thread's own id/code: real Unix's own + WIFEXITED(status) + WEXITSTATUS(status), exposed directly as + si_pid/si_status instead of a packed word a caller must decode. */ + sigRaiseInfo(thread->parentId, sigChld, siCodeChldExited, (int)thread->id, (int)code); + + /* Wake whichever thread (if any) is parked in threadJoin(thread->id) -- + the channel is this slot's own address, matching threadJoin's + schedBlockCurrent(&threads[id]) below. Harmless no-op if nobody is + joining this thread. */ + schedWakeChannel(thread); + + /* Also wake whichever thread (if any) is parked waiting for ANY child + of thread->parentId (sysWait's waitAnyPid path, syscall.c, via + schedBlockCurrent(currentThread())) -- see threadExit's own doc + comment in sched.h. Channel is the PARENT's own slot address, not + this dying thread's, so it is naturally distinct from the wake above + and a harmless no-op if the parent never called userWaitAny. */ + schedWakeChannel(&threads[thread->parentId]); +} + +bool threadKill(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + + ThreadState state = threads[id].state; + if (state != threadRunnable && state != threadSleeping && + state != threadBlocked && state != threadStopped) + return false; + + threads[id].exitCode = threadKilledExitCode; + threads[id].state = threadDead; + kprintf("[sched] pid=%u '%s' killed\n", id, threads[id].name); + + /* Same sigChld-to-parent contract as threadExit -- a killed child is + still a dead child as far as SIGCHLD is concerned (see threadKill's + own doc comment). siCodeChldKilled/id/threadKilledExitCode: real + Unix's own WIFSIGNALED(status) + WTERMSIG(status) would report the + actual killing signal number; this kernel's threadKill is not tied + to any one specific signal (SIGKILL's sigRaiseInfo delegates + straight here without ever reaching sigPendingInfo itself, and + userKill calls this directly with no signal at all) -- si_status + reports threadKilledExitCode instead, the exact same "killed" value + userWait already surfaces elsewhere in this kernel, not a new + fabricated number. */ + sigRaiseInfo(threads[id].parentId, sigChld, siCodeChldKilled, (int)id, (int)threadKilledExitCode); + + /* Same wake-the-joiner contract as threadExit -- a thread already + parked in threadJoin(id) (or the retry-syscall path in sysWait, + syscall.c) must not wait for its victim to "finish" some + in-progress work first, since a killed thread never gets to. */ + schedWakeChannel(&threads[id]); + + /* Same any-child wake as threadExit -- see threadKill's own doc + comment in sched.h. */ + schedWakeChannel(&threads[threads[id].parentId]); + return true; +} + +uint32 threadPgid(uint32 id) +{ + if (id >= maxThreads || threads[id].state == threadUnused) + return threadInvalidId; + return threads[id].pgid; +} + +bool threadSetPgid(uint32 id, uint32 pgid) +{ + if (id == 0 || id >= maxThreads) + return false; + ThreadState state = threads[id].state; + if (state == threadDead || state == threadUnused) + return false; + + threads[id].pgid = pgid; + return true; +} + +bool threadKillGroup(uint32 pgid) +{ + bool anyKilled = false; + for (uint32 id = 1; id < threadTotal; id++) { + if (threads[id].state == threadDead || threads[id].state == threadUnused) + continue; + if (threads[id].pgid != pgid) + continue; + if (threadKill(id)) + anyKilled = true; + } + return anyKilled; +} + +const char* currentThreadCwd(void) +{ + return threads[currentIndex].cwd; +} + +bool currentThreadSetCwd(const char* path) +{ + uint64 len = strlen(path); + if (len >= fsPathMax) + return false; + memcpy(threads[currentIndex].cwd, path, len); + threads[currentIndex].cwd[len] = 0; + return true; +} + +uint32 currentThreadUid(void) +{ + return threads[currentIndex].uid; +} + +bool currentThreadSetUid(uint32 newUid) +{ + uint32 uid = threads[currentIndex].uid; + if (uid != 0 && newUid != uid) + return false; + threads[currentIndex].uid = newUid; + return true; +} + +uint32 currentThreadGid(void) +{ + return threads[currentIndex].gid; +} + +bool currentThreadSetGid(uint32 newGid) +{ + /* Privilege gate is this thread's own UID (root, CAP_SETGID-style), not + its current GID -- see sched.h's own doc comment for why that + mirrors real setgid(2) rather than currentThreadSetUid's shape. */ + if (threads[currentIndex].uid != 0) { + uint32 gid = threads[currentIndex].gid; + if (newGid != gid) + return false; + } + threads[currentIndex].gid = newGid; + return true; +} + +/* True if `entry` (a stored "NAME=VALUE" slot) names the same variable as + `name` -- i.e. `entry` starts with `name` followed immediately by '='. + Comparing this way (rather than strcmp against a rebuilt "NAME=" prefix) + needs no scratch buffer and stops at the first mismatching byte, same + spirit as string.h's own strncmp. */ +static bool envNameMatches(const char* entry, const char* name) +{ + while (*name) { + if (*entry != *name) + return false; + entry++; + name++; + } + return *entry == '='; +} + +const char* currentThreadGetEnv(const char* name) +{ + Thread* self = &threads[currentIndex]; + for (int i = 0; i < self->envCount; i++) { + if (envNameMatches(self->env[i], name)) { + const char* value = self->env[i]; + while (*value != '=') + value++; + return value + 1; + } + } + return nullptr; +} + +bool currentThreadSetEnv(const char* name, const char* value) +{ + Thread* self = &threads[currentIndex]; + uint64 nameLen = strlen(name); + uint64 valueLen = strlen(value); + if (nameLen + 1 + valueLen + 1 > envEntryMax) + return false; + + int slot = -1; + for (int i = 0; i < self->envCount; i++) { + if (envNameMatches(self->env[i], name)) { + slot = i; + break; + } + } + if (slot < 0) { + if (self->envCount >= envMaxVars) + return false; + slot = self->envCount++; + } + + memcpy(self->env[slot], name, nameLen); + self->env[slot][nameLen] = '='; + memcpy(self->env[slot] + nameLen + 1, value, valueLen); + self->env[slot][nameLen + 1 + valueLen] = 0; + return true; +} + +bool currentThreadUnsetEnv(const char* name) +{ + Thread* self = &threads[currentIndex]; + int slot = -1; + for (int i = 0; i < self->envCount; i++) { + if (envNameMatches(self->env[i], name)) { + slot = i; + break; + } + } + if (slot < 0) + return false; + + int last = self->envCount - 1; + if (slot != last) + memmove(self->env[slot], self->env[slot + 1], (uint64)(last - slot) * envEntryMax); + self->envCount--; + return true; +} + +int currentThreadEnvCount(void) +{ + return threads[currentIndex].envCount; +} + +const char* currentThreadEnvEntry(int index) +{ + if (index < 0 || index >= threads[currentIndex].envCount) + return nullptr; + return threads[currentIndex].env[index]; +} + +bool threadStop(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + + ThreadState state = threads[id].state; + if (state == threadDead || state == threadUnused) + return false; + if (state == threadStopped) { + /* Idempotent, matching real kill(pid, SIGSTOP) sent to an already- + stopped process: the state transition already happened, so there + is no new event for the parent's SIGCHLD to report -- raising + again here would be a second notification for a single stop. */ + return true; + } + + threads[id].state = threadStopped; + kprintf("[sched] pid=%u '%s' stopped\n", id, threads[id].name); + + /* Real job-control SIGCHLD-on-stop, the same asynchronous notification + threadExit/threadKill above already raise on death (Thread.parentId, + signal.h's sigRaiseInfo) -- a shell watching a background job's + parentId finds out the instant it stops, not only when it eventually + dies or exits. Only reached on the actual ...->Stopped transition + above, never on the already-Stopped no-op return just above. + siCodeChldStopped/id/sigStop: real Unix's WIFSTOPPED(status) + + WSTOPSIG(status) would report whichever signal actually caused the + stop -- this function is called from exactly two places (signal.c's + sigRaise sigStop branch, a real SIGSTOP; isr.c's sigTstp-default- + disposition branch, a real SIGTSTP) and does not itself know which + one, so si_status always reports sigStop here regardless. A known, + documented imprecision for the SIGTSTP path (which also has no live + ring-3 demo of its own yet -- see signal.h's file comment) -- not a + fabricated distinction either path never had. */ + sigRaiseInfo(threads[id].parentId, sigChld, siCodeChldStopped, (int)id, sigStop); + return true; +} + +bool threadContinue(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + if (threads[id].state != threadStopped) + return false; + + threads[id].state = threadRunnable; + kprintf("[sched] pid=%u '%s' continued\n", id, threads[id].name); + + /* Same job-control SIGCHLD contract as threadStop above, mirrored for + SIGCONT: real job-control shells rely on this to notice a + backgrounded job resumed without polling its state. Only reached + past the "was it actually Stopped" guard above, so this never fires + for a SIGCONT sent to a target that was never stopped in the first + place, matching real Unix (nothing to report). siCodeChldContinued/ + id/sigCont: real Unix's WIFCONTINUED(status), si_status unused for + this one (POSIX defines none for CLD_CONTINUED either) -- sigCont + here is honest, not a placeholder, unlike threadStop's own sigStop + above: threadContinue really is only ever reached via a genuine + SIGCONT (signal.c's sigRaise sigCont branch is its only caller with + a live path to a Stopped target). */ + sigRaiseInfo(threads[id].parentId, sigChld, siCodeChldContinued, (int)id, sigCont); + return true; +} + +bool threadWakeForSignal(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + + ThreadState state = threads[id].state; + if (state != threadSleeping && state != threadBlocked) + return false; + + threads[id].state = threadRunnable; + kprintf("[sig] pid=%u '%s' woken early for a signal\n", id, threads[id].name); + return true; +} + +uint32 threadCapacity(void) +{ + return maxThreads; +} + +/* Gives a dead/discarded user thread's whole elfSlotSize slot back to the + pmm. Verified, not just invoked blindly: a byte inside the slot's stack + region (always mapped for any user thread, whatever its image looks + like -- see loadImageByPath/spawnUser) is checked present BEFORE the + unmap (proving there was really something to reclaim, not a no-op on an + already-empty slot) and absent AFTER (proving the unmap actually took + the frame back), logged so the boot gate can hold the wiring itself + accountable rather than trusting that calling pagingUnmapUserRange was + enough. Kernel threads (userSlotBase 0) are silently skipped. */ +static void reclaimUserSlot(uint32 id, const Thread* thread) +{ + if (thread->userSlotBase == 0) + return; + + uint64 slotBase = thread->userSlotBase; + uint64 stackProbe = slotBase + elfSlotStackOffset - 1; + bool wasMapped = pagingResolve(stackProbe) != 0; + + pagingUnmapUserRange(slotBase, elfSlotSize); + + bool nowFreed = pagingResolve(stackProbe) == 0; + kprintf("[sched] reap pid=%u freed user slot %p (was-mapped=%s now-freed=%s) -> %s\n", + id, (void*)slotBase, wasMapped ? "yes" : "no", nowFreed ? "yes" : "no", + (wasMapped && nowFreed) ? "user-mem-reclaimed-ok" : "user-mem-reclaimed-FAIL"); +} + +/* Every open fd a thread still holds when it dies represents a live + reference into some other subsystem's own refcounted table -- fd 0's + console reader queue (keyboard.h, allocated unconditionally by vfsFdInit + regardless of whether the thread ever reads stdin, see vfs.c) and any + fdPipeRead/fdPipeWrite fd (pipe.h's readers/writers counts on the + underlying handle, from vfsPipe/vfsPipeAttach/a named fifo's vfsOpen). + vfsClose already decrements both correctly, but only for a LIVE thread + closing its OWN fd through currentThread() -- a thread that dies + (threadKill, or simply returns/threadExit) while still holding either + open skips vfsClose entirely, so without this the memset below would + just drop the handle on the floor. + For the console reader this was caught live (FALA 13): the reader table + is a small fixed pool (maxConsoleReaders), and schedTest.c alone creates + and tears down dozens of throwaway threads while proving the sched table + itself reuses freed slots (threadCreateExhaustsThenThreadDiscardFrees...), + which permanently exhausted the ENTIRELY SEPARATE console reader pool + before boot ever reached kmain.c's real spawnUser calls -- every + boot-time process's fd 0 silently went non-blocking (keyboardOpenReader + returning consoleReaderInvalid), so consolereader1.c/consolereader2.c's + userRead(0, ...) returned -1 immediately instead of blocking for the + synthetic keystroke. + The pipe/fifo case is the same shape of leak, just against pipe.c's own + readers/writers counters instead of a fixed pool: the OTHER end of a + shared pipe (a second, independent process, e.g. fifomaster.c's + children) would never see the true EOF/broken-pipe pipeRead/pipeWrite + report once every attached end on this side is gone, and would instead + retry "try again" forever -- exactly the resource-exhaustion shape + bug#2 above had, just not yet observed breaking anything live because + nothing today loses a pipe/fifo fd without an orderly close() first (see + schedTest.c's reclaimOpenFds tests, which prove the leak by fault + injection rather than waiting for a live symptom). */ +static void reclaimOpenFds(const Thread* thread) +{ + for (uint32 i = 0; i < maxOpenFiles; i++) { + const FileDescriptor* fd = &thread->fds[i]; + if (fd->kind == fdConsole && fd->pipeHandle >= 0) + keyboardCloseReader(fd->pipeHandle); + else if (fd->kind == fdPipeRead) + pipeCloseRead(fd->pipeHandle); + else if (fd->kind == fdPipeWrite) + pipeCloseWrite(fd->pipeHandle); + else if (fd->kind == fdPidfd) + pidfdClose(fd->pipeHandle); + } +} + +bool threadReap(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + if (threads[id].state != threadDead) + return false; + + reclaimUserSlot(id, &threads[id]); + reclaimOpenFds(&threads[id]); + if (threads[id].stackBase) + kfree((void*)threads[id].stackBase); + memset(&threads[id], 0, sizeof(Thread)); + /* memset leaves state at 0, which is threadUnused -- exactly the value + allocSlot() scans for when looking for a slot to reuse. */ + return true; +} + +bool threadDiscard(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return false; + if (threads[id].state != threadRunnable || threads[id].everRan) + return false; + + if (threads[id].userSlotBase != 0) + pagingUnmapUserRange(threads[id].userSlotBase, elfSlotSize); + reclaimOpenFds(&threads[id]); + if (threads[id].stackBase) + kfree((void*)threads[id].stackBase); + memset(&threads[id], 0, sizeof(Thread)); + return true; +} + +int64 threadJoin(uint32 id) +{ + if (id == 0 || id >= maxThreads) + return -1; + + for (;;) { + ThreadState state = threads[id].state; + if (state == threadUnused) + return -1; + if (state == threadDead) + break; + schedBlockCurrent(&threads[id]); + halt(); + } + + uint64 code = threads[id].exitCode; + threadReap(id); + return (int64)code; +} + +WaitScanResult threadJoinAnyChild(uint32 parentId, uint32* outId, int64* outCode) +{ + bool hasChild = false; + for (uint32 i = 1; i < threadTotal; i++) { + if (threads[i].state == threadUnused || threads[i].parentId != parentId) + continue; + hasChild = true; + if (threads[i].state == threadDead) { + *outId = i; + *outCode = (int64)threads[i].exitCode; + threadReap(i); + return waitScanFoundDead; + } + } + return hasChild ? waitScanNoneDead : waitScanNoChildren; } void threadSleep(uint64 milliseconds) @@ -145,18 +1134,107 @@ void threadSleep(uint64 milliseconds) threads[currentIndex].state = threadSleeping; } +uint64 threadSetAlarm(uint64 seconds) +{ + Thread* self = &threads[currentIndex]; + uint64 now = pitTicks(); + + uint64 previousRemaining = 0; + if (self->alarmAt != 0 && self->alarmAt > now) { + uint64 remainingTicks = self->alarmAt - now; + uint32 freq = pitFrequency(); + /* Ceiling division: a deadline anywhere inside the current second + still counts as "1 second remaining", never "0" -- matching real + alarm()'s own contract that a not-yet-fired alarm never reports + 0 remaining (0 is reserved for "none pending" everywhere in this + API, see the header comment). */ + previousRemaining = (remainingTicks + freq - 1) / freq; + } + + if (seconds == 0) + self->alarmAt = 0; + else + self->alarmAt = now + seconds * (uint64)pitFrequency(); + + return previousRemaining; +} + +void schedBlockCurrent(const void* channel) +{ + threads[currentIndex].waitChannel = channel; + threads[currentIndex].state = threadBlocked; +} + +const void* schedPollChannel(void) +{ + static const int pollChannelToken; + return (const void*)&pollChannelToken; +} + +void schedWakeChannel(const void* channel) +{ + for (uint32 i = 0; i < threadTotal; i++) { + if (threads[i].state == threadBlocked && threads[i].waitChannel == channel) { + threads[i].waitChannel = nullptr; + threads[i].state = threadRunnable; + } + } +} + void threadSetBrk(uint32 id, uint64 brk) { + if (id >= maxThreads) + return; threads[id].brk = brk; } +uint64 threadUserSlotBase(uint32 id) +{ + if (id >= maxThreads) + return 0; + return threads[id].userSlotBase; +} + +void threadSetUserSlotBase(uint32 id, uint64 base) +{ + if (id >= maxThreads) + return; + threads[id].userSlotBase = base; +} + +bool threadSetPriority(uint32 id, uint32 weight) +{ + if (id == 0 || id >= maxThreads || weight == 0) + return false; + ThreadState state = threads[id].state; + if (state == threadDead || state == threadUnused) + return false; + + threads[id].weight = weight; + return true; +} + +uint32 threadPriority(uint32 id) +{ + if (id >= maxThreads || threads[id].state == threadUnused) + return 0; + return threads[id].weight; +} + +uint64 threadVruntime(uint32 id) +{ + if (id >= threadTotal) + return 0; + return threads[id].vruntime; +} + uint64 currentThreadSbrk(int64 increment) { Thread* thread = &threads[currentIndex]; uint64 previous = thread->brk; if (increment > 0) - pagingMapUserRange(previous, (uint64)increment); + pagingMapUserRangeFlags(previous, (uint64)increment, pageNoExecute); thread->brk = previous + (uint64)increment; return previous; diff --git a/src/schedTest.c b/src/schedTest.c new file mode 100644 index 0000000..5e6053e --- /dev/null +++ b/src/schedTest.c @@ -0,0 +1,1655 @@ +#include "ktest.h" +#include "sched.h" +#include "ports.h" +#include "elf.h" +#include "paging.h" +#include "pmm.h" +#include "pipe.h" +#include "signal.h" +#include "pit.h" +#include "klib.h" + +/* + * ktestRunAll() runs before schedStart() (see ktest.h), on a single thread + * with no live scheduler - there is no second execution context to actually + * context-switch into, and no interrupts to wake a genuinely halted CPU. + * The first block of tests below therefore exercises schedBlockCurrent/ + * schedWakeChannel as the pure state-machine they are (mark-blocked / + * match-and-wake), using the always-present current thread (idle, id 0 at + * this point in boot) rather than creating new threads. + * + * The second block tests threadCreate/threadReap/threadJoin's bookkeeping + * (slot allocation, exhaustion, reuse, bounds-checking) directly: a thread + * created here is never actually scheduled to run (the scheduler is not yet + * live), so as long as every thread a test creates is reaped again before + * the test returns, the real thread table is left exactly as it found it - + * no throwaway slot is ever left behind to eat into kmain.c's later budget. + * The one path that genuinely cannot be tested here is threadJoin() ever + * actually parking (schedBlockCurrent + halt()) on a thread that is still + * alive: that requires a second thread for the scheduler to switch to, + * which does not exist before schedStart(). That path is instead proven + * live by the join demo in kmain.c (needle "join-reap-ok" in ci-test.sh). + */ + +KTEST(schedBlockCurrentMarksBlockedWithChannel) +{ + ThreadState saved = currentThread()->state; + int channel; + + schedBlockCurrent(&channel); + KT_EQ(t, currentThread()->state, threadBlocked); + KT_EQP(t, currentThread()->waitChannel, &channel); + + schedWakeChannel(&channel); + currentThread()->state = saved; +} + +KTEST(schedWakeChannelIgnoresMismatchedChannel) +{ + ThreadState saved = currentThread()->state; + int channelA; + int channelB; + + schedBlockCurrent(&channelA); + KT_EQ(t, currentThread()->state, threadBlocked); + + schedWakeChannel(&channelB); + KT_EQ(t, currentThread()->state, threadBlocked); + KT_EQP(t, currentThread()->waitChannel, &channelA); + + schedWakeChannel(&channelA); + KT_EQ(t, currentThread()->state, threadRunnable); + + currentThread()->state = saved; +} + +KTEST(schedWakeChannelClearsWaitChannelOnWake) +{ + ThreadState saved = currentThread()->state; + int channel; + + schedBlockCurrent(&channel); + schedWakeChannel(&channel); + KT_EQ(t, currentThread()->state, threadRunnable); + KT_NULL(t, currentThread()->waitChannel); + + currentThread()->state = saved; +} + +KTEST(schedPollChannelIsStableAndUsableLikeAnyOtherChannel) +{ + ThreadState saved = currentThread()->state; + + /* Same address every call -- every not-yet-satisfied sysPoll (poll.h) + across every thread blocks on this ONE shared channel, so callers + must never see it move. */ + KT_EQP(t, schedPollChannel(), schedPollChannel()); + KT_NOTNULL(t, schedPollChannel()); + + /* And it works exactly like any other channel schedBlockCurrent/ + schedWakeChannel already accept -- the schedule()-driven pollDeadline + expiry scan (sched.c) that forces a wake even with nobody calling + schedWakeChannel(schedPollChannel()) needs a live scheduler + (schedulerActive) to run at all, so it is proven live instead, by + user/polltest.c's real timeout phase (tools/ci-test.sh's + "polltest: poll-timeout-ok" needle) -- the same split schedTest.c's + own file comment already documents for sleepUntil/alarmAt. */ + schedBlockCurrent(schedPollChannel()); + KT_EQ(t, currentThread()->state, threadBlocked); + KT_EQP(t, currentThread()->waitChannel, schedPollChannel()); + + schedWakeChannel(schedPollChannel()); + KT_EQ(t, currentThread()->state, threadRunnable); + + currentThread()->state = saved; +} + +KTEST(schedWakeChannelWithNoWaiterIsHarmlessNoop) +{ + ThreadState saved = currentThread()->state; + int channel; + + /* Nobody is blocked on this address: waking it must not touch any + thread's state, including the caller's own. */ + schedWakeChannel(&channel); + KT_EQ(t, currentThread()->state, saved); + + currentThread()->state = saved; +} + +KTEST(schedBlockCurrentIsIdempotentUnderRepeatedCalls) +{ + ThreadState saved = currentThread()->state; + int channelA; + int channelB; + + schedBlockCurrent(&channelA); + schedBlockCurrent(&channelB); + KT_EQ(t, currentThread()->state, threadBlocked); + KT_EQP(t, currentThread()->waitChannel, &channelB); + + /* Waking the stale first channel must not affect a thread that has since + re-blocked on a different one. */ + schedWakeChannel(&channelA); + KT_EQ(t, currentThread()->state, threadBlocked); + + schedWakeChannel(&channelB); + KT_EQ(t, currentThread()->state, threadRunnable); + + currentThread()->state = saved; +} + +/* Never actually invoked (the scheduler is not live yet) -- just a valid + function pointer for threadCreate's entry parameter. If it were ever + scheduled for real it would be a harmless, correctly-behaved kernel + thread body (park forever), same convention as workerA/workerB. */ +static void noopThreadEntry(void) +{ + for (;;) + halt(); +} + +KTEST(threadCreateExhaustsThenThreadDiscardFreesSlotsBackForReuse) +{ + /* Sized off threadCapacity() itself, not a hardcoded number that would + silently stop meaning "the whole table" the next time maxThreads + (sched.c) grows -- exactly the staleness this test's own array size + once had (fixed at 64, which only ever happened to equal maxThreads + by coincidence of when it was written; the very next maxThreads bump + after this one otherwise made the while loop below stop at the array + bound before threadCreate ever actually failed, so the "must refuse + to grow past capacity" check two lines down silently stopped testing + anything and leaked one never-discarded thread for the rest of + boot). idle already occupies one slot, so successful creates always + stay strictly below `capacity`, and the loop below is guaranteed to + hit its threadInvalidId break at or before `count == capacity - 1` -- + this array can never actually overflow. */ + uint32 capacity = threadCapacity(); + uint32 created[capacity]; + uint32 count = 0; + + /* Every thread created here stays Runnable/never-run for its entire + life in this test (schedule() never runs before schedStart()), so + threadDiscard -- not threadReap, which requires threadDead -- is the + correct way to tear them back down. */ + while (count < capacity) { + uint32 id = threadCreate("ktestDummy", noopThreadEntry); + if (id == threadInvalidId) + break; + created[count++] = id; + } + + /* The table must have refused to grow past its fixed capacity instead + of silently writing past threads[] -- this is the bounds-check the + original allocator did not have. */ + KT_EQU(t, threadCreate("shouldFail", noopThreadEntry), threadInvalidId); + + for (uint32 i = 0; i < count; i++) + KT_TRUE(t, threadDiscard(created[i])); + + /* Every discarded slot must be usable again: the very next create + should succeed (it would fail immediately if discard had left the + table full). */ + uint32 reused = threadCreate("afterDiscard", noopThreadEntry); + KT_NE(t, reused, threadInvalidId); + KT_TRUE(t, threadDiscard(reused)); +} + +KTEST(threadCreateSkipsAFreshlyFreedLowSlotInFavorOfMovingForward) +{ + /* allocSlot (sched.c) used to always hand out the LOWEST currently-free + slot -- this test used to assert exactly that (KT_EQ(reused, b) + after discarding the middle one of three). That was a real, + reproducible bug: this fala's own gate caught killtest.c's and + nicetest.c's "a pid I just userWait()-reaped must reject a further + kill/nice" checks occasionally failing for real, because some + completely unrelated thread elsewhere in the system called + threadCreate in the tiny window between the reap and the recheck + and got handed that exact just-freed low index straight back. The + fix -- allocSlot's own new round-robin cursor -- means the very + next create after freeing a LOW slot, with plenty of headroom still + available, deliberately does NOT reuse it: it keeps moving forward + and grows the high-water mark instead, exactly like a real OS's own + pid allocator avoids immediately handing back a pid that just + exited. See allocSlot's own doc comment for the full story and + why this is a practical mitigation via headroom, not a mathematical + impossibility. */ + uint32 a = threadCreate("rrA", noopThreadEntry); + uint32 b = threadCreate("rrB", noopThreadEntry); + uint32 c = threadCreate("rrC", noopThreadEntry); + KT_LT(t, a, b); + KT_LT(t, b, c); + + KT_TRUE(t, threadDiscard(b)); + + uint32 afterFree = threadCreate("rrAfterFree", noopThreadEntry); + KT_NE(t, afterFree, b); + KT_GT(t, afterFree, c); + + KT_TRUE(t, threadDiscard(a)); + KT_TRUE(t, threadDiscard(afterFree)); + KT_TRUE(t, threadDiscard(c)); + + /* b itself is still sitting there Unused, unreachable until the + cursor eventually wraps back around to it -- the very next test + below proves it is not leaked forever, just deferred. */ + KT_EQU(t, threadPgid(b), threadInvalidId); +} + +KTEST(threadCreateReusesTheOnlyFreedSlotOnceTheTableIsFull) +{ + /* Same "must reuse freed slots, not just grow the high-water mark + forever" property the old lowest-index-specific test above used to + cover, restated so it holds regardless of WHERE allocSlot's cursor + happens to be: fill the ENTIRE table (sized off threadCapacity() + itself, same staleness-proofing reasoning as + threadCreateExhaustsThenThreadDiscardFreesSlotsBackForReuse above), + free exactly one slot picked from the middle, then prove the very + next create can ONLY land on that one slot -- there is nowhere else + left to land, so the round-robin scan is forced all the way around + to it regardless of its own starting position. */ + uint32 capacity = threadCapacity(); + uint32 created[capacity]; + uint32 count = 0; + + while (count < capacity) { + uint32 id = threadCreate("rrFill", noopThreadEntry); + if (id == threadInvalidId) + break; + created[count++] = id; + } + KT_EQU(t, threadCreate("rrShouldFail", noopThreadEntry), threadInvalidId); + + uint32 freedId = created[count / 2]; + KT_TRUE(t, threadDiscard(freedId)); + + uint32 reused = threadCreate("rrReusesOnlyFree", noopThreadEntry); + KT_EQ(t, reused, freedId); + + for (uint32 i = 0; i < count; i++) { + if (created[i] != freedId) + KT_TRUE(t, threadDiscard(created[i])); + } + KT_TRUE(t, threadDiscard(reused)); +} + +KTEST(threadDiscardRejectsIdleOutOfRangeAndDoubleDiscard) +{ + /* id 0 is the permanent idle thread: everRan is true for it from + schedInit onward, so discard must refuse it. */ + KT_FALSE(t, threadDiscard(0)); + + /* Past the fixed capacity entirely. */ + KT_FALSE(t, threadDiscard(threadCapacity())); + KT_FALSE(t, threadDiscard(threadCapacity() + 1000)); + + uint32 id = threadCreate("discardMe", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_TRUE(t, threadDiscard(id)); + + /* Already threadUnused: a second discard on the same id must fail + rather than double-free the (already-freed) stack. */ + KT_FALSE(t, threadDiscard(id)); +} + +KTEST(threadReapRejectsIdleOutOfRangeAndNeverCreatedSlots) +{ + /* id 0 is the permanent idle thread: it is always Running/Runnable, + never Dead, so reap must refuse it. */ + KT_FALSE(t, threadReap(0)); + + /* Past the fixed capacity entirely. */ + KT_FALSE(t, threadReap(threadCapacity())); + KT_FALSE(t, threadReap(threadCapacity() + 1000)); + + /* A slot nobody has ever created a thread in yet is threadUnused, not + threadDead. A freshly created, never-run thread (Runnable) is not + threadDead either -- reap must refuse it too, distinctly from + threadDiscard above. */ + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadReap(neverUsed)); + + uint32 runnable = threadCreate("notDeadYet", noopThreadEntry); + KT_NE(t, runnable, threadInvalidId); + KT_FALSE(t, threadReap(runnable)); + KT_TRUE(t, threadDiscard(runnable)); +} + +KTEST(threadJoinRejectsInvalidIdsWithoutBlocking) +{ + /* All of these must return immediately (no schedBlockCurrent/halt()) + since there is no live scheduler to ever wake this thread back up -- + a wrong implementation here would hang the whole test battery, not + just fail an assertion. */ + KT_EQ(t, threadJoin(0), -1); + KT_EQ(t, threadJoin(threadCapacity()), -1); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_EQ(t, threadJoin(neverUsed), -1); +} + +KTEST(threadJoinOnDiscardedSlotFailsCleanly) +{ + uint32 id = threadCreate("transient", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_TRUE(t, threadDiscard(id)); + + /* The slot is threadUnused again -- joining it must behave exactly like + joining an id that was never created, not crash or block. */ + KT_EQ(t, threadJoin(id), -1); +} + +/* + * threadCreateUser/threadReap/threadDiscard user-memory reclamation + * (Thread.userSlotBase, sched.h). Every real spawn/exec used to leak its + * whole elfSlotSize slot's worth of physical frames on death, since + * frostbyte's single shared page table is never touched by thread teardown + * -- these tests drive that path directly with a synthetic "process" (its + * own fake slot, far above any real USER_BASE any binary is linked at, so + * it can never collide with a real program's mapping) rather than relying + * only on the live boot demo (kmain.c's proctest/spawntarget/exectarget + * chain, needles "user-mem-reclaimed-ok"/"exec-old-slot-freed-ok" in + * ci-test.sh) to prove it. + */ + +KTEST(threadCreateUserSetsUserSlotBaseFromEntry) +{ + uint64 entry = 0x320000000ull + 0x2000; + uint32 id = threadCreateUser("probe", entry, elfSlotStackTop(entry)); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadUserSlotBase(id), elfSlotBase(entry)); + KT_EQU(t, threadUserSlotBase(id), 0x320000000ull); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadReapOnDeadUserThreadReclaimsItsMappedPages) +{ + uint64 fakeSlotBase = 0x330000000ull; + uint64 entry = fakeSlotBase + 0x2000; + uint64 stackTop = elfSlotStackTop(entry); + uint64 stackSize = 4 * frameSize; + uint64 before = pmmFreeFrames(); + + /* Mirror what a real loadImageByPath/spawnUser call maps for any user + thread: a sliver of "image" plus an eagerly-mapped stack, both well + inside the 256 MiB slot -- most of the slot stays unmapped, exactly + like a real small program. */ + /* At least the image page plus the stack pages -- entry and fakeSlotBase + are virgin address space, so mapping also pays for whatever PDPT/PD/PT + page-table pages do not exist yet (paging.c's nextTable create path), + which this test does not try to predict the exact count of. The reap/ + discard side below is the precise assertion: every frame spent here, + data or structural, must come all the way back. */ + KT_TRUE(t, pagingMapUserRange(entry, frameSize)); + KT_TRUE(t, pagingMapUserRange(stackTop - stackSize, stackSize)); + KT_LE(t, pmmFreeFrames(), before - 1 - (stackSize / frameSize)); + + uint32 id = threadCreateUser("fakeProc", entry, stackTop); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadUserSlotBase(id), fakeSlotBase); + + /* No live scheduler to run threadExit through before schedStart() -- + poke the state directly, the same workaround + schedBlockCurrentMarksBlockedWithChannel uses above for the same + reason. */ + threadAt(id)->state = threadDead; + + KT_TRUE(t, threadReap(id)); + KT_EQU(t, pmmFreeFrames(), before); + KT_EQ(t, pagingResolve(entry), 0); + KT_EQ(t, pagingResolve(stackTop - 1), 0); +} + +KTEST(threadDiscardOnNeverRunUserThreadReclaimsItsMappedPages) +{ + uint64 fakeSlotBase = 0x340000000ull; + uint64 entry = fakeSlotBase + 0x2000; + uint64 stackTop = elfSlotStackTop(entry); + uint64 stackSize = 4 * frameSize; + uint64 before = pmmFreeFrames(); + + /* At least the image page plus the stack pages -- entry and fakeSlotBase + are virgin address space, so mapping also pays for whatever PDPT/PD/PT + page-table pages do not exist yet (paging.c's nextTable create path), + which this test does not try to predict the exact count of. The reap/ + discard side below is the precise assertion: every frame spent here, + data or structural, must come all the way back. */ + KT_TRUE(t, pagingMapUserRange(entry, frameSize)); + KT_TRUE(t, pagingMapUserRange(stackTop - stackSize, stackSize)); + KT_LE(t, pmmFreeFrames(), before - 1 - (stackSize / frameSize)); + + uint32 id = threadCreateUser("fakeProc2", entry, stackTop); + KT_NE(t, id, threadInvalidId); + + /* Runnable, never scheduled: the threadDiscard path, not threadReap. */ + KT_TRUE(t, threadDiscard(id)); + KT_EQU(t, pmmFreeFrames(), before); + KT_EQ(t, pagingResolve(entry), 0); + KT_EQ(t, pagingResolve(stackTop - 1), 0); +} + +KTEST(threadCreateKernelThreadHasNoUserSlotAndReapLeavesPmmUntouched) +{ + uint64 before = pmmFreeFrames(); + + uint32 id = threadCreate("kernelOnly", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadUserSlotBase(id), 0); + + threadAt(id)->state = threadDead; + KT_TRUE(t, threadReap(id)); + KT_EQU(t, pmmFreeFrames(), before); +} + +/* + * threadKill (sched.c) -- the only way to recover a thread genuinely stuck + * forever, now that sysRead/sysWrite can really block on a shared pipe + * (pipe.h/vfs.h). The live proof that it tears down a thread ACTUALLY + * parked mid-syscall is killtest.c spawning killvictim.c (needles + * "killtest: killed victim"/"killtest: kill-ok" in tools/ci-test.sh); what + * is deterministic here is threadKill's own state-machine bookkeeping. + */ + +KTEST(threadKillRejectsIdleOutOfRangeAndNeverCreatedSlots) +{ + KT_FALSE(t, threadKill(0)); + KT_FALSE(t, threadKill(threadCapacity())); + KT_FALSE(t, threadKill(threadCapacity() + 1000)); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadKill(neverUsed)); +} + +KTEST(threadKillMarksRunnableThreadDeadWithKilledExitCode) +{ + uint32 id = threadCreate("killMe", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadKill(id)); + KT_EQ(t, threadAt(id)->state, threadDead); + KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode); + + /* A killed thread reaps exactly like one that called threadExit on its + own -- no separate "killed" bookkeeping survives past exitCode. */ + KT_TRUE(t, threadReap(id)); +} + +KTEST(threadKillOnAlreadyDeadOrUnusedSlotFails) +{ + uint32 id = threadCreate("killTwice", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadKill(id)); + /* Already Dead: a second kill must not succeed (nor reset exitCode). */ + KT_FALSE(t, threadKill(id)); + KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode); + + KT_TRUE(t, threadReap(id)); + /* threadUnused again after reap: still rejected. */ + KT_FALSE(t, threadKill(id)); +} + +KTEST(threadKillWakesAThreadParkedInJoin) +{ + ThreadState saved = currentThread()->state; + + uint32 victim = threadCreate("victim", noopThreadEntry); + KT_NE(t, victim, threadInvalidId); + + /* Mirrors exactly what threadJoin (sched.c) itself does while waiting + for `victim`: block the current thread (the idle thread, id 0, at + this point in boot) on the victim slot's own address -- the same + channel threadExit/threadKill both wake. */ + schedBlockCurrent(threadAt(victim)); + KT_EQ(t, currentThread()->state, threadBlocked); + + KT_TRUE(t, threadKill(victim)); + KT_EQ(t, currentThread()->state, threadRunnable); + + currentThread()->state = saved; + KT_TRUE(t, threadReap(victim)); +} + +/* + * Thread.parentId (sched.h) and the real, asynchronous SIGCHLD it drives + * from threadExit/threadKill (signal.h/signal.c's sigRaise) -- the live + * ring-3 proof that a genuinely blocked parent actually wakes for its own + * child's death is user/sigchldtest.c spawning user/sigchldchild.c (needle + * "sigchldtest: sigchld-delivered-to-parent-ok" in tools/ci-test.sh); what + * is deterministic here is the plumbing itself: threadCreate/ + * threadCreateUser attributing parentId to whoever is truly running at + * creation time, and threadKill (parameterized by an explicit id, unlike + * threadExit which only ever acts on the CURRENT thread and so cannot be + * driven this way with ktestRunAll's single, never-context-switched + * thread -- see this file's own header comment) actually raising sigChld + * against exactly that id when the id it names dies. + */ + +KTEST(threadCreateAttributesParentIdToWhicheverThreadIsActuallyCurrent) +{ + uint32 id = threadCreate("childOfCurrent", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadAt(id)->parentId, currentThreadId()); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadKillRaisesSigChldAgainstParentId) +{ + uint32 parent = threadCreate("chldParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("chldVictim", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + + /* ktestRunAll runs single-threaded (see file header) -- threadCreate + always attributes parentId to THIS KTEST runner itself, so the + "child was spawned by parent" relationship this test actually wants + is set up directly here, the same white-box technique + threadStopExcludesTargetFromUnrelatedWaitChannelWake below already + uses to reach a state the real call path would only reach through + an actual live sysSpawn. */ + threadAt(child)->parentId = parent; + + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull); + KT_TRUE(t, threadKill(child)); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld)); + + KT_TRUE(t, threadReap(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadKillEarlyWakesAParentGenuinelyBlockedWaitingForSigChld) +{ + int channel; + + uint32 parent = threadCreate("chldBlockedParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("chldVictim2", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + /* sigChld's real-world default disposition is Ignore (sigDefaultIsIgnore, + signal.c), which sigWouldDeliver (also signal.c) treats exactly like + an installed sigHandlerIgnore -- correctly declining to early-wake a + thread for a signal that would just be silently dismissed on + delivery anyway, real Unix's own "SIGCHLD does nothing without a + handler" behavior. A parent that actually wants this notification + (like user/sigchldtest.c) always installs a real handler first, so + this test must too, or threadWakeForSignal below would (correctly) + never fire and this would be testing the wrong thing. */ + sigSetHandler(threadAt(parent), sigChld, 0x40005000ull, 0, 0); + + /* Fabricate exactly what schedBlockCurrent would have left behind had + parent been the one calling it (see + threadStopExcludesTargetFromUnrelatedWaitChannelWake above for the + same convention) -- e.g. a real sigchldtest.c genuinely blocked in + userRead on a pipe with no writer (see user/sigchldtest.c) while + its own sigChld handler is installed. threadWakeForSignal's own + state-machine bookkeeping is already proven generically by + threadWakeForSignalMovesSleepingOrBlockedThreadToRunnable above (for + any signal); this proves specifically that threadKill's own new + sigRaise(parentId, sigChld) call actually reaches that same + early-wake path for THIS signal, not just the pending-bit half. */ + threadAt(parent)->state = threadBlocked; + threadAt(parent)->waitChannel = &channel; + + KT_TRUE(t, threadKill(child)); + KT_EQ(t, threadAt(parent)->state, threadRunnable); + + KT_TRUE(t, threadReap(child)); + /* Runnable and never actually scheduled (ktestRunAll runs + single-threaded, see file header) -- threadDiscard, not threadReap, + is the correct teardown here, same as every other fabricated-but- + never-run thread in this file. */ + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadKillAgainstAnOrphanWithADeadParentIdRaisesNothing) +{ + uint32 deadParent = threadCreate("deadParent", noopThreadEntry); + KT_NE(t, deadParent, threadInvalidId); + KT_TRUE(t, threadKill(deadParent)); + KT_EQ(t, threadAt(deadParent)->state, threadDead); + + uint32 orphan = threadCreate("orphan", noopThreadEntry); + KT_NE(t, orphan, threadInvalidId); + threadAt(orphan)->parentId = deadParent; + + /* sigRaise's own targetId-state guard (signal.c) is what makes this + safe, not anything special here -- an orphan whose spawning parent + is already gone simply has nowhere for sigChld to land, the same + way a real kernel would reparent it to init instead of crashing or + misdelivering into whatever unrelated thread later reuses + deadParent's slot; this kernel does not implement reparenting (one + shared address space, no true fork(), see doSpawn's own file + comment in syscall.c), so the signal is just silently dropped. */ + KT_TRUE(t, threadKill(orphan)); + KT_EQ(t, threadAt(orphan)->state, threadDead); + KT_EQU(t, threadAt(deadParent)->sigPending & (1ull << sigChld), 0ull); + + KT_TRUE(t, threadReap(deadParent)); + KT_TRUE(t, threadReap(orphan)); +} + +/* + * threadJoinAnyChild (sched.c) -- waitpid(-1)-style "wait for ANY child" + * behind sysWait's waitAnyPid case (syscall.c) / userWaitAny (ulib.c). Live + * proof that it genuinely blocks and is genuinely woken by an arbitrary + * child's death (not just a specifically-named one) is user/waitanytest.c + * (needle "waitanytest: any-child-reap-set-ok" in tools/ci-test.sh); what + * is deterministic here is its own non-blocking scan logic, the same shape + * as the parentId/sigChld block above. + */ + +KTEST(threadJoinAnyChildReturnsNoChildrenForAThreadWithNoneAtAll) +{ + uint32 parent = threadCreate("anyNoKids", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + + uint32 outId = 0; + int64 outCode = 0; + KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoChildren); + + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadJoinAnyChildReturnsNoneDeadWhileAllChildrenAreStillAlive) +{ + uint32 parent = threadCreate("anyLiveParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("anyLiveChild", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + uint32 outId = 0; + int64 outCode = 0; + KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoneDead); + /* Not reaped -- still exactly as threadCreate left it. */ + KT_EQ(t, threadAt(child)->state, threadRunnable); + + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadJoinAnyChildPicksTheLowestIndexedDeadChildAndCanBeCalledAgainForTheRest) +{ + uint32 parent = threadCreate("anyTwoKidsParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 childLow = threadCreate("anyChildLow", noopThreadEntry); + KT_NE(t, childLow, threadInvalidId); + uint32 childHigh = threadCreate("anyChildHigh", noopThreadEntry); + KT_NE(t, childHigh, threadInvalidId); + /* allocSlot (sched.c) always hands out the lowest currently-free slot + and nothing else has been created in between, so childHigh's own id + is guaranteed higher than childLow's -- the "lowest index wins" + assertion below actually depends on this. */ + KT_LT(t, childLow, childHigh); + threadAt(childLow)->parentId = parent; + threadAt(childHigh)->parentId = parent; + + /* Kill the HIGHER-indexed one first, on purpose: proves the scan below + picks the lowest matching slot, not whichever died most recently. */ + KT_TRUE(t, threadKill(childHigh)); + KT_TRUE(t, threadKill(childLow)); + + uint32 outId = 0xFFFFFFFFu; + int64 outCode = -1; + KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanFoundDead); + KT_EQU(t, outId, childLow); + KT_EQ(t, outCode, threadKilledExitCode); + /* childLow is fully reaped already -- its slot is threadUnused now. */ + KT_EQ(t, threadAt(childLow)->state, threadUnused); + /* childHigh is untouched by that call: still Dead, NOT yet reaped. */ + KT_EQ(t, threadAt(childHigh)->state, threadDead); + + outId = 0xFFFFFFFFu; + outCode = -1; + KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanFoundDead); + KT_EQU(t, outId, childHigh); + KT_EQ(t, outCode, threadKilledExitCode); + + KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoChildren); + + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadKillWakesAParentGenuinelyBlockedInWaitAnyOnAnyChildDeath) +{ + uint32 parent = threadCreate("anyBlockedParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("anyBlockedChild", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + /* Fabricate exactly what sysWait's waitAnyPid path leaves behind via + schedBlockCurrent(currentThread()) when parent itself is the one + calling it (see threadStopExcludesTargetFromUnrelatedWaitChannelWake + below for the same white-box convention) -- channel is the PARENT's + own slot address, not the child's, unlike + threadKillWakesAThreadParkedInJoin's channel above. */ + threadAt(parent)->state = threadBlocked; + threadAt(parent)->waitChannel = threadAt(parent); + + KT_TRUE(t, threadKill(child)); + KT_EQ(t, threadAt(parent)->state, threadRunnable); + + KT_TRUE(t, threadReap(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +/* + * threadStop/threadContinue (sched.c) -- job control (signal.h's SIGSTOP/ + * SIGCONT/SIGTSTP) on top of the new threadStopped state. The live proof + * that SIGSTOP genuinely stops a thread parked mid-syscall and SIGCONT + * genuinely resumes it through the exact restart-syscall path sysRead + * already uses is user/jobtest.c spawning user/jobtarget.c (needle + * "jobtest: job-stop-continue-ok" in tools/ci-test.sh); what is + * deterministic here is threadStop/threadContinue's own state-machine + * bookkeeping, mirroring threadKillRejects.../threadKillMarks... above. + */ + +KTEST(threadStopRejectsIdleOutOfRangeAndNeverCreatedSlots) +{ + KT_FALSE(t, threadStop(0)); + KT_FALSE(t, threadStop(threadCapacity())); + KT_FALSE(t, threadStop(threadCapacity() + 1000)); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadStop(neverUsed)); +} + +KTEST(threadStopMovesRunnableThreadToStoppedAndIsIdempotent) +{ + uint32 id = threadCreate("stopMe", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_EQ(t, threadAt(id)->state, threadRunnable); + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + /* Idempotent: stopping an already-Stopped thread still succeeds, + matching real kill(pid, SIGSTOP) sent a second time. */ + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + KT_TRUE(t, threadContinue(id)); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadStopAcceptsSleepingBlockedAndRunningStates) +{ + uint32 id = threadCreate("stopFromEveryState", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Fabricated states, the same way threadReapClosesOpenWritePipeFdSoReaderSeesEof + below pokes threadDead directly: this thread is never actually + scheduled in this pre-schedStart() harness, so its `state` field + stands in for "genuinely Sleeping/Blocked/Running" -- exactly the + states a real SIGSTOP target could be caught in. */ + threadAt(id)->state = threadSleeping; + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + KT_TRUE(t, threadContinue(id)); + + threadAt(id)->state = threadBlocked; + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + KT_TRUE(t, threadContinue(id)); + + /* Running -- the self-stop case (sysSigSend targeting the caller's own + pid, syscall.c): threadStop does not special-case "am I the one + calling this" the way threadKill implicitly does (Running is simply + absent from threadKill's accepted set); a thread can stop itself. */ + threadAt(id)->state = threadRunning; + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + KT_TRUE(t, threadContinue(id)); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadStopOnDeadOrUnusedSlotFails) +{ + uint32 id = threadCreate("stopDead", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + threadAt(id)->state = threadDead; + KT_FALSE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadDead); + KT_TRUE(t, threadReap(id)); + + /* threadUnused again after reap: still rejected. */ + KT_FALSE(t, threadStop(id)); +} + +KTEST(threadContinueRejectsOutOfRangeAndNonStoppedThreads) +{ + KT_FALSE(t, threadContinue(threadCapacity())); + KT_FALSE(t, threadContinue(threadCapacity() + 1000)); + + uint32 id = threadCreate("notStopped", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Runnable, never stopped: SIGCONT against a process that was never + stopped is a harmless no-op at this level (see signal.c's + sigRaise, which still delivers the signal itself regardless). */ + KT_FALSE(t, threadContinue(id)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadStopExcludesTargetFromUnrelatedWaitChannelWake) +{ + int channel; + + uint32 id = threadCreate("stoppedWhileWaiting", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Fabricate exactly what schedBlockCurrent would have left behind had + this thread been the one calling it (see + threadDiscardClosesOpenReadPipeFdSoWriterSeesBrokenPipe below for + the same fabricate-then-assert convention). */ + threadAt(id)->state = threadBlocked; + threadAt(id)->waitChannel = &channel; + + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + /* The defining difference from threadBlocked: schedWakeChannel must + NOT match a Stopped thread even though its waitChannel field still + names this exact channel -- only threadContinue (an explicit + SIGCONT) may move it back to threadRunnable, never "the resource it + was originally waiting on became available". */ + schedWakeChannel(&channel); + KT_EQ(t, threadAt(id)->state, threadStopped); + + KT_TRUE(t, threadContinue(id)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + + KT_TRUE(t, threadDiscard(id)); +} + +/* + * threadStop/threadContinue's own new SIGCHLD-on-stop/SIGCHLD-on-continue + * (sched.c, mirroring threadExit/threadKill's parentId/sigChld block + * above): real job control notifies the parent asynchronously the instant + * a child stops or resumes, not only when it eventually dies. The live + * ring-3 proof that this reaches a genuinely blocked-mid-syscall target + * and a real installed handler is user/stopchldtest.c spawning + * user/stopchldtarget.c (needle "stopchldtest: job-control-sigchld-ok" in + * tools/ci-test.sh); what is deterministic here is the same plumbing shape + * threadKillRaisesSigChldAgainstParentId/ + * threadKillEarlyWakesAParentGenuinelyBlockedWaitingForSigChld above + * already establish for the death case. + */ + +KTEST(threadStopRaisesSigChldAgainstParentIdOnTheActualStopTransition) +{ + uint32 parent = threadCreate("stopChldParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("stopChldVictim", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull); + KT_TRUE(t, threadStop(child)); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld)); + + KT_TRUE(t, threadContinue(child)); + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadStopDoesNotReRaiseSigChldAgainstAnAlreadyStoppedTarget) +{ + uint32 parent = threadCreate("stopChldParent2", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("stopChldVictim2", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + KT_TRUE(t, threadStop(child)); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld)); + + /* Simulate the parent's own handler already having consumed that one + notification (sigConsumePending, signal.c, clears the bit on + delivery) -- exactly the "next natural ring-3 return" a real + sigchldtest.c-style handler would have already run through before a + second, redundant sigStop against the same still-Stopped target + could possibly arrive. */ + threadAt(parent)->sigPending &= ~(1ull << sigChld); + + KT_TRUE(t, threadStop(child)); + KT_EQ(t, threadAt(child)->state, threadStopped); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull); + + KT_TRUE(t, threadContinue(child)); + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadStopEarlyWakesAParentGenuinelyBlockedWaitingForSigChldOnStop) +{ + int channel; + + uint32 parent = threadCreate("stopChldBlockedParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("stopChldVictim3", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + /* Same real-handler-required setup as + threadKillEarlyWakesAParentGenuinelyBlockedWaitingForSigChld above -- + sigChld's default disposition is Ignore, so sigWouldDeliver would + (correctly) decline to early-wake without one. */ + sigSetHandler(threadAt(parent), sigChld, 0x40005000ull, 0, 0); + + threadAt(parent)->state = threadBlocked; + threadAt(parent)->waitChannel = &channel; + + KT_TRUE(t, threadStop(child)); + KT_EQ(t, threadAt(parent)->state, threadRunnable); + + KT_TRUE(t, threadContinue(child)); + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadContinueRaisesSigChldAgainstParentIdOnTheActualResumeTransition) +{ + uint32 parent = threadCreate("contChldParent", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("contChldVictim", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + KT_TRUE(t, threadStop(child)); + /* Drain the stop's own notification first (see the "already stopped" + test above for why) so the assertion below is unambiguously about + threadContinue's OWN raise, not a leftover bit from the stop. */ + threadAt(parent)->sigPending &= ~(1ull << sigChld); + + KT_TRUE(t, threadContinue(child)); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld)); + + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadContinueOnATargetThatWasNeverStoppedRaisesNothing) +{ + uint32 parent = threadCreate("contChldParent2", noopThreadEntry); + KT_NE(t, parent, threadInvalidId); + uint32 child = threadCreate("contChldVictim2", noopThreadEntry); + KT_NE(t, child, threadInvalidId); + threadAt(child)->parentId = parent; + + KT_EQ(t, threadAt(child)->state, threadRunnable); + KT_FALSE(t, threadContinue(child)); + KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull); + + KT_TRUE(t, threadDiscard(child)); + KT_TRUE(t, threadDiscard(parent)); +} + +KTEST(threadStopAgainstAnOrphanWithADeadParentIdRaisesNothing) +{ + uint32 deadParent = threadCreate("stopDeadParent", noopThreadEntry); + KT_NE(t, deadParent, threadInvalidId); + KT_TRUE(t, threadKill(deadParent)); + KT_EQ(t, threadAt(deadParent)->state, threadDead); + + uint32 orphan = threadCreate("stopOrphan", noopThreadEntry); + KT_NE(t, orphan, threadInvalidId); + threadAt(orphan)->parentId = deadParent; + + /* Same sigRaise targetId-state guard as + threadKillAgainstAnOrphanWithADeadParentIdRaisesNothing above -- an + orphan whose spawning parent already died has nowhere for its own + stop notification to land either. */ + KT_TRUE(t, threadStop(orphan)); + KT_EQ(t, threadAt(orphan)->state, threadStopped); + KT_EQU(t, threadAt(deadParent)->sigPending & (1ull << sigChld), 0ull); + + KT_TRUE(t, threadContinue(orphan)); + KT_TRUE(t, threadReap(deadParent)); + KT_TRUE(t, threadDiscard(orphan)); +} + +KTEST(threadWakeForSignalRejectsIdleOutOfRangeAndNeverCreatedSlots) +{ + KT_FALSE(t, threadWakeForSignal(0)); + KT_FALSE(t, threadWakeForSignal(threadCapacity())); + KT_FALSE(t, threadWakeForSignal(threadCapacity() + 1000)); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadWakeForSignal(neverUsed)); +} + +KTEST(threadWakeForSignalMovesSleepingOrBlockedThreadToRunnable) +{ + uint32 id = threadCreate("wakeFromSleep", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + threadAt(id)->state = threadSleeping; + KT_TRUE(t, threadWakeForSignal(id)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + + threadAt(id)->state = threadBlocked; + KT_TRUE(t, threadWakeForSignal(id)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadWakeForSignalIsNoOpOnRunnableRunningStoppedDeadAndUnused) +{ + uint32 id = threadCreate("wakeNoOp", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Already Runnable/Running: nothing to force. */ + KT_EQ(t, threadAt(id)->state, threadRunnable); + KT_FALSE(t, threadWakeForSignal(id)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + + threadAt(id)->state = threadRunning; + KT_FALSE(t, threadWakeForSignal(id)); + KT_EQ(t, threadAt(id)->state, threadRunning); + + /* Stopped is the one deliberately EXCLUDED state -- an ordinary signal + must never undo job control's SIGSTOP; only threadContinue (an + explicit SIGCONT) may ever move a Stopped thread. This is what + signal.c's sigRaise relies on for a signal raised against a Stopped + target: sigWouldDeliver may say yes, but threadWakeForSignal still + refuses. */ + KT_TRUE(t, threadStop(id)); + KT_FALSE(t, threadWakeForSignal(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + KT_TRUE(t, threadContinue(id)); + + threadAt(id)->state = threadDead; + KT_FALSE(t, threadWakeForSignal(id)); + KT_TRUE(t, threadReap(id)); + + /* threadUnused again after reap: still rejected. */ + KT_FALSE(t, threadWakeForSignal(id)); +} + +KTEST(threadKillAcceptsStoppedThreadLikeRealSigkillOnAStoppedProcess) +{ + uint32 id = threadCreate("killStopped", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + KT_TRUE(t, threadKill(id)); + KT_EQ(t, threadAt(id)->state, threadDead); + KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode); + + KT_TRUE(t, threadReap(id)); +} + +/* + * threadReap/threadDiscard's own fd cleanup (sched.c's reclaimOpenFds) -- a + * thread that dies while still holding an open fdPipeRead/fdPipeWrite fd + * must give pipe.c's readers/writers count back exactly like an orderly + * vfsClose would, or the pipe's OTHER end (a second, independent process, + * e.g. fifomaster.c's children) would wait forever for an EOF/broken-pipe + * that can now never come -- the same shape of leak FALA 13's bug#2 found + * for console readers, just against pipe.c's own refcounts. These fabricate + * the fd the same way the threadCreateUser tests above fabricate a user + * slot: poke threadAt(id)->fds[] directly (bypassing vfsPipeAttach's own + * allocFd/currentThread(), since the thread under test is never actually + * running) with a real pipeCreateDetached handle, so the underlying pipe.c + * refcounts driving the assertions are the genuine article, not a mock. + */ + +KTEST(threadDiscardClosesOpenReadPipeFdSoWriterSeesBrokenPipe) +{ + int handle = pipeCreateDetached(); + KT_GE(t, handle, 0); + + uint32 id = threadCreate("pipeReadHolder", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Simulate this (never-run) thread having opened the read end via + vfsPipeAttach: a real reader attached to the handle, referenced only + from this thread's own fd table (index 3: 0-2 are always fdConsole, + see vfsFdInit). */ + pipeAddReader(handle); + threadAt(id)->fds[3].kind = fdPipeRead; + threadAt(id)->fds[3].pipeHandle = handle; + + /* A second, independent side attaches the write end -- tracked purely + by handle, with no fd of its own, exactly like pipeShareTest.c's + coverage of the same primitives. */ + pipeAddWriter(handle); + KT_FALSE(t, pipeWriteBroken(handle)); + + /* Runnable, never scheduled: threadDiscard's path (e.g. rolling back a + thread that never got to run its own vfsClose). */ + KT_TRUE(t, threadDiscard(id)); + + /* The only reader was this thread's fd -- once reclaimOpenFds has run, + the writer must see the pipe as genuinely, permanently broken, not + "try again" forever. */ + KT_TRUE(t, pipeWriteBroken(handle)); + KT_EQ(t, pipeWrite(handle, "x", 1), -1); + + pipeCloseWrite(handle); +} + +KTEST(threadReapClosesOpenWritePipeFdSoReaderSeesEof) +{ + int handle = pipeCreateDetached(); + KT_GE(t, handle, 0); + + uint32 id = threadCreate("pipeWriteHolder", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + pipeAddWriter(handle); + threadAt(id)->fds[3].kind = fdPipeWrite; + threadAt(id)->fds[3].pipeHandle = handle; + + pipeAddReader(handle); + char buffer[4]; + /* Ring empty, a writer is still attached: "try again", not EOF yet. */ + KT_EQ(t, pipeRead(handle, buffer, sizeof(buffer)), -1); + + /* No live scheduler to run threadExit through before schedStart() -- + poke the state directly, the same workaround + threadReapOnDeadUserThreadReclaimsItsMappedPages uses above for the + same reason. */ + threadAt(id)->state = threadDead; + KT_TRUE(t, threadReap(id)); + + /* The only writer was this thread's fd -- once reclaimOpenFds has run, + the reader must see real end-of-file (0), not "try again" (-1) + forever. */ + KT_EQ(t, pipeRead(handle, buffer, sizeof(buffer)), 0); + + pipeCloseRead(handle); +} + +/* threadSetAlarm (sched.c) always targets currentThread() -- there is no + target-id argument, exactly like real alarm(2) -- so these tests exercise + it exactly the way syscall.c's sysAlarm does, against whatever thread is + "current" in this pre-schedStart() harness (the permanent idle thread, + id 0), rather than a freshly created one the way most other tests here + fabricate their own target. That is safe: deliverPendingSignals (isr.c) + is a no-op for anything that is not a ring-3 return context (`(regs->cs & + 3) != 3`), so a pending sigAlrm bit left on the idle thread by an + in-progress deadline is never actually consumed, and schedule()'s own + alarm-expiry scan (see its file comment) clears alarmAt back to 0 the + first tick it notices the deadline has passed either way -- but each test + below still explicitly cancels (threadSetAlarm(0)) before returning, so + no stray deadline is left for a LATER test or real boot to trip over. */ +KTEST(threadSetAlarmReturnsZeroWithNoPriorAlarmAndRecordsAFutureDeadline) +{ + Thread* self = threadAt(currentThreadId()); + KT_EQU(t, self->alarmAt, 0ull); + + uint64 now = pitTicks(); + KT_EQU(t, threadSetAlarm(5), 0ull); + KT_TRUE(t, self->alarmAt > now); + KT_EQU(t, self->alarmAt, now + 5ull * pitFrequency()); + + threadSetAlarm(0); + KT_EQU(t, self->alarmAt, 0ull); +} + +KTEST(threadSetAlarmReplacingOrCancelingAPendingAlarmReturnsItsRemainingSecondsRoundedUp) +{ + Thread* self = threadAt(currentThreadId()); + + /* Replace a 10s alarm with a 3s one: the return value describes the + OLD (10s) deadline, not the new one, and the struct itself now holds + the new one -- matching real alarm()'s "at most one pending alarm, + setting a new one silently discards whatever was left of the old" + contract. No real time passes between these two calls (interrupts + are off, there is no scheduler to preempt this function), so the + ceiling in threadSetAlarm's own comment reports the full, exact 10, + not 9 or 11 -- a flaky "off by the current tick's remainder" result + here would itself be a bug in that rounding, not a timing accident. */ + KT_EQU(t, threadSetAlarm(10), 0ull); + KT_EQU(t, threadSetAlarm(3), 10ull); + + /* Canceling (0) reports the 3s alarm's remaining time and clears the + deadline back to "none pending" -- the other real alarm(0) case, + exercised separately from the plain replace case above. */ + KT_EQU(t, threadSetAlarm(0), 3ull); + KT_EQU(t, self->alarmAt, 0ull); + + /* Canceling an ALREADY-canceled (none pending) alarm reports 0, the + same "nothing was pending" value a fresh thread starts with. */ + KT_EQU(t, threadSetAlarm(0), 0ull); +} + +/* + * Thread.pgid (sched.h) -- process groups, the scope real job control and + * kill(2)/sigSend's pid<0 convention actually target (signal.c's + * sigRaiseGroup, sched.c's threadKillGroup, syscall.c's sysKill/sysSigSend, + * keyboard.c's real Ctrl-C/Ctrl-Z). ktestRunAll always runs as the + * permanent idle thread (id 0) -- exactly the one context initialPgid + * (sched.c) treats as "boot-time", per this file's own header comment -- + * so every plain threadCreate/threadCreateUser call here exercises the + * "becomes its own new group leader" default; the OTHER default (a + * runtime child inheriting a genuinely running, non-idle parent's pgid) + * can only ever be exercised by a real ring-3 userSpawn, which is why + * user/jobtest.c's own pgid check (spawning user/jobtarget.c at runtime) + * is this feature's live proof instead. + */ + +KTEST(threadCreateDefaultsPgidToItsOwnIdWhenCurrentIsTheIdleThread) +{ + uint32 id = threadCreate("pgidDefaultKernel", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadAt(id)->pgid, id); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadCreateUserDefaultsPgidToItsOwnIdWhenCurrentIsTheIdleThread) +{ + uint64 entry = 0x350000000ull + 0x2000; + uint32 id = threadCreateUser("pgidDefaultUser", entry, elfSlotStackTop(entry)); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadAt(id)->pgid, id); + KT_TRUE(t, threadDiscard(id)); +} + +/* + * Thread.uid (sched.h) -- the credential vfs.c's vfsPermitted consults + * alongside a file's own owner uid and mode (see coreTest.c's own + * vfsPermitted coverage for that pure-logic half). Same "ktestRunAll + * always runs as the idle thread" reasoning as the pgid tests immediately + * above applies here too: every plain threadCreate/threadCreateUser call + * exercises the "boot-time thread starts as uid 0" default; the OTHER + * default (a runtime child inheriting a genuinely running, non-root + * parent's uid) can only ever be exercised by a real ring-3 userSpawn, + * which is why user/identtest.c's own inheritance-then-drop demo is this + * feature's live proof instead. currentThreadSetUid's own privilege rule + * (root may become anyone; anyone else only their own current uid) is + * likewise never exercised here on purpose: it can only ever act on + * "whoever is currently running" (see its own doc comment), and mutating + * ktestRunAll's own idle-thread uid away from 0 would permanently corrupt + * every later test AND every boot-time spawnUser call still to come after + * ktestRunAll returns -- a real, one-way privilege drop is exactly the + * kind of state no shared test-suite context may ever touch. + */ + +KTEST(threadCreateDefaultsUidToRootWhenCurrentIsTheIdleThread) +{ + uint32 id = threadCreate("uidDefaultKernel", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadAt(id)->uid, 0u); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadCreateUserDefaultsUidToRootWhenCurrentIsTheIdleThread) +{ + uint64 entry = 0x350000000ull + 0x2000; + uint32 id = threadCreateUser("uidDefaultUser", entry, elfSlotStackTop(entry)); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadAt(id)->uid, 0u); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadPgidReturnsInvalidForOutOfRangeOrNeverCreatedSlots) +{ + KT_EQU(t, threadPgid(threadCapacity()), threadInvalidId); + KT_EQU(t, threadPgid(threadCapacity() + 1000), threadInvalidId); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_EQU(t, threadPgid(neverUsed), threadInvalidId); +} + +KTEST(threadPgidReturnsInvalidAfterDiscardOrReap) +{ + uint32 id = threadCreate("pgidGoneAfterDiscard", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadPgid(id), id); + + KT_TRUE(t, threadDiscard(id)); + KT_EQU(t, threadPgid(id), threadInvalidId); +} + +KTEST(threadSetPgidRejectsIdleOutOfRangeAndNeverCreatedSlots) +{ + KT_FALSE(t, threadSetPgid(0, 1)); + KT_FALSE(t, threadSetPgid(threadCapacity(), 1)); + KT_FALSE(t, threadSetPgid(threadCapacity() + 1000, 1)); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadSetPgid(neverUsed, 1)); +} + +KTEST(threadSetPgidChangesGroupOfALiveThread) +{ + uint32 leader = threadCreate("pgidLeader", noopThreadEntry); + uint32 joiner = threadCreate("pgidJoiner", noopThreadEntry); + KT_NE(t, leader, threadInvalidId); + KT_NE(t, joiner, threadInvalidId); + + /* Each started as its own group leader (the KTEST-runs-as-idle default + above) -- explicitly move joiner into leader's group, exactly like a + real setpgid(joinerPid, leaderPid) call. */ + KT_NE(t, threadAt(joiner)->pgid, threadAt(leader)->pgid); + KT_TRUE(t, threadSetPgid(joiner, threadAt(leader)->pgid)); + KT_EQU(t, threadAt(joiner)->pgid, threadAt(leader)->pgid); + + KT_TRUE(t, threadDiscard(leader)); + KT_TRUE(t, threadDiscard(joiner)); +} + +KTEST(threadSetPgidRejectsDeadOrUnusedTarget) +{ + uint32 id = threadCreate("pgidSetOnDead", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadKill(id)); + KT_FALSE(t, threadSetPgid(id, 12345)); + + KT_TRUE(t, threadReap(id)); + KT_FALSE(t, threadSetPgid(id, 12345)); +} + +KTEST(threadPriorityDefaultsToSchedDefaultWeightForANewThread) +{ + uint32 id = threadCreate("priorityDefault", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadPriority(id), schedDefaultWeight); + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadPriorityReturnsZeroForOutOfRangeOrNeverCreatedSlots) +{ + KT_EQU(t, threadPriority(threadCapacity()), 0u); + KT_EQU(t, threadPriority(threadCapacity() + 1000), 0u); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_EQU(t, threadPriority(neverUsed), 0u); +} + +KTEST(threadPriorityReturnsZeroAfterDiscard) +{ + uint32 id = threadCreate("priorityGoneAfterDiscard", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_EQU(t, threadPriority(id), schedDefaultWeight); + + KT_TRUE(t, threadDiscard(id)); + KT_EQU(t, threadPriority(id), 0u); +} + +KTEST(threadSetPriorityRejectsIdleOutOfRangeZeroWeightAndNeverCreatedSlots) +{ + KT_FALSE(t, threadSetPriority(0, 2048)); + KT_FALSE(t, threadSetPriority(threadCapacity(), 2048)); + KT_FALSE(t, threadSetPriority(threadCapacity() + 1000, 2048)); + + uint32 id = threadCreate("priorityRejectZero", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + KT_FALSE(t, threadSetPriority(id, 0)); + KT_EQU(t, threadPriority(id), schedDefaultWeight); + KT_TRUE(t, threadDiscard(id)); + + uint32 neverUsed = threadCount(); + if (neverUsed < threadCapacity()) + KT_FALSE(t, threadSetPriority(neverUsed, 2048)); +} + +KTEST(threadSetPriorityChangesWeightOfALiveThread) +{ + uint32 id = threadCreate("priorityChange", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadSetPriority(id, 4096)); + KT_EQU(t, threadPriority(id), 4096u); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(threadSetPriorityRejectsDeadOrUnusedTarget) +{ + uint32 id = threadCreate("prioritySetOnDead", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadKill(id)); + KT_FALSE(t, threadSetPriority(id, 4096)); + + KT_TRUE(t, threadReap(id)); + KT_FALSE(t, threadSetPriority(id, 4096)); +} + +KTEST(threadKillGroupKillsEveryLiveMemberAndLeavesOtherGroupsAlone) +{ + uint32 a = threadCreate("groupKillA", noopThreadEntry); + uint32 b = threadCreate("groupKillB", noopThreadEntry); + uint32 outsider = threadCreate("groupKillOutsider", noopThreadEntry); + KT_NE(t, a, threadInvalidId); + KT_NE(t, b, threadInvalidId); + KT_NE(t, outsider, threadInvalidId); + + uint32 pgid = threadAt(a)->pgid; + KT_TRUE(t, threadSetPgid(b, pgid)); + KT_NE(t, threadAt(outsider)->pgid, pgid); + + KT_TRUE(t, threadKillGroup(pgid)); + KT_EQ(t, threadAt(a)->state, threadDead); + KT_EQ(t, threadAt(b)->state, threadDead); + /* A completely different group must be untouched -- the exact scoping + property keyboard.c's real Ctrl-C/Ctrl-Z depends on (user/pgroupfg.c/ + user/pgroupbg.c is the live ring-3 proof for sigRaiseGroup's own + version of this same scoping, signal.c). */ + KT_EQ(t, threadAt(outsider)->state, threadRunnable); + + KT_TRUE(t, threadReap(a)); + KT_TRUE(t, threadReap(b)); + KT_TRUE(t, threadDiscard(outsider)); +} + +KTEST(threadKillGroupReturnsFalseWhenNoLiveThreadHasThatPgid) +{ + /* No thread created anywhere in this whole KTEST run ever uses this + exact value as its pgid -- ids never reach anywhere near it (see + threadCapacity/maxThreads), so it can never collide with a real + group by coincidence. */ + KT_FALSE(t, threadKillGroup(0xBEEF0000u)); +} + +KTEST(threadKillGroupSkipsAlreadyDeadMembersButStillReportsTheGroupAsFound) +{ + uint32 a = threadCreate("groupKillDeadA", noopThreadEntry); + uint32 b = threadCreate("groupKillDeadB", noopThreadEntry); + KT_NE(t, a, threadInvalidId); + KT_NE(t, b, threadInvalidId); + + uint32 pgid = threadAt(a)->pgid; + KT_TRUE(t, threadSetPgid(b, pgid)); + + /* b is killed FIRST, independently -- threadKillGroup must still find + and kill a (already-Dead members are simply skipped, exactly like + sigRaiseGroup below skips them via sigRaiseInfo's own Dead/Unused + guard), and must still report true since a real target existed. */ + KT_TRUE(t, threadKill(b)); + KT_TRUE(t, threadKillGroup(pgid)); + KT_EQ(t, threadAt(a)->state, threadDead); + + KT_TRUE(t, threadReap(a)); + KT_TRUE(t, threadReap(b)); +} + +/* currentThreadGetEnv/currentThreadSetEnv (sched.h's new Thread.env) always + act on whoever is "current" -- at KTEST time that is the idle thread + (id 0, same "always-present current thread" reasoning this file's own + header comment already gives for schedBlockCurrent above), so every test + below resets threadAt(0)->envCount to 0 first: idle's own table is pure + scratch space for these tests (initialEnv gives every REAL boot-time + spawn an empty table regardless of whatever idle's own happens to hold, + see initialEnv's own "currentIndex == 0" branch, sched.c), but leaving + it dirty between tests would let an earlier test's entries leak into a + later one's count/index assumptions. */ +KTEST(currentThreadSetEnvThenGetEnvRoundTrips) +{ + threadAt(0)->envCount = 0; + + KT_TRUE(t, currentThreadSetEnv("FOO", "bar")); + KT_STREQ(t, currentThreadGetEnv("FOO"), "bar"); + KT_NULL(t, currentThreadGetEnv("MISSING")); +} + +KTEST(currentThreadSetEnvOverwritesExistingNameInPlace) +{ + threadAt(0)->envCount = 0; + + KT_TRUE(t, currentThreadSetEnv("FOO", "bar")); + KT_TRUE(t, currentThreadSetEnv("FOO", "baz")); + KT_STREQ(t, currentThreadGetEnv("FOO"), "baz"); + /* An overwrite must never consume a second slot. */ + KT_EQ(t, currentThreadEnvCount(), 1); +} + +KTEST(currentThreadSetEnvRejectsEntryTooLongForStorage) +{ + threadAt(0)->envCount = 0; + + /* envEntryMax (sched.h) bounds "NAME=VALUE\0" together -- a value long + enough to blow that budget must be rejected outright, leaving no + partially-written entry behind. */ + char hugeValue[envEntryMax]; + for (int i = 0; i < envEntryMax - 1; i++) + hugeValue[i] = 'x'; + hugeValue[envEntryMax - 1] = 0; + + KT_FALSE(t, currentThreadSetEnv("FOO", hugeValue)); + KT_NULL(t, currentThreadGetEnv("FOO")); + KT_EQ(t, currentThreadEnvCount(), 0); +} + +KTEST(currentThreadSetEnvRejectsNewNameOnceTableIsFull) +{ + threadAt(0)->envCount = 0; + + char name[8]; + for (int i = 0; i < envMaxVars; i++) { + snprintf(name, sizeof(name), "V%d", i); + KT_TRUE(t, currentThreadSetEnv(name, "x")); + } + KT_EQ(t, currentThreadEnvCount(), envMaxVars); + + KT_FALSE(t, currentThreadSetEnv("ONE_TOO_MANY", "x")); + KT_EQ(t, currentThreadEnvCount(), envMaxVars); + + /* Overwriting an EXISTING name must still work even while the table is + completely full -- it takes the already-occupied slot, never a new + one. */ + KT_TRUE(t, currentThreadSetEnv("V0", "changed")); + KT_STREQ(t, currentThreadGetEnv("V0"), "changed"); + KT_EQ(t, currentThreadEnvCount(), envMaxVars); + + threadAt(0)->envCount = 0; +} + +KTEST(currentThreadEnvEntryExposesRawStorageForListing) +{ + threadAt(0)->envCount = 0; + + KT_TRUE(t, currentThreadSetEnv("A", "1")); + KT_TRUE(t, currentThreadSetEnv("B", "2")); + KT_EQ(t, currentThreadEnvCount(), 2); + KT_STREQ(t, currentThreadEnvEntry(0), "A=1"); + KT_STREQ(t, currentThreadEnvEntry(1), "B=2"); + KT_NULL(t, currentThreadEnvEntry(2)); + KT_NULL(t, currentThreadEnvEntry(-1)); + + threadAt(0)->envCount = 0; +} + +/* currentThreadUnsetEnv (sched.h) -- real unsetenv(3): a no-op success on a + missing name, a real compacting removal on a present one (never just a + tombstone, since currentThreadEnvEntry's own index space must stay dense + for shell.c's `env` listing to keep working after a removal in the + middle). */ +KTEST(currentThreadUnsetEnvOnMissingNameIsNoOpSuccess) +{ + threadAt(0)->envCount = 0; + + KT_TRUE(t, currentThreadSetEnv("FOO", "bar")); + KT_FALSE(t, currentThreadUnsetEnv("MISSING")); + KT_EQ(t, currentThreadEnvCount(), 1); + KT_STREQ(t, currentThreadGetEnv("FOO"), "bar"); + + threadAt(0)->envCount = 0; +} + +KTEST(currentThreadUnsetEnvRemovesLastEntry) +{ + threadAt(0)->envCount = 0; + + KT_TRUE(t, currentThreadSetEnv("A", "1")); + KT_TRUE(t, currentThreadSetEnv("B", "2")); + KT_TRUE(t, currentThreadUnsetEnv("B")); + KT_EQ(t, currentThreadEnvCount(), 1); + KT_STREQ(t, currentThreadGetEnv("A"), "1"); + KT_NULL(t, currentThreadGetEnv("B")); + + threadAt(0)->envCount = 0; +} + +KTEST(currentThreadUnsetEnvCompactsMiddleEntryDown) +{ + threadAt(0)->envCount = 0; + + /* Removing the MIDDLE of three entries must shift every LATER entry + down by one slot (memmove, not just marking a hole) -- otherwise + currentThreadEnvEntry(1) after this removal would either still show + the removed "B=2" or skip straight to garbage past envCount. */ + KT_TRUE(t, currentThreadSetEnv("A", "1")); + KT_TRUE(t, currentThreadSetEnv("B", "2")); + KT_TRUE(t, currentThreadSetEnv("C", "3")); + KT_TRUE(t, currentThreadUnsetEnv("B")); + + KT_EQ(t, currentThreadEnvCount(), 2); + KT_STREQ(t, currentThreadEnvEntry(0), "A=1"); + KT_STREQ(t, currentThreadEnvEntry(1), "C=3"); + KT_NULL(t, currentThreadEnvEntry(2)); + KT_NULL(t, currentThreadGetEnv("B")); + KT_STREQ(t, currentThreadGetEnv("A"), "1"); + KT_STREQ(t, currentThreadGetEnv("C"), "3"); + + threadAt(0)->envCount = 0; +} + +KTEST(currentThreadUnsetEnvActuallyFreesSlotForReuse) +{ + threadAt(0)->envCount = 0; + + /* Fill the table completely, remove one, then prove a genuinely NEW + name can now be set -- if unset only tombstoned the slot instead of + compacting envCount back down, this set would still see a "full" + table and fail. */ + for (int i = 0; i < envMaxVars; i++) { + char name[8]; + snprintf(name, sizeof(name), "V%d", i); + KT_TRUE(t, currentThreadSetEnv(name, "x")); + } + KT_EQ(t, currentThreadEnvCount(), envMaxVars); + + KT_TRUE(t, currentThreadUnsetEnv("V0")); + KT_EQ(t, currentThreadEnvCount(), envMaxVars - 1); + + KT_TRUE(t, currentThreadSetEnv("FRESH", "new")); + KT_EQ(t, currentThreadEnvCount(), envMaxVars); + KT_STREQ(t, currentThreadGetEnv("FRESH"), "new"); + KT_NULL(t, currentThreadGetEnv("V0")); + + threadAt(0)->envCount = 0; +} diff --git a/src/sfs.c b/src/sfs.c new file mode 100644 index 0000000..6c5d6f2 --- /dev/null +++ b/src/sfs.c @@ -0,0 +1,1426 @@ +#include "sfs.h" +#include "ata.h" +#include "heap.h" +#include "string.h" +#include "rtc.h" +#include "fs.h" +#include "sched.h" +#include "vfs.h" + +#define sfsDirectBlocks 10 +#define sfsPtrsPerBlock (sfsBlockSize / sizeof(uint32)) /* 128 */ + +/* 88 bytes: 512/88 = 5 inodes per block (72 bytes/block left unused -- + inodeLocation and the format-time inodeTableBlocks math already derive + everything from sizeof(SfsInode) generically, so this shrinking density + is the honest cost of the extra fields, not a layout hack, same story as + 84 bytes/6-per-block was before gid). Addressing is direct (10 blocks) + + single indirect (128 blocks) + double indirect (128 * 128 blocks) + + triple indirect (128 * 128 * 128 blocks): a max file size of roughly + (10 + 128 + 128*128 + 128*128*128) * 512 bytes ~= 1032 MiB, versus ~8 MiB + with direct/single/double alone. mtime/ctime (rtc.h's rtcNow(), seconds + since the Unix epoch, truncated to 32 bits -- valid through year 2106, + same honest scope as rtc.c's own 2000-2099 century limit), mode (fs.h's + fsModeOwner*, real chmod(2) bits), uid (fs.h's FbStat.uid, sched.h's + currentThreadUid() at creation) and now gid (fs.h's FbStat.gid, sched.h's + currentThreadGid() at creation) are the newest fields, genuinely growing + the struct like tripleIndirect did before them -- there is no padding + left to repurpose. */ +typedef struct SfsInode { + uint32 type; + uint32 size; + uint32 linkCount; + uint32 blockCount; + uint32 direct[sfsDirectBlocks]; + uint32 indirect; + uint32 doubleIndirect; + uint32 tripleIndirect; + uint32 mtime; + uint32 ctime; + uint32 mode; + uint32 uid; + uint32 gid; +} SfsInode; + +/* Exactly 64 bytes: 512/64 = 8 directory entries per block. name[56] leaves + room for sfsNameMax (55) chars plus the null terminator. */ +typedef struct SfsDirEntry { + uint32 inode; + uint32 type; + char name[56]; +} SfsDirEntry; + +typedef struct SfsSuperblock { + uint32 magic; + uint32 blockSize; + uint32 totalBlocks; + uint32 inodeCount; + uint32 inodeBitmapStart; + uint32 inodeBitmapBlocks; + uint32 blockBitmapStart; + uint32 blockBitmapBlocks; + uint32 inodeTableStart; + uint32 inodeTableBlocks; + uint32 dataStart; + uint32 rootInode; +} SfsSuperblock; + +#define sfsMagic 0x53465331u /* "SFS1" */ + +static SfsSuperblock sb; +static uint8* inodeBitmap = nullptr; +static uint8* blockBitmap = nullptr; +static bool isMounted = false; + +/* See sfsGeneration()'s doc comment (sfs.h). Starts at 1 (not 0) so a caller + that zero-initializes its own cache can use generation 0 to mean "never + bound to anything yet" without that ever colliding with a real one. */ +static uint64 formatGeneration = 1; + +/* --- internal helpers (forward-declared so definition order can follow the + dependency graph without header noise) --- */ +static bool bitTest(const uint8* map, uint32 bit); +static void bitSet(uint8* map, uint32 bit); +static void bitClear(uint8* map, uint32 bit); +static bool blockRead(uint32 blockAbs, void* buffer); +static bool blockWrite(uint32 blockAbs, const void* buffer); +static bool flushInodeBitmap(void); +static bool flushBlockBitmap(void); +static bool flushSuperblock(void); +static void inodeLocation(int32 inodeNum, uint32* blockAbs, uint32* index); +static bool readInode(int32 inodeNum, SfsInode* out); +static bool writeInodeRaw(int32 inodeNum, const SfsInode* in); +static int32 allocInodeNum(void); +static void freeInodeNum(int32 inodeNum); +static int32 allocDataBlockAbs(void); +static void freeDataBlockAbs(uint32 blockAbs); +static bool loadOrAllocPointerBlock(SfsInode* inode, uint32* holderAbs, uint8* buf); +static bool getOrAllocBlockForWrite(SfsInode* inode, uint32 logicalIndex, uint32* outAbs); +static bool getBlockForRead(const SfsInode* inode, uint32 logicalIndex, uint32* outAbs); +static bool sfsWriteRaw(SfsInode* inode, int32 inodeNum, uint64 position, const void* data, uint64 length); +static bool sfsTruncateInode(SfsInode* inode, int32 inodeNum, uint32 newSize); +static bool splitPath(const char* path, char* parentOut, uint64 parentCap, char* leafOut, uint64 leafCap); +static int32 dirFind(int32 dirInode, const char* name, uint64 nameLen); +static bool dirAdd(int32 dirInode, const char* name, uint64 nameLen, int32 childInode, uint32 type); +static bool dirRemove(int32 dirInode, const char* name, uint64 nameLen); +static int32 createNode(const char* path, uint32 type); +static bool readSymlinkTarget(int32 inodeNum, char* buf, uint64 cap); +static int32 lookupFrom(const char* path, bool followFinal, int depth); + +static bool bitTest(const uint8* map, uint32 bit) +{ + return (map[bit / 8] >> (bit % 8)) & 1u; +} + +static void bitSet(uint8* map, uint32 bit) +{ + map[bit / 8] = (uint8)(map[bit / 8] | (uint8)(1u << (bit % 8))); +} + +static void bitClear(uint8* map, uint32 bit) +{ + map[bit / 8] = (uint8)(map[bit / 8] & (uint8)~(1u << (bit % 8))); +} + +static bool blockRead(uint32 blockAbs, void* buffer) +{ + return ataReadSectors(sfsBaseLba + blockAbs, 1, buffer); +} + +static bool blockWrite(uint32 blockAbs, const void* buffer) +{ + return ataWriteSectors(sfsBaseLba + blockAbs, 1, buffer); +} + +static bool flushInodeBitmap(void) +{ + for (uint32 i = 0; i < sb.inodeBitmapBlocks; i++) + if (!blockWrite(sb.inodeBitmapStart + i, inodeBitmap + (uint64)i * sfsBlockSize)) + return false; + return true; +} + +static bool flushBlockBitmap(void) +{ + for (uint32 i = 0; i < sb.blockBitmapBlocks; i++) + if (!blockWrite(sb.blockBitmapStart + i, blockBitmap + (uint64)i * sfsBlockSize)) + return false; + return true; +} + +static bool flushSuperblock(void) +{ + uint8 buf[sfsBlockSize]; + memset(buf, 0, sizeof(buf)); + memcpy(buf, &sb, sizeof(sb)); + return blockWrite(0, buf); +} + +static void inodeLocation(int32 inodeNum, uint32* blockAbs, uint32* index) +{ + uint32 perBlock = sfsBlockSize / sizeof(SfsInode); + *blockAbs = sb.inodeTableStart + (uint32)inodeNum / perBlock; + *index = (uint32)inodeNum % perBlock; +} + +/* Shared bound check for readInode/writeInodeRaw below: every REAL inode + number allocInodeNum ever hands out satisfies 1 <= n < sb.inodeCount (the + loop above never even considers slot 0, reserved as an eternal sentinel). + Every one of this file's own internal callers already passes a value it + got from allocInodeNum/sfsLookup/dirFind, so this never rejects a + legitimate call -- it exists to stop an out-of-range inodeNum (a caller + bug, or a deliberately-invalid probe like sfsModeOf(9999)) from silently + computing a block address past the real inode table and reading/writing + whatever data block happens to live there instead of failing cleanly. */ +static bool inodeNumInRange(int32 inodeNum) +{ + return inodeNum > 0 && (uint32)inodeNum < sb.inodeCount; +} + +static bool readInode(int32 inodeNum, SfsInode* out) +{ + if (!inodeNumInRange(inodeNum)) + return false; + uint32 blk, idx; + inodeLocation(inodeNum, &blk, &idx); + uint8 buf[sfsBlockSize]; + if (!blockRead(blk, buf)) + return false; + memcpy(out, buf + (uint64)idx * sizeof(SfsInode), sizeof(SfsInode)); + return true; +} + +static bool writeInodeRaw(int32 inodeNum, const SfsInode* in) +{ + if (!inodeNumInRange(inodeNum)) + return false; + uint32 blk, idx; + inodeLocation(inodeNum, &blk, &idx); + uint8 buf[sfsBlockSize]; + if (!blockRead(blk, buf)) + return false; + memcpy(buf + (uint64)idx * sizeof(SfsInode), in, sizeof(SfsInode)); + return blockWrite(blk, buf); +} + +static int32 allocInodeNum(void) +{ + for (uint32 i = 1; i < sb.inodeCount; i++) { + if (!bitTest(inodeBitmap, i)) { + bitSet(inodeBitmap, i); + flushInodeBitmap(); + return (int32)i; + } + } + return -1; +} + +static void freeInodeNum(int32 inodeNum) +{ + bitClear(inodeBitmap, (uint32)inodeNum); + flushInodeBitmap(); +} + +static int32 allocDataBlockAbs(void) +{ + uint32 dataBlockCount = sb.totalBlocks - sb.dataStart; + for (uint32 i = 0; i < dataBlockCount; i++) { + if (!bitTest(blockBitmap, i)) { + bitSet(blockBitmap, i); + flushBlockBitmap(); + uint32 abs = sb.dataStart + i; + uint8 zero[sfsBlockSize]; + memset(zero, 0, sizeof(zero)); + blockWrite(abs, zero); + return (int32)abs; + } + } + return -1; +} + +static void freeDataBlockAbs(uint32 blockAbs) +{ + bitClear(blockBitmap, blockAbs - sb.dataStart); + flushBlockBitmap(); +} + +/* Allocates (if needed) blockAbs's holderAbs on first use and returns the + holder block's contents in buf, zeroed for a fresh allocation. Shared by + the single-indirect and each level of the double-indirect path below, so + the "allocate a pointer block, zero it, write it back" dance is written + once. *holderAbs is updated (and inode->blockCount bumped) when a new + block is allocated. */ +static bool loadOrAllocPointerBlock(SfsInode* inode, uint32* holderAbs, uint8* buf) +{ + if (*holderAbs == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + *holderAbs = (uint32)nb; + inode->blockCount++; + memset(buf, 0, sfsBlockSize); + return blockWrite(*holderAbs, buf); + } + return blockRead(*holderAbs, buf); +} + +static bool getOrAllocBlockForWrite(SfsInode* inode, uint32 logicalIndex, uint32* outAbs) +{ + if (logicalIndex < sfsDirectBlocks) { + if (inode->direct[logicalIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + inode->direct[logicalIndex] = (uint32)nb; + inode->blockCount++; + } + *outAbs = inode->direct[logicalIndex]; + return true; + } + + uint32 indIndex = logicalIndex - sfsDirectBlocks; + if (indIndex < sfsPtrsPerBlock) { + uint8 indBuf[sfsBlockSize]; + if (!loadOrAllocPointerBlock(inode, &inode->indirect, indBuf)) + return false; + + uint32* ptrs = (uint32*)indBuf; + if (ptrs[indIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + ptrs[indIndex] = (uint32)nb; + inode->blockCount++; + if (!blockWrite(inode->indirect, indBuf)) + return false; + } + *outAbs = ptrs[indIndex]; + return true; + } + + uint32 doubleIndex = indIndex - sfsPtrsPerBlock; + if (doubleIndex < sfsPtrsPerBlock * sfsPtrsPerBlock) { + uint32 outerIndex = doubleIndex / sfsPtrsPerBlock; + uint32 innerIndex = doubleIndex % sfsPtrsPerBlock; + + uint8 outerBuf[sfsBlockSize]; + if (!loadOrAllocPointerBlock(inode, &inode->doubleIndirect, outerBuf)) + return false; + + uint32* outerPtrs = (uint32*)outerBuf; + uint8 innerBuf[sfsBlockSize]; + bool outerNeedsFlush = false; + if (outerPtrs[outerIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + outerPtrs[outerIndex] = (uint32)nb; + inode->blockCount++; + outerNeedsFlush = true; + memset(innerBuf, 0, sizeof(innerBuf)); + if (!blockWrite(outerPtrs[outerIndex], innerBuf)) + return false; + } else if (!blockRead(outerPtrs[outerIndex], innerBuf)) { + return false; + } + if (outerNeedsFlush && !blockWrite(inode->doubleIndirect, outerBuf)) + return false; + + uint32* innerPtrs = (uint32*)innerBuf; + if (innerPtrs[innerIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + innerPtrs[innerIndex] = (uint32)nb; + inode->blockCount++; + if (!blockWrite(outerPtrs[outerIndex], innerBuf)) + return false; + } + *outAbs = innerPtrs[innerIndex]; + return true; + } + + /* Same three-level shape as the double-indirect block above, one level + deeper: tripleIndirect -> top (128 mid-block pointers) -> mid (128 + leaf-block pointers) -> leaf (128 data-block pointers) -> data. */ + uint32 tripleIndex = doubleIndex - sfsPtrsPerBlock * sfsPtrsPerBlock; + uint32 topIndex = tripleIndex / (sfsPtrsPerBlock * sfsPtrsPerBlock); + uint32 midIndex = (tripleIndex / sfsPtrsPerBlock) % sfsPtrsPerBlock; + uint32 leafIndex = tripleIndex % sfsPtrsPerBlock; + if (topIndex >= sfsPtrsPerBlock) + return false; /* past the addressing limit (~1 GiB) */ + + uint8 topBuf[sfsBlockSize]; + if (!loadOrAllocPointerBlock(inode, &inode->tripleIndirect, topBuf)) + return false; + + uint32* topPtrs = (uint32*)topBuf; + uint8 midBuf[sfsBlockSize]; + bool topNeedsFlush = false; + if (topPtrs[topIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + topPtrs[topIndex] = (uint32)nb; + inode->blockCount++; + topNeedsFlush = true; + memset(midBuf, 0, sizeof(midBuf)); + if (!blockWrite(topPtrs[topIndex], midBuf)) + return false; + } else if (!blockRead(topPtrs[topIndex], midBuf)) { + return false; + } + if (topNeedsFlush && !blockWrite(inode->tripleIndirect, topBuf)) + return false; + + uint32* midPtrs = (uint32*)midBuf; + uint8 leafBuf[sfsBlockSize]; + bool midNeedsFlush = false; + if (midPtrs[midIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + midPtrs[midIndex] = (uint32)nb; + inode->blockCount++; + midNeedsFlush = true; + memset(leafBuf, 0, sizeof(leafBuf)); + if (!blockWrite(midPtrs[midIndex], leafBuf)) + return false; + } else if (!blockRead(midPtrs[midIndex], leafBuf)) { + return false; + } + if (midNeedsFlush && !blockWrite(topPtrs[topIndex], midBuf)) + return false; + + uint32* leafPtrs = (uint32*)leafBuf; + if (leafPtrs[leafIndex] == 0) { + int32 nb = allocDataBlockAbs(); + if (nb < 0) + return false; + leafPtrs[leafIndex] = (uint32)nb; + inode->blockCount++; + if (!blockWrite(midPtrs[midIndex], leafBuf)) + return false; + } + *outAbs = leafPtrs[leafIndex]; + return true; +} + +static bool getBlockForRead(const SfsInode* inode, uint32 logicalIndex, uint32* outAbs) +{ + if (logicalIndex < sfsDirectBlocks) { + *outAbs = inode->direct[logicalIndex]; + return *outAbs != 0; + } + + uint32 indIndex = logicalIndex - sfsDirectBlocks; + if (indIndex < sfsPtrsPerBlock) { + if (inode->indirect == 0) + return false; + uint8 indBuf[sfsBlockSize]; + if (!blockRead(inode->indirect, indBuf)) + return false; + uint32* ptrs = (uint32*)indBuf; + *outAbs = ptrs[indIndex]; + return *outAbs != 0; + } + + uint32 doubleIndex = indIndex - sfsPtrsPerBlock; + if (doubleIndex < sfsPtrsPerBlock * sfsPtrsPerBlock) { + uint32 outerIndex = doubleIndex / sfsPtrsPerBlock; + uint32 innerIndex = doubleIndex % sfsPtrsPerBlock; + if (inode->doubleIndirect == 0) + return false; + + uint8 outerBuf[sfsBlockSize]; + if (!blockRead(inode->doubleIndirect, outerBuf)) + return false; + uint32* outerPtrs = (uint32*)outerBuf; + if (outerPtrs[outerIndex] == 0) + return false; + + uint8 innerBuf[sfsBlockSize]; + if (!blockRead(outerPtrs[outerIndex], innerBuf)) + return false; + uint32* innerPtrs = (uint32*)innerBuf; + *outAbs = innerPtrs[innerIndex]; + return *outAbs != 0; + } + + uint32 tripleIndex = doubleIndex - sfsPtrsPerBlock * sfsPtrsPerBlock; + uint32 topIndex = tripleIndex / (sfsPtrsPerBlock * sfsPtrsPerBlock); + uint32 midIndex = (tripleIndex / sfsPtrsPerBlock) % sfsPtrsPerBlock; + uint32 leafIndex = tripleIndex % sfsPtrsPerBlock; + if (topIndex >= sfsPtrsPerBlock || inode->tripleIndirect == 0) + return false; + + uint8 topBuf[sfsBlockSize]; + if (!blockRead(inode->tripleIndirect, topBuf)) + return false; + uint32* topPtrs = (uint32*)topBuf; + if (topPtrs[topIndex] == 0) + return false; + + uint8 midBuf[sfsBlockSize]; + if (!blockRead(topPtrs[topIndex], midBuf)) + return false; + uint32* midPtrs = (uint32*)midBuf; + if (midPtrs[midIndex] == 0) + return false; + + uint8 leafBuf[sfsBlockSize]; + if (!blockRead(midPtrs[midIndex], leafBuf)) + return false; + uint32* leafPtrs = (uint32*)leafBuf; + *outAbs = leafPtrs[leafIndex]; + return *outAbs != 0; +} + +static bool sfsWriteRaw(SfsInode* inode, int32 inodeNum, uint64 position, const void* data, uint64 length) +{ + if (length == 0) + return true; + + const uint8* src = (const uint8*)data; + uint64 done = 0; + while (done < length) { + uint64 pos = position + done; + uint32 logicalBlock = (uint32)(pos / sfsBlockSize); + uint32 within = (uint32)(pos % sfsBlockSize); + uint32 chunk = sfsBlockSize - within; + if ((uint64)chunk > length - done) + chunk = (uint32)(length - done); + + uint32 blockAbs; + if (!getOrAllocBlockForWrite(inode, logicalBlock, &blockAbs)) + return false; + + uint8 blockBuf[sfsBlockSize]; + if (chunk == sfsBlockSize) { + if (src) + memcpy(blockBuf, src + done, sfsBlockSize); + else + memset(blockBuf, 0, sfsBlockSize); + } else { + if (!blockRead(blockAbs, blockBuf)) + return false; + if (src) + memcpy(blockBuf + within, src + done, chunk); + else + memset(blockBuf + within, 0, chunk); + } + if (!blockWrite(blockAbs, blockBuf)) + return false; + + done += chunk; + } + + uint64 newSize = position + length; + if (newSize > inode->size) + inode->size = (uint32)newSize; + inode->mtime = inode->ctime = (uint32)rtcNow(); + return writeInodeRaw(inodeNum, inode); +} + +static bool sfsTruncateInode(SfsInode* inode, int32 inodeNum, uint32 newSize) +{ + uint32 oldBlocks = inode->size ? (inode->size + sfsBlockSize - 1) / sfsBlockSize : 0; + uint32 neededBlocks = newSize ? (newSize + sfsBlockSize - 1) / sfsBlockSize : 0; + + for (uint32 i = neededBlocks; i < oldBlocks; i++) { + uint32 abs = 0; + if (i < sfsDirectBlocks) { + abs = inode->direct[i]; + inode->direct[i] = 0; + } else { + uint32 indIndex = i - sfsDirectBlocks; + if (indIndex < sfsPtrsPerBlock) { + if (inode->indirect == 0) + continue; + uint8 indBuf[sfsBlockSize]; + if (!blockRead(inode->indirect, indBuf)) + continue; + uint32* ptrs = (uint32*)indBuf; + abs = ptrs[indIndex]; + ptrs[indIndex] = 0; + blockWrite(inode->indirect, indBuf); + } else { + uint32 doubleIndex = indIndex - sfsPtrsPerBlock; + if (doubleIndex < sfsPtrsPerBlock * sfsPtrsPerBlock) { + uint32 outerIndex = doubleIndex / sfsPtrsPerBlock; + uint32 innerIndex = doubleIndex % sfsPtrsPerBlock; + if (inode->doubleIndirect == 0) + continue; + uint8 outerBuf[sfsBlockSize]; + if (!blockRead(inode->doubleIndirect, outerBuf)) + continue; + uint32* outerPtrs = (uint32*)outerBuf; + if (outerPtrs[outerIndex] == 0) + continue; + uint8 innerBuf[sfsBlockSize]; + if (!blockRead(outerPtrs[outerIndex], innerBuf)) + continue; + uint32* innerPtrs = (uint32*)innerBuf; + abs = innerPtrs[innerIndex]; + innerPtrs[innerIndex] = 0; + blockWrite(outerPtrs[outerIndex], innerBuf); + } else { + uint32 tripleIndex = doubleIndex - sfsPtrsPerBlock * sfsPtrsPerBlock; + uint32 topIndex = tripleIndex / (sfsPtrsPerBlock * sfsPtrsPerBlock); + uint32 midIndex = (tripleIndex / sfsPtrsPerBlock) % sfsPtrsPerBlock; + uint32 leafIndex = tripleIndex % sfsPtrsPerBlock; + if (inode->tripleIndirect == 0 || topIndex >= sfsPtrsPerBlock) + continue; + uint8 topBuf[sfsBlockSize]; + if (!blockRead(inode->tripleIndirect, topBuf)) + continue; + uint32* topPtrs = (uint32*)topBuf; + if (topPtrs[topIndex] == 0) + continue; + uint8 midBuf[sfsBlockSize]; + if (!blockRead(topPtrs[topIndex], midBuf)) + continue; + uint32* midPtrs = (uint32*)midBuf; + if (midPtrs[midIndex] == 0) + continue; + uint8 leafBuf[sfsBlockSize]; + if (!blockRead(midPtrs[midIndex], leafBuf)) + continue; + uint32* leafPtrs = (uint32*)leafBuf; + abs = leafPtrs[leafIndex]; + leafPtrs[leafIndex] = 0; + blockWrite(midPtrs[midIndex], leafBuf); + } + } + } + if (abs != 0) { + freeDataBlockAbs(abs); + inode->blockCount--; + } + } + + /* Pointer-holder blocks themselves (the indirect block, every inner + indirect block plus the outer double-indirect block, and every + top/mid/leaf-pointer block of the triple-indirect chain) are + reclaimed all-or-nothing once nothing addressed through them is + needed anymore -- the loop above already frees every leaf data block + that was in range, so by this point each holder either still guards + live data (neededBlocks reaches into its range: leave it alone) or is + entirely spent (free it, and everything it pointed at). Checked + highest level first (triple, then double, then single) so a shrink + that drops the whole file frees every level in one pass. */ + if (neededBlocks <= sfsDirectBlocks + sfsPtrsPerBlock + sfsPtrsPerBlock * sfsPtrsPerBlock + && inode->tripleIndirect != 0) { + uint8 topBuf[sfsBlockSize]; + if (blockRead(inode->tripleIndirect, topBuf)) { + uint32* topPtrs = (uint32*)topBuf; + for (uint32 j = 0; j < sfsPtrsPerBlock; j++) { + if (topPtrs[j] == 0) + continue; + uint8 midBuf[sfsBlockSize]; + if (blockRead(topPtrs[j], midBuf)) { + uint32* midPtrs = (uint32*)midBuf; + for (uint32 k = 0; k < sfsPtrsPerBlock; k++) { + if (midPtrs[k] != 0) { + freeDataBlockAbs(midPtrs[k]); + inode->blockCount--; + } + } + } + freeDataBlockAbs(topPtrs[j]); + inode->blockCount--; + } + } + freeDataBlockAbs(inode->tripleIndirect); + inode->tripleIndirect = 0; + inode->blockCount--; + } + + if (neededBlocks <= sfsDirectBlocks + sfsPtrsPerBlock && inode->doubleIndirect != 0) { + uint8 outerBuf[sfsBlockSize]; + if (blockRead(inode->doubleIndirect, outerBuf)) { + uint32* outerPtrs = (uint32*)outerBuf; + for (uint32 j = 0; j < sfsPtrsPerBlock; j++) { + if (outerPtrs[j] != 0) { + freeDataBlockAbs(outerPtrs[j]); + inode->blockCount--; + } + } + } + freeDataBlockAbs(inode->doubleIndirect); + inode->doubleIndirect = 0; + inode->blockCount--; + } + + if (neededBlocks <= sfsDirectBlocks && inode->indirect != 0) { + freeDataBlockAbs(inode->indirect); + inode->indirect = 0; + inode->blockCount--; + } + + inode->size = newSize; + inode->mtime = inode->ctime = (uint32)rtcNow(); + return writeInodeRaw(inodeNum, inode); +} + +static bool splitPath(const char* path, char* parentOut, uint64 parentCap, char* leafOut, uint64 leafCap) +{ + uint64 len = strlen(path); + while (len > 0 && path[len - 1] == '/') + len--; + if (len == 0) + return false; + + int64 slash = -1; + for (int64 i = (int64)len - 1; i >= 0; i--) + if (path[i] == '/') { + slash = i; + break; + } + + uint64 leafStart, leafLen; + if (slash < 0) { + parentOut[0] = 0; + leafStart = 0; + leafLen = len; + } else { + uint64 plen = (uint64)slash; + if (plen >= parentCap) + plen = parentCap - 1; + memcpy(parentOut, path, plen); + parentOut[plen] = 0; + leafStart = (uint64)slash + 1; + leafLen = len - leafStart; + } + + if (leafLen == 0 || leafLen >= leafCap) + return false; + memcpy(leafOut, path + leafStart, leafLen); + leafOut[leafLen] = 0; + return true; +} + +static int32 dirFind(int32 dirInode, const char* name, uint64 nameLen) +{ + SfsDirEntry entry; + uint64 pos = 0; + for (;;) { + int64 got = sfsReadAt(dirInode, pos, &entry, sizeof(entry)); + if (got != (int64)sizeof(entry)) + break; + if (strlen(entry.name) == nameLen && memcmp(entry.name, name, nameLen) == 0) + return (int32)entry.inode; + pos += sizeof(entry); + } + return -1; +} + +static bool dirAdd(int32 dirInode, const char* name, uint64 nameLen, int32 childInode, uint32 type) +{ + if (nameLen == 0 || nameLen >= sfsNameMax) + return false; + if (dirFind(dirInode, name, nameLen) >= 0) + return false; + + SfsDirEntry entry; + memset(&entry, 0, sizeof(entry)); + entry.inode = (uint32)childInode; + entry.type = type; + memcpy(entry.name, name, nameLen); + entry.name[nameLen] = 0; + + uint64 size = sfsSizeOf(dirInode); + return sfsWriteAt(dirInode, size, &entry, sizeof(entry)) == (int64)sizeof(entry); +} + +static bool dirRemove(int32 dirInode, const char* name, uint64 nameLen) +{ + SfsDirEntry entry; + uint64 pos = 0; + int64 foundPos = -1; + for (;;) { + int64 got = sfsReadAt(dirInode, pos, &entry, sizeof(entry)); + if (got != (int64)sizeof(entry)) + break; + if (strlen(entry.name) == nameLen && memcmp(entry.name, name, nameLen) == 0) { + foundPos = (int64)pos; + break; + } + pos += sizeof(entry); + } + if (foundPos < 0) + return false; + + uint64 size = sfsSizeOf(dirInode); + uint64 lastPos = size - sizeof(SfsDirEntry); + if ((uint64)foundPos != lastPos) { + SfsDirEntry last; + if (sfsReadAt(dirInode, lastPos, &last, sizeof(last)) != (int64)sizeof(last)) + return false; + if (sfsWriteAt(dirInode, (uint64)foundPos, &last, sizeof(last)) != (int64)sizeof(last)) + return false; + } + + SfsInode inode; + if (!readInode(dirInode, &inode)) + return false; + return sfsTruncateInode(&inode, dirInode, (uint32)lastPos); +} + +static int32 createNode(const char* path, uint32 type) +{ + if (!isMounted) + return -1; + + char parentBuf[256]; + char leaf[sfsNameMax]; + if (!splitPath(path, parentBuf, sizeof(parentBuf), leaf, sizeof(leaf))) + return -1; + + int32 parentInode = sfsLookup(parentBuf); + if (parentInode < 0 || sfsTypeOf(parentInode) != sfsTypeDir) + return -1; + /* Real Unix directory-write semantics: creating a NEW entry needs + write permission on the PARENT directory, mirroring vfs.c's + vfsMkdir/resolveOpen create-branch's own equivalent tmpfs check. */ + if (!vfsPermitted(sfsModeOf(parentInode), sfsUidOf(parentInode), sfsGidOf(parentInode), + currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + if (dirFind(parentInode, leaf, strlen(leaf)) >= 0) + return -1; + + int32 newInode = allocInodeNum(); + if (newInode < 0) + return -1; + + SfsInode inode; + memset(&inode, 0, sizeof(inode)); + inode.type = type; + inode.linkCount = 1; + inode.mtime = inode.ctime = (uint32)rtcNow(); + inode.mode = (type == sfsTypeDir) ? fsModeDefaultDir : fsModeDefaultFile; + inode.uid = currentThreadUid(); + inode.gid = currentThreadGid(); + if (!writeInodeRaw(newInode, &inode)) { + freeInodeNum(newInode); + return -1; + } + + if (!dirAdd(parentInode, leaf, strlen(leaf), newInode, type)) { + freeInodeNum(newInode); + return -1; + } + return newInode; +} + +static bool readSymlinkTarget(int32 inodeNum, char* buf, uint64 cap) +{ + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return false; + if (inode.type != sfsTypeSymlink) + return false; + if (cap == 0 || (uint64)inode.size >= cap) + return false; + + int64 got = sfsReadAt(inodeNum, 0, buf, inode.size); + if (got != (int64)inode.size) + return false; + buf[inode.size] = 0; + return true; +} + +/* Walks path one component at a time from the root, exactly like the old + flat sfsLookup used to, except a symlink component (other than possibly + the last one, depending on followFinal) is resolved by re-running this + same walk on its target and splicing the result back in as `cur` before + continuing with whatever of the original path remains. depth counts hops + already taken across that splicing, so a cycle (or merely a very long + chain) fails cleanly with -1 instead of recursing without bound. */ +static int32 lookupFrom(const char* path, bool followFinal, int depth) +{ + if (!isMounted) + return -1; + if (depth > sfsMaxSymlinkHops) + return -1; + + int32 cur = sfsRootInode; + const char* p = path ? path : ""; + while (*p == '/') + p++; + + while (*p) { + const char* start = p; + while (*p && *p != '/') + p++; + uint64 len = (uint64)(p - start); + if (len == 0 || len >= sfsNameMax) + return -1; + if (sfsTypeOf(cur) != sfsTypeDir) + return -1; + /* Real POSIX search permission on every directory ANCESTOR walked + through, mirroring vfs.c's lookupNode's own equivalent check for + tmpfs -- resolving a name inside `cur` needs the owner-or-other + exec bit for the calling thread's uid, not just "cur is a + directory". */ + if (!vfsPermitted(sfsModeOf(cur), sfsUidOf(cur), sfsGidOf(cur), + currentThreadUid(), currentThreadGid(), fsModeOwnerExec)) + return -1; + int32 child = dirFind(cur, start, len); + if (child < 0) + return -1; + + while (*p == '/') + p++; + bool isLast = (*p == 0); + + if (sfsTypeOf(child) == sfsTypeSymlink && (!isLast || followFinal)) { + char target[sfsSymlinkTargetMax + 1]; + if (!readSymlinkTarget(child, target, sizeof(target))) + return -1; + int32 resolved = lookupFrom(target, true, depth + 1); + if (resolved < 0) + return -1; + child = resolved; + } + + cur = child; + } + return cur; +} + +/* --- public API --- */ + +bool sfsFormat(void) +{ + if (!ataPresent()) + return false; + + uint64 sectors = ataSectorCount(); + if (sectors <= sfsBaseLba + sfsReserveEnd + 64) + return false; + + SfsSuperblock nsb; + memset(&nsb, 0, sizeof(nsb)); + nsb.magic = sfsMagic; + nsb.blockSize = sfsBlockSize; + nsb.totalBlocks = (uint32)(sectors - sfsBaseLba - sfsReserveEnd); + nsb.inodeCount = sfsMaxNodes; + + nsb.inodeBitmapStart = 1; + nsb.inodeBitmapBlocks = (nsb.inodeCount + 8 * sfsBlockSize - 1) / (8 * sfsBlockSize); + if (nsb.inodeBitmapBlocks == 0) + nsb.inodeBitmapBlocks = 1; + + nsb.blockBitmapStart = nsb.inodeBitmapStart + nsb.inodeBitmapBlocks; + nsb.blockBitmapBlocks = (nsb.totalBlocks + 8 * sfsBlockSize - 1) / (8 * sfsBlockSize); + if (nsb.blockBitmapBlocks == 0) + nsb.blockBitmapBlocks = 1; + + nsb.inodeTableStart = nsb.blockBitmapStart + nsb.blockBitmapBlocks; + uint32 inodesPerBlock = sfsBlockSize / sizeof(SfsInode); + nsb.inodeTableBlocks = (nsb.inodeCount + inodesPerBlock - 1) / inodesPerBlock; + + nsb.dataStart = nsb.inodeTableStart + nsb.inodeTableBlocks; + nsb.rootInode = sfsRootInode; + if (nsb.dataStart >= nsb.totalBlocks) + return false; + + /* Committed to actually wiping from here on (every earlier return above + is a rejected format that touched nothing) -- every inode number's + old meaning is about to become garbage, so anything that cached one + from before this point must be told apart from anything cached + after. */ + formatGeneration++; + + if (inodeBitmap) { + kfree(inodeBitmap); + inodeBitmap = nullptr; + } + if (blockBitmap) { + kfree(blockBitmap); + blockBitmap = nullptr; + } + + inodeBitmap = (uint8*)kmalloc((uint64)nsb.inodeBitmapBlocks * sfsBlockSize); + blockBitmap = (uint8*)kmalloc((uint64)nsb.blockBitmapBlocks * sfsBlockSize); + if (!inodeBitmap || !blockBitmap) { + if (inodeBitmap) kfree(inodeBitmap); + if (blockBitmap) kfree(blockBitmap); + inodeBitmap = nullptr; + blockBitmap = nullptr; + return false; + } + memset(inodeBitmap, 0, (uint64)nsb.inodeBitmapBlocks * sfsBlockSize); + memset(blockBitmap, 0, (uint64)nsb.blockBitmapBlocks * sfsBlockSize); + + sb = nsb; + isMounted = true; + + uint8 zeroBlk[sfsBlockSize]; + memset(zeroBlk, 0, sizeof(zeroBlk)); + for (uint32 i = 0; i < sb.inodeTableBlocks; i++) + blockWrite(sb.inodeTableStart + i, zeroBlk); + + bitSet(inodeBitmap, 0); + bitSet(inodeBitmap, sfsRootInode); + + SfsInode rootInode; + memset(&rootInode, 0, sizeof(rootInode)); + rootInode.type = sfsTypeDir; + rootInode.linkCount = 1; + rootInode.mtime = rootInode.ctime = (uint32)rtcNow(); + rootInode.mode = fsModeDefaultDir; + rootInode.uid = currentThreadUid(); + rootInode.gid = currentThreadGid(); + writeInodeRaw(sfsRootInode, &rootInode); + + flushInodeBitmap(); + flushBlockBitmap(); + flushSuperblock(); + return true; +} + +bool sfsMount(void) +{ + if (!ataPresent()) + return false; + + uint8 buf[sfsBlockSize]; + if (!ataReadSectors(sfsBaseLba, 1, buf)) + return false; + + SfsSuperblock onDisk; + memcpy(&onDisk, buf, sizeof(onDisk)); + if (onDisk.magic != sfsMagic) + return sfsFormat(); + + sb = onDisk; + + if (inodeBitmap) { + kfree(inodeBitmap); + inodeBitmap = nullptr; + } + if (blockBitmap) { + kfree(blockBitmap); + blockBitmap = nullptr; + } + + inodeBitmap = (uint8*)kmalloc((uint64)sb.inodeBitmapBlocks * sfsBlockSize); + blockBitmap = (uint8*)kmalloc((uint64)sb.blockBitmapBlocks * sfsBlockSize); + if (!inodeBitmap || !blockBitmap) + return false; + + for (uint32 i = 0; i < sb.inodeBitmapBlocks; i++) + if (!blockRead(sb.inodeBitmapStart + i, inodeBitmap + (uint64)i * sfsBlockSize)) + return false; + for (uint32 i = 0; i < sb.blockBitmapBlocks; i++) + if (!blockRead(sb.blockBitmapStart + i, blockBitmap + (uint64)i * sfsBlockSize)) + return false; + + isMounted = true; + return true; +} + +void sfsUnmount(void) +{ + if (!isMounted) + return; + flushInodeBitmap(); + flushBlockBitmap(); + flushSuperblock(); + if (inodeBitmap) { + kfree(inodeBitmap); + inodeBitmap = nullptr; + } + if (blockBitmap) { + kfree(blockBitmap); + blockBitmap = nullptr; + } + isMounted = false; +} + +bool sfsMounted(void) +{ + return isMounted; +} + +uint64 sfsCapacityBytes(void) +{ + if (!isMounted) + return 0; + return (uint64)(sb.totalBlocks - sb.dataStart) * sfsBlockSize; +} + +uint64 sfsFreeDataBlocks(void) +{ + if (!isMounted) + return 0; + uint32 dataBlockCount = sb.totalBlocks - sb.dataStart; + uint64 free = 0; + for (uint32 i = 0; i < dataBlockCount; i++) + if (!bitTest(blockBitmap, i)) + free++; + return free; +} + +int32 sfsLookup(const char* path) +{ + return lookupFrom(path, true, 0); +} + +int32 sfsLookupNoFollow(const char* path) +{ + return lookupFrom(path, false, 0); +} + +int32 sfsCreateFile(const char* path) +{ + return createNode(path, sfsTypeFile); +} + +int32 sfsCreateDir(const char* path) +{ + return createNode(path, sfsTypeDir); +} + +int32 sfsCreateFifo(const char* path) +{ + return createNode(path, sfsTypeFifo); +} + +uint64 sfsGeneration(void) +{ + return formatGeneration; +} + +int32 sfsCreateSymlink(const char* path, const char* target) +{ + if (!isMounted || !target) + return -1; + uint64 targetLen = strlen(target); + if (targetLen == 0 || targetLen > sfsSymlinkTargetMax) + return -1; + + int32 newInode = createNode(path, sfsTypeSymlink); + if (newInode < 0) + return -1; + + if (sfsWriteAt(newInode, 0, target, targetLen) != (int64)targetLen) { + char parentBuf[256]; + char leaf[sfsNameMax]; + if (splitPath(path, parentBuf, sizeof(parentBuf), leaf, sizeof(leaf))) { + int32 parentInode = sfsLookup(parentBuf); + if (parentInode >= 0) + dirRemove(parentInode, leaf, strlen(leaf)); + } + freeInodeNum(newInode); + return -1; + } + return newInode; +} + +bool sfsReadlink(int32 inodeNum, char* buf, uint64 cap) +{ + if (!isMounted || inodeNum <= 0) + return false; + return readSymlinkTarget(inodeNum, buf, cap); +} + +int32 sfsLink(const char* existingPath, const char* newPath) +{ + if (!isMounted) + return -1; + + int32 target = sfsLookup(existingPath); + if (target < 0) + return -1; + + SfsInode inode; + if (!readInode(target, &inode)) + return -1; + if (inode.type == sfsTypeDir) + return -1; + + char parentBuf[256]; + char leaf[sfsNameMax]; + if (!splitPath(newPath, parentBuf, sizeof(parentBuf), leaf, sizeof(leaf))) + return -1; + + int32 parentInode = sfsLookup(parentBuf); + if (parentInode < 0 || sfsTypeOf(parentInode) != sfsTypeDir) + return -1; + /* Same parent-directory write check as createNode/sfsRemove above -- + adding a new hardlink NAME needs write permission on the PARENT of + the new name, not on the existing target being linked to. */ + if (!vfsPermitted(sfsModeOf(parentInode), sfsUidOf(parentInode), sfsGidOf(parentInode), + currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + if (dirFind(parentInode, leaf, strlen(leaf)) >= 0) + return -1; + + inode.linkCount++; + inode.ctime = (uint32)rtcNow(); + if (!writeInodeRaw(target, &inode)) + return -1; + + if (!dirAdd(parentInode, leaf, strlen(leaf), target, inode.type)) { + inode.linkCount--; + writeInodeRaw(target, &inode); + return -1; + } + return target; +} + +uint32 sfsLinkCountOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.linkCount; +} + +bool sfsRemove(const char* path) +{ + if (!isMounted) + return false; + + char parentBuf[256]; + char leaf[sfsNameMax]; + if (!splitPath(path, parentBuf, sizeof(parentBuf), leaf, sizeof(leaf))) + return false; + + int32 parentInode = sfsLookup(parentBuf); + if (parentInode < 0) + return false; + /* Same parent-directory write check as createNode above -- removing an + entry needs write permission on the PARENT, not the entry itself, + mirroring vfs.c's vfsUnlink/vfsRmdir's own equivalent tmpfs check. */ + if (!vfsPermitted(sfsModeOf(parentInode), sfsUidOf(parentInode), sfsGidOf(parentInode), + currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return false; + int32 target = dirFind(parentInode, leaf, strlen(leaf)); + if (target < 0) + return false; + + SfsInode inode; + if (!readInode(target, &inode)) + return false; + if (inode.type == sfsTypeDir && inode.size > 0) + return false; + + if (!dirRemove(parentInode, leaf, strlen(leaf))) + return false; + + if (inode.linkCount > 1) { + inode.linkCount--; + inode.ctime = (uint32)rtcNow(); + return writeInodeRaw(target, &inode); + } + + if (!sfsTruncateInode(&inode, target, 0)) + return false; + freeInodeNum(target); + return true; +} + +uint64 sfsSizeOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.size; +} + +uint32 sfsTypeOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return sfsTypeFree; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return sfsTypeFree; + return inode.type; +} + +uint64 sfsMtimeOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.mtime; +} + +uint64 sfsCtimeOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.ctime; +} + +uint32 sfsModeOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.mode; +} + +bool sfsChmod(int32 inodeNum, uint32 mode) +{ + if (!isMounted || inodeNum <= 0) + return false; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return false; + inode.mode = mode; + inode.ctime = (uint32)rtcNow(); + return writeInodeRaw(inodeNum, &inode); +} + +uint32 sfsUidOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.uid; +} + +bool sfsChown(int32 inodeNum, uint32 newUid) +{ + if (!isMounted || inodeNum <= 0) + return false; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return false; + inode.uid = newUid; + inode.ctime = (uint32)rtcNow(); + return writeInodeRaw(inodeNum, &inode); +} + +uint32 sfsGidOf(int32 inodeNum) +{ + if (!isMounted || inodeNum <= 0) + return 0; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return 0; + return inode.gid; +} + +bool sfsChgrp(int32 inodeNum, uint32 newGid) +{ + if (!isMounted || inodeNum <= 0) + return false; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return false; + inode.gid = newGid; + inode.ctime = (uint32)rtcNow(); + return writeInodeRaw(inodeNum, &inode); +} + +bool sfsTruncate(int32 inodeNum, uint64 newSize) +{ + if (!isMounted || inodeNum <= 0 || newSize > 0xFFFFFFFFu) + return false; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return false; + if (inode.type != sfsTypeFile) + return false; + return sfsTruncateInode(&inode, inodeNum, (uint32)newSize); +} + +int64 sfsReadAt(int32 inodeNum, uint64 offset, void* buffer, uint64 length) +{ + if (!isMounted || inodeNum <= 0) + return -1; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return -1; + if (inode.type != sfsTypeFile && inode.type != sfsTypeDir && inode.type != sfsTypeSymlink) + return -1; + if (offset >= inode.size) + return 0; + + uint64 want = length; + if (want > (uint64)inode.size - offset) + want = (uint64)inode.size - offset; + + uint8* dst = (uint8*)buffer; + uint64 done = 0; + while (done < want) { + uint64 pos = offset + done; + uint32 logicalBlock = (uint32)(pos / sfsBlockSize); + uint32 within = (uint32)(pos % sfsBlockSize); + uint32 chunk = sfsBlockSize - within; + if ((uint64)chunk > want - done) + chunk = (uint32)(want - done); + + uint32 blockAbs; + if (getBlockForRead(&inode, logicalBlock, &blockAbs)) { + uint8 blockBuf[sfsBlockSize]; + if (!blockRead(blockAbs, blockBuf)) + break; + memcpy(dst + done, blockBuf + within, chunk); + } else { + memset(dst + done, 0, chunk); + } + done += chunk; + } + return (int64)done; +} + +int64 sfsWriteAt(int32 inodeNum, uint64 offset, const void* buffer, uint64 length) +{ + if (!isMounted || inodeNum <= 0) + return -1; + SfsInode inode; + if (!readInode(inodeNum, &inode)) + return -1; + if (inode.type != sfsTypeFile && inode.type != sfsTypeDir && inode.type != sfsTypeSymlink) + return -1; + if (length == 0) + return 0; + + if (offset > inode.size) { + if (!sfsWriteRaw(&inode, inodeNum, inode.size, nullptr, offset - inode.size)) + return -1; + } + if (!sfsWriteRaw(&inode, inodeNum, offset, buffer, length)) + return -1; + return (int64)length; +} + +bool sfsReaddirAt(int32 dirInode, uint32 index, char* nameOut, uint32 nameCap, uint32* typeOut) +{ + if (!isMounted || sfsTypeOf(dirInode) != sfsTypeDir) + return false; + + SfsDirEntry entry; + uint64 pos = (uint64)index * sizeof(SfsDirEntry); + if (sfsReadAt(dirInode, pos, &entry, sizeof(entry)) != (int64)sizeof(entry)) + return false; + + uint64 nlen = strlen(entry.name); + if (nlen >= nameCap) + nlen = nameCap - 1; + memcpy(nameOut, entry.name, nlen); + nameOut[nlen] = 0; + if (typeOut) + *typeOut = entry.type; + return true; +} diff --git a/src/sfsTest.c b/src/sfsTest.c new file mode 100644 index 0000000..baaf56b --- /dev/null +++ b/src/sfsTest.c @@ -0,0 +1,1357 @@ +#include "ktest.h" +#include "sfs.h" +#include "ata.h" +#include "string.h" +#include "heap.h" +#include "console.h" +#include "rtc.h" +#include "fs.h" + +/* + * Every test starts with an explicit sfsFormat() so results do not depend on + * whatever build/disk.img happened to contain from a previous `make test` + * run (the disk image is not recreated between runs unless deleted). That + * keeps the gate deterministic: a stale leftover file on disk must never be + * the reason a check passes or fails. + */ + +KTEST(sfsFormatAndMountAreReal) +{ + if (!KT_TRUE(t, ataPresent())) + return; + KT_TRUE(t, sfsFormat()); + KT_TRUE(t, sfsMounted()); + KT_GT(t, sfsCapacityBytes(), 1024 * 1024); + + int32 root = sfsLookup(""); + KT_EQ(t, root, sfsRootInode); + KT_EQ(t, sfsTypeOf(root), sfsTypeDir); + KT_EQ(t, sfsSizeOf(root), 0); +} + +KTEST(sfsCreateFileRoundTrip) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("hello.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsTypeOf(f), sfsTypeFile); + KT_EQ(t, sfsSizeOf(f), 0); + + const char* msg = "hello, disk!"; + uint64 len = strlen(msg); + KT_EQ(t, sfsWriteAt(f, 0, msg, len), (int64)len); + KT_EQ(t, sfsSizeOf(f), len); + + char back[32]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(f, 0, back, len), (int64)len); + KT_EQ(t, memcmp(back, msg, len), 0); +} + +KTEST(sfsOverwriteReflectsLatestData) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("a.bin"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "AAAA", 4), 4); + KT_EQ(t, sfsWriteAt(f, 0, "BB", 2), 2); + + char back[4]; + KT_EQ(t, sfsReadAt(f, 0, back, 4), 4); + KT_EQ(t, memcmp(back, "BBAA", 4), 0); + KT_EQ(t, sfsSizeOf(f), 4); +} + +KTEST(sfsWritePastEndZeroFillsGap) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("gap.bin"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 10, "X", 1), 1); + KT_EQ(t, sfsSizeOf(f), 11); + + uint8 back[11]; + KT_EQ(t, sfsReadAt(f, 0, back, 11), 11); + for (int i = 0; i < 10; i++) + KT_EQ(t, back[i], 0); + KT_EQ(t, back[10], 'X'); +} + +KTEST(sfsMultiBlockFileSpansDirectPointers) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("multi.bin"); + KT_TRUE(t, f > 0); + + /* Big enough to span several blocks: kmalloc'd rather than kept on the + stack, matching the rbt/slab stress-test convention (this ktest + battery runs on the fixed 16 KiB boot stack, not a per-thread one). */ + #define sfsTestSpan (3 * sfsBlockSize + 200) + uint8* pattern = (uint8*)kmalloc(sfsTestSpan); + uint8* back = (uint8*)kmalloc(sfsTestSpan); + KT_NOTNULL(t, pattern); + KT_NOTNULL(t, back); + if (!pattern || !back) { + kfree(pattern); + kfree(back); + return; + } + for (int i = 0; i < sfsTestSpan; i++) + pattern[i] = (uint8)(i * 31 + 7); + + KT_EQ(t, sfsWriteAt(f, 0, pattern, sfsTestSpan), sfsTestSpan); + KT_EQ(t, sfsSizeOf(f), sfsTestSpan); + + memset(back, 0, sfsTestSpan); + KT_EQ(t, sfsReadAt(f, 0, back, sfsTestSpan), sfsTestSpan); + KT_EQ(t, memcmp(back, pattern, sfsTestSpan), 0); + + kfree(pattern); + kfree(back); + #undef sfsTestSpan +} + +KTEST(sfsFileGrowsThroughIndirectBlock) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("big.bin"); + KT_TRUE(t, f > 0); + + /* 10 direct blocks (5120 bytes) plus a chunk that only the single + indirect block can address: this must reach past sfsDirectBlocks. + kmalloc'd (three of these at once would be ~18 KiB, more than the + whole 16 KiB boot stack ktest runs on). */ + #define sfsBigSpan (12 * sfsBlockSize + 33) + uint8* pattern = (uint8*)kmalloc(sfsBigSpan); + uint8* back = (uint8*)kmalloc(sfsBigSpan); + uint8* verify = (uint8*)kmalloc(sfsBigSpan); + KT_NOTNULL(t, pattern); + KT_NOTNULL(t, back); + KT_NOTNULL(t, verify); + if (!pattern || !back || !verify) { + kfree(pattern); + kfree(back); + kfree(verify); + return; + } + for (int i = 0; i < sfsBigSpan; i++) + pattern[i] = (uint8)(i ^ 0x5A); + + KT_EQ(t, sfsWriteAt(f, 0, pattern, sfsBigSpan), sfsBigSpan); + KT_EQ(t, sfsSizeOf(f), sfsBigSpan); + + memset(back, 0, sfsBigSpan); + KT_EQ(t, sfsReadAt(f, 0, back, sfsBigSpan), sfsBigSpan); + KT_EQ(t, memcmp(back, pattern, sfsBigSpan), 0); + + /* Overwrite a slice that straddles the direct/indirect boundary and + confirm only that slice changes. */ + uint8 patch[64]; + memset(patch, 0xEE, sizeof(patch)); + uint64 patchOffset = 10 * sfsBlockSize - 20; + KT_EQ(t, sfsWriteAt(f, patchOffset, patch, sizeof(patch)), (int64)sizeof(patch)); + + KT_EQ(t, sfsReadAt(f, 0, verify, sfsBigSpan), sfsBigSpan); + KT_EQ(t, memcmp(verify + patchOffset, patch, sizeof(patch)), 0); + KT_EQ(t, memcmp(verify, pattern, patchOffset), 0); + + kfree(pattern); + kfree(back); + kfree(verify); + #undef sfsBigSpan +} + +KTEST(sfsNestedDirectoriesAndLookup) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("etc") > 0); + KT_TRUE(t, sfsCreateDir("etc/sub") > 0); + int32 f = sfsCreateFile("etc/sub/leaf.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "leaf", 4), 4); + + int32 viaLookup = sfsLookup("etc/sub/leaf.txt"); + KT_EQ(t, viaLookup, f); + int32 subDir = sfsLookup("etc/sub"); + KT_TRUE(t, subDir > 0); + KT_EQ(t, sfsTypeOf(subDir), sfsTypeDir); + KT_EQ(t, sfsLookup("etc/nope"), -1); + KT_EQ(t, sfsLookup("nope/at/all"), -1); +} + +KTEST(sfsDuplicateCreateRejected) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateFile("dup.txt") > 0); + KT_EQ(t, sfsCreateFile("dup.txt"), -1); + KT_EQ(t, sfsCreateDir("dup.txt"), -1); +} + +KTEST(sfsRemoveFileFreesBlocksForReuse) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("tofree.bin"); + KT_TRUE(t, f > 0); + #define sfsFreeChunk (4 * sfsBlockSize) + uint8* chunk = (uint8*)kmalloc(sfsFreeChunk); + KT_NOTNULL(t, chunk); + if (!chunk) + return; + memset(chunk, 0x42, sfsFreeChunk); + KT_EQ(t, sfsWriteAt(f, 0, chunk, sfsFreeChunk), sfsFreeChunk); + kfree(chunk); + #undef sfsFreeChunk + + KT_TRUE(t, sfsRemove("tofree.bin")); + KT_EQ(t, sfsLookup("tofree.bin"), -1); + + /* A same-size file created afterward must succeed, proving the blocks + (and the inode slot) actually came back to the free pool rather than + leaking on every create/remove cycle. */ + int32 again = sfsCreateFile("tofree.bin"); + KT_TRUE(t, again > 0); + KT_EQ(t, sfsSizeOf(again), 0); +} + +KTEST(sfsRmdirRequiresEmpty) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("full") > 0); + KT_TRUE(t, sfsCreateFile("full/child.txt") > 0); + KT_FALSE(t, sfsRemove("full")); + + KT_TRUE(t, sfsRemove("full/child.txt")); + KT_TRUE(t, sfsRemove("full")); + KT_EQ(t, sfsLookup("full"), -1); +} + +KTEST(sfsReaddirListsAllEntriesAndSwapRemoveKeepsRest) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 dir = sfsCreateDir("listing"); + KT_TRUE(t, dir > 0); + KT_TRUE(t, sfsCreateFile("listing/a") > 0); + KT_TRUE(t, sfsCreateFile("listing/b") > 0); + KT_TRUE(t, sfsCreateFile("listing/c") > 0); + + uint32 seen = 0; + char name[sfsNameMax + 1]; + uint32 type; + for (uint32 i = 0; sfsReaddirAt(dir, i, name, sizeof(name), &type); i++) { + KT_EQ(t, type, sfsTypeFile); + seen++; + } + KT_EQ(t, seen, 3); + + KT_TRUE(t, sfsRemove("listing/b")); + KT_NE(t, sfsLookup("listing/a"), -1); + KT_NE(t, sfsLookup("listing/c"), -1); + KT_EQ(t, sfsLookup("listing/b"), -1); + + seen = 0; + for (uint32 i = 0; sfsReaddirAt(dir, i, name, sizeof(name), &type); i++) + seen++; + KT_EQ(t, seen, 2); +} + +KTEST(sfsTruncateShrinksAndFreesBlocks) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("shrink.bin"); + KT_TRUE(t, f > 0); + #define sfsShrinkChunk (5 * sfsBlockSize) + uint8* chunk = (uint8*)kmalloc(sfsShrinkChunk); + KT_NOTNULL(t, chunk); + if (!chunk) + return; + memset(chunk, 0x7A, sfsShrinkChunk); + KT_EQ(t, sfsWriteAt(f, 0, chunk, sfsShrinkChunk), sfsShrinkChunk); + + KT_TRUE(t, sfsTruncate(f, sfsBlockSize)); + KT_EQ(t, sfsSizeOf(f), sfsBlockSize); + + uint8 back[sfsBlockSize]; + KT_EQ(t, sfsReadAt(f, 0, back, sfsBlockSize), sfsBlockSize); + KT_EQ(t, memcmp(back, chunk, sfsBlockSize), 0); + kfree(chunk); + #undef sfsShrinkChunk + + KT_TRUE(t, sfsTruncate(f, 0)); + KT_EQ(t, sfsSizeOf(f), 0); +} + +KTEST(sfsPersistsAcrossUnmountRemount) +{ + /* The real point of this test: sfsUnmount() drops every in-memory + structure (bitmaps freed, mounted flag cleared). If sfsMount() can + still find the file and its exact bytes afterward, the filesystem is + genuinely backed by disk, not by a cache that happens to survive. */ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("keep") > 0); + int32 f = sfsCreateFile("keep/data.bin"); + KT_TRUE(t, f > 0); + const char* payload = "this must survive a real remount"; + uint64 len = strlen(payload); + KT_EQ(t, sfsWriteAt(f, 0, payload, len), (int64)len); + + sfsUnmount(); + KT_FALSE(t, sfsMounted()); + + KT_TRUE(t, sfsMount()); + KT_TRUE(t, sfsMounted()); + + int32 reloaded = sfsLookup("keep/data.bin"); + KT_EQ(t, reloaded, f); + KT_EQ(t, sfsSizeOf(reloaded), len); + + char back[64]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(reloaded, 0, back, len), (int64)len); + KT_EQ(t, memcmp(back, payload, len), 0); +} + +KTEST(sfsMountWithoutFormatRecoversPriorState) +{ + /* sfsMount() on an already-formatted disk must NOT reformat: a second + mount call has to see the first mount's data, exactly like a real + reboot re-mounting an existing filesystem. */ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("survives.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "still here", 10), 10); + sfsUnmount(); + + KT_TRUE(t, sfsMount()); + int32 found = sfsLookup("survives.txt"); + KT_EQ(t, found, f); + char back[10]; + KT_EQ(t, sfsReadAt(found, 0, back, 10), 10); + KT_EQ(t, memcmp(back, "still here", 10), 0); +} + +/* + * mtime/ctime coverage below. Every bound uses a real rtcNow() reading taken + * strictly before or after the operation under test, in this same thread, so + * "before <= stamped-value <= after" is a real chronological fact (the clock + * only ever moves forward), never a flaky timing assumption -- unlike a + * cross-run "did it increase" check, which would need a real elapsed second + * to be meaningful. sfsLinkBumpsCtimeButNeverTouchesMtime instead checks + * exact, timing-independent equality: the link code path must never write + * inode.mtime at all, so it has to come back byte-for-byte unchanged + * regardless of how much wall-clock time passed. + */ + +KTEST(sfsCreateFileStampsMtimeAndCtimeNearNow) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int64 before = rtcNow(); + int32 f = sfsCreateFile("stamped.txt"); + if (!KT_TRUE(t, f > 0)) + return; + int64 after = rtcNow(); + + uint64 mtime = sfsMtimeOf(f); + uint64 ctime = sfsCtimeOf(f); + KT_EQ(t, mtime, ctime); + KT_GE(t, mtime, before); + KT_LE(t, mtime, after); +} + +KTEST(sfsWriteAtBumpsMtimeAndCtimeStayingInSync) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("written.txt"); + if (!KT_TRUE(t, f > 0)) + return; + uint64 createdMtime = sfsMtimeOf(f); + + int64 before = rtcNow(); + const char* payload = "sfs mtime test payload"; + uint64 len = strlen(payload); + KT_EQ(t, sfsWriteAt(f, 0, payload, len), (int64)len); + int64 after = rtcNow(); + + uint64 mtime = sfsMtimeOf(f); + uint64 ctime = sfsCtimeOf(f); + KT_EQ(t, mtime, ctime); + KT_GE(t, mtime, createdMtime); + KT_GE(t, mtime, before); + KT_LE(t, mtime, after); +} + +KTEST(sfsLinkBumpsCtimeButNeverTouchesMtime) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("original.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQ(t, sfsWriteAt(f, 0, "hi", 2), 2); + + uint64 mtimeBeforeLink = sfsMtimeOf(f); + uint64 ctimeBeforeLink = sfsCtimeOf(f); + + int64 before = rtcNow(); + KT_EQ(t, sfsLink("original.txt", "alias.txt"), f); + int64 after = rtcNow(); + + KT_EQ(t, sfsMtimeOf(f), mtimeBeforeLink); + KT_GE(t, sfsCtimeOf(f), ctimeBeforeLink); + KT_GE(t, sfsCtimeOf(f), before); + KT_LE(t, sfsCtimeOf(f), after); +} + +KTEST(sfsRemoveOneOfTwoHardlinksBumpsRemainingLinkCtime) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("keepme.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQ(t, sfsLink("keepme.txt", "alias2.txt"), f); + KT_EQ(t, sfsLinkCountOf(f), 2u); + + uint64 ctimeBeforeRemove = sfsCtimeOf(f); + int64 before = rtcNow(); + KT_TRUE(t, sfsRemove("alias2.txt")); + int64 after = rtcNow(); + + KT_EQ(t, sfsLinkCountOf(f), 1u); + KT_GE(t, sfsCtimeOf(f), ctimeBeforeRemove); + KT_GE(t, sfsCtimeOf(f), before); + KT_LE(t, sfsCtimeOf(f), after); +} + +KTEST(sfsPersistsMtimeAndCtimeAcrossUnmountRemount) +{ + /* Same real point as sfsPersistsAcrossUnmountRemount above, applied to + the new fields: sfsUnmount() drops every in-memory structure, so an + exact match after sfsMount() proves these timestamps genuinely live on + disk inside the inode, not in some cache that happens to survive. */ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("timepersist.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQ(t, sfsWriteAt(f, 0, "persisted", 9), 9); + + uint64 mtimeBefore = sfsMtimeOf(f); + uint64 ctimeBefore = sfsCtimeOf(f); + KT_GT(t, mtimeBefore, 0); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 reloaded = sfsLookup("timepersist.txt"); + KT_EQ(t, reloaded, f); + KT_EQ(t, sfsMtimeOf(reloaded), mtimeBefore); + KT_EQ(t, sfsCtimeOf(reloaded), ctimeBefore); +} + +/* Real chmod(2) owner rwx bits (fs.h's fsModeOwner*): createNode stamps a + type-based default (a file gets fsModeDefaultFile, a directory gets + fsModeDefaultDir), sfsChmod overwrites it and bumps ctime like any other + metadata-only mutation (sfsLink's linkCount bump does the same, see + above), and sfsModeOf/sfsChmod on an invalid inode number fail the same + way sfsSizeOf/sfsTypeOf already do for one. */ +KTEST(sfsModeDefaultsByTypeAndChmodBumpsCtime) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("modefile.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQ(t, sfsModeOf(f), fsModeDefaultFile); + + int32 d = sfsCreateDir("modedir"); + if (!KT_TRUE(t, d > 0)) + return; + KT_EQ(t, sfsModeOf(d), fsModeDefaultDir); + + uint64 ctimeBefore = sfsCtimeOf(f); + int64 before = rtcNow(); + KT_TRUE(t, sfsChmod(f, 0400)); + int64 after = rtcNow(); + + KT_EQ(t, sfsModeOf(f), 0400u); + KT_GE(t, sfsCtimeOf(f), ctimeBefore); + KT_GE(t, sfsCtimeOf(f), before); + KT_LE(t, sfsCtimeOf(f), after); + + KT_EQ(t, sfsModeOf(-1), 0u); + KT_EQ(t, sfsModeOf(9999), 0u); + KT_FALSE(t, sfsChmod(-1, 0644)); + KT_FALSE(t, sfsChmod(9999, 0644)); +} + +/* Same real point as sfsPersistsMtimeAndCtimeAcrossUnmountRemount above, + applied to mode: sfsUnmount() drops every in-memory structure, so an + exact match after sfsMount() proves the chmod'd value genuinely lives on + disk inside the inode, not in some cache that happens to survive. */ +KTEST(sfsPersistsModeAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("modepersist.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_TRUE(t, sfsChmod(f, 0400)); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 reloaded = sfsLookup("modepersist.txt"); + KT_EQ(t, reloaded, f); + KT_EQ(t, sfsModeOf(reloaded), 0400u); +} + +/* Real owner identity (fs.h's FbStat.uid, sched.h's currentThreadUid()): + createNode stamps every new node -- file, dir, symlink, fifo all go + through it, exactly like mtime/ctime already verified sufficient for + (FALA 39's own note) -- with whoever is CURRENT at creation time. + ktestRunAll always runs as the permanent idle thread (id 0, uid 0), the + same context schedTest.c's own uid-default tests already document + relying on, so both a freshly created file and the root inode itself + (sfsFormat, this test's own sfsFormat() call included) come back + root-owned. sfsUidOf on an invalid inode number fails the same way + sfsModeOf/sfsSizeOf/sfsTypeOf already do for one. */ +KTEST(sfsCreateNodeStampsCreatorUidAsOwner) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_EQU(t, sfsUidOf(sfsRootInode), 0u); + + int32 f = sfsCreateFile("uidfile.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQU(t, sfsUidOf(f), 0u); + + int32 d = sfsCreateDir("uiddir"); + if (!KT_TRUE(t, d > 0)) + return; + KT_EQU(t, sfsUidOf(d), 0u); + + KT_EQU(t, sfsUidOf(-1), 0u); + KT_EQU(t, sfsUidOf(9999), 0u); +} + +/* Same real point as sfsPersistsModeAcrossUnmountRemount above, applied to + uid: sfsUnmount() drops every in-memory structure, so an exact match + after sfsMount() proves ownership genuinely lives on disk inside the + inode, not in some cache that happens to survive. */ +KTEST(sfsPersistsUidAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("uidpersist.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQU(t, sfsUidOf(f), 0u); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 reloaded = sfsLookup("uidpersist.txt"); + KT_EQ(t, reloaded, f); + KT_EQU(t, sfsUidOf(reloaded), 0u); +} + +/* Real chown(2)'s on-disk half (sfsChown): overwrites the inode's owner + uid outright and bumps ctime like sfsChmod's own metadata-only mutation + does -- same "bump ctime, invalid inode fails the same way" shape as + sfsModeDefaultsByTypeAndChmodBumpsCtime above. sfsChown itself has no + privilege check (vfsChown, vfs.c, is the real choke point that enforces + chown(2)'s root-only rule), so calling it directly here at any uid is + deliberately not restricted. */ +KTEST(sfsChownReassignsOwnerAndBumpsCtime) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("chownfile.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQU(t, sfsUidOf(f), 0u); + + uint64 ctimeBefore = sfsCtimeOf(f); + int64 before = rtcNow(); + KT_TRUE(t, sfsChown(f, 9001)); + int64 after = rtcNow(); + + KT_EQU(t, sfsUidOf(f), 9001u); + KT_GE(t, sfsCtimeOf(f), ctimeBefore); + KT_GE(t, sfsCtimeOf(f), before); + KT_LE(t, sfsCtimeOf(f), after); + + KT_FALSE(t, sfsChown(-1, 1000)); + KT_FALSE(t, sfsChown(9999, 1000)); +} + +/* Same real point as sfsPersistsUidAcrossUnmountRemount above, but for a + uid REASSIGNED by sfsChown after creation, not just the creation-time + default -- proves the chown write path itself lands on disk, not just + that createNode's own initial stamp does. */ +KTEST(sfsPersistsChownedUidAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("chownpersist.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_TRUE(t, sfsChown(f, 8123)); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 reloaded = sfsLookup("chownpersist.txt"); + KT_EQ(t, reloaded, f); + KT_EQU(t, sfsUidOf(reloaded), 8123u); +} + +/* Real chgrp(2)'s on-disk half (sfsChgrp), mirroring + sfsChownReassignsOwnerAndBumpsCtime exactly one field over -- same "no + privilege check of its own, ctime bumps, invalid inode fails the same + way" shape. */ +KTEST(sfsChgrpReassignsGroupAndBumpsCtime) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("chgrpfile.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_EQU(t, sfsGidOf(f), 0u); + + uint64 ctimeBefore = sfsCtimeOf(f); + int64 before = rtcNow(); + KT_TRUE(t, sfsChgrp(f, 9002)); + int64 after = rtcNow(); + + KT_EQU(t, sfsGidOf(f), 9002u); + KT_GE(t, sfsCtimeOf(f), ctimeBefore); + KT_GE(t, sfsCtimeOf(f), before); + KT_LE(t, sfsCtimeOf(f), after); + + KT_FALSE(t, sfsChgrp(-1, 1000)); + KT_FALSE(t, sfsChgrp(9999, 1000)); +} + +/* Same real point as sfsPersistsChownedUidAcrossUnmountRemount above, one + field over: proves the chgrp write path itself lands on disk. */ +KTEST(sfsPersistsChgrpedGidAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("chgrppersist.txt"); + if (!KT_TRUE(t, f > 0)) + return; + KT_TRUE(t, sfsChgrp(f, 7124)); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 reloaded = sfsLookup("chgrppersist.txt"); + KT_EQ(t, reloaded, f); + KT_EQU(t, sfsGidOf(reloaded), 7124u); +} + +/* Real directory-write enforcement at the on-disk choke point (sfs.c's + createNode, shared by sfsCreateFile/sfsCreateDir/sfsCreateFifo/ + sfsCreateSymlink) -- mirrors coreTest.c's tmpfs + coreVfsMkdirAndCreateRequireParentWrite for the SFS backing store. + ktestRunAll always runs as uid 0, and vfsPermitted has no uid-0 bypass, + so removing the parent's own owner-write bit denies even its own + creator/owner. */ +KTEST(sfsCreateNodeRequiresParentWrite) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("dwparent") > 0); + KT_TRUE(t, sfsChmod(sfsLookup("dwparent"), 0555)); /* r-x, no owner-write */ + + KT_EQ(t, sfsCreateFile("dwparent/f"), -1); + KT_EQ(t, sfsCreateDir("dwparent/sub"), -1); + + KT_TRUE(t, sfsChmod(sfsLookup("dwparent"), fsModeDefaultDir)); + KT_TRUE(t, sfsCreateFile("dwparent/f") > 0); + KT_TRUE(t, sfsCreateDir("dwparent/sub") > 0); +} + +/* Same parent-write rule applied to sfsLink -- adding a new hardlink NAME + is a directory-entry creation exactly like sfsCreateFile, so it needs + write permission on the PARENT of the new name, not any permission on + the existing target being linked to. */ +KTEST(sfsLinkRequiresParentWriteOnNewName) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateFile("lwtarget.txt") > 0); + KT_TRUE(t, sfsCreateDir("lwparent") > 0); + KT_TRUE(t, sfsChmod(sfsLookup("lwparent"), 0555)); + + KT_EQ(t, sfsLink("lwtarget.txt", "lwparent/alias"), -1); + + KT_TRUE(t, sfsChmod(sfsLookup("lwparent"), fsModeDefaultDir)); + KT_TRUE(t, sfsLink("lwtarget.txt", "lwparent/alias") > 0); +} + +/* Real unlink(2)/rmdir(2) semantics at the on-disk choke point (sfs.c's + sfsRemove) -- mirrors coreTest.c's tmpfs + coreVfsUnlinkAndRmdirRequireParentWrite: removing an entry needs write + permission on the PARENT, never the entry's own mode. */ +KTEST(sfsRemoveRequiresParentWrite) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("rwparent") > 0); + KT_TRUE(t, sfsCreateFile("rwparent/f") > 0); + + KT_TRUE(t, sfsChmod(sfsLookup("rwparent"), 0555)); + KT_FALSE(t, sfsRemove("rwparent/f")); + + KT_TRUE(t, sfsChmod(sfsLookup("rwparent"), fsModeDefaultDir)); + KT_TRUE(t, sfsRemove("rwparent/f")); +} + +/* Real POSIX search permission at the on-disk choke point (sfs.c's + lookupFrom, checked on every directory ANCESTOR a path walks through) -- + mirrors coreTest.c's tmpfs coreVfsLookupRequiresAncestorExec. Removing + the exec bit on an ancestor makes every lookup THROUGH it fail even + though the target inode itself never changed. */ +KTEST(sfsLookupRequiresAncestorExec) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("xancestor") > 0); + int32 leaf = sfsCreateFile("xancestor/leaf"); + KT_TRUE(t, leaf > 0); + + KT_TRUE(t, sfsChmod(sfsLookup("xancestor"), 0600)); /* rw-, no exec */ + KT_EQ(t, sfsLookup("xancestor/leaf"), -1); + + KT_TRUE(t, sfsChmod(sfsLookup("xancestor"), fsModeDefaultDir)); + KT_EQ(t, sfsLookup("xancestor/leaf"), leaf); +} + +KTEST(sfsSymlinkResolvesToTargetFile) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + const char* msg = "reached through a link"; + uint64 len = strlen(msg); + KT_EQ(t, sfsWriteAt(f, 0, msg, len), (int64)len); + + int32 link = sfsCreateSymlink("link.txt", "real.txt"); + KT_TRUE(t, link > 0); + KT_NE(t, link, f); + + /* sfsLookup follows the symlink: it must land on the SAME inode as + looking the real file up directly, not a copy of it. */ + KT_EQ(t, sfsLookup("link.txt"), f); + KT_EQ(t, sfsTypeOf(sfsLookup("link.txt")), sfsTypeFile); + + char back[64]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(sfsLookup("link.txt"), 0, back, len), (int64)len); + KT_EQ(t, memcmp(back, msg, len), 0); +} + +KTEST(sfsReadlinkReturnsStoredTargetAndNoFollowSeesTheLinkItself) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + int32 link = sfsCreateSymlink("link.txt", "real.txt"); + KT_TRUE(t, link > 0); + + char target[32]; + memset(target, 0, sizeof(target)); + KT_TRUE(t, sfsReadlink(link, target, sizeof(target))); + KT_STREQ(t, target, "real.txt"); + + /* No-follow gives back the symlink's own inode (and its own type), + distinct from what sfsLookup (which follows) would give. */ + int32 noFollow = sfsLookupNoFollow("link.txt"); + KT_EQ(t, noFollow, link); + KT_EQ(t, sfsTypeOf(noFollow), sfsTypeSymlink); + KT_NE(t, noFollow, f); +} + +KTEST(sfsReadlinkRejectsNonSymlinkAndUndersizedBuffer) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("plain.txt"); + KT_TRUE(t, f > 0); + char buf[8]; + KT_FALSE(t, sfsReadlink(f, buf, sizeof(buf))); + + int32 link = sfsCreateSymlink("link.txt", "a target long enough to not fit"); + KT_TRUE(t, link > 0); + + char tiny[4]; + KT_FALSE(t, sfsReadlink(link, tiny, sizeof(tiny))); +} + +KTEST(sfsSymlinkToDirectoryAllowsDescendingThroughIt) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("realdir") > 0); + int32 f = sfsCreateFile("realdir/inside.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "found me", 8), 8); + + int32 link = sfsCreateSymlink("linkdir", "realdir"); + KT_TRUE(t, link > 0); + KT_EQ(t, sfsTypeOf(link), sfsTypeSymlink); + + /* The symlink is an INTERMEDIATE component here (there is more path + after it), which must still be followed even though sfsLookup's + "final component" special case does not apply. */ + int32 viaLink = sfsLookup("linkdir/inside.txt"); + KT_EQ(t, viaLink, f); + + /* Creating a new file through the symlinked directory must land in the + real directory, reachable from both paths. */ + KT_TRUE(t, sfsCreateFile("linkdir/new.txt") > 0); + KT_EQ(t, sfsLookup("realdir/new.txt"), sfsLookup("linkdir/new.txt")); +} + +KTEST(sfsDanglingSymlinkFailsLookupButReadlinkStillWorks) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 link = sfsCreateSymlink("broken.txt", "does/not/exist.txt"); + KT_TRUE(t, link > 0); + + KT_EQ(t, sfsLookup("broken.txt"), -1); + + char target[64]; + memset(target, 0, sizeof(target)); + KT_TRUE(t, sfsReadlink(link, target, sizeof(target))); + KT_STREQ(t, target, "does/not/exist.txt"); +} + +KTEST(sfsSymlinkCycleFailsCleanlyInsteadOfHanging) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateSymlink("a", "b") > 0); + KT_TRUE(t, sfsCreateSymlink("b", "a") > 0); + + /* The whole point: this must return with an error, not recurse forever + (or overflow the caller's stack) chasing a->b->a->b->... */ + KT_EQ(t, sfsLookup("a"), -1); + KT_EQ(t, sfsLookup("b"), -1); + + /* The table must still be in a usable state afterward - a failed + resolution is not the same thing as a corrupted filesystem. */ + KT_TRUE(t, sfsCreateFile("stillworks.txt") > 0); +} + +KTEST(sfsCreateSymlinkRejectsEmptyOrOversizedTarget) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_EQ(t, sfsCreateSymlink("empty.txt", ""), -1); + + char huge[sfsSymlinkTargetMax + 2]; + memset(huge, 'x', sizeof(huge) - 1); + huge[sizeof(huge) - 1] = 0; + KT_EQ(t, sfsCreateSymlink("huge.txt", huge), -1); + + /* Rejected attempts must not leave a half-created inode or directory + entry behind. */ + KT_EQ(t, sfsLookupNoFollow("empty.txt"), -1); + KT_EQ(t, sfsLookupNoFollow("huge.txt"), -1); +} + +KTEST(sfsRemoveSymlinkDoesNotAffectTarget) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "untouched", 9), 9); + KT_TRUE(t, sfsCreateSymlink("link.txt", "real.txt") > 0); + + KT_TRUE(t, sfsRemove("link.txt")); + KT_EQ(t, sfsLookupNoFollow("link.txt"), -1); + + /* The target must survive with its data intact - unlink of a symlink + must never recurse into what it points at. */ + int32 stillThere = sfsLookup("real.txt"); + KT_EQ(t, stillThere, f); + char back[16]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(stillThere, 0, back, 9), 9); + KT_EQ(t, memcmp(back, "untouched", 9), 0); +} + +KTEST(sfsSymlinkPersistsAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "on disk for real", 17), 17); + int32 link = sfsCreateSymlink("link.txt", "real.txt"); + KT_TRUE(t, link > 0); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + KT_EQ(t, sfsLookup("link.txt"), f); + KT_EQ(t, sfsLookupNoFollow("link.txt"), link); + KT_EQ(t, sfsTypeOf(link), sfsTypeSymlink); + + char target[32]; + memset(target, 0, sizeof(target)); + KT_TRUE(t, sfsReadlink(link, target, sizeof(target))); + KT_STREQ(t, target, "real.txt"); +} + +KTEST(sfsFileGrowsThroughDoubleIndirectBlock) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("huge.bin"); + KT_TRUE(t, f > 0); + + /* sfsDirectBlocks (10) + sfsPtrsPerBlock (128) = 138 logical blocks are + reachable through direct + single indirect addressing; block 138 + onward can only be reached through the double indirect block added + in this wave. Starting a few blocks before that boundary and writing + past it exercises the crossing and the double-indirect-only region + in one write (sfsWriteAt zero-fills 0..134 first, all still within + single-indirect range, before this pattern lands on 135..144). */ + #define sfsDoubleSpanBlocks 10 + #define sfsDoubleStartBlock 135 + #define sfsDoubleSpan (sfsDoubleSpanBlocks * sfsBlockSize) + uint8* pattern = (uint8*)kmalloc(sfsDoubleSpan); + uint8* verify = (uint8*)kmalloc(sfsDoubleSpan); + KT_NOTNULL(t, pattern); + KT_NOTNULL(t, verify); + if (!pattern || !verify) { + kfree(pattern); + kfree(verify); + return; + } + for (int i = 0; i < sfsDoubleSpan; i++) + pattern[i] = (uint8)(i * 17 + 3); + + uint64 offset = (uint64)sfsDoubleStartBlock * sfsBlockSize; + KT_EQ(t, sfsWriteAt(f, offset, pattern, sfsDoubleSpan), sfsDoubleSpan); + KT_EQ(t, sfsSizeOf(f), offset + sfsDoubleSpan); + + memset(verify, 0, sfsDoubleSpan); + KT_EQ(t, sfsReadAt(f, offset, verify, sfsDoubleSpan), sfsDoubleSpan); + KT_EQ(t, memcmp(verify, pattern, sfsDoubleSpan), 0); + + /* Patch a window straddling the single-indirect/double-indirect + boundary itself (logical block 137/138) and confirm only that slice + changed -- the same "boundary patch" shape as + sfsFileGrowsThroughIndirectBlock, one level deeper. */ + uint8 patch[48]; + memset(patch, 0xCC, sizeof(patch)); + uint64 patchOffset = 138 * sfsBlockSize - 24; + KT_EQ(t, sfsWriteAt(f, patchOffset, patch, sizeof(patch)), (int64)sizeof(patch)); + + KT_EQ(t, sfsReadAt(f, offset, verify, sfsDoubleSpan), sfsDoubleSpan); + uint64 patchInSpan = patchOffset - offset; + KT_EQ(t, memcmp(verify + patchInSpan, patch, sizeof(patch)), 0); + KT_EQ(t, memcmp(verify, pattern, patchInSpan), 0); + + kfree(pattern); + kfree(verify); + #undef sfsDoubleSpan + #undef sfsDoubleStartBlock + #undef sfsDoubleSpanBlocks +} + +KTEST(sfsTruncateFreesDoubleIndirectBlocks) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("hugeshrink.bin"); + KT_TRUE(t, f > 0); + + /* Baseline is taken AFTER creating the file, not before: creating the + very first entry in a previously-empty root directory grows the + root's own data block permanently (it is a directory entry, not file + content, and stays even after truncating the FILE back to empty) -- + that is correct, unrelated bookkeeping this test must not conflate + with whether truncating hugeshrink.bin's own content gives its + blocks back. */ + uint64 baseline = sfsFreeDataBlocks(); + KT_GT(t, baseline, 0); + + #define sfsShrinkSpanBlocks 8 + #define sfsShrinkStartBlock 136 + #define sfsShrinkSpan (sfsShrinkSpanBlocks * sfsBlockSize) + uint8* chunk = (uint8*)kmalloc(sfsShrinkSpan); + KT_NOTNULL(t, chunk); + if (!chunk) + return; + memset(chunk, 0x91, sfsShrinkSpan); + uint64 offset = (uint64)sfsShrinkStartBlock * sfsBlockSize; + KT_EQ(t, sfsWriteAt(f, offset, chunk, sfsShrinkSpan), sfsShrinkSpan); + kfree(chunk); + #undef sfsShrinkSpan + #undef sfsShrinkStartBlock + #undef sfsShrinkSpanBlocks + + /* This file now owns: 10 direct + 1 single-indirect holder + 128 + single-indirect data blocks + 1 double-indirect holder + 1 inner + indirect holder + a handful of double-indirect data blocks. Every + one of those must come back. */ + KT_LT(t, sfsFreeDataBlocks(), baseline); + + KT_TRUE(t, sfsTruncate(f, 0)); + KT_EQ(t, sfsSizeOf(f), 0); + KT_EQU(t, sfsFreeDataBlocks(), baseline); + + /* And the inode itself must still be usable afterward, not left in some + half-truncated state. */ + KT_EQ(t, sfsWriteAt(f, 0, "reused", 6), 6); + KT_EQ(t, sfsSizeOf(f), 6); +} + +KTEST(sfsFileGrowsThroughTripleIndirectBlock) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("huger.bin"); + KT_TRUE(t, f > 0); + + /* sfsDirectBlocks (10) + sfsPtrsPerBlock (128) + sfsPtrsPerBlock^2 + (16384) = 16522 logical blocks are reachable through direct + single + + double indirect addressing; block 16522 onward can only be reached + through the triple indirect block added in this wave. Physically + reaching that boundary with real writes from offset 0 would mean + sfsWriteAt zero-filling ~8 MiB of real leading blocks first (its own + "no sparse holes" contract, see its doc comment) -- correct, but far + too slow for a headless boot-time gate. sfsTruncate's own grow path + is a genuine sparse hole instead (see sfsTruncateInode: growing only + ever rewrites the size field, no block is touched), the same + lseek-past-EOF-then-write shape a real filesystem's holes have -- so + pre-growing with it reaches the boundary for free, in O(1), then a + real write of a handful of blocks exercises the actual double/triple + crossing without paying for the untouched blocks in between. */ + #define sfsTripleBoundaryBlock 16522 + #define sfsTripleSpanBlocks 12 + #define sfsTripleStartBlock (sfsTripleBoundaryBlock - 6) + #define sfsTripleSpan (sfsTripleSpanBlocks * sfsBlockSize) + uint64 startOffset = (uint64)sfsTripleStartBlock * sfsBlockSize; + KT_TRUE(t, sfsTruncate(f, startOffset)); + KT_EQ(t, sfsSizeOf(f), startOffset); + + uint8* pattern = (uint8*)kmalloc(sfsTripleSpan); + uint8* verify = (uint8*)kmalloc(sfsTripleSpan); + KT_NOTNULL(t, pattern); + KT_NOTNULL(t, verify); + if (!pattern || !verify) { + kfree(pattern); + kfree(verify); + return; + } + for (int i = 0; i < sfsTripleSpan; i++) + pattern[i] = (uint8)(i * 29 + 7); + + /* Six blocks land in the double-indirect region (16516..16521), the + other six only exist through the new triple-indirect block + (16522..16527) -- one write straddles both. */ + KT_EQ(t, sfsWriteAt(f, startOffset, pattern, sfsTripleSpan), sfsTripleSpan); + KT_EQ(t, sfsSizeOf(f), startOffset + sfsTripleSpan); + + memset(verify, 0, sfsTripleSpan); + KT_EQ(t, sfsReadAt(f, startOffset, verify, sfsTripleSpan), sfsTripleSpan); + KT_EQ(t, memcmp(verify, pattern, sfsTripleSpan), 0); + + /* Patch a window straddling the double-indirect/triple-indirect + boundary itself (logical block 16521/16522) -- same "boundary patch" + shape as sfsFileGrowsThroughDoubleIndirectBlock, one level deeper. */ + uint8 patch[48]; + memset(patch, 0xAB, sizeof(patch)); + uint64 patchOffset = (uint64)sfsTripleBoundaryBlock * sfsBlockSize - 24; + KT_EQ(t, sfsWriteAt(f, patchOffset, patch, sizeof(patch)), (int64)sizeof(patch)); + + KT_EQ(t, sfsReadAt(f, startOffset, verify, sfsTripleSpan), sfsTripleSpan); + uint64 patchInSpan = patchOffset - startOffset; + KT_EQ(t, memcmp(verify + patchInSpan, patch, sizeof(patch)), 0); + KT_EQ(t, memcmp(verify, pattern, patchInSpan), 0); + + kfree(pattern); + kfree(verify); + #undef sfsTripleSpan + #undef sfsTripleStartBlock + #undef sfsTripleSpanBlocks + #undef sfsTripleBoundaryBlock +} + +KTEST(sfsTruncateFreesTripleIndirectBlocks) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("hugershrink.bin"); + KT_TRUE(t, f > 0); + + /* Baseline taken after creating the file, same reasoning as + sfsTruncateFreesDoubleIndirectBlocks: the root directory's own data + block is unrelated bookkeeping this test must not conflate with + hugershrink.bin's own content. */ + uint64 baseline = sfsFreeDataBlocks(); + KT_GT(t, baseline, 0); + + /* Grow straight to the triple-indirect boundary via sfsTruncate (O(1), + a genuine sparse hole -- see sfsFileGrowsThroughTripleIndirectBlock + above) and write ENTIRELY inside the triple-indirect region, never + touching a single/double-indirect block. That keeps inode->indirect + and inode->doubleIndirect at 0, so the shrink-to-0 below only ever + does a cheap in-memory "still unallocated" check across the whole + single+double logical range instead of re-reading real holder blocks + for it -- proportional to the actual work, not to how far into the + address space the file happens to reach. */ + #define sfsTripleBoundaryBlock 16522 + #define sfsShrink2SpanBlocks 8 + #define sfsShrink2Span (sfsShrink2SpanBlocks * sfsBlockSize) + uint64 offset = (uint64)sfsTripleBoundaryBlock * sfsBlockSize; + KT_TRUE(t, sfsTruncate(f, offset)); + + uint8* chunk = (uint8*)kmalloc(sfsShrink2Span); + KT_NOTNULL(t, chunk); + if (!chunk) + return; + memset(chunk, 0x62, sfsShrink2Span); + KT_EQ(t, sfsWriteAt(f, offset, chunk, sfsShrink2Span), sfsShrink2Span); + kfree(chunk); + #undef sfsShrink2Span + #undef sfsShrink2SpanBlocks + #undef sfsTripleBoundaryBlock + + /* This file now owns: 1 triple-indirect holder + 1 mid holder + 1 + leaf-pointer holder + a handful of triple-indirect data blocks. + Every one of those must come back. */ + KT_LT(t, sfsFreeDataBlocks(), baseline); + + KT_TRUE(t, sfsTruncate(f, 0)); + KT_EQ(t, sfsSizeOf(f), 0); + KT_EQU(t, sfsFreeDataBlocks(), baseline); + + /* And the inode itself must still be usable afterward. */ + KT_EQ(t, sfsWriteAt(f, 0, "reused", 6), 6); + KT_EQ(t, sfsSizeOf(f), 6); +} + +KTEST(sfsHardlinkSharesInodeAndData) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("original.txt"); + KT_TRUE(t, f > 0); + const char* msg = "shared bytes"; + uint64 len = strlen(msg); + KT_EQ(t, sfsWriteAt(f, 0, msg, len), (int64)len); + KT_EQ(t, sfsLinkCountOf(f), 1); + + int32 linked = sfsLink("original.txt", "alias.txt"); + KT_EQ(t, linked, f); + KT_EQ(t, sfsLinkCountOf(f), 2); + KT_EQ(t, sfsLookup("alias.txt"), f); + + /* A write through the alias must be visible through the original name: + they are not two files that happen to match, they are the same + inode. */ + KT_EQ(t, sfsWriteAt(sfsLookup("alias.txt"), len, " too", 4), 4); + char back[32]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(f, 0, back, len + 4), (int64)(len + 4)); + KT_EQ(t, memcmp(back, "shared bytes too", len + 4), 0); +} + +KTEST(sfsHardlinkRejectsDirectoriesAndBadArguments) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + KT_TRUE(t, sfsCreateDir("adir") > 0); + KT_EQ(t, sfsLink("adir", "adiralias"), -1); + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsLink("does/not/exist.txt", "alias.txt"), -1); + + KT_TRUE(t, sfsCreateFile("taken.txt") > 0); + KT_EQ(t, sfsLink("real.txt", "taken.txt"), -1); + + /* None of the rejected attempts may have left a stray link count or + directory entry behind. */ + KT_EQ(t, sfsLinkCountOf(f), 1); + KT_EQ(t, sfsLookup("alias.txt"), -1); +} + +KTEST(sfsRemoveOneHardlinkKeepsOtherAlive) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("first.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "durable", 7), 7); + KT_TRUE(t, sfsLink("first.txt", "second.txt") > 0); + + KT_TRUE(t, sfsRemove("first.txt")); + KT_EQ(t, sfsLookup("first.txt"), -1); + + int32 viaSecond = sfsLookup("second.txt"); + KT_EQ(t, viaSecond, f); + KT_EQ(t, sfsLinkCountOf(viaSecond), 1); + + char back[8]; + KT_EQ(t, sfsReadAt(viaSecond, 0, back, 7), 7); + KT_EQ(t, memcmp(back, "durable", 7), 0); +} + +KTEST(sfsRemoveLastHardlinkFreesInodeAndBlocks) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + uint64 baseline = sfsFreeDataBlocks(); + + int32 f = sfsCreateFile("only.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "temporary", 9), 9); + KT_TRUE(t, sfsLink("only.txt", "also.txt") > 0); + KT_LT(t, sfsFreeDataBlocks(), baseline); + + KT_TRUE(t, sfsRemove("only.txt")); + KT_NE(t, sfsLookup("also.txt"), -1); /* one link left: data still lives */ + KT_TRUE(t, sfsRemove("also.txt")); + KT_EQ(t, sfsLookup("also.txt"), -1); + + KT_EQU(t, sfsFreeDataBlocks(), baseline); + + /* The inode slot must be reusable, not permanently burned. */ + int32 again = sfsCreateFile("only.txt"); + KT_TRUE(t, again > 0); + KT_EQ(t, sfsSizeOf(again), 0); +} + +KTEST(sfsHardlinkThroughSymlinkTargetsRealInode) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("real.txt"); + KT_TRUE(t, f > 0); + KT_TRUE(t, sfsCreateSymlink("link.txt", "real.txt") > 0); + + /* Linking through a symlink must attach the new name to whatever the + symlink resolves to, not to the symlink inode itself (matches + link()'s default follow-existingPath semantics, documented on + sfsLink). */ + int32 linked = sfsLink("link.txt", "alias.txt"); + KT_EQ(t, linked, f); + KT_EQ(t, sfsLinkCountOf(f), 2); + KT_EQ(t, sfsTypeOf(sfsLookup("alias.txt")), sfsTypeFile); +} + +KTEST(sfsHardlinkPersistsAcrossUnmountRemount) +{ + if (!KT_TRUE(t, sfsFormat())) + return; + + int32 f = sfsCreateFile("orig.txt"); + KT_TRUE(t, f > 0); + KT_EQ(t, sfsWriteAt(f, 0, "persisted", 9), 9); + KT_TRUE(t, sfsLink("orig.txt", "alias.txt") > 0); + + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + int32 viaOrig = sfsLookup("orig.txt"); + int32 viaAlias = sfsLookup("alias.txt"); + KT_EQ(t, viaOrig, f); + KT_EQ(t, viaOrig, viaAlias); + KT_EQ(t, sfsLinkCountOf(f), 2); + + char back[16]; + memset(back, 0, sizeof(back)); + KT_EQ(t, sfsReadAt(viaAlias, 0, back, 9), 9); + KT_EQ(t, memcmp(back, "persisted", 9), 0); +} + +/* Leave the filesystem in a clean, empty, formatted state for any code that + runs after the ktest battery (e.g. a userspace program exercising /mnt + through the real syscall path). */ +KTEST(sfsFormatLeavesCleanStateForLaterUse) +{ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, sfsLookup(""), sfsRootInode); + + char name[sfsNameMax + 1]; + uint32 type; + KT_FALSE(t, sfsReaddirAt(sfsRootInode, 0, name, sizeof(name), &type)); +} diff --git a/src/shell.c b/src/shell.c index 0089257..e778c17 100644 --- a/src/shell.c +++ b/src/shell.c @@ -1,6 +1,7 @@ #include "shell.h" #include "console.h" #include "keyboard.h" +#include "ports.h" #include "vga.h" #include "pmm.h" #include "heap.h" @@ -9,11 +10,864 @@ #include "string.h" #include "vfs.h" #include "fs.h" +#include "jobctl.h" +#include "klib.h" +#include "pipe.h" +#include "rtc.h" #define lineCapacity 128 #define pathCapacity 256 +#define binPathCapacity 272 -static char cwd[pathCapacity] = "/"; +/* Real shell "command could not even be started" exit status (bad + redirection, wildcard overflow, unknown binary, ...) -- the same 127 + real shells report for "command not found", reused here for every + pre-spawn failure in runProgram/runPipelineStages so shell.c's new + `&&`/`||` chaining (execute()) always sees a nonzero, "this failed" + status for them, exactly like a real spawned process's own nonzero + exit code would. */ +#define shellExecNotFound 127 + +/* The shell used to own the only "current directory" concept in this whole + kernel, a single static local shared by nothing else -- sched.h's new + Thread.cwd (per-thread, real chdir(2)/getcwd(2) semantics, vfs.h's + vfsChdir/vfsGetcwd/vfsResolvePath) replaces it: the shell is a plain + kernel thread like any other (see shellRun's own comment below), so it + simply becomes one more caller of the same real subsystem instead of + keeping a second, disconnected notion of "cwd" in parallel. This also + means a job this shell spawns now genuinely inherits whatever directory + the shell itself is currently in (initialCwd, sched.c) -- true before + this fala only by accident of both defaulting to "/", never actually + wired together. */ + +/* + * Real job control for the interactive shell (jobctl.h does the actual + * spawning/terminal-ownership work): any command line that is not one of + * the built-ins above is tried as /bin/ instead of just being + * rejected outright, with the rest of the line tokenized into a real argv + * exactly like a normal shell does. A trailing lone "&" token backgrounds + * it (jobctlRunBackground, no terminal, returns immediately) instead of + * running it in the foreground (jobctlSpawnForeground + jobctlWaitForeground, + * which blocks until it exits with the terminal genuinely handed to it -- + * see jobctl.c). + */ +#define maxBackgroundJobs 8 + +typedef struct BackgroundJob { + uint32 pid; + char name[32]; + bool used; +} BackgroundJob; + +static BackgroundJob backgroundJobs[maxBackgroundJobs]; +static uint32 lastSpawnedPid = threadInvalidId; + +/* Real shell `$?` -- the exit status of the most recently completed + command-or-pipeline-or-builtin, persisting across SEPARATELY typed + lines (unlike the `&&`/`||` chaining `status` local in execute() below, + which only ever lives for the duration of one typed line's own + segments). Initialized to 0, matching a real shell's own `$?` before + anything has ever run. Updated in exactly one place (execute(), right + after every executeSingle() call, chained or not) and read in exactly + one place (shellExpandToken, via shellIsStatusVarToken) -- the same + "shell is a plain kernel thread, needs no syscall to reach its own + state" shape doExport/doUnset/changeDir already use for their own + thread-local state, just kept in a file-scope static here since $? is + a shell-language special parameter, not a real environment variable + (real shells never let you `export $?` either). */ +static int64 lastStatus = 0; + +uint32 shellLastSpawnedPid(void) +{ + return lastSpawnedPid; +} + +/* Opportunistically reaps any of THIS shell's own children that have + already exited (threadJoinAnyChild, sched.h -- the same non-blocking + waitpid(-1)-shaped scan user/waitanytest.c already proves) and prints + what finished. Only ever finds background jobs here: a foreground job is + already fully joined-and-reaped by jobctlWaitForeground itself before + that call ever returns, so by the time control comes back to this + function there is nothing left over from it to double-reap. */ +static void reapFinishedBackgroundJobs(void) +{ + uint32 deadId; + int64 code; + while (threadJoinAnyChild(currentThreadId(), &deadId, &code) == waitScanFoundDead) { + for (int i = 0; i < maxBackgroundJobs; i++) { + if (backgroundJobs[i].used && backgroundJobs[i].pid == deadId) { + kprintf("[%u] %s done, exit code %ld\n", deadId, backgroundJobs[i].name, code); + backgroundJobs[i].used = false; + break; + } + } + } +} + +static void listBackgroundJobs(void) +{ + bool any = false; + for (int i = 0; i < maxBackgroundJobs; i++) { + if (backgroundJobs[i].used) { + kprintf("[%u] running %s\n", backgroundJobs[i].pid, backgroundJobs[i].name); + any = true; + } + } + if (!any) + kprintf("no background jobs\n"); +} + +int shellTokenize(char* text, char* tokens[], int maxTokens) +{ + int count = 0; + char* cursor = text; + while (*cursor && count < maxTokens) { + while (*cursor == ' ') + cursor++; + if (*cursor == 0) + break; + tokens[count++] = cursor; + while (*cursor && *cursor != ' ') + cursor++; + if (*cursor == ' ') { + *cursor = 0; + cursor++; + } + } + return count; +} + +int shellSplitPipelineStages(char* line, char* stages[], int maxStages) +{ + /* First pass: count top-level '|' occurrences without mutating + anything, so a too-many-stages line can be rejected with `line` + completely untouched -- the same "validate before mutating" shape + shellExtractRedirects already uses for its own dangling-operator + check. */ + int pipeCount = 0; + for (const char* cursor = line; *cursor; cursor++) + if (*cursor == '|') + pipeCount++; + + if (pipeCount == 0) + return 0; + if (pipeCount + 1 > maxStages) + return -1; + + int count = 0; + char* stageStart = line; + char* cursor = line; + while (true) { + bool atPipe = (*cursor == '|'); + bool atEnd = (*cursor == 0); + if (atPipe || atEnd) { + char* stageEnd = cursor; + while (stageEnd > stageStart && stageEnd[-1] == ' ') + stageEnd--; + *stageEnd = 0; + + stages[count++] = stageStart; + + if (atEnd) + break; + + *cursor = 0; + stageStart = cursor + 1; + while (*stageStart == ' ') + stageStart++; + } + cursor++; + } + + return count; +} + +int shellSplitAndOr(char* line, char* segments[], ShellAndOrOp andOrOps[], int maxSegments) +{ + /* Same "count first, mutate nothing, reject up front" shape + shellSplitPipelineStages uses for its own analogous limit -- + matching two-character operators one at a time (never overlapping: + stepping by 2 on a match means "&&&" is one "&&" followed by a lone + '&' left inside a segment, not two overlapping matches). */ + int opCount = 0; + for (const char* cursor = line; *cursor; ) { + if ((cursor[0] == '&' && cursor[1] == '&') || (cursor[0] == '|' && cursor[1] == '|')) { + opCount++; + cursor += 2; + } else { + cursor++; + } + } + + if (opCount == 0) + return 0; + if (opCount + 1 > maxSegments) + return -1; + + int count = 0; + char* segStart = line; + char* cursor = line; + while (true) { + bool atAnd = (cursor[0] == '&' && cursor[1] == '&'); + bool atOr = (cursor[0] == '|' && cursor[1] == '|'); + bool atEnd = (*cursor == 0); + if (atAnd || atOr || atEnd) { + char* segEnd = cursor; + while (segEnd > segStart && segEnd[-1] == ' ') + segEnd--; + *segEnd = 0; + + segments[count++] = segStart; + + if (atEnd) + break; + + andOrOps[count - 1] = atAnd ? shellAndOrAnd : shellAndOrOr; + + cursor += 2; + segStart = cursor; + while (*segStart == ' ') + segStart++; + cursor = segStart; + continue; + } + cursor++; + } + + return count; +} + +bool shellExtractRedirects(char** argv, int* argc, ShellRedirect* out) +{ + int count = *argc; + + /* Validate BEFORE mutating anything: a dangling operator (no filename + token after it) must leave argv and argc completely untouched, + exactly like shellSplitPipelineStages leaves `line` untouched when it + finds no pipe -- the caller needs to tell "malformed, nothing + changed" apart from "changed, here is the result" without having to + snapshot argv itself first. */ + for (int i = 0; i < count; i++) { + bool isOperator = strcmp(argv[i], "<") == 0 || strcmp(argv[i], "<<<") == 0 || + strcmp(argv[i], ">") == 0 || strcmp(argv[i], ">>") == 0 || + strcmp(argv[i], "2>") == 0 || strcmp(argv[i], "2>>") == 0; + if (isOperator && i + 1 >= count) + return false; + } + + out->inPath = nullptr; + out->outPath = nullptr; + out->appendOut = false; + out->errPath = nullptr; + out->appendErr = false; + out->hereString = nullptr; + + int writeIndex = 0; + for (int i = 0; i < count; i++) { + if (strcmp(argv[i], "<") == 0) { + out->inPath = argv[++i]; + out->hereString = nullptr; + } else if (strcmp(argv[i], "<<<") == 0) { + out->hereString = argv[++i]; + out->inPath = nullptr; + } else if (strcmp(argv[i], ">") == 0) { + out->outPath = argv[++i]; + out->appendOut = false; + } else if (strcmp(argv[i], ">>") == 0) { + out->outPath = argv[++i]; + out->appendOut = true; + } else if (strcmp(argv[i], "2>") == 0) { + out->errPath = argv[++i]; + out->appendErr = false; + } else if (strcmp(argv[i], "2>>") == 0) { + out->errPath = argv[++i]; + out->appendErr = true; + } else { + argv[writeIndex++] = argv[i]; + } + } + *argc = writeIndex; + return true; +} + +bool shellParseVarRef(const char* token, char* nameOut, int nameCap) +{ + if (token[0] != '$' || token[1] == 0) + return false; + + int len = 0; + for (const char* c = token + 1; *c; c++) { + bool isNameChar = (*c >= 'a' && *c <= 'z') || (*c >= 'A' && *c <= 'Z') || + (*c >= '0' && *c <= '9') || *c == '_'; + if (!isNameChar) + return false; + len++; + } + if (len + 1 > nameCap) + return false; + + memcpy(nameOut, token + 1, (uint64)len); + nameOut[len] = 0; + return true; +} + +bool shellIsStatusVarToken(const char* token) +{ + return strcmp(token, "$?") == 0; +} + +/* Shared expansion rule behind both shellExpandArgv (per-argv-position, an + entire spawned program's argument list) and shellExpandRedirect + (per-redirect-target) below: `$?` (shellIsStatusVarToken, this shell's + own most-recently-completed status, lastStatus above) is checked FIRST + since shellParseVarRef can never match it anyway (see + shellIsStatusVarToken's own doc comment in shell.h), then a real + `$NAME` environment lookup (shellParseVarRef + currentThreadGetEnv, an + unset name expanding to an empty string like a real shell's own + default). Anything that is neither is returned completely untouched, + still pointing into whatever buffer the caller originally handed it -- + `storage` is only ever written to, and only ever the return value, when + an expansion actually happened. */ +static const char* shellExpandToken(const char* token, char* storage, int storageCap) +{ + if (shellIsStatusVarToken(token)) { + snprintf(storage, (uint64)storageCap, "%ld", lastStatus); + return storage; + } + + char name[envEntryMax]; + if (shellParseVarRef(token, name, sizeof(name))) { + const char* value = currentThreadGetEnv(name); + snprintf(storage, (uint64)storageCap, "%s", value ? value : ""); + return storage; + } + + return token; +} + +/* Resolves every `$?`/`$NAME` token in argv[0..argc) via shellExpandToken + above, overwriting argv[i] with a pointer into storage[i] whenever one + expands -- necessary because argv[i] itself is a pointer into the + shared line buffer with only enough room for the ORIGINAL (unexpanded) + token, never enough for whatever length the variable's value happens to + be. */ +static void shellExpandArgv(char** argv, int argc, char storage[][envEntryMax]) +{ + for (int i = 0; i < argc; i++) + argv[i] = (char*)shellExpandToken(argv[i], storage[i], envEntryMax); +} + +/* Expands `$?`/`$NAME` (shellExpandToken above) in each of ShellRedirect's + own target fields (inPath/outPath/errPath/hereString) in place -- these + come straight from shellExtractRedirects, which only ever pulls raw + tokens out of argv with no expansion of its own, so a redirect target or + here-string text written as `$NAME`/`$?` was previously left completely + unexpanded (unlike every other argv position, which shellExpandArgv + already covered) -- an honestly-scoped gap flagged when `$NAME` + expansion was first added, closed here. Each field gets its own + dedicated storage slot since all four could need to be expanded on the + SAME command line at once (e.g. `cmd < $IN > $OUT 2> $ERR`). */ +static void shellExpandRedirect(ShellRedirect* redirect, char storage[4][envEntryMax]) +{ + if (redirect->inPath) + redirect->inPath = shellExpandToken(redirect->inPath, storage[0], envEntryMax); + if (redirect->outPath) + redirect->outPath = shellExpandToken(redirect->outPath, storage[1], envEntryMax); + if (redirect->errPath) + redirect->errPath = shellExpandToken(redirect->errPath, storage[2], envEntryMax); + if (redirect->hereString) + redirect->hereString = shellExpandToken(redirect->hereString, storage[3], envEntryMax); +} + +bool shellFindCommandSubst(const char* line, int* startOut, int* endOut, char* cmdOut, int cmdCap) +{ + const char* open = strstr(line, "$("); + if (!open) { + *startOut = -1; + return true; + } + + const char* close = strchr(open + 2, ')'); + if (!close) + return false; + + int len = (int)(close - (open + 2)); + if (len > cmdCap - 1) + len = cmdCap - 1; + memcpy(cmdOut, open + 2, (uint64)len); + cmdOut[len] = 0; + + *startOut = (int)(open - line); + *endOut = (int)(close - line) + 1; + return true; +} + +bool shellGlobMatch(const char* pattern, const char* name) +{ + const char* p = pattern; + const char* n = name; + const char* starPattern = nullptr; + const char* starName = nullptr; + + while (*n) { + if (*p == '?' || *p == *n) { + p++; + n++; + } else if (*p == '*') { + starPattern = p++; + starName = n; + } else if (starPattern) { + p = starPattern + 1; + n = ++starName; + } else { + return false; + } + } + while (*p == '*') + p++; + return *p == 0; +} + +bool shellTokenHasGlob(const char* token) +{ + for (const char* c = token; *c; c++) + if (*c == '*' || *c == '?') + return true; + return false; +} + +void shellSplitGlobToken(const char* token, char* dirOut, int dirCap, char* patternOut, int patternCap) +{ + const char* lastSlash = nullptr; + for (const char* c = token; *c; c++) + if (*c == '/') + lastSlash = c; + + if (!lastSlash) { + if (dirCap > 0) + dirOut[0] = 0; + snprintf(patternOut, (uint64)patternCap, "%s", token); + return; + } + + int dirLen = (int)(lastSlash - token) + 1; + if (dirLen >= dirCap) + dirLen = dirCap - 1; + memcpy(dirOut, token, (uint64)dirLen); + dirOut[dirLen] = 0; + + snprintf(patternOut, (uint64)patternCap, "%s", lastSlash + 1); +} + +/* Storage capacity for one shellExpandGlobs match's resolved path -- a + smaller, dedicated constant rather than reusing pathCapacity, the same + "arbitrary but documented" fixed-size trade-off shellExpandArgv's own + envEntryMax already makes for a $NAME value, since this buffer gets + multiplied by shellMaxArgs (and, in runPipelineStages, by + shellMaxPipelineStages again on top of that) -- see shellExpandGlobs' + own doc comment for why that multiplication matters for a 16KiB kernel + thread stack (sched.c's threadStackSize). */ +#define shellGlobPathCapacity 64 + +/* Expands any argv[i] (i in [0,argc)) containing a glob metacharacter into + the real directory entries matching it (VFS child-list order, vfsChild/ + vfsNext -- the same traversal listDir's own `ls` builtin above already + uses), replacing that one token with however many real paths matched, + written into `storage` (one shellGlobPathCapacity-byte slot per FINAL + argv position, not per original token) and pointed at from `argv` in + place. A token with no glob character is passed through completely + unchanged. A token that DOES contain a glob character but matches NO + real file is ALSO passed through completely unchanged, as its own + literal single token (bash's own default nullglob=off behavior) -- so a + program that tries to open a nonexistent glob gets the same "no such + file" error a real shell gives for an unmatched pattern, not a silently + vanished argument. Only the FINAL path component may contain glob syntax + (shellSplitGlobToken's own doc comment) -- `/tmp/gd/ *.txt` is supported, + `/tmp/ * /file.txt` is not. The directory part is resolved through + vfsResolvePath first, so a bare `*.txt` (no '/' at all) globs the + CALLING thread's own current directory, same cwd-awareness every other + path-taking builtin in this file already gets. Deliberately called by + runProgram/runPipelineStages AFTER shellExtractRedirects has already + pulled out redirection operators and their targets, and after the + trailing "&" background marker has already been stripped -- so a glob + character can never appear in a redirect target or be mistaken for + something other than a real program argument; an honestly-scoped + limitation matching this file's existing "no stage may be a builtin"/ + redirect-target-scope documented choices elsewhere. Returns the new argc, + or -1 (argv left completely untouched) if expansion would need more than + `maxArgc` slots -- the same "malformed/too-large input, caller reports an + error instead of silently truncating" shape shellSplitPipelineStages + already uses for "too many stages". */ +static int shellExpandGlobs(char** argv, int argc, int maxArgc, char storage[][shellGlobPathCapacity]) +{ + char* result[shellMaxArgs]; + int resultCount = 0; + + for (int i = 0; i < argc; i++) { + if (!shellTokenHasGlob(argv[i])) { + if (resultCount >= maxArgc) + return -1; + result[resultCount++] = argv[i]; + continue; + } + + char dirPart[pathCapacity]; + char patternPart[pathCapacity]; + shellSplitGlobToken(argv[i], dirPart, sizeof(dirPart), patternPart, sizeof(patternPart)); + + char resolvedDir[pathCapacity]; + vfsResolvePath(dirPart, resolvedDir, sizeof(resolvedDir)); + + int matchStart = resultCount; + VfsNode* dirNode = vfsLookup(resolvedDir); + if (dirNode && vfsIsDir(dirNode)) { + for (VfsNode* child = vfsChild(dirNode); child; child = vfsNext(child)) { + if (!shellGlobMatch(patternPart, vfsName(child))) + continue; + if (resultCount >= maxArgc) + return -1; + + int pos = snprintf(storage[resultCount], shellGlobPathCapacity, "%s", resolvedDir); + if (pos < 0) + pos = 0; + if (pos >= shellGlobPathCapacity) + pos = shellGlobPathCapacity - 1; + snprintf(storage[resultCount] + pos, (uint64)(shellGlobPathCapacity - pos), "%s", + vfsName(child)); + + result[resultCount] = storage[resultCount]; + resultCount++; + } + } + + if (resultCount == matchStart) { + if (resultCount >= maxArgc) + return -1; + result[resultCount++] = argv[i]; + } + } + + for (int i = 0; i < resultCount; i++) + argv[i] = result[i]; + return resultCount; +} + +/* Fallback for a command that matched none of the built-ins in execute() + below: `line` is already the first word (NUL-terminated in place by + execute()'s own split) and `argument` is the rest of the original typed + line, still starting right after that NUL in the very same buffer. */ +/* Returns the real exit status of whatever this line ran (0/nonzero from + the spawned process itself for a foreground command, shellExecNotFound + above for anything that never even got to spawn, or 0 immediately for a + backgrounded job -- matching real `cmd &`'s own immediate-success exit + status, never the job's own eventual, still-unknown-at-this-point exit + code) -- feeds shell.c's new `&&`/`||` chaining (execute()) the same way + listDir/catFile/makeDir/changeDir/doExport/doUnset's own int returns + already do for builtins. */ +static int64 runProgram(char* line, char* argument) +{ + char* argv[shellMaxArgs]; + int argc = 0; + argv[argc++] = line; + argc += shellTokenize(argument, &argv[argc], shellMaxArgs - argc); + + /* argv[0] (== line, the program name) is deliberately left out of + expansion: runProgram resolves the binary to spawn from `line` + itself, never from argv[0], so expanding it would let argv[0] show + the child a different name than the path actually loaded. */ + char expandedArgv[shellMaxArgs][envEntryMax]; + shellExpandArgv(argv + 1, argc - 1, expandedArgv + 1); + + bool background = (argc > 1 && strcmp(argv[argc - 1], "&") == 0); + if (background) + argc--; + + /* `< IN`/`<<< TEXT`/`> OUT`/`>> OUT`/`2> ERR`/`2>> ERR` are stripped out + of argv here, AFTER the trailing "&" check above (so "&" itself is + never mistaken for a redirection target) and BEFORE the program ever + sees argv -- exactly like a real shell, which never hands its own + redirection operators to the child as arguments. */ + ShellRedirect redirect; + if (!shellExtractRedirects(argv, &argc, &redirect)) { + kprintf("%s: redirection operator with no filename\n", line); + return shellExecNotFound; + } + char redirectStorage[4][envEntryMax]; + shellExpandRedirect(&redirect, redirectStorage); + + /* Wildcard expansion runs LAST, on the real argv the child program will + actually receive -- after redirects/here-string/background have all + already been pulled out, so a glob character can only ever expand a + genuine program argument (see shellExpandGlobs' own doc comment for + why). argv[0] (the program name) is excluded, same reasoning as + shellExpandArgv's own exclusion just above. */ + char globStorage[shellMaxArgs][shellGlobPathCapacity]; + int expandedArgc = shellExpandGlobs(argv + 1, argc - 1, shellMaxArgs - 1, globStorage); + if (expandedArgc < 0) { + kprintf("%s: too many arguments after wildcard expansion\n", line); + return shellExecNotFound; + } + argc = 1 + expandedArgc; + + char binPath[binPathCapacity]; + snprintf(binPath, sizeof(binPath), "/bin/%s", line); + + if (background) { + uint32 pid; + /* A here-string needs its own spawn path (jobctlRunBackgroundHereString + wires the child's fd 0 to a real pipe the SHELL itself writes the + text into, rather than opening a path) -- redirect.inPath is + always nullptr whenever hereString is set (shellExtractRedirects' + own last-one-wins rule between the two), so the plain Redirected + call below would otherwise just leave fd 0 at the console. */ + bool spawned = redirect.hereString + ? jobctlRunBackgroundHereString(binPath, (const char* const*)argv, argc, + redirect.hereString, redirect.outPath, + redirect.appendOut, redirect.errPath, + redirect.appendErr, &pid) + : jobctlRunBackgroundRedirected(binPath, (const char* const*)argv, argc, + redirect.inPath, redirect.outPath, redirect.appendOut, + redirect.errPath, redirect.appendErr, &pid); + if (!spawned) { + kprintf("unknown command or bad redirection: %s (try 'help')\n", line); + return shellExecNotFound; + } + for (int i = 0; i < maxBackgroundJobs; i++) { + if (!backgroundJobs[i].used) { + backgroundJobs[i].used = true; + backgroundJobs[i].pid = pid; + snprintf(backgroundJobs[i].name, sizeof(backgroundJobs[i].name), "%s", line); + break; + } + } + lastSpawnedPid = pid; + kprintf("[%u] %s started in background\n", pid, line); + return 0; + } + + /* Split into spawn-then-wait (rather than one combined call) so + lastSpawnedPid below is visible to anyone asking (shellLastSpawnedPid) + the instant the job exists, not only after jobctlWaitForeground's + blocking wait has already finished -- the same immediacy the + background branch above already gets for free from jobctlRunBackground + never blocking in the first place. Same here-string-vs-plain split as + the background branch above, same reason. */ + uint32 pid = redirect.hereString + ? jobctlSpawnForegroundHereString(binPath, (const char* const*)argv, argc, + redirect.hereString, redirect.outPath, + redirect.appendOut, redirect.errPath, redirect.appendErr) + : jobctlSpawnForegroundRedirected(binPath, (const char* const*)argv, argc, + redirect.inPath, redirect.outPath, + redirect.appendOut, redirect.errPath, + redirect.appendErr); + if (pid == threadInvalidId) { + kprintf("unknown command or bad redirection: %s (try 'help')\n", line); + return shellExecNotFound; + } + lastSpawnedPid = pid; + int64 exitCode = jobctlWaitForeground(pid); + kprintf("%s exited with code %ld\n", line, exitCode); + return exitCode; +} + +/* Splits each of an already-`shellSplitPipelineStages`d line's stages into + its own binary name + argv, exactly like runProgram's own split above -- + each stage is independently "the first word, then the rest" in the SAME + shape execute() already hands runProgram, just done once per stage here + instead of once total. */ +static void splitCommandWord(char* text, char** word, char** rest) +{ + char* cursor = text; + while (*cursor && *cursor != ' ') + cursor++; + if (*cursor == ' ') { + *cursor = 0; + cursor++; + } + while (*cursor == ' ') + cursor++; + *word = text; + *rest = cursor; +} + +/* Real N-stage shell pipeline (2 <= count <= shellMaxPipelineStages): + stage i's stdout feeds stage i+1's stdin through a real pipe + (vfsPipeCreateHandle + jobctlSpawnPiped's fd-baking, jobctl.h) for every + adjacent pair -- stage 0 otherwise keeps the real console for its stdin, + the last stage otherwise keeps it for its stdout, exactly like a real + shell's own `cmd1 | cmd2 | ... | cmdN`. jobctlSpawnPiped already takes + independent stdin/stdout pipe handles per call, so a MIDDLE stage (both + ends piped at once) needs no new primitive here, only the loop below + wiring each stage's stdin to the previous pipe and its stdout to the + next one. No stage may itself be a builtin (echo/ls/cat/... run directly + on THIS thread via kprintf/vfs, never through a spawned process's own fd + 1, so they have no real fd to redirect) -- an honestly-scoped limitation + matching jobctl.h's own doc comments elsewhere in this file. Only stage + 0's stdin may additionally be a here-string (`cmd1 <<< TEXT | cmd2 | ...`, + jobctlSpawnPipedHereString below) -- every later stage's stdin is already + a pipe, exactly like the old two-stage runPipeline already required. + Returns the real exit status of the whole pipeline (the LAST stage's own + exit code, matching real `$?`, or shellExecNotFound for any error that + never even got every stage spawned) -- same "feeds shell.c's new + `&&`/`||` chaining" reasoning as runProgram's own return above. */ +static int64 runPipelineStages(char* stages[], int count) +{ + char* word[shellMaxPipelineStages]; + char* argv[shellMaxPipelineStages][shellMaxArgs]; + int argc[shellMaxPipelineStages]; + ShellRedirect redirect[shellMaxPipelineStages]; + /* Per-stage expansion scratch for shellExpandArgv, same "argv[0] is + never expanded" reasoning as runProgram's own expandedArgv -- each + stage's actual binary comes from word[i], never from argv[i][0]. */ + char expanded[shellMaxPipelineStages][shellMaxArgs][envEntryMax]; + /* Per-stage wildcard expansion scratch (shellExpandGlobs) -- a + dedicated, smaller shellGlobPathCapacity buffer rather than reusing + pathCapacity, since this array is already multiplied by + shellMaxPipelineStages on top of shellMaxArgs and shares a single + 16KiB kernel-thread stack (sched.c's threadStackSize) with every + other local array in this function -- see shellExpandGlobs' own doc + comment in shell.c for the full sizing reasoning. */ + char globStorage[shellMaxPipelineStages][shellMaxArgs][shellGlobPathCapacity]; + /* Per-stage redirect-target expansion scratch (shellExpandRedirect) -- + same reasoning as runProgram's own redirectStorage, one dedicated + 4-slot array per stage since a stage's own inPath/outPath/errPath/ + hereString could all need expanding on the same line at once. */ + char redirectStorage[shellMaxPipelineStages][4][envEntryMax]; + + for (int i = 0; i < count; i++) { + char* rest; + splitCommandWord(stages[i], &word[i], &rest); + argc[i] = 0; + argv[i][argc[i]++] = word[i]; + argc[i] += shellTokenize(rest, &argv[i][argc[i]], shellMaxArgs - argc[i]); + shellExpandArgv(&argv[i][1], argc[i] - 1, &expanded[i][1]); + + if (!shellExtractRedirects(argv[i], &argc[i], &redirect[i])) { + kprintf("%s: redirection operator with no filename\n", word[i]); + return shellExecNotFound; + } + shellExpandRedirect(&redirect[i], redirectStorage[i]); + + /* Same "expand real arguments last, after redirects" ordering as + runProgram's own single-command path above. */ + int expandedArgc = shellExpandGlobs(&argv[i][1], argc[i] - 1, shellMaxArgs - 1, globStorage[i]); + if (expandedArgc < 0) { + kprintf("%s: too many arguments after wildcard expansion\n", word[i]); + return shellExecNotFound; + } + argc[i] = 1 + expandedArgc; + } + + /* Only the two OPEN ends of the pipeline may be file/here-string + redirected for stdin/stdout -- stage 0's stdin and the LAST stage's + stdout -- exactly like this function's own header comment already + scopes "no stage may be a builtin" for a similar reason. Every + interior fd is already wired to a real pipe on both sides, so those + combinations are rejected outright below rather than silently + guessing which of "the pipe" or "the file" should win. fd 2 + (stderr) is never part of any pipe, so `2>`/`2>>` are always allowed + on every stage independently, with no such restriction. */ + for (int i = 0; i < count; i++) { + if (i > 0 && redirect[i].inPath) { + kprintf("pipeline: '%s' already reads from the pipe, input redirection is only " + "supported on the first stage\n", word[i]); + return shellExecNotFound; + } + if (i > 0 && redirect[i].hereString) { + kprintf("pipeline: '%s' already reads from the pipe, here-string redirection is " + "only supported on the first stage\n", word[i]); + return shellExecNotFound; + } + if (i < count - 1 && redirect[i].outPath) { + kprintf("pipeline: '%s' already writes to the pipe, output redirection is only " + "supported on the last stage\n", word[i]); + return shellExecNotFound; + } + } + + uint32 pids[shellMaxPipelineStages]; + /* pipeHandle[i] connects stage i's stdout to stage i+1's stdin -- only + count-1 links exist for count stages. */ + int pipeHandle[shellMaxPipelineStages - 1]; + + for (int i = 0; i < count; i++) { + int stdinHandle = (i == 0) ? -1 : pipeHandle[i - 1]; + int stdoutHandle = -1; + if (i < count - 1) { + pipeHandle[i] = vfsPipeCreateHandle(); + if (pipeHandle[i] < 0) { + kprintf("pipeline: no pipe slots free\n"); + for (int k = 0; k < i; k++) { + threadKill(pids[k]); + threadJoin(pids[k]); + } + return shellExecNotFound; + } + stdoutHandle = pipeHandle[i]; + } + + /* joinPgid: every stage after the first joins stage 0's own group + (jobctlSpawnPiped's own doc comment) so a single Ctrl-C, or this + function's own eventual keyboardSetForegroundPgid via + jobctlWaitPipelineStages, reaches every stage at once. */ + uint32 joinPgid = (i == 0) ? threadInvalidId : pids[0]; + + char binPath[binPathCapacity]; + snprintf(binPath, sizeof(binPath), "/bin/%s", word[i]); + + /* A here-string is only ever possible on stage 0 (validated above) + -- jobctlSpawnPipedHereString wires fd 0 to a real pipe the + SHELL itself writes the text into, rather than leaving it at the + console, then passes stdoutHandle/joinPgid straight through + exactly like the plain jobctlSpawnPiped every other stage + uses. */ + uint32 pid = (i == 0 && redirect[i].hereString) + ? jobctlSpawnPipedHereString(binPath, (const char* const*)argv[i], argc[i], + redirect[i].hereString, stdoutHandle, joinPgid) + : jobctlSpawnPiped(binPath, (const char* const*)argv[i], argc[i], + stdinHandle, stdoutHandle, joinPgid); + + if (pid == threadInvalidId) { + kprintf("unknown command: %s (try 'help')\n", word[i]); + if (i < count - 1) + pipeDestroyIfUnattached(pipeHandle[i]); + for (int k = 0; k < i; k++) { + threadKill(pids[k]); + threadJoin(pids[k]); + } + return shellExecNotFound; + } + pids[i] = pid; + + const char* inPath = (i == 0) ? redirect[i].inPath : nullptr; + const char* outPath = (i == count - 1) ? redirect[i].outPath : nullptr; + bool appendOut = (i == count - 1) ? redirect[i].appendOut : false; + if ((inPath || outPath || redirect[i].errPath) && + !jobctlApplyRedirects(pid, inPath, outPath, appendOut, + redirect[i].errPath, redirect[i].appendErr)) { + kprintf("%s: cannot open %s\n", word[i], + inPath ? inPath : (outPath ? outPath : redirect[i].errPath)); + for (int k = 0; k <= i; k++) { + threadKill(pids[k]); + threadJoin(pids[k]); + } + return shellExecNotFound; + } + } + + lastSpawnedPid = pids[count - 1]; + int64 exitCode = jobctlWaitPipelineStages(pids, count); + + char summary[lineCapacity]; + int pos = 0; + for (int i = 0; i < count; i++) { + if (i > 0) + pos += snprintf(summary + pos, sizeof(summary) - pos, " | "); + pos += snprintf(summary + pos, sizeof(summary) - pos, "%s", word[i]); + } + kprintf("%s exited with code %ld\n", summary, exitCode); + return exitCode; +} static void printHelp(void) { @@ -21,15 +875,36 @@ static void printHelp(void) kprintf(" help show this list\n"); kprintf(" echo TEXT print the given text\n"); kprintf(" echo TEXT >F write the text to file F\n"); - kprintf(" ls [PATH] list a directory\n"); + kprintf(" ls [-l] [PATH] list a directory (tmpfs or /mnt), -l adds type/size/mtime\n"); kprintf(" cat FILE print a file\n"); kprintf(" mkdir PATH create a directory (writable mounts only)\n"); + kprintf(" rm PATH remove a file (refuses a directory)\n"); + kprintf(" rmdir PATH remove an empty directory\n"); + kprintf(" chmod MODE PATH set owner/group/other rwx bits (octal, e.g. 644 or 750)\n"); + kprintf(" chgrp GID PATH set group (decimal); owner may only set its own gid\n"); kprintf(" cd PATH change the working directory\n"); kprintf(" pwd print the working directory\n"); + kprintf(" whoami print this shell's own uid (0 = root)\n"); kprintf(" mem physical memory and heap usage\n"); kprintf(" uptime ticks and seconds since boot\n"); kprintf(" ps list kernel threads\n"); + kprintf(" jobs list running background jobs\n"); + kprintf(" export N=V set environment variable N to V (inherited by children)\n"); + kprintf(" unset N remove environment variable N\n"); + kprintf(" env list this shell's environment variables\n"); kprintf(" clear clear the screen\n"); + kprintf(" $NAME (in a spawned program's arguments) expands to that variable\n"); + kprintf(" $? (same place) expands to the last command's exit status\n"); + kprintf(" NAME dir/*.ext (glob) expands to every real matching filename in dir\n"); + kprintf(" NAME [ARGS] run /bin/NAME in the foreground, waits for it to exit\n"); + kprintf(" NAME [ARGS] & run /bin/NAME in the background, prints its pid\n"); + kprintf(" NAME < IN redirect NAME's stdin from file IN\n"); + kprintf(" NAME <<< TEXT feed TEXT (plus a newline) to NAME's stdin\n"); + kprintf(" NAME > OUT redirect NAME's stdout to file OUT (create/truncate)\n"); + kprintf(" NAME >> OUT redirect NAME's stdout to file OUT (create/append)\n"); + kprintf(" NAME 2> ERR redirect NAME's stderr to file ERR (create/truncate)\n"); + kprintf(" NAME 2>> ERR redirect NAME's stderr to file ERR (create/append)\n"); + kprintf(" A [ARGS] | B [ARGS] pipe A's stdout into B's stdin, waits for both\n"); } static void printMem(void) @@ -54,12 +929,26 @@ static void printUptime(void) ticks, ticks / frequency, frequency); } +static const char* threadStateName(ThreadState state) +{ + switch (state) { + case threadUnused: return "unused"; + case threadRunnable: return "runnable"; + case threadRunning: return "running"; + case threadSleeping: return "sleeping"; + case threadDead: return "dead"; + case threadBlocked: return "blocked"; + case threadStopped: return "stopped"; + default: return "?"; + } +} + static void printThreads(void) { kprintf("id state name\n"); for (uint32 i = 0; i < threadCount(); i++) { Thread* thread = threadAt(i); - const char* state = thread->state == threadRunning ? "running" : "runnable"; + const char* state = threadStateName(thread->state); char marker = thread->id == currentThreadId() ? '*' : ' '; kprintf("%c%u %s %s\n", marker, thread->id, state, thread->name); } @@ -67,58 +956,148 @@ static void printThreads(void) static void buildPath(const char* argument, char* out) { - if (argument[0] == '/') { - uint64 length = strlen(argument); - if (length >= pathCapacity) - length = pathCapacity - 1; - memcpy(out, argument, length); - out[length] = 0; - return; - } - - uint64 pos = strlen(cwd); - if (pos >= pathCapacity) - pos = pathCapacity - 1; - memcpy(out, cwd, pos); - if (pos == 0 || out[pos - 1] != '/') { - if (pos < pathCapacity - 1) - out[pos++] = '/'; - } - for (uint64 i = 0; argument[i] && pos < pathCapacity - 1; i++) - out[pos++] = argument[i]; - out[pos] = 0; + vfsResolvePath(argument, out, pathCapacity); } -static void listDir(const char* argument) +/* One line of `ls` output for a single entry, real stat data (fs.h's + FbStat) already resolved by the caller -- shared by both the plain and + `-l` formats and by the "argument names a file, not a directory" case + below, so all three ever only format an FbStat one way. */ +static void printLsEntry(bool longFormat, const FbStat* stat, const char* name) { - char path[pathCapacity]; - if (argument[0] == 0) - memcpy(path, cwd, strlen(cwd) + 1); - else - buildPath(argument, path); + char typeChar = stat->type == fsTypeDir ? 'd' : (stat->type == fsTypeFifo ? 'p' : 'f'); - VfsNode* node = vfsLookup(path); - if (!node) { - kprintf("ls: no such path: %s\n", path); - return; - } - if (!vfsIsDir(node)) { - kprintf("%s %lu\n", vfsName(node), vfsSize(node)); - return; - } - for (VfsNode* child = vfsChild(node); child; child = vfsNext(child)) { - if (vfsIsDir(child)) - kprintf("%s/\n", vfsName(child)); + if (!longFormat) { + if (stat->type == fsTypeDir) + kprintf("%s/\n", name); else - kprintf("%s %lu\n", vfsName(child), vfsSize(child)); + kprintf("%s %lu\n", name, stat->size); + return; + } + + /* Deliberately prints type/size/name BEFORE the date: a needle that + greps for a known type+size+name substring (tools/ci-test.sh) then + never has to also match today's wall-clock date, which is real and + therefore different on every run. The real owner/group/other rwx + permission string (fs.h's fsModeOwner*, fsModeGroup*, fsModeOther*) is + appended AFTER the date for the same reason, in the other direction + -- every existing needle that greps a type+size+name+date-prefix + substring keeps matching unchanged, since nothing before it in the + line moved (only perm itself grew longer, from 3 chars to the full + 9-char rwxrwxrwx real `ls -l` prints, now that a real gid exists to + make the group triad meaningful). */ + char perm[10]; + perm[0] = (stat->mode & fsModeOwnerRead) ? 'r' : '-'; + perm[1] = (stat->mode & fsModeOwnerWrite) ? 'w' : '-'; + perm[2] = (stat->mode & fsModeOwnerExec) ? 'x' : '-'; + perm[3] = (stat->mode & fsModeGroupRead) ? 'r' : '-'; + perm[4] = (stat->mode & fsModeGroupWrite) ? 'w' : '-'; + perm[5] = (stat->mode & fsModeGroupExec) ? 'x' : '-'; + perm[6] = (stat->mode & fsModeOtherRead) ? 'r' : '-'; + perm[7] = (stat->mode & fsModeOtherWrite) ? 'w' : '-'; + perm[8] = (stat->mode & fsModeOtherExec) ? 'x' : '-'; + perm[9] = 0; + + kprintf("%c %lu %s ", typeChar, stat->size, name); + if (stat->mtime == 0) { + /* tmpfs/dev entries never track time (fs.h's FbStat comment) -- + an honest "no timestamp" marker instead of a fake 1970 date. */ + kprintf("- %s\n", perm); + } else { + RtcDateTime dt; + rtcCivilFromEpoch((int64)stat->mtime, &dt); + kprintf("%04ld-%02ld-%02ld %02ld:%02ld:%02ld %s\n", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, perm); } } -static void catFile(const char* argument) +/* `ls [-l] [PATH]` -- generalized over vfsOpen/vfsGetdents/vfsStat + (fs.h/vfs.h) instead of the old tmpfs-only vfsLookup/vfsChild/vfsNext + VfsNode-tree walk, so a directory under `/mnt` (SFS) can finally be + listed from the interactive shell at all (previously vfsLookup, which + only ever understood the in-memory tmpfs tree, silently returned "no + such path" for every SFS directory -- fstest.c/linktest.c could only + ever prove SFS directory listing through a raw userGetdents syscall, + never through this builtin). `-l` adds real stat data per entry (type, + size, mtime -- sfs.h's on-disk timestamps, FALA 39) the same way a real + `ls -l` would. */ +/* Returns 0 on success, 1 on failure (real POSIX `ls` exit-status shape) -- + feeds shell.c's new `&&`/`||` conditional chaining (execute()) the same + way a spawned program's own real exit code already does, see + shellSplitAndOr's own doc comment in shell.h. */ +static int listDir(const char* argument) +{ + bool longFormat = false; + if (argument[0] == '-' && argument[1] == 'l' && (argument[2] == 0 || argument[2] == ' ')) { + longFormat = true; + argument += 2; + while (*argument == ' ') + argument++; + } + + char path[pathCapacity]; + buildPath(argument, path); + + FbStat stat; + if (vfsStat(path, &stat) != 0) { + kprintf("ls: no such path: %s\n", path); + return 1; + } + + if (stat.type != fsTypeDir) { + const char* base = path; + for (const char* p = path; *p; p++) { + if (*p == '/') + base = p + 1; + } + printLsEntry(longFormat, &stat, base); + return 0; + } + + int fd = vfsOpen(path, oReadOnly); + if (fd < 0) { + kprintf("ls: cannot open %s\n", path); + return 1; + } + + uint64 pathLen = strlen(path); + if (pathLen >= pathCapacity) + pathLen = pathCapacity - 1; + bool needSlash = pathLen == 0 || path[pathLen - 1] != '/'; + + FbDirent entries[16]; + int64 bytes; + while ((bytes = vfsGetdents(fd, entries, sizeof(entries))) > 0) { + int64 count = bytes / (int64)sizeof(FbDirent); + for (int64 i = 0; i < count; i++) { + char childPath[pathCapacity]; + uint64 childLen = pathLen; + memcpy(childPath, path, childLen); + if (needSlash && childLen < pathCapacity - 1) + childPath[childLen++] = '/'; + uint64 nameLen = strlen(entries[i].name); + if (childLen + nameLen >= pathCapacity) + nameLen = pathCapacity - 1 - childLen; + memcpy(childPath + childLen, entries[i].name, nameLen); + childPath[childLen + nameLen] = 0; + + FbStat childStat; + if (vfsStat(childPath, &childStat) == 0) + printLsEntry(longFormat, &childStat, entries[i].name); + else + kprintf("%s ?\n", entries[i].name); + } + } + vfsClose(fd); + return 0; +} + +/* Same "0 success, 1 failure" shape listDir above uses. */ +static int catFile(const char* argument) { if (argument[0] == 0) { kprintf("cat: missing file\n"); - return; + return 1; } char path[pathCapacity]; buildPath(argument, path); @@ -126,16 +1105,17 @@ static void catFile(const char* argument) VfsNode* node = vfsLookup(path); if (!node) { kprintf("cat: no such file: %s\n", path); - return; + return 1; } if (vfsIsDir(node)) { kprintf("cat: %s is a directory\n", path); - return; + return 1; } const char* data = (const char*)vfsFileData(node); uint64 size = vfsSize(node); for (uint64 i = 0; i < size; i++) kputchar(data[i]); + return 0; } static void echoWrite(char* text, const char* target) @@ -154,19 +1134,188 @@ static void echoWrite(char* text, const char* target) vfsClose(fd); } -static void makeDir(const char* argument) +/* Same "0 success, 1 failure" shape listDir/catFile above use. */ +static int makeDir(const char* argument) { if (argument[0] == 0) { kprintf("mkdir: missing path\n"); - return; + return 1; } char path[pathCapacity]; buildPath(argument, path); - if (vfsMkdir(path) != 0) + if (vfsMkdir(path) != 0) { kprintf("mkdir: cannot create %s\n", path); + return 1; + } + return 0; } -static void changeDir(const char* argument) +/* Real `rm`: refuses a directory outright (real rm without -r never + descends), matching vfsUnlink's own EISDIR-shaped refusal (vfs.c) -- + the vfsStat check here exists only to give a friendlier message than + vfsUnlink's plain -1 would on its own, the actual enforcement is + vfsUnlink's, not this. */ +static int removeFile(const char* argument) +{ + if (argument[0] == 0) { + kprintf("rm: missing path\n"); + return 1; + } + char path[pathCapacity]; + buildPath(argument, path); + + FbStat stat; + if (vfsStat(path, &stat) != 0) { + kprintf("rm: no such file: %s\n", path); + return 1; + } + if (stat.type == fsTypeDir) { + kprintf("rm: %s: is a directory\n", path); + return 1; + } + if (vfsUnlink(path) != 0) { + kprintf("rm: cannot remove %s\n", path); + return 1; + } + return 0; +} + +/* Real `rmdir`: only ever removes an EMPTY directory, same split from `rm` + above that real rmdir(2)/unlink(2) have. */ +static int removeDir(const char* argument) +{ + if (argument[0] == 0) { + kprintf("rmdir: missing path\n"); + return 1; + } + char path[pathCapacity]; + buildPath(argument, path); + + FbStat stat; + if (vfsStat(path, &stat) != 0) { + kprintf("rmdir: no such directory: %s\n", path); + return 1; + } + if (stat.type != fsTypeDir) { + kprintf("rmdir: %s: not a directory\n", path); + return 1; + } + if (vfsRmdir(path) != 0) { + kprintf("rmdir: %s: not empty\n", path); + return 1; + } + return 0; +} + +/* Real `chmod MODE PATH`: MODE is parsed as octal (strtoul base 8, klib.h), + matching real chmod(1)'s numeric-mode convention -- "755" already means + 0755, no leading 0 required. Both the owner AND other triads are + meaningful now that a real uid model exists (fs.h's fsModeOwner and + fsModeOther bits, vfs.c's vfsPermitted) -- refused outright unless the + caller owns the target or is uid 0 (vfsChmod's own vfsOwnerOrRoot check). + Symbolic modes (u+x, a=r, ...) are not supported, the same "numeric only" + scope every other new builtin in this shell has picked over a fuller + grammar it does not need. */ +static int doChmod(const char* argument) +{ + char modeText[16]; + uint64 i = 0; + while (argument[i] && argument[i] != ' ' && i < sizeof(modeText) - 1) { + modeText[i] = argument[i]; + i++; + } + modeText[i] = 0; + + const char* rest = argument + i; + while (*rest == ' ') + rest++; + + if (modeText[0] == 0 || rest[0] == 0) { + kprintf("chmod: usage: chmod MODE PATH\n"); + return 1; + } + + uint32 mode = (uint32)strtoul(modeText, nullptr, 8); + char path[pathCapacity]; + buildPath(rest, path); + if (vfsChmod(path, mode) != 0) { + kprintf("chmod: cannot chmod %s\n", path); + return 1; + } + return 0; +} + +/* Real `chown UID PATH`: UID is parsed as a plain decimal number (strtoul + base 10, klib.h), unlike chmod's octal MODE -- a uid is never a bitmask, + so there is no "leading digit means octal" convention to match. Refused + outright unless the caller is uid 0 (vfsChown's own root-only check) -- + even the file's own owner may not give it away, matching real Unix's + _POSIX_CHOWN_RESTRICTED default. */ +static int doChown(const char* argument) +{ + char uidText[16]; + uint64 i = 0; + while (argument[i] && argument[i] != ' ' && i < sizeof(uidText) - 1) { + uidText[i] = argument[i]; + i++; + } + uidText[i] = 0; + + const char* rest = argument + i; + while (*rest == ' ') + rest++; + + if (uidText[0] == 0 || rest[0] == 0) { + kprintf("chown: usage: chown UID PATH\n"); + return 1; + } + + uint32 uid = (uint32)strtoul(uidText, nullptr, 10); + char path[pathCapacity]; + buildPath(rest, path); + if (vfsChown(path, uid) != 0) { + kprintf("chown: cannot chown %s\n", path); + return 1; + } + return 0; +} + +/* Real `chgrp GID PATH`: decimal like chown, not octal like chmod -- a gid + is never a bitmask either. Unlike chown (root-only, full stop), the + file's own owner may also call this, but only to set the group to THIS + shell's own current gid (vfsChgrp's own owner-may-only-name-their-own-gid + rule, see its doc comment) -- root may still set any gid. */ +static int doChgrp(const char* argument) +{ + char gidText[16]; + uint64 i = 0; + while (argument[i] && argument[i] != ' ' && i < sizeof(gidText) - 1) { + gidText[i] = argument[i]; + i++; + } + gidText[i] = 0; + + const char* rest = argument + i; + while (*rest == ' ') + rest++; + + if (gidText[0] == 0 || rest[0] == 0) { + kprintf("chgrp: usage: chgrp GID PATH\n"); + return 1; + } + + uint32 gid = (uint32)strtoul(gidText, nullptr, 10); + char path[pathCapacity]; + buildPath(rest, path); + if (vfsChgrp(path, gid) != 0) { + kprintf("chgrp: cannot chgrp %s\n", path); + return 1; + } + return 0; +} + +/* Same "0 success, 1 failure" shape as above. */ +static int changeDir(const char* argument) { char path[pathCapacity]; if (argument[0] == 0) @@ -174,12 +1323,76 @@ static void changeDir(const char* argument) else buildPath(argument, path); - VfsNode* node = vfsLookup(path); - if (!node || !vfsIsDir(node)) { + /* vfsChdir (vfs.h) also understands "/mnt" directories -- unlike the + vfsLookup+vfsIsDir check this used to do, which only ever walked the + VfsNode tree (ustar/tmpfs) and so could never actually cd into the + persistent on-disk filesystem at all. */ + if (vfsChdir(path) != 0) { kprintf("cd: no such directory: %s\n", path); - return; + return 1; } - vfsPath(node, cwd, sizeof(cwd)); + return 0; +} + +/* `export NAME=VALUE` -- real POSIX shell syntax, no quoting support + (matching every other built-in in this shell, e.g. doEcho below never + understands quotes either): the FIRST '=' splits `argument` into name + and value, everything after it becomes the value verbatim (including any + embedded spaces), the same "rest of the line, taken as-is" shape doEcho + already gives its own unredirected text. currentThreadSetEnv (sched.h) + sets it directly in THIS thread's own table -- the shell is a plain + kernel thread (shellRun's own comment above), so it needs no syscall to + reach its own state, exactly like changeDir calling vfsChdir directly + above. Any job spawned AFTER this call (jobctl.h, via threadCreateUser's + initialEnv) inherits a real copy of the new value. */ +/* Same "0 success, 1 failure" shape listDir/catFile/makeDir/changeDir + above use. */ +static int doExport(char* argument) +{ + char* eq = argument; + while (*eq && *eq != '=') + eq++; + if (*eq != '=' || eq == argument) { + kprintf("export: usage: export NAME=VALUE\n"); + return 1; + } + *eq = 0; + const char* value = eq + 1; + if (!currentThreadSetEnv(argument, value)) { + kprintf("export: cannot set %s (name/value too long, or too many variables)\n", argument); + return 1; + } + return 0; +} + +/* `unset NAME` -- real POSIX shell syntax, removes `argument` from this + thread's own environment table via currentThreadUnsetEnv (sched.h), + the exact same "no syscall needed, shell is a plain kernel thread" + shape as doExport above. Silently succeeds whether or not `argument` + actually had an entry (matching real unsetenv(3)'s own no-op-success + semantics for an already-absent name) -- the usage error below is only + for the "no argument at all" case, not "name not found", and (same "0 + success, 1 failure" shape listDir/catFile/makeDir/changeDir/doExport + above use) the only way this can ever return failure. */ +static int doUnset(char* argument) +{ + if (argument[0] == 0) { + kprintf("unset: usage: unset NAME\n"); + return 1; + } + currentThreadUnsetEnv(argument); + return 0; +} + +/* `env` -- lists every "NAME=VALUE" this shell thread currently holds + (currentThreadEnvCount/currentThreadEnvEntry, sched.h), the same raw + storage format doExport above writes and shellExpandArgv's $NAME lookups + read. */ +static void printEnv(void) +{ + int count = currentThreadEnvCount(); + for (int i = 0; i < count; i++) + kprintf("env: %s\n", currentThreadEnvEntry(i)); } static void doEcho(char* argument) @@ -205,10 +1418,156 @@ static void doEcho(char* argument) } } -static void execute(char* line) +/* Upper bound on how many `$(...)` command substitutions execute() will + expand on one typed line -- the same "generous cap for one + lineCapacity-bounded line, not a claim real shells have such a limit" + reasoning shellMaxPipelineStages/shellMaxAndOrSegments already give + their own per-line caps. */ +#define shellMaxCommandSubst 4 + +/* Expands every non-nested `$(cmd [args])` in `srcLine` into `outLine`, + left to right -- called from executeSingle below only once `&&`/`||` + (execute()'s own shellSplitAndOr, over the ORIGINAL typed line) and `|` + (executeSingle's own shellSplitPipelineStages, over THIS segment) have + already been resolved, so a literal `&&`/`||`/`|` byte sitting inside a + substituted command's own captured output can never be mistaken for a + real operator -- those are only ever recognized once, before any + expansion runs, exactly like a real shell's own grammar (operators are + parsed structurally first; expansion happens per-word afterward, never + the other way around). + Builds the result into a PRIVATE `work` copy first, splicing in place + there, rather than mutating `srcLine` itself: `srcLine` here is not + necessarily its own standalone buffer -- shellSplitAndOr/ + shellSplitPipelineStages both split their input IN PLACE by writing NUL + terminators into ONE shared backing array (shell.h's own doc comments on + both), so a segment that still has ANOTHER segment's text sitting + immediately after its own NUL terminator would have that neighbor + silently corrupted by any in-place splice that grows past the original + text's own length. Working in a fully separate `work`/`outLine` buffer + makes that impossible regardless of how much the line grows or shrinks. + Each `$(...)` found (shellFindCommandSubst, shell.h) is run to + completion via jobctlCaptureOutput -- a REAL spawn, not a builtin (no fd + of its own to capture from, the same "no stage may be a builtin" scope + runPipelineStages already documents) -- with its own argv given + `$NAME`/`$?` expansion (shellExpandArgv) but nothing else: no wildcard + globs, no redirection, no pipes, no further `$(...)` nesting. Trailing + newlines are stripped from the captured output, matching a real shell's + own `$(...)`. Only ever scans PAST whatever it just spliced into `work` + (`scanFrom` below), never re-scanning a command's own captured output + for a further `$(` -- the same single-pass, no-second-expansion rule a + real shell gives command substitution's own result. + Returns true having written the fully expanded line into `outLine` + (unchanged from `srcLine` if there was nothing to expand), or false, + having already reported the error via kprintf and left `outLine` + untouched, on a malformed `$(` with no matching `)`, an unknown command, + a spliced result that would overflow lineCapacity, or more than + shellMaxCommandSubst substitutions on one line. */ +static bool shellExpandCommandSubst(const char* srcLine, char* outLine, int outCap) +{ + char work[lineCapacity]; + snprintf(work, sizeof(work), "%s", srcLine); + + int scanFrom = 0; + + for (int iterations = 0; iterations < shellMaxCommandSubst; iterations++) { + int start, end; + char cmd[lineCapacity]; + if (!shellFindCommandSubst(work + scanFrom, &start, &end, cmd, sizeof(cmd))) { + kprintf("shell: '$(' with no matching ')'\n"); + return false; + } + if (start < 0) { + snprintf(outLine, (uint64)outCap, "%s", work); + return true; + } + start += scanFrom; + end += scanFrom; + + char* word; + char* rest; + splitCommandWord(cmd, &word, &rest); + + char* argv[shellMaxArgs]; + int argc = 0; + argv[argc++] = word; + argc += shellTokenize(rest, &argv[argc], shellMaxArgs - argc); + + char expandedArgv[shellMaxArgs][envEntryMax]; + shellExpandArgv(argv + 1, argc - 1, expandedArgv + 1); + + char binPath[binPathCapacity]; + snprintf(binPath, sizeof(binPath), "/bin/%s", word); + + char captured[lineCapacity]; + int64 status; + bool spawned = jobctlCaptureOutput(binPath, (const char* const*)argv, argc, + captured, sizeof(captured), &status); + /* Deliberately discarded: real shells leave `$?` reflecting the + OUTER command that actually runs after substitution completes, + never the substituted command itself (executeSingle/execute + already set lastStatus from whatever this line goes on to + spawn). */ + (void)status; + if (!spawned) { + kprintf("%s: command substitution failed (unknown command)\n", word); + return false; + } + + int capturedLen = (int)strlen(captured); + while (capturedLen > 0 && captured[capturedLen - 1] == '\n') + captured[--capturedLen] = 0; + + int tailLen = (int)strlen(work + end); + if (start + capturedLen + tailLen + 1 > lineCapacity) { + kprintf("shell: command substitution result too long\n"); + return false; + } + + memmove(work + start + capturedLen, work + end, (uint64)(tailLen + 1)); + memcpy(work + start, captured, (uint64)capturedLen); + + scanFrom = start + capturedLen; + } + + kprintf("shell: too many '$(' substitutions (max %d)\n", shellMaxCommandSubst); + return false; +} + +/* Runs exactly one command-or-pipeline (never itself another `&&`/`||` + chain -- shellSplitAndOr has already peeled those apart before this is + ever called, see execute() below) and returns its real exit status: a + builtin's own 0/1 (listDir/catFile/makeDir/changeDir/doExport/doUnset), + 0 for every other builtin (none of them has a documented failure case), + or whatever runProgram/runPipelineStages themselves return for a + spawned command or pipeline. This used to be the entire body of + execute() itself, before shell.c had any notion of chaining multiple + commands' exit statuses together. */ +static int64 executeSingle(char* line) { if (line[0] == 0) - return; + return 0; + + char* pipeStages[shellMaxPipelineStages]; + int pipeStageCount = shellSplitPipelineStages(line, pipeStages, shellMaxPipelineStages); + if (pipeStageCount == -1) { + kprintf("pipeline: too many '|' stages (max %d)\n", shellMaxPipelineStages); + return shellExecNotFound; + } + if (pipeStageCount > 0) + return runPipelineStages(pipeStages, pipeStageCount); + + /* `$(cmd)` command substitution (shellExpandCommandSubst above) only + ever runs for a single, non-piped command -- an honestly-scoped + limitation matching this file's own history of landing a feature for + the plain case first and generalizing to pipeline stages in a later + fala (`<<<` here-strings took the same path: jobctlSpawnForegroundHereString + landed before jobctlSpawnPipedHereString ever fed a pipeline's first + stage). `|` has already been ruled out above (pipeStageCount == 0), + so nothing here can still be misread as a pipe. */ + char expandedLine[lineCapacity]; + if (!shellExpandCommandSubst(line, expandedLine, sizeof(expandedLine))) + return shellExecNotFound; + line = expandedLine; char* argument = line; while (*argument && *argument != ' ') @@ -220,30 +1579,111 @@ static void execute(char* line) while (*argument == ' ') argument++; - if (strcmp(line, "help") == 0) + if (strcmp(line, "help") == 0) { printHelp(); - else if (strcmp(line, "echo") == 0) + return 0; + } else if (strcmp(line, "echo") == 0) { doEcho(argument); - else if (strcmp(line, "ls") == 0) - listDir(argument); + return 0; + } else if (strcmp(line, "ls") == 0) + return listDir(argument); else if (strcmp(line, "cat") == 0) - catFile(argument); + return catFile(argument); else if (strcmp(line, "mkdir") == 0) - makeDir(argument); + return makeDir(argument); + else if (strcmp(line, "rm") == 0) + return removeFile(argument); + else if (strcmp(line, "rmdir") == 0) + return removeDir(argument); + else if (strcmp(line, "chmod") == 0) + return doChmod(argument); + else if (strcmp(line, "chown") == 0) + return doChown(argument); + else if (strcmp(line, "chgrp") == 0) + return doChgrp(argument); else if (strcmp(line, "cd") == 0) - changeDir(argument); - else if (strcmp(line, "pwd") == 0) - kprintf("%s\n", cwd); - else if (strcmp(line, "mem") == 0) + return changeDir(argument); + else if (strcmp(line, "pwd") == 0) { + char path[pathCapacity]; + vfsGetcwd(path, sizeof(path)); + kprintf("%s\n", path); + return 0; + } + else if (strcmp(line, "whoami") == 0) { + kprintf("%u\n", currentThreadUid()); + return 0; + } + else if (strcmp(line, "mem") == 0) { printMem(); - else if (strcmp(line, "uptime") == 0) + return 0; + } else if (strcmp(line, "uptime") == 0) { printUptime(); - else if (strcmp(line, "ps") == 0) + return 0; + } else if (strcmp(line, "ps") == 0) { printThreads(); - else if (strcmp(line, "clear") == 0) + return 0; + } else if (strcmp(line, "jobs") == 0) { + listBackgroundJobs(); + return 0; + } else if (strcmp(line, "export") == 0) + return doExport(argument); + else if (strcmp(line, "env") == 0) { + printEnv(); + return 0; + } else if (strcmp(line, "unset") == 0) + return doUnset(argument); + else if (strcmp(line, "clear") == 0) { vgaClear(); - else - kprintf("unknown command: %s (try 'help')\n", line); + return 0; + } else + return runProgram(line, argument); +} + +/* Splits `line` at every top-level `&&`/`||` (shellSplitAndOr, shell.h) + and runs each segment left to right through executeSingle above, + short-circuiting exactly like a real shell: a segment preceded by `&&` + only runs if the PREVIOUS segment that actually ran (not necessarily the + lexically previous one -- a skipped segment leaves `status` completely + unchanged) exited 0, one preceded by `||` only runs if that same status + was nonzero. This is real conditional chaining WITHIN a single typed + line (`cmd1 && cmd2 || cmd3`, the classic shell if-then-else idiom). + `lastStatus` (this file's own file-scope static above) is updated right + after every executeSingle() call below, chained or not -- a real shell's + own `$?` updates after EVERY simple command, including mid-chain (so + `false || echo $?` sees `false`'s own status while evaluating `echo`'s + argv, not some later value), and survives PAST this function returning, + so a later, completely separate typed line can still read it via + shellExpandToken's own `$?` handling -- closing the "no persistent `$?` + across separately typed lines" gap this file used to document here as a + deliberate limitation. A SKIPPED segment leaves `lastStatus` untouched + exactly like it already leaves the local chaining `status` untouched. */ +static void execute(char* line) +{ + if (line[0] == 0) + return; + + char* andOrSegments[shellMaxAndOrSegments]; + ShellAndOrOp andOrOps[shellMaxAndOrSegments - 1]; + int andOrCount = shellSplitAndOr(line, andOrSegments, andOrOps, shellMaxAndOrSegments); + if (andOrCount == -1) { + kprintf("shell: too many '&&'/'||' segments (max %d)\n", shellMaxAndOrSegments); + return; + } + if (andOrCount == 0) { + lastStatus = executeSingle(line); + return; + } + + int64 status = 0; + for (int i = 0; i < andOrCount; i++) { + if (i > 0) { + bool shouldRun = (andOrOps[i - 1] == shellAndOrAnd) ? (status == 0) : (status != 0); + if (!shouldRun) + continue; + } + status = executeSingle(andOrSegments[i]); + lastStatus = status; + } } void shellRun(void) @@ -251,17 +1691,34 @@ void shellRun(void) char line[lineCapacity]; uint32 length = 0; + /* The shell is its own independent console reader (keyboard.h), same as + every ring-3 process's fd 0 -- a keystroke keyboardHandleScancode + decodes is broadcast to every open reader, this one included, so the + shell keeps working unchanged even while other processes are also + blocked reading fd 0 (see the consoleReaderA/consoleReaderB live boot + demo in kmain.c). Plain kernel-thread context (not a syscall + handler), so the blocking helper's real halt() loop is safe here, + exactly like pipeReadBlocking's callers. */ + int reader = keyboardOpenReader(); + if (reader == consoleReaderInvalid) { + kprintf("[shell] console reader table full, shell has no input\n"); + for (;;) + halt(); + } + kprintf("\nfrostbyte shell ready. type 'help'.\n"); kprintf("frostbyte> "); for (;;) { - char c = keyboardReadChar(); + char c; + keyboardReaderReadBlocking(reader, &c, 1); if (c == '\n') { kputchar('\n'); line[length] = 0; execute(line); length = 0; + reapFinishedBackgroundJobs(); kprintf("frostbyte> "); } else if (c == '\b') { if (length > 0) { diff --git a/src/shellTest.c b/src/shellTest.c new file mode 100644 index 0000000..8303677 --- /dev/null +++ b/src/shellTest.c @@ -0,0 +1,812 @@ +#include "ktest.h" +#include "shell.h" +#include "string.h" + +/* shellTokenize (shell.c) is the pure-string half of the shell's new + `NAME [ARGS] [&]` job-control dispatch (runProgram, shell.c) -- turning + a typed line into a real argv[] the way a normal shell does, instead of + the single "rest of the line" string every other built-in command is + content with. Everything below runs before schedStart() (ktest.h's own + file comment), so it deliberately never touches the VFS, jobctl.h, or a + real spawn -- only the tokenizer itself, which is ordinary deterministic + string manipulation. */ + +KTEST(shellTokenizeEmptyLine) +{ + char text[] = ""; + char* tokens[4]; + int count = shellTokenize(text, tokens, 4); + KT_EQ(t, count, 0); +} + +KTEST(shellTokenizeSingleWord) +{ + char text[] = "hello"; + char* tokens[4]; + int count = shellTokenize(text, tokens, 4); + KT_EQ(t, count, 1); + KT_STREQ(t, tokens[0], "hello"); +} + +KTEST(shellTokenizeMultipleWordsCollapsesRepeatedSpaces) +{ + char text[] = " one two three "; + char* tokens[8]; + int count = shellTokenize(text, tokens, 8); + KT_EQ(t, count, 3); + KT_STREQ(t, tokens[0], "one"); + KT_STREQ(t, tokens[1], "two"); + KT_STREQ(t, tokens[2], "three"); +} + +KTEST(shellTokenizeAllSpacesYieldsNothing) +{ + char text[] = " "; + char* tokens[4]; + int count = shellTokenize(text, tokens, 4); + KT_EQ(t, count, 0); +} + +KTEST(shellTokenizeRespectsCapacityInsteadOfOverrunningArray) +{ + char text[] = "a b c d e"; + char* tokens[3]; + int count = shellTokenize(text, tokens, 3); + KT_EQ(t, count, 3); + KT_STREQ(t, tokens[0], "a"); + KT_STREQ(t, tokens[1], "b"); + KT_STREQ(t, tokens[2], "c"); +} + +KTEST(shellTokenizeTrailingAmpersandIsJustAnotherToken) +{ + /* runProgram (shell.c) is the one that gives "&" its background + meaning by inspecting the LAST token itself -- shellTokenize's own + job is only to split words apart, so it must hand "&" back + unchanged like any other token, not special-case it. */ + char text[] = "sleepjob arg1 &"; + char* tokens[8]; + int count = shellTokenize(text, tokens, 8); + KT_EQ(t, count, 3); + KT_STREQ(t, tokens[0], "sleepjob"); + KT_STREQ(t, tokens[1], "arg1"); + KT_STREQ(t, tokens[2], "&"); +} + +KTEST(shellTokenizeZeroCapacityYieldsNothing) +{ + char text[] = "one two"; + char* tokens[1]; + int count = shellTokenize(text, tokens, 0); + KT_EQ(t, count, 0); +} + +/* shellSplitPipelineStages (shell.c) is the pure-string half of the shell's + `cmd1 | cmd2 | ... | cmdN` pipeline syntax -- deciding whether execute() + should run runPipelineStages (N spawned processes joined by N-1 real + pipes) or fall back to a single command, exactly the same "pure logic, no + VFS/scheduler" split shellTokenize already has from its own doc comment + above. */ + +KTEST(shellSplitPipelineStagesNoPipeReturnsZero) +{ + char text[] = "ls -l"; + char* stages[shellMaxPipelineStages]; + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, 0); +} + +KTEST(shellSplitPipelineStagesBasicTwoStageSplit) +{ + char text[] = "producer|consumer"; + char* stages[shellMaxPipelineStages]; + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, 2); + KT_STREQ(t, stages[0], "producer"); + KT_STREQ(t, stages[1], "consumer"); +} + +KTEST(shellSplitPipelineStagesTrimsSpacesAroundEachPipe) +{ + char text[] = "pipesrc arg1 | pipecat | pipesink arg2"; + char* stages[shellMaxPipelineStages]; + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, 3); + KT_STREQ(t, stages[0], "pipesrc arg1"); + KT_STREQ(t, stages[1], "pipecat"); + KT_STREQ(t, stages[2], "pipesink arg2"); +} + +KTEST(shellSplitPipelineStagesThreeStageSplitIsRealNotJustFirstPipe) +{ + /* Unlike the old two-way shellSplitPipeline this generalizes, every + top-level '|' is now recognized -- a real N-stage pipeline, not just + the first split with the rest left embedded in one text blob. */ + char text[] = "a | b | c"; + char* stages[shellMaxPipelineStages]; + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, 3); + KT_STREQ(t, stages[0], "a"); + KT_STREQ(t, stages[1], "b"); + KT_STREQ(t, stages[2], "c"); +} + +KTEST(shellSplitPipelineStagesEmptyLastStageYieldsEmptyString) +{ + char text[] = "producer |"; + char* stages[shellMaxPipelineStages]; + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, 2); + KT_STREQ(t, stages[0], "producer"); + KT_STREQ(t, stages[1], ""); +} + +KTEST(shellSplitPipelineStagesTooManyStagesReturnsNegativeOneWithoutMutating) +{ + char text[] = "a | b | c | d | e"; + char* stages[shellMaxPipelineStages]; + /* shellMaxPipelineStages is 4, but this line has 5 stages -- must be + rejected outright, leaving `text` completely untouched, exactly the + same "malformed, nothing changed" contract shellExtractRedirects' + own dangling-operator case already uses. */ + int count = shellSplitPipelineStages(text, stages, shellMaxPipelineStages); + KT_EQ(t, count, -1); + KT_STREQ(t, text, "a | b | c | d | e"); +} + +/* shellSplitAndOr (shell.c) is the pure-string half of the shell's new + `cmd1 && cmd2 || cmd3 ...` conditional-chaining syntax -- deciding + whether execute() should short-circuit through several commands' real + exit statuses (executeSingle, shell.c) or fall back to a single + command-or-pipeline exactly as before, run BEFORE pipeline splitting + (`|` binds tighter than `&&`/`||`), the same "pure logic, no + VFS/scheduler" split every other parser above already has. */ + +KTEST(shellSplitAndOrNoOperatorReturnsZero) +{ + char text[] = "ls -l"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 0); +} + +KTEST(shellSplitAndOrBasicAndSplit) +{ + char text[] = "mkdir /tmp/a && echo ok"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 2); + KT_STREQ(t, segments[0], "mkdir /tmp/a"); + KT_STREQ(t, segments[1], "echo ok"); + KT_EQ(t, ops[0], shellAndOrAnd); +} + +KTEST(shellSplitAndOrBasicOrSplit) +{ + char text[] = "false || echo fallback"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 2); + KT_STREQ(t, segments[0], "false"); + KT_STREQ(t, segments[1], "echo fallback"); + KT_EQ(t, ops[0], shellAndOrOr); +} + +KTEST(shellSplitAndOrMixedChainRecordsEachOperatorIndependently) +{ + /* The classic shell if-then-else idiom: "run b only if a succeeded, + run c only if the segment that actually ran last (a or b) did NOT + succeed" -- proves && and || are tracked as two independently + recorded operators, not collapsed into one boolean. */ + char text[] = "a && b || c"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 3); + KT_STREQ(t, segments[0], "a"); + KT_STREQ(t, segments[1], "b"); + KT_STREQ(t, segments[2], "c"); + KT_EQ(t, ops[0], shellAndOrAnd); + KT_EQ(t, ops[1], shellAndOrOr); +} + +KTEST(shellSplitAndOrTrimsSpacesAroundEachOperator) +{ + char text[] = "a&&b"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 2); + KT_STREQ(t, segments[0], "a"); + KT_STREQ(t, segments[1], "b"); +} + +KTEST(shellSplitAndOrLeavesSingleTopLevelPipeInsideItsOwnSegment) +{ + /* '|' binds tighter than '&&'/'||': a lone pipe character must never be + mistaken for half of '||' (which only matches two CONSECUTIVE '|' + characters), so "a | b" stays a single, whole segment here -- only + execute()'s own later shellSplitPipelineStages call on that segment + ever splits it further. */ + char text[] = "a | b && c"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, 2); + KT_STREQ(t, segments[0], "a | b"); + KT_STREQ(t, segments[1], "c"); + KT_EQ(t, ops[0], shellAndOrAnd); +} + +KTEST(shellSplitAndOrTooManySegmentsReturnsNegativeOneWithoutMutating) +{ + char text[] = "a && b && c && d && e"; + char* segments[shellMaxAndOrSegments]; + ShellAndOrOp ops[shellMaxAndOrSegments - 1]; + /* shellMaxAndOrSegments is 4, but this line has 5 segments -- must be + rejected outright, leaving `text` completely untouched, the same + "malformed, nothing changed" contract shellSplitPipelineStages' own + analogous limit already uses. */ + int count = shellSplitAndOr(text, segments, ops, shellMaxAndOrSegments); + KT_EQ(t, count, -1); + KT_STREQ(t, text, "a && b && c && d && e"); +} + +/* shellExtractRedirects (shell.c) is the pure-token half of shell.c's new + `cmd [ARGS] [< IN] [> OUT | >> OUT]` file redirection -- exactly the same + "pure logic, no VFS/scheduler" split shellTokenize/shellSplitPipelineStages + already have from their own doc comments above, operating on + shellTokenize's own output shape (an already-split argv) instead of a + raw line. */ + +KTEST(shellExtractRedirectsNoOperatorsLeavesArgvUnchanged) +{ + char text[] = "cat file.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 2); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, argv[1], "file.txt"); + KT_NULL(t, redirect.inPath); + KT_NULL(t, redirect.outPath); +} + +KTEST(shellExtractRedirectsOutputTruncate) +{ + char text[] = "sort > out.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "sort"); + KT_NULL(t, redirect.inPath); + KT_NOTNULL(t, redirect.outPath); + KT_STREQ(t, redirect.outPath, "out.txt"); + KT_FALSE(t, redirect.appendOut); +} + +KTEST(shellExtractRedirectsOutputAppend) +{ + char text[] = "sort >> out.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "sort"); + KT_STREQ(t, redirect.outPath, "out.txt"); + KT_TRUE(t, redirect.appendOut); +} + +KTEST(shellExtractRedirectsInputAndOutputTogetherKeepRealArgs) +{ + char text[] = "sort -r < in.txt > out.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 2); + KT_STREQ(t, argv[0], "sort"); + KT_STREQ(t, argv[1], "-r"); + KT_STREQ(t, redirect.inPath, "in.txt"); + KT_STREQ(t, redirect.outPath, "out.txt"); + KT_FALSE(t, redirect.appendOut); +} + +KTEST(shellExtractRedirectsOperatorsCanComeBeforeRealArgs) +{ + /* A real shell accepts redirection operators anywhere on the line, not + only trailing -- "> out.txt cat file.txt" is a weird but legal way + to write "cat file.txt > out.txt". */ + char text[] = "> out.txt cat file.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 2); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, argv[1], "file.txt"); + KT_STREQ(t, redirect.outPath, "out.txt"); +} + +KTEST(shellExtractRedirectsLastOutputOperatorWins) +{ + char text[] = "cat > a.txt >> b.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, redirect.outPath, "b.txt"); + KT_TRUE(t, redirect.appendOut); +} + +KTEST(shellExtractRedirectsDanglingOperatorFailsWithoutMutating) +{ + char text[] = "cat file.txt >"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + int originalArgc = argc; + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_FALSE(t, ok); + KT_EQ(t, argc, originalArgc); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, argv[1], "file.txt"); + KT_STREQ(t, argv[2], ">"); +} + +KTEST(shellExtractRedirectsNoTokensYieldsNoRedirects) +{ + char* argv[1]; + int argc = 0; + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 0); + KT_NULL(t, redirect.inPath); + KT_NULL(t, redirect.outPath); +} + +/* `2>`/`2>>` (stderr redirection) are a separate operator family from + `<`/`>`/`>>` above, targeting fd 2 instead of fd 0/fd 1 -- covered on + their own (truncate, append, combined with stdout, last-one-wins, + dangling) plus one case proving the two families never interfere with + each other even when both appear on the same line. */ + +KTEST(shellExtractRedirectsErrTruncate) +{ + char text[] = "prog 2> err.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "prog"); + KT_NULL(t, redirect.inPath); + KT_NULL(t, redirect.outPath); + KT_NOTNULL(t, redirect.errPath); + KT_STREQ(t, redirect.errPath, "err.txt"); + KT_FALSE(t, redirect.appendErr); +} + +KTEST(shellExtractRedirectsErrAppend) +{ + char text[] = "prog 2>> err.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "prog"); + KT_STREQ(t, redirect.errPath, "err.txt"); + KT_TRUE(t, redirect.appendErr); +} + +KTEST(shellExtractRedirectsStdoutAndStderrTogetherKeepRealArgs) +{ + char text[] = "sort -r > out.txt 2> err.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 2); + KT_STREQ(t, argv[0], "sort"); + KT_STREQ(t, argv[1], "-r"); + KT_STREQ(t, redirect.outPath, "out.txt"); + KT_FALSE(t, redirect.appendOut); + KT_STREQ(t, redirect.errPath, "err.txt"); + KT_FALSE(t, redirect.appendErr); +} + +KTEST(shellExtractRedirectsLastErrOperatorWins) +{ + char text[] = "cat 2> a.txt 2>> b.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, redirect.errPath, "b.txt"); + KT_TRUE(t, redirect.appendErr); +} + +KTEST(shellExtractRedirectsDanglingErrOperatorFailsWithoutMutating) +{ + char text[] = "cat file.txt 2>"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + int originalArgc = argc; + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_FALSE(t, ok); + KT_EQ(t, argc, originalArgc); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, argv[1], "file.txt"); + KT_STREQ(t, argv[2], "2>"); +} + +/* `<<<` (here-string) is a third, independent fd-0 operator alongside `<` -- + covered on its own (basic recognition, combined with real args and `>`, + dangling), plus the two last-one-wins cases proving `<` and `<<<` share + ownership of the SAME fd 0 (whichever comes later in the line wins, + clearing the other field entirely) rather than being two unrelated + fields a caller could accidentally read both of at once. */ + +KTEST(shellExtractRedirectsHereStringBasic) +{ + char text[] = "cat <<< hello"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, redirect.hereString, "hello"); + KT_NULL(t, redirect.inPath); +} + +KTEST(shellExtractRedirectsHereStringWithRealArgsAndOutput) +{ + char text[] = "sort -r <<< hello > out.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 2); + KT_STREQ(t, argv[0], "sort"); + KT_STREQ(t, argv[1], "-r"); + KT_STREQ(t, redirect.hereString, "hello"); + KT_STREQ(t, redirect.outPath, "out.txt"); + KT_NULL(t, redirect.inPath); +} + +KTEST(shellExtractRedirectsDanglingHereStringFailsWithoutMutating) +{ + char text[] = "cat <<<"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + int originalArgc = argc; + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_FALSE(t, ok); + KT_EQ(t, argc, originalArgc); + KT_STREQ(t, argv[0], "cat"); + KT_STREQ(t, argv[1], "<<<"); +} + +KTEST(shellExtractRedirectsHereStringAfterFileRedirectWins) +{ + char text[] = "cat < a.txt <<< b"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, redirect.hereString, "b"); + KT_NULL(t, redirect.inPath); +} + +KTEST(shellExtractRedirectsFileRedirectAfterHereStringWins) +{ + char text[] = "cat <<< b < a.txt"; + char* argv[8]; + int argc = shellTokenize(text, argv, 8); + ShellRedirect redirect; + bool ok = shellExtractRedirects(argv, &argc, &redirect); + KT_TRUE(t, ok); + KT_EQ(t, argc, 1); + KT_STREQ(t, redirect.inPath, "a.txt"); + KT_NULL(t, redirect.hereString); +} + +/* shellParseVarRef (shell.c) is the pure-syntax half of the shell's new + `$NAME` unquoted parameter expansion -- recognizing the "$" + name-chars + shape, never touching sched.h's Thread.env (that lookup is + shellExpandArgv's job, not KTEST-able here for the same pre-scheduler + reason shellTokenize's own file comment gives). */ +KTEST(shellParseVarRefAcceptsPlainName) +{ + char name[16]; + bool ok = shellParseVarRef("$HOME", name, sizeof(name)); + KT_TRUE(t, ok); + KT_STREQ(t, name, "HOME"); +} + +KTEST(shellParseVarRefAcceptsDigitsAndUnderscore) +{ + char name[16]; + bool ok = shellParseVarRef("$_var_2", name, sizeof(name)); + KT_TRUE(t, ok); + KT_STREQ(t, name, "_var_2"); +} + +KTEST(shellParseVarRefRejectsBareDollarSign) +{ + char name[16]; + KT_FALSE(t, shellParseVarRef("$", name, sizeof(name))); +} + +KTEST(shellParseVarRefRejectsTokenWithoutLeadingDollar) +{ + char name[16]; + KT_FALSE(t, shellParseVarRef("HOME", name, sizeof(name))); +} + +KTEST(shellParseVarRefRejectsEmbeddedPunctuation) +{ + char name[16]; + KT_FALSE(t, shellParseVarRef("$FOO=bar", name, sizeof(name))); + KT_FALSE(t, shellParseVarRef("$FOO/bar", name, sizeof(name))); +} + +KTEST(shellParseVarRefRejectsNameTooLongForCapacity) +{ + char name[4]; + KT_FALSE(t, shellParseVarRef("$TOOLONG", name, sizeof(name))); +} + +/* shellParseVarRef must never accept `$?` itself -- '?' is not in its own + [A-Za-z0-9_] name-character set -- so the two token classes + (shellIsStatusVarToken below and shellParseVarRef above) can never + both claim the same token. */ +KTEST(shellParseVarRefRejectsQuestionMark) +{ + char name[16]; + KT_FALSE(t, shellParseVarRef("$?", name, sizeof(name))); +} + +/* shellIsStatusVarToken (shell.c) is the pure-syntax half of the shell's + new `$?` special parameter -- recognizing the EXACT two-character token + "$?", never touching this file's own file-scope `lastStatus` (that + substitution is shellExpandToken's job, not KTEST-able here for the + same pre-scheduler reason shellExpandArgv's own env lookups are not). */ +KTEST(shellIsStatusVarTokenAcceptsExactMatch) +{ + KT_TRUE(t, shellIsStatusVarToken("$?")); +} + +KTEST(shellIsStatusVarTokenRejectsBareDollarSign) +{ + KT_FALSE(t, shellIsStatusVarToken("$")); +} + +KTEST(shellIsStatusVarTokenRejectsBareQuestionMark) +{ + KT_FALSE(t, shellIsStatusVarToken("?")); +} + +KTEST(shellIsStatusVarTokenRejectsTrailingGarbage) +{ + KT_FALSE(t, shellIsStatusVarToken("$?x")); +} + +KTEST(shellIsStatusVarTokenRejectsLeadingGarbage) +{ + KT_FALSE(t, shellIsStatusVarToken("x$?")); +} + +/* shellGlobMatch/shellTokenHasGlob/shellSplitGlobToken (shell.c) are the + pure-logic half of the shell's new wildcard glob expansion + (shellExpandGlobs, which actually walks the VFS and is not KTEST-able + here for the same pre-scheduler reason shellExpandArgv's own VFS/thread + lookups are not) -- the same "pure logic here, thread/VFS-touching glue + stays static in shell.c" split every other pure function above already + follows. */ + +KTEST(shellGlobMatchExactLiteral) +{ + KT_TRUE(t, shellGlobMatch("hello", "hello")); + KT_FALSE(t, shellGlobMatch("hello", "hellox")); + KT_FALSE(t, shellGlobMatch("hello", "hell")); +} + +KTEST(shellGlobMatchStarMatchesAnySuffix) +{ + KT_TRUE(t, shellGlobMatch("a*", "a")); + KT_TRUE(t, shellGlobMatch("a*", "abc")); + KT_FALSE(t, shellGlobMatch("a*", "xabc")); +} + +KTEST(shellGlobMatchStarMatchesAnyPrefix) +{ + KT_TRUE(t, shellGlobMatch("*.txt", "a.txt")); + KT_TRUE(t, shellGlobMatch("*.txt", ".txt")); + KT_FALSE(t, shellGlobMatch("*.txt", "a.log")); +} + +KTEST(shellGlobMatchStarInTheMiddle) +{ + KT_TRUE(t, shellGlobMatch("a*b", "ab")); + KT_TRUE(t, shellGlobMatch("a*b", "axxxb")); + KT_FALSE(t, shellGlobMatch("a*b", "axxxc")); +} + +KTEST(shellGlobMatchMultipleStars) +{ + KT_TRUE(t, shellGlobMatch("*a*b*", "aXbY")); + KT_TRUE(t, shellGlobMatch("*a*b*", "ab")); + KT_FALSE(t, shellGlobMatch("*a*b*", "ba")); +} + +KTEST(shellGlobMatchBareStarMatchesEverythingIncludingEmpty) +{ + KT_TRUE(t, shellGlobMatch("*", "")); + KT_TRUE(t, shellGlobMatch("*", "anything")); +} + +KTEST(shellGlobMatchQuestionMarkMatchesExactlyOneChar) +{ + KT_TRUE(t, shellGlobMatch("a?c", "abc")); + KT_FALSE(t, shellGlobMatch("a?c", "ac")); + KT_FALSE(t, shellGlobMatch("a?c", "abbc")); +} + +KTEST(shellGlobMatchEmptyPatternOnlyMatchesEmptyName) +{ + KT_TRUE(t, shellGlobMatch("", "")); + KT_FALSE(t, shellGlobMatch("", "x")); +} + +KTEST(shellTokenHasGlobDetectsStarAndQuestionMark) +{ + KT_TRUE(t, shellTokenHasGlob("*.txt")); + KT_TRUE(t, shellTokenHasGlob("file?.log")); + KT_FALSE(t, shellTokenHasGlob("plain.txt")); + KT_FALSE(t, shellTokenHasGlob("")); +} + +KTEST(shellSplitGlobTokenNoSlashYieldsEmptyDir) +{ + char dir[64]; + char pattern[64]; + shellSplitGlobToken("*.txt", dir, sizeof(dir), pattern, sizeof(pattern)); + KT_STREQ(t, dir, ""); + KT_STREQ(t, pattern, "*.txt"); +} + +KTEST(shellSplitGlobTokenSplitsAtLastSlashIncludingIt) +{ + char dir[64]; + char pattern[64]; + shellSplitGlobToken("/tmp/gd/*.txt", dir, sizeof(dir), pattern, sizeof(pattern)); + KT_STREQ(t, dir, "/tmp/gd/"); + KT_STREQ(t, pattern, "*.txt"); +} + +KTEST(shellSplitGlobTokenNoGlobCharStillSplitsCleanly) +{ + /* shellSplitGlobToken itself does not care whether the pattern half + actually contains a glob character -- that gate is + shellTokenHasGlob's own job, called first by shellExpandGlobs. */ + char dir[64]; + char pattern[64]; + shellSplitGlobToken("/tmp/gd/plain.txt", dir, sizeof(dir), pattern, sizeof(pattern)); + KT_STREQ(t, dir, "/tmp/gd/"); + KT_STREQ(t, pattern, "plain.txt"); +} + +/* shellFindCommandSubst (shell.c) is the pure-syntax half of the shell's + new `$(cmd [args])` command-substitution expansion -- the impure glue + that actually spawns and captures a command's output (shellExpandCommandSubst, + static in shell.c) needs a real jobctl spawn, off limits for KTEST's + pre-scheduler context, so only the locate-and-extract half is covered + here, the same "pure logic here, thread/VFS-touching glue stays static + in shell.c" split every other shell.h syntax function already gets. */ + +KTEST(shellFindCommandSubstNoDollarParenReturnsNotFound) +{ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("echo hello", &start, &end, cmd, sizeof(cmd))); + KT_EQ(t, start, -1); +} + +KTEST(shellFindCommandSubstWholeToken) +{ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("$(date)", &start, &end, cmd, sizeof(cmd))); + KT_EQ(t, start, 0); + KT_EQ(t, end, 7); + KT_STREQ(t, cmd, "date"); +} + +KTEST(shellFindCommandSubstEmbeddedInLargerLine) +{ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("echo $(date) done", &start, &end, cmd, sizeof(cmd))); + KT_EQ(t, start, 5); + KT_EQ(t, end, 12); + KT_STREQ(t, cmd, "date"); +} + +KTEST(shellFindCommandSubstCapturesMultiWordCommand) +{ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("argecho $(argecho a b)", &start, &end, cmd, sizeof(cmd))); + KT_STREQ(t, cmd, "argecho a b"); +} + +KTEST(shellFindCommandSubstUnterminatedIsMalformed) +{ + int start, end; + char cmd[32]; + KT_FALSE(t, shellFindCommandSubst("echo $(date", &start, &end, cmd, sizeof(cmd))); +} + +KTEST(shellFindCommandSubstEmptyParensYieldsEmptyCommand) +{ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("$()", &start, &end, cmd, sizeof(cmd))); + KT_EQ(t, start, 0); + KT_EQ(t, end, 3); + KT_STREQ(t, cmd, ""); +} + +KTEST(shellFindCommandSubstTruncatesToCapacity) +{ + int start, end; + char cmd[4]; + KT_TRUE(t, shellFindCommandSubst("$(longcommand)", &start, &end, cmd, sizeof(cmd))); + KT_STREQ(t, cmd, "lon"); +} + +KTEST(shellFindCommandSubstOnlyMatchesFirstOccurrence) +{ + /* No nested-paren support (documented in shell.h) -- the very NEXT ')' + after the opening '$(' ends the match, so a second, unrelated + `$(...)` later in the line is left completely untouched for a + caller's own repeat scan (shellExpandCommandSubst's own loop) to + find on a later pass, never folded into this one's own cmdOut. */ + int start, end; + char cmd[32]; + KT_TRUE(t, shellFindCommandSubst("$(a) $(b)", &start, &end, cmd, sizeof(cmd))); + KT_EQ(t, start, 0); + KT_EQ(t, end, 4); + KT_STREQ(t, cmd, "a"); +} diff --git a/src/sigTest.c b/src/sigTest.c new file mode 100644 index 0000000..5ed983f --- /dev/null +++ b/src/sigTest.c @@ -0,0 +1,1247 @@ +#include "ktest.h" +#include "sched.h" +#include "signal.h" +#include "ports.h" +#include "string.h" +#include "gdt.h" + +/* + * Pure logic for signal.c's delivery decision (sigConsumePending) and its + * supporting state (sigRaise/sigSetHandler/sigProcMask/sigResetState/ + * sigOnSigReturn) -- all fully testable without a live scheduler or a real + * ring-3 transition, since they only ever touch a Thread's own fields and a + * caller-supplied Registers snapshot. The one thing this file cannot cover + * (a real trapframe actually being rewritten so iretq lands ring 3 + * execution inside the shared trampoline page, the handler genuinely + * running, and sysSigReturn genuinely resuming afterwards) is proven live + * instead by sigtest.c/sigtarget.c/sigdefault.c (ci-test.sh needles + * "sigtest: handler-roundtrip-ok" / "sigtest: default-terminate-ok"). + */ + +static void noopThreadEntry(void) +{ + for (;;) + halt(); +} + +/* A plausible "interrupted in ring 3, mid-instruction" snapshot: distinct, + recognizable values in every field sigConsumePending/sigOnSigReturn are + expected to touch (rip/rax/rdi) or must leave alone until sigOnSigReturn + restores them (rbx/rsp/cs/ss/rflags), so a test can tell "changed" from + "still original" without ambiguity. */ +static Registers makeUserRegs(void) +{ + Registers regs; + memset(®s, 0, sizeof(regs)); + regs.rip = 0x40001234ull; + regs.rax = 0xAAAAAAAAull; + regs.rdi = 0xBBBBBBBBull; + regs.rbx = 0xCCCCCCCCull; + regs.rsp = 0x48003FF0ull; + regs.cs = userCodeSelector | 3; + regs.ss = userDataSelector | 3; + regs.rflags = 0x202; + return regs; +} + +KTEST(sigRaiseSetsPendingBitAndIsIdempotent) +{ + uint32 id = threadCreate("sigRaiseTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, sigRaise(id, sigUsr1)); + KT_EQU(t, threadAt(id)->sigPending, (1ull << sigUsr1)); + + /* Raising the same signal again while it is still pending changes + nothing -- there is exactly one bit per signal number, not a queue + or a count, matching real Unix's non-queuing signals. */ + KT_TRUE(t, sigRaise(id, sigUsr1)); + KT_EQU(t, threadAt(id)->sigPending, (1ull << sigUsr1)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseRejectsOutOfRangeSignumAndBadTarget) +{ + uint32 id = threadCreate("sigRaiseBad", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_FALSE(t, sigRaise(id, 0)); + KT_FALSE(t, sigRaise(id, -1)); + KT_FALSE(t, sigRaise(id, sigMaxSignals)); + + KT_FALSE(t, sigRaise(0, sigUsr1)); + KT_FALSE(t, sigRaise(threadCapacity(), sigUsr1)); + KT_FALSE(t, sigRaise(threadCapacity() + 1000, sigUsr1)); + + KT_TRUE(t, threadDiscard(id)); + + /* Slot just discarded is threadUnused again -- a signal aimed at it + must fail exactly like one aimed at a never-created id. */ + KT_FALSE(t, sigRaise(id, sigUsr1)); +} + +KTEST(sigRaiseSigKillDelegatesToThreadKill) +{ + uint32 id = threadCreate("sigKillTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* threadKill only accepts Runnable/Sleeping/Blocked; a freshly created + thread is Runnable, so this is the same live-target case + threadKillMarksRunnableThreadDeadWithKilledExitCode already covers + for threadKill directly -- this test is about sigRaise's OWN + dispatch reaching it, not re-testing threadKill's state machine. */ + KT_TRUE(t, sigRaise(id, sigKill)); + KT_EQ(t, threadAt(id)->state, threadDead); + KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode); + + /* sigKill never touches sigPending -- it bypasses the pending/blocked + machinery entirely (see signal.h's file comment). */ + KT_EQU(t, threadAt(id)->sigPending, 0ull); + + KT_TRUE(t, threadReap(id)); +} + +KTEST(sigRaiseSigStopDelegatesToThreadStopAndBypassesPending) +{ + uint32 id = threadCreate("sigStopTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, sigRaise(id, sigStop)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + /* Like sigKill, sigStop never touches sigPending -- it bypasses the + pending/blocked/handler machinery entirely (see signal.h's file + comment and sigRaiseSigKillDelegatesToThreadKill above). */ + KT_EQU(t, threadAt(id)->sigPending, 0ull); + + /* Real kill(pid, SIGKILL) kills a stopped process too (threadKill was + widened to accept threadStopped alongside threadStop's own + introduction). */ + KT_TRUE(t, threadKill(id)); + KT_TRUE(t, threadReap(id)); +} + +KTEST(sigRaiseSigStopRejectsInvalidTargetsLikeThreadStop) +{ + KT_FALSE(t, sigRaise(0, sigStop)); + KT_FALSE(t, sigRaise(threadCapacity(), sigStop)); + KT_FALSE(t, sigRaise(threadCapacity() + 1000, sigStop)); +} + +KTEST(sigRaiseSigContResumesStoppedTargetUnconditionally) +{ + uint32 id = threadCreate("sigContTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, sigRaise(id, sigStop)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + /* sigCont, unlike sigStop, still falls through to the generic pending + path afterwards -- but the resume itself (threadContinue) happens + first, as an unconditional side effect, regardless of sigBlocked: + blocking EVERYTHING (short of sigKill/sigStop, which sigProcMask + always strips) must not stop the actual resume. */ + sigProcMask(threadAt(id), sigMaskSetMask, sigAllBlocked); + KT_TRUE(t, sigRaise(id, sigCont)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + KT_TRUE(t, threadAt(id)->sigPending & (1ull << sigCont)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseSigContOnNonStoppedTargetStillDeliversSignal) +{ + uint32 id = threadCreate("sigContNotStopped", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + /* Never stopped: SIGCONT here is a no-op at the process-state level + (real Unix: continuing an already-running process does nothing extra), + but the signal itself is still delivered -- an installed handler + still runs through the normal sigConsumePending scan regardless. */ + KT_TRUE(t, sigRaise(id, sigCont)); + KT_EQ(t, threadAt(id)->state, threadRunnable); + KT_TRUE(t, threadAt(id)->sigPending & (1ull << sigCont)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseWakesSleepingOrBlockedTargetEarlyForADeliverableSignal) +{ + uint32 id = threadCreate("sigWakeSleeping", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + /* No handler installed: sigTerm's default disposition is Terminate, + which sigWouldDeliver treats as genuinely deliverable -- exactly the + user/eintrtarget.c scenario (a thread blocked on a condition that + will never naturally occur, recovered by an ORDINARY signal, not + threadKill's direct bypass). */ + thread->state = threadSleeping; + KT_TRUE(t, sigRaise(id, sigTerm)); + KT_EQ(t, thread->state, threadRunnable); + + thread->state = threadBlocked; + KT_TRUE(t, sigRaise(id, sigTstp)); + KT_EQ(t, thread->state, threadRunnable); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseDoesNotWakeSleepingOrBlockedTargetForABlockedOrIgnoredSignal) +{ + uint32 id = threadCreate("sigNoWakeBlocked", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + /* sigUsr1 explicitly blocked: must not disturb the sleep at all, same + as real Unix -- a blocked signal cannot interrupt anything. */ + sigProcMask(thread, sigMaskBlock, (1ull << sigUsr1)); + thread->state = threadSleeping; + KT_TRUE(t, sigRaise(id, sigUsr1)); + KT_EQ(t, thread->state, threadSleeping); + KT_TRUE(t, thread->sigPending & (1ull << sigUsr1)); + + /* sigChld's real-world default is Ignore -- dismissed outright, never + delivered, so it must not wake anything either. */ + thread->state = threadBlocked; + KT_TRUE(t, sigRaise(id, sigChld)); + KT_EQ(t, thread->state, threadBlocked); + + /* An explicit sigHandlerIgnore on an otherwise-Terminate signal is the + same story. */ + sigSetHandler(thread, sigUsr2, sigHandlerIgnore, 0, 0); + KT_TRUE(t, sigRaise(id, sigUsr2)); + KT_EQ(t, thread->state, threadBlocked); + + threadAt(id)->state = threadRunnable; + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseDoesNotEarlyWakeForSignalMaskedByActiveHandlersSaMask) +{ + /* The gap the two tests above do NOT cover: sigWouldDeliver (and + therefore threadWakeForSignal) reads thread->sigBlocked verbatim, + with no idea whether any given bit got there via an explicit + sigProcMask call (tested above) or via sigConsumePending's own + handler-entry union of sigHandlerMasks[signum] (the file comment's + per-signal sa_mask paragraph). This drives the SECOND path for real + -- actually entering a handler via sigConsumePending, not just + calling sigProcMask by hand -- to prove the early-wake mechanism + (FALA 16) genuinely respects a mask that arrived that way too, the + exact interaction FALA 19 flagged as unexamined before deferring + this whole topic. user/masktarget.c is this same scenario live: its + onUsr1 handler (sa_mask = sigHup's bit here, sigUsr2's there) blocks + on a real read WHILE the mask is active, so an early wake bug here + would hang it forever instead of just failing a check. */ + uint32 id = threadCreate("sigMaskedNoEarlyWake", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + uint64 usr1Handler = 0x40009000ull; + sigSetHandler(thread, sigUsr1, usr1Handler, (1ull << sigHup), 0); + sigRaise(id, sigUsr1); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_TRUE(t, thread->sigBlocked & (1ull << sigHup)); + + /* onUsr1 is now "running" (as far as this thread's own state is + concerned) and immediately blocks on something real, exactly like + user/masktarget.c's own userRead(goFd) call from inside its + handler. */ + thread->state = threadBlocked; + + /* sigHup is covered by onUsr1's sa_mask -- sigWouldDeliver must say no, + so threadWakeForSignal is never even called: the Blocked state (and + therefore whatever real wait this models) must survive completely + undisturbed, same as the explicit-sigProcMask case above. */ + KT_TRUE(t, sigRaise(id, sigHup)); + KT_EQ(t, thread->state, threadBlocked); + KT_TRUE(t, thread->sigPending & (1ull << sigHup)); + + /* Once onUsr1 genuinely returns (sigOnSigReturn, restoring sigBlocked + to what it was before -- no sigHup bit), the deferred sigHup is + deliverable again, but this thread is STILL Blocked (the read it was + modelling has not itself completed) -- sigOnSigReturn only unmasks, + it is not itself a wake source, so nothing here should move the + thread back to Runnable behind the scheduler's own back. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_FALSE(t, thread->sigBlocked & (1ull << sigHup)); + KT_EQ(t, thread->state, threadBlocked); + + threadAt(id)->state = threadRunnable; + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseNeverWakesAStoppedTargetForAnOrdinarySignal) +{ + uint32 id = threadCreate("sigStoppedNoWake", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + + KT_TRUE(t, threadStop(id)); + KT_EQ(t, threadAt(id)->state, threadStopped); + + /* sigTerm would genuinely deliver against a Runnable/Sleeping/Blocked + target (sigWouldDeliver says yes -- no handler, default Terminate, + not blocked) but job control's "stopped means stopped" invariant + must survive it existing at all: only threadContinue (an explicit + SIGCONT) may ever move a Stopped thread, exactly like real Unix + (a stopped process does not die of SIGTERM either, until continued + or SIGKILLed). */ + KT_TRUE(t, sigRaise(id, sigTerm)); + KT_EQ(t, threadAt(id)->state, threadStopped); + KT_TRUE(t, threadAt(id)->sigPending & (1ull << sigTerm)); + + KT_TRUE(t, threadKill(id)); + KT_TRUE(t, threadReap(id)); +} + +KTEST(sigSetHandlerInstallsAndReturnsPreviousDisposition) +{ + uint32 id = threadCreate("sigHandlerTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + KT_EQU(t, sigSetHandler(thread, sigUsr1, 0x40005000ull, 0, 0), sigHandlerDefault); + KT_EQU(t, thread->sigHandlers[sigUsr1], 0x40005000ull); + + KT_EQU(t, sigSetHandler(thread, sigUsr1, sigHandlerIgnore, 0, 0), 0x40005000ull); + KT_EQU(t, thread->sigHandlers[sigUsr1], sigHandlerIgnore); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigSetHandlerRejectsSigKillAndOutOfRangeSignum) +{ + uint32 id = threadCreate("sigHandlerBad", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + KT_EQU(t, sigSetHandler(thread, sigKill, 0x40005000ull, 0, 0), sigSetFailed); + KT_EQU(t, thread->sigHandlers[sigKill], sigHandlerDefault); + + KT_EQU(t, sigSetHandler(thread, 0, 0x40005000ull, 0, 0), sigSetFailed); + KT_EQU(t, sigSetHandler(thread, sigMaxSignals, 0x40005000ull, 0, 0), sigSetFailed); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigSetHandlerRejectsSigStopTooLikeRealEinval) +{ + uint32 id = threadCreate("sigStopHandlerBad", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + KT_EQU(t, sigSetHandler(thread, sigStop, 0x40005000ull, 0, 0), sigSetFailed); + KT_EQU(t, thread->sigHandlers[sigStop], sigHandlerDefault); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigSetHandlerStoresMaskStrippedOfSigKillAndSigStop) +{ + uint32 id = threadCreate("sigMaskStoreTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + sigSetHandler(thread, sigUsr1, 0x40005000ull, + (1ull << sigUsr2) | (1ull << sigKill) | (1ull << sigStop), 0); + KT_EQU(t, thread->sigHandlerMasks[sigUsr1], (1ull << sigUsr2)); + + /* Installing a handler for a DIFFERENT signum must not disturb sigUsr1's + own stored mask -- sigHandlerMasks[] is indexed by the signal being + handled, one independent slot per signal number, same as + sigHandlers[] itself already is. */ + sigSetHandler(thread, sigTerm, 0x40006000ull, (1ull << sigHup), 0); + KT_EQU(t, thread->sigHandlerMasks[sigUsr1], (1ull << sigUsr2)); + KT_EQU(t, thread->sigHandlerMasks[sigTerm], (1ull << sigHup)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigProcMaskBlockUnblockSetMaskReturnPreviousAndStripSigKill) +{ + uint32 id = threadCreate("sigMaskTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + KT_EQU(t, sigProcMask(thread, sigMaskBlock, (1ull << sigUsr1)), 0ull); + KT_EQU(t, thread->sigBlocked, (1ull << sigUsr1)); + + KT_EQU(t, sigProcMask(thread, sigMaskBlock, (1ull << sigUsr2)), + (1ull << sigUsr1)); + KT_EQU(t, thread->sigBlocked, (1ull << sigUsr1) | (1ull << sigUsr2)); + + KT_EQU(t, sigProcMask(thread, sigMaskUnblock, (1ull << sigUsr1)), + (1ull << sigUsr1) | (1ull << sigUsr2)); + KT_EQU(t, thread->sigBlocked, (1ull << sigUsr2)); + + KT_EQU(t, sigProcMask(thread, sigMaskSetMask, (1ull << sigTerm)), + (1ull << sigUsr2)); + KT_EQU(t, thread->sigBlocked, (1ull << sigTerm)); + + /* sigKill can never be blocked, matching real Unix: its bit is + silently stripped from whatever mask is passed in, for BOTH + sigMaskBlock and sigMaskSetMask. */ + KT_EQU(t, sigProcMask(thread, sigMaskSetMask, sigAllBlocked), (1ull << sigTerm)); + KT_FALSE(t, thread->sigBlocked & (1ull << sigKill)); + /* sigStop can never be blocked either, same treatment as sigKill. */ + KT_FALSE(t, thread->sigBlocked & (1ull << sigStop)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingReturnsNoneWhenNothingIsPendingOrEverythingIsBlocked) +{ + uint32 id = threadCreate("sigConsumeNone", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeNone); + KT_EQ(t, signum, -1); + + sigRaise(id, sigUsr1); + sigProcMask(thread, sigMaskBlock, (1ull << sigUsr1)); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeNone); + /* Blocked, not delivered: the pending bit must survive untouched for + whenever it eventually gets unblocked. */ + KT_TRUE(t, thread->sigPending & (1ull << sigUsr1)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingDismissesIgnoredSignalsAndKeepsScanning) +{ + uint32 id = threadCreate("sigConsumeIgnore", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + /* sigChld's real-world default disposition is Ignore -- with no + handler installed at all it must be silently dismissed, not + terminate the thread. */ + sigRaise(id, sigChld); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeNone); + KT_FALSE(t, thread->sigPending & (1ull << sigChld)); + + /* An explicit sigHandlerIgnore dismisses a signal whose default would + otherwise be Terminate. */ + sigSetHandler(thread, sigUsr1, sigHandlerIgnore, 0, 0); + sigRaise(id, sigUsr1); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeNone); + KT_FALSE(t, thread->sigPending & (1ull << sigUsr1)); + + /* An ignored signal must not mask a REAL one raised alongside it in the + same scan: sigHup (explicit sigHandlerIgnore here) sits at a LOWER + signal number than sigTerm (default Terminate, no handler), so the + scan hits the dismissed one first and must keep going instead of + stopping there. */ + sigSetHandler(thread, sigHup, sigHandlerIgnore, 0, 0); + sigRaise(id, sigHup); + sigRaise(id, sigTerm); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeTerminate); + KT_EQ(t, signum, sigTerm); + KT_FALSE(t, thread->sigPending & (1ull << sigHup)); + KT_FALSE(t, thread->sigPending & (1ull << sigTerm)); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingDefaultDispositionReturnsTerminate) +{ + uint32 id = threadCreate("sigConsumeTerm", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + Registers before = regs; + int signum = -1; + + sigRaise(id, sigTerm); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeTerminate); + KT_EQ(t, signum, sigTerm); + KT_FALSE(t, thread->sigPending & (1ull << sigTerm)); + + /* Terminate is reported, not carried out, here -- sigConsumePending has + no way to hand back a new rsp for a thread it just killed, so it + must not touch the trapframe at all; isr.c's deliverPendingSignals + is the one that actually calls threadExit. */ + KT_EQU(t, regs.rip, before.rip); + KT_EQU(t, regs.rax, before.rax); + KT_EQU(t, thread->sigHandlerDepth, 0u); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingSigTstpDefaultDispositionReturnsStopOutcome) +{ + uint32 id = threadCreate("sigConsumeTstp", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + Registers before = regs; + int signum = -1; + + /* SIGTSTP's real-world default disposition is Stop, not Terminate -- + the catchable half of job control (see sigDefaultIsStop, signal.c). + sigStop itself never reaches here at all (sigRaise bypasses this + whole scan for it, see sigRaiseSigStopDelegatesToThreadStopAndBypassesPending + above), so this is the ONLY way sigOutcomeStop is ever produced. */ + sigRaise(id, sigTstp); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeStop); + KT_EQ(t, signum, sigTstp); + KT_FALSE(t, thread->sigPending & (1ull << sigTstp)); + + /* Reported, not carried out, here -- isr.c's deliverPendingSignals is + the one that actually calls threadStop, the same contract + sigOutcomeTerminate already has with threadExit. */ + KT_EQU(t, regs.rip, before.rip); + KT_EQU(t, regs.rax, before.rax); + KT_EQU(t, thread->sigHandlerDepth, 0u); + KT_EQ(t, thread->state, threadRunnable); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingSigTstpWithHandlerRunsHandlerInsteadOfStopping) +{ + uint32 id = threadCreate("sigConsumeTstpCaught", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + /* Unlike SIGSTOP, SIGTSTP is fully catchable: an installed handler + overrides the default Stop disposition, exactly like any other + catchable signal overriding a default Terminate. */ + uint64 handlerAddr = 0x4000A000ull; + sigSetHandler(thread, sigTstp, handlerAddr, 0, 0); + sigRaise(id, sigTstp); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigTstp); + KT_EQU(t, regs.rax, handlerAddr); + KT_EQU(t, regs.rip, sigTrampolineVirtualAddr); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingSaMaskDefersDifferentSignalUntilHandlerReturns) +{ + uint32 id = threadCreate("sigSaMaskTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + /* sigUsr1's sa_mask explicitly includes sigHup -- unlike the nesting + test below (mask 0), THIS is what should keep sigHup from + interrupting sigUsr1's handler at all. */ + uint64 usr1Handler = 0x40009000ull; + uint64 hupHandler = 0x4000C000ull; + sigSetHandler(thread, sigUsr1, usr1Handler, (1ull << sigHup), 0); + sigSetHandler(thread, sigHup, hupHandler, 0, 0); + + sigRaise(id, sigUsr1); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + KT_TRUE(t, thread->sigBlocked & (1ull << sigHup)); + + /* sigHup arrives while sigUsr1's handler is active -- masked by its + sa_mask, so it must stay pending, NOT nest, unlike an unmasked + signal would (see the nesting test below). */ + sigRaise(id, sigHup); + int deferredSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &deferredSignum), sigOutcomeNone); + KT_EQ(t, deferredSignum, -1); + KT_TRUE(t, thread->sigPending & (1ull << sigHup)); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + /* Once sigUsr1's handler returns, its sa_mask's grip on sigHup ends + with it (sigBlocked restored to what it was before) -- the deferred + signal is finally deliverable, as its own TOP-LEVEL handler, not a + nested one. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_FALSE(t, thread->sigBlocked & (1ull << sigHup)); + + int finalSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &finalSignum), sigOutcomeHandler); + KT_EQ(t, finalSignum, sigHup); + KT_EQU(t, regs.rax, hupHandler); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + KT_TRUE(t, threadDiscard(id)); +} + +/* Default (sigFlagNoDefer NOT set) half of the contrast: a signal raised + again against ITSELF while its own handler is still active must stay + merely pending, not nest -- sigConsumePendingCustomHandlerRewritesTrapframeAndPushesContext + above already asserts sigUsr1's own bit lands in sigBlocked once its + handler starts; this test is the direct behavioral consequence of that + bit actually being there, driven the same way + sigConsumePendingSaMaskDefersDifferentSignalUntilHandlerReturns drives + sa_mask's deferral. */ +KTEST(sigConsumePendingDefersSameSignalRaisedAgainWhileItsOwnHandlerIsActive) +{ + uint32 id = threadCreate("sigNoDeferOffTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + uint64 handlerAddr = 0x40009000ull; + sigSetHandler(thread, sigUsr1, handlerAddr, 0, 0); + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + KT_TRUE(t, thread->sigBlocked & (1ull << sigUsr1)); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + /* Same signum, raised again while its own handler is still active -- + self-blocked (no sigFlagNoDefer), so this must stay pending, not + enter a second, nested instance of the same handler. */ + sigRaise(id, sigUsr1); + int deferredSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &deferredSignum), sigOutcomeNone); + KT_EQ(t, deferredSignum, -1); + KT_TRUE(t, thread->sigPending & (1ull << sigUsr1)); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + /* Once the (only) active handler returns, its self-block lifts and the + deferred re-raise is finally deliverable -- as a fresh TOP-LEVEL + invocation (depth back to 1, not 2), never having nested at all. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_FALSE(t, thread->sigBlocked & (1ull << sigUsr1)); + + int finalSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &finalSignum), sigOutcomeHandler); + KT_EQ(t, finalSignum, sigUsr1); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + KT_TRUE(t, threadDiscard(id)); +} + +/* sigFlagNoDefer's whole point: registered for a signal, that signal's own + bit is no longer added to sigBlocked when its handler starts, so raising + the SAME signum again while that handler is still active genuinely + nests a second instance of it -- the direct opposite outcome of the + test just above, same signum, only the flag differs. */ +KTEST(sigConsumePendingNoDeferFlagLetsSameSignalNestIntoItsOwnActiveHandler) +{ + uint32 id = threadCreate("sigNoDeferOnTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + uint64 handlerAddr = 0x40009000ull; + sigSetHandler(thread, sigUsr1, handlerAddr, 0, sigFlagNoDefer); + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + /* The one thing sigFlagNoDefer actually changes: sigUsr1's own bit is + NOT in sigBlocked, unlike the default case above. */ + KT_FALSE(t, thread->sigBlocked & (1ull << sigUsr1)); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + Registers midHandler = regs; + sigRaise(id, sigUsr1); + int nestedSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &nestedSignum), sigOutcomeHandler); + KT_EQ(t, nestedSignum, sigUsr1); + KT_EQU(t, regs.rip, sigTrampolineVirtualAddr); + KT_EQU(t, regs.rax, handlerAddr); + KT_EQU(t, thread->sigHandlerDepth, 2u); + /* Still no self-block even at the nested depth -- a third raise would + nest again the same way, bounded only by sigMaxNestDepth, never by + sigUsr1 blocking itself. */ + KT_FALSE(t, thread->sigBlocked & (1ull << sigUsr1)); + KT_EQ(t, memcmp(&thread->sigSavedRegs[1], &midHandler, sizeof(midHandler)), 0); + + /* Unwinding pops the nested instance first, resuming the outer one + exactly where the re-raise interrupted it -- LIFO, same as any other + nested delivery. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_EQ(t, memcmp(®s, &midHandler, sizeof(midHandler)), 0); + KT_EQU(t, thread->sigHandlerDepth, 1u); + + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_EQU(t, thread->sigHandlerDepth, 0u); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingCustomHandlerRewritesTrapframeAndPushesContext) +{ + uint32 id = threadCreate("sigConsumeHandler", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + Registers before = regs; + int signum = -1; + + uint64 handlerAddr = 0x40009000ull; + sigSetHandler(thread, sigUsr1, handlerAddr, 0, 0); + sigProcMask(thread, sigMaskBlock, (1ull << sigUsr2)); + uint64 blockedBefore = thread->sigBlocked; + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + + /* Trapframe rewritten to enter the trampoline: rip is the shared + trampoline entry, rax is the handler address its `call *%rax` + invokes, rdi is the signal number (the handler's sole argument). */ + KT_EQU(t, regs.rip, sigTrampolineVirtualAddr); + KT_EQU(t, regs.rax, handlerAddr); + KT_EQU(t, regs.rdi, (uint64)sigUsr1); + /* Untouched: the trampoline runs on the interrupted thread's own + existing stack, no separate signal stack. */ + KT_EQU(t, regs.rsp, before.rsp); + + /* sigUsr1's own sa_mask is empty (0 above), so the ONLY new bit added + to sigBlocked is sigUsr1 itself (self-blocked, like real Unix) -- + whatever was already blocked (sigUsr2, from sigProcMask) survives + untouched, a union not a replacement. */ + KT_EQU(t, thread->sigHandlerDepth, 1u); + KT_EQU(t, thread->sigBlocked, blockedBefore | (1ull << sigUsr1)); + KT_EQU(t, thread->sigSavedBlocked[0], blockedBefore); + + /* The ENTIRE pre-signal trapframe must be recoverable later -- not + just the fields this test happens to check above. */ + KT_EQ(t, memcmp(&thread->sigSavedRegs[0], &before, sizeof(before)), 0); + + /* An ALREADY-blocked signal (sigUsr2, blocked since before sigUsr1 was + even raised) stays deferred while a handler is active, exactly like + it would with no handler running at all -- the depth stack does not + change what sigBlocked itself gates. */ + sigRaise(id, sigUsr2); + int blockedSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &blockedSignum), sigOutcomeNone); + KT_EQ(t, blockedSignum, -1); + KT_TRUE(t, thread->sigPending & (1ull << sigUsr2)); + + /* A signal NOT covered by sigUsr1's mask nor its own self-block -- + sigHup -- genuinely interrupts the active handler with a NESTED one + of its own: this is the behavior a single "one handler at a time" + bool (this code used to have) could never produce, since it gated + delivery before sigBlocked was even consulted. */ + Registers midUsr1Handler = regs; + uint64 handlerAddrHup = 0x4000B000ull; + sigSetHandler(thread, sigHup, handlerAddrHup, 0, 0); + sigRaise(id, sigHup); + int nestedSignum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &nestedSignum), sigOutcomeHandler); + KT_EQ(t, nestedSignum, sigHup); + KT_EQU(t, regs.rip, sigTrampolineVirtualAddr); + KT_EQU(t, regs.rax, handlerAddrHup); + KT_EQU(t, regs.rdi, (uint64)sigHup); + KT_EQU(t, thread->sigHandlerDepth, 2u); + /* sigHup's own handler has no mask either, so its blocked set is + whatever sigUsr1's handler already made it, plus sigHup's own bit. */ + KT_EQU(t, thread->sigBlocked, + blockedBefore | (1ull << sigUsr1) | (1ull << sigHup)); + /* What got pushed for THIS (nested) frame is exactly the trapframe + sigUsr1's handler was actually running with the instant sigHup + preempted it -- not the original pre-sigUsr1 `before` snapshot. */ + KT_EQ(t, memcmp(&thread->sigSavedRegs[1], &midUsr1Handler, + sizeof(midUsr1Handler)), 0); + KT_EQU(t, thread->sigSavedBlocked[1], blockedBefore | (1ull << sigUsr1)); + + /* Unwinding: first sigOnSigReturn resumes sigUsr1's still-active + handler exactly where sigHup interrupted it, not the original + program -- LIFO, like a real call stack of signal frames. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_EQ(t, memcmp(®s, &midUsr1Handler, sizeof(midUsr1Handler)), 0); + KT_EQU(t, thread->sigHandlerDepth, 1u); + KT_EQU(t, thread->sigBlocked, blockedBefore | (1ull << sigUsr1)); + + /* Second sigOnSigReturn finally resumes the ORIGINAL interrupted + program, restoring the exact pre-sigUsr1 trapframe and blocked + mask. */ + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_EQ(t, memcmp(®s, &before, sizeof(before)), 0); + KT_EQU(t, thread->sigHandlerDepth, 0u); + KT_EQU(t, thread->sigBlocked, blockedBefore); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingRefusesNewHandlerAtMaxNestDepth) +{ + uint32 id = threadCreate("sigNestDepthLimit", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + int signum = -1; + + /* Poke the depth field directly to the bound rather than actually + nesting sigMaxNestDepth real handlers -- this test is about the + GUARD, which the fault-injection check below confirms is load- + bearing (removing it does not, by itself, corrupt anything a plain + "one more handler" scenario would exercise; it needs the table + genuinely full to matter). */ + thread->sigHandlerDepth = sigMaxNestDepth; + sigSetHandler(thread, sigUsr1, 0x40009000ull, 0, 0); + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeNone); + KT_EQ(t, signum, -1); + /* Left pending, not lost -- exactly like a blocked signal, it simply + waits for room (a sigOnSigReturn dropping the depth back down). */ + KT_TRUE(t, thread->sigPending & (1ull << sigUsr1)); + KT_EQU(t, thread->sigHandlerDepth, (uint32)sigMaxNestDepth); + + thread->sigHandlerDepth = 0; + KT_TRUE(t, threadDiscard(id)); +} + +/* + * SA_RESTART / EINTR (signalnum.h's sigFlagRestart, sched.h's + * Thread.sigInRetryableSyscall) -- signal.h's file comment has the full + * story. These four drive sigConsumePending directly instead of a live + * syscall retry, the same division of labor the rest of this file already + * uses: user/restarttarget.c/restarttest.c prove the real ring-3 round + * trip, these prove the exact rip/rax rewrite (or lack of it) is gated on + * precisely the right condition. + */ + +KTEST(sigConsumePendingReturnsEintrForInterruptedRetryableSyscallWithoutRestartFlag) +{ + uint32 id = threadCreate("sigEintrTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + /* Stand-in for the exact state sysRead/sysWrite/sysWait (syscall.c) + leave a thread in right before blocking: rip rewound 2 bytes behind + the `int $0x80` that trapped in, so re-entering ring 3 normally + would re-execute it. */ + regs.rip = 0x40001232ull; + int signum = -1; + + uint64 handlerAddr = 0x4000A000ull; + sigSetHandler(thread, sigUsr1, handlerAddr, 0, 0); + thread->sigInRetryableSyscall = true; + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + + /* The handler still runs normally (trampoline entry) -- the rewrite + only touches the SAVED, about-to-resume context below, never how + this handler itself gets entered. */ + KT_EQU(t, regs.rip, sigTrampolineVirtualAddr); + KT_EQU(t, regs.rax, handlerAddr); + + /* What actually resumes once this handler returns: rip past the + interrupted `int $0x80` entirely (not just past the rewind -- the + retry itself is skipped), rax the raw negative "errno" a real + syscall would return -- genuine EINTR, not a silent retry. */ + KT_EQU(t, thread->sigSavedRegs[0].rip, 0x40001232ull + 2); + KT_EQU(t, thread->sigSavedRegs[0].rax, (uint64)(int64)errInterrupted); + + /* Consumed -- a later, nested signal must never see this as still + true (see the next test). */ + KT_FALSE(t, thread->sigInRetryableSyscall); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingPreservesTransparentRetryWhenRestartFlagSet) +{ + uint32 id = threadCreate("sigRestartTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + regs.rip = 0x40001232ull; + Registers before = regs; + int signum = -1; + + sigSetHandler(thread, sigUsr1, 0x4000A000ull, 0, sigFlagRestart); + thread->sigInRetryableSyscall = true; + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + + /* sigFlagRestart: the saved, about-to-resume context is untouched -- + exactly the original retry point, so the syscall genuinely retries + once this handler returns, same as every handler behaved before this + flag existed. */ + KT_EQ(t, memcmp(&thread->sigSavedRegs[0], &before, sizeof(before)), 0); + KT_FALSE(t, thread->sigInRetryableSyscall); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingLeavesRegsAloneWhenNotInterruptingARetryableSyscall) +{ + uint32 id = threadCreate("sigNoRetryTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + Registers before = regs; + int signum = -1; + + /* No sigFlagRestart, AND this thread was never actually mid a + retryable syscall (a fresh thread's sigInRetryableSyscall starts + false) -- ordinary running code being interrupted, exactly like a + plain sysSleep or no syscall at all. The gate must refuse to invent + an EINTR here just because the flag is absent. */ + sigSetHandler(thread, sigUsr1, 0x4000A000ull, 0, 0); + KT_FALSE(t, thread->sigInRetryableSyscall); + sigRaise(id, sigUsr1); + + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, memcmp(&thread->sigSavedRegs[0], &before, sizeof(before)), 0); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingNestedHandlerNeverInheritsOuterRetryState) +{ + uint32 id = threadCreate("sigNestedRetryTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + regs.rip = 0x40001232ull; + int outerSignum = -1; + + sigSetHandler(thread, sigUsr1, 0x4000A000ull, 0, sigFlagRestart); + sigSetHandler(thread, sigHup, 0x4000B000ull, 0, 0); + thread->sigInRetryableSyscall = true; + sigRaise(id, sigUsr1); + KT_EQ(t, sigConsumePending(thread, ®s, &outerSignum), sigOutcomeHandler); + KT_FALSE(t, thread->sigInRetryableSyscall); + + Registers midOuterHandler = regs; + int nestedSignum = -1; + sigRaise(id, sigHup); + KT_EQ(t, sigConsumePending(thread, ®s, &nestedSignum), sigOutcomeHandler); + + /* sigHup has no sigFlagRestart, but it never interrupted a retryable + syscall -- it interrupted sigUsr1's OWN already-running handler. + thread->sigInRetryableSyscall was already consumed (cleared) by the + outer delivery above, so this nested push must leave regs alone + regardless of sigHup's own flags: the saved context is exactly the + mid-outer-handler snapshot, not an invented EINTR. */ + KT_EQ(t, memcmp(&thread->sigSavedRegs[1], &midOuterHandler, + sizeof(midOuterHandler)), 0); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigOnSigReturnRestoresExactPreSignalContext) +{ + uint32 id = threadCreate("sigReturnTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + Registers regs = makeUserRegs(); + Registers before = regs; + int signum = -1; + + sigSetHandler(thread, sigUsr1, 0x40009000ull, 0, 0); + sigProcMask(thread, sigMaskBlock, (1ull << sigUsr2)); + uint64 blockedBefore = thread->sigBlocked; + sigRaise(id, sigUsr1); + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + + /* regs now holds whatever the trampoline/handler would have left it + as by the time it traps into sysSigReturn -- simulate that instead + of actually running user code, since this file has no ring 3. */ + regs.rax = 0xDEADBEEFull; + regs.rdi = 0xFEEDFACEull; + + KT_TRUE(t, sigOnSigReturn(thread, ®s)); + KT_EQ(t, memcmp(®s, &before, sizeof(before)), 0); + KT_EQU(t, thread->sigHandlerDepth, 0u); + KT_EQU(t, thread->sigBlocked, blockedBefore); + + /* Misuse: calling sigreturn while NOT actually inside a handler must be + a safe no-op, not a crash or a silent corruption of an unrelated + trapframe. */ + Registers untouched = regs; + KT_FALSE(t, sigOnSigReturn(thread, ®s)); + KT_EQ(t, memcmp(®s, &untouched, sizeof(untouched)), 0); + + KT_TRUE(t, threadDiscard(id)); +} + +/* A table slot recycled by threadDiscard/threadReap must never let a dead + process's signal state leak into whatever unrelated process reuses its + id next. Today that is guaranteed twice over -- threadReap/threadDiscard + already memset() the whole Thread struct before any slot is handed back + to allocSlot, and sigResetState does it again explicitly from + threadCreate (see sched.h's field comment) -- so this test does not + currently isolate sigResetState's own contribution; it locks in the + observable end-to-end property regardless of which of the two is doing + the work, the same way the console-reader/pipe-fd leak fixes (see + keyboard.c/sched.c history) earned tests for state that lived OUTSIDE + Thread and had no such blanket memset to fall back on. */ +KTEST(threadCreateResetsSignalStateOnRecycledSlot) +{ + uint32 first = threadCreate("sigLeakA", noopThreadEntry); + KT_NE(t, first, threadInvalidId); + Thread* a = threadAt(first); + + sigSetHandler(a, sigUsr1, 0x40009000ull, (1ull << sigUsr2), 0); + sigProcMask(a, sigMaskBlock, (1ull << sigUsr2)); + sigRaise(first, sigUsr1); + Registers regs = makeUserRegs(); + int signum = -1; + /* Drives `a` into sigHandlerDepth==1 (via the installed handler above) + so that field's reset gets exercised too, not just + sigPending/sigBlocked/sigHandlers/sigHandlerMasks. */ + KT_EQ(t, sigConsumePending(a, ®s, &signum), sigOutcomeHandler); + KT_EQU(t, a->sigHandlerDepth, 1u); + + KT_TRUE(t, threadDiscard(first)); + + /* allocSlot's own round-robin cursor (sched.c) no longer guarantees + the very next create reuses THIS exact slot -- drive it around + until it does, discarding every other slot it hands back along the + way, so this test still deterministically exercises a real recycle + of `first` regardless of where the cursor currently sits. Bounded + by threadCapacity(): the cursor sweeps every currently-live index + at most once per full lap, and nothing else in this test holds a + slot open forever, so `first` is guaranteed to come back around + well within that many attempts. */ + uint32 second = threadInvalidId; + for (uint32 attempt = 0; attempt < threadCapacity(); attempt++) { + uint32 candidate = threadCreate("sigLeakB", noopThreadEntry); + KT_NE(t, candidate, threadInvalidId); + if (candidate == first) { + second = candidate; + break; + } + KT_TRUE(t, threadDiscard(candidate)); + } + KT_EQ(t, second, first); + Thread* b = threadAt(second); + + KT_EQU(t, b->sigPending, 0ull); + KT_EQU(t, b->sigBlocked, 0ull); + KT_EQU(t, b->sigHandlerDepth, 0u); + KT_EQU(t, b->sigSavedBlocked[0], 0ull); + for (int signumIndex = 1; signumIndex < sigMaxSignals; signumIndex++) { + KT_EQU(t, b->sigHandlers[signumIndex], sigHandlerDefault); + KT_EQU(t, b->sigHandlerMasks[signumIndex], 0ull); + } + + KT_TRUE(t, threadDiscard(second)); +} + +/* + * sigFlagSigInfo / SigInfo (signalnum.h) -- signal.c's sigConsumePending + * populating the shared sigInfoVirtualAddr page and rewriting rsi/rdx to + * hand a sigFlagSigInfo handler a real (SigInfo*, NULL) pair, deterministic + * counterpart to the live ring-3 round trip in siginfotest.c/ + * siginfotarget.c (ci-test.sh needles "siginfotarget: siginfo-sender-pid-ok" + * / "siginfotest: sigchld-siginfo-accurate-ok"), which cannot itself prove + * the EXACT bytes sigConsumePending computed the way direct access to + * `regs`/the dereferenced pointer here can. + */ + +KTEST(sigConsumePendingPopulatesSigInfoForSigFlagSigInfoHandler) +{ + uint32 id = threadCreate("sigInfoTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + sigSetHandler(thread, sigUsr1, 0x40009000ull, 0, sigFlagSigInfo); + KT_TRUE(t, sigRaiseInfo(id, sigUsr1, siCodeUser, 4242, 99)); + + Registers regs = makeUserRegs(); + int signum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + KT_EQ(t, signum, sigUsr1); + + /* rdx is always NULL (no ucontext_t this kernel can expose) -- see + signal.h's sigConsumePending doc comment. */ + KT_EQU(t, regs.rdx, 0ull); + + /* rsi is a real pointer into the shared, kernel-populated page -- not + just "some nonzero value", dereferencing it must show exactly what + sigRaiseInfo attached above. */ + KT_NE(t, regs.rsi, 0ull); + SigInfo* info = (SigInfo*)regs.rsi; + KT_EQ(t, info->siSignum, sigUsr1); + KT_EQ(t, info->siCode, siCodeUser); + KT_EQ(t, info->siPid, 4242); + KT_EQ(t, info->siStatus, 99); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigConsumePendingLeavesRsiRdxUntouchedWithoutSigFlagSigInfo) +{ + uint32 id = threadCreate("sigNoInfoTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + /* No sigFlagSigInfo this time -- a plain one-argument handler, exactly + like every signal demo before this session. */ + sigSetHandler(thread, sigUsr1, 0x40009000ull, 0, 0); + KT_TRUE(t, sigRaise(id, sigUsr1)); + + Registers regs = makeUserRegs(); + regs.rsi = 0x5151515151515151ull; + regs.rdx = 0x6262626262626262ull; + int signum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + + /* Untouched, exactly as documented -- a handler with no + sigFlagSigInfo never reads either register, so sigConsumePending + must not spend any effort computing values for them. */ + KT_EQU(t, regs.rsi, 0x5151515151515151ull); + KT_EQU(t, regs.rdx, 0x6262626262626262ull); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaisePlainWrapperDefaultsToSiCodeKernelWithNoSenderOrStatus) +{ + uint32 id = threadCreate("sigInfoDefaultTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + sigSetHandler(thread, sigHup, 0x40009000ull, 0, sigFlagSigInfo); + /* The plain, pre-existing sigRaise -- every one of this file's other + ~50 call sites uses exactly this, and none of them supply detail, + so sigRaise's own thin-wrapper default (siCodeKernel/0/0) is what + they all still get. */ + KT_TRUE(t, sigRaise(id, sigHup)); + + Registers regs = makeUserRegs(); + int signum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + + SigInfo* info = (SigInfo*)regs.rsi; + KT_EQ(t, info->siSignum, sigHup); + KT_EQ(t, info->siCode, siCodeKernel); + KT_EQ(t, info->siPid, 0); + KT_EQ(t, info->siStatus, 0); + + KT_TRUE(t, threadDiscard(id)); +} + +KTEST(sigRaiseInfoOverwritesStalePendingInfoOnReRaiseBeforeDelivery) +{ + uint32 id = threadCreate("sigInfoOverwriteTarget", noopThreadEntry); + KT_NE(t, id, threadInvalidId); + Thread* thread = threadAt(id); + + sigSetHandler(thread, sigUsr2, 0x40009000ull, 0, sigFlagSigInfo); + KT_TRUE(t, sigRaiseInfo(id, sigUsr2, siCodeUser, 111, 1)); + /* Same signum raised again before it was ever delivered -- non-queuing + signals coalesce, so only the SECOND raise's detail should ever be + observable, matching sigPending itself staying a single bit rather + than a count. */ + KT_TRUE(t, sigRaiseInfo(id, sigUsr2, siCodeUser, 222, 2)); + + Registers regs = makeUserRegs(); + int signum = -1; + KT_EQ(t, sigConsumePending(thread, ®s, &signum), sigOutcomeHandler); + + SigInfo* info = (SigInfo*)regs.rsi; + KT_EQ(t, info->siPid, 222); + KT_EQ(t, info->siStatus, 2); + + KT_TRUE(t, threadDiscard(id)); +} + +/* + * sigRaiseGroup (signal.h) -- the group-targeted half of real kill(2)'s + * pid<0/pid==0 convention (syscall.c's sysSigSend) and keyboard.c's own + * real Ctrl-C/Ctrl-Z, delivered to the FOREGROUND process group + * (sched.h's Thread.pgid) rather than one hardcoded target. The live + * ring-3 proof that this scoping actually protects a DIFFERENT group from + * a keyboard-generated signal is user/pgroupfg.c (killed by it) versus + * user/pgroupbg.c (proven still alive afterwards) in kmain.c's boot + * sequence; what is deterministic here is the plain fan-out-and-filter + * logic itself, mirroring schedTest.c's threadKillGroup coverage for the + * exact same shape of primitive one layer down. + */ + +KTEST(sigRaiseGroupDeliversToEveryLiveMemberOfThatGroupOnly) +{ + uint32 a = threadCreate("sigGroupA", noopThreadEntry); + uint32 b = threadCreate("sigGroupB", noopThreadEntry); + uint32 outsider = threadCreate("sigGroupOutsider", noopThreadEntry); + KT_NE(t, a, threadInvalidId); + KT_NE(t, b, threadInvalidId); + KT_NE(t, outsider, threadInvalidId); + + uint32 pgid = threadAt(a)->pgid; + KT_TRUE(t, threadSetPgid(b, pgid)); + KT_NE(t, threadAt(outsider)->pgid, pgid); + + KT_TRUE(t, sigRaiseGroup(pgid, sigUsr1, siCodeKernel, 0)); + KT_EQU(t, threadAt(a)->sigPending & (1ull << sigUsr1), (1ull << sigUsr1)); + KT_EQU(t, threadAt(b)->sigPending & (1ull << sigUsr1), (1ull << sigUsr1)); + /* The whole point: a DIFFERENT group must be completely untouched -- + this is what keeps a real Ctrl-C from ever reaching a background + job. */ + KT_EQU(t, threadAt(outsider)->sigPending, 0ull); + + KT_TRUE(t, threadDiscard(a)); + KT_TRUE(t, threadDiscard(b)); + KT_TRUE(t, threadDiscard(outsider)); +} + +KTEST(sigRaiseGroupReturnsFalseWhenNoLiveThreadHasThatPgid) +{ + KT_FALSE(t, sigRaiseGroup(0xBEEF0001u, sigUsr1, siCodeKernel, 0)); +} + +KTEST(sigRaiseGroupSkipsDeadOrUnusedMembersButStillFindsLiveOnes) +{ + uint32 a = threadCreate("sigGroupDeadA", noopThreadEntry); + uint32 b = threadCreate("sigGroupDeadB", noopThreadEntry); + KT_NE(t, a, threadInvalidId); + KT_NE(t, b, threadInvalidId); + + uint32 pgid = threadAt(a)->pgid; + KT_TRUE(t, threadSetPgid(b, pgid)); + KT_TRUE(t, threadKill(b)); + + KT_TRUE(t, sigRaiseGroup(pgid, sigUsr2, siCodeKernel, 0)); + KT_EQU(t, threadAt(a)->sigPending & (1ull << sigUsr2), (1ull << sigUsr2)); + /* Dead members are silently skipped, exactly like sigRaiseInfo's own + Dead/Unused guard already refuses a single-target send against one + -- no crash, no stale sigPending bit written into a slot that is no + longer this thread's own. */ + KT_EQU(t, threadAt(b)->sigPending, 0ull); + + KT_TRUE(t, threadDiscard(a)); + KT_TRUE(t, threadReap(b)); +} + +KTEST(sigRaiseGroupWithSigStopStopsEveryLiveMemberOfThatGroup) +{ + uint32 a = threadCreate("sigGroupStopA", noopThreadEntry); + uint32 b = threadCreate("sigGroupStopB", noopThreadEntry); + KT_NE(t, a, threadInvalidId); + KT_NE(t, b, threadInvalidId); + + uint32 pgid = threadAt(a)->pgid; + KT_TRUE(t, threadSetPgid(b, pgid)); + + /* sigStop is delegated straight to threadStop per-target, bypassing + sigPending entirely -- the same bypass sigRaiseInfo's own file + comment documents for a single-target sigStop, now proven to apply + independently to every member the group fan-out visits. */ + KT_TRUE(t, sigRaiseGroup(pgid, sigStop, siCodeKernel, 0)); + KT_EQ(t, threadAt(a)->state, threadStopped); + KT_EQ(t, threadAt(b)->state, threadStopped); + + KT_TRUE(t, threadContinue(a)); + KT_TRUE(t, threadContinue(b)); + KT_TRUE(t, threadDiscard(a)); + KT_TRUE(t, threadDiscard(b)); +} diff --git a/src/sigTrampoline.S b/src/sigTrampoline.S new file mode 100644 index 0000000..c46b899 --- /dev/null +++ b/src/sigTrampoline.S @@ -0,0 +1,41 @@ +#include "syscall.h" + +/* + * Shared signal-delivery trampoline (see signal.h/signal.c). This is NOT + * entered like a normal function call from C -- signal.c's + * sigConsumePending rewrites a thread's own saved trapframe so that + * iretq lands ring 3 execution directly at sigTrampolineStart, with %rax + * already holding the handler's address and %rdi already holding the + * signal number (the handler's sole SysV argument), exactly as if the + * kernel itself had performed `call *%rax` with that argument set up. + * + * Position independent by construction: every instruction here is either + * PC-relative (the final backward jmp) or references no address at all + * (call through a register, an immediate syscall number, int $0x80) -- so + * sigInstallTrampoline can copy these bytes to any physical frame and map + * that frame at any virtual address (sigTrampolineVirtualAddr) and it + * still runs correctly, unlike this same code linked and executed in + * place inside the kernel's own (very different) virtual address range. + */ +.section .text +.code64 + +.global sigTrampolineStart +.global sigTrampolineEnd + +sigTrampolineStart: + call *%rax + movl $sysSigReturn, %eax + int $0x80 + +/* Only reached if sysSigReturn somehow returned instead of restoring the + pre-signal context and resuming it (a kernel bug, not a state a correct + sysOnSigReturn can produce) -- park here rather than run off into + whatever garbage happens to follow this page. */ +sigTrampolineHang: + hlt + jmp sigTrampolineHang + +sigTrampolineEnd: + +.section .note.GNU-stack, "", @progbits diff --git a/src/signal.c b/src/signal.c new file mode 100644 index 0000000..9d89936 --- /dev/null +++ b/src/signal.c @@ -0,0 +1,402 @@ +#include "signal.h" +#include "string.h" +#include "pmm.h" +#include "paging.h" + +/* Machine code for the shared trampoline page, sigTrampoline.S -- position + independent by construction (no absolute address references at all), so + copying its bytes anywhere and mapping them there is enough. */ +extern char sigTrampolineStart[]; +extern char sigTrampolineEnd[]; + +/* Physical frame addresses backing sigInfoVirtualAddr's page(s), filled in + by sigInstallInfoPage -- kept as separate frames (not assumed physically + contiguous, unlike their virtual mapping, which IS contiguous by + construction: pmmAlloc gives no contiguity guarantee across calls, so + sigConsumePending must look up the right frame for a given slot rather + than pointer-arithmetic across a single base). Physical frames below the + 1 GiB boot-time identity map (see sigTrampolineVirtualAddr's own comment) + are directly dereferenceable through their own physical value from + kernel code, exactly like sigInstallTrampoline's memcpy into `frame` + above already relies on. */ +static uint64 sigInfoFrames[sigInfoMaxPages]; +static uint32 sigInfoPageCount = 0; + +/* How many SigInfo structs fit in one frameSize page -- shared by + sigInstallInfoPage's sizing loop and sigConsumePending's slot lookup, so + the two can never disagree about the layout. */ +#define sigInfoSlotsPerPage (frameSize / sizeof(SigInfo)) + +void sigResetState(Thread* thread) +{ + thread->sigPending = 0; + thread->sigBlocked = 0; + thread->sigHandlerDepth = 0; + thread->sigInRetryableSyscall = false; + memset(thread->sigHandlers, 0, sizeof(thread->sigHandlers)); + memset(thread->sigHandlerMasks, 0, sizeof(thread->sigHandlerMasks)); + memset(thread->sigHandlerFlags, 0, sizeof(thread->sigHandlerFlags)); + memset(thread->sigPendingInfo, 0, sizeof(thread->sigPendingInfo)); + memset(thread->sigSavedBlocked, 0, sizeof(thread->sigSavedBlocked)); + memset(thread->sigSavedRegs, 0, sizeof(thread->sigSavedRegs)); +} + +/* Real-world default disposition for signals this kernel actually defines + that is Ignore rather than Terminate -- SIGCHLD (no handler installed: + nothing happens) and SIGCONT (its actual resume effect already happened + unconditionally in sigRaise -- see file comment -- so "no handler" here + just means no handler runs, not that nothing happened at all). SIGSTOP + never reaches this function to begin with (sigRaise bypasses the whole + scan for it, like sigKill), so it is deliberately absent from this list, + the same way sigKill already was. SIGTSTP is NOT here -- its default is + Stop, not Ignore, see sigDefaultIsStop below. Every other defined signal + defaults to Terminate. */ +static bool sigDefaultIsIgnore(int signum) +{ + switch (signum) { + case sigChld: + case sigCont: + return true; + default: + return false; + } +} + +/* Real-world default disposition that is Stop rather than Terminate: + SIGTSTP only. Unlike SIGSTOP (see file comment / sigRaise), SIGTSTP is + fully catchable/blockable/ignorable -- this default only applies when it + is neither, exactly like sigDefaultIsIgnore's entries. */ +static bool sigDefaultIsStop(int signum) +{ + return signum == sigTstp; +} + +/* Whether `signum` would genuinely be delivered (a real handler OR a + default disposition of Terminate/Stop -- anything sigConsumePending + itself would act on, not silently dismiss) if `thread` returned to ring 3 + right now. Deliberately mirrors sigConsumePending's own + blocked/ignored-dismissal logic for exactly one signal number rather than + sharing code with it -- sigConsumePending also mutates sigPending/ + sigHandlerDepth/the trapframe as it scans, none of which this pure + predicate may do, since it runs from sigRaise against a target that is + not currently resuming at all. Used solely to decide whether a Sleeping/ + Blocked target needs early-waking (threadWakeForSignal, sched.c) for this + signal -- if this ever drifted out of sync with sigConsumePending's own + logic, the failure mode is always benign either way: "wakes for a signal + that turns out not to be delivered" is a harmless spurious wake (the + target's own retry-syscall loop just re-blocks, exactly like a real + spurious wakeup), and "does not wake for one that would have delivered" + just falls back to today's behavior of waiting for a natural wake. */ +static bool sigWouldDeliver(const Thread* thread, int signum) +{ + uint64 bit = 1ull << (uint32)signum; + if (thread->sigBlocked & bit) + return false; + + uint64 handler = thread->sigHandlers[signum]; + if (handler == sigHandlerIgnore || + (handler == sigHandlerDefault && sigDefaultIsIgnore(signum))) + return false; + + return true; +} + +bool sigRaiseInfo(uint32 targetId, int signum, int siCode, int siPid, int siStatus) +{ + if (signum <= 0 || signum >= sigMaxSignals) + return false; + + /* SIGKILL never touches sigPending/sigBlocked at all -- it is + delegated straight to the pre-existing unconditional termination + path, exactly like real kill(pid, SIGKILL) bypassing every handler/ + mask a target could have installed. threadKill does its own id + validation (range, state), so none is duplicated here. Neither it + nor SIGSTOP just below ever reaches a sigHandlers[] entry that could + even be sigFlagSigInfo, so the caller-supplied SigInfo fields are + simply never used on either of these two paths. */ + if (signum == sigKill) + return threadKill(targetId); + + /* SIGSTOP: uncatchable, unblockable, unignorable, exactly like SIGKILL + above -- bypasses sigPending/sigHandlers/sigBlocked entirely and acts + on ThreadState directly. threadStop does its own id validation. */ + if (signum == sigStop) + return threadStop(targetId); + + if (targetId == 0 || targetId >= threadCapacity()) + return false; + + Thread* target = threadAt(targetId); + if (target->state == threadDead || target->state == threadUnused) + return false; + + /* SIGCONT's resume is likewise not something a mask or a missing + handler can defer -- called unconditionally before the generic + pending-bit path below, so a Stopped target is moved back to + threadRunnable regardless of whether it also has a handler + installed. Its return value is intentionally ignored: "target + was not actually Stopped" is a harmless no-op at the process-state + level (real SIGCONT to a running process), not a reason to skip + delivering the signal itself for any installed handler. */ + if (signum == sigCont) + threadContinue(targetId); + + target->sigPending |= (1ull << (uint32)signum); + + /* Attached regardless of whether signum's handler actually has + sigFlagSigInfo set (sigConsumePending is what decides whether this + ever gets read at all) -- cheap (4 ints), and simpler than threading + the handler's own flags in here just to skip it, matching how + sigHandlerMasks/sigHandlerFlags are likewise set unconditionally by + sigSetHandler and only ever consulted conditionally. */ + target->sigPendingInfo[signum].siSignum = signum; + target->sigPendingInfo[signum].siCode = siCode; + target->sigPendingInfo[signum].siPid = siPid; + target->sigPendingInfo[signum].siStatus = siStatus; + + /* Real Unix's interruptible sleep/EINTR (deliberately out of scope + until now, see the file comment's history): a Sleeping/Blocked + target whose sleep timer or wait-channel condition would otherwise + never naturally fire again (e.g. user/eintrtarget.c's read that no + writer will ever satisfy) must still actually receive a signal that + would genuinely be delivered, not sit on it forever. Skipped + entirely for a blocked or ignored signal -- see sigWouldDeliver -- + so a masked/ignored signal never disturbs a thread's sleep/wait at + all, matching real Unix. threadWakeForSignal itself refuses anything + other than Sleeping/Blocked (Stopped is excluded on purpose -- only + threadContinue may move it), so this is safe to call unconditionally + whenever sigWouldDeliver agrees. */ + if (sigWouldDeliver(target, signum)) + threadWakeForSignal(targetId); + + return true; +} + +bool sigRaise(uint32 targetId, int signum) +{ + return sigRaiseInfo(targetId, signum, siCodeKernel, 0, 0); +} + +bool sigRaiseGroup(uint32 pgid, int signum, int siCode, int siPid) +{ + bool anyTarget = false; + for (uint32 id = 1; id < threadCapacity(); id++) { + Thread* candidate = threadAt(id); + if (candidate->state == threadDead || candidate->state == threadUnused) + continue; + if (candidate->pgid != pgid) + continue; + + anyTarget = true; + sigRaiseInfo(id, signum, siCode, siPid, 0); + } + return anyTarget; +} + +uint64 sigSetHandler(Thread* thread, int signum, uint64 handlerAddr, uint64 mask, uint64 flags) +{ + if (signum <= 0 || signum >= sigMaxSignals) + return sigSetFailed; + if (signum == sigKill || signum == sigStop) + return sigSetFailed; + + uint64 previous = thread->sigHandlers[signum]; + thread->sigHandlers[signum] = handlerAddr; + + /* Same stripping sigProcMask already does to a mask it is handed: a + mask cannot make sigKill/sigStop blockable just because it asked. */ + mask &= ~(1ull << sigKill); + mask &= ~(1ull << sigStop); + thread->sigHandlerMasks[signum] = mask; + thread->sigHandlerFlags[signum] = flags; + + return previous; +} + +uint64 sigProcMask(Thread* thread, int how, uint64 mask) +{ + uint64 previous = thread->sigBlocked; + mask &= ~(1ull << sigKill); + mask &= ~(1ull << sigStop); + + switch (how) { + case sigMaskBlock: + thread->sigBlocked |= mask; + break; + case sigMaskUnblock: + thread->sigBlocked &= ~mask; + break; + case sigMaskSetMask: + thread->sigBlocked = mask; + break; + default: + break; + } + + return previous; +} + +SigOutcome sigConsumePending(Thread* thread, Registers* regs, int* outSignum) +{ + if (thread->sigHandlerDepth >= sigMaxNestDepth) + return sigOutcomeNone; + + for (int signum = 1; signum < sigMaxSignals; signum++) { + uint64 bit = 1ull << (uint32)signum; + if (!(thread->sigPending & bit) || (thread->sigBlocked & bit)) + continue; + + thread->sigPending &= ~bit; + uint64 handler = thread->sigHandlers[signum]; + + if (handler == sigHandlerIgnore || + (handler == sigHandlerDefault && sigDefaultIsIgnore(signum))) + continue; + + if (outSignum) + *outSignum = signum; + + if (handler == sigHandlerDefault) { + if (sigDefaultIsStop(signum)) + return sigOutcomeStop; + return sigOutcomeTerminate; + } + + /* SA_RESTART / EINTR (see file comment): consumed here, exactly + once, regardless of which way the decision goes -- a signal + nested INSIDE the handler this delivery is about to enter is + interrupting that handler's own code, not a retryable syscall + retry point, so it must never see this as still true. Only + rewrites `regs` (the context about to be saved below) when this + thread was genuinely mid one of sysRead/sysWrite/sysWait's own + retry-on-block pattern AND this particular signal was not + registered with sigFlagRestart -- anything else (plain running + code, a sleep, an already-nested handler, or sigFlagRestart + itself) leaves `regs` completely untouched, preserving the + unconditional-retry behavior this kernel had before the flag + existed. */ + bool interruptedRetry = thread->sigInRetryableSyscall; + thread->sigInRetryableSyscall = false; + if (interruptedRetry && !(thread->sigHandlerFlags[signum] & sigFlagRestart)) { + regs->rip += 2; + regs->rax = (uint64)(int64)errInterrupted; + } + + /* Push the whole interrupted trapframe (now possibly rewritten + above) onto this thread's small context stack (the only copy of + it that exists anywhere -- unlike a real kernel this never pushes + one onto the user stack) so sigOnSigReturn can restore it byte + for byte, even if a DIFFERENT, unmasked signal nests another + handler on top before this one ever returns. */ + uint32 depth = thread->sigHandlerDepth; + thread->sigSavedRegs[depth] = *regs; + thread->sigSavedBlocked[depth] = thread->sigBlocked; + thread->sigHandlerDepth = depth + 1; + + /* sa_mask (sigHandlerMasks[signum], set by sigSetHandler) plus the + signal's own bit (self-blocked, like real Unix's implicit + default) are ADDED to whatever was already blocked -- a union, + not a replacement, so an outer handler's own blocked set stays + blocked for this nested one too. sigFlagNoDefer (signalnum.h) + opts out of exactly the self-block half of that union: a signal + registered with it does not gain its own bit here, so a second + raise of the SAME signum while this handler is still active + finds sigBlocked genuinely clear for it and nests, instead of + merely staying pending until this handler returns. Stripped of + sigKill/sigStop for the same reason every other mask operation in + this file is: neither ever consults sigBlocked at all, so this is + purely cosmetic, but leaving either bit set here would visibly + contradict "can never be blocked" for anything that inspects + sigBlocked. */ + uint64 newBlocked = thread->sigBlocked | thread->sigHandlerMasks[signum]; + if (!(thread->sigHandlerFlags[signum] & sigFlagNoDefer)) + newBlocked |= bit; + newBlocked &= ~(1ull << sigKill); + newBlocked &= ~(1ull << sigStop); + thread->sigBlocked = newBlocked; + + /* Rewrite `regs` in place to enter the shared trampoline: rip is + the trampoline's entry, rax is the handler address its + `call *%rax` invokes, rdi is signum (the handler's sole SysV + argument). regs->rsp (the interrupted user stack) is left + untouched -- the trampoline runs on the target's own stack, + right where it already was. */ + regs->rax = handler; + regs->rdi = (uint64)signum; + regs->rip = sigTrampolineVirtualAddr; + + /* sigFlagSigInfo (signalnum.h): copy the SigInfo sigRaiseInfo + attached to this signum into THIS (thread, depth) pair's own + slot of the shared sigInfoVirtualAddr page -- a different slot + than any still-active outer handler's own siginfo pointer, by + the exact same depth-indexed reasoning sigSavedRegs/ + sigSavedBlocked already use, so a nested sigFlagSigInfo delivery + can never clobber an outer one's still-unread copy. rdx is + always 0 (no ucontext_t this kernel can expose). Left completely + untouched otherwise -- see this function's own doc comment. */ + if (thread->sigHandlerFlags[signum] & sigFlagSigInfo && sigInfoPageCount > 0) { + uint32 slot = thread->id * sigMaxNestDepth + depth; + uint32 page = slot / sigInfoSlotsPerPage; + uint32 offset = (slot % sigInfoSlotsPerPage) * (uint32)sizeof(SigInfo); + SigInfo* dst = (SigInfo*)(sigInfoFrames[page] + offset); + *dst = thread->sigPendingInfo[signum]; + regs->rsi = sigInfoVirtualAddr + (uint64)page * frameSize + offset; + regs->rdx = 0; + } + + return sigOutcomeHandler; + } + + return sigOutcomeNone; +} + +bool sigOnSigReturn(Thread* thread, Registers* regs) +{ + if (thread->sigHandlerDepth == 0) + return false; + + uint32 depth = thread->sigHandlerDepth - 1; + *regs = thread->sigSavedRegs[depth]; + thread->sigBlocked = thread->sigSavedBlocked[depth]; + thread->sigHandlerDepth = depth; + return true; +} + +void sigInstallTrampoline(void) +{ + uint64 frame = pmmAlloc(); + if (frame == 0) + return; + + uint64 length = (uint64)(sigTrampolineEnd - sigTrampolineStart); + memset((void*)frame, 0, frameSize); + memcpy((void*)frame, sigTrampolineStart, length); + + pagingMap(sigTrampolineVirtualAddr, frame, pagePresent | pageUser); +} + +void sigInstallInfoPage(void) +{ + uint64 totalSlots = (uint64)threadCapacity() * sigMaxNestDepth; + uint64 pagesNeeded = (totalSlots + sigInfoSlotsPerPage - 1) / sigInfoSlotsPerPage; + if (pagesNeeded > sigInfoMaxPages) + pagesNeeded = sigInfoMaxPages; + + for (uint32 i = 0; i < pagesNeeded; i++) { + uint64 frame = pmmAlloc(); + if (frame == 0) + return; + + memset((void*)frame, 0, frameSize); + /* pageUser, no pageWrite: a handler may only ever READ the SigInfo + it was handed a pointer to, exactly like the trampoline page + above is read+execute, never write -- the kernel is the only + writer, via `frame`'s own physical address (see the frame + comment above sigInfoFrames), never through this user-facing + mapping. */ + pagingMap(sigInfoVirtualAddr + (uint64)i * frameSize, frame, pagePresent | pageUser); + sigInfoFrames[i] = frame; + } + + sigInfoPageCount = (uint32)pagesNeeded; +} diff --git a/src/slab.c b/src/slab.c new file mode 100644 index 0000000..59fcea8 --- /dev/null +++ b/src/slab.c @@ -0,0 +1,130 @@ +#include "slab.h" +#include "pmm.h" + +typedef struct FreeObj { + struct FreeObj* next; +} FreeObj; + +typedef struct Slab { + struct Slab* next; + FreeObj* freeList; + usize freeCount; +} Slab; + +static usize alignUp(usize value, usize alignment) +{ + return (value + alignment - 1) & ~(alignment - 1); +} + +static Slab* growSlab(KmemCache* cache) +{ + uint64 frame = pmmAlloc(); + if (frame == 0) + return nullptr; + + Slab* slab = (Slab*)frame; + slab->next = cache->slabs; + slab->freeCount = cache->objsPerSlab; + + uint8* base = (uint8*)frame + alignUp(sizeof(Slab), sizeof(void*)); + FreeObj* prev = nullptr; + for (usize i = 0; i < cache->objsPerSlab; i++) { + FreeObj* obj = (FreeObj*)(base + i * cache->objSize); + obj->next = prev; + prev = obj; + } + slab->freeList = prev; /* built back-to-front; order does not matter */ + + cache->slabs = slab; + cache->slabCount++; + return slab; +} + +bool kmemCacheInit(KmemCache* cache, const char* name, usize objSize) +{ + cache->name = name; + cache->slabs = nullptr; + cache->slabCount = 0; + cache->usedObjs = 0; + + usize size = objSize < sizeof(FreeObj) ? sizeof(FreeObj) : objSize; + size = alignUp(size, sizeof(void*)); + cache->objSize = size; + + usize headerSize = alignUp(sizeof(Slab), sizeof(void*)); + if (headerSize + size > frameSize) { + cache->objsPerSlab = 0; + return false; + } + cache->objsPerSlab = (frameSize - headerSize) / size; + return true; +} + +void kmemCacheDestroy(KmemCache* cache) +{ + Slab* slab = cache->slabs; + while (slab != nullptr) { + Slab* next = slab->next; + pmmFree((uint64)slab); + slab = next; + } + + cache->name = nullptr; + cache->objSize = 0; + cache->objsPerSlab = 0; + cache->slabs = nullptr; + cache->slabCount = 0; + cache->usedObjs = 0; +} + +void* kmemCacheAlloc(KmemCache* cache) +{ + if (cache->objsPerSlab == 0) + return nullptr; + + Slab* slab = cache->slabs; + while (slab != nullptr && slab->freeList == nullptr) + slab = slab->next; + + if (slab == nullptr) { + slab = growSlab(cache); + if (slab == nullptr) + return nullptr; + } + + FreeObj* obj = slab->freeList; + slab->freeList = obj->next; + slab->freeCount--; + cache->usedObjs++; + return (void*)obj; +} + +void kmemCacheFree(KmemCache* cache, void* obj) +{ + Slab* slab = (Slab*)((uintptr)obj & ~(uintptr)(frameSize - 1)); + FreeObj* fo = (FreeObj*)obj; + fo->next = slab->freeList; + slab->freeList = fo; + slab->freeCount++; + cache->usedObjs--; +} + +usize kmemCacheUsed(const KmemCache* cache) +{ + return cache->usedObjs; +} + +usize kmemCacheCapacity(const KmemCache* cache) +{ + return cache->slabCount * cache->objsPerSlab; +} + +usize kmemCacheSlabCount(const KmemCache* cache) +{ + return cache->slabCount; +} + +usize kmemCacheObjsPerSlab(const KmemCache* cache) +{ + return cache->objsPerSlab; +} diff --git a/src/slabTest.c b/src/slabTest.c new file mode 100644 index 0000000..5036387 --- /dev/null +++ b/src/slabTest.c @@ -0,0 +1,154 @@ +#include "ktest.h" +#include "slab.h" +#include "pmm.h" +#include "string.h" +#include "heap.h" + +KTEST(slabInitRejectsOversizedObject) +{ + KmemCache cache; + KT_FALSE(t, kmemCacheInit(&cache, "toobig", frameSize)); + KT_EQ(t, kmemCacheCapacity(&cache), 0); + KT_NULL(t, kmemCacheAlloc(&cache)); +} + +KTEST(slabAllocDistinctNonOverlapping) +{ + KmemCache cache; + KT_TRUE(t, kmemCacheInit(&cache, "small32", 32)); + KT_GT(t, kmemCacheObjsPerSlab(&cache), 0); + KT_EQ(t, kmemCacheCapacity(&cache), 0); /* nothing grown yet */ + + void* a = kmemCacheAlloc(&cache); + void* b = kmemCacheAlloc(&cache); + void* c = kmemCacheAlloc(&cache); + KT_NOTNULL(t, a); + KT_NOTNULL(t, b); + KT_NOTNULL(t, c); + KT_NE(t, a, b); + KT_NE(t, b, c); + KT_NE(t, a, c); + KT_EQ(t, kmemCacheUsed(&cache), 3); + + /* write a distinct pattern into each object and check none of them + stomped on a neighbor */ + memset(a, 0xAA, 32); + memset(b, 0xBB, 32); + memset(c, 0xCC, 32); + uint8 probe[32]; + memset(probe, 0xAA, 32); + KT_EQ(t, memcmp(a, probe, 32), 0); + memset(probe, 0xBB, 32); + KT_EQ(t, memcmp(b, probe, 32), 0); + memset(probe, 0xCC, 32); + KT_EQ(t, memcmp(c, probe, 32), 0); + + kmemCacheFree(&cache, a); + kmemCacheFree(&cache, b); + kmemCacheFree(&cache, c); + KT_EQ(t, kmemCacheUsed(&cache), 0); + + kmemCacheDestroy(&cache); +} + +KTEST(slabFreeReusesLifo) +{ + KmemCache cache; + kmemCacheInit(&cache, "reuse", 16); + + void* a = kmemCacheAlloc(&cache); + kmemCacheFree(&cache, a); + void* b = kmemCacheAlloc(&cache); + KT_EQP(t, a, b); /* single-slot free list, must come back the same */ + + kmemCacheFree(&cache, b); + kmemCacheDestroy(&cache); +} + +KTEST(slabGrowsAcrossMultipleSlabs) +{ + KmemCache cache; + kmemCacheInit(&cache, "grow64", 64); + usize perSlab = kmemCacheObjsPerSlab(&cache); + KT_GT(t, perSlab, 0); + + uint64 framesBefore = pmmFreeFrames(); + + /* fill exactly the first slab */ + void* first = nullptr; + for (usize i = 0; i < perSlab; i++) { + void* p = kmemCacheAlloc(&cache); + KT_NOTNULL(t, p); + if (i == 0) + first = p; + } + KT_EQ(t, kmemCacheSlabCount(&cache), 1); + KT_EQ(t, kmemCacheUsed(&cache), perSlab); + + /* one more forces a second slab (a second frame from pmm) */ + void* overflow = kmemCacheAlloc(&cache); + KT_NOTNULL(t, overflow); + KT_EQ(t, kmemCacheSlabCount(&cache), 2); + KT_EQ(t, kmemCacheCapacity(&cache), perSlab * 2); + KT_EQ(t, pmmFreeFrames(), framesBefore - 2); + KT_NE(t, overflow, first); + + kmemCacheDestroy(&cache); + /* destroying the cache must return every frame it ever grew into */ + KT_EQ(t, pmmFreeFrames(), framesBefore); +} + +#define slabStressCount 800 + +KTEST(slabStressAllocFreePattern) +{ + KmemCache cache; + kmemCacheInit(&cache, "stress", 40); + + void** slots = (void**)kmalloc(sizeof(void*) * slabStressCount); + KT_NOTNULL(t, slots); + if (!slots) { + kmemCacheDestroy(&cache); + return; + } + + for (int i = 0; i < slabStressCount; i++) { + slots[i] = kmemCacheAlloc(&cache); + KT_NOTNULL(t, slots[i]); + if (slots[i]) + *(int32*)slots[i] = i; + } + KT_EQ(t, kmemCacheUsed(&cache), slabStressCount); + + /* every slot still holds the value we stamped, none aliased */ + for (int i = 0; i < slabStressCount; i++) { + if (slots[i]) + KT_EQ(t, *(int32*)slots[i], i); + } + + /* free every third object, then re-allocate that many and confirm the + cache handed back exactly that many reclaimed slots (no capacity + growth needed to satisfy them) */ + usize freedCount = 0; + for (int i = 0; i < slabStressCount; i += 3) { + kmemCacheFree(&cache, slots[i]); + slots[i] = nullptr; + freedCount++; + } + KT_EQ(t, kmemCacheUsed(&cache), slabStressCount - freedCount); + + usize capacityBeforeRefill = kmemCacheCapacity(&cache); + for (int i = 0; i < slabStressCount; i += 3) { + slots[i] = kmemCacheAlloc(&cache); + KT_NOTNULL(t, slots[i]); + } + KT_EQ(t, kmemCacheCapacity(&cache), capacityBeforeRefill); + KT_EQ(t, kmemCacheUsed(&cache), slabStressCount); + + for (int i = 0; i < slabStressCount; i++) + kmemCacheFree(&cache, slots[i]); + KT_EQ(t, kmemCacheUsed(&cache), 0); + + kfree(slots); + kmemCacheDestroy(&cache); +} diff --git a/src/spinlock.c b/src/spinlock.c new file mode 100644 index 0000000..86c11da --- /dev/null +++ b/src/spinlock.c @@ -0,0 +1,40 @@ +#include "spinlock.h" +#include "ports.h" + +void spinLockInit(SpinLock* lock) +{ + lock->locked = 0; +} + +uint64 spinLockAcquire(SpinLock* lock) +{ + uint64 flags = saveAndDisableInterrupts(); + while (__atomic_exchange_n(&lock->locked, 1, __ATOMIC_ACQUIRE) != 0) { + /* single-core: a lock held across this spin can only be released by + an interrupt handler, but interrupts are off -- callers must never + acquire a lock they (or a handler nested under them) already hold */ + } + return flags; +} + +bool spinLockTryAcquire(SpinLock* lock, uint64* savedFlags) +{ + uint64 flags = saveAndDisableInterrupts(); + if (__atomic_exchange_n(&lock->locked, 1, __ATOMIC_ACQUIRE) != 0) { + restoreInterrupts(flags); + return false; + } + *savedFlags = flags; + return true; +} + +void spinLockRelease(SpinLock* lock, uint64 flags) +{ + __atomic_store_n(&lock->locked, 0, __ATOMIC_RELEASE); + restoreInterrupts(flags); +} + +bool spinLockIsLocked(const SpinLock* lock) +{ + return __atomic_load_n(&lock->locked, __ATOMIC_ACQUIRE) != 0; +} diff --git a/src/spinlockTest.c b/src/spinlockTest.c new file mode 100644 index 0000000..fd8d0d4 --- /dev/null +++ b/src/spinlockTest.c @@ -0,0 +1,73 @@ +#include "ktest.h" +#include "spinlock.h" + +KTEST(spinLockInitStartsUnlocked) +{ + SpinLock lock; + spinLockInit(&lock); + KT_FALSE(t, spinLockIsLocked(&lock)); +} + +KTEST(spinLockAcquireReleaseCycle) +{ + SpinLock lock; + spinLockInit(&lock); + + uint64 flags = spinLockAcquire(&lock); + KT_TRUE(t, spinLockIsLocked(&lock)); + + spinLockRelease(&lock, flags); + KT_FALSE(t, spinLockIsLocked(&lock)); + + /* lock is reusable after release */ + flags = spinLockAcquire(&lock); + KT_TRUE(t, spinLockIsLocked(&lock)); + spinLockRelease(&lock, flags); + KT_FALSE(t, spinLockIsLocked(&lock)); +} + +KTEST(spinLockTryAcquireFailsWhenHeld) +{ + SpinLock lock; + spinLockInit(&lock); + + uint64 flags; + KT_TRUE(t, spinLockTryAcquire(&lock, &flags)); + KT_TRUE(t, spinLockIsLocked(&lock)); + + /* second attempt must not block (and must not touch flags/state) since + nothing else on a single core will ever release it for us */ + uint64 otherFlags = 0xDEADBEEF; + KT_FALSE(t, spinLockTryAcquire(&lock, &otherFlags)); + KT_EQU(t, otherFlags, 0xDEADBEEF); + KT_TRUE(t, spinLockIsLocked(&lock)); + + spinLockRelease(&lock, flags); + KT_FALSE(t, spinLockIsLocked(&lock)); + + /* now that it is free, tryAcquire succeeds again */ + KT_TRUE(t, spinLockTryAcquire(&lock, &flags)); + spinLockRelease(&lock, flags); +} + +KTEST(spinLockIndependentLocksDoNotInterfere) +{ + SpinLock a, b; + spinLockInit(&a); + spinLockInit(&b); + + uint64 flagsA = spinLockAcquire(&a); + KT_TRUE(t, spinLockIsLocked(&a)); + KT_FALSE(t, spinLockIsLocked(&b)); + + uint64 flagsB = spinLockAcquire(&b); + KT_TRUE(t, spinLockIsLocked(&a)); + KT_TRUE(t, spinLockIsLocked(&b)); + + spinLockRelease(&a, flagsA); + KT_FALSE(t, spinLockIsLocked(&a)); + KT_TRUE(t, spinLockIsLocked(&b)); + + spinLockRelease(&b, flagsB); + KT_FALSE(t, spinLockIsLocked(&b)); +} diff --git a/src/syscall.c b/src/syscall.c index 16847e9..1f6b3de 100644 --- a/src/syscall.c +++ b/src/syscall.c @@ -5,10 +5,24 @@ #include "vfs.h" #include "fs.h" #include "string.h" +#include "elf.h" +#include "paging.h" +#include "signal.h" +#include "keyboard.h" +#include "pit.h" +#include "poll.h" +#include "rtc.h" +#include "mmap.h" #define userSpaceMin 0x0000000040000000ull #define userSpaceMax 0x0000800000000000ull +/* Matches kmain.c's userStackSize -- both sides map exactly one slot's + worth of stack for a freshly loaded binary, whether it was spawned at + boot (kmain.c's spawnUser, addresses picked by hand) or at runtime + (doSpawn/doExec below, addresses derived from elfSlotStackTop). */ +#define execStackSize 0x4000 + static bool userRangeOk(uint64 pointer, uint64 length) { if (pointer < userSpaceMin) @@ -34,6 +48,45 @@ static bool copyUserPath(uint64 pointer, char* dst, uint64 max) return true; } +#define execArgMaxLen 256 + +/* Copies a NULL-terminated array of user-space `const char*` (the argv + parameter of sysExec/sysSpawn -- userExec/userSpawn, ulib.c) into kernel + memory: up to elfMaxArgs (elf.h) pointers are read out of userspace one + at a time (each individually range-checked, since the array itself is + just user memory a buggy or hostile caller controls) and each string + copied via copyUserPath into `storage`, which the caller owns for as + long as it needs argvPtrs to stay valid. A null `argvPointer` (the + common "no arguments" case) is not an error -- it yields argc 0, letting + userExec(path, nullptr) mean the same as a bare execve(path) with an + otherwise-empty argv. Fails (leaving *outArgc untouched) on a bad + pointer, a string that doesn't fit `execArgMaxLen`, or an argv longer + than elfMaxArgs entries that never hits its NULL terminator. */ +static bool copyUserArgv(uint64 argvPointer, char storage[elfMaxArgs][execArgMaxLen], + const char* argvPtrs[elfMaxArgs], int* outArgc) +{ + if (argvPointer == 0) { + *outArgc = 0; + return true; + } + if (!userRangeOk(argvPointer, sizeof(uint64) * (elfMaxArgs + 1))) + return false; + + const uint64* userArgv = (const uint64*)argvPointer; + int argc = 0; + while (argc < elfMaxArgs && userArgv[argc] != 0) { + if (!copyUserPath(userArgv[argc], storage[argc], execArgMaxLen)) + return false; + argvPtrs[argc] = storage[argc]; + argc++; + } + if (argc == elfMaxArgs && userArgv[argc] != 0) + return false; + + *outArgc = argc; + return true; +} + static int64 doWrite(uint64 fd, uint64 buffer, uint64 length) { if (!userRangeOk(buffer, length)) @@ -48,15 +101,222 @@ static int64 doRead(uint64 fd, uint64 buffer, uint64 length) return vfsRead((int)fd, (void*)buffer, length); } +typedef struct LoadedImage { + uint64 entry; + uint64 stackTop; + uint64 brkStart; +} LoadedImage; + +/* Shared by doExec and doSpawn: resolves `path` through the VFS, loads its + LOAD segments via elfLoad (which maps them at whatever fixed virtual + address they were linked at -- see elf.c), then derives and maps a fresh + stack for that slot from nothing but the ELF's own entry point (see + elfSlotStackTop/elf.h). Leaves the caller's thread state untouched; + callers decide what to do with a successfully loaded image. */ +static bool loadImageByPath(const char* path, LoadedImage* out) +{ + VfsNode* node = vfsLookup(path); + if (!node) + return false; + /* Real EACCES-shaped refusal: a file without the right exec bit for the + CALLING thread's identity (vfsPermitted, owner-vs-other-vs-root) can + never be exec'd or spawned, exactly like real Unix's own execve() + check -- jobctl.c's own jobctlSpawn (the shell's separate, parallel + loading path) enforces the identical check the identical way. */ + if (!vfsPermitted(vfsMode(node), vfsUid(node), vfsGid(node), currentThreadUid(), currentThreadGid(), fsModeOwnerExec)) + return false; + + const void* image = vfsFileData(node); + uint64 size = vfsSize(node); + uint64 entry = elfLoad(image, size); + if (entry == 0) + return false; + + out->entry = entry; + out->stackTop = elfSlotStackTop(entry); + out->brkStart = elfSlotBrkStart(entry); + + return pagingMapUserRangeFlags(out->stackTop - execStackSize, execStackSize, pageNoExecute); +} + +/* execve()-lite: replaces the calling thread's own program image with a + different one, in place -- same pid, same open file descriptors (POSIX's + default, no O_CLOEXEC support to opt out of here), new code/stack/brk. + Mutates the live interrupt frame's rip/rsp directly (the same trick + threadCreateUser uses to bootstrap a brand new thread's very first + frame): on success this syscall does not "return" to the caller in any + meaningful sense, since regs->rip now points at the new image's entry + point instead of back into the old one's code -- exactly real exec() + semantics. General-purpose registers are zeroed so the new program never + observes leftover state from a syscall it never made. Failure (bad path, + not a valid ELF, thread table/frame mapping issue) leaves regs untouched + apart from the -1 return value: the caller keeps running its old image, + also matching real exec(). + + Also reclaims the OLD image's slot: frostbyte's flat single-address-space + design (see doSpawn below) means a program that exec()s into a binary + linked at a different fixed base -- the common case, since two distinct + binaries can never share a slot -- would otherwise leak its entire + elfSlotSize worth of physical frames every time, forever. Skipped only + when the new image happens to land in the SAME slot as the old one (a + binary exec()ing itself): unmapping first would free frames the new + image's own loadImageByPath call had already mapped moments earlier. + + argv/argc are already kernel-side copies (copyUserArgv, called from the + sysExec dispatch case below, BEFORE this runs) -- critical ordering, + since they may point into the OLD image's own memory (a process's argv + commonly lives in its own .rodata/stack), which this function is about + to unmap out from under it. */ +static bool doExec(const char* path, const char* const* argv, int argc, Registers* regs) +{ + uint32 pid = currentThreadId(); + uint64 oldSlotBase = threadUserSlotBase(pid); + + LoadedImage img; + if (!loadImageByPath(path, &img)) + return false; + + uint64 initialRsp; + if (!elfBuildInitialStack(img.stackTop, execStackSize, argv, argc, &initialRsp)) + return false; + + uint64 newSlotBase = elfSlotBase(img.entry); + if (oldSlotBase != 0 && oldSlotBase != newSlotBase) { + uint64 stackProbe = oldSlotBase + elfSlotStackOffset - 1; + bool wasMapped = pagingResolve(stackProbe) != 0; + + pagingUnmapUserRange(oldSlotBase, elfSlotSize); + + bool nowFreed = pagingResolve(stackProbe) == 0; + kprintf("[exec] pid=%u freed old slot %p (was-mapped=%s now-freed=%s) -> %s\n", + pid, (void*)oldSlotBase, wasMapped ? "yes" : "no", nowFreed ? "yes" : "no", + (wasMapped && nowFreed) ? "exec-old-slot-freed-ok" : "exec-old-slot-freed-FAIL"); + } else if (oldSlotBase != 0) { + /* Self-exec (or the pid==0/no-prior-slot case, unreachable for a + real ring-3 caller): the branch above is skipped precisely to + avoid unmapping the NEW image's own LOAD segments/stack, which + already landed on these same pages -- but any mmap mapping from + the OLD image is a disjoint sub-range (mmap.h's own mmapReserve + region, [slot+elfSlotStackOffset, slot+elfSlotSize)) that + loadImageByPath/elfBuildInitialStack never touch, so it still + needs reclaiming here explicitly or its frames would leak + forever every time a binary exec()s itself. */ + pagingUnmapUserRange(oldSlotBase + elfSlotStackOffset, elfSlotSize - elfSlotStackOffset); + } + if (oldSlotBase != 0) + mmapReleaseAll(threadAt(pid)); + threadSetUserSlotBase(pid, newSlotBase); + + threadSetName(pid, path); + threadSetBrk(pid, img.brkStart); + + regs->rip = img.entry; + regs->rsp = initialRsp; + regs->rbp = 0; + regs->rdi = 0; + regs->rsi = 0; + regs->rdx = 0; + regs->rcx = 0; + regs->rbx = 0; + regs->rax = 0; + regs->r8 = 0; + regs->r9 = 0; + regs->r10 = 0; + regs->r11 = 0; + regs->r12 = 0; + regs->r13 = 0; + regs->r14 = 0; + regs->r15 = 0; + return true; +} + +/* posix_spawn()-lite: loads `path` into a brand new thread/process rather + than replacing the caller, and returns its pid immediately without + waiting -- the caller joins it later with sysWait (threadJoin). This is + deliberately not a literal fork(): frostbyte has one shared address space + system-wide (no per-process page tables/CR3, see paging.c), so there is + no address space to duplicate, only a fresh slot to load a binary into. + As with any binary in this kernel, spawning two live instances of the + same path (or of two paths whose fixed link addresses happen to share a + slot) at once would map both onto the same pages and corrupt each other; + this is an accepted limitation of the flat single-address-space design, + not something doSpawn can detect from a path string alone. */ +static int64 doSpawn(const char* path, const char* const* argv, int argc) +{ + LoadedImage img; + if (!loadImageByPath(path, &img)) + return -1; + + uint64 initialRsp; + if (!elfBuildInitialStack(img.stackTop, execStackSize, argv, argc, &initialRsp)) + return -1; + + uint32 pid = threadCreateUser(path, img.entry, initialRsp); + if (pid == threadInvalidId) + return -1; + + threadSetBrk(pid, img.brkStart); + return (int64)pid; +} + uint64 syscallDispatch(Registers* regs) { + /* This thread is not (yet, or any more) parked mid a retryable + syscall's own block-and-retry pattern the instant ANY dispatch for + it begins -- whether this dispatch turns out to BE that exact retry + re-executing (sysRead/sysWrite/sysWait's own cases set it true again + below if they block once more) or a completely unrelated syscall. + See sched.h's Thread.sigInRetryableSyscall and signal.c's + sigConsumePending for why this precise a window matters: without it, + a signal delivered while this thread is doing perfectly ordinary + work could be mistaken for one interrupting a slow syscall. */ + currentThread()->sigInRetryableSyscall = false; + switch (regs->rax) { - case sysWrite: - regs->rax = (uint64)doWrite(regs->rdi, regs->rsi, regs->rdx); + case sysWrite: { + int64 result = doWrite(regs->rdi, regs->rsi, regs->rdx); + if (result == -1) { + /* A "would block" result on a fdFlagBlocking pipe end (see + vfs.h) is retried, not returned: park on the pipe's channel + and rewind rip by the 2-byte `int $0x80` this trap came from + so the whole syscall re-executes once schedWakeChannel makes + this thread Runnable again (pipeWrite, called from doWrite + above, already does that whenever it frees or fills space) -- + the exact restart-syscall pattern sysWait uses for + threadJoin, for the same reason: halt()ing here directly + would freeze the whole machine, not just this thread, since + this handler runs with interrupts disabled. A non-blocking + pipe end (vfsPipe, e.g. pipetest.c) or a permanently broken + one gets nullptr back and the -1 passes through untouched. */ + const void* channel = vfsBlockingChannel((int)regs->rdi, true); + if (channel) { + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(channel); + regs->rip -= 2; + return schedule((uint64)regs); + } + } + regs->rax = (uint64)result; return (uint64)regs; - case sysRead: - regs->rax = (uint64)doRead(regs->rdi, regs->rsi, regs->rdx); + } + case sysRead: { + int64 result = doRead(regs->rdi, regs->rsi, regs->rdx); + if (result == -1) { + /* Covers a fdFlagBlocking pipe/fifo end (see sysWrite above) and + fd 0's console reader queue (keyboard.h) alike -- both report + "would block" as -1 from doRead, and vfsBlockingChannel picks + the right wait channel for whichever kind fd actually is. */ + const void* channel = vfsBlockingChannel((int)regs->rdi, false); + if (channel) { + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(channel); + regs->rip -= 2; + return schedule((uint64)regs); + } + } + regs->rax = (uint64)result; return (uint64)regs; + } case sysGetpid: regs->rax = currentThreadId(); return (uint64)regs; @@ -114,6 +374,68 @@ uint64 syscallDispatch(Registers* regs) regs->rax = (uint64)(int64)vfsMkdir(path); return (uint64)regs; } + case sysChdir: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsChdir(path); + return (uint64)regs; + } + case sysGetcwd: + if (!userRangeOk(regs->rdi, regs->rsi)) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)vfsGetcwd((char*)regs->rdi, regs->rsi); + return (uint64)regs; + case sysGetEnv: { + char name[envEntryMax]; + if (!copyUserPath(regs->rdi, name, sizeof(name)) || !userRangeOk(regs->rsi, regs->rdx)) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + const char* value = currentThreadGetEnv(name); + if (!value) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + uint64 len = strlen(value); + if (len >= regs->rdx) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + memcpy((void*)regs->rsi, value, len + 1); + regs->rax = (uint64)len; + return (uint64)regs; + } + case sysSetEnv: { + char name[envEntryMax]; + char value[envEntryMax]; + if (!copyUserPath(regs->rdi, name, sizeof(name)) || + !copyUserPath(regs->rsi, value, sizeof(value))) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + regs->rax = currentThreadSetEnv(name, value) ? 0 : (uint64)-1; + return (uint64)regs; + } + case sysUnsetEnv: { + /* Real unsetenv(3): a no-op success whether or not `name` actually + had an entry -- ONLY a bad user pointer/path is a real failure + here, unlike sysSetEnv above where "table full"/"too long" are + genuine failures. currentThreadUnsetEnv's own bool return (which + DOES distinguish "removed" from "was not there", for a kernel- + side caller like shell.c that might want to) is deliberately + discarded at this syscall boundary. */ + char name[envEntryMax]; + if (!copyUserPath(regs->rdi, name, sizeof(name))) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + currentThreadUnsetEnv(name); + regs->rax = 0; + return (uint64)regs; + } case sysUnlink: { char path[256]; if (!copyUserPath(regs->rdi, path, sizeof(path))) @@ -122,6 +444,526 @@ uint64 syscallDispatch(Registers* regs) regs->rax = (uint64)(int64)vfsUnlink(path); return (uint64)regs; } + case sysRmdir: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsRmdir(path); + return (uint64)regs; + } + case sysChmod: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsChmod(path, (uint32)regs->rsi); + return (uint64)regs; + } + case sysChown: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsChown(path, (uint32)regs->rsi); + return (uint64)regs; + } + case sysGetuid: + /* Real getuid(2): no arguments, always succeeds, straight through + to the calling thread's own credential. */ + regs->rax = (uint64)currentThreadUid(); + return (uint64)regs; + case sysSetuid: + /* Real setuid(2): currentThreadSetUid (sched.h) itself enforces the + actual privilege rule (uid 0 may become anyone; anyone else may + only pass their own current uid back in) -- this dispatch case + does no additional validation of its own, matching sysChdir's own + "the underlying function already checks everything" split. */ + regs->rax = currentThreadSetUid((uint32)regs->rdi) ? 0 : (uint64)-1; + return (uint64)regs; + case sysGetgid: + /* Real getgid(2): same shape as sysGetuid above. */ + regs->rax = (uint64)currentThreadGid(); + return (uint64)regs; + case sysSetgid: + /* Real setgid(2): currentThreadSetGid (sched.h) itself enforces the + actual privilege rule (gated by this thread's own UID, not GID -- + see that function's own doc comment). */ + regs->rax = currentThreadSetGid((uint32)regs->rdi) ? 0 : (uint64)-1; + return (uint64)regs; + case sysChgrp: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsChgrp(path, (uint32)regs->rsi); + return (uint64)regs; + } + case sysPidfdOpen: + /* vfsPidfdOpen (vfs.c) already does every validity check pidfd.h's + own pidfdOpen needs (id 0/out-of-range/already-unused target) and + the fd-table installation vfsPipe/vfsPipeAttach's own dispatch + cases above delegate the same way -- nothing left for this + handler to add. */ + regs->rax = (uint64)vfsPidfdOpen((uint32)regs->rdi); + return (uint64)regs; + case sysPidfdSend: { + /* vfsPidfdTarget folds "is this fd even a live pidfd" and "has the + original target since been reaped/recycled" (pidfd.h's own + generation check) into one call -- a stale handle is rejected + right here, before sigRaiseInfo ever sees a pid, which is + exactly the ABA-safety pidfd.h exists to provide over a raw + userSigSend(pid, signum) on a cached pid (see pidfd.h's own file + comment). Otherwise identical to sysSigSend's own single-target + path just above (same siCodeUser/senderPid convention, same + sigKill/sigStop bypasses already living inside sigRaiseInfo). */ + uint32 target = vfsPidfdTarget((int)regs->rdi); + if (target == threadInvalidId) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + int signum = (int)regs->rsi; + bool ok = sigRaiseInfo(target, signum, siCodeUser, (int)currentThreadId(), 0); + regs->rax = ok ? 0 : (uint64)-1; + return (uint64)regs; + } + case sysPidfdWait: { + /* Same retry-the-syscall park pattern as sysWait's own specific-pid + path below, just resolving the target through a pidfd instead of + a raw pid -- re-resolved on EVERY retry (not just the first + check), so a target that gets reaped by someone else while this + thread is blocked is caught the instant this thread wakes back + up, rather than only at the moment sysPidfdWait was first called. + threadJoin(target) below only ever reaps a thread this exact + check just proved is threadDead AND still the same incarnation + this handle was opened on, so there is no risk of it silently + reaping (or reporting the exit code of) an unrelated thread that + happens to have landed on the same numeric slot since. */ + uint32 target = vfsPidfdTarget((int)regs->rdi); + if (target == threadInvalidId) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + Thread* targetThread = threadAt(target); + if (targetThread->state != threadDead) { + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(targetThread); + regs->rip -= 2; + return schedule((uint64)regs); + } + regs->rax = (uint64)threadJoin(target); + return (uint64)regs; + } + case sysSymlink: { + char target[256]; + char path[256]; + if (!copyUserPath(regs->rdi, target, sizeof(target)) || + !copyUserPath(regs->rsi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsSymlink(path, target); + return (uint64)regs; + } + case sysReadlink: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path)) || !userRangeOk(regs->rsi, regs->rdx)) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)vfsReadlink(path, (char*)regs->rsi, regs->rdx); + return (uint64)regs; + } + case sysLink: { + char oldPath[256]; + char newPath[256]; + if (!copyUserPath(regs->rdi, oldPath, sizeof(oldPath)) || + !copyUserPath(regs->rsi, newPath, sizeof(newPath))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsLink(oldPath, newPath); + return (uint64)regs; + } + case sysPipe: { + if (!userRangeOk(regs->rdi, 2 * sizeof(int))) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + int* userFds = (int*)regs->rdi; + int readFd, writeFd; + if (vfsPipe(&readFd, &writeFd) < 0) { + regs->rax = (uint64)-1; + } else { + userFds[0] = readFd; + userFds[1] = writeFd; + regs->rax = 0; + } + return (uint64)regs; + } + case sysPipeCreate: + regs->rax = (uint64)vfsPipeCreateHandle(); + return (uint64)regs; + case sysPipeAttach: + regs->rax = (uint64)vfsPipeAttach((int)regs->rdi, regs->rsi != 0); + return (uint64)regs; + case sysKill: { + /* Real kill(2)'s own pid<0/pid==0 convention (userKill's `pid` is + already a signed `long`, see ulib.h -- only this kernel-side + interpretation of the raw bit pattern was missing): pid>0 is the + single-target path this always had; pid==0 targets the caller's + OWN process group (threadKillGroup, sched.c -- naturally kills + no one else if this is the group's only OTHER member, since + threadKill itself already excludes the caller's own Running + thread); pid<0 targets group -pid, exactly like real + kill(-pgid, SIGKILL). */ + int64 rawPid = (int64)regs->rdi; + bool ok; + if (rawPid > 0) + ok = threadKill((uint32)rawPid); + else + ok = threadKillGroup(rawPid == 0 ? currentThread()->pgid : (uint32)(-rawPid)); + regs->rax = ok ? 0 : (uint64)-1; + return (uint64)regs; + } + case sysSigSend: { + /* Same pid>0/pid==0/pid<0 convention as sysKill just above, this + time through sigRaiseGroup (signal.h) for the two group cases -- + see that function's own doc comment for why every per-target + special case (SIGKILL/SIGSTOP/SIGCONT bypasses, early-wake, + SigInfo) still applies exactly as it already does for a plain, + single-target send. */ + int64 rawPid = (int64)regs->rdi; + int signum = (int)regs->rsi; + uint32 senderPid = currentThreadId(); + bool isGroup = (rawPid <= 0); + uint32 targetPgid = isGroup ? (rawPid == 0 ? currentThread()->pgid : (uint32)(-rawPid)) : 0; + + /* siCodeUser/senderPid: a real userSigSend always has a genuine + sending process, unlike sched.c's own kernel-internal raises + (SIGALRM/SIGCHLD) or keyboard.c's terminal-generated ones -- + exactly real Unix's own SI_USER, si_pid the caller's own pid, + exactly like real kill(2). */ + bool ok = isGroup + ? sigRaiseGroup(targetPgid, signum, siCodeUser, (int)senderPid) + : sigRaiseInfo((uint32)rawPid, signum, siCodeUser, (int)senderPid, 0); + regs->rax = ok ? 0 : (uint64)-1; + + /* Every OTHER outcome of sigRaise/sigRaiseGroup either changes + nothing about the CALLING thread's own state (a signal aimed at + someone else) or, for an ordinary catchable signal aimed at + self, is already caught by isrDispatch's unconditional + deliverPendingSignals call right after this handler returns + (see isr.c) -- self-signaling sigUsr1 and having its handler + fire before ever reaching userspace again already works today + without any special case here. SIGSTOP against the caller's own + pid (or a group that includes it) is the one exception: + threadStop (called from sigRaise above, once per group member + for the group case) already flipped this very thread's state + away from Running, but sigRaise/sysSigSend never touch + sigPending for it (see signal.h), so deliverPendingSignals' + pending-bit scan would find nothing and simply hand `regs` + straight back -- returning to ring 3 and continuing to run a + thread the scheduler no longer considers Runnable, until the + next timer tick eventually notices. Explicit reschedule here + closes that gap, the same "flip state, then let schedule() do + the actual waiting" pattern sysSleep/sysExit already use for + their own self-directed state changes. The group case checks + targetPgid against the caller's OWN pgid rather than an exact id + match, since the caller itself -- not just some other member -- + is who this reschedule is actually about. */ + bool selfTargeted = isGroup ? (targetPgid == currentThread()->pgid) : ((uint32)rawPid == senderPid); + if (ok && signum == sigStop && selfTargeted) + return schedule((uint64)regs); + return (uint64)regs; + } + case sysSigAction: + regs->rax = sigSetHandler(currentThread(), (int)regs->rdi, regs->rsi, regs->rdx, regs->r10); + return (uint64)regs; + case sysSigProcMask: + regs->rax = sigProcMask(currentThread(), (int)regs->rdi, regs->rsi); + return (uint64)regs; + case sysSigReturn: + sigOnSigReturn(currentThread(), regs); + return (uint64)regs; + case sysAlarm: + regs->rax = threadSetAlarm(regs->rdi); + return (uint64)regs; + case sysSetpgid: { + /* Real setpgid(2)'s own "0 means self"/"0 means use pid itself as + the new group id" resolution -- threadSetPgid (sched.h) takes an + already-fully-resolved value, exactly like threadSetBrk/ + threadSetAlarm above do for THEIR raw syscall arguments. */ + uint32 pid = (regs->rdi == 0) ? currentThreadId() : (uint32)regs->rdi; + uint32 pgid = (regs->rsi == 0) ? pid : (uint32)regs->rsi; + regs->rax = threadSetPgid(pid, pgid) ? 0 : (uint64)-1; + return (uint64)regs; + } + case sysGetpgid: { + uint32 pid = (regs->rdi == 0) ? currentThreadId() : (uint32)regs->rdi; + uint32 pgid = threadPgid(pid); + regs->rax = (pgid == threadInvalidId) ? (uint64)-1 : (uint64)pgid; + return (uint64)regs; + } + case sysNice: { + /* Real setpriority(PRIO_PROCESS, ...)'s own "0 means the caller + itself" convention, same resolution shape as sysSetpgid's `pid` + argument just above -- rdi/pid, rsi/weight, straight through to + threadSetPriority (sched.h), which now does the SAME id==0/ + out-of-range/Dead-or-Unused rejection threadSetPgid already did + (see that function's own updated doc comment for why it had to + catch up once this syscall made it reachable from ring 3). */ + uint32 pid = (regs->rdi == 0) ? currentThreadId() : (uint32)regs->rdi; + uint32 weight = (uint32)regs->rsi; + regs->rax = threadSetPriority(pid, weight) ? 0 : (uint64)-1; + return (uint64)regs; + } + case sysGetPriority: { + uint32 pid = (regs->rdi == 0) ? currentThreadId() : (uint32)regs->rdi; + uint32 weight = threadPriority(pid); + regs->rax = (weight == 0) ? (uint64)-1 : (uint64)weight; + return (uint64)regs; + } + case sysTime: + /* Real time(2)'s job: seconds since the Unix epoch, off the real + CMOS RTC (rtc.c) -- the first syscall in this kernel answering + "what day is it" instead of "how long since boot" (sysSleep, + userAlarm). No arguments, nothing to validate. */ + regs->rax = (uint64)rtcNow(); + return (uint64)regs; + case sysSetFgPgrp: + /* Real tcsetpgrp(3)'s job -- names which process group keyboard.c's + real Ctrl-C/Ctrl-Z (SIGINT/SIGTSTP) is scoped to next. No + permission check at all (this kernel has no notion of "the + controlling terminal of MY session" to check ownership against, + the same honest, documented omission threadSetPgid's own doc + comment already gives for setpgid's session check) -- any + process can claim the foreground for any pgid, including one it + is not even a member of. */ + keyboardSetForegroundPgid((uint32)regs->rdi); + regs->rax = 0; + return (uint64)regs; + case sysGetFgPgrp: + regs->rax = (uint64)keyboardForegroundPgid(); + return (uint64)regs; + case sysPoll: { + /* Real poll(2): rdi/PollFd array, rsi/nfds, rdx/timeoutMs (signed, + -1 = wait forever, 0 = report current readiness without ever + blocking, like every real poll(2) implementation). Flat address + space (this kernel's honest fork()-less design, see doSpawn's own + comment) means the array can be read and written in place, the + same way sysStat writes straight into its caller's FbStat* + without a separate copy-out step. */ + uint64 nfds = regs->rsi; + int64 timeoutMs = (int64)regs->rdx; + if (nfds > maxPollFds || !userRangeOk(regs->rdi, nfds * sizeof(PollFd))) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + PollFd* userFds = (PollFd*)regs->rdi; + Thread* self = currentThread(); + + /* First entry into this call (not a retry re-executing after a + park below) picks the deadline once and remembers it in + pollDeadline -- a later retry must NOT recompute it from the + still-unchanged rdx, or a poll that keeps almost-but-not-quite + finding a ready fd could have its effective timeout pushed out + indefinitely instead of counting down real elapsed time. */ + if (!self->pollActive && timeoutMs != 0) { + self->pollActive = true; + if (timeoutMs < 0) { + self->pollDeadline = 0; + } else { + uint64 ticks = ((uint64)timeoutMs * (uint64)pitFrequency()) / 1000; + if (ticks == 0) + ticks = 1; + self->pollDeadline = pitTicks() + ticks; + } + } + + int64 ready = 0; + for (uint64 i = 0; i < nfds; i++) { + int16 revents = 0; + if (vfsPollReady(userFds[i].fd, userFds[i].events, &revents)) + ready++; + userFds[i].revents = revents; + } + + bool expired = self->pollActive && self->pollDeadline != 0 && + pitTicks() >= self->pollDeadline; + if (ready > 0 || timeoutMs == 0 || expired) { + self->pollActive = false; + self->pollDeadline = 0; + regs->rax = (uint64)ready; + return (uint64)regs; + } + + /* None ready yet, timeout not yet reached: park on the one shared + channel every poller blocks on (schedPollChannel, sched.h) and + rewind rip so the whole syscall re-executes -- the same + restart-the-syscall trick sysRead/sysWrite/sysWait already use, + except schedule()'s own expiry scan (sched.c) is what guarantees + this retry eventually happens even if no fd ever becomes ready. */ + self->sigInRetryableSyscall = true; + schedBlockCurrent(schedPollChannel()); + regs->rip -= 2; + return schedule((uint64)regs); + } + case sysMkfifo: { + char path[256]; + if (!copyUserPath(regs->rdi, path, sizeof(path))) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)(int64)vfsMkfifo(path); + return (uint64)regs; + } + case sysExec: { + char path[256]; + char argvStorage[elfMaxArgs][execArgMaxLen]; + const char* argvPtrs[elfMaxArgs]; + int argc; + if (!copyUserPath(regs->rdi, path, sizeof(path)) || + !copyUserArgv(regs->rsi, argvStorage, argvPtrs, &argc) || + !doExec(path, argvPtrs, argc, regs)) + regs->rax = (uint64)-1; + return (uint64)regs; + } + case sysSpawn: { + char path[256]; + char argvStorage[elfMaxArgs][execArgMaxLen]; + const char* argvPtrs[elfMaxArgs]; + int argc; + if (!copyUserPath(regs->rdi, path, sizeof(path)) || + !copyUserArgv(regs->rsi, argvStorage, argvPtrs, &argc)) + regs->rax = (uint64)-1; + else + regs->rax = (uint64)doSpawn(path, argvPtrs, argc); + return (uint64)regs; + } + case sysWait: { + uint32 id = (uint32)regs->rdi; + if (id == waitAnyPid) { + /* waitpid(-1)-style "wait for ANY child" -- see + threadJoinAnyChild's own doc comment (sched.h) for the full + contract. regs->rsi is an optional user `long*` pointer + (userWaitAny's outPid, ulib.c) that receives which pid + actually got reaped; nullptr means the caller does not care. + Written as a full 8-byte store below to match `long`'s real + width, not truncated to sizeof(uint32) -- a narrower store + would leave the user variable's upper 4 bytes as whatever + uninitialized garbage the caller's stack already had. */ + if (regs->rsi != 0 && !userRangeOk(regs->rsi, sizeof(uint64))) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + uint32 reapedId; + int64 code; + WaitScanResult scan = threadJoinAnyChild(currentThreadId(), &reapedId, &code); + if (scan == waitScanNoChildren) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + if (scan == waitScanNoneDead) { + /* Same retry-syscall dance as the specific-pid path below, + just parked on our OWN slot instead of a named child's -- + threadExit/threadKill wake this channel for EVERY dying + child of ours, not just one in particular (see + threadJoinAnyChild's doc comment), so re-scanning from + scratch on each retry is what actually picks up whichever + one died. */ + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(currentThread()); + regs->rip -= 2; + return schedule((uint64)regs); + } + if (regs->rsi != 0) + *(uint64*)regs->rsi = (uint64)reapedId; + regs->rax = (uint64)code; + return (uint64)regs; + } + if (id == 0 || id >= threadCapacity()) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + Thread* target = threadAt(id); + if (target->state == threadUnused) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + if (target->state != threadDead) { + /* Not dead yet: parking here and calling halt() the way + threadJoin (sched.c) does for a plain kernel thread body + would hang the whole machine, not just this thread -- this + handler runs with interrupts disabled (int 0x80 is an + interrupt gate, idt.c) on the single shared bootstrap kernel + stack (isrStubs.S/tss.c), not the calling thread's own + persistent one, so unlike kmain.c's joinerThread it cannot + literally halt in place and pick its own C call frame back + up later: hlt with IF=0 never wakes, since nothing can ever + deliver the timer interrupt that would run schedule() again. + Instead: block on the same channel threadExit wakes + (schedWakeChannel(&threads[id])), rewind rip by the 2 bytes + of the `int $0x80` opcode this trap just came from so it + re-executes once this thread is scheduled again, and hand + the CPU to schedule() -- the same "flip state, then let the + interrupt-return path do the actual waiting" technique + sysSleep already uses. The retry re-enters this exact case + with the same rax/rdi; since threadExit will have already + flipped the target to Dead before waking this thread back + up, it takes the branch below next time and completes + without blocking again. */ + currentThread()->sigInRetryableSyscall = true; + schedBlockCurrent(target); + regs->rip -= 2; + return schedule((uint64)regs); + } + regs->rax = (uint64)threadJoin(id); + return (uint64)regs; + } + case sysMmap: { + /* addrHint==0 means "kernel picks" (mmapReserve's own first-fit, + real mmap(NULL, ...) shape); a nonzero addrHint is taken as an + exact placement request, refused (not silently relocated) if it + does not fit -- there is no MAP_FIXED bit to distinguish the two, + every addrHint here behaves like real mmap's own weaker "hint" + contract already narrowed down to "exact or refused". fd/offset + are read (and validated below) even when MAP_ANONYMOUS is set, + harmlessly ignored by mmapReserve itself in that case, exactly + like real mmap(2)'s own documented behavior. */ + uint64 addrHint = regs->rdi; + uint64 length = regs->rsi; + uint32 prot = (uint32)regs->rdx; + uint32 flags = (uint32)regs->r10; + int fd = (int)regs->r8; + uint64 offset = regs->r9; + bool anonymous = (flags & mmapAnonymous) != 0; + + if (!anonymous) { + /* Same three seekable, real-data fd kinds vfsLseek itself + already restricts to (vfs.c) -- a pipe/fifo/console/pidfd fd + has no fixed byte range a page fault could ever re-read + deterministically, so mmap refuses them here rather than + letting mmapFaultHandler discover that failure lazily, one + page at a time, later. */ + if (fd < 0 || fd >= maxOpenFiles) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + FileDescriptor* fdEntry = ¤tThread()->fds[fd]; + if (fdEntry->kind != fdFile && fdEntry->kind != fdSfs && fdEntry->kind != fdBlockDev) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + } + + uint64 mappedAddr; + if (!mmapReserve(currentThread(), addrHint, length, prot, anonymous, fd, offset, &mappedAddr)) { + regs->rax = (uint64)-1; + return (uint64)regs; + } + regs->rax = mappedAddr; + return (uint64)regs; + } + case sysMunmap: + regs->rax = mmapRelease(currentThread(), regs->rdi, regs->rsi) ? 0 : (uint64)-1; + return (uint64)regs; default: regs->rax = (uint64)-1; return (uint64)regs; diff --git a/src/vec.c b/src/vec.c new file mode 100644 index 0000000..7a0cc1f --- /dev/null +++ b/src/vec.c @@ -0,0 +1,116 @@ +#include "vec.h" +#include "heap.h" +#include "string.h" + +void vecInit(Vec* v, usize elemSize) +{ + v->data = nullptr; + v->elemSize = elemSize; + v->length = 0; + v->capacity = 0; +} + +void vecFree(Vec* v) +{ + kfree(v->data); + v->data = nullptr; + v->length = 0; + v->capacity = 0; +} + +bool vecReserve(Vec* v, usize newCapacity) +{ + if (newCapacity <= v->capacity) + return true; + + usize grown = v->capacity == 0 ? 4 : v->capacity * 2; + if (grown < newCapacity) + grown = newCapacity; + + uint8* bigger = (uint8*)krealloc(v->data, grown * v->elemSize); + if (!bigger) + return false; + + v->data = bigger; + v->capacity = grown; + return true; +} + +bool vecPush(Vec* v, const void* elem) +{ + if (!vecReserve(v, v->length + 1)) + return false; + memcpy(v->data + v->length * v->elemSize, elem, v->elemSize); + v->length++; + return true; +} + +bool vecPop(Vec* v, void* outElem) +{ + if (v->length == 0) + return false; + v->length--; + if (outElem) + memcpy(outElem, v->data + v->length * v->elemSize, v->elemSize); + return true; +} + +void* vecAt(Vec* v, usize index) +{ + if (index >= v->length) + return nullptr; + return v->data + index * v->elemSize; +} + +bool vecSet(Vec* v, usize index, const void* elem) +{ + if (index >= v->length) + return false; + memcpy(v->data + index * v->elemSize, elem, v->elemSize); + return true; +} + +bool vecInsert(Vec* v, usize index, const void* elem) +{ + if (index > v->length) + return false; + if (!vecReserve(v, v->length + 1)) + return false; + + usize tailCount = v->length - index; + if (tailCount > 0) { + memmove(v->data + (index + 1) * v->elemSize, + v->data + index * v->elemSize, + tailCount * v->elemSize); + } + memcpy(v->data + index * v->elemSize, elem, v->elemSize); + v->length++; + return true; +} + +bool vecRemove(Vec* v, usize index, void* outElem) +{ + if (index >= v->length) + return false; + + if (outElem) + memcpy(outElem, v->data + index * v->elemSize, v->elemSize); + + usize tailCount = v->length - index - 1; + if (tailCount > 0) { + memmove(v->data + index * v->elemSize, + v->data + (index + 1) * v->elemSize, + tailCount * v->elemSize); + } + v->length--; + return true; +} + +void vecClear(Vec* v) +{ + v->length = 0; +} + +usize vecLen(const Vec* v) { return v->length; } +usize vecCap(const Vec* v) { return v->capacity; } +bool vecIsEmpty(const Vec* v) { return v->length == 0; } diff --git a/src/vecTest.c b/src/vecTest.c new file mode 100644 index 0000000..0b9cc50 --- /dev/null +++ b/src/vecTest.c @@ -0,0 +1,116 @@ +#include "ktest.h" +#include "vec.h" + +KTEST(vecPushPopGrows) +{ + Vec v; + vecInit(&v, sizeof(int)); + KT_TRUE(t, vecIsEmpty(&v)); + KT_EQ(t, vecCap(&v), 0); + + for (int i = 0; i < 20; i++) + KT_TRUE(t, vecPush(&v, &i)); + KT_EQ(t, vecLen(&v), 20); + KT_GE(t, vecCap(&v), 20); + + for (int i = 0; i < 20; i++) + KT_EQ(t, *(int*)vecAt(&v, (usize)i), i); + + int popped = -1; + KT_TRUE(t, vecPop(&v, &popped)); + KT_EQ(t, popped, 19); + KT_EQ(t, vecLen(&v), 19); + + vecFree(&v); +} + +KTEST(vecPopEmptyFails) +{ + Vec v; + vecInit(&v, sizeof(int)); + int out; + KT_FALSE(t, vecPop(&v, &out)); + KT_NULL(t, vecAt(&v, 0)); + vecFree(&v); +} + +KTEST(vecSetAt) +{ + Vec v; + vecInit(&v, sizeof(int)); + for (int i = 0; i < 5; i++) + vecPush(&v, &i); + + int replacement = 99; + KT_TRUE(t, vecSet(&v, 2, &replacement)); + KT_EQ(t, *(int*)vecAt(&v, 2), 99); + KT_FALSE(t, vecSet(&v, 10, &replacement)); /* out of range */ + + vecFree(&v); +} + +KTEST(vecInsertRemoveShifts) +{ + Vec v; + vecInit(&v, sizeof(int)); + for (int i = 0; i < 5; i++) + vecPush(&v, &i); /* 0 1 2 3 4 */ + + int inserted = 100; + KT_TRUE(t, vecInsert(&v, 2, &inserted)); /* 0 1 100 2 3 4 */ + KT_EQ(t, vecLen(&v), 6); + KT_EQ(t, *(int*)vecAt(&v, 2), 100); + KT_EQ(t, *(int*)vecAt(&v, 3), 2); + KT_EQ(t, *(int*)vecAt(&v, 5), 4); + + int removed = -1; + KT_TRUE(t, vecRemove(&v, 2, &removed)); /* back to 0 1 2 3 4 */ + KT_EQ(t, removed, 100); + KT_EQ(t, vecLen(&v), 5); + for (int i = 0; i < 5; i++) + KT_EQ(t, *(int*)vecAt(&v, (usize)i), i); + + vecFree(&v); +} + +KTEST(vecClearKeepsCapacity) +{ + Vec v; + vecInit(&v, sizeof(int)); + for (int i = 0; i < 8; i++) + vecPush(&v, &i); + usize capBefore = vecCap(&v); + + vecClear(&v); + KT_EQ(t, vecLen(&v), 0); + KT_TRUE(t, vecIsEmpty(&v)); + KT_EQ(t, vecCap(&v), capBefore); /* clear does not free backing storage */ + + int again = 7; + KT_TRUE(t, vecPush(&v, &again)); + KT_EQ(t, *(int*)vecAt(&v, 0), 7); + + vecFree(&v); +} + +KTEST(vecStructElements) +{ + typedef struct { int32 x; int32 y; } Point; + + Vec v; + vecInit(&v, sizeof(Point)); + + Point a = { 1, 2 }; + Point b = { 3, 4 }; + vecPush(&v, &a); + vecPush(&v, &b); + + Point* pa = (Point*)vecAt(&v, 0); + Point* pb = (Point*)vecAt(&v, 1); + KT_EQ(t, pa->x, 1); + KT_EQ(t, pa->y, 2); + KT_EQ(t, pb->x, 3); + KT_EQ(t, pb->y, 4); + + vecFree(&v); +} diff --git a/src/vfs.c b/src/vfs.c index 0da7118..783e4bd 100644 --- a/src/vfs.c +++ b/src/vfs.c @@ -5,17 +5,51 @@ #include "console.h" #include "keyboard.h" #include "sched.h" +#include "pipe.h" +#include "pidfd.h" +#include "ata.h" +#include "sfs.h" +#include "poll.h" #define tmpfsBlock 512 +#define blockDevPath "/dev/disk0" +#define sfsMountPrefix "/mnt" +#define sfsMountPrefixLen 4 struct VfsNode { char name[fsNameMax]; uint32 type; bool writable; + /* Real owner rwx permission bits (fs.h's fsModeOwner*), an axis + independent of `writable` above: `writable` says whether this node's + CONTENT/CHILDREN may be mutated at all (a mount-level property, false + for every ustar-loaded node regardless of mode); `mode` is the real, + chmod(2)-settable per-node permission a caller must ALSO satisfy to + open the node for writing or (jobctl.c/syscall.c) exec it. Both gates + must pass, exactly like a real read-only mount PLUS real per-file + permission bits would. */ + uint32 mode; + /* Real owner identity (POSIX uid, fs.h's FbStat.uid): whichever + currentThreadUid() was current at nodeAlloc time -- always 0 ("root") + for every ustar-loaded and boot-time tmpfs-mount node, since + vfsMountUstar/vfsMountTmpfs both run after schedInit but before any + real process exists (schedInit's idle thread, uid 0, is always + "current" then). Never retroactive, like mode's own default; there is + no chown-shaped call anywhere in this kernel to ever change it after + creation (see fs.h's FbStat.uid doc comment). */ + uint32 uid; + /* Real group identity (fs.h's FbStat.gid): whichever currentThreadGid() + was current at nodeAlloc time, same creation-time-only, never- + retroactive shape as uid immediately above. */ + uint32 gid; const uint8* roData; uint8* rwData; uint64 size; uint64 capacity; + /* Only meaningful for type == fsTypeFifo: the pipe.h handle every + vfsOpen on this path attaches a fresh end to (see makeChildFifo). -1 + for every other node type. */ + int32 pipeHandle; struct VfsNode* parent; struct VfsNode* firstChild; struct VfsNode* nextSibling; @@ -23,7 +57,7 @@ struct VfsNode { static VfsNode* root; -static VfsNode* nodeAlloc(const char* name, uint64 nameLen, uint32 type, bool writable) +static VfsNode* nodeAlloc(const char* name, uint64 nameLen, uint32 type, bool writable, uint32 mode) { VfsNode* node = (VfsNode*)kmalloc(sizeof(VfsNode)); if (!node) @@ -35,6 +69,10 @@ static VfsNode* nodeAlloc(const char* name, uint64 nameLen, uint32 type, bool wr node->name[nameLen] = 0; node->type = type; node->writable = writable; + node->mode = mode; + node->uid = currentThreadUid(); + node->gid = currentThreadGid(); + node->pipeHandle = -1; return node; } @@ -53,18 +91,18 @@ static VfsNode* findChild(VfsNode* parent, const char* name, uint64 nameLen) return nullptr; } -static VfsNode* makeChildDir(VfsNode* parent, const char* name, uint64 nameLen, bool writable) +static VfsNode* makeChildDir(VfsNode* parent, const char* name, uint64 nameLen, bool writable, uint32 mode) { - VfsNode* node = nodeAlloc(name, nameLen, fsTypeDir, writable); + VfsNode* node = nodeAlloc(name, nameLen, fsTypeDir, writable, mode); if (node) addChild(parent, node); return node; } static VfsNode* makeChildFile(VfsNode* parent, const char* name, uint64 nameLen, - const uint8* data, uint64 size, bool writable) + const uint8* data, uint64 size, bool writable, uint32 mode) { - VfsNode* node = nodeAlloc(name, nameLen, fsTypeFile, writable); + VfsNode* node = nodeAlloc(name, nameLen, fsTypeFile, writable, mode); if (!node) return nullptr; if (writable) { @@ -79,6 +117,29 @@ static VfsNode* makeChildFile(VfsNode* parent, const char* name, uint64 nameLen, return node; } +static VfsNode* makeChildFifo(VfsNode* parent, const char* name, uint64 nameLen, int pipeHandle) +{ + VfsNode* node = nodeAlloc(name, nameLen, fsTypeFifo, true, fsModeDefaultFile); + if (!node) + return nullptr; + node->pipeHandle = pipeHandle; + addChild(parent, node); + return node; +} + +/* Real POSIX "search" permission: resolving a NAME inside a directory (as + opposed to "." / ".." above, which never look anything up) requires the + owner-or-other exec bit on that directory for the calling thread's own + uid, exactly like a real kernel's namei() checks X_OK on every directory + it walks through. Deliberately only gates the findChild() branch below, + not "."/".." -- an honest, minor scope boundary matching this codebase's + existing style of not fixing unrelated pre-existing gaps while adding a + new feature (see this file's many other "deliberately out of scope" + comments). Denial collapses to the same nullptr every other lookup + failure already returns, matching every other EACCES-shaped refusal in + this file (resolveOpen's write check, etc.) -- callers cannot tell + "does not exist" from "exists but not searchable", exactly like the rest + of this VFS layer's error handling. */ static VfsNode* lookupNode(const char* path) { if (!path) @@ -103,6 +164,8 @@ static VfsNode* lookupNode(const char* path) } else { if (cur->type != fsTypeDir) return nullptr; + if (!vfsPermitted(cur->mode, cur->uid, cur->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerExec)) + return nullptr; VfsNode* child = findChild(cur, start, len); if (!child) return nullptr; @@ -191,12 +254,27 @@ static void ustarAdd(const char* rawPath, bool isDir, const uint8* data, uint64 if (last) { if (!child) { if (isDir) - child = makeChildDir(cur, path + start, len, false); - else - child = makeChildFile(cur, path + start, len, data, size, false); + child = makeChildDir(cur, path + start, len, false, fsModeDefaultDir); + else { + /* Anything shipped under "bin/" (mktar's own stored-name + convention, no leading slash -- see tools/mktar.c) is a + real binary this kernel spawns by path (jobctlSpawn, + syscall.c's loadImageByPath): give it the owner-exec + bit by default, exactly as if someone had already run + `chmod 755` on it, since nothing in this kernel could + otherwise ever chmod a read-only ustar-loaded file + (vfsChmod refuses a non-writable node, same as every + other tmpfs mutation). Everything else defaults to + fsModeDefaultFile (no exec bit), matching a real + ordinary shipped data file. */ + uint32 mode = (n > 4 && memcmp(path, "bin/", 4) == 0) + ? fsModeDefaultExec + : fsModeDefaultFile; + child = makeChildFile(cur, path + start, len, data, size, false, mode); + } } } else if (!child) { - child = makeChildDir(cur, path + start, len, false); + child = makeChildDir(cur, path + start, len, false, fsModeDefaultDir); } if (!child) return; @@ -206,7 +284,7 @@ static void ustarAdd(const char* rawPath, bool isDir, const uint8* data, uint64 void vfsInit(void) { - root = nodeAlloc("/", 1, fsTypeDir, false); + root = nodeAlloc("/", 1, fsTypeDir, false, fsModeDefaultDir); } void vfsMountUstar(uint64 start, uint64 end) @@ -233,7 +311,7 @@ void vfsMountTmpfs(const char* path) return; if (findChild(parent, leaf, strlen(leaf))) return; - makeChildDir(parent, leaf, strlen(leaf), true); + makeChildDir(parent, leaf, strlen(leaf), true, fsModeDefaultDir); kprintf("[vfs] mounted writable tmpfs at %s\n", path); } @@ -244,10 +322,25 @@ void vfsFdInit(FileDescriptor* fds) fds[i].offset = 0; fds[i].kind = fdFree; fds[i].flags = 0; + fds[i].pipeHandle = -1; } fds[0].kind = fdConsole; fds[1].kind = fdConsole; fds[2].kind = fdConsole; + + /* fd 0's own console reader queue (keyboard.h) -- reused field, same as + a pipe/fifo fd's handle into pipe.c's table. Every thread (kernel and + user alike) gets one, whether it ever reads stdin or not, matching + fd 0 already always being fdConsole for every thread regardless of + use. fdFlagBlocking makes syscall.c's sysRead retry-syscall path + treat it exactly like a blocking pipe end (vfsBlockingChannel below); + fd 1/2 (stdout/stderr) never read, so they get no reader and no + blocking flag. If the reader table is exhausted, fd 0 is left + non-blocking with an invalid handle -- reads on it then simply fail + (-1) rather than crash, the same fail-open behavior a fully exhausted + pipe/fifo table already has elsewhere in this file. */ + fds[0].pipeHandle = keyboardOpenReader(); + fds[0].flags = fdFlagBlocking; } static FileDescriptor* fdAt(int fd) @@ -286,35 +379,509 @@ static bool ensureCapacity(VfsNode* node, uint64 needed) return true; } -int vfsOpen(const char* path, uint32 flags) +static uint64 blockDevTotalBytes(void) { + return ataSectorCount() * (uint64)ataSectorSize; +} + +/* Reads/writes at most one sector's worth per iteration so a request that + straddles a sector boundary (or several) is handled as a sequence of + aligned sector transfers, each preceded by a read-modify-write step when + the access does not start or end on a sector boundary. */ +static int64 blockDevRead(FileDescriptor* d, void* buffer, uint64 length) +{ + uint64 total = blockDevTotalBytes(); + if (d->offset >= total) + return 0; + uint64 want = length; + if (want > total - d->offset) + want = total - d->offset; + + uint8* dst = (uint8*)buffer; + uint64 done = 0; + while (done < want) { + uint64 offset = d->offset + done; + uint64 lba = offset / ataSectorSize; + uint64 within = offset % ataSectorSize; + uint64 chunk = ataSectorSize - within; + if (chunk > want - done) + chunk = want - done; + + uint8 sector[ataSectorSize]; + if (!ataReadSectors(lba, 1, sector)) + break; + memcpy(dst + done, sector + within, chunk); + done += chunk; + } + + d->offset += done; + if (done > 0) + return (int64)done; + return want == 0 ? 0 : -1; +} + +static int64 blockDevWrite(FileDescriptor* d, const void* buffer, uint64 length) +{ + uint64 total = blockDevTotalBytes(); + if (d->offset >= total) + return -1; + uint64 want = length; + if (want > total - d->offset) + want = total - d->offset; + + const uint8* src = (const uint8*)buffer; + uint64 done = 0; + while (done < want) { + uint64 offset = d->offset + done; + uint64 lba = offset / ataSectorSize; + uint64 within = offset % ataSectorSize; + uint64 chunk = ataSectorSize - within; + if (chunk > want - done) + chunk = want - done; + + uint8 sector[ataSectorSize]; + if (chunk == ataSectorSize) { + memcpy(sector, src + done, ataSectorSize); + } else if (!ataReadSectors(lba, 1, sector)) { + break; + } else { + memcpy(sector + within, src + done, chunk); + } + if (!ataWriteSectors(lba, 1, sector)) + break; + done += chunk; + } + + d->offset += done; + if (done > 0) + return (int64)done; + return want == 0 ? 0 : -1; +} + +/* True for "/mnt" itself and for anything under "/mnt/". relOut receives the + path with the prefix stripped: "" (or "/foo") for sfsLookup, which treats + an empty relative path as the sfs root. */ +static bool isSfsPath(const char* path, const char** relOut) +{ + if (strncmp(path, sfsMountPrefix, sfsMountPrefixLen) != 0) + return false; + char after = path[sfsMountPrefixLen]; + if (after != '\0' && after != '/') + return false; + *relOut = path + sfsMountPrefixLen; + return true; +} + +/* See vfs.h's own doc comment for the full contract -- this is the single + place shell.c's old per-builtin static cwd's logic now lives, shared by + every path-taking entry point below instead of only shell.c's own + handful of commands. */ +void vfsResolvePath(const char* path, char* out, uint64 cap) +{ + if (path[0] == '/') { + uint64 len = strlen(path); + if (len >= cap) + len = cap - 1; + memcpy(out, path, len); + out[len] = 0; + return; + } + + const char* cwd = currentThreadCwd(); + uint64 pos = strlen(cwd); + if (pos >= cap) + pos = cap - 1; + memcpy(out, cwd, pos); + if (pos == 0 || out[pos - 1] != '/') { + if (pos < cap - 1) + out[pos++] = '/'; + } + uint64 i = 0; + while (path[i] && pos < cap - 1) + out[pos++] = path[i++]; + out[pos] = 0; +} + +static uint32 sfsTypeToFsType(uint32 sfsType) +{ + if (sfsType == sfsTypeDir) + return fsTypeDir; + if (sfsType == sfsTypeFifo) + return fsTypeFifo; + return fsTypeFile; +} + +/* Persistent (sfs.h) named-pipe bindings for "/mnt" fifos: sfs.c stores only + that a path IS a fifo (the inode's type), never a live byte stream -- the + actual pipe.h handle carrying bytes between two independent openers of + the same persistent path is purely in-memory here, exactly like the + VfsNode-resident one a tmpfs fifo carries on its own node forever (see + makeChildFifo above), except an SFS inode number has nowhere on-disk to + remember a handle. This small fixed table maps inode number -> live + handle, created lazily on the FIRST open() of either direction (or the + first open() since every previous session's ends have both gone away, + see sfsFifoForget) and reused by every later open() of the same inode + until then. + Slot 0 is never a real fifo inode (allocInodeNum, sfs.c, starts at 1; + inode 0 is permanently marked used by sfsFormat as a sentinel), so a + zero-initialized table already starts every slot correctly "free" -- + inodeNum 0 doubles as "empty slot" the same way pipeHandle -1 already + means "no handle" everywhere else in this file. + Two INDEPENDENT staleness checks guard against two INDEPENDENT ways a + remembered (inodeNum, handle) pair can stop meaning what it used to: + - sfsGeneration() (sfs.h) catches "the whole filesystem underneath was + wiped since" -- every sfsTest.c test starts with its own sfsFormat() + call, which reuses inode numbers for completely unrelated entries + dozens of times a boot; without this check, a binding left over from + a wiped filesystem could silently hand a brand-new, unrelated + inode's opener the OLD fifo's (possibly still-leaked) handle. + - pipeIncarnation(handle) (pipe.h) catches "pipe.c's own table recycled + this NUMBER for an unrelated pipe since" -- an entirely ordinary + event that needs no filesystem format at all: the moment BOTH ends + of a completed fifo round-trip close, pipeReleaseIfUnused frees the + slot back to the general pool, and the very next unrelated + vfsPipe()/vfsMkfifo() anywhere in the kernel is free to claim that + exact number for something else entirely. pipeValid(handle) alone + cannot tell "still my pipe, nobody has closed it" apart from + "already reused by a stranger" -- only the incarnation counter can. + Either mismatch is treated exactly like "never bound": the stale slot is + evicted -- WITHOUT touching pipe.c, since whatever handle number it + remembers may already mean something else by now -- and a fresh handle + is created as if this were the first open ever. */ +#define maxSfsFifoBindings 8 + +typedef struct SfsFifoBinding { + int32 inodeNum; + int32 pipeHandle; + uint32 incarnation; + uint64 generation; +} SfsFifoBinding; + +static SfsFifoBinding sfsFifoBindings[maxSfsFifoBindings]; + +/* Finds inodeNum's live binding, discarding it first if it is stale by + either check above. Returns nullptr if there is no valid binding (never + bound, or just evicted as stale). */ +static SfsFifoBinding* sfsFifoFind(int32 inodeNum) +{ + uint64 gen = sfsGeneration(); + for (uint32 i = 0; i < maxSfsFifoBindings; i++) { + SfsFifoBinding* b = &sfsFifoBindings[i]; + if (b->inodeNum != inodeNum) + continue; + if (b->generation != gen || pipeIncarnation(b->pipeHandle) != b->incarnation) { + b->inodeNum = 0; + return nullptr; + } + return b; + } + return nullptr; +} + +/* Returns inodeNum's live pipe handle, creating a fresh pipeCreateDetached + one (and a new table slot) the first time this inode is opened since the + last time nobody had it open -- exactly the "nobody attached yet, keep + retrying rather than report broken/EOF" contract pipeCreateDetached + itself already documents. -1 if pipe.c's own table is exhausted, or this + inode has no existing binding and the (much smaller) binding table above + is itself full. */ +static int32 sfsFifoHandleFor(int32 inodeNum) +{ + SfsFifoBinding* existing = sfsFifoFind(inodeNum); + if (existing) + return existing->pipeHandle; + + int handle = pipeCreateDetached(); + if (handle < 0) + return -1; + + for (uint32 i = 0; i < maxSfsFifoBindings; i++) { + if (sfsFifoBindings[i].inodeNum == 0) { + sfsFifoBindings[i].inodeNum = inodeNum; + sfsFifoBindings[i].pipeHandle = handle; + sfsFifoBindings[i].incarnation = pipeIncarnation(handle); + sfsFifoBindings[i].generation = sfsGeneration(); + return handle; + } + } + pipeDestroyIfUnattached(handle); + return -1; +} + +/* Called when inodeNum's own directory entry is actually gone for good + (sfsRemove reached the last link, see vfsUnlink) -- evicts the table slot + FIRST, then attempts pipeDestroyIfUnattached on the handle it held: + unlinking a fifo that is currently open must leave the still-open ends + alone (they keep working off their own copy of the handle number, + matching the tmpfs fifo's own vfsUnlink comment below), so the destroy is + a no-op unless nobody ever attached to it. Evicting before destroying + (rather than after) closes the narrow window where a concurrent opener + of the SAME inode number could otherwise see the stale slot as still + valid and attach to a handle number pipe.c is about to hand out to + something completely unrelated. */ +static void sfsFifoForget(int32 inodeNum) +{ + for (uint32 i = 0; i < maxSfsFifoBindings; i++) { + if (sfsFifoBindings[i].inodeNum == inodeNum) { + int handle = sfsFifoBindings[i].pipeHandle; + sfsFifoBindings[i].inodeNum = 0; + pipeDestroyIfUnattached(handle); + return; + } + } +} + +static int64 sfsFdRead(FileDescriptor* d, void* buffer, uint64 length) +{ + int64 got = sfsReadAt(d->pipeHandle, d->offset, buffer, length); + if (got > 0) + d->offset += (uint64)got; + return got; +} + +static int64 sfsFdWrite(FileDescriptor* d, const void* buffer, uint64 length) +{ + int64 written = sfsWriteAt(d->pipeHandle, d->offset, buffer, length); + if (written > 0) + d->offset += (uint64)written; + return written; +} + +int vfsMkfifo(const char* path) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + /* Persistent named pipe (sfs.h's sfsTypeFifo): only the directory entry + and its type are created here -- the live pipe.h handle two openers + actually rendezvous over is created lazily by vfsOpen the first time + anyone opens it (sfsFifoHandleFor above), the same "create on first + real use" split every other SFS-backed thing keeps between "what is + on disk" (sfs.c, no pipe.h dependency at all) and "what is live in + this boot's memory" (here). */ + if (isSfsPath(path, &rel)) + return sfsCreateFifo(rel) >= 0 ? 0 : -1; + if (strcmp(path, blockDevPath) == 0) + return -1; + if (lookupNode(path)) + return -1; + + char leaf[fsNameMax]; + VfsNode* parent = splitParent(path, leaf, sizeof(leaf)); + if (!parent || parent->type != fsTypeDir || !parent->writable + || !vfsPermitted(parent->mode, parent->uid, parent->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + + int handle = pipeCreateDetached(); + if (handle < 0) + return -1; + + if (!makeChildFifo(parent, leaf, strlen(leaf), handle)) { + pipeDestroyIfUnattached(handle); + return -1; + } + return 0; +} + +/* Shared core of vfsOpen/vfsOpenInto below: resolves `path`+`flags` and, on + success, fills EVERY field of `d` (whichever FileDescriptor the caller + decided on -- a freshly allocated slot in the calling thread's own table + for vfsOpen, or an explicit slot in some other, not-yet-running thread's + table for vfsOpenInto) and returns 0. On failure, returns -1 having + written NOTHING to `d` at all -- every early return below is before the + first store into `d`, so a caller never has to clean up a half-written + descriptor. */ +static int resolveOpen(FileDescriptor* d, const char* path, uint32 flags) +{ + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0) { + if (!(flags & oCreate)) + return -1; + inodeNum = sfsCreateFile(rel); + if (inodeNum < 0) + return -1; + } else if (sfsTypeOf(inodeNum) == sfsTypeFifo) { + /* One-directional only, exactly like the tmpfs fifo branch + below and vfsPipeAttach's wantWrite bool -- a real O_RDWR + fifo fd has no equivalent here, since a single fd can only be + fdPipeRead or fdPipeWrite, never both. */ + uint32 mode = flags & (oWriteOnly | oReadWrite); + bool wantWrite; + if (mode == oWriteOnly) + wantWrite = true; + else if (mode == oReadOnly) + wantWrite = false; + else + return -1; + + int handle = sfsFifoHandleFor(inodeNum); + if (handle < 0) + return -1; + + d->node = nullptr; + d->offset = 0; + d->kind = wantWrite ? fdPipeWrite : fdPipeRead; + d->flags = fdFlagBlocking; + d->pipeHandle = handle; + + if (wantWrite) + pipeAddWriter(handle); + else + pipeAddReader(handle); + return 0; + } else { + /* Real EACCES-shaped refusal: opening an EXISTING file for + writing without the right write bit for the CALLING thread's + identity (vfsPermitted, owner-vs-other-vs-root) fails at + open() time, before any fd (and therefore any write()) ever + exists -- matches real Unix, where permission is checked + once, at open, never re-checked against an fd already held. + A freshly oCreate'd file above always starts with + fsModeDefaultFile owned by its creator (has the owner-write + bit for exactly that uid), so this can never reject the + create-a-new-file path. */ + if ((flags & (oWriteOnly | oReadWrite)) && sfsTypeOf(inodeNum) == sfsTypeFile + && !vfsPermitted(sfsModeOf(inodeNum), sfsUidOf(inodeNum), sfsGidOf(inodeNum), + currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + /* Real EACCES-shaped refusal, the read-side counterpart of the + write check immediately above -- previously MISSING entirely + (a genuine, previously-undiscovered gap this fala's own + grouptest.c live demo found: a caller matching neither owner + nor group could still read a file with no read bit for + either, since nothing here ever consulted fsModeOwnerRead/ + fsModeGroupRead/fsModeOtherRead for a plain file open, only + for a directory's own opendir()-shaped check below). Fires + for oReadOnly AND oReadWrite (real Unix's O_RDWR needs read + permission too, not just write) -- `!(flags & oWriteOnly)` is + the same 2-bit-access-mode test the fifo branch above already + uses to distinguish all three. */ + if (!(flags & oWriteOnly) && sfsTypeOf(inodeNum) == sfsTypeFile + && !vfsPermitted(sfsModeOf(inodeNum), sfsUidOf(inodeNum), sfsGidOf(inodeNum), + currentThreadUid(), currentThreadGid(), fsModeOwnerRead)) + return -1; + /* Real opendir()-shaped refusal: listing a directory's own + entries (vfsGetdents below, on whatever fd this open() + eventually returns) needs read permission on the directory + ITSELF -- a separate bit from the exec/search permission + lookupNode/lookupFrom already require on every ANCESTOR + directory just to resolve this path down to here. */ + if (sfsTypeOf(inodeNum) == sfsTypeDir + && !vfsPermitted(sfsModeOf(inodeNum), sfsUidOf(inodeNum), sfsGidOf(inodeNum), + currentThreadUid(), currentThreadGid(), fsModeOwnerRead)) + return -1; + if ((flags & oTrunc) && sfsTypeOf(inodeNum) == sfsTypeFile) + sfsTruncate(inodeNum, 0); + } + + d->kind = fdSfs; + d->node = nullptr; + d->flags = flags; + d->pipeHandle = inodeNum; + d->offset = (flags & oAppend) ? sfsSizeOf(inodeNum) : 0; + return 0; + } + + if (strcmp(path, blockDevPath) == 0) { + d->kind = fdBlockDev; + d->node = nullptr; + d->flags = flags; + d->offset = 0; + return 0; + } + VfsNode* node = lookupNode(path); if (!node) { if (!(flags & oCreate)) return -1; char leaf[fsNameMax]; VfsNode* parent = splitParent(path, leaf, sizeof(leaf)); - if (!parent || parent->type != fsTypeDir || !parent->writable) + if (!parent || parent->type != fsTypeDir || !parent->writable + || !vfsPermitted(parent->mode, parent->uid, parent->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) return -1; - node = makeChildFile(parent, leaf, strlen(leaf), nullptr, 0, true); + node = makeChildFile(parent, leaf, strlen(leaf), nullptr, 0, true, fsModeDefaultFile); if (!node) return -1; - } else if ((flags & oTrunc) && node->type == fsTypeFile && node->writable) { - node->size = 0; + } else if (node->type == fsTypeFifo) { + /* One-directional only, exactly like vfsPipeAttach's wantWrite bool + -- a real O_RDWR fifo fd (Linux allows it; POSIX leaves it + undefined) has no equivalent here, since a single fd can only be + fdPipeRead or fdPipeWrite, never both. */ + uint32 mode = flags & (oWriteOnly | oReadWrite); + bool wantWrite; + if (mode == oWriteOnly) + wantWrite = true; + else if (mode == oReadOnly) + wantWrite = false; + else + return -1; + + d->node = nullptr; + d->offset = 0; + d->kind = wantWrite ? fdPipeWrite : fdPipeRead; + d->flags = fdFlagBlocking; + d->pipeHandle = node->pipeHandle; + + if (wantWrite) + pipeAddWriter(node->pipeHandle); + else + pipeAddReader(node->pipeHandle); + return 0; + } else { + /* Same real EACCES-shaped refusal as the SFS branch above, applied + to tmpfs -- see that branch's comment for the full reasoning. */ + if ((flags & (oWriteOnly | oReadWrite)) && node->type == fsTypeFile + && !vfsPermitted(node->mode, node->uid, node->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + /* Same real file-read check as the SFS branch above (its own + comment has the full "previously missing entirely" reasoning). */ + if (!(flags & oWriteOnly) && node->type == fsTypeFile + && !vfsPermitted(node->mode, node->uid, node->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerRead)) + return -1; + /* Same opendir()-shaped read check as the SFS branch above. */ + if (node->type == fsTypeDir + && !vfsPermitted(node->mode, node->uid, node->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerRead)) + return -1; + if ((flags & oTrunc) && node->type == fsTypeFile && node->writable) + node->size = 0; } - int fd = allocFd(); - if (fd < 0) - return -1; - - FileDescriptor* d = ¤tThread()->fds[fd]; d->kind = fdFile; d->node = node; d->flags = flags; d->offset = (flags & oAppend) ? node->size : 0; + return 0; +} + +int vfsOpen(const char* path, uint32 flags) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + + int fd = allocFd(); + if (fd < 0) + return -1; + if (resolveOpen(¤tThread()->fds[fd], resolvedPath, flags) < 0) + return -1; return fd; } +int vfsOpenInto(FileDescriptor* targetFds, int fd, const char* path, uint32 flags) +{ + /* Resolved against the CALLING thread's own cwd (the spawning parent, + e.g. jobctl.c's redirect setup running before its not-yet-run child + ever executes) -- exactly the cwd `targetFds`'s own owner already + inherited a copy of at spawn time (initialCwd, sched.c), since + nothing chdirs in between a spawn and its immediate redirect setup. */ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + return resolveOpen(&targetFds[fd], resolvedPath, flags); +} + int vfsClose(int fd) { FileDescriptor* d = fdAt(fd); @@ -322,13 +889,189 @@ int vfsClose(int fd) return -1; if (d->kind == fdConsole) return 0; + if (d->kind == fdPipeRead) + pipeCloseRead(d->pipeHandle); + else if (d->kind == fdPipeWrite) + pipeCloseWrite(d->pipeHandle); + else if (d->kind == fdPidfd) + pidfdClose(d->pipeHandle); d->kind = fdFree; d->node = nullptr; d->offset = 0; d->flags = 0; + d->pipeHandle = -1; return 0; } +int vfsPipe(int* readFd, int* writeFd) +{ + int rfd = allocFd(); + if (rfd < 0) + return -1; + currentThread()->fds[rfd].kind = fdPipeRead; + + int wfd = allocFd(); + if (wfd < 0) { + currentThread()->fds[rfd].kind = fdFree; + return -1; + } + currentThread()->fds[wfd].kind = fdPipeWrite; + + int handle = pipeCreate(); + if (handle < 0) { + currentThread()->fds[rfd].kind = fdFree; + currentThread()->fds[wfd].kind = fdFree; + return -1; + } + + currentThread()->fds[rfd].pipeHandle = handle; + currentThread()->fds[wfd].pipeHandle = handle; + *readFd = rfd; + *writeFd = wfd; + return 0; +} + +int vfsPipeCreateHandle(void) +{ + return pipeCreateDetached(); +} + +int vfsPipeAttach(int handle, bool wantWrite) +{ + if (!pipeValid(handle)) + return -1; + + int fd = allocFd(); + if (fd < 0) + return -1; + + FileDescriptor* d = ¤tThread()->fds[fd]; + d->node = nullptr; + d->offset = 0; + d->kind = wantWrite ? fdPipeWrite : fdPipeRead; + d->flags = fdFlagBlocking; + d->pipeHandle = handle; + + if (wantWrite) + pipeAddWriter(handle); + else + pipeAddReader(handle); + return fd; +} + +void vfsPipeAttachInto(FileDescriptor* targetFds, int fd, int handle, bool wantWrite) +{ + if (!pipeValid(handle)) + return; + + FileDescriptor* d = &targetFds[fd]; + d->node = nullptr; + d->offset = 0; + d->kind = wantWrite ? fdPipeWrite : fdPipeRead; + d->flags = fdFlagBlocking; + d->pipeHandle = handle; + + if (wantWrite) + pipeAddWriter(handle); + else + pipeAddReader(handle); +} + +int vfsPidfdOpen(uint32 targetId) +{ + int handle = pidfdOpen(targetId); + if (handle < 0) + return -1; + + int fd = allocFd(); + if (fd < 0) { + pidfdClose(handle); + return -1; + } + + FileDescriptor* d = ¤tThread()->fds[fd]; + d->node = nullptr; + d->offset = 0; + d->kind = fdPidfd; + d->flags = 0; + d->pipeHandle = handle; + return fd; +} + +uint32 vfsPidfdTarget(int fd) +{ + FileDescriptor* d = fdAt(fd); + if (!d || d->kind != fdPidfd) + return threadInvalidId; + return pidfdTarget(d->pipeHandle); +} + +const void* vfsBlockingChannel(int fd, bool forWrite) +{ + FileDescriptor* d = fdAt(fd); + if (!d || !(d->flags & fdFlagBlocking)) + return nullptr; + + if (d->kind == fdPipeRead || d->kind == fdPipeWrite) { + if (forWrite) { + if (d->kind != fdPipeWrite || pipeWriteBroken(d->pipeHandle)) + return nullptr; + } else if (d->kind != fdPipeRead) { + return nullptr; + } + return pipeChannel(d->pipeHandle); + } + + if (d->kind == fdConsole) { + /* Console writes (kputchar) are never buffered and never block. */ + if (forWrite) + return nullptr; + return keyboardReaderChannel(d->pipeHandle); + } + + return nullptr; +} + +bool vfsPollReady(int fd, int16 events, int16* outRevents) +{ + int16 revents = 0; + FileDescriptor* d = fdAt(fd); + if (!d) { + *outRevents = pollNval; + return true; + } + + switch (d->kind) { + case fdPipeRead: + if ((events & pollIn) && pipeHasData(d->pipeHandle)) + revents |= pollIn; + break; + case fdPipeWrite: + if (pipeWriteBroken(d->pipeHandle)) + revents |= pollErr; + else if ((events & pollOut) && pipeHasSpace(d->pipeHandle)) + revents |= pollOut; + break; + case fdConsole: + if ((events & pollIn) && keyboardReaderHasData(d->pipeHandle)) + revents |= pollIn; + /* Console writes (kputchar) are never buffered and never block -- + see vfsBlockingChannel above. */ + if (events & pollOut) + revents |= pollOut; + break; + default: + /* fdFile, fdBlockDev, fdSfs: every read/write on these runs to + completion synchronously (see vfsRead/vfsWrite below), exactly + like a real local filesystem always reports poll-ready. */ + revents |= (int16)(events & (pollIn | pollOut)); + break; + } + + *outRevents = revents; + return revents != 0; +} + int64 vfsWrite(int fd, const void* buffer, uint64 length) { FileDescriptor* d = fdAt(fd); @@ -342,6 +1085,15 @@ int64 vfsWrite(int fd, const void* buffer, uint64 length) return (int64)length; } + if (d->kind == fdPipeWrite) + return pipeWrite(d->pipeHandle, buffer, length); + if (d->kind == fdPipeRead) + return -1; + if (d->kind == fdBlockDev) + return blockDevWrite(d, buffer, length); + if (d->kind == fdSfs) + return sfsFdWrite(d, buffer, length); + VfsNode* node = d->node; if (node->type != fsTypeFile || !node->writable) return -1; @@ -360,16 +1112,27 @@ int64 vfsRead(int fd, void* buffer, uint64 length) if (!d) return -1; + if (d->kind == fdPipeRead) + return pipeRead(d->pipeHandle, buffer, length); + if (d->kind == fdPipeWrite) + return -1; + if (d->kind == fdBlockDev) + return blockDevRead(d, buffer, length); + if (d->kind == fdSfs) + return sfsFdRead(d, buffer, length); + if (d->kind == fdConsole) { - char* text = (char*)buffer; - uint64 count = 0; - while (count < length) { - char c = keyboardReadChar(); - text[count++] = c; - if (c == '\n') - break; - } - return (int64)count; + /* Non-blocking, like every other real fd kind above: -1 (queue + currently empty) is the "would block" signal syscall.c's sysRead + retries via vfsBlockingChannel's channel + the restart-syscall + trick, instead of this function looping/halting in place the way + the old single-global-buffer keyboardReadChar() used to. Returns + whatever is queued right now (possibly fewer than length bytes, + same partial-read contract pipeRead already has) rather than + insisting on a full line -- nothing in this kernel relied on the + old line-buffering behavior (grep shows no caller ever read fd 0 + before this wave). */ + return keyboardReaderRead(d->pipeHandle, buffer, length); } VfsNode* node = d->node; @@ -388,7 +1151,7 @@ int64 vfsRead(int fd, void* buffer, uint64 length) int64 vfsLseek(int fd, int64 offset, int whence) { FileDescriptor* d = fdAt(fd); - if (!d || d->kind != fdFile) + if (!d || (d->kind != fdFile && d->kind != fdBlockDev && d->kind != fdSfs)) return -1; int64 base; @@ -396,9 +1159,14 @@ int64 vfsLseek(int fd, int64 offset, int whence) base = 0; else if (whence == seekCur) base = (int64)d->offset; - else if (whence == seekEnd) - base = (int64)d->node->size; - else + else if (whence == seekEnd) { + if (d->kind == fdBlockDev) + base = (int64)blockDevTotalBytes(); + else if (d->kind == fdSfs) + base = (int64)sfsSizeOf(d->pipeHandle); + else + base = (int64)d->node->size; + } else return -1; int64 target = base + offset; @@ -410,20 +1178,75 @@ int64 vfsLseek(int fd, int64 offset, int whence) int vfsStat(const char* path, FbStat* out) { + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0) + return -1; + out->size = sfsSizeOf(inodeNum); + out->type = sfsTypeToFsType(sfsTypeOf(inodeNum)); + out->mode = sfsModeOf(inodeNum); + out->uid = sfsUidOf(inodeNum); + out->gid = sfsGidOf(inodeNum); + out->mtime = sfsMtimeOf(inodeNum); + out->ctime = sfsCtimeOf(inodeNum); + return 0; + } + + if (strcmp(path, blockDevPath) == 0) { + out->size = blockDevTotalBytes(); + out->type = fsTypeFile; + out->mode = 0666; + out->uid = 0; + out->gid = 0; + out->mtime = 0; + out->ctime = 0; + return 0; + } + VfsNode* node = lookupNode(path); if (!node) return -1; out->size = node->size; out->type = node->type; - out->mode = node->writable ? 0666 : 0444; + out->mode = node->mode; + out->uid = node->uid; + out->gid = node->gid; + out->mtime = 0; + out->ctime = 0; return 0; } int64 vfsGetdents(int fd, void* buffer, uint64 length) { FileDescriptor* d = fdAt(fd); - if (!d || d->kind != fdFile) + if (!d || (d->kind != fdFile && d->kind != fdSfs)) return -1; + + if (d->kind == fdSfs) { + if (sfsTypeOf(d->pipeHandle) != sfsTypeDir) + return -1; + FbDirent* out = (FbDirent*)buffer; + uint64 written = 0; + char name[fsNameMax]; + uint32 type; + while (written + sizeof(FbDirent) <= length && + sfsReaddirAt(d->pipeHandle, (uint32)d->offset, name, sizeof(name), &type)) { + uint64 nameLen = strlen(name); + out->type = sfsTypeToFsType(type); + out->nameLength = (unsigned int)nameLen; + memcpy(out->name, name, nameLen + 1); + out++; + written += sizeof(FbDirent); + d->offset++; + } + return (int64)written; + } + VfsNode* node = d->node; if (node->type != fsTypeDir) return -1; @@ -453,34 +1276,323 @@ int64 vfsGetdents(int fd, void* buffer, uint64 length) int vfsMkdir(const char* path) { + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) + return sfsCreateDir(rel) >= 0 ? 0 : -1; + if (lookupNode(path)) return -1; char leaf[fsNameMax]; VfsNode* parent = splitParent(path, leaf, sizeof(leaf)); - if (!parent || parent->type != fsTypeDir || !parent->writable) + if (!parent || parent->type != fsTypeDir || !parent->writable + || !vfsPermitted(parent->mode, parent->uid, parent->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) return -1; - return makeChildDir(parent, leaf, strlen(leaf), true) ? 0 : -1; + return makeChildDir(parent, leaf, strlen(leaf), true, fsModeDefaultDir) ? 0 : -1; } int vfsUnlink(const char* path) { + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + /* Real unlink(2) semantics: EISDIR on a directory, even an empty + one -- rmdir(2) (vfsRmdir below) is the only sanctioned way to + remove one. sfsRemove itself has no opinion here (it is the raw + on-disk primitive, also used by vfsRmdir below), so the type + check has to happen at this dispatch layer, matching the tmpfs + branch below which has always refused a directory this way. */ + if (inodeNum < 0 || sfsTypeOf(inodeNum) == sfsTypeDir) + return -1; + /* Only the LAST directory entry pointing at a persistent fifo's + inode should ever tear down its live pipe.h binding (see + sfsFifoForget above) -- a hardlinked fifo (sfsLink does not + special-case sfsTypeFifo, unlike sfsTypeDir) losing one of + several names must leave any live session alone, exactly like + sfsRemove itself only actually frees the inode once linkCount + drops to 0. Checked and captured BEFORE sfsRemove, since a + successful removal may itself free the inode (readInode on it + afterwards would read whatever unrelated data now occupies that + slot, if anything). */ + bool lastLinkOfFifo = sfsTypeOf(inodeNum) == sfsTypeFifo && + sfsLinkCountOf(inodeNum) <= 1; + if (!sfsRemove(rel)) + return -1; + if (lastLinkOfFifo) + sfsFifoForget(inodeNum); + return 0; + } + VfsNode* node = lookupNode(path); - if (!node || node->type != fsTypeFile || !node->writable) + if (!node || !node->writable || (node->type != fsTypeFile && node->type != fsTypeFifo)) return -1; + /* Real unlink(2) semantics: removing a directory ENTRY needs write + permission on the PARENT directory, not on the entry itself -- a + real Unix caller can delete a read-only file it can write into the + containing directory of, matching real Unix's own "the file's own + mode is irrelevant to unlink" rule. */ VfsNode* parent = node->parent; + if (!vfsPermitted(parent->mode, parent->uid, parent->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + VfsNode** link = &parent->firstChild; while (*link && *link != node) link = &(*link)->nextSibling; if (*link) *link = node->nextSibling; + /* Any fd already opened on this fifo (fdPipeRead/fdPipeWrite) keeps + working off its own copy of node->pipeHandle -- it never dereferences + this node again, so freeing it here does not disturb a live reader or + writer, matching real unlink-while-open semantics. Only reclaim the + underlying pipe slot if nobody ever actually opened it; otherwise the + still-open ends must keep it alive until their own vfsClose. */ + if (node->type == fsTypeFifo) + pipeDestroyIfUnattached(node->pipeHandle); + if (node->rwData) kfree(node->rwData); kfree(node); return 0; } +/* Real rmdir(2): the counterpart vfsUnlink above deliberately refuses. Only + ever removes a directory, and only an EMPTY one (ENOTDIR/ENOTEMPTY in + real errno terms, both collapsed to -1 here like every other vfs.c entry + point). root itself can never be removed -- root is created non-writable + (nodeAlloc("/", ...) in vfsInit), so the same `!node->writable` check + every other mutating entry point already relies on covers it for free, + no separate identity check needed. */ +int vfsRmdir(const char* path) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0 || sfsTypeOf(inodeNum) != sfsTypeDir) + return -1; + /* sfsRemove's own `inode.type == sfsTypeDir && inode.size > 0` + check (sfs.c) still refuses a non-empty directory -- kept as + defense in depth rather than duplicated here. */ + return sfsRemove(rel) ? 0 : -1; + } + + VfsNode* node = lookupNode(path); + if (!node || !node->writable || node->type != fsTypeDir || node->firstChild) + return -1; + + /* Same parent-directory write check as vfsUnlink above -- removing a + directory ENTRY needs write permission on the PARENT, not the + directory being removed itself. */ + VfsNode* parent = node->parent; + if (!vfsPermitted(parent->mode, parent->uid, parent->gid, currentThreadUid(), currentThreadGid(), fsModeOwnerWrite)) + return -1; + + VfsNode** link = &parent->firstChild; + while (*link && *link != node) + link = &(*link)->nextSibling; + if (*link) + *link = node->nextSibling; + + kfree(node); + return 0; +} + +bool vfsOwnerOrRoot(uint32 ownerUid, uint32 callerUid) +{ + return callerUid == 0 || callerUid == ownerUid; +} + +int vfsChmod(const char* path, uint32 mode) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0) + return -1; + if (!vfsOwnerOrRoot(sfsUidOf(inodeNum), currentThreadUid())) + return -1; + return sfsChmod(inodeNum, mode) ? 0 : -1; + } + + VfsNode* node = lookupNode(path); + if (!node || !node->writable) + return -1; + if (!vfsOwnerOrRoot(node->uid, currentThreadUid())) + return -1; + node->mode = mode; + return 0; +} + +int vfsChown(const char* path, uint32 newUid) +{ + if (currentThreadUid() != 0) + return -1; + + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0) + return -1; + return sfsChown(inodeNum, newUid) ? 0 : -1; + } + + VfsNode* node = lookupNode(path); + if (!node || !node->writable) + return -1; + node->uid = newUid; + return 0; +} + +int vfsChgrp(const char* path, uint32 newGid) +{ + uint32 callerUid = currentThreadUid(); + bool isRoot = callerUid == 0; + /* Non-root: only the CALLER's own current gid may ever be assigned + (this kernel's one-gid-per-thread "membership"), and only by the + file's own owner -- both conditions checked up front so neither + branch below needs to repeat them. */ + if (!isRoot && newGid != currentThreadGid()) + return -1; + + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (isSfsPath(path, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0) + return -1; + if (!isRoot && sfsUidOf(inodeNum) != callerUid) + return -1; + return sfsChgrp(inodeNum, newGid) ? 0 : -1; + } + + VfsNode* node = lookupNode(path); + if (!node || !node->writable) + return -1; + if (!isRoot && node->uid != callerUid) + return -1; + node->gid = newGid; + return 0; +} + +int vfsSymlink(const char* path, const char* target) +{ + /* Only `path` (where the symlink itself is created) is resolved against + the calling thread's cwd -- `target` (the string stored INSIDE it) is + kept exactly as given, never resolved, matching real symlink(2) + (a relative target is only ever interpreted later, at traversal + time, relative to the symlink's own directory -- not the creating + process's cwd at creation time). */ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + + const char* rel; + if (!isSfsPath(resolvedPath, &rel)) + return -1; + return sfsCreateSymlink(rel, target) >= 0 ? 0 : -1; +} + +int64 vfsReadlink(const char* path, char* buffer, uint64 cap) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + path = resolvedPath; + + const char* rel; + if (!isSfsPath(path, &rel)) + return -1; + + int32 inodeNum = sfsLookupNoFollow(rel); + if (inodeNum < 0 || sfsTypeOf(inodeNum) != sfsTypeSymlink) + return -1; + if (!sfsReadlink(inodeNum, buffer, cap)) + return -1; + return (int64)strlen(buffer); +} + +int vfsLink(const char* existingPath, const char* newPath) +{ + char resolvedExisting[fsPathMax]; + char resolvedNew[fsPathMax]; + vfsResolvePath(existingPath, resolvedExisting, sizeof(resolvedExisting)); + vfsResolvePath(newPath, resolvedNew, sizeof(resolvedNew)); + existingPath = resolvedExisting; + newPath = resolvedNew; + + const char* existingRel; + const char* newRel; + /* Cross-filesystem hardlinks (one path under /mnt, the other not) are + not supported -- same scope limit as a real link(2) across different + mounted filesystems (EXDEV), just expressed as a flat rejection since + this kernel has exactly one linkable filesystem. */ + if (!isSfsPath(existingPath, &existingRel) || !isSfsPath(newPath, &newRel)) + return -1; + return sfsLink(existingRel, newRel) >= 0 ? 0 : -1; +} + +int vfsChdir(const char* path) +{ + char resolvedPath[fsPathMax]; + vfsResolvePath(path, resolvedPath, sizeof(resolvedPath)); + + const char* rel; + if (isSfsPath(resolvedPath, &rel)) { + int32 inodeNum = sfsLookup(rel); + if (inodeNum < 0 || sfsTypeOf(inodeNum) != sfsTypeDir) + return -1; + /* No parent-walk primitive exists for sfs.h inodes (unlike vfsPath + below for the VfsNode tree), so the /mnt branch stores exactly + what vfsResolvePath produced -- the one honestly-scoped gap + vfsChdir's own doc comment (vfs.h) calls out. */ + return currentThreadSetCwd(resolvedPath) ? 0 : -1; + } + + VfsNode* node = lookupNode(resolvedPath); + if (!node || node->type != fsTypeDir) + return -1; + + /* Canonicalizes away any "."/".." components the caller typed by + walking the resolved node's REAL parent chain (vfsPath), rather than + storing vfsResolvePath's own plain string join verbatim -- shell.c's + old changeDir already did exactly this before this subsystem + existed, so this keeps that behavior for the VfsNode-tree case. */ + char canonical[fsPathMax]; + vfsPath(node, canonical, sizeof(canonical)); + return currentThreadSetCwd(canonical) ? 0 : -1; +} + +int64 vfsGetcwd(char* out, uint64 cap) +{ + const char* cwd = currentThreadCwd(); + uint64 len = strlen(cwd); + if (len >= cap) + return -1; + memcpy(out, cwd, len + 1); + return (int64)len; +} + VfsNode* vfsLookup(const char* path) { return lookupNode(path); @@ -511,6 +1623,56 @@ uint64 vfsSize(VfsNode* node) return node->size; } +uint32 vfsMode(VfsNode* node) +{ + return node->mode; +} + +uint32 vfsUid(VfsNode* node) +{ + return node->uid; +} + +uint32 vfsGid(VfsNode* node) +{ + return node->gid; +} + +/* Real Unix owner/other permission algorithm, the one real choke-point + piece of logic resolveOpen's two branches (tmpfs, SFS) and syscall.c's + loadImageByPath/jobctl.c's jobctlSpawn all now share instead of each + re-deriving it -- previously all four sites just tested a single bit + unconditionally (`mode & fsModeOwnerWrite`), honest only because there + was no uid model yet to make owner-vs-other meaningful. Deliberately a + pure function of its four arguments, not of "whoever is currently + running" -- every real call site passes currentThreadUid() in explicitly + for `callerUid`, so this stays testable against arbitrary uid/mode/owner + combinations without ever touching a live thread's own credential (see + coreTest.c's own KTEST coverage). `wantOwnerBit` is always one of + fs.h's fsModeOwner* constants; the matching "other" bit is derived by + shifting it right 6 (fsModeOther*'s own doc comment explains why that is + exact, not approximate) rather than requiring a second argument. + + Deliberately NO uid-0-bypasses-everything superuser rule, unlike real + Unix's root: every boot-time process starts at uid 0 (sched.h's + Thread.uid doc comment), so a blanket bypass would have silently undone + the entire pre-existing "chmod denies write even to the file's own + creator" contract every current permission test (this file's own + resolveOpen tests, permtest.c's live demo) already relies on -- caught + by exactly those tests going red the first time this function existed + with a bypass in it. uid 0 is simply this kernel's default starting + identity, gated by the same owner/other bits as any other uid; its only + real asymmetry is currentThreadSetUid's own one-way privilege-drop rule + (sched.h), not a free pass here. */ +bool vfsPermitted(uint32 mode, uint32 ownerUid, uint32 ownerGid, uint32 callerUid, uint32 callerGid, uint32 wantOwnerBit) +{ + if (callerUid == ownerUid) + return (mode & wantOwnerBit) != 0; + if (callerGid == ownerGid) + return (mode & (wantOwnerBit >> 3)) != 0; + return (mode & (wantOwnerBit >> 6)) != 0; +} + const void* vfsFileData(VfsNode* node) { if (!node) diff --git a/src/vfsFifoTest.c b/src/vfsFifoTest.c new file mode 100644 index 0000000..e0fa612 --- /dev/null +++ b/src/vfsFifoTest.c @@ -0,0 +1,405 @@ +#include "ktest.h" +#include "vfs.h" +#include "pipe.h" +#include "fs.h" +#include "string.h" +#include "klib.h" +#include "sfs.h" + +/* + * Coverage for named pipes (vfsMkfifo, vfs.h/vfs.c): a real filesystem entry + * of type fsTypeFifo that lets two processes rendezvous over a blocking + * pipe just by agreeing on a path, with no common ancestor and no handle + * number to pass through argv the way vfsPipeCreateHandle/vfsPipeAttach + * (pipeShareTest.c) require. The genuine cross-process park/wake handshake + * over such a fifo only runs for real at boot (namedreader.c/namedwriter.c, + * two independently spawned kmain.c programs, needles "namedreader: + * opened, blocking on read"/"namedreader: payload-ok" in tools/ci-test.sh) + * -- what is deterministic and scheduler-free (open/read/write never + * actually parking a thread) lives here, matching the division of labor + * pipeShareTest.c already established for the handle-based flavor. + */ + +KTEST(vfsMkfifoCreatesFifoNodeVisibleViaStat) +{ + KT_EQ(t, vfsMkfifo("/tmp/fifoBasic"), 0); + + FbStat st; + KT_EQ(t, vfsStat("/tmp/fifoBasic", &st), 0); + KT_EQ(t, st.type, fsTypeFifo); + + /* Same path again must fail, like mkdir/open(O_CREAT|O_EXCL) would -- + mkfifo never silently succeeds on an existing name. */ + KT_EQ(t, vfsMkfifo("/tmp/fifoBasic"), -1); + + KT_EQ(t, vfsUnlink("/tmp/fifoBasic"), 0); + KT_EQ(t, vfsStat("/tmp/fifoBasic", &st), -1); +} + +KTEST(vfsMkfifoRejectsReservedAndInvalidPaths) +{ + /* The one reserved, non-tree path mkfifo never supports -- a raw byte + device has no notion of a named directory entry at all, unlike "/mnt" + (see the sfsCreateFifo-backed tests below), which does. */ + KT_EQ(t, vfsMkfifo("/dev/disk0"), -1); + /* Parent directory does not exist at all. */ + KT_EQ(t, vfsMkfifo("/doesNotExist/fifo"), -1); + /* Root itself is never writable -- only vfsMountTmpfs-created + directories (e.g. "/tmp") are, same rule makeChildFile enforces for + oCreate. */ + KT_EQ(t, vfsMkfifo("/rootFifo"), -1); +} + +KTEST(vfsFifoOpenRejectsDualDirectionModes) +{ + KT_EQ(t, vfsMkfifo("/tmp/fifoModes"), 0); + + /* This kernel's pipe end is inherently one-directional (fdPipeRead XOR + fdPipeWrite) -- a fifo fd can never be both, unlike Linux's permissive + (POSIX-undefined) O_RDWR on a FIFO. */ + KT_EQ(t, vfsOpen("/tmp/fifoModes", oReadWrite), -1); + KT_EQ(t, vfsOpen("/tmp/fifoModes", oReadWrite | oWriteOnly), -1); + + KT_EQ(t, vfsUnlink("/tmp/fifoModes"), 0); +} + +KTEST(vfsFifoRendezvousByPathAloneNoSharedHandle) +{ + KT_EQ(t, vfsMkfifo("/tmp/fifoRendezvous"), 0); + + /* Two independent vfsOpen calls on the SAME PATH -- no handle number, no + argv, no common caller -- is exactly what namedreader.c/namedwriter.c + (two kmain.c boot programs with no parent-child relationship) rely + on. */ + int rfd = vfsOpen("/tmp/fifoRendezvous", oReadOnly); + KT_GE(t, rfd, 0); + + /* Nobody has opened the write end yet: a read must report "try again" + (-1), never spurious end-of-file, exactly like a freshly + pipeCreateDetached handle (pipeShareTest.c). */ + char probe[4]; + KT_EQ(t, vfsRead(rfd, probe, sizeof(probe)), -1); + KT_NOTNULL(t, vfsBlockingChannel(rfd, false)); + + int wfd = vfsOpen("/tmp/fifoRendezvous", oWriteOnly); + KT_GE(t, wfd, 0); + KT_NE(t, rfd, wfd); + + KT_EQ(t, vfsWrite(wfd, "hi", 2), 2); + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 2); + KT_TRUE(t, memcmp(buffer, "hi", 2) == 0); + + vfsClose(rfd); + vfsClose(wfd); + KT_EQ(t, vfsUnlink("/tmp/fifoRendezvous"), 0); +} + +KTEST(vfsFifoUnlinkWhileOpenLeavesLiveFdsWorking) +{ + KT_EQ(t, vfsMkfifo("/tmp/fifoUnlinkLive"), 0); + + int rfd = vfsOpen("/tmp/fifoUnlinkLive", oReadOnly); + int wfd = vfsOpen("/tmp/fifoUnlinkLive", oWriteOnly); + KT_GE(t, rfd, 0); + KT_GE(t, wfd, 0); + + /* Real unlink-while-open semantics: removing the name must not disturb + fds opened before the unlink -- they keep working off their own copy + of the raw pipe handle, never the freed vfs node. */ + KT_EQ(t, vfsUnlink("/tmp/fifoUnlinkLive"), 0); + FbStat st; + KT_EQ(t, vfsStat("/tmp/fifoUnlinkLive", &st), -1); + + KT_EQ(t, vfsWrite(wfd, "ok", 2), 2); + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 2); + KT_TRUE(t, memcmp(buffer, "ok", 2) == 0); + + vfsClose(rfd); + vfsClose(wfd); +} + +KTEST(vfsUnlinkOnNeverOpenedFifoReclaimsPipeSlot) +{ + /* Drives the pipe table (pipe.h's fixed-size `pipes[maxPipes]`, shared + with every other pipe user in the kernel) to exhaustion using fifos + that are never opened by anyone -- the one case pipeCloseRead/ + pipeCloseWrite's own cleanup can never reach, since neither is ever + called for an end nobody attached. Does not assume the table starts + fully empty (kmain.c's own vfsMkfifo("/tmp/rendezvous") already holds + one slot for the rest of boot by the time ktests run) -- just proves + whatever headroom exists gets used up, and reclaimed, correctly. */ + char paths[maxPipes][24]; + int madeCount = 0; + for (int i = 0; i < (int)maxPipes; i++) { + snprintf(paths[i], sizeof(paths[i]), "/tmp/fifoLeak%d", i); + if (vfsMkfifo(paths[i]) != 0) + break; + madeCount++; + } + KT_GT(t, madeCount, 0); + + /* Table is now genuinely full: one more must fail. */ + KT_EQ(t, vfsMkfifo("/tmp/fifoOverflow"), -1); + + /* Unlinking one never-opened fifo must reclaim its slot immediately + (pipeDestroyIfUnattached), not leave it dangling until some later, + unrelated event. */ + KT_EQ(t, vfsUnlink(paths[0]), 0); + KT_EQ(t, vfsMkfifo("/tmp/fifoOverflow"), 0); + + /* Leave the shared pipe table exactly as free as it was found, so later + boot-time pipe users (the pipeProducer/pipeConsumer demo, + fifomaster's handle, killtest's shared pipe, namedreader/ + namedwriter's own fifo) are never starved by this test having run. */ + vfsUnlink("/tmp/fifoOverflow"); + for (int i = 1; i < madeCount; i++) + vfsUnlink(paths[i]); +} + +/* + * Coverage for PERSISTENT named pipes: vfsMkfifo under "/mnt" now creates a + * real, on-disk sfs.h entry (sfsTypeFifo) instead of being rejected, so the + * special file itself -- unlike everything above, which lives only in this + * process's kmalloc heap -- survives sfsUnmount()+sfsMount(). The live + * pipe.h byte stream two openers exchange data through is still purely + * in-memory (vfs.c's sfsFifoHandleFor), created lazily on first open, since + * buffered bytes never persist across a real mkfifo(2)'s reboot either -- + * only the rendezvous point's name and type do. Every test below starts + * with its own sfsFormat() (same discipline sfsTest.c uses) so results + * never depend on whatever an earlier test (here or in sfsTest.c, same + * boot, same disk) left behind. + */ + +KTEST(sfsBackedMkfifoCreatesPersistentFifoVisibleViaStat) +{ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoBasic"), 0); + + FbStat st; + KT_EQ(t, vfsStat("/mnt/fifoBasic", &st), 0); + KT_EQ(t, st.type, fsTypeFifo); + KT_EQU(t, st.size, 0u); + + /* Same path again must fail, exactly like the tmpfs case above. */ + KT_EQ(t, vfsMkfifo("/mnt/fifoBasic"), -1); + + KT_EQ(t, vfsUnlink("/mnt/fifoBasic"), 0); + KT_EQ(t, vfsStat("/mnt/fifoBasic", &st), -1); +} + +KTEST(sfsBackedFifoRendezvousByPathAloneNoSharedHandle) +{ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoRendezvous"), 0); + + int rfd = vfsOpen("/mnt/fifoRendezvous", oReadOnly); + KT_GE(t, rfd, 0); + + /* Nobody has opened the write end yet: "try again" (-1), never spurious + end-of-file, exactly like the tmpfs fifo and a freshly + pipeCreateDetached handle both already guarantee. */ + char probe[4]; + KT_EQ(t, vfsRead(rfd, probe, sizeof(probe)), -1); + KT_NOTNULL(t, vfsBlockingChannel(rfd, false)); + + int wfd = vfsOpen("/mnt/fifoRendezvous", oWriteOnly); + KT_GE(t, wfd, 0); + KT_NE(t, rfd, wfd); + + KT_EQ(t, vfsWrite(wfd, "hi", 2), 2); + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 2); + KT_TRUE(t, memcmp(buffer, "hi", 2) == 0); + + vfsClose(rfd); + vfsClose(wfd); + KT_EQ(t, vfsUnlink("/mnt/fifoRendezvous"), 0); +} + +KTEST(sfsBackedFifoUnlinkWhileOpenLeavesLiveFdsWorking) +{ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoUnlinkLive"), 0); + + int rfd = vfsOpen("/mnt/fifoUnlinkLive", oReadOnly); + int wfd = vfsOpen("/mnt/fifoUnlinkLive", oWriteOnly); + KT_GE(t, rfd, 0); + KT_GE(t, wfd, 0); + + /* Real unlink-while-open semantics, same guarantee as the tmpfs fifo: + removing the directory entry must not disturb fds opened before the + unlink -- they keep working off their own copy of the raw pipe + handle number. */ + KT_EQ(t, vfsUnlink("/mnt/fifoUnlinkLive"), 0); + FbStat st; + KT_EQ(t, vfsStat("/mnt/fifoUnlinkLive", &st), -1); + + KT_EQ(t, vfsWrite(wfd, "ok", 2), 2); + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 2); + KT_TRUE(t, memcmp(buffer, "ok", 2) == 0); + + vfsClose(rfd); + vfsClose(wfd); +} + +KTEST(sfsBackedFifoEntryPersistsAcrossUnmountRemount) +{ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoPersist"), 0); + + /* The real point of this test (mirrors sfsTest.c's own + sfsMountReloadsExistingFilesystem-style coverage): drop EVERY + in-memory sfs.c structure (bitmaps, superblock) and reconstruct them + purely from what sfsFormat/sfsCreateFifo actually wrote to disk -- + proving the fifo is a genuine on-disk entry, not a kmalloc node that + would evaporate the instant this process's heap did (like the tmpfs + fifo above, which vfsMkfifo("/mnt/...") deliberately never was). */ + sfsUnmount(); + KT_TRUE(t, sfsMount()); + + FbStat st; + KT_EQ(t, vfsStat("/mnt/fifoPersist", &st), 0); + KT_EQ(t, st.type, fsTypeFifo); + + /* Still fully functional after the remount, not just present in a + directory listing: a fresh open/write/read round trip works exactly + as it would have before the remount. */ + int rfd = vfsOpen("/mnt/fifoPersist", oReadOnly); + int wfd = vfsOpen("/mnt/fifoPersist", oWriteOnly); + KT_GE(t, rfd, 0); + KT_GE(t, wfd, 0); + KT_EQ(t, vfsWrite(wfd, "yo", 2), 2); + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 2); + KT_TRUE(t, memcmp(buffer, "yo", 2) == 0); + + vfsClose(rfd); + vfsClose(wfd); + KT_EQ(t, vfsUnlink("/mnt/fifoPersist"), 0); +} + +KTEST(sfsBackedFifoReopenAfterCompletedRoundTripGetsFreshWorkingSession) +{ + /* Ordinary fifo usage, not a corner case: open both ends, exchange + data, close both, then reopen the SAME persistent path later for a + second, unrelated conversation -- exactly what a real named pipe is + for. The first round trip fully releases pipe.h's underlying slot + back to its general pool (pipeReleaseIfUnused, once both readers and + writers hit 0), so the second round's vfs.c-level binding + (sfsFifoHandleFor) MUST detect that its remembered handle number no + longer refers to the same pipe (pipeIncarnation, pipe.h) and mint a + genuinely fresh one -- otherwise this second write would silently + target a slot nobody has attached to (or, worse, one some unrelated + pipe elsewhere in the kernel grabbed in between), and would report + -1 forever instead of actually delivering the byte. */ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoRoundTrip"), 0); + + int rfd1 = vfsOpen("/mnt/fifoRoundTrip", oReadOnly); + int wfd1 = vfsOpen("/mnt/fifoRoundTrip", oWriteOnly); + KT_GE(t, rfd1, 0); + KT_GE(t, wfd1, 0); + KT_EQ(t, vfsWrite(wfd1, "one", 3), 3); + char first[4]; + memset(first, 0, sizeof(first)); + KT_EQ(t, vfsRead(rfd1, first, sizeof(first)), 3); + KT_TRUE(t, memcmp(first, "one", 3) == 0); + vfsClose(rfd1); + vfsClose(wfd1); + + /* Second, independent session on the same path. */ + int rfd2 = vfsOpen("/mnt/fifoRoundTrip", oReadOnly); + KT_GE(t, rfd2, 0); + + /* A genuinely fresh pipe reports "try again" here, exactly like a + first-ever open -- a stale, no-longer-attached handle would instead + report -1 forever for an entirely different reason (pipeAt finding + nothing "used" at that slot), which the write assertion below is + what actually tells apart from this one. */ + char probe[4]; + KT_EQ(t, vfsRead(rfd2, probe, sizeof(probe)), -1); + + int wfd2 = vfsOpen("/mnt/fifoRoundTrip", oWriteOnly); + KT_GE(t, wfd2, 0); + KT_EQ(t, vfsWrite(wfd2, "two", 3), 3); + char second[4]; + memset(second, 0, sizeof(second)); + KT_EQ(t, vfsRead(rfd2, second, sizeof(second)), 3); + KT_TRUE(t, memcmp(second, "two", 3) == 0); + + vfsClose(rfd2); + vfsClose(wfd2); + KT_EQ(t, vfsUnlink("/mnt/fifoRoundTrip"), 0); +} + +KTEST(sfsBackedFifoBindingSurvivesReformatOfAnUnrelatedInode) +{ + /* sfsTest.c's tests each start with their own sfsFormat(), reusing + inode numbers dozens of times a boot. A fifo binding left open + ACROSS such a reformat (contrived here, but exactly the shape + reclaimOpenFds/sched.c would leave behind for a thread that died + without closing its fd -- see sched.c) must never let the fresh + filesystem's completely unrelated reuse of the same inode number + inherit the old, orphaned session. */ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoGenA"), 0); + int32 firstInode = sfsLookup("/fifoGenA"); + KT_GE(t, firstInode, 0); + + /* Left deliberately open across the reformat below -- an orphaned + session, on purpose, is exactly what must NOT leak into the new one. */ + int staleRfd = vfsOpen("/mnt/fifoGenA", oReadOnly); + KT_GE(t, staleRfd, 0); + + /* A full reformat: every old inode number, including firstInode, now + means whatever the fresh, empty filesystem below reassigns it to. */ + KT_TRUE(t, sfsFormat()); + KT_EQ(t, vfsMkfifo("/mnt/fifoGenA"), 0); + int32 secondInode = sfsLookup("/fifoGenA"); + KT_GE(t, secondInode, 0); + /* Deterministic on a freshly formatted, otherwise-empty filesystem: + both are the very first entry created after root, so the numbers + collide -- exactly the scenario that makes the generation check + necessary rather than merely defensive. */ + KT_EQ(t, secondInode, firstInode); + + int rfd = vfsOpen("/mnt/fifoGenA", oReadOnly); + int wfd = vfsOpen("/mnt/fifoGenA", oWriteOnly); + KT_GE(t, rfd, 0); + KT_GE(t, wfd, 0); + KT_EQ(t, vfsWrite(wfd, "new", 3), 3); + + /* The discriminating check: if the binding table wrongly inherited the + pre-reformat session (generation check not actually doing its job), + staleRfd would be a SECOND reader attached to the very same + underlying pipe wfd just wrote into, and -- read before rfd below -- + would steal the payload meant for the new session's own reader. + Correct isolation means staleRfd's pipe never had any writer at all + (it only ever attached as a reader), so it must still report "try + again", never the new session's bytes. */ + char stalePeek[4]; + KT_EQ(t, vfsRead(staleRfd, stalePeek, sizeof(stalePeek)), -1); + + char buffer[4]; + memset(buffer, 0, sizeof(buffer)); + KT_EQ(t, vfsRead(rfd, buffer, sizeof(buffer)), 3); + KT_TRUE(t, memcmp(buffer, "new", 3) == 0); + + vfsClose(rfd); + vfsClose(wfd); + /* The orphaned pre-reformat fd is cleaned up here rather than left to + leak a pipe.c slot for the rest of the boot -- vfsClose only ever + touches pipe.c state (pipeCloseRead), never re-derefs the long-gone + inode, so this is safe regardless of what the fresh filesystem did + with the number. */ + vfsClose(staleRfd); + vfsUnlink("/mnt/fifoGenA"); +} diff --git a/tools/ci-test.sh b/tools/ci-test.sh index 11a9117..704a31d 100755 --- a/tools/ci-test.sh +++ b/tools/ci-test.sh @@ -1,19 +1,27 @@ #!/bin/sh # Headless boot gate: run the ISO in QEMU, capture COM1, and require that a -# ring-3 program read a known ramdisk (USTAR) file and round-tripped a tmpfs -# file. Exits non-zero when any expected string is missing. +# ring-3 program read a known ramdisk (USTAR) file, round-tripped a tmpfs +# file, and round-tripped real sector I/O against a QEMU-emulated ATA disk +# via /dev/disk0. Exits non-zero when any expected string is missing. here=$(dirname "$0") root=$(cd "$here/.." && pwd) iso="$root/os.iso" +disk="$root/build/disk.img" if [ ! -f "$iso" ]; then echo "ci-test: $iso not found, build it first (make)" exit 2 fi +if [ ! -f "$disk" ]; then + mkdir -p "$root/build" + dd if=/dev/zero of="$disk" bs=1M count=8 status=none +fi + log=$(mktemp) -timeout 25 qemu-system-x86_64 -cdrom "$iso" -serial "file:$log" \ +timeout 25 qemu-system-x86_64 -cdrom "$iso" \ + -drive file="$disk",format=raw,if=ide -serial "file:$log" \ -display none -m 128M -no-reboot >/dev/null 2>&1 || true echo "----- serial log (tail) -----" @@ -24,7 +32,301 @@ status=0 for needle in \ "frostbyte-ustar-read-ok" \ "frostbyte-tmpfs-rw-ok" \ - "fileread: done" + "fileread: done" \ + "pipetest: done" \ + "pipe demo: eof, count=10" \ + "disktest: done" \ + "fstest: done" \ + "linktest: done" \ + "sched-priority-ok" \ + "join-reap-ok" \ + "proctest: spawn+wait ok" \ + "proctest: spawn bad path rejected" \ + "proctest: wait bad pid rejected" \ + "exectarget: running" \ + "exec-pid-preserved-ok" \ + "user-mem-reclaimed-ok" \ + "exec-old-slot-freed-ok" \ + "proctest: spawn with argv ok" \ + "argtest: argv-ok" \ + "fiforeader: attached, blocking on read" \ + "fifowriter: wrote payload" \ + "fiforeader: payload-ok" \ + "fifomaster: pipeshare ok" \ + "killtest: self-kill rejected" \ + "killtest: kill bad pid rejected" \ + "killvictim: blocking on read forever" \ + "killtest: killed victim" \ + "killtest: kill-ok" \ + "mkfifo /tmp/rendezvous ready" \ + "namedreader: opened, blocking on read" \ + "namedwriter: wrote payload" \ + "namedreader: payload-ok" \ + "consolereader1: start" \ + "consolereader2: start" \ + "injecting synthetic keypress" \ + "console-fanout-1-ok" \ + "console-fanout-2-ok" \ + "sigtest: signal to bad pid rejected" \ + "sigtest: sigkill-handler-rejected-ok" \ + "sigtarget: waiting for signal" \ + "sigtest: sent sigUsr1 to sigtarget" \ + "sigtarget: handler invoked signum=10" \ + "sigtarget: handler-fired-ok" \ + "sigtarget: resumed-after-handler-ok" \ + "sigtest: handler-roundtrip-ok" \ + "sigdefault: waiting to be terminated by default disposition" \ + "sigtest: sent sigTerm to sigdefault" \ + "sigtest: default-terminate-ok" \ + "jobtarget: ready, blocking on go pipe" \ + "jobtest: jobtarget ready" \ + "jobtest: child-inherits-parent-pgid-ok" \ + "jobtest: sent sigStop to jobtarget" \ + "jobtest: sent sigCont to jobtarget" \ + "jobtarget: received go" \ + "jobtest: job-stop-continue-ok" \ + "nesttarget: ready, waiting for sigUsr1" \ + "nesttest: nesttarget ready" \ + "nesttest: sent sigUsr1 to nesttarget" \ + "nesttarget: usr1-handler-enter" \ + "nesttest: nesttarget usr1-handler-entered" \ + "nesttest: sent sigUsr2 to nesttarget" \ + "nesttarget: usr2-handler-fired-nested" \ + "nesttarget: usr1-handler-resumed-after-nested-usr2" \ + "nesttarget: nested-delivery-ok" \ + "nesttarget: resumed-after-both-handlers-ok" \ + "nesttest: nested-signal-delivery-ok" \ + "eintrtarget: blocking on read forever" \ + "eintrtest: eintrtarget ready" \ + "eintrtest: sent sigTerm to eintrtarget" \ + "eintrtest: early-wake-signal-ok" \ + "restarttarget: ready, handlers installed" \ + "restarttest: restarttarget ready" \ + "restarttest: sent sigUsr1 (no restart) to restarttarget" \ + "restarttarget: usr1-handler-fired" \ + "restarttarget: phase1-eintr-no-restart-ok" \ + "restarttest: sent sigUsr2 (restart) to restarttarget" \ + "restarttarget: usr2-handler-fired" \ + "restarttarget: phase2-restart-transparent-ok" \ + "restarttarget: all-phases-ok" \ + "restarttest: sa-restart-eintr-distinction-ok" \ + "masktarget: ready, waiting for sigUsr1" \ + "masktest: masktarget ready" \ + "masktest: sent sigUsr1 to masktarget" \ + "masktarget: usr1-handler-enter" \ + "masktest: masktarget usr1-handler-entered" \ + "masktest: sent sigUsr2 to masktarget (should stay masked)" \ + "masktarget: still-masked-after-read-ok" \ + "masktarget: usr2-handler-fired-after-unmask" \ + "masktarget: mask-defers-then-delivers-ok" \ + "masktarget: resumed-after-deferred-usr2-ok" \ + "masktest: sa-mask-defer-then-deliver-ok" \ + "sigchldchild: exiting now" \ + "sigchldtest: sigchld-handler-fired" \ + "sigchldtest: sigchld-delivered-to-parent-ok" \ + "sigchldtest: chld-reap-after-signal-ok" \ + "waitanyfast: exiting now" \ + "waitanyslow: exiting now" \ + "waitanytest: any-child-reap-set-ok" \ + "waitanytest: echild-when-no-children-ok" \ + "stopchldtarget: ready, blocking on go pipe" \ + "stopchldtest: stopchldtarget ready" \ + "stopchldtest: sent sigStop to stopchldtarget" \ + "stopchldtest: sigchld-on-stop-ok" \ + "stopchldtest: sent sigCont to stopchldtarget" \ + "stopchldtest: sigchld-on-continue-ok" \ + "stopchldtarget: received go" \ + "stopchldtest: job-control-sigchld-ok" \ + "nodefertest: onUsr1 first entry, self-raising sigUsr1 (NODEFER)" \ + "nodefertest: onUsr1 nested entry (depth 2)" \ + "nodefertest: onUsr1 observed its own nested re-entry (nodefer-self-nest-ok)" \ + "nodefertest: nodefer-self-nest-verified-ok" \ + "nodefertest: onUsr2 first entry, self-raising sigUsr2 (default defer)" \ + "nodefertest: onUsr2 confirmed no self-nest while deferred (still-deferred-ok)" \ + "nodefertest: onUsr2 second, TOP-LEVEL entry after first returned" \ + "nodefertest: default-defer-no-self-nest-verified-ok" \ + "nodefertest: sa-nodefer-contrast-ok" \ + "alarmtest: alarm-replace-and-cancel-bookkeeping-ok" \ + "alarmtest: blocking on read, waiting for real alarm to fire" \ + "alarmtest: onAlrm fired" \ + "alarmtest: alarm-fired-interrupted-blocking-read-ok" \ + "alarmdefault: blocking on read forever" \ + "alarmtest: alarm-default-disposition-terminated-ok" \ + "alarmtest: sigalrm-ok" \ + "siginfotarget: siginfo-sender-pid-ok" \ + "siginfotarget: resumed-after-siginfo-handler-ok" \ + "siginfotest: sent sigUsr1 to siginfotarget" \ + "siginfotest: sigchld-siginfo-accurate-ok" \ + "siginfotest: siginfo-roundtrip-ok" \ + "selfstoptarget: self-stopping now" \ + "selfstoptest: self-stop-confirmed-via-sigchld-ok" \ + "selfstoptest: still-stopped-before-cont-ok" \ + "selfstoptest: sent sigCont to selfstoptarget" \ + "selfstoptarget: resumed-after-self-stop-ok" \ + "selfstoptest: post-cont-proof-received-ok" \ + "selfstoptest: self-stop-continue-ok" \ + "pgroupfg: ready, blocking on go pipe" \ + "pgroupbg: own-process-group-ok" \ + "pgroupbg: ready, blocking on go pipe" \ + "injecting synthetic ctrl-c for the process-group demo" \ + "pgrouptest-fg-terminated-by-sigint-ok" \ + "pgroupbg: still-alive-after-ctrlc-ok" \ + "pgroupbg: resumed-after-signal-ok" \ + "jobctlbg started in background" \ + "jobctlbg: own-process-group-ok" \ + "jobctlbg: ready, blocking on go pipe" \ + "jobctlfg: ready, blocking on go pipe" \ + "injecting synthetic ctrl-c for the on-demand job-control demo" \ + "jobctlfg exited with code 130" \ + "shell-foreground-reclaimed-ok" \ + "jobctlbg: still-alive-after-ctrlc-ok" \ + "jobctlbg: resumed-after-signal-ok" \ + "polltest: non-blocking-poll-empty-ok" \ + "polltest: poll-timeout-ok" \ + "pollwriter: attached, sleeping before write" \ + "polltest: blocking on multi-fd poll, waiting for pollwriter" \ + "pollwriter: wrote payload" \ + "polltest: poll-woken-by-write-ok" \ + "polltest: payload-ok" \ + "polltest: poll-multiplex-ok" \ + "typing shell command for the pipeline demo" \ + "pipesrc: wrote payload" \ + "pipesink: blocking on read, waiting for pipesrc" \ + "pipesink: payload-ok" \ + "pipesrc | pipesink exited with code 0" \ + "typing shell commands for the file redirection demo" \ + "redirwriter: wrote payload" \ + "redirwriter exited with code 0" \ + "redirreader: reading redirected stdin" \ + "redirreader: payload-ok" \ + "redirreader exited with code 0" \ + "typing shell commands for the stderr redirection demo" \ + "stderrwriter: wrote payload" \ + "stderrwriter exited with code 0" \ + "stderrreader: reading redirected stdin" \ + "stderrreader: payload-ok" \ + "stderrreader exited with code 0" \ + "typing shell command for the here-string demo" \ + "herestest: reading redirected stdin" \ + "herestest: payload-ok" \ + "herestest exited with code 0" \ + "typing shell command for the here-string pipeline demo" \ + "hcat: reading here-string stdin" \ + "hcat: forwarded payload" \ + "hsink: blocking on read, waiting for hcat" \ + "hsink: payload-ok" \ + "hcat | hsink exited with code 0" \ + "typing shell command for the three-stage pipeline demo" \ + "pipecat: reading piped stdin" \ + "pipecat: forwarded payload" \ + "pipesrc | pipecat | pipesink exited with code 0" \ + "cwdtest: chdir-cwda-ok" \ + "cwdtest: relative-write-cwda-ok" \ + "cwdtarget: inherited-cwd-ok" \ + "cwdtarget: relative-write-cwdb-ok" \ + "cwdtest: parent-cwd-unaffected-ok" \ + "cwdtest: isolation-verified-ok" \ + "typing shell commands for the environment variable demo" \ + "envprint: inherited-ok GREETING=frostbyte" \ + "envprint: self-set-ok GREETING=child-mutated" \ + "envprint: unset-ok" \ + "envprint: unset-missing-ok" \ + "envprint exited with code 0" \ + "env: GREETING=frostbyte" \ + "nicetest: bogus-pid-rejected-ok" \ + "nicetest: zero-weight-rejected-ok" \ + "nicetest: self-renice-ok" \ + "nicetarget: reniced-while-sleeping-ok" \ + "nicetest: other-renice-ok" \ + "nicetest: reaped-pid-rejected-ok" \ + "nicetest: priority-syscalls-ok" \ + "date: printed-ok" \ + "date: roundtrip-ok" \ + "typing shell commands for the wildcard glob demo" \ + "globcat: got 2 files" \ + "globcat: /tmp/gd/a.txt=a" \ + "globcat: /tmp/gd/b.txt=b" \ + "timetest: create-stamped-ok" \ + "timetest: write-stamped-ok" \ + "timetest: hardlink-shares-mtime-ok" \ + "timetest: PASS" \ + "rmtest: tmpfs-reject-ok" \ + "rmtest: tmpfs-remove-ok" \ + "rmtest: sfs-reject-ok" \ + "rmtest: sfs-remove-ok" \ + "rmtest: PASS" \ + "permtest: default-mode-ok" \ + "permtest: write-denied-ok" \ + "permtest: write-restored-ok" \ + "permtest: copy-not-executable-ok" \ + "permtest: exec-denied-ok" \ + "permtest: exec-after-chmod-ok" \ + "permtest: PASS" \ + "identtest: root-by-default-ok" \ + "identtest: owner-root-ok" \ + "identtest: owner-write-ok-before-drop" \ + "identtest: owner-exec-ok-before-drop" \ + "identtest: chown-root-ok" \ + "identtest: drop-to-1000-ok" \ + "identtest: cannot-regain-root-ok" \ + "identtest: setuid-self-noop-ok" \ + "identtest: write-denied-non-owner-ok" \ + "identtest: exec-denied-non-owner-ok" \ + "identtest: chmod-nonowner-denied-ok" \ + "identtest: chown-nonroot-denied-ok" \ + "identtest: chmod-owner-after-drop-ok" \ + "identtest: chown-owner-nonroot-denied-ok" \ + "identtest: PASS" \ + "dirpermtest: setup-ok" \ + "dirpermtest: root-self-write-denied-ok" \ + "dirpermtest: drop-to-1000-ok" \ + "dirpermtest: parent-write-denied-ok" \ + "dirpermtest: other-write-allowed-ok" \ + "dirpermtest: other-unlink-allowed-ok" \ + "dirpermtest: traverse-denied-ok" \ + "dirpermtest: traverse-allowed-ok" \ + "dirpermtest: list-denied-ok" \ + "dirpermtest: search-without-list-ok" \ + "dirpermtest: owner-write-allowed-ok" \ + "dirpermtest: PASS" \ + "grouptest: root-by-default-ok" \ + "grouptest: fresh-file-gid-stamped-ok" \ + "grouptest: chgrp-root-arbitrary-ok" \ + "grouptest: setgid-root-arbitrary-ok" \ + "grouptest: drop-to-6000-7000-ok" \ + "grouptest: setgid-nonroot-denied-ok" \ + "grouptest: setgid-self-noop-ok" \ + "grouptest: group-read-allowed-ok" \ + "grouptest: group-write-denied-ok" \ + "grouptest: other-fallback-denied-ok" \ + "grouptest: group-exec-allowed-ok" \ + "grouptest: group-exec-denied-ok" \ + "grouptest: chgrp-owner-self-gid-ok" \ + "grouptest: chgrp-owner-arbitrary-denied-ok" \ + "grouptest: chgrp-nonowner-nonroot-denied-ok" \ + "grouptest: PASS" \ + "pidfdtest: bad-target rejected" \ + "pidfdtest: pidfd-open-ok" \ + "pidfdtest: pidfd-send-ok" \ + "pidfdtest: pidfd-wait-ok" \ + "pidfdtest: reaped-target-detected-ok" \ + "pidfdtest: PASS" \ + "mmaptest: anon-zero-fill-ok" \ + "mmaptest: anon-write-read-ok" \ + "mmaptest: anon-munmap-ok" \ + "mmaptest: file-map-content-ok" \ + "mmapprotvictim: read-ok" \ + "mmaptest: protection-sigsegv-ok" \ + "mmaptest: exec-allowed-ok" \ + "mmapexecvictim: write-ok" \ + "mmaptest: exec-denied-sigsegv-ok" \ + "mmaptest: PASS" \ + "typing shell commands for the ls -l demo" \ + "f 17 data.txt " \ + "f 17 alias.txt " \ + "f 17 data.txt 20" \ + "typing shell commands for the &&/|| chaining demo" \ + "ao2" do if grep -qF "$needle" "$log"; then echo "PASS: found '$needle'" @@ -34,10 +336,32 @@ do fi done +# In-kernel self-test battery (ktest). The summary line is machine-readable: +# "KTEST SUMMARY: tests, checks, failed" +# The gate requires that line to exist, report 0 failures, and run at least +# one test. A hung or crashing test suppresses the line and fails the gate. +summary=$(grep -F "KTEST SUMMARY:" "$log" | tail -n 1) +if [ -z "$summary" ]; then + echo "FAIL: no 'KTEST SUMMARY:' line (in-kernel tests did not complete)" + status=1 +else + echo "PASS: $summary" + tests=$(echo "$summary" | sed -n 's/.*: \([0-9]*\) tests.*/\1/p') + fails=$(echo "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') + if [ "${tests:-0}" -lt 1 ]; then + echo "FAIL: ktest ran 0 tests" + status=1 + fi + if [ "${fails:-1}" -ne 0 ]; then + echo "FAIL: ktest reported $fails failed check(s)" + status=1 + fi +fi + rm -f "$log" if [ "$status" -eq 0 ]; then - echo "ci-test: all filesystem checks passed" + echo "ci-test: all boot and self-test checks passed" else - echo "ci-test: filesystem checks FAILED" + echo "ci-test: checks FAILED" fi exit $status diff --git a/user/alarmdefault.c b/user/alarmdefault.c new file mode 100644 index 0000000..5605e8b --- /dev/null +++ b/user/alarmdefault.c @@ -0,0 +1,53 @@ +#include "ulib.h" + +/* + * Only ever spawned by alarmtest.c. Installs NO sigAlrm handler at all -- + * the real-Unix default disposition for SIGALRM (signal.c's + * sigDefaultIsIgnore explicitly does NOT list sigAlrm, so it falls through + * to the same default-Terminate path every other "real" signal in this + * kernel already has) is Terminate, not Ignore. userAlarm(1) schedules a + * real SIGALRM one second from now against THIS process; the read below + * blocks on a pipe no writer will ever attach to (the same "blocked + * forever" setup eintrtarget.c/killvictim.c already use), so the only way + * this process ever leaves that read is the alarm actually firing and + * killing it -- exactly like eintrtarget.c, except the interrupting signal + * here comes from schedule()'s own timer-tick expiry scan (sched.c), not + * from another process's userSigSend. If threadSetAlarm/schedule's expiry + * scan were ever broken, this process would simply never return from the + * read below, hanging the whole boot gate to its 25s QEMU timeout instead + * of merely failing a check -- the same fault-injection-visible shape + * eintrtest.c's own file comment already establishes for early-wake. + */ + +int main(void) +{ + print("alarmdefault: start, pid="); + printDec(userGetpid()); + print("\n"); + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("alarmdefault: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("alarmdefault: FAIL attach victim pipe\n"); + return 1; + } + + if (userAlarm(1) != 0) { + print("alarmdefault: FAIL unexpected pre-existing alarm\n"); + return 1; + } + + print("alarmdefault: blocking on read forever\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + /* Only reached if userRead somehow returned on its own -- nothing ever + writes to the victim pipe, so the alarm not firing (or firing but + not actually terminating this process) is the only way to get here. */ + print("alarmdefault: FAIL read returned without being signaled\n"); + return 1; +} diff --git a/user/alarmtest.c b/user/alarmtest.c new file mode 100644 index 0000000..396555d --- /dev/null +++ b/user/alarmtest.c @@ -0,0 +1,168 @@ +#include "ulib.h" + +/* + * Boot-time driver for real alarm(2)/SIGALRM (sched.c's threadSetAlarm, + * schedule()'s own timer-tick expiry scan, signalnum.h's sigAlrm): + * schedule() already fires against a per-thread pitTicks() deadline + * regardless of what that thread is doing, the exact same shape sleepUntil + * already has for threadSleep -- but expiry here raises a REAL signal + * (sigRaise) instead of just flipping ThreadState, which means everything + * this kernel already built for signals (catch, default-terminate, + * SA_RESTART/EINTR on a retryable syscall, early-wake for a Sleeping/ + * Blocked target) applies to it for free. Three phases, each proving a + * DIFFERENT piece of that: + * + * phase 1: userAlarm's own "at most one pending alarm" bookkeeping -- + * scheduling one, then replacing it, then canceling it, checking the + * PREVIOUS remaining-seconds return value each time. Purely arithmetic + * (see threadSetAlarm's own KTEST coverage in schedTest.c for the same + * logic driven directly, deterministically, with no real waiting) -- + * this phase proves the userspace-visible contract of that same logic, + * with no real elapsed time required at all. + * + * phase 2: a REAL 1-second alarm actually firing against a thread + * genuinely blocked forever in userRead (the same "attach a victim pipe + * no writer will ever attach to" setup eintrtarget.c/restarttarget.c + * already use) -- WITH a caught handler, no sigFlagRestart, so the read + * must return errInterrupted the instant the handler returns, exactly + * like restarttarget.c's own phase 1, just self-triggered by a hardware + * timer instead of another process's userSigSend. + * + * phase 3: the SAME real firing, but against a completely separate, + * freshly spawned child (alarmdefault.c) that installs NO handler at + * all -- SIGALRM's real default disposition is Terminate (signal.c's + * sigDefaultIsIgnore deliberately does not list it), so that child must + * actually die, with exit code 128+sigAlrm, exactly like sigtest.c's own + * sigdefault.c proves for sigTerm. + * + * alarmdefault is deliberately SPAWNED before phase 2's own 1-second alarm + * is even set, not after phase 2 finishes: its own 1-second countdown then + * runs CONCURRENTLY with this process's phase-2 wait (this kernel's real + * preemptive scheduler already runs every Runnable/Sleeping thread's own + * timer independently -- see schedule()'s alarm-expiry scan), so the two + * real-time waits overlap into roughly one real second of added boot time + * total, not two sequential ones -- every boot-time demo shares one fixed + * 25s QEMU budget (tools/ci-test.sh), and stacking real-time waits serially + * eats into every OTHER demo's own margin against that same clock for no + * added proof value (phase 2 and phase 3 exercise two independent code + * paths -- caught+EINTR vs default-Terminate -- neither depends on the + * other's outcome, so nothing is lost by racing them). + * + * If schedule()'s alarm-expiry scan were ever broken (deadline set but + * never actually checked, or checked but sigRaise never called), phase 2's + * read call below would simply never return and phase 3's userWait would + * simply never see its child die -- both hang the whole boot gate to its + * 25s QEMU timeout rather than merely failing a check, the same + * fault-injection-visible shape every other "blocked forever until a + * signal rescues it" demo in this kernel already has. + */ + +static volatile int handlerFired = 0; + +static void onAlrm(int signum) +{ + (void)signum; + handlerFired++; + print("alarmtest: onAlrm fired\n"); +} + +int main(void) +{ + print("alarmtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* Phase 1: pure bookkeeping, no real waiting. */ + unsigned long previous1 = userAlarm(10); + if (previous1 != 0) { + print("alarmtest: FAIL expected no prior alarm\n"); + return 1; + } + unsigned long previous2 = userAlarm(3); + if (previous2 != 10) { + print("alarmtest: FAIL expected 10s remaining on replaced alarm, got "); + printDec((long)previous2); + print("\n"); + return 1; + } + unsigned long previous3 = userAlarm(0); + if (previous3 != 3) { + print("alarmtest: FAIL expected 3s remaining on canceled alarm, got "); + printDec((long)previous3); + print("\n"); + return 1; + } + unsigned long previous4 = userAlarm(0); + if (previous4 != 0) { + print("alarmtest: FAIL expected no alarm pending after cancel, got "); + printDec((long)previous4); + print("\n"); + return 1; + } + print("alarmtest: alarm-replace-and-cancel-bookkeeping-ok\n"); + + /* Spawned here, deliberately BEFORE phase 2 sets its own alarm below -- + see the file comment: this lets alarmdefault's own 1-second alarm + count down concurrently with phase 2's, instead of only starting + once phase 2 has already finished. */ + long child = userSpawn("/bin/alarmdefault", 0); + if (child < 0) { + print("alarmtest: FAIL spawn alarmdefault\n"); + return 1; + } + + /* Phase 2: real firing, caught, no sigFlagRestart -- must EINTR. */ + if (userSigAction(sigAlrm, onAlrm, 0, 0) == (long)sigSetFailed) { + print("alarmtest: FAIL install sigAlrm handler\n"); + return 1; + } + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("alarmtest: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("alarmtest: FAIL attach victim pipe\n"); + return 1; + } + + if (userAlarm(1) != 0) { + print("alarmtest: FAIL expected no prior alarm before phase 2\n"); + return 1; + } + print("alarmtest: blocking on read, waiting for real alarm to fire\n"); + + char victimBuffer[4]; + long readResult = userRead((int)victimFd, victimBuffer, sizeof(victimBuffer)); + userClose((int)victimFd); + + if (handlerFired != 1) { + print("alarmtest: FAIL onAlrm handler did not fire exactly once\n"); + return 1; + } + if (readResult != errInterrupted) { + print("alarmtest: FAIL blocking read did not return errInterrupted\n"); + return 1; + } + print("alarmtest: alarm-fired-interrupted-blocking-read-ok\n"); + + /* Phase 3: real firing, default disposition, must terminate a + completely separate process -- alarmdefault (spawned above, before + phase 2) has had its own 1-second alarm counting down this whole + time, so this wait should return almost immediately, not add a + second real second of its own. */ + long childStatus = userWait(child); + print("alarmtest: alarmdefault exit status="); + printDec(childStatus); + print("\n"); + if (childStatus != 128 + sigAlrm) { + print("alarmtest: FAIL expected alarmdefault exit status 128+sigAlrm\n"); + return 1; + } + print("alarmtest: alarm-default-disposition-terminated-ok\n"); + + print("alarmtest: sigalrm-ok\n"); + return 0; +} diff --git a/user/argecho.c b/user/argecho.c new file mode 100644 index 0000000..c1fac0c --- /dev/null +++ b/user/argecho.c @@ -0,0 +1,24 @@ +#include "ulib.h" + +/* + * Real ring-3 proof that shell.c's `$NAME`/`$?` expansion (shellExpandArgv, + * shellExpandToken) genuinely reaches a SPAWNED program's own argv, not + * just a shell builtin's -- every earlier consumer of expansion (export, + * env, echo) is a builtin, and shell.c's doEcho never calls shellExpandArgv + * at all (only runProgram/runPipelineStages do, for a real spawned + * process), so nothing in this kernel's live-demo chain had ever actually + * typed a `$` at the real shell before this program existed. Prints each + * argv[1..argc) on its own line, prefixed like globcat.c's own + * "globcat: " diagnostic style -- a generic argv printer, not tied to any + * one variable, so a future demo can reuse it for any other `$NAME`/`$?` + * case without needing yet another single-purpose binary. + */ +int main(int argc, char** argv) +{ + for (int i = 1; i < argc; i++) { + print("argecho: "); + print(argv[i]); + print("\n"); + } + return 0; +} diff --git a/user/argtest.c b/user/argtest.c new file mode 100644 index 0000000..d8c8b15 --- /dev/null +++ b/user/argtest.c @@ -0,0 +1,47 @@ +#include "ulib.h" + +/* + * Exercises argv/argc plumbing added this wave (elfBuildInitialStack in + * elf.c, copyUserArgv in syscall.c, crt.S's _start now reading argc/argv + * off the initial stack instead of calling main() with nothing). Only ever + * spawned by proctest.c, with a specific, known argv -- if the kernel-side + * stack layout is wrong in any way (misaligned rsp, wrong argc, a string + * copied to the wrong address, an argv[i] pointer landing on garbage), + * these comparisons catch it directly instead of a lucky match papering + * over a bug. userspace has no libc string.h, so the comparison is spelled + * out by hand exactly the way spawntarget.c/proctest.c already avoid + * pulling in anything beyond ulib.h. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(int argc, char** argv) +{ + print("argtest: argc="); + printDec(argc); + print("\n"); + + for (int i = 0; i < argc; i++) { + print("argtest: argv["); + printDec(i); + print("]="); + print(argv[i]); + print("\n"); + } + + int ok = argc == 4 && + streq(argv[0], "/bin/argtest") && + streq(argv[1], "alpha") && + streq(argv[2], "beta") && + streq(argv[3], "gamma"); + + print(ok ? "argtest: argv-ok\n" : "argtest: argv-FAIL\n"); + return ok ? 0 : 1; +} diff --git a/user/consolereader1.c b/user/consolereader1.c new file mode 100644 index 0000000..cb9e109 --- /dev/null +++ b/user/consolereader1.c @@ -0,0 +1,37 @@ +#include "ulib.h" + +/* + * Boot-spawned independently of consolereader2.c (see kmain.c's spawnUser + * calls) -- like namedreader.c/namedwriter.c, neither process's pid, argv, + * or fd table ever mentions the other. Both simply read fd 0, which every + * process gets for free (vfs.c's vfsFdInit) as its own console reader queue + * (keyboard.h) rather than a single kernel-wide buffer: proving that two + * completely unrelated processes reading "the keyboard" each get their own + * independent copy of every keystroke instead of racing each other for it. + * + * kmain.c's consoleInjectorThread fires exactly one synthetic keystroke a + * few hundred milliseconds after boot (there is no automated way to drive a + * real PS/2 device under headless QEMU, see its own comment) through the + * same keyboardHandleScancode the real IRQ handler calls -- so this process + * has to genuinely park in userRead across many timer ticks and be woken + * later, not just win a race against a byte that was already queued. + */ + +int main(void) +{ + print("consolereader1: start, pid="); + printDec(userGetpid()); + print(", blocking on fd 0\n"); + + char c = 0; + long got = userRead(0, &c, 1); + if (got != 1) { + print("consolereader1: FAIL read\n"); + return 1; + } + + print("consolereader1: got byte '"); + userWrite(1, &c, 1); + print("' -> console-fanout-1-ok\n"); + return 0; +} diff --git a/user/consolereader2.c b/user/consolereader2.c new file mode 100644 index 0000000..0c194d0 --- /dev/null +++ b/user/consolereader2.c @@ -0,0 +1,30 @@ +#include "ulib.h" + +/* See consolereader1.c's comment -- an entirely independent process reading + the same fd 0, spawned separately by kmain.c, sharing nothing with + consolereader1 but the fact that both happen to be alive when + consoleInjectorThread's single synthetic keystroke fires. If fan-out + ever regressed back to one shared buffer, whichever of the two processes + the scheduler happened to run first would steal the only queued byte and + the other would hang forever -- the boot gate's timeout would catch that + as a missing needle rather than a hang, since only this process is + missing, not the whole machine. */ + +int main(void) +{ + print("consolereader2: start, pid="); + printDec(userGetpid()); + print(", blocking on fd 0\n"); + + char c = 0; + long got = userRead(0, &c, 1); + if (got != 1) { + print("consolereader2: FAIL read\n"); + return 1; + } + + print("consolereader2: got byte '"); + userWrite(1, &c, 1); + print("' -> console-fanout-2-ok\n"); + return 0; +} diff --git a/user/crt.S b/user/crt.S index 600fdf3..2a74282 100644 --- a/user/crt.S +++ b/user/crt.S @@ -2,6 +2,11 @@ .global _start _start: xorq %rbp, %rbp + /* Real x86_64 SysV process entry: rsp points at argc, with argv[0..] + and a NULL terminator right above it (elfBuildInitialStack, + elf.c/syscall.c doExec/doSpawn, kmain.c spawnUser). */ + movq (%rsp), %rdi + leaq 8(%rsp), %rsi call main movq %rax, %rdi movq $0, %rax diff --git a/user/cwdtarget.c b/user/cwdtarget.c new file mode 100644 index 0000000..28b8ec7 --- /dev/null +++ b/user/cwdtarget.c @@ -0,0 +1,75 @@ +#include "ulib.h" + +/* + * Only ever spawned by cwdtest.c, with no argv at all -- unlike every other + * *test / *target pair in this kernel (which rendezvous over a shared pipe + * handle passed as an argv string), this pair's whole point is that the + * KERNEL itself hands this process its starting directory at creation time + * (sched.c's threadCreate/threadCreateUser, via the new initialCwd helper + * that mirrors initialPgid's own "inherit from whoever is currently + * running" shape for Thread.pgid), never anything cwdtest.c has to pass + * explicitly. + * + * Proves inheritance first, then proves the inherited value is a real, + * independent COPY rather than shared state: this process's own later + * chdir() must not reach back into cwdtest.c's Thread.cwd (cwdtest.c's own + * post-wait getcwd() check is the other half of that proof). + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("cwdtarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + char cwdBuf[64]; + long len = userGetcwd(cwdBuf, sizeof(cwdBuf)); + if (len < 0 || !streq(cwdBuf, "/tmp/cwda")) { + print("cwdtarget: FAIL inherited cwd, got: "); + print(len < 0 ? "(error)" : cwdBuf); + print("\n"); + return 1; + } + print("cwdtarget: inherited-cwd-ok\n"); + + if (userChdir("/tmp/cwdb") != 0) { + print("cwdtarget: FAIL chdir /tmp/cwdb\n"); + return 1; + } + len = userGetcwd(cwdBuf, sizeof(cwdBuf)); + if (len < 0 || !streq(cwdBuf, "/tmp/cwdb")) { + print("cwdtarget: FAIL getcwd after own chdir, got: "); + print(len < 0 ? "(error)" : cwdBuf); + print("\n"); + return 1; + } + + /* Relative path again -- must land at /tmp/cwdb/note, a COMPLETELY + different file than cwdtest.c's own /tmp/cwda/note despite the + identical relative name "note", since each process resolves it + against its own Thread.cwd. */ + int fd = (int)userOpen("note", oCreate | oWriteOnly | oTrunc); + if (fd < 0) { + print("cwdtarget: FAIL create relative note in cwdb\n"); + return 1; + } + const char* payload = "cwdb-payload"; + unsigned long payloadLen = stringLength(payload); + if (userWrite(fd, payload, payloadLen) != (long)payloadLen) { + print("cwdtarget: FAIL write relative note in cwdb\n"); + return 1; + } + userClose(fd); + print("cwdtarget: relative-write-cwdb-ok\n"); + + return 0; +} diff --git a/user/cwdtest.c b/user/cwdtest.c new file mode 100644 index 0000000..2c3b1f9 --- /dev/null +++ b/user/cwdtest.c @@ -0,0 +1,148 @@ +#include "ulib.h" + +/* + * Boot-time driver for real per-process working directories (sched.h's new + * Thread.cwd, vfs.h's new vfsChdir/vfsGetcwd/vfsResolvePath). Before this + * fala the only "current directory" concept anywhere in this kernel was a + * single static local inside shell.c, shared by nothing else -- a spawned + * user process calling open()/mkdir()/etc with a RELATIVE path had never + * actually worked, since vfs.c's lookupNode/sfsLookup always resolved + * relative to the filesystem root, never to any per-process notion of + * "here". + * + * This proves three things a single shared global cannot: (1) THIS + * process's own chdir() makes its OWN later relative opens resolve there, + * (2) cwdtarget, spawned AFTER that chdir(), inherits the new directory at + * creation time (real fork()'s own cwd-copy semantics, sched.c's new + * initialCwd mirroring initialPgid's own shape exactly), and (3) + * cwdtarget's own later chdir() does NOT reach back and change THIS + * process's -- each Thread owns an independent buffer, never a pointer to + * shared state. No pipes, signals, or injected keystrokes anywhere in this + * pair: every step is ordered by a real userWait, so unlike most of this + * kernel's other live demos this one has no scheduling-timing race to flake + * on at all. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("cwdtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + userMkdir("/tmp/cwda"); + userMkdir("/tmp/cwdb"); + + if (userChdir("/tmp/cwda") != 0) { + print("cwdtest: FAIL chdir /tmp/cwda\n"); + return 1; + } + + char cwdBuf[64]; + long len = userGetcwd(cwdBuf, sizeof(cwdBuf)); + if (len < 0 || !streq(cwdBuf, "/tmp/cwda")) { + print("cwdtest: FAIL getcwd after chdir, got: "); + print(len < 0 ? "(error)" : cwdBuf); + print("\n"); + return 1; + } + print("cwdtest: chdir-cwda-ok\n"); + + /* Relative path -- no leading '/' -- must resolve against the cwd just + set above, landing at /tmp/cwda/note, not the filesystem root. */ + int fd = (int)userOpen("note", oCreate | oWriteOnly | oTrunc); + if (fd < 0) { + print("cwdtest: FAIL create relative note in cwda\n"); + return 1; + } + const char* cwdaPayload = "cwda-payload"; + unsigned long cwdaLen = stringLength(cwdaPayload); + if (userWrite(fd, cwdaPayload, cwdaLen) != (long)cwdaLen) { + print("cwdtest: FAIL write relative note in cwda\n"); + return 1; + } + userClose(fd); + print("cwdtest: relative-write-cwda-ok\n"); + + long target = userSpawn("/bin/cwdtarget", 0); + if (target < 0) { + print("cwdtest: FAIL spawn cwdtarget\n"); + return 1; + } + + long status = userWait(target); + print("cwdtest: cwdtarget exit status="); + printDec(status); + print("\n"); + if (status != 0) { + print("cwdtest: FAIL cwdtarget did not exit cleanly\n"); + return 1; + } + + /* cwdtarget's own chdir("/tmp/cwdb") must never have reached back into + THIS process's Thread.cwd -- each is an independent buffer, not a + pointer to shared state (unlike shell.c's OLD single static global + this subsystem replaces). */ + len = userGetcwd(cwdBuf, sizeof(cwdBuf)); + if (len < 0 || !streq(cwdBuf, "/tmp/cwda")) { + print("cwdtest: FAIL own cwd changed after child exited, got: "); + print(len < 0 ? "(error)" : cwdBuf); + print("\n"); + return 1; + } + print("cwdtest: parent-cwd-unaffected-ok\n"); + + /* Re-read using an absolute path this time -- still resolves to the + exact same file the earlier relative open created, and must still + hold the ORIGINAL payload, untouched by anything cwdtarget did in + its own, completely separate directory. */ + char readBuf[32]; + fd = (int)userOpen("/tmp/cwda/note", oReadOnly); + if (fd < 0) { + print("cwdtest: FAIL reopen /tmp/cwda/note\n"); + return 1; + } + long got = userRead(fd, readBuf, sizeof(readBuf) - 1); + userClose(fd); + if (got < 0) { + print("cwdtest: FAIL read /tmp/cwda/note\n"); + return 1; + } + readBuf[got] = 0; + if (!streq(readBuf, cwdaPayload)) { + print("cwdtest: FAIL /tmp/cwda/note payload mismatch, got: "); + print(readBuf); + print("\n"); + return 1; + } + + fd = (int)userOpen("/tmp/cwdb/note", oReadOnly); + if (fd < 0) { + print("cwdtest: FAIL reopen /tmp/cwdb/note\n"); + return 1; + } + got = userRead(fd, readBuf, sizeof(readBuf) - 1); + userClose(fd); + if (got < 0) { + print("cwdtest: FAIL read /tmp/cwdb/note\n"); + return 1; + } + readBuf[got] = 0; + if (!streq(readBuf, "cwdb-payload")) { + print("cwdtest: FAIL /tmp/cwdb/note payload mismatch, got: "); + print(readBuf); + print("\n"); + return 1; + } + + print("cwdtest: isolation-verified-ok\n"); + return 0; +} diff --git a/user/date.c b/user/date.c new file mode 100644 index 0000000..a69673d --- /dev/null +++ b/user/date.c @@ -0,0 +1,127 @@ +#include "ulib.h" + +/* + * Real ring-3 "date": the first live demo of rtc.c/sysTime's wall-clock + * time, spawned directly by kmain.c at boot exactly like nicetest.c above + * it -- fully synchronous, no injected keystroke or pipe rendezvous, so + * unlike most of this kernel's live demos this one has no scheduling- + * timing race to flake on at all. + * + * Deliberately duplicates (not shares) Howard Hinnant's civil-calendar + * algorithm rtc.c already uses: this file writes its OWN forward + * (civil -> epoch) and inverse (epoch -> civil) implementations from + * scratch, independent of the kernel side. Decoding the epoch userTime() + * returns and then re-encoding it back with this independent forward + * function must reproduce the exact same number, which proves this + * program's own encode/decode pair is a true bijection over the date it + * actually ran on -- a real, self-verifying round trip, not a print + * nobody checks. + */ + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +/* Zero-padded unsigned decimal, `width` digits minimum (e.g. printPadded(7, 2) + -> "07"). Never negative here -- every field this file ever passes in is + already a non-negative calendar component. */ +static void printPadded(long value, int width) +{ + char digits[16]; + int len = 0; + long remaining = value; + + if (remaining == 0) + digits[len++] = '0'; + while (remaining > 0) { + digits[len++] = (char)('0' + (remaining % 10)); + remaining /= 10; + } + while (len < width) + digits[len++] = '0'; + + for (int i = len - 1; i >= 0; i--) + userWrite(1, &digits[i], 1); +} + +/* Howard Hinnant's days_from_civil -- forward direction, civil date to a + day count relative to 1970-01-01. See rtc.c for the same algorithm used + kernel-side; this copy is independent, not shared, on purpose (see file + header comment). */ +static long daysFromCivil(long y, long m, long d) +{ + y -= (m <= 2) ? 1 : 0; + long era = (y >= 0 ? y : y - 399) / 400; + long yoe = y - era * 400; + long doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + long doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; +} + +/* Howard Hinnant's civil_from_days -- the inverse direction, a day count + back to a civil date. */ +static void civilFromDays(long z, long* outYear, long* outMonth, long* outDay) +{ + z += 719468; + long era = (z >= 0 ? z : z - 146096) / 146097; + long doe = z - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long yy = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long dd = doy - (153 * mp + 2) / 5 + 1; + long mm = mp + (mp < 10 ? 3 : -9); + + *outYear = yy + (mm <= 2 ? 1 : 0); + *outMonth = mm; + *outDay = dd; +} + +int main(void) +{ + long epoch = userTime(); + if (epoch <= 0) { + printErr("date: FAIL sysTime returned a non-positive epoch\n"); + return 1; + } + + long days = epoch / 86400; + long secondsOfDay = epoch % 86400; + long hour = secondsOfDay / 3600; + long minute = (secondsOfDay % 3600) / 60; + long second = secondsOfDay % 60; + + long year; + long month; + long day; + civilFromDays(days, &year, &month, &day); + + if (year < 2020 || year > 2099) { + printErr("date: FAIL implausible year decoded\n"); + return 1; + } + + printPadded(year, 4); + userWrite(1, "-", 1); + printPadded(month, 2); + userWrite(1, "-", 1); + printPadded(day, 2); + userWrite(1, " ", 1); + printPadded(hour, 2); + userWrite(1, ":", 1); + printPadded(minute, 2); + userWrite(1, ":", 1); + printPadded(second, 2); + userWrite(1, "\n", 1); + printErr("date: printed-ok\n"); + + long roundTrip = daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; + if (roundTrip != epoch) { + printErr("date: FAIL roundtrip mismatch\n"); + return 1; + } + printErr("date: roundtrip-ok\n"); + + return 0; +} diff --git a/user/dirpermtest.c b/user/dirpermtest.c new file mode 100644 index 0000000..d00f156 --- /dev/null +++ b/user/dirpermtest.c @@ -0,0 +1,220 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Real ring-3 proof of directory-level permission enforcement (vfs.c's + * lookupNode/vfsMkdir/resolveOpen, sfs.c's lookupFrom/createNode/sfsRemove/ + * sfsLink) -- the gap FALA 44 explicitly flagged as stored-but-unenforced: + * before this fala, a directory's own mode bits were reported by stat/ls -l + * but NEVER consulted by anything, so ANY uid could create/remove entries + * in ANY directory and traverse ANY directory tree regardless of its mode. + * Fully synchronous like identtest.c/permtest.c/rmtest.c: no injected + * keystrokes, no shared shell channel, so there is no scheduling-timing + * race to flake on regardless of host load. + * + * Three distinct real POSIX bits are proven separately, not just "denied + * vs allowed" as one blob: + * - write on a PARENT directory governs creating/removing entries in it + * (not the entry's own mode -- real unlink(2)'s own rule). + * - exec (search) on every ANCESTOR directory governs resolving a NAME + * inside it, checked on every directory a path walks through. + * - read on a directory ITSELF governs listing its entries (getdents), + * a SEPARATE bit from exec -- a directory can be searchable (you can + * open a file inside it by name) without being listable, and this test + * proves that distinction concretely, not just asserts it in a comment. + */ + +int main(void) +{ + print("dirpermtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userGetuid() != 0) { + print("dirpermtest: FAIL boot-time uid is not 0\n"); + return 1; + } + + /* --- setup, entirely as root --- */ + if (userMkdir("/tmp/dpd") != 0) { + print("dirpermtest: FAIL mkdir /tmp/dpd\n"); + return 1; + } + int sfd = (int)userOpen("/tmp/dpd/shared.txt", oReadWrite | oCreate); + if (sfd < 0 || userWrite(sfd, "s", 1) != 1) { + print("dirpermtest: FAIL create shared.txt\n"); + return 1; + } + userClose(sfd); + + /* noexec: root removes ALL bits (0000) after creating one file inside + it -- no owner or other exec at all, so NOBODY (not even root) can + search inside it afterward. */ + if (userMkdir("/tmp/dpd/noexec") != 0) { + print("dirpermtest: FAIL mkdir noexec\n"); + return 1; + } + int ifd = (int)userOpen("/tmp/dpd/noexec/inside.txt", oReadWrite | oCreate); + if (ifd < 0 || userWrite(ifd, "i", 1) != 1) { + print("dirpermtest: FAIL create inside.txt\n"); + return 1; + } + userClose(ifd); + if (userChmod("/tmp/dpd/noexec", 0000) != 0) { + print("dirpermtest: FAIL chmod noexec to 0000\n"); + return 1; + } + + /* noread: owner keeps full rwx, other gets search (x) but not list (r) + -- proves exec and read are genuinely separate bits, not aliases. */ + if (userMkdir("/tmp/dpd/noread") != 0) { + print("dirpermtest: FAIL mkdir noread\n"); + return 1; + } + int vfd = (int)userOpen("/tmp/dpd/noread/visible.txt", oReadWrite | oCreate); + if (vfd < 0 || userWrite(vfd, "v", 1) != 1) { + print("dirpermtest: FAIL create visible.txt\n"); + return 1; + } + userClose(vfd); + if (userChmod("/tmp/dpd/noread", 0711) != 0) { + print("dirpermtest: FAIL chmod noread to 0711\n"); + return 1; + } + + /* writable: full rwx for owner AND other -- proves a non-owner CAN + create/remove entries when the other-write bit genuinely allows it, + not that this feature is a blanket deny. */ + if (userMkdir("/tmp/dpd/writable") != 0) { + print("dirpermtest: FAIL mkdir writable\n"); + return 1; + } + if (userChmod("/tmp/dpd/writable", 0777) != 0) { + print("dirpermtest: FAIL chmod writable to 0777\n"); + return 1; + } + + /* owned: reassigned to uid 1000 (real chown(2)) BEFORE this process + ever drops privilege, mirroring identtest.c's owned.txt -- after the + drop, this process really is the OWNER, so the owner bits (not the + other bits) govern. */ + if (userMkdir("/tmp/dpd/owned") != 0) { + print("dirpermtest: FAIL mkdir owned\n"); + return 1; + } + if (userChown("/tmp/dpd/owned", 1000) != 0) { + print("dirpermtest: FAIL chown owned to 1000\n"); + return 1; + } + + /* rootRO: root removes its OWN write bit (0555) -- proves no uid-0 + superuser bypass exists for directory writes either, matching + vfsPermitted's own file-level rule (identtest.c's chmod-nonowner- + denied-ok proved the same "no bypass" story for file bits). Checked + WHILE STILL ROOT, before the drop below. */ + if (userMkdir("/tmp/dpd/rootRO") != 0) { + print("dirpermtest: FAIL mkdir rootRO\n"); + return 1; + } + if (userChmod("/tmp/dpd/rootRO", 0555) != 0) { + print("dirpermtest: FAIL chmod rootRO to 0555\n"); + return 1; + } + print("dirpermtest: setup-ok\n"); + + if (userOpen("/tmp/dpd/rootRO/x", oWriteOnly | oCreate) >= 0) { + print("dirpermtest: FAIL root created a file with its own write bit off\n"); + return 1; + } + print("dirpermtest: root-self-write-denied-ok\n"); + + /* --- real, permanent privilege drop --- */ + if (userSetuid(1000) != 0 || userGetuid() != 1000) { + print("dirpermtest: FAIL setuid(1000)\n"); + return 1; + } + print("dirpermtest: drop-to-1000-ok\n"); + + /* /tmp/dpd itself is root-owned, default 0755 (other = r-x, no w) -- + a non-owner cannot create a new entry in it. */ + if (userOpen("/tmp/dpd/newfile.txt", oWriteOnly | oCreate) >= 0) { + print("dirpermtest: FAIL non-owner created entry in default-mode dir\n"); + return 1; + } + print("dirpermtest: parent-write-denied-ok\n"); + + /* writable/ is 0777 -- other-write is genuinely granted, so create AND + remove must both succeed for this non-owner. */ + int nfd = (int)userOpen("/tmp/dpd/writable/n.txt", oWriteOnly | oCreate); + if (nfd < 0) { + print("dirpermtest: FAIL non-owner denied create in 0777 dir\n"); + return 1; + } + userClose(nfd); + print("dirpermtest: other-write-allowed-ok\n"); + if (userUnlink("/tmp/dpd/writable/n.txt") != 0) { + print("dirpermtest: FAIL non-owner denied unlink in 0777 dir\n"); + return 1; + } + print("dirpermtest: other-unlink-allowed-ok\n"); + + /* noexec/ has NO exec bit for anyone -- resolving inside.txt inside it + must fail at the ancestor search-permission check, not at some later + stage. */ + if (userOpen("/tmp/dpd/noexec/inside.txt", oReadOnly) >= 0) { + print("dirpermtest: FAIL traversed into a no-exec directory\n"); + return 1; + } + print("dirpermtest: traverse-denied-ok\n"); + + /* /tmp/dpd itself is still searchable (other-exec=1 from the default + 0755) even though this same process cannot WRITE into it -- proves + the denial above was specific to noexec's own missing bit, not a + blanket failure of every directory this non-owner touches. */ + char buf[4]; + int rfd = (int)userOpen("/tmp/dpd/shared.txt", oReadOnly); + if (rfd < 0 || userRead(rfd, buf, 1) != 1) { + print("dirpermtest: FAIL could not read shared.txt through a searchable dir\n"); + return 1; + } + userClose(rfd); + print("dirpermtest: traverse-allowed-ok\n"); + + /* noread/ grants other-exec but not other-read: opening the DIRECTORY + itself for listing must fail... */ + if (userOpen("/tmp/dpd/noread", oReadOnly) >= 0) { + print("dirpermtest: FAIL listed a directory with no read bit\n"); + return 1; + } + print("dirpermtest: list-denied-ok\n"); + + /* ...but opening a FILE inside it BY NAME must still succeed, since + that only needs search (exec), never list (read), on noread itself + -- the exact distinction real Unix's `ls somedir/knownfile` (works + without +r on somedir) vs `ls somedir` (needs +r) draws. */ + int nrfd = (int)userOpen("/tmp/dpd/noread/visible.txt", oReadOnly); + if (nrfd < 0) { + print("dirpermtest: FAIL could not open known file via exec-only dir\n"); + return 1; + } + userClose(nrfd); + print("dirpermtest: search-without-list-ok\n"); + + /* owned/ was chowned to 1000 before the drop -- this process really is + the owner now, so default 0755's OWNER bits (full rwx) govern, not + the other bits a stranger would be limited to. */ + int ofd = (int)userOpen("/tmp/dpd/owned/mine.txt", oWriteOnly | oCreate); + if (ofd < 0) { + print("dirpermtest: FAIL owner denied create in its own directory\n"); + return 1; + } + userClose(ofd); + if (userUnlink("/tmp/dpd/owned/mine.txt") != 0) { + print("dirpermtest: FAIL owner denied unlink in its own directory\n"); + return 1; + } + print("dirpermtest: owner-write-allowed-ok\n"); + + print("dirpermtest: PASS\n"); + return 0; +} diff --git a/user/disktest.c b/user/disktest.c new file mode 100644 index 0000000..ff8cfdc --- /dev/null +++ b/user/disktest.c @@ -0,0 +1,131 @@ +#include "ulib.h" +#include "fs.h" + +static const char* diskPath = "/dev/disk0"; + +static void printLong(const char* label, long value) +{ + print(label); + printDec(value); + print("\n"); +} + +static int bytesEqual(const char* a, const char* b, unsigned long n) +{ + for (unsigned long i = 0; i < n; i++) + if (a[i] != b[i]) + return 0; + return 1; +} + +int main(void) +{ + print("disktest: start, pid="); + printDec(userGetpid()); + print("\n"); + + FbStat st; + if (userStat(diskPath, &st) != 0) { + print("disktest: stat failed\n"); + return 1; + } + printLong("disktest: disk size bytes=", (long)st.size); + if (st.size < 64 * 1024) { + print("disktest: FAIL disk suspiciously small\n"); + return 1; + } + + int fd = (int)userOpen(diskPath, oReadWrite); + if (fd < 0) { + print("disktest: open failed\n"); + return 1; + } + + long end = userLseek(fd, 0, seekEnd); + printLong("disktest: seekEnd=", end); + if (end != (long)st.size) { + print("disktest: FAIL seekEnd does not match stat size\n"); + return 1; + } + + /* Unaligned write/read spanning a 512-byte sector boundary: byte offset + 50*512 + 500 leaves 12 bytes in sector 50 and spills 8 bytes into + sector 51, exercising the read-modify-write path in vfs.c blockDevRead + /blockDevWrite rather than a single aligned sector transfer. */ + const long straddleOffset = 50L * 512 + 500; + const char* straddlePayload = "ABCDEFGHIJKLMNOPQRST"; /* 20 bytes */ + const unsigned long straddleLen = 20; + + if (userLseek(fd, straddleOffset, seekSet) != straddleOffset) { + print("disktest: FAIL lseek to straddle offset\n"); + return 1; + } + long written = userWrite(fd, straddlePayload, straddleLen); + printLong("disktest: straddle write bytes=", written); + if (written != (long)straddleLen) { + print("disktest: FAIL short straddle write\n"); + return 1; + } + + if (userLseek(fd, straddleOffset, seekSet) != straddleOffset) { + print("disktest: FAIL lseek back to straddle offset\n"); + return 1; + } + char straddleBack[20]; + long straddleRead = userRead(fd, straddleBack, straddleLen); + if (straddleRead != (long)straddleLen || !bytesEqual(straddleBack, straddlePayload, straddleLen)) { + print("disktest: FAIL straddle round-trip mismatch\n"); + return 1; + } + print("disktest: straddle round-trip ok\n"); + + /* Aligned full-sector write/read at a disjoint offset (sector 55), + proving the non-read-modify-write path also works. */ + const long alignedOffset = 55L * 512; + char alignedOut[512]; + for (int i = 0; i < 512; i++) + alignedOut[i] = (char)('a' + (i % 26)); + + if (userLseek(fd, alignedOffset, seekSet) != alignedOffset) { + print("disktest: FAIL lseek to aligned offset\n"); + return 1; + } + if (userWrite(fd, alignedOut, sizeof(alignedOut)) != (long)sizeof(alignedOut)) { + print("disktest: FAIL aligned sector write\n"); + return 1; + } + if (userLseek(fd, alignedOffset, seekSet) != alignedOffset) { + print("disktest: FAIL lseek back to aligned offset\n"); + return 1; + } + char alignedIn[512]; + if (userRead(fd, alignedIn, sizeof(alignedIn)) != (long)sizeof(alignedIn) || + !bytesEqual(alignedIn, alignedOut, sizeof(alignedOut))) { + print("disktest: FAIL aligned sector round-trip mismatch\n"); + return 1; + } + print("disktest: aligned sector round-trip ok\n"); + + /* Overwrite proves the second write actually lands on disk, not just in + a stale in-memory copy: reading back must see the newest data. */ + if (userLseek(fd, alignedOffset, seekSet) != alignedOffset) + return 1; + char overwrite[512]; + for (int i = 0; i < 512; i++) + overwrite[i] = (char)('A' + (i % 26)); + userWrite(fd, overwrite, sizeof(overwrite)); + + if (userLseek(fd, alignedOffset, seekSet) != alignedOffset) + return 1; + char reread[512]; + userRead(fd, reread, sizeof(reread)); + if (!bytesEqual(reread, overwrite, sizeof(overwrite)) || bytesEqual(reread, alignedOut, sizeof(alignedOut))) { + print("disktest: FAIL overwrite not reflected\n"); + return 1; + } + print("disktest: overwrite round-trip ok\n"); + + userClose(fd); + print("disktest: done\n"); + return 0; +} diff --git a/user/eintrtarget.c b/user/eintrtarget.c new file mode 100644 index 0000000..d1badcd --- /dev/null +++ b/user/eintrtarget.c @@ -0,0 +1,61 @@ +#include "ulib.h" + +/* + * Only ever spawned by eintrtest.c. Attaches as the READ end of its own, + * self-contained pipe (userPipeCreate/userPipeAttach) that no writer will + * ever attach to -- the exact same "blocked forever" setup killtest.c's + * killvictim.c already uses for threadKill -- but the way out here is an + * ORDINARY signal (sigTerm, default disposition Terminate) going through + * the FULL sigRaise -> pending-bit -> early-wake -> deliverPendingSignals + * pipeline (signal.h/sched.c's threadWakeForSignal), not threadKill's + * direct ThreadState bypass. Before that early-wake existed, sigRaise only + * ever set a pending bit and left a Blocked thread exactly where it was -- + * delivery only happens at a return-to-ring3 this process would otherwise + * never naturally reach again, so sigTerm sent here would have done + * nothing observable, ever: the read below is never naturally satisfied, + * by construction. + */ + +int main(int argc, char** argv) +{ + print("eintrtarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 2) { + print("eintrtarget: FAIL expected one argv (ready handle)\n"); + return 1; + } + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("eintrtarget: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("eintrtarget: FAIL attach victim pipe\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long readyFd = userPipeAttach(readyHandle, 1); + if (readyFd < 0) { + print("eintrtarget: FAIL attach ready pipe\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("eintrtarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + + print("eintrtarget: blocking on read forever\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + /* Only reached if userRead somehow returned on its own -- eintrtest.c + never writes to the victim pipe, so that would itself be a bug. */ + print("eintrtarget: FAIL read returned without being signaled\n"); + return 1; +} diff --git a/user/eintrtest.c b/user/eintrtest.c new file mode 100644 index 0000000..791a946 --- /dev/null +++ b/user/eintrtest.c @@ -0,0 +1,71 @@ +#include "ulib.h" + +/* + * Boot-time driver for early-wake signal delivery (signal.h/sched.c's + * threadWakeForSignal): spawns eintrtarget.c, waits for a real + * happens-before confirmation that it is about to block forever on a read + * that no writer will ever satisfy (the same unsatisfiable-by-construction + * setup killtest.c's killvictim.c already established for threadKill), and + * sends it a real sigTerm through userSigSend -- an ORDINARY signal with + * its real default disposition (Terminate), not threadKill's direct + * bypass. + * + * If userWait below ever hung the whole boot (see ci-test.sh's 25s QEMU + * timeout), that would itself BE the regression this proves against: a + * Blocked thread that never naturally wakes has no other way back to ring 3 + * for isr.c's deliverPendingSignals to ever reach it, without the + * early-wake this test exists to exercise. + */ + +int main(void) +{ + print("eintrtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long readyHandle = userPipeCreate(); + if (readyHandle < 0) { + print("eintrtest: FAIL create ready pipe\n"); + return 1; + } + long readyFd = userPipeAttach(readyHandle, 0); + if (readyFd < 0) { + print("eintrtest: FAIL attach ready pipe\n"); + return 1; + } + + char readyHandleStr[24]; + decToString(readyHandle, readyHandleStr); + char* targetArgv[] = { "/bin/eintrtarget", readyHandleStr, 0 }; + long target = userSpawn("/bin/eintrtarget", targetArgv); + if (target < 0) { + print("eintrtest: FAIL spawn eintrtarget\n"); + return 1; + } + + char readyByte; + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("eintrtest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("eintrtest: eintrtarget ready\n"); + + if (userSigSend(target, sigTerm) != 0) { + print("eintrtest: FAIL send sigTerm to eintrtarget\n"); + return 1; + } + print("eintrtest: sent sigTerm to eintrtarget\n"); + + long status = userWait(target); + print("eintrtest: eintrtarget exit status="); + printDec(status); + print("\n"); + if (status != 128 + sigTerm) { + print("eintrtest: FAIL expected eintrtarget exit status 128+sigTerm\n"); + return 1; + } + print("eintrtest: early-wake-signal-ok\n"); + + return 0; +} diff --git a/user/envprint.c b/user/envprint.c new file mode 100644 index 0000000..8b31389 --- /dev/null +++ b/user/envprint.c @@ -0,0 +1,84 @@ +#include "ulib.h" + +/* + * Live ring-3 proof for the real environment-variable subsystem (sched.h's + * Thread.env, syscall.c's sysGetEnv/sysSetEnv/sysUnsetEnv, shell.c's + * `export`/`env`/`unset` builtins and `$NAME` expansion) -- loaded on demand by + * the real interactive shell (kmain.c's envDriverThread types + * "export GREETING=frostbyte" then "envprint" at it, the same injected- + * keystroke mechanism every shell-driven demo above already uses), never + * boot-spawned, the same "no kmain.c spawnUser call, only /bin/NAME + * resolved by the shell's own fallback" shape jobctlfg.c/hcat.c/pipecat.c + * already established. + * + * Proves inheritance first (this process never called userSetEnv itself + * before the first check -- whatever it sees came only from + * threadCreateUser's own initialEnv copying the shell's table at spawn + * time), then proves this process's OWN later userSetEnv is visible to + * itself immediately, then proves userUnsetEnv actually removes the entry + * (a following userGetEnv on the same name fails) and that unsetting a + * name that was never set is still a no-op success, not an error. The + * other half of the isolation guarantee -- that + * this process's own mutation can never reach back into the PARENT + * shell's copy -- is proven by envDriverThread itself: it types a plain + * "env" at the shell AFTER this process has already exited and checks + * that the shell still reports the ORIGINAL "frostbyte" value, not + * "child-mutated" (mirrors cwdtest.c's own post-userWait + * parent-cwd-unaffected check, just driven through the shell instead of a + * direct userWait). + */ + +int main(void) +{ + print("envprint: start, pid="); + printDec(userGetpid()); + print("\n"); + + char value[32]; + long len = userGetEnv("GREETING", value, sizeof(value)); + if (len < 0) { + print("envprint: FAIL missing inherited GREETING\n"); + return 1; + } + print("envprint: inherited-ok GREETING="); + print(value); + print("\n"); + + if (userGetEnv("NO_SUCH_VAR", value, sizeof(value)) >= 0) { + print("envprint: FAIL unset variable returned a value\n"); + return 1; + } + + if (userSetEnv("GREETING", "child-mutated") != 0) { + print("envprint: FAIL setenv\n"); + return 1; + } + len = userGetEnv("GREETING", value, sizeof(value)); + if (len < 0) { + print("envprint: FAIL read back own setenv\n"); + return 1; + } + print("envprint: self-set-ok GREETING="); + print(value); + print("\n"); + + if (userUnsetEnv("GREETING") != 0) { + print("envprint: FAIL unset\n"); + return 1; + } + if (userGetEnv("GREETING", value, sizeof(value)) >= 0) { + print("envprint: FAIL unset did not remove variable\n"); + return 1; + } + print("envprint: unset-ok\n"); + + /* Real unsetenv(3) is a no-op success on a name that was never set -- + this must NOT be confused with a "not found" error. */ + if (userUnsetEnv("NO_SUCH_VAR_EVER") != 0) { + print("envprint: FAIL unset-missing should be a no-op success\n"); + return 1; + } + print("envprint: unset-missing-ok\n"); + + return 0; +} diff --git a/user/exectarget.c b/user/exectarget.c new file mode 100644 index 0000000..90c7b65 --- /dev/null +++ b/user/exectarget.c @@ -0,0 +1,19 @@ +#include "ulib.h" + +/* + * Never spawned directly by kmain.c or by proctest.c's userSpawn -- the + * only way this binary's "exectarget: running" line can ever appear in the + * boot log is if proctest.c's userExec("/bin/exectarget") call actually + * replaced proctest's own image with this one (see proctest.c). Its pid is + * therefore the SAME pid proctest was given at spawnUser time in kmain.c, + * which is what kmain.c's execReaperThread checks via threadJoin to prove + * exec() preserves process identity, not just that some program ran. + */ + +int main(void) +{ + print("exectarget: running, pid="); + printDec(userGetpid()); + print("\n"); + return 7; +} diff --git a/user/false.c b/user/false.c new file mode 100644 index 0000000..f50de6c --- /dev/null +++ b/user/false.c @@ -0,0 +1,15 @@ +#include "ulib.h" + +/* + * Real POSIX false(1): does nothing, always exits 1. Exists purely so + * shell.c's new `&&`/`||` conditional chaining (execute()/executeSingle, + * shellSplitAndOr in shell.h) has a genuinely SPAWNED process (real + * fork-less spawn + real userExit, not just a builtin's own int return + * like doExport/changeDir) whose nonzero exit status drives a real `&&` + * skip followed by a real `||` fallback -- kmain.c's andOrDriverThread + * types `false && echo x || echo ao2` at the live shell to prove it. + */ +int main(void) +{ + return 1; +} diff --git a/user/fifomaster.c b/user/fifomaster.c new file mode 100644 index 0000000..539a86f --- /dev/null +++ b/user/fifomaster.c @@ -0,0 +1,56 @@ +#include "ulib.h" + +/* + * Drives the shared-pipe demo added this wave (pipeCreateDetached/ + * pipeAddReader/pipeAddWriter, pipe.h; vfsPipeCreateHandle/vfsPipeAttach, + * vfs.h; sysRead/sysWrite's retry-the-syscall path, syscall.c): creates one + * detached pipe handle and hands it to two INDEPENDENTLY spawned processes + * as a decimal argv string (decToString/parseDec, ulib.c) rather than a + * shared fd -- frostbyte has no fork(), so a raw pipe.h handle number is + * the only thing two unrelated processes can rendezvous on. fifowriter.c + * deliberately sleeps before writing, so fiforeader.c's read() genuinely + * has to block across several timer ticks and be woken later, not just win + * a scheduling race against an already-full buffer -- see fifowriter.c/ + * fiforeader.c for how each end proves that on its own side. + */ + +int main(void) +{ + print("fifomaster: start, pid="); + printDec(userGetpid()); + print("\n"); + + long handle = userPipeCreate(); + if (handle < 0) { + print("fifomaster: FAIL pipe create\n"); + return 1; + } + + char handleStr[24]; + decToString(handle, handleStr); + + char* readerArgv[] = { "/bin/fiforeader", handleStr, 0 }; + long readerPid = userSpawn("/bin/fiforeader", readerArgv); + if (readerPid < 0) { + print("fifomaster: FAIL spawn fiforeader\n"); + return 1; + } + + char* writerArgv[] = { "/bin/fifowriter", handleStr, 0 }; + long writerPid = userSpawn("/bin/fifowriter", writerArgv); + if (writerPid < 0) { + print("fifomaster: FAIL spawn fifowriter\n"); + return 1; + } + + long readerStatus = userWait(readerPid); + long writerStatus = userWait(writerPid); + + if (readerStatus != 0 || writerStatus != 0) { + print("fifomaster: FAIL reader or writer reported failure\n"); + return 1; + } + + print("fifomaster: pipeshare ok\n"); + return 0; +} diff --git a/user/fiforeader.c b/user/fiforeader.c new file mode 100644 index 0000000..0887fc9 --- /dev/null +++ b/user/fiforeader.c @@ -0,0 +1,53 @@ +#include "ulib.h" + +/* + * Only ever spawned by fifomaster.c, with the shared pipe's handle number + * as argv[1]. Attaches its end and immediately calls a blocking read() on + * a pipe fifowriter.c (spawned alongside it, sleeping before it writes) + * has not put anything into yet -- this call must genuinely park (the + * syscall-restart path in syscall.c's sysRead, parked on pipeChannel) and + * only return once fifowriter.c's write wakes it back up, not before. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(int argc, char** argv) +{ + if (argc != 2) { + print("fiforeader: FAIL expected one argv (pipe handle)\n"); + return 1; + } + + long handle = parseDec(argv[1]); + long fd = userPipeAttach(handle, 0); + if (fd < 0) { + print("fiforeader: FAIL attach\n"); + return 1; + } + + print("fiforeader: attached, blocking on read\n"); + char buffer[64]; + long got = userRead((int)fd, buffer, sizeof(buffer) - 1); + if (got <= 0) { + print("fiforeader: FAIL read\n"); + return 1; + } + buffer[got] = 0; + + print("fiforeader: got: "); + print(buffer); + print("\n"); + + int ok = streq(buffer, "shared pipe payload"); + print(ok ? "fiforeader: payload-ok\n" : "fiforeader: payload-FAIL\n"); + + userClose((int)fd); + return ok ? 0 : 1; +} diff --git a/user/fifowriter.c b/user/fifowriter.c new file mode 100644 index 0000000..de14e5e --- /dev/null +++ b/user/fifowriter.c @@ -0,0 +1,43 @@ +#include "ulib.h" + +/* + * Only ever spawned by fifomaster.c, with the shared pipe's handle number + * as argv[1] (decToString'd -- see fifomaster.c). Sleeps well past when + * fiforeader.c (spawned moments earlier) will already be parked in a + * blocking read() on the still-empty pipe, so the write below has to + * actually wake it back up (schedWakeChannel, pipeWrite in pipe.c) instead + * of the reader simply finding data already waiting for it -- proof that + * the block was real, not a lucky race. + */ + +#define fifoWriterDelayMs 200 + +static const char* payload = "shared pipe payload"; + +int main(int argc, char** argv) +{ + if (argc != 2) { + print("fifowriter: FAIL expected one argv (pipe handle)\n"); + return 1; + } + + long handle = parseDec(argv[1]); + long fd = userPipeAttach(handle, 1); + if (fd < 0) { + print("fifowriter: FAIL attach\n"); + return 1; + } + + print("fifowriter: attached, sleeping before write\n"); + userSleep(fifoWriterDelayMs); + + long written = userWrite((int)fd, payload, stringLength(payload)); + if (written != (long)stringLength(payload)) { + print("fifowriter: FAIL short/failed write\n"); + return 1; + } + print("fifowriter: wrote payload\n"); + + userClose((int)fd); + return 0; +} diff --git a/user/fstest.c b/user/fstest.c new file mode 100644 index 0000000..78059f0 --- /dev/null +++ b/user/fstest.c @@ -0,0 +1,150 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Exercises the persistent SFS filesystem (sfs.h/sfs.c) through the exact + * same syscall path a real userspace program would use: open/write/read/ + * close/mkdir/getdents/unlink on paths under "/mnt", which vfs.c dispatches + * to sfs.c instead of the kmalloc-based tmpfs tree. Proves the full stack + * (syscall -> vfs -> sfs -> real block I/O on the same ATA disk /dev/disk0 + * uses) works end to end, not just the in-kernel sfs.c unit tests. + */ + +static int bytesEqual(const char* a, const char* b, unsigned long n) +{ + for (unsigned long i = 0; i < n; i++) + if (a[i] != b[i]) + return 0; + return 1; +} + +int main(void) +{ + print("fstest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* Nested directory creation. */ + if (userMkdir("/mnt/app") != 0) { + print("fstest: FAIL mkdir /mnt/app\n"); + return 1; + } + if (userMkdir("/mnt/app/logs") != 0) { + print("fstest: FAIL mkdir /mnt/app/logs\n"); + return 1; + } + + /* Create + write + close, then reopen with a fresh fd to prove the data + lives past the writer's own fd, not just in that fd's local state. */ + const char* payload = "frostbyte fstest payload line one\n"; + unsigned long payloadLen = stringLength(payload); + + int wfd = (int)userOpen("/mnt/app/logs/boot.log", oReadWrite | oCreate); + if (wfd < 0) { + print("fstest: FAIL open for create\n"); + return 1; + } + if (userWrite(wfd, payload, payloadLen) != (long)payloadLen) { + print("fstest: FAIL short write\n"); + return 1; + } + userClose(wfd); + + FbStat st; + if (userStat("/mnt/app/logs/boot.log", &st) != 0 || st.size != payloadLen) { + print("fstest: FAIL stat size mismatch\n"); + return 1; + } + + int rfd = (int)userOpen("/mnt/app/logs/boot.log", oReadOnly); + if (rfd < 0) { + print("fstest: FAIL reopen\n"); + return 1; + } + char back[128]; + long got = userRead(rfd, back, sizeof(back)); + if (got != (long)payloadLen || !bytesEqual(back, payload, payloadLen)) { + print("fstest: FAIL reopen round-trip mismatch\n"); + return 1; + } + userClose(rfd); + print("fstest: create/write/reopen/read round-trip ok\n"); + + /* Append across two separate open() calls: growth must pick up where + the previous session's data ended. */ + int afd = (int)userOpen("/mnt/app/logs/boot.log", oReadWrite | oAppend); + if (afd < 0) { + print("fstest: FAIL open for append\n"); + return 1; + } + const char* more = "line two\n"; + unsigned long moreLen = stringLength(more); + if (userWrite(afd, more, moreLen) != (long)moreLen) { + print("fstest: FAIL append write\n"); + return 1; + } + userClose(afd); + + if (userStat("/mnt/app/logs/boot.log", &st) != 0 || st.size != payloadLen + moreLen) { + print("fstest: FAIL stat after append\n"); + return 1; + } + print("fstest: append across reopen ok\n"); + + /* A second, unrelated file in the same directory, to exercise readdir + against more than one entry. */ + int cfd = (int)userOpen("/mnt/app/logs/counters.txt", oReadWrite | oCreate); + if (cfd < 0) { + print("fstest: FAIL create second file\n"); + return 1; + } + userWrite(cfd, "0", 1); + userClose(cfd); + + int dfd = (int)userOpen("/mnt/app/logs", oReadOnly); + if (dfd < 0) { + print("fstest: FAIL open dir for listing\n"); + return 1; + } + FbDirent entries[8]; + long entCount = userGetdents(dfd, entries, sizeof(entries)); + userClose(dfd); + if (entCount != 2 * (long)sizeof(FbDirent)) { + print("fstest: FAIL readdir entry count\n"); + return 1; + } + int sawBoot = 0, sawCounters = 0; + for (int i = 0; i < 2; i++) { + if (entries[i].type != fsTypeFile) { + print("fstest: FAIL readdir entry type\n"); + return 1; + } + if (bytesEqual(entries[i].name, "boot.log", 9)) + sawBoot = 1; + if (bytesEqual(entries[i].name, "counters.txt", 13)) + sawCounters = 1; + } + if (!sawBoot || !sawCounters) { + print("fstest: FAIL readdir missing an expected entry\n"); + return 1; + } + print("fstest: readdir lists both files ok\n"); + + /* Unlink one, verify it is gone and its sibling survives. */ + if (userUnlink("/mnt/app/logs/counters.txt") != 0) { + print("fstest: FAIL unlink counters.txt\n"); + return 1; + } + if (userStat("/mnt/app/logs/counters.txt", &st) == 0) { + print("fstest: FAIL unlinked file still stat-able\n"); + return 1; + } + if (userStat("/mnt/app/logs/boot.log", &st) != 0) { + print("fstest: FAIL sibling disappeared after unlink\n"); + return 1; + } + print("fstest: unlink ok, sibling survives\n"); + + print("fstest: done\n"); + return 0; +} diff --git a/user/globcat.c b/user/globcat.c new file mode 100644 index 0000000..03a9e7a --- /dev/null +++ b/user/globcat.c @@ -0,0 +1,51 @@ +#include "ulib.h" + +/* + * Live proof that shell.c's new wildcard glob expansion (shellExpandGlobs) + * really hands a spawned process REAL, distinct filenames resolved from a + * pattern typed at the shell (`globcat /tmp/gd/ *.txt`) -- not a literal, + * unexpanded "*.txt" string, and not every file in the directory (the + * sibling /tmp/gd/c.log the driver thread also creates must NOT appear). + * Prints how many files it actually received, then each one's own path and + * content, all on fd 1 (unlike pipecat.c/hcat.c, this program's own argv + * and stdout ARE the thing being demonstrated, not a pipe payload it must + * keep pristine). + */ + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +int main(int argc, char** argv) +{ + print("globcat: got "); + printDec(argc - 1); + print(" files\n"); + + for (int i = 1; i < argc; i++) { + long fd = userOpen(argv[i], oReadOnly); + if (fd < 0) { + printErr("globcat: FAIL cannot open "); + printErr(argv[i]); + printErr("\n"); + return 1; + } + + char buffer[64]; + long got = userRead((int)fd, buffer, sizeof(buffer) - 1); + userClose((int)fd); + if (got < 0) { + printErr("globcat: FAIL read\n"); + return 1; + } + buffer[got] = 0; + + print("globcat: "); + print(argv[i]); + print("="); + print(buffer); + } + + return 0; +} diff --git a/user/grouptest.c b/user/grouptest.c new file mode 100644 index 0000000..20558fe --- /dev/null +++ b/user/grouptest.c @@ -0,0 +1,280 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Real ring-3 proof of the group credential (sched.h's Thread.gid, + * currentThreadGid/currentThreadSetGid) and the new middle branch of + * vfs.c's vfsPermitted (owner-vs-group-vs-other) -- coreTest.c's + * coreVfsPermittedGroupMatchChecksGroupBitOnly can only exercise the pure + * predicate directly, since KTEST always runs as the permanent idle + * thread's own (uid 0, gid 0). This is the live proof that a caller who is + * NOT a file's owner but DOES share its group gid gets governed by the + * fsModeGroup* bits specifically -- neither the owner bits (wrong uid) nor + * the other bits (deliberately left clear below) -- and that a caller + * matching NEITHER uid nor gid correctly falls through to other. Also + * proves real chgrp(2) (userChgrp, vfs.c's vfsChgrp): root may reassign + * any gid, but a non-root owner may only ever set the group to its OWN + * current gid. Fully synchronous like identtest.c/dirpermtest.c: no + * injected keystrokes, no shared shell channel, so there is no + * scheduling-timing race to flake on regardless of host load. + */ + +static int copyFile(const char* srcPath, const char* dstPath) +{ + int src = (int)userOpen(srcPath, oReadOnly); + if (src < 0) + return -1; + int dst = (int)userOpen(dstPath, oReadWrite | oCreate | oTrunc); + if (dst < 0) { + userClose(src); + return -1; + } + + char buf[512]; + long got; + while ((got = userRead(src, buf, sizeof(buf))) > 0) { + if (userWrite(dst, buf, (unsigned long)got) != got) { + userClose(src); + userClose(dst); + return -1; + } + } + userClose(src); + userClose(dst); + return got < 0 ? -1 : 0; +} + +int main(void) +{ + print("grouptest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* --- boot-time default: every process starts at gid 0, alongside the + already-proven uid 0 (identtest.c). --- */ + if (userGetuid() != 0 || userGetgid() != 0) { + print("grouptest: FAIL boot-time uid/gid is not 0\n"); + return 1; + } + print("grouptest: root-by-default-ok\n"); + + if (userMkdir("/tmp/groupd") != 0) { + print("grouptest: FAIL mkdir /tmp/groupd\n"); + return 1; + } + + /* --- a fresh file is stamped with the CREATOR's current gid, exactly + like uid already was. --- */ + int fd = (int)userOpen("/tmp/groupd/stamp", oReadWrite | oCreate); + if (fd < 0) { + print("grouptest: FAIL create stamp\n"); + return 1; + } + userClose(fd); + FbStat st; + if (userStat("/tmp/groupd/stamp", &st) != 0 || st.gid != 0) { + print("grouptest: FAIL fresh file not stamped with creator gid\n"); + return 1; + } + print("grouptest: fresh-file-gid-stamped-ok\n"); + + /* --- set up every file this process will need access to AFTER its own + permanent identity drop, exactly like identtest.c's owned.txt has to + be chown'd before dropping: once this process is no longer root, + neither chown nor an arbitrary chgrp will ever work again. --- + + groupOnly: owned by a stranger (9000), group 7000 (the gid THIS + process will adopt), mode 0040 (group-read only, nothing else). */ + fd = (int)userOpen("/tmp/groupd/groupOnly", oReadWrite | oCreate); + if (fd < 0) { + print("grouptest: FAIL create groupOnly\n"); + return 1; + } + userClose(fd); + if (userChown("/tmp/groupd/groupOnly", 9000) != 0 || + userChgrp("/tmp/groupd/groupOnly", 7000) != 0 || + userChmod("/tmp/groupd/groupOnly", 0040) != 0) { + print("grouptest: FAIL set up groupOnly\n"); + return 1; + } + + /* mismatch: owned by a stranger (9000), group 8000 (NOT the gid this + process will adopt), mode 0040 (group-read only) -- so after the + drop, neither uid nor gid matches, and other has nothing either. */ + fd = (int)userOpen("/tmp/groupd/mismatch", oReadWrite | oCreate); + if (fd < 0) { + print("grouptest: FAIL create mismatch\n"); + return 1; + } + userClose(fd); + if (userChown("/tmp/groupd/mismatch", 9000) != 0 || + userChgrp("/tmp/groupd/mismatch", 8000) != 0 || + userChmod("/tmp/groupd/mismatch", 0040) != 0) { + print("grouptest: FAIL set up mismatch\n"); + return 1; + } + print("grouptest: chgrp-root-arbitrary-ok\n"); + + /* progOk: a copy of /bin/false, owned by 9000, group 7000, group-exec + only -- proves the SAME group model gates exec (loadImageByPath/ + jobctlSpawn), not just open(). progDenied: identical except group + 8000, so it must refuse after the drop. */ + if (copyFile("/bin/false", "/tmp/groupd/progOk") != 0 || + copyFile("/bin/false", "/tmp/groupd/progDenied") != 0) { + print("grouptest: FAIL copy /bin/false\n"); + return 1; + } + if (userChown("/tmp/groupd/progOk", 9000) != 0 || + userChgrp("/tmp/groupd/progOk", 7000) != 0 || + userChmod("/tmp/groupd/progOk", 0010) != 0) { + print("grouptest: FAIL set up progOk\n"); + return 1; + } + if (userChown("/tmp/groupd/progDenied", 9000) != 0 || + userChgrp("/tmp/groupd/progDenied", 8000) != 0 || + userChmod("/tmp/groupd/progDenied", 0010) != 0) { + print("grouptest: FAIL set up progDenied\n"); + return 1; + } + + /* owned: THIS process's own future uid (6000) owns it outright, + initial group 0 -- the target for the owner-chgrp tests below, + mirroring identtest.c's owned.txt. */ + fd = (int)userOpen("/tmp/groupd/owned", oReadWrite | oCreate); + if (fd < 0) { + print("grouptest: FAIL create owned\n"); + return 1; + } + userClose(fd); + if (userChown("/tmp/groupd/owned", 6000) != 0) { + print("grouptest: FAIL chown owned to 6000\n"); + return 1; + } + + /* --- real setgid(2): root may set its own gid to anything. --- */ + if (userSetgid(7000) != 0) { + print("grouptest: FAIL root could not setgid(7000)\n"); + return 1; + } + if (userGetgid() != 7000) { + print("grouptest: FAIL gid did not actually change to 7000\n"); + return 1; + } + print("grouptest: setgid-root-arbitrary-ok\n"); + + /* --- real, permanent privilege drop: root becoming a plain uid, gid + already set above -- both credentials independent, matching real + Unix's separate uid/gid drop order. --- */ + if (userSetuid(6000) != 0) { + print("grouptest: FAIL root could not setuid(6000)\n"); + return 1; + } + if (userGetuid() != 6000 || userGetgid() != 7000) { + print("grouptest: FAIL identity after drop is not (6000,7000)\n"); + return 1; + } + print("grouptest: drop-to-6000-7000-ok\n"); + + /* --- no way back: setgid is a one-way trapdoor too, same shape as + setuid's own cannot-regain-root-ok. --- */ + if (userSetgid(9999) != -1) { + print("grouptest: FAIL non-root regained arbitrary gid via setgid\n"); + return 1; + } + if (userGetgid() != 7000) { + print("grouptest: FAIL gid changed despite rejected setgid\n"); + return 1; + } + print("grouptest: setgid-nonroot-denied-ok\n"); + + if (userSetgid(7000) != 0) { + print("grouptest: FAIL setgid to own current gid was rejected\n"); + return 1; + } + print("grouptest: setgid-self-noop-ok\n"); + + /* --- the actual point of this fala: a caller that is NOT the owner + (uid 6000 vs 9000) but DOES share the file's group (7000) is + governed by the group bits, not other. groupOnly has ONLY + group-read set, so read succeeds and write fails. --- */ + int rfd = (int)userOpen("/tmp/groupd/groupOnly", oReadOnly); + if (rfd < 0) { + print("grouptest: FAIL group-matching read denied\n"); + return 1; + } + userClose(rfd); + print("grouptest: group-read-allowed-ok\n"); + + if (userOpen("/tmp/groupd/groupOnly", oWriteOnly) >= 0) { + print("grouptest: FAIL group-write succeeded without the bit\n"); + return 1; + } + print("grouptest: group-write-denied-ok\n"); + + /* --- a caller matching NEITHER uid (6000 vs 9000) NOR gid (7000 vs + 8000) must fall through to other, which mismatch also leaves + clear. --- */ + if (userOpen("/tmp/groupd/mismatch", oReadOnly) >= 0) { + print("grouptest: FAIL non-member read succeeded via wrong branch\n"); + return 1; + } + print("grouptest: other-fallback-denied-ok\n"); + + /* --- the same group model gates real exec, not just open(). --- */ + long pid = userSpawn("/tmp/groupd/progOk", 0); + if (pid < 0) { + print("grouptest: FAIL group-exec spawn denied\n"); + return 1; + } + if (userWait(pid) != 1) { + print("grouptest: FAIL group-exec spawned copy did not exit 1\n"); + return 1; + } + print("grouptest: group-exec-allowed-ok\n"); + + if (userSpawn("/tmp/groupd/progDenied", 0) >= 0) { + print("grouptest: FAIL group-exec spawn succeeded across groups\n"); + return 1; + } + print("grouptest: group-exec-denied-ok\n"); + + /* --- real chgrp(2) privilege: the owner (this process really does own + "owned" now, uid 6000) may set the group to its OWN current gid + (7000) -- but not to anything else, even though it owns the + file. --- */ + if (userChgrp("/tmp/groupd/owned", 7000) != 0) { + print("grouptest: FAIL owner chgrp to own gid denied\n"); + return 1; + } + if (userStat("/tmp/groupd/owned", &st) != 0 || st.gid != 7000) { + print("grouptest: FAIL owned gid not actually reassigned\n"); + return 1; + } + print("grouptest: chgrp-owner-self-gid-ok\n"); + + if (userChgrp("/tmp/groupd/owned", 8000) != -1) { + print("grouptest: FAIL owner chgrp to arbitrary gid succeeded\n"); + return 1; + } + if (userStat("/tmp/groupd/owned", &st) != 0 || st.gid != 7000) { + print("grouptest: FAIL owned gid changed by denied chgrp\n"); + return 1; + } + print("grouptest: chgrp-owner-arbitrary-denied-ok\n"); + + /* --- a non-owner, non-root caller can never chgrp at all, even to its + own gid -- mismatch is owned by 9000, this process is neither owner + nor root. --- */ + if (userChgrp("/tmp/groupd/mismatch", 7000) != -1) { + print("grouptest: FAIL non-owner non-root chgrp succeeded\n"); + return 1; + } + if (userStat("/tmp/groupd/mismatch", &st) != 0 || st.gid != 8000) { + print("grouptest: FAIL mismatch gid changed by denied chgrp\n"); + return 1; + } + print("grouptest: chgrp-nonowner-nonroot-denied-ok\n"); + + print("grouptest: PASS\n"); + return 0; +} diff --git a/user/hcat.c b/user/hcat.c new file mode 100644 index 0000000..bd3bbe0 --- /dev/null +++ b/user/hcat.c @@ -0,0 +1,57 @@ +#include "ulib.h" + +/* + * Left half of shell.c's new `cmd1 <<< TEXT | cmd2` here-string-into- + * pipeline live demo -- jobctl.c's new jobctlSpawnPipedHereString wires + * THIS process's fd 0 to a real pipe the shell itself writes the typed + * `<<<` text into, AND its fd 1 to the pipeline's shared pipe (right + * half's fd 0), both BEFORE this process ever runs a single instruction. + * From this program's own point of view there is nothing special about + * either fd: it is simply "cat" -- read stdin to real EOF, then write + * exactly those bytes to stdout -- with no idea either end is a pipe + * rather than a real file or console. Named "hcat" (not "cat") purely so + * the driver thread's typed line (binary name + `<<<` + text + `|` + + * binary name) stays clearly under keyboard.c's 32-byte + * consoleReaderBufferSize per line -- see kmain.c's FALA 31 lesson, + * documented right where stderrDriverThread's own path length once + * crossed that exact boundary. + * + * Diagnostics go to fd 2 (never fd 1) for the same reason pipesrc.c's own + * printErr does: fd 1 here is the real pipeline payload hsink.c compares + * byte-for-byte, and print()/printDec() (ulib.h) are both hardcoded to + * fd 1. + */ + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +int main(void) +{ + printErr("hcat: reading here-string stdin\n"); + + char buffer[64]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + printErr("hcat: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + + long written = userWrite(1, buffer, total); + if (written != (long)total) { + printErr("hcat: FAIL short/failed write\n"); + return 1; + } + + printErr("hcat: forwarded payload\n"); + return 0; +} diff --git a/user/herestest.c b/user/herestest.c new file mode 100644 index 0000000..1fe69de --- /dev/null +++ b/user/herestest.c @@ -0,0 +1,53 @@ +#include "ulib.h" + +/* + * Live ring-3 proof for shell.c's new `cmd <<< TEXT` here-string syntax + * (jobctl.c's jobctlSpawnForegroundHereString/jobctlSpawnHereStringPiped): + * from this program's own point of view fd 0 is just an ordinary blocking + * pipe read end, exactly like pipesink.c's -- it has no idea the other end + * was ever the SHELL ITSELF writing literal typed text into a pipe rather + * than another spawned process's stdout. Reads to real EOF, then compares + * byte-for-byte against the exact text kmain.c's hereDriverThread types + * after `<<<` plus the single trailing newline jobctlSpawnHereStringPiped + * always appends (matching a real shell's own `<<<` behavior of feeding + * the text as one full line, terminator included). + */ +#define hereStringPayload "hs-ok\n" + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("herestest: reading redirected stdin\n"); + + char buffer[32]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + print("herestest: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + buffer[total] = 0; + + print("herestest: got: "); + print(buffer); + + int ok = streq(buffer, hereStringPayload); + print(ok ? "herestest: payload-ok\n" : "herestest: payload-FAIL\n"); + + return ok ? 0 : 1; +} diff --git a/user/hsink.c b/user/hsink.c new file mode 100644 index 0000000..e3db481 --- /dev/null +++ b/user/hsink.c @@ -0,0 +1,55 @@ +#include "ulib.h" + +/* + * Right half of shell.c's new `cmd1 <<< TEXT | cmd2` here-string-into- + * pipeline live demo -- see hcat.c's own header comment for the full + * picture. jobctlSpawnPiped bakes the pipeline's shared pipe's read end + * into this process's own fd 0 exactly like pipesink.c's, so the blocking + * userRead below has to genuinely retry across timer ticks until hcat.c's + * own forwarded write actually lands. Compares the received bytes + * byte-for-byte against the exact text kmain.c's driver thread types after + * `<<<` plus the single trailing newline jobctlSpawnPipedHereString always + * appends (matching a real shell's own `<<<` behavior) -- unlike hcat.c, + * this process's own fd 1 (stdout) is NOT part of the pipeline, so + * print()/printDec() (ulib.h) reach the real console exactly like + * pipesink.c's own needle. + */ +#define hsinkPayload "hpipe-ok\n" + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("hsink: blocking on read, waiting for hcat\n"); + + char buffer[32]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + print("hsink: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + buffer[total] = 0; + + print("hsink: got: "); + print(buffer); + + int ok = streq(buffer, hsinkPayload); + print(ok ? "hsink: payload-ok\n" : "hsink: payload-FAIL\n"); + + return ok ? 0 : 1; +} diff --git a/user/identtest.c b/user/identtest.c new file mode 100644 index 0000000..429f529 --- /dev/null +++ b/user/identtest.c @@ -0,0 +1,251 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Real ring-3 proof of the uid credential (sched.h's Thread.uid, + * currentThreadUid/currentThreadSetUid) and the owner/other half of + * vfs.c's vfsPermitted: userGetuid/userSetuid (real getuid(2)/setuid(2)), + * a genuine, permanent, one-way privilege drop from root (uid 0) to a + * plain uid, and the resulting real EACCES-shaped refusals once this same + * process is no longer the owner of files it itself created moments + * earlier as root. Also proves real chown(2) (userChown, vfs.c's + * vfsChown, root-only per _POSIX_CHOWN_RESTRICTED) and the fix to a real, + * previously-missing bug: vfsChmod used to let ANY uid chmod ANY file's + * bits, since it never consulted ownership at all -- this is the live, + * ring-3 proof that a non-owner, non-root chmod is now genuinely refused, + * not just unit-tested against a hand-picked uid pair (coreTest.c's + * coreVfsOwnerOrRootPredicate can only exercise the pure predicate, since + * KTEST always runs as the permanent idle thread's own uid 0). Fully + * synchronous like permtest.c/rmtest.c/timetest.c: no injected keystrokes, + * no shared shell channel, so there is no scheduling-timing race to flake + * on regardless of host load. + */ + +static int copyFile(const char* srcPath, const char* dstPath) +{ + int src = (int)userOpen(srcPath, oReadOnly); + if (src < 0) + return -1; + int dst = (int)userOpen(dstPath, oReadWrite | oCreate | oTrunc); + if (dst < 0) { + userClose(src); + return -1; + } + + char buf[512]; + long got; + while ((got = userRead(src, buf, sizeof(buf))) > 0) { + if (userWrite(dst, buf, (unsigned long)got) != got) { + userClose(src); + userClose(dst); + return -1; + } + } + userClose(src); + userClose(dst); + return got < 0 ? -1 : 0; +} + +int main(void) +{ + print("identtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* --- boot-time default: every process starts as uid 0, and a freshly + created file is owned by whoever created it. --- */ + if (userGetuid() != 0) { + print("identtest: FAIL boot-time uid is not 0\n"); + return 1; + } + print("identtest: root-by-default-ok\n"); + + if (userMkdir("/tmp/identd") != 0) { + print("identtest: FAIL mkdir /tmp/identd\n"); + return 1; + } + int fd = (int)userOpen("/tmp/identd/secret.txt", oReadWrite | oCreate); + if (fd < 0) { + print("identtest: FAIL create secret.txt\n"); + return 1; + } + if (userWrite(fd, "root-only", 9) != 9) { + print("identtest: FAIL initial write\n"); + return 1; + } + userClose(fd); + + FbStat st; + if (userStat("/tmp/identd/secret.txt", &st) != 0 || st.uid != 0) { + print("identtest: FAIL fresh file not owned by uid 0\n"); + return 1; + } + print("identtest: owner-root-ok\n"); + + /* Owner-only rw, nothing for anyone else -- while still root, this + process is the OWNER, so the owner bit (still set) governs and + write access works normally through vfsPermitted's owner-match + branch, not through any special uid-0 treatment (there is none). */ + if (userChmod("/tmp/identd/secret.txt", 0600) != 0) { + print("identtest: FAIL chmod to owner-only\n"); + return 1; + } + int wfd = (int)userOpen("/tmp/identd/secret.txt", oWriteOnly); + if (wfd < 0) { + print("identtest: FAIL owner write denied while still owner\n"); + return 1; + } + userClose(wfd); + print("identtest: owner-write-ok-before-drop\n"); + + /* --- a copy of /bin/false, owned by root, chmod'd to owner-exec only + (no other-exec) -- spawnable now, while this process is still + uid 0 and therefore still the owner. --- */ + if (copyFile("/bin/false", "/tmp/identd/prog") != 0) { + print("identtest: FAIL copy /bin/false to prog\n"); + return 1; + } + if (userChmod("/tmp/identd/prog", 0100) != 0) { + print("identtest: FAIL chmod prog to owner-exec-only\n"); + return 1; + } + long pid = userSpawn("/tmp/identd/prog", 0); + if (pid < 0) { + print("identtest: FAIL spawn denied while still owner\n"); + return 1; + } + if (userWait(pid) != 1) { + print("identtest: FAIL spawned copy did not exit 1\n"); + return 1; + } + print("identtest: owner-exec-ok-before-drop\n"); + + /* --- real chown(2), root-only: a second file, still owned by root at + creation, handed to uid 1000 BEFORE this process ever drops its own + privilege. Kept separate from secret.txt (which stays root-owned + throughout) so the post-drop non-owner checks below have both a + file this process will NEVER own (secret.txt) and one it WILL + (owned.txt), proving the two different denial shapes distinctly. --- */ + int ofd = (int)userOpen("/tmp/identd/owned.txt", oReadWrite | oCreate); + if (ofd < 0) { + print("identtest: FAIL create owned.txt\n"); + return 1; + } + userClose(ofd); + if (userChown("/tmp/identd/owned.txt", 1000) != 0) { + print("identtest: FAIL root chown owned.txt to 1000\n"); + return 1; + } + if (userStat("/tmp/identd/owned.txt", &st) != 0 || st.uid != 1000) { + print("identtest: FAIL owned.txt uid not reassigned\n"); + return 1; + } + print("identtest: chown-root-ok\n"); + + /* --- real, permanent privilege drop: root becoming a plain uid. --- */ + if (userSetuid(1000) != 0) { + print("identtest: FAIL root could not setuid(1000)\n"); + return 1; + } + if (userGetuid() != 1000) { + print("identtest: FAIL uid did not actually change to 1000\n"); + return 1; + } + print("identtest: drop-to-1000-ok\n"); + + /* --- there is no way back: this is a one-way trapdoor, not a toggle. --- */ + if (userSetuid(0) != -1) { + print("identtest: FAIL non-root regained root via setuid\n"); + return 1; + } + if (userGetuid() != 1000) { + print("identtest: FAIL uid changed despite rejected setuid\n"); + return 1; + } + print("identtest: cannot-regain-root-ok\n"); + + /* --- setting your own current uid is always a no-op success, exactly + like real setuid(getuid()). --- */ + if (userSetuid(1000) != 0) { + print("identtest: FAIL setuid to own current uid was rejected\n"); + return 1; + } + print("identtest: setuid-self-noop-ok\n"); + + /* --- now a non-owner (uid 1000 against a uid-0-owned file): the + owner-only 0600 mode has no other-write bit, so this write must be + refused, even though this exact process wrote the same file + successfully moments ago as its owner. --- */ + if (userOpen("/tmp/identd/secret.txt", oWriteOnly) >= 0) { + print("identtest: FAIL write succeeded after dropping ownership\n"); + return 1; + } + print("identtest: write-denied-non-owner-ok\n"); + + /* --- same story for exec: owner-exec-only, no other-exec, so the + exact same binary this process spawned successfully as root now + refuses to spawn at all. --- */ + if (userSpawn("/tmp/identd/prog", 0) >= 0) { + print("identtest: FAIL spawn succeeded after dropping ownership\n"); + return 1; + } + print("identtest: exec-denied-non-owner-ok\n"); + + /* --- real, previously-missing enforcement: before this fala, chmod had + no ownership check at all, so ANY uid could chmod ANY file. Now that + vfsChmod refuses a non-owner, non-root caller, this must fail against + secret.txt (owned by root, this process is neither the owner nor + root anymore) -- and the mode must be left completely unchanged. --- */ + if (userChmod("/tmp/identd/secret.txt", 0666) != -1) { + print("identtest: FAIL non-owner chmod succeeded on secret.txt\n"); + return 1; + } + if (userStat("/tmp/identd/secret.txt", &st) != 0 || st.mode != 0600) { + print("identtest: FAIL secret.txt mode changed by denied chmod\n"); + return 1; + } + print("identtest: chmod-nonowner-denied-ok\n"); + + /* --- chown is root-only, full stop -- not even trying to chown a file + this process does not own is any different from trying on one it + does (see chown-owner-nonroot-denied-ok below): both are refused for + the exact same reason, non-root. secret.txt's uid must stay 0. --- */ + if (userChown("/tmp/identd/secret.txt", 1000) != -1) { + print("identtest: FAIL non-root chown succeeded on secret.txt\n"); + return 1; + } + if (userStat("/tmp/identd/secret.txt", &st) != 0 || st.uid != 0) { + print("identtest: FAIL secret.txt uid changed by denied chown\n"); + return 1; + } + print("identtest: chown-nonroot-denied-ok\n"); + + /* --- owned.txt was chown'd to 1000 above, so THIS process (now uid + 1000) really is its owner -- chmod does not require root, only + ownership, so this must succeed even after the privilege drop. --- */ + if (userChmod("/tmp/identd/owned.txt", 0644) != 0) { + print("identtest: FAIL owner chmod denied on owned.txt after drop\n"); + return 1; + } + if (userStat("/tmp/identd/owned.txt", &st) != 0 || st.mode != 0644) { + print("identtest: FAIL owned.txt mode not actually changed\n"); + return 1; + } + print("identtest: chmod-owner-after-drop-ok\n"); + + /* --- but chown is root-only even for your OWN file: real Unix's + _POSIX_CHOWN_RESTRICTED default means an owner cannot give a file + away either. owned.txt's uid must stay 1000. --- */ + if (userChown("/tmp/identd/owned.txt", 0) != -1) { + print("identtest: FAIL owner chown succeeded without being root\n"); + return 1; + } + if (userStat("/tmp/identd/owned.txt", &st) != 0 || st.uid != 1000) { + print("identtest: FAIL owned.txt uid changed by denied chown\n"); + return 1; + } + print("identtest: chown-owner-nonroot-denied-ok\n"); + + print("identtest: PASS\n"); + return 0; +} diff --git a/user/jobctlbg.c b/user/jobctlbg.c new file mode 100644 index 0000000..8233c0d --- /dev/null +++ b/user/jobctlbg.c @@ -0,0 +1,80 @@ +#include "ulib.h" + +/* + * Companion to jobctlfg.c: loaded the same way, on demand, when kmain.c's + * jobctlDriverThread types "jobctlbg &" at the real interactive shell. + * The trailing "&" (shell.c's runProgram) routes this through + * jobctlRunBackground (jobctl.c) instead of jobctlSpawnForeground -- own + * new process group leader exactly like jobctlfg.c, but the shell never + * calls keyboardSetForegroundPgid for it and never blocks waiting for it + * either, a real shell's `&` job semantics. This is the CONTROL side of + * the jobctlfg/jobctlbg pair: jobctlfg proves the shell's foreground + * grant actually reaches an injected Ctrl-C; this file proves that exact + * same event does NOT reach a job the shell deliberately left in the + * background -- if shell.c's `&` handling or jobctl.c's group-leader + * assignment were ever broken such that this process shared jobctlfg's + * group (or somehow ended up foreground itself), this process would + * already be Dead by the time jobctlDriverThread later tries to prove it + * alive, and its own needles below would simply never appear. + * + * Also exercises the actual sysGetpgid syscall boundary (unreachable from + * KTEST, which runs before schedStart() with no int 0x80 path at all) to + * confirm jobctl.c's threadSetPgid(pid, pid) call genuinely took effect + * from ring3's own point of view, not just internally. + */ + +static volatile int handlerFired = 0; + +static void onUsr1(int signum) +{ + (void)signum; + handlerFired = 1; + print("jobctlbg: still-alive-after-ctrlc-ok\n"); +} + +int main(void) +{ + long myPid = userGetpid(); + print("jobctlbg: start, pid="); + printDec(myPid); + print("\n"); + + long pgid = userGetpgid(0); + if (pgid != myPid) { + print("jobctlbg: FAIL expected to be its own group leader\n"); + return 1; + } + print("jobctlbg: own-process-group-ok\n"); + + if (userSigAction(sigUsr1, onUsr1, 0, 0) == (long)sigSetFailed) { + print("jobctlbg: FAIL install handler\n"); + return 1; + } + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("jobctlbg: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("jobctlbg: FAIL attach victim pipe\n"); + return 1; + } + + print("jobctlbg: ready, blocking on go pipe\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + if (!handlerFired) { + print("jobctlbg: FAIL read returned without being signaled\n"); + return 1; + } + + /* Reached only if the read was genuinely interrupted (errInterrupted, + no sigFlagRestart) by onUsr1 above, sent by kmain.c's own driver + thread well AFTER the Ctrl-C injected for jobctlfg -- proving this + backgrounded process was never touched by it. */ + print("jobctlbg: resumed-after-signal-ok\n"); + return 0; +} diff --git a/user/jobctlfg.c b/user/jobctlfg.c new file mode 100644 index 0000000..efae425 --- /dev/null +++ b/user/jobctlfg.c @@ -0,0 +1,54 @@ +#include "ulib.h" + +/* + * Never spawned directly by kmain.c the way every other boot demo in this + * kernel is -- it just sits in /bin like any other binary until kmain.c's + * jobctlDriverThread types "jobctlfg" at the REAL interactive shell + * (shell.c) through injected keystrokes (same "no automated way to drive a + * real PS/2 device under headless QEMU" workaround every other keyboard + * demo in this file already uses). shell.c's runProgram falls back to + * jobctlSpawnForeground (jobctl.c) for any word that isn't one of its + * built-ins, which is the first time this kernel has ever loaded a binary + * on demand from user input rather than eagerly at boot -- a real shell's + * $PATH lookup, not another pre-arranged spawnUser call. + * + * jobctlSpawnForeground makes this process its own new group leader and + * calls keyboardSetForegroundPgid on its behalf BEFORE the shell blocks + * waiting for it (jobctlWaitForeground) -- so this file's proof is exactly + * pgroupfg.c's own (an uncaught, real, injected Ctrl-C actually reaches + * "the foreground" and terminates it by default disposition), but now + * "the foreground" was granted by the SHELL's own runtime job-control + * decision instead of a single hardcoded kmain.c call. No handler + * installed for sigInt at all: the real-world default disposition + * (signal.c's sigDefaultIsIgnore does not list sigInt) is Terminate, + * exactly like a real uncaught Ctrl-C killing a foreground program. + */ + +int main(void) +{ + print("jobctlfg: start, pid="); + printDec(userGetpid()); + print("\n"); + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("jobctlfg: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("jobctlfg: FAIL attach victim pipe\n"); + return 1; + } + + print("jobctlfg: ready, blocking on go pipe\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + /* Only reached if userRead somehow returned on its own -- nothing ever + writes to the victim pipe, so the shell-granted foreground Ctrl-C + never arriving (or arriving but not actually terminating this + process) is the only way to get here. */ + print("jobctlfg: FAIL read returned without being interrupted\n"); + return 1; +} diff --git a/user/jobtarget.c b/user/jobtarget.c new file mode 100644 index 0000000..f344c3a --- /dev/null +++ b/user/jobtarget.c @@ -0,0 +1,75 @@ +#include "ulib.h" + +/* + * Only ever spawned by jobtest.c, with two shared pipe handles as argv[1] + * (the "ready" pipe, this process writes) and argv[2] (the "go" pipe, this + * process reads) -- decToString'd exactly like fifomaster.c's/sigtarget.c's + * children. Proves job control (signal.h's SIGSTOP/SIGCONT) works end to + * end against a thread that is genuinely Blocked mid-syscall, not just + * Runnable: this process parks in a real blocking userRead on the go pipe, + * jobtest.c stops it there with a real userSigSend(target, sigStop) (not a + * kernel-internal shortcut), writes the byte it is waiting for WHILE it is + * stopped, and only then sends sigCont -- the read can only ever complete + * through the exact restart-syscall retry loop sysRead already uses + * (syscall.c), which only runs again once threadContinue has moved this + * thread back to threadRunnable. + * + * No sleep/timing guess anywhere in this file or jobtest.c: every step is + * ordered by a real blocking read or a real userWait, the same + * happens-before discipline sigtarget.c's rendezvous pipe already + * established for a different race. + */ + +int main(int argc, char** argv) +{ + print("jobtarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 3) { + print("jobtarget: FAIL expected two argv (ready handle, go handle)\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long goHandle = parseDec(argv[2]); + + long goFd = userPipeAttach(goHandle, 0); + if (goFd < 0) { + print("jobtarget: FAIL attach go pipe as reader\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 1); + if (readyFd < 0) { + print("jobtarget: FAIL attach ready pipe as writer\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("jobtarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("jobtarget: ready, blocking on go pipe\n"); + + char goByte = 0; + /* Real blocking read: parks this thread (threadBlocked) until a byte is + actually available. jobtest.c raises SIGSTOP against this exact + blocking read, then writes the byte, then raises SIGCONT -- the read + below can only return once BOTH have happened, since sysRead's own + restart-syscall retry (syscall.c) never runs again while this thread + is threadStopped instead of threadRunnable. */ + if (userRead((int)goFd, &goByte, 1) != 1) { + print("jobtarget: FAIL read go byte\n"); + return 1; + } + userClose((int)goFd); + + if (goByte != 'g') { + print("jobtarget: FAIL wrong go byte\n"); + return 1; + } + print("jobtarget: received go\n"); + + return 55; +} diff --git a/user/jobtest.c b/user/jobtest.c new file mode 100644 index 0000000..433379f --- /dev/null +++ b/user/jobtest.c @@ -0,0 +1,131 @@ +#include "ulib.h" + +/* + * Boot-time driver for job control (signal.h's SIGSTOP/SIGCONT/SIGTSTP, + * sched.h's threadStopped): spawns jobtarget.c, waits for it to actually + * be parked in a real blocking userRead (not guessed via a sleep), stops it + * there with userSigSend(target, sigStop), writes the very byte it is + * blocked waiting for WHILE it is stopped, and only then sends sigCont -- + * proving the resume happens through the genuine restart-syscall retry + * path sysRead already uses (syscall.c), not some shortcut that would have + * worked even if threadStop/threadContinue did nothing at all. + * + * Two independent pipes, not one, exactly because this needs traffic in + * BOTH directions kept open across the whole exchange (sigtest.c's own + * single "ready" pipe is closed by both sides right after one byte, which + * is not enough here): jobtest attaches BOTH ends it owns (ready as + * reader, go as writer) before ever spawning jobtarget, for the same + * "attach first" reason sigtest.c documents at length -- whichever side + * would otherwise attach second risks the pipe's slot already being freed + * by pipeReleaseIfUnused if the other side raced through attach+use+close + * first. + */ + +int main(void) +{ + print("jobtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long readyHandle = userPipeCreate(); + long goHandle = userPipeCreate(); + if (readyHandle < 0 || goHandle < 0) { + print("jobtest: FAIL create pipes\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 0); + long goFd = userPipeAttach(goHandle, 1); + if (readyFd < 0 || goFd < 0) { + print("jobtest: FAIL attach pipes\n"); + return 1; + } + + char readyHandleStr[24]; + char goHandleStr[24]; + decToString(readyHandle, readyHandleStr); + decToString(goHandle, goHandleStr); + + char* targetArgv[] = { "/bin/jobtarget", readyHandleStr, goHandleStr, 0 }; + long target = userSpawn("/bin/jobtarget", targetArgv); + if (target < 0) { + print("jobtest: FAIL spawn jobtarget\n"); + return 1; + } + + /* sched.h's Thread.pgid: a RUNTIME userSpawn from a genuinely running, + non-idle parent (this process) must have jobtarget inherit THIS + process's own pgid, matching real fork()'s default before any + setpgid ever runs -- the opposite default from every boot-time + spawnUser call in kmain.c (each becomes its own new group leader, + sched.c's initialPgid), which schedTest.c's own KTEST coverage + cannot exercise at all (ktestRunAll always runs as the permanent + idle thread, the one case that can never be a real spawning parent + -- see that file's header comment). This is the only live ring-3 + proof of the inherit-from-parent half of initialPgid. */ + if (userGetpgid(target) != userGetpgid(0)) { + print("jobtest: FAIL jobtarget did not inherit parent pgid\n"); + return 1; + } + print("jobtest: child-inherits-parent-pgid-ok\n"); + + char readyByte; + /* Real blocking read: does not return until jobtarget has actually + attached the go pipe as a reader AND written its ready byte -- so by + the time this returns, jobtarget is either about to call, or already + inside, its own blocking read on the go pipe. */ + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("jobtest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("jobtest: jobtarget ready\n"); + + if (userSigSend(target, sigStop) != 0) { + print("jobtest: FAIL send sigStop to jobtarget\n"); + return 1; + } + print("jobtest: sent sigStop to jobtarget\n"); + + /* Writing the byte jobtarget is blocked waiting for WHILE it is + stopped, rather than after sigCont, is deliberate: it exercises the + realistic job-control shape (a stopped job's input keeps queuing up + behind it) and proves stop+cont do not lose or corrupt that pending + write. It is NOT, on its own, an airtight proof that sigStop above + did anything at all -- pipeWrite (pipe.c) calls schedWakeChannel + unconditionally on every successful write, so a jobtarget that was + merely left threadBlocked (sigStop silently broken/a no-op) would + ALSO be woken by this very write and finish on its own, without + needing sigCont below at all. That rigorous "would this still pass + if stop were a no-op" distinction is exactly what + schedTest.c's threadStopExcludesTargetFromUnrelatedWaitChannelWake + proves instead (fault-injection verified: a genuinely Stopped + thread's waitChannel is never matched by schedWakeChannel) -- what + THIS live demo proves is the piece that KTEST cannot: a real ring-3 + program blocked in a real syscall survives being stopped and + resumed through the real syscall retry path, end to end, without + crashing or wedging the system. */ + if (userWrite((int)goFd, "g", 1) != 1) { + print("jobtest: FAIL write go byte\n"); + return 1; + } + + if (userSigSend(target, sigCont) != 0) { + print("jobtest: FAIL send sigCont to jobtarget\n"); + return 1; + } + print("jobtest: sent sigCont to jobtarget\n"); + userClose((int)goFd); + + long status = userWait(target); + print("jobtest: jobtarget exit status="); + printDec(status); + print("\n"); + if (status != 55) { + print("jobtest: FAIL expected jobtarget exit status 55\n"); + return 1; + } + print("jobtest: job-stop-continue-ok\n"); + + return 0; +} diff --git a/user/killtest.c b/user/killtest.c new file mode 100644 index 0000000..f52c3ba --- /dev/null +++ b/user/killtest.c @@ -0,0 +1,68 @@ +#include "ulib.h" + +/* + * Exercises threadKill (sched.c) end to end through sysKill: spawns + * killvictim.c (which blocks forever in a real, syscall-level pipe read + * with no writer ever attaching), waits long enough for it to actually + * reach and park inside that read, then kills it and confirms userWait + * reports exactly the killed exit status (9) -- the only way this kernel + * can ever recover a process stuck in a genuine block, now that + * sysRead/sysWrite can really block (pipe.h/vfs.h, same wave as this). + */ + +int main(void) +{ + print("killtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userKill(userGetpid()) != -1) { + print("killtest: FAIL self-kill should be rejected\n"); + return 1; + } + print("killtest: self-kill rejected\n"); + + if (userKill(999999) != -1) { + print("killtest: FAIL kill of bogus pid should fail\n"); + return 1; + } + print("killtest: kill bad pid rejected\n"); + + long victim = userSpawn("/bin/killvictim", 0); + if (victim < 0) { + print("killtest: FAIL spawn killvictim\n"); + return 1; + } + + /* Give the victim time to actually attach and reach its blocking read + -- killing it before it ever calls userRead would still "work" (any + Runnable/Sleeping/Blocked thread is a valid target), but would not + prove the interesting case: tearing down a call already parked + mid-syscall. */ + userSleep(100); + + if (userKill(victim) != 0) { + print("killtest: FAIL kill victim\n"); + return 1; + } + print("killtest: killed victim\n"); + + long status = userWait(victim); + print("killtest: victim exit status="); + printDec(status); + print("\n"); + if (status != 9) { + print("killtest: FAIL expected exit status 9\n"); + return 1; + } + + /* Already dead and reaped (userWait above reaped it): killing it again + must fail cleanly, not resurrect a slot that has since been reused. */ + if (userKill(victim) != -1) { + print("killtest: FAIL kill of already-reaped pid should fail\n"); + return 1; + } + + print("killtest: kill-ok\n"); + return 0; +} diff --git a/user/killvictim.c b/user/killvictim.c new file mode 100644 index 0000000..1af5fc1 --- /dev/null +++ b/user/killvictim.c @@ -0,0 +1,38 @@ +#include "ulib.h" + +/* + * Only ever spawned by killtest.c. Attaches as the READ end of a shared + * pipe (userPipeCreate/userPipeAttach) that no writer will ever attach to, + * so the userRead call below can only ever return by being killed -- + * proving threadKill (sched.c) genuinely recovers a process that would + * otherwise be stuck forever now that sysRead can really block (see + * pipe.h/vfs.h's shared-pipe machinery, added the same wave as this). + */ + +int main(void) +{ + print("killvictim: start, pid="); + printDec(userGetpid()); + print("\n"); + + long handle = userPipeCreate(); + if (handle < 0) { + print("killvictim: FAIL pipe create\n"); + return 1; + } + + long fd = userPipeAttach(handle, 0); + if (fd < 0) { + print("killvictim: FAIL attach\n"); + return 1; + } + + print("killvictim: blocking on read forever\n"); + char buffer[4]; + userRead((int)fd, buffer, sizeof(buffer)); + + /* Only reached if userRead somehow returned on its own -- killtest.c + never writes to this pipe, so that would itself be a bug. */ + print("killvictim: FAIL read returned without being killed\n"); + return 1; +} diff --git a/user/linktest.c b/user/linktest.c new file mode 100644 index 0000000..b6f4b25 --- /dev/null +++ b/user/linktest.c @@ -0,0 +1,156 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Exercises the symlink/readlink/link syscalls (sysSymlink/sysReadlink/ + * sysLink -> vfs.c -> sfs.c, sfs.h already had the on-disk machinery from an + * earlier wave; this wave wires it up to ring 3) against the persistent SFS + * filesystem under "/mnt". Kernel-side symlink target strings are resolved + * relative to the SFS root (sfs.h), not to "/mnt" -- so a symlink created + * from userspace at "/mnt/linktest/link.txt" pointing at + * "/mnt/linktest/real.txt" must store the target as "/linktest/real.txt" + * (the "/mnt" prefix stripped, exactly what vfsSymlink hands to + * sfsCreateSymlink) for later resolution to actually find it. + */ + +static int bytesEqual(const char* a, const char* b, unsigned long n) +{ + for (unsigned long i = 0; i < n; i++) + if (a[i] != b[i]) + return 0; + return 1; +} + +int main(void) +{ + print("linktest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userMkdir("/mnt/linktest") != 0) { + print("linktest: FAIL mkdir /mnt/linktest\n"); + return 1; + } + + const char* payload = "frostbyte linktest payload\n"; + unsigned long payloadLen = stringLength(payload); + + int wfd = (int)userOpen("/mnt/linktest/real.txt", oReadWrite | oCreate); + if (wfd < 0) { + print("linktest: FAIL create real.txt\n"); + return 1; + } + if (userWrite(wfd, payload, payloadLen) != (long)payloadLen) { + print("linktest: FAIL write real.txt\n"); + return 1; + } + userClose(wfd); + + /* Symlink resolution through open()/read(): opening the link must + transparently reach real.txt's data. */ + if (userSymlink("/linktest/real.txt", "/mnt/linktest/link.txt") != 0) { + print("linktest: FAIL symlink create\n"); + return 1; + } + + int lfd = (int)userOpen("/mnt/linktest/link.txt", oReadOnly); + if (lfd < 0) { + print("linktest: FAIL open through symlink\n"); + return 1; + } + char back[64]; + long got = userRead(lfd, back, sizeof(back)); + userClose(lfd); + if (got != (long)payloadLen || !bytesEqual(back, payload, payloadLen)) { + print("linktest: FAIL read through symlink mismatch\n"); + return 1; + } + print("linktest: open/read transparently follows symlink ok\n"); + + /* stat() must also follow: size should match the target, not 0. */ + FbStat st; + if (userStat("/mnt/linktest/link.txt", &st) != 0 || st.size != payloadLen) { + print("linktest: FAIL stat through symlink\n"); + return 1; + } + print("linktest: stat follows symlink ok\n"); + + /* readlink() must NOT follow -- it reports the link's own target + string, not the target's contents. */ + char targetBuf[64]; + long targetLen = userReadlink("/mnt/linktest/link.txt", targetBuf, sizeof(targetBuf)); + const char* expectedTarget = "/linktest/real.txt"; + unsigned long expectedTargetLen = stringLength(expectedTarget); + if (targetLen != (long)expectedTargetLen || + !bytesEqual(targetBuf, expectedTarget, expectedTargetLen)) { + print("linktest: FAIL readlink target mismatch\n"); + return 1; + } + print("linktest: readlink reports target string ok\n"); + + /* readlink on a non-symlink must fail, not read garbage. */ + if (userReadlink("/mnt/linktest/real.txt", targetBuf, sizeof(targetBuf)) >= 0) { + print("linktest: FAIL readlink on non-symlink should fail\n"); + return 1; + } + print("linktest: readlink rejects non-symlink ok\n"); + + /* Hardlink: alias.txt shares real.txt's inode and data. */ + if (userLink("/mnt/linktest/real.txt", "/mnt/linktest/alias.txt") != 0) { + print("linktest: FAIL hardlink create\n"); + return 1; + } + int afd = (int)userOpen("/mnt/linktest/alias.txt", oReadOnly); + if (afd < 0) { + print("linktest: FAIL open alias.txt\n"); + return 1; + } + got = userRead(afd, back, sizeof(back)); + userClose(afd); + if (got != (long)payloadLen || !bytesEqual(back, payload, payloadLen)) { + print("linktest: FAIL alias.txt content mismatch\n"); + return 1; + } + print("linktest: hardlink shares data ok\n"); + + /* Directories cannot be hardlinked. */ + if (userLink("/mnt/linktest", "/mnt/linktest2") == 0) { + print("linktest: FAIL hardlinking a directory should be rejected\n"); + return 1; + } + print("linktest: hardlink rejects directory ok\n"); + + /* Removing the original must leave the hardlinked alias alive (link + count keeps the inode+data around). */ + if (userUnlink("/mnt/linktest/real.txt") != 0) { + print("linktest: FAIL unlink real.txt\n"); + return 1; + } + afd = (int)userOpen("/mnt/linktest/alias.txt", oReadOnly); + if (afd < 0) { + print("linktest: FAIL alias.txt gone after unlinking real.txt\n"); + return 1; + } + got = userRead(afd, back, sizeof(back)); + userClose(afd); + if (got != (long)payloadLen || !bytesEqual(back, payload, payloadLen)) { + print("linktest: FAIL alias.txt data lost after unlinking real.txt\n"); + return 1; + } + print("linktest: alias survives unlink of original ok\n"); + + /* Dangling symlink: creation succeeds (target is never validated), open + through it must fail cleanly instead of crashing. */ + if (userSymlink("/linktest/does-not-exist.txt", "/mnt/linktest/dangling.txt") != 0) { + print("linktest: FAIL dangling symlink create\n"); + return 1; + } + if (userOpen("/mnt/linktest/dangling.txt", oReadOnly) >= 0) { + print("linktest: FAIL open through dangling symlink should fail\n"); + return 1; + } + print("linktest: dangling symlink open fails cleanly ok\n"); + + print("linktest: done\n"); + return 0; +} diff --git a/user/masktarget.c b/user/masktarget.c new file mode 100644 index 0000000..bf6ab14 --- /dev/null +++ b/user/masktarget.c @@ -0,0 +1,182 @@ +#include "ulib.h" + +/* + * Only ever spawned by masktest.c, with three shared pipe handles as argv + * (ready/handlerReady/go, decToString'd -- the exact same convention + * nesttarget.c already established, and for the same reason: an extra + * pipe beyond the usual two is needed to announce "the handler has + * genuinely entered and is about to block" from INSIDE a signal handler, + * not main()). + * + * Proves the OPPOSITE half of nesttarget.c's story for real, ring-3 + * delivery (signal.h's per-signal sa_mask): unlike nesttarget's onUsr1 + * (mask 0, so sigUsr2 genuinely nests), THIS onUsr1 installs sa_mask = + * sigUsr2's bit, so sigUsr2 arriving while onUsr1 is active must stay + * pending, NOT nest -- sigTest.c's + * sigConsumePendingSaMaskDefersDifferentSignalUntilHandlerReturns already + * proves this deterministically by driving sigConsumePending/sigOnSigReturn + * directly; this is its live ring-3 counterpart, plus the one thing a pure + * logic test cannot show: sigUsr2, once deferred, is actually delivered + * the INSTANT onUsr1 returns -- riding the very same trap-return sysSigReturn + * itself takes (isr.c's isrDispatch calls deliverPendingSignals + * unconditionally right after syscallDispatch handles sysSigReturn), with + * no further syscall or scheduling needed to notice it. No sleep/timing + * guess anywhere: the three pipes make "handler installed", "handler + * entered, mask active, about to block", and "safe to unblock it" all real + * happens-before rendezvous, the same discipline nesttarget.c/jobtest.c + * already established for different races. + */ + +static volatile int handlerUsr1Entered = 0; +static volatile int handlerUsr2Fired = 0; +static volatile int stillMaskedCheckOk = 0; + +static long handlerReadyFd = -1; +static long goFd = -1; + +static void onUsr2(int signum) +{ + (void)signum; + /* Only ever expected to run once onUsr1 has already returned and + lifted its sa_mask's grip on sigUsr2 -- see the check inside onUsr1 + below for what catches it firing any earlier than that. */ + print("masktarget: usr2-handler-fired-after-unmask\n"); + handlerUsr2Fired = 1; +} + +static void onUsr1(int signum) +{ + (void)signum; + print("masktarget: usr1-handler-enter\n"); + handlerUsr1Entered = 1; + + /* Announces "I am about to block on goFd, with sigUsr2 now masked" -- + masktest.c will not send sigUsr2 until it reads this byte, so + sigUsr2 can never race ahead of onUsr1's sa_mask actually being in + effect. */ + if (userWrite((int)handlerReadyFd, "h", 1) != 1) { + print("masktarget: FAIL write handler-ready byte\n"); + return; + } + userClose((int)handlerReadyFd); + + char goByte = 0; + /* Real blocking read, issued BY THE HANDLER ITSELF, exactly like + nesttarget.c's own goFd read -- the moment it completes, control + returns to ring 3 through the same int 0x80 path any syscall does, + which isr.c's deliverPendingSignals always re-checks. Unlike + nesttarget.c, sigUsr2 is NOT deliverable here: onUsr1's own sa_mask + still covers it, so this return-to-ring3 must dismiss it and resume + onUsr1's own code, not enter a nested handler. */ + if (userRead((int)goFd, &goByte, 1) != 1) { + print("masktarget: FAIL read go byte inside handler\n"); + return; + } + userClose((int)goFd); + + /* The actual proof: if sigUsr2 had genuinely nested (mask ineffective), + onUsr2 would have already run and set this flag BEFORE control ever + reached here (a nested handler's own sigreturn is what lets THIS + read call's return value reach onUsr1's C code at all) -- so seeing + it still 0 here is not a guess, it is the direct consequence of + what deliverPendingSignals did (or did not do) at the read's own + return-to-ring3 point. */ + if (handlerUsr2Fired) { + print("masktarget: FAIL usr2 fired while masked by usr1's sa_mask\n"); + return; + } + stillMaskedCheckOk = 1; + print("masktarget: still-masked-after-read-ok\n"); + + /* Returning from here is sysSigReturn -- sigOnSigReturn restores + sigBlocked to what it was before onUsr1 (no sigUsr2 bit), and + isrDispatch's deliverPendingSignals immediately re-runs against the + just-restored context, in the SAME trap return, before this thread + ever resumes anywhere else -- sigUsr2, still pending since masktest + sent it, is delivered right here as its own top-level handler. */ +} + +int main(int argc, char** argv) +{ + print("masktarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 4) { + print("masktarget: FAIL expected three argv (ready/handlerReady/go handles)\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long handlerReadyHandle = parseDec(argv[2]); + long goHandle = parseDec(argv[3]); + + /* sigUsr1's sa_mask covers sigUsr2 -- this is the whole point of this + demo, the opposite of nesttarget.c's mask-0 onUsr1. sigUsr2 itself + has no mask (nothing is meant to interrupt IT here). sigUsr1's own + delivery always interrupts main()'s plain userSleep loop below + (never a retryable syscall), and sigUsr2 never interrupts a + retryable syscall of its own either (it fires on a sigreturn's own + trap-return, not mid a sysRead/sysWrite/sysWait retry), so sa_flags + is irrelevant for both. */ + if (userSigAction(sigUsr1, onUsr1, (1ull << sigUsr2), 0) == (long)sigSetFailed || + userSigAction(sigUsr2, onUsr2, 0, 0) == (long)sigSetFailed) { + print("masktarget: FAIL install handlers\n"); + return 1; + } + + /* Attach the pipes THIS process will use from inside a handler before + ever installing/receiving anything that could reach them -- same + "attach first" discipline sigtest.c/jobtest.c/nesttarget.c already + document at length, so neither slot can be reclaimed out from under + a late attach. */ + handlerReadyFd = userPipeAttach(handlerReadyHandle, 1); + goFd = userPipeAttach(goHandle, 0); + if (handlerReadyFd < 0 || goFd < 0) { + print("masktarget: FAIL attach handler-ready/go pipes\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 1); + if (readyFd < 0) { + print("masktarget: FAIL attach ready pipe\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("masktarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("masktarget: ready, waiting for sigUsr1\n"); + + /* Several short sleeps rather than one long one -- this kernel only + checks for a pending signal when the target thread actually returns + to ring 3 (isr.c's deliverPendingSignals), the same reasoning + nesttarget.c's own poll loop documents. Once onUsr1 fires, ALL the + real work (the deferred check, then the deferred-until-now onUsr2) + happens synchronously before it ever returns here. */ + for (int i = 0; i < 80 && !handlerUsr2Fired; i++) + userSleep(20); + + if (!handlerUsr1Entered) { + print("masktarget: FAIL usr1 handler never fired\n"); + return 1; + } + if (!stillMaskedCheckOk) { + print("masktarget: FAIL usr1 handler did not confirm usr2 stayed masked\n"); + return 1; + } + if (!handlerUsr2Fired) { + print("masktarget: FAIL usr2 handler never fired after usr1 returned\n"); + return 1; + } + print("masktarget: mask-defers-then-delivers-ok\n"); + + /* Reached only if BOTH sysSigReturn calls (onUsr1's own, then the + deferred onUsr2's) correctly restored their respective saved + contexts -- a corrupted pop at either depth would have crashed, + hung, or resumed the wrong saved trapframe instead of returning + here from main()'s own interrupted sleep. */ + print("masktarget: resumed-after-deferred-usr2-ok\n"); + return 78; +} diff --git a/user/masktest.c b/user/masktest.c new file mode 100644 index 0000000..a33d9ae --- /dev/null +++ b/user/masktest.c @@ -0,0 +1,115 @@ +#include "ulib.h" + +/* + * Boot-time driver for real, ring-3 sa_mask DEFERRAL of signal delivery + * (signal.h's per-signal sa_mask / Thread.sigHandlerMasks): spawns + * masktarget.c, and, through three real happens-before rendezvous pipes + * (never a sleep guess, the same discipline nesttest.c already + * established), sends it sigUsr1, waits for genuine confirmation that + * sigUsr1's handler has actually entered and masked sigUsr2, and ONLY THEN + * sends sigUsr2 -- proving sigUsr2 does NOT interrupt the already-active + * handler the way nesttest.c's own sigUsr2 deliberately does, but instead + * waits until sigUsr1's handler genuinely returns before ever running. + * + * userSigSend's pending-bit write happens synchronously, inside the syscall + * itself, before it ever returns to this process -- so by the time it + * returns 0 below, sigUsr2 is unconditionally pending against masktarget + * regardless of exactly what masktarget's own thread is doing at that + * instant. Only the goFd write that follows needs to be strictly ordered + * after it, which normal program order on THIS process already guarantees + * -- the same reasoning nesttest.c's own comment already gives for its + * mirror-image scenario. + */ + +int main(void) +{ + print("masktest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long readyHandle = userPipeCreate(); + long handlerReadyHandle = userPipeCreate(); + long goHandle = userPipeCreate(); + if (readyHandle < 0 || handlerReadyHandle < 0 || goHandle < 0) { + print("masktest: FAIL create pipes\n"); + return 1; + } + + /* Attach every end THIS process owns before masktarget even exists -- + same "attach first" discipline as jobtest.c/nesttest.c, so none of + these three slots can be reclaimed by a same-instant close on the + other side. */ + long readyFd = userPipeAttach(readyHandle, 0); + long handlerReadyFd = userPipeAttach(handlerReadyHandle, 0); + long goFd = userPipeAttach(goHandle, 1); + if (readyFd < 0 || handlerReadyFd < 0 || goFd < 0) { + print("masktest: FAIL attach pipes\n"); + return 1; + } + + char readyHandleStr[24]; + char handlerReadyHandleStr[24]; + char goHandleStr[24]; + decToString(readyHandle, readyHandleStr); + decToString(handlerReadyHandle, handlerReadyHandleStr); + decToString(goHandle, goHandleStr); + + char* targetArgv[] = { "/bin/masktarget", readyHandleStr, + handlerReadyHandleStr, goHandleStr, 0 }; + long target = userSpawn("/bin/masktarget", targetArgv); + if (target < 0) { + print("masktest: FAIL spawn masktarget\n"); + return 1; + } + + char readyByte; + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("masktest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("masktest: masktarget ready\n"); + + if (userSigSend(target, sigUsr1) != 0) { + print("masktest: FAIL send sigUsr1 to masktarget\n"); + return 1; + } + print("masktest: sent sigUsr1 to masktarget\n"); + + char handlerReadyByte; + /* Real blocking read: does not return until onUsr1 (masktarget.c) has + actually started running, installed its sa_mask's grip on sigUsr2, + AND is about to block on goFd -- the happens-before guarantee that + makes the deferral proof below meaningful instead of a coincidence + of timing. */ + if (userRead((int)handlerReadyFd, &handlerReadyByte, 1) != 1) { + print("masktest: FAIL read handler-ready byte\n"); + return 1; + } + userClose((int)handlerReadyFd); + print("masktest: masktarget usr1-handler-entered\n"); + + if (userSigSend(target, sigUsr2) != 0) { + print("masktest: FAIL send sigUsr2 to masktarget\n"); + return 1; + } + print("masktest: sent sigUsr2 to masktarget (should stay masked)\n"); + + if (userWrite((int)goFd, "g", 1) != 1) { + print("masktest: FAIL write go byte\n"); + return 1; + } + userClose((int)goFd); + + long status = userWait(target); + print("masktest: masktarget exit status="); + printDec(status); + print("\n"); + if (status != 78) { + print("masktest: FAIL expected masktarget exit status 78\n"); + return 1; + } + print("masktest: sa-mask-defer-then-deliver-ok\n"); + + return 0; +} diff --git a/user/mmapexecvictim.c b/user/mmapexecvictim.c new file mode 100644 index 0000000..f00663a --- /dev/null +++ b/user/mmapexecvictim.c @@ -0,0 +1,40 @@ +#include "ulib.h" + +/* + * Spawned by mmaptest.c to prove that a real jump into a mapping that never + * asked for mmapProtExec turns into a genuine SIGSEGV (isr.c's tryResolve- + * UserPageFault I/D-bit short-circuit, not an infinite fault loop and not + * a kernel-wide panic) rather than silently letting the CPU execute + * whatever bytes happen to sit there -- the exact NX enforcement gap + * fs.h's own mmapProtExec doc comment used to name as unimplemented. This + * process is expected to die, not return 0; its own final "FAIL" print/ + * return only runs if that protection was somehow NOT enforced. + */ + +typedef void (*victimFn)(void); + +int main(void) +{ + print("mmapexecvictim: start\n"); + + void* addr = userMmap(0, 4096, mmapProtRead | mmapProtWrite, mmapAnonymous, -1, 0); + if (addr == userMmapFailed) { + print("mmapexecvictim: FAIL mmap\n"); + return 1; + } + + /* A real `ret` instruction (0xC3) -- harmless if it ever actually runs, + since the point is that it must never get the chance to. */ + volatile unsigned char* code = (volatile unsigned char*)addr; + code[0] = 0xC3; + print("mmapexecvictim: write-ok\n"); + + /* PROT_READ|PROT_WRITE only -- no PROT_EXEC -- so this call's own + instruction fetch must be refused by the CPU's own NX bit and turn + into a real SIGSEGV delivered against THIS process alone. */ + victimFn fn = (victimFn)addr; + fn(); + + print("mmapexecvictim: FAIL exec should have segfaulted\n"); + return 1; +} diff --git a/user/mmapprotvictim.c b/user/mmapprotvictim.c new file mode 100644 index 0000000..6defdf2 --- /dev/null +++ b/user/mmapprotvictim.c @@ -0,0 +1,40 @@ +#include "ulib.h" + +/* + * Spawned by mmaptest.c to prove that a real write against a PROT_READ-only + * mapping turns into a genuine SIGSEGV (isr.c's #PF handler) rather than a + * kernel-wide panic -- this process is expected to die, not return 0. Its + * own final "FAIL" print/return only runs if that protection was somehow + * NOT enforced. + */ + +int main(void) +{ + print("mmapprotvictim: start\n"); + + int fd = userOpen("/etc/motd.txt", oReadOnly); + if (fd < 0) { + print("mmapprotvictim: FAIL open\n"); + return 1; + } + + void* addr = userMmap(0, 4096, mmapProtRead, 0, fd, 0); + if (addr == userMmapFailed) { + print("mmapprotvictim: FAIL mmap\n"); + return 1; + } + + volatile char* p = (volatile char*)addr; + if (p[0] != 'f') { + print("mmapprotvictim: FAIL unexpected read content\n"); + return 1; + } + print("mmapprotvictim: read-ok\n"); + + /* PROT_READ only -- this write must be refused by mmapFaultHandler and + turn into a real SIGSEGV delivered against THIS process alone. */ + p[0] = 'X'; + + print("mmapprotvictim: FAIL write should have segfaulted\n"); + return 1; +} diff --git a/user/mmaptest.c b/user/mmaptest.c new file mode 100644 index 0000000..74ae590 --- /dev/null +++ b/user/mmaptest.c @@ -0,0 +1,134 @@ +#include "ulib.h" + +/* + * Live ring-3 proof for mmap.c's real demand-paged mmap(2)/munmap(2), + * through the actual sysMmap/sysMunmap syscall path (not just mmapTest.c's + * own in-kernel KTEST coverage, which calls mmap.c's functions directly): + * anonymous demand-zero fill, a real write landing on that same page, a + * real file-backed mapping whose content matches the real file it names, + * (via mmapprotvictim.c) a real SIGSEGV -- not a kernel panic -- when a + * different process writes to a PROT_READ-only mapping, a real function + * call succeeding against a mapping that WAS granted PROT_EXEC, and (via + * mmapexecvictim.c) a real SIGSEGV when a different process jumps into a + * mapping that was NOT granted it -- the hardware NX enforcement fs.h's + * own mmapProtExec doc comment used to name as unimplemented. + */ + +#define mmapTestPageSize 4096 + +static int bytesEqual(const volatile unsigned char* a, const char* b, unsigned long n) +{ + for (unsigned long i = 0; i < n; i++) + if (a[i] != (unsigned char)b[i]) + return 0; + return 1; +} + +int main(void) +{ + print("mmaptest: start\n"); + + void* anon = userMmap(0, 2 * mmapTestPageSize, mmapProtRead | mmapProtWrite, mmapAnonymous, -1, 0); + if (anon == userMmapFailed) { + print("mmaptest: FAIL anon mmap\n"); + return 1; + } + volatile unsigned char* ap = (volatile unsigned char*)anon; + if (ap[0] != 0 || ap[mmapTestPageSize] != 0) { + print("mmaptest: FAIL anon page not zero-filled\n"); + return 1; + } + print("mmaptest: anon-zero-fill-ok\n"); + + ap[0] = 7; + ap[mmapTestPageSize] = 9; + if (ap[0] != 7 || ap[mmapTestPageSize] != 9) { + print("mmaptest: FAIL anon write/read mismatch\n"); + return 1; + } + print("mmaptest: anon-write-read-ok\n"); + + if (userMunmap(anon, 2 * mmapTestPageSize) != 0) { + print("mmaptest: FAIL munmap anon\n"); + return 1; + } + print("mmaptest: anon-munmap-ok\n"); + + int fd = userOpen("/etc/motd.txt", oReadOnly); + if (fd < 0) { + print("mmaptest: FAIL open motd\n"); + return 1; + } + + void* filed = userMmap(0, mmapTestPageSize, mmapProtRead, 0, fd, 0); + if (filed == userMmapFailed) { + print("mmaptest: FAIL file mmap\n"); + return 1; + } + if (!bytesEqual((const volatile unsigned char*)filed, "frostbyte ramdisk file\n", 23)) { + print("mmaptest: FAIL file-backed content mismatch\n"); + return 1; + } + print("mmaptest: file-map-content-ok\n"); + userClose(fd); + + long victim = userSpawn("/bin/mmapprotvictim", 0); + if (victim < 0) { + print("mmaptest: FAIL spawn mmapprotvictim\n"); + return 1; + } + long status = userWait(victim); + print("mmaptest: victim exit status="); + printDec(status); + print("\n"); + if (status != 128 + sigSegv) { + print("mmaptest: FAIL expected sigsegv exit status\n"); + return 1; + } + print("mmaptest: protection-sigsegv-ok\n"); + + /* A mapping that WAS granted mmapProtExec really is executable, not + just "not actively refused" -- a real function call through it, + not merely a read/write, is the only proof that means anything + given mmapexecvictim.c below proves the opposite direction. */ + void* execMem = userMmap(0, mmapTestPageSize, mmapProtRead | mmapProtWrite | mmapProtExec, + mmapAnonymous, -1, 0); + if (execMem == userMmapFailed) { + print("mmaptest: FAIL exec mmap\n"); + return 1; + } + /* x86_64: mov eax, 42; ret -- a real, self-contained function body. */ + unsigned char* code = (unsigned char*)execMem; + code[0] = 0xB8; + code[1] = 42; + code[2] = 0; + code[3] = 0; + code[4] = 0; + code[5] = 0xC3; + int (*fn)(void) = (int (*)(void))execMem; + int result = fn(); + if (result != 42) { + print("mmaptest: FAIL exec-allowed call returned wrong value\n"); + return 1; + } + print("mmaptest: exec-allowed-ok\n"); + userMunmap(execMem, mmapTestPageSize); + + long execVictim = userSpawn("/bin/mmapexecvictim", 0); + if (execVictim < 0) { + print("mmaptest: FAIL spawn mmapexecvictim\n"); + return 1; + } + long execStatus = userWait(execVictim); + print("mmaptest: exec victim exit status="); + printDec(execStatus); + print("\n"); + if (execStatus != 128 + sigSegv) { + print("mmaptest: FAIL expected sigsegv exit status for exec victim\n"); + return 1; + } + print("mmaptest: exec-denied-sigsegv-ok\n"); + + print("mmaptest: PASS\n"); + return 0; +} diff --git a/user/namedreader.c b/user/namedreader.c new file mode 100644 index 0000000..4319fbe --- /dev/null +++ b/user/namedreader.c @@ -0,0 +1,60 @@ +#include "ulib.h" + +/* + * Boot-spawned independently of namedwriter.c (see kmain.c's spawnUser + * calls) -- unlike fiforeader.c/fifowriter.c, which fifomaster.c spawns as + * its own children and hands a shared pipe.h handle to via argv, this + * process and namedwriter.c have no relationship at all beyond both being + * launched at boot: neither one's pid, argv, or fd table ever mentions the + * other. The only thing they share is agreeing on the path "/tmp/rendezvous" + * (mkfifo'd by kmain.c before either is spawned) -- proving vfsMkfifo/ + * vfsOpen (vfs.h) really do let two unrelated processes rendezvous purely + * through the filesystem namespace, the gap FALA 11 explicitly left open. + * Opens for read immediately, well before namedwriter.c's deliberate sleep + * elapses, so the blocking read() below has to genuinely park across many + * timer ticks and be woken later -- not just win a race against data that + * was already sitting in the pipe. + */ + +#define namedPipePath "/tmp/rendezvous" + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("namedreader: start, pid="); + printDec(userGetpid()); + print("\n"); + + long fd = userOpen(namedPipePath, oReadOnly); + if (fd < 0) { + print("namedreader: FAIL open\n"); + return 1; + } + + print("namedreader: opened, blocking on read\n"); + char buffer[64]; + long got = userRead((int)fd, buffer, sizeof(buffer) - 1); + if (got <= 0) { + print("namedreader: FAIL read\n"); + return 1; + } + buffer[got] = 0; + + print("namedreader: got: "); + print(buffer); + print("\n"); + + int ok = streq(buffer, "hello via named pipe"); + print(ok ? "namedreader: payload-ok\n" : "namedreader: payload-FAIL\n"); + + userClose((int)fd); + return ok ? 0 : 1; +} diff --git a/user/namedwriter.c b/user/namedwriter.c new file mode 100644 index 0000000..f9116ab --- /dev/null +++ b/user/namedwriter.c @@ -0,0 +1,42 @@ +#include "ulib.h" + +/* + * Sibling of namedreader.c -- see its header comment for the full picture. + * Sleeps well past when namedreader.c (spawned moments earlier, at boot) + * will already be parked in a blocking read() on the still-empty named + * pipe, so the write below has to actually wake it back up (schedWakeChannel + * via pipeWrite, pipe.c) instead of the reader simply finding data already + * waiting -- same proof pattern fifowriter.c uses for the handle-based + * shared pipe, now for a path-based one neither side had to be told about + * by a common parent. + */ + +#define namedPipePath "/tmp/rendezvous" +#define namedWriterDelayMs 200 + +static const char* payload = "hello via named pipe"; + +int main(void) +{ + print("namedwriter: start, pid="); + printDec(userGetpid()); + print("\n"); + + userSleep(namedWriterDelayMs); + + long fd = userOpen(namedPipePath, oWriteOnly); + if (fd < 0) { + print("namedwriter: FAIL open\n"); + return 1; + } + + long written = userWrite((int)fd, payload, stringLength(payload)); + if (written != (long)stringLength(payload)) { + print("namedwriter: FAIL short/failed write\n"); + return 1; + } + print("namedwriter: wrote payload\n"); + + userClose((int)fd); + return 0; +} diff --git a/user/nesttarget.c b/user/nesttarget.c new file mode 100644 index 0000000..6570ab1 --- /dev/null +++ b/user/nesttarget.c @@ -0,0 +1,153 @@ +#include "ulib.h" + +/* + * Only ever spawned by nesttest.c, with three shared pipe handles as argv + * (ready/handlerReady/go, decToString'd -- the same convention jobtest.c's + * two-pipe rendezvous already established, extended to three because this + * needs an extra one to announce "the handler has genuinely entered and is + * about to block" from INSIDE a signal handler, not main()). + * + * Proves real, ring-3 NESTED signal delivery end to end (signal.h's + * per-signal sa_mask / Thread.sigHandlerDepth): onUsr1 installs no sa_mask + * at all (mask 0), so sigUsr2 arriving while it is still active is not + * blocked by anything -- it genuinely interrupts onUsr1 with its own, + * separate trampoline entry, onUsr1 resuming afterwards exactly where the + * blocking read below left it. No sleep/timing guess: the three pipes make + * "handler installed", "handler entered and blocked", and "safe to unblock + * it" all real happens-before rendezvous, the same discipline sigtarget.c/ + * jobtest.c already established for different races. + */ + +static volatile int handlerUsr1Entered = 0; +static volatile int handlerUsr2FiredNested = 0; +static volatile int handlerUsr1Resumed = 0; + +static long handlerReadyFd = -1; +static long goFd = -1; + +static void onUsr2(int signum) +{ + (void)signum; + print("nesttarget: usr2-handler-fired-nested\n"); + handlerUsr2FiredNested = 1; +} + +static void onUsr1(int signum) +{ + (void)signum; + print("nesttarget: usr1-handler-enter\n"); + handlerUsr1Entered = 1; + + /* Announces "I am about to block on goFd" from INSIDE the handler -- + nesttest.c will not send sigUsr2 until it reads this byte, so sigUsr2 + can never race ahead of onUsr1 actually being active. */ + if (userWrite((int)handlerReadyFd, "h", 1) != 1) { + print("nesttarget: FAIL write handler-ready byte\n"); + return; + } + userClose((int)handlerReadyFd); + + char goByte = 0; + /* Real blocking read, issued BY THE HANDLER ITSELF: the moment it + completes, control returns to ring 3 through the exact same + int 0x80 path any syscall does, which isr.c's deliverPendingSignals + always re-checks -- if sigUsr2 is pending and unblocked by then (it + is: onUsr1's own sa_mask is empty), it is delivered as a genuinely + NESTED handler right here, before this function's next line ever + runs. */ + if (userRead((int)goFd, &goByte, 1) != 1) { + print("nesttarget: FAIL read go byte inside handler\n"); + return; + } + userClose((int)goFd); + + print("nesttarget: usr1-handler-resumed-after-nested-usr2\n"); + handlerUsr1Resumed = 1; +} + +int main(int argc, char** argv) +{ + print("nesttarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 4) { + print("nesttarget: FAIL expected three argv (ready/handlerReady/go handles)\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long handlerReadyHandle = parseDec(argv[2]); + long goHandle = parseDec(argv[3]); + + /* sigUsr1's sa_mask is 0 (no additional blocking) so sigUsr2 -- also + unmasked -- can genuinely nest inside it; sigUsr2 has no mask of its + own either, since nothing is meant to interrupt IT here. sigUsr1's + own delivery always interrupts main()'s plain userSleep loop below + (never a retryable syscall), so its sa_flags is irrelevant either + way. sigUsr2, though, genuinely interrupts onUsr1's OWN blocking + userRead(goFd) -- this test's whole point is that read transparently + resumes and returns the real byte once onUsr2 returns, so sigUsr2 + is registered WITH sigFlagRestart (signalnum.h); without it, that + read would return errInterrupted instead, which + user/restarttarget.c -- not this file -- is what actually exercises. */ + if (userSigAction(sigUsr1, onUsr1, 0, 0) == (long)sigSetFailed || + userSigAction(sigUsr2, onUsr2, 0, sigFlagRestart) == (long)sigSetFailed) { + print("nesttarget: FAIL install handlers\n"); + return 1; + } + + /* Attach the pipes THIS process will use from inside a handler before + ever installing/receiving anything that could reach them -- same + "attach first" discipline sigtest.c/jobtest.c document at length, so + neither slot can be reclaimed out from under a late attach. */ + handlerReadyFd = userPipeAttach(handlerReadyHandle, 1); + goFd = userPipeAttach(goHandle, 0); + if (handlerReadyFd < 0 || goFd < 0) { + print("nesttarget: FAIL attach handler-ready/go pipes\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 1); + if (readyFd < 0) { + print("nesttarget: FAIL attach ready pipe\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("nesttarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("nesttarget: ready, waiting for sigUsr1\n"); + + /* Several short sleeps rather than one long one -- this kernel only + checks for a pending signal when the target thread actually returns + to ring 3 (isr.c's deliverPendingSignals), the same reasoning + sigtarget.c's own poll loop documents. Once onUsr1 fires, ALL the + real work (including the nested onUsr2) happens synchronously inside + it before it ever returns here. */ + for (int i = 0; i < 80 && !handlerUsr1Resumed; i++) + userSleep(20); + + if (!handlerUsr1Entered) { + print("nesttarget: FAIL usr1 handler never fired\n"); + return 1; + } + if (!handlerUsr2FiredNested) { + print("nesttarget: FAIL usr2 handler never fired nested inside usr1\n"); + return 1; + } + if (!handlerUsr1Resumed) { + print("nesttarget: FAIL usr1 handler never resumed after nested usr2\n"); + return 1; + } + print("nesttarget: nested-delivery-ok\n"); + + /* Reached only if BOTH sysSigReturn calls (the nested usr2's and then + usr1's own) correctly restored their respective saved contexts -- + a corrupted pop at either depth would have crashed, hung, or + resumed the wrong one of the two saved trapframes instead of + returning here from onUsr1's own return. */ + print("nesttarget: resumed-after-both-handlers-ok\n"); + return 77; +} diff --git a/user/nesttest.c b/user/nesttest.c new file mode 100644 index 0000000..2920029 --- /dev/null +++ b/user/nesttest.c @@ -0,0 +1,112 @@ +#include "ulib.h" + +/* + * Boot-time driver for real, ring-3 NESTED signal delivery (signal.h's + * per-signal sa_mask / Thread.sigHandlerDepth stack): spawns nesttarget.c, + * and, through three real happens-before rendezvous pipes (never a sleep + * guess), sends it sigUsr1, waits for genuine confirmation that sigUsr1's + * handler has actually entered and blocked, and ONLY THEN sends sigUsr2 -- + * proving sigUsr2 interrupts an ALREADY-ACTIVE handler with a second one of + * its own, rather than either being dropped, deferred until sigUsr1 returns + * (which is what an empty-but-still-"one handler at a time" gate, the + * architecture this replaced, would have done), or racing ahead of sigUsr1 + * even starting. + * + * userSigSend's pending-bit write happens synchronously, inside the syscall + * itself, before it ever returns to this process -- so by the time it + * returns 0 below, sigUsr2 is unconditionally pending against nesttarget + * regardless of exactly what nesttarget's own thread is doing at that + * instant. Only the goFd write that follows needs to be strictly ordered + * after it, which normal program order on THIS process already guarantees. + */ + +int main(void) +{ + print("nesttest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long readyHandle = userPipeCreate(); + long handlerReadyHandle = userPipeCreate(); + long goHandle = userPipeCreate(); + if (readyHandle < 0 || handlerReadyHandle < 0 || goHandle < 0) { + print("nesttest: FAIL create pipes\n"); + return 1; + } + + /* Attach every end THIS process owns before nesttarget even exists -- + same "attach first" discipline as jobtest.c, so none of these three + slots can be reclaimed by a same-instant close on the other side. */ + long readyFd = userPipeAttach(readyHandle, 0); + long handlerReadyFd = userPipeAttach(handlerReadyHandle, 0); + long goFd = userPipeAttach(goHandle, 1); + if (readyFd < 0 || handlerReadyFd < 0 || goFd < 0) { + print("nesttest: FAIL attach pipes\n"); + return 1; + } + + char readyHandleStr[24]; + char handlerReadyHandleStr[24]; + char goHandleStr[24]; + decToString(readyHandle, readyHandleStr); + decToString(handlerReadyHandle, handlerReadyHandleStr); + decToString(goHandle, goHandleStr); + + char* targetArgv[] = { "/bin/nesttarget", readyHandleStr, + handlerReadyHandleStr, goHandleStr, 0 }; + long target = userSpawn("/bin/nesttarget", targetArgv); + if (target < 0) { + print("nesttest: FAIL spawn nesttarget\n"); + return 1; + } + + char readyByte; + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("nesttest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("nesttest: nesttarget ready\n"); + + if (userSigSend(target, sigUsr1) != 0) { + print("nesttest: FAIL send sigUsr1 to nesttarget\n"); + return 1; + } + print("nesttest: sent sigUsr1 to nesttarget\n"); + + char handlerReadyByte; + /* Real blocking read: does not return until onUsr1 (nesttarget.c) has + actually started running AND is about to block on goFd -- the + happens-before guarantee that makes the nested-delivery proof below + meaningful instead of a coincidence of timing. */ + if (userRead((int)handlerReadyFd, &handlerReadyByte, 1) != 1) { + print("nesttest: FAIL read handler-ready byte\n"); + return 1; + } + userClose((int)handlerReadyFd); + print("nesttest: nesttarget usr1-handler-entered\n"); + + if (userSigSend(target, sigUsr2) != 0) { + print("nesttest: FAIL send sigUsr2 to nesttarget\n"); + return 1; + } + print("nesttest: sent sigUsr2 to nesttarget\n"); + + if (userWrite((int)goFd, "g", 1) != 1) { + print("nesttest: FAIL write go byte\n"); + return 1; + } + userClose((int)goFd); + + long status = userWait(target); + print("nesttest: nesttarget exit status="); + printDec(status); + print("\n"); + if (status != 77) { + print("nesttest: FAIL expected nesttarget exit status 77\n"); + return 1; + } + print("nesttest: nested-signal-delivery-ok\n"); + + return 0; +} diff --git a/user/nicetarget.c b/user/nicetarget.c new file mode 100644 index 0000000..89894b3 --- /dev/null +++ b/user/nicetarget.c @@ -0,0 +1,35 @@ +#include "ulib.h" + +/* + * Only ever spawned by nicetest.c, with no argv at all -- same "*test / + * *target" shape cwdtest.c/cwdtarget.c already has. Sleeps immediately so + * nicetest.c gets a real window to renice it from the OUTSIDE while it is + * genuinely Sleeping, not just Runnable -- proving sysNice (syscall.c) + * reaches a blocked target, not only one that happens to be running right + * now. 4096 is an arbitrary but SHARED magic weight, agreed with + * nicetest.c's own renice call the same way herestest.c/the real shell + * agree on the literal string "hs-ok" -- no header constant ties the two + * together, the number is just repeated on both sides. + */ + +int main(void) +{ + print("nicetarget: start, pid="); + printDec(userGetpid()); + print("\n"); + print("nicetarget: ready, sleeping before priority self-check\n"); + + userSleep(200); + + long weight = userGetPriority(0); + print("nicetarget: own weight after sleep="); + printDec(weight); + print("\n"); + if (weight != 4096) { + print("nicetarget: FAIL expected reniced weight 4096\n"); + return 1; + } + print("nicetarget: reniced-while-sleeping-ok\n"); + + return 0; +} diff --git a/user/nicetest.c b/user/nicetest.c new file mode 100644 index 0000000..7018b04 --- /dev/null +++ b/user/nicetest.c @@ -0,0 +1,108 @@ +#include "ulib.h" + +/* + * Boot-time driver for sysNice/sysGetPriority (syscall.c), the first way + * for any real userspace process to reach sched.h's CFS-lite scheduling + * weight -- until this fala, threadSetPriority was reachable only from + * kmain.c itself, once, to set up the kernel-internal workerA/workerB + * "higher weight runs more often" demo (sched-priority-ok's own needle). + * That demo already proves the SCHEDULER honors weight; this one proves + * the SYSCALL PLUMBING that lets userspace change it does the right thing: + * pid==0 self-targeting, targeting a real separate process (including one + * that is genuinely Sleeping, not just Runnable), and every rejection path + * (bogus pid, zero weight, an already-reaped pid) sysKill's own + * killtest.c already established the shape for. + * + * Fully synchronous, like cwdtest.c/cwdtarget.c: every step is ordered by + * a real userSleep/userWait, never an injected keystroke or a pipe + * rendezvous, so unlike most of this kernel's other live demos this one + * has no scheduling-timing race to flake on at all. + */ + +int main(void) +{ + print("nicetest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userNice(999999, 2048) != -1) { + print("nicetest: FAIL nice on bogus pid should fail\n"); + return 1; + } + if (userGetPriority(999999) != -1) { + print("nicetest: FAIL getpriority on bogus pid should fail\n"); + return 1; + } + print("nicetest: bogus-pid-rejected-ok\n"); + + if (userNice(0, 0) != -1) { + print("nicetest: FAIL nice with zero weight should fail\n"); + return 1; + } + print("nicetest: zero-weight-rejected-ok\n"); + + long before = userGetPriority(0); + if (before <= 0) { + print("nicetest: FAIL getpriority(self) before renice\n"); + return 1; + } + if (userNice(0, before * 2) != 0) { + print("nicetest: FAIL nice(self) should succeed\n"); + return 1; + } + long after = userGetPriority(0); + if (after != before * 2) { + print("nicetest: FAIL self weight did not change as expected\n"); + return 1; + } + print("nicetest: self-renice-ok\n"); + + /* Restore the original weight before moving on -- nothing downstream + depends on it, just honest hygiene rather than leaving this process + double-weighted for the rest of its own run. */ + userNice(0, before); + + long target = userSpawn("/bin/nicetarget", 0); + if (target < 0) { + print("nicetest: FAIL spawn nicetarget\n"); + return 1; + } + + /* Give nicetarget time to actually reach its own sleep before touching + it -- same reasoning as killtest.c's own userSleep(100) before + killing its victim mid-syscall. */ + userSleep(100); + + if (userNice(target, 4096) != 0) { + print("nicetest: FAIL renice nicetarget while sleeping\n"); + return 1; + } + long targetWeight = userGetPriority(target); + if (targetWeight != 4096) { + print("nicetest: FAIL getpriority(nicetarget) after renice\n"); + return 1; + } + print("nicetest: other-renice-ok\n"); + + long status = userWait(target); + print("nicetest: nicetarget exit status="); + printDec(status); + print("\n"); + if (status != 0) { + print("nicetest: FAIL nicetarget did not exit cleanly\n"); + return 1; + } + + /* Already dead and reaped (userWait above reaped it): renicing it + again must fail cleanly, not resurrect a slot that has since been + reused -- same "kill an already-reaped pid" shape killtest.c's own + last check has. */ + if (userNice(target, 1024) != -1) { + print("nicetest: FAIL renice of already-reaped pid should fail\n"); + return 1; + } + print("nicetest: reaped-pid-rejected-ok\n"); + + print("nicetest: priority-syscalls-ok\n"); + return 0; +} diff --git a/user/nodefertest.c b/user/nodefertest.c new file mode 100644 index 0000000..6acdaee --- /dev/null +++ b/user/nodefertest.c @@ -0,0 +1,167 @@ +#include "ulib.h" + +/* + * Boot-time, single-process live demo of sigFlagNoDefer (signalnum.h / + * signal.c's sigConsumePending): unlike every other signal demo in this + * kernel (nesttest/masktest/jobtest/...), this needs no second process -- + * SA_NODEFER is entirely about whether a signal can interrupt ITS OWN + * still-active handler, a property one process can raise against itself + * and observe directly, with no inter-process rendezvous required at all. + * + * Two contrasting handlers, proven in the same run: + * + * onUsr1 is registered WITH sigFlagNoDefer. Its first invocation + * self-raises sigUsr1 again (userSigSend(selfPid, sigUsr1)) while still + * active. Since sigFlagNoDefer means sigUsr1's own bit was never added + * to sigBlocked, that re-raise is genuinely deliverable the instant the + * self-raise syscall itself returns to ring 3 (isr.c's + * deliverPendingSignals, unconditional on every trap return) -- entering + * a SECOND, NESTED instance of onUsr1 before the first one's own + * userSigSend call ever returns to it. The nested instance sees + * usr1EnterCount already 2 and does not re-raise again (bounded + * recursion), returns via the trampoline's automatic sysSigReturn, + * letting the outer instance resume exactly where its self-raise call + * left off and observe the count already at 2 -- proof the nesting + * already happened, not a race it merely hopes for. + * + * onUsr2 is registered WITHOUT the flag (plain default, like every other + * handler in this kernel before this session). Its first invocation + * self-raises sigUsr2 the same way, but sigUsr2's own bit IS in + * sigBlocked for as long as onUsr2 is active, so that re-raise's own + * syscall returns without anything nesting -- onUsr2 finishes its first + * invocation having observed no nested re-entry at all (the negative + * half of the proof), and only once it actually returns (sysSigReturn + * restoring sigBlocked without sigUsr2's self-block, isrDispatch + * immediately re-checking pending signals on that very trap return, + * exactly like masktarget.c's own deferred-delivery proof) does the + * deferred second raise finally arrive, as its own fresh TOP-LEVEL + * invocation, never nested. + * + * No sleep/timing guess anywhere: every self-raise's pending-bit write + * happens synchronously inside the syscall against this exact same + * thread, and delivery/deferral is entirely decided by sigBlocked at the + * next trap return, which happens deterministically at each userSigSend's + * or handler-return's own int 0x80 -- there is no window for either + * outcome to be a coincidence of scheduling. + */ + +static long selfPid = -1; + +static volatile int usr1EnterCount = 0; +static volatile int usr1NestedConfirmed = 0; + +static void onUsr1(int signum) +{ + (void)signum; + usr1EnterCount++; + + if (usr1EnterCount == 1) { + print("nodefertest: onUsr1 first entry, self-raising sigUsr1 (NODEFER)\n"); + if (userSigSend(selfPid, sigUsr1) != 0) { + print("nodefertest: FAIL self-raise sigUsr1\n"); + return; + } + /* If sigFlagNoDefer genuinely lifted the self-block, the nested + instance of THIS SAME function already ran to completion, in + full, during the userSigSend call above (its own return-to-ring3 + delivered it before ever coming back here) -- so usr1EnterCount + must already read 2 by the time control resumes at this exact + line, not merely "eventually". */ + if (usr1EnterCount >= 2) { + usr1NestedConfirmed = 1; + print("nodefertest: onUsr1 observed its own nested re-entry (nodefer-self-nest-ok)\n"); + } else { + print("nodefertest: FAIL onUsr1 did not nest into itself\n"); + } + } else { + print("nodefertest: onUsr1 nested entry (depth 2)\n"); + } +} + +static volatile int usr2EnterCount = 0; +static volatile int usr2WronglyNested = 0; + +static void onUsr2(int signum) +{ + (void)signum; + usr2EnterCount++; + + if (usr2EnterCount == 1) { + print("nodefertest: onUsr2 first entry, self-raising sigUsr2 (default defer)\n"); + if (userSigSend(selfPid, sigUsr2) != 0) { + print("nodefertest: FAIL self-raise sigUsr2\n"); + return; + } + /* Unlike onUsr1 above: sigUsr2's own bit IS self-blocked here (no + sigFlagNoDefer), so the re-raise above must NOT have nested -- + usr2EnterCount must still read exactly 1 at this exact line. */ + if (usr2EnterCount != 1) { + usr2WronglyNested = 1; + print("nodefertest: FAIL onUsr2 nested into itself without sigFlagNoDefer\n"); + } else { + print("nodefertest: onUsr2 confirmed no self-nest while deferred (still-deferred-ok)\n"); + } + } else { + print("nodefertest: onUsr2 second, TOP-LEVEL entry after first returned\n"); + } +} + +int main(void) +{ + print("nodefertest: start, pid="); + printDec(userGetpid()); + print("\n"); + selfPid = userGetpid(); + + if (userSigAction(sigUsr1, onUsr1, 0, sigFlagNoDefer) == (long)sigSetFailed || + userSigAction(sigUsr2, onUsr2, 0, 0) == (long)sigSetFailed) { + print("nodefertest: FAIL install handlers\n"); + return 1; + } + + /* This syscall's own return-to-ring3 is what delivers onUsr1's FIRST + invocation -- the nested second one (see onUsr1's own comment) + happens entirely inside that delivery, synchronously, before this + call ever returns to main() below. */ + if (userSigSend(selfPid, sigUsr1) != 0) { + print("nodefertest: FAIL send sigUsr1 to self\n"); + return 1; + } + + if (!usr1NestedConfirmed) { + print("nodefertest: FAIL usr1 nodefer self-nest never confirmed\n"); + return 1; + } + if (usr1EnterCount != 2) { + print("nodefertest: FAIL expected exactly 2 usr1 entries, got "); + printDec(usr1EnterCount); + print("\n"); + return 1; + } + print("nodefertest: nodefer-self-nest-verified-ok\n"); + + /* Same shape for onUsr2: this call's return delivers its first + invocation; the DEFERRED second one (no nesting) is delivered on + that first invocation's own sysSigReturn, still inside this single + userSigSend's worth of ring-3 return handling, before control ever + reaches back here. */ + if (userSigSend(selfPid, sigUsr2) != 0) { + print("nodefertest: FAIL send sigUsr2 to self\n"); + return 1; + } + + if (usr2WronglyNested) { + print("nodefertest: FAIL usr2 self-nested despite no sigFlagNoDefer\n"); + return 1; + } + if (usr2EnterCount != 2) { + print("nodefertest: FAIL expected exactly 2 usr2 entries (deferred, not nested), got "); + printDec(usr2EnterCount); + print("\n"); + return 1; + } + print("nodefertest: default-defer-no-self-nest-verified-ok\n"); + + print("nodefertest: sa-nodefer-contrast-ok\n"); + return 0; +} diff --git a/user/permtest.c b/user/permtest.c new file mode 100644 index 0000000..3642949 --- /dev/null +++ b/user/permtest.c @@ -0,0 +1,157 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Real ring-3 proof of chmod(2) + real owner rwx permission bits (fs.h's + * fsModeOwner*), enforced at the two real choke points this fala added: + * resolveOpen (vfs.c) refuses to open an existing file for writing without + * fsModeOwnerWrite, and jobctlSpawn/loadImageByPath (jobctl.c/syscall.c) + * refuse to spawn a file without fsModeOwnerExec. Fully synchronous like + * rmtest.c/timetest.c/nicetest.c: no injected keystrokes, no shared shell + * channel, so there is no scheduling-timing race to flake on regardless of + * host load. + */ + +static int copyFile(const char* srcPath, const char* dstPath) +{ + int src = (int)userOpen(srcPath, oReadOnly); + if (src < 0) + return -1; + int dst = (int)userOpen(dstPath, oReadWrite | oCreate | oTrunc); + if (dst < 0) { + userClose(src); + return -1; + } + + char buf[512]; + long got; + while ((got = userRead(src, buf, sizeof(buf))) > 0) { + if (userWrite(dst, buf, (unsigned long)got) != got) { + userClose(src); + userClose(dst); + return -1; + } + } + userClose(src); + userClose(dst); + return got < 0 ? -1 : 0; +} + +int main(void) +{ + print("permtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* --- write permission: a fresh tmpfs file starts at fsModeDefaultFile + (0644, owner-writable); chmod to 0444 makes a SUBSEQUENT open-for- + write fail while reads keep working, and chmod back restores write + access immediately -- real Unix only checks permission at open() + time, never re-checks an fd already held. --- */ + if (userMkdir("/tmp/permd") != 0) { + print("permtest: FAIL mkdir /tmp/permd\n"); + return 1; + } + + int fd = (int)userOpen("/tmp/permd/data.txt", oReadWrite | oCreate); + if (fd < 0) { + print("permtest: FAIL create data.txt\n"); + return 1; + } + if (userWrite(fd, "hi", 2) != 2) { + print("permtest: FAIL initial write\n"); + return 1; + } + userClose(fd); + + FbStat st; + if (userStat("/tmp/permd/data.txt", &st) != 0 || st.mode != 0644) { + print("permtest: FAIL default mode not 0644\n"); + return 1; + } + print("permtest: default-mode-ok\n"); + + if (userChmod("/tmp/permd/data.txt", 0444) != 0) { + print("permtest: FAIL chmod to read-only\n"); + return 1; + } + if (userStat("/tmp/permd/data.txt", &st) != 0 || st.mode != 0444) { + print("permtest: FAIL stat after chmod 0444\n"); + return 1; + } + if (userOpen("/tmp/permd/data.txt", oWriteOnly) >= 0) { + print("permtest: FAIL write-only open succeeded on a read-only file\n"); + return 1; + } + int rfd = (int)userOpen("/tmp/permd/data.txt", oReadOnly); + if (rfd < 0) { + print("permtest: FAIL read-only open failed on a readable file\n"); + return 1; + } + char rdbuf[4]; + if (userRead(rfd, rdbuf, 2) != 2) { + print("permtest: FAIL read from read-only file\n"); + return 1; + } + userClose(rfd); + print("permtest: write-denied-ok\n"); + + if (userChmod("/tmp/permd/data.txt", 0644) != 0) { + print("permtest: FAIL chmod back to 0644\n"); + return 1; + } + int wfd = (int)userOpen("/tmp/permd/data.txt", oWriteOnly | oAppend); + if (wfd < 0) { + print("permtest: FAIL write open failed after chmod restored write bit\n"); + return 1; + } + if (userWrite(wfd, "!", 1) != 1) { + print("permtest: FAIL write after chmod restored\n"); + return 1; + } + userClose(wfd); + print("permtest: write-restored-ok\n"); + + /* --- exec permission: copying an existing binary's bytes into a NEW + tmpfs file (no fork/exec, just plain read+write, like `cp` would) + never carries the exec bit over -- the copy starts at the same + fsModeDefaultFile every other fresh file gets, exactly like a real + shell script you just wrote needs `chmod +x` before `./it` works. + Reuses /bin/false (always exits 1) as the payload -- cheap, already + built, and its own fixed exit code doubles as proof the COPY, not + just some cached original, is what actually ran. --- */ + if (copyFile("/bin/false", "/tmp/permd/prog") != 0) { + print("permtest: FAIL copy /bin/false to prog\n"); + return 1; + } + if (userStat("/tmp/permd/prog", &st) != 0 || (st.mode & 0100) != 0) { + print("permtest: FAIL fresh copy already executable\n"); + return 1; + } + print("permtest: copy-not-executable-ok\n"); + + if (userSpawn("/tmp/permd/prog", 0) >= 0) { + print("permtest: FAIL spawn succeeded without exec bit\n"); + return 1; + } + print("permtest: exec-denied-ok\n"); + + if (userChmod("/tmp/permd/prog", 0755) != 0) { + print("permtest: FAIL chmod +x\n"); + return 1; + } + long pid = userSpawn("/tmp/permd/prog", 0); + if (pid < 0) { + print("permtest: FAIL spawn after chmod +x\n"); + return 1; + } + long status = userWait(pid); + if (status != 1) { + print("permtest: FAIL spawned copy did not exit 1\n"); + return 1; + } + print("permtest: exec-after-chmod-ok\n"); + + print("permtest: PASS\n"); + return 0; +} diff --git a/user/pgroupbg.c b/user/pgroupbg.c new file mode 100644 index 0000000..2374bc3 --- /dev/null +++ b/user/pgroupbg.c @@ -0,0 +1,90 @@ +#include "ulib.h" + +/* + * Spawned directly by kmain.c at boot, alongside pgroupfg.c. Deliberately + * NEVER told to keyboardSetForegroundPgid -- every boot-time spawnUser call + * becomes its own new process group leader by default (sched.c's + * initialPgid), so this process's pgid already differs from pgroupfg's + * without any extra call, the same way two independently-launched + * background jobs in a real shell would each get their own group. This is + * the CONTROL side of the pgroupfg/pgroupbg pair: pgroupfg proves the + * injected Ctrl-C reaches the foreground group and kills it; this file + * proves that exact same event does NOT reach a DIFFERENT group at all -- + * if keyboard.c's scoping were ever broken to deliver sigInt/sigTstp to + * every live thread (the bug this whole subsystem exists to prevent), this + * process would already be Dead by the time kmain.c's injector later tries + * to prove it alive, and every needle below would simply never appear. + * + * Along the way, exercises the actual syscall boundary (sysSetpgid/ + * sysGetpgid, syscall.c) that sched.c's own KTEST coverage cannot reach on + * its own (ktestRunAll runs before schedStart(), with no int 0x80 path at + * all -- see schedTest.c's file comment): userGetpgid(0) must already + * equal this process's own pid (new-group-leader default), and an + * explicit, real setpgid(0, 0) self-affirmation (the same defensive call a + * real shell places a background job's own PID with, even when it is + * already its own leader) must be a harmless no-op. + */ + +static volatile int handlerFired = 0; + +static void onUsr1(int signum) +{ + (void)signum; + handlerFired = 1; + print("pgroupbg: still-alive-after-ctrlc-ok\n"); +} + +int main(void) +{ + long myPid = userGetpid(); + print("pgroupbg: start, pid="); + printDec(myPid); + print("\n"); + + long pgid = userGetpgid(0); + if (pgid != myPid) { + print("pgroupbg: FAIL expected to be its own group leader\n"); + return 1; + } + if (userSetpgid(0, 0) != 0) { + print("pgroupbg: FAIL self setpgid(0,0) rejected\n"); + return 1; + } + if (userGetpgid(0) != myPid) { + print("pgroupbg: FAIL pgid changed after self-affirming setpgid\n"); + return 1; + } + print("pgroupbg: own-process-group-ok\n"); + + if (userSigAction(sigUsr1, onUsr1, 0, 0) == (long)sigSetFailed) { + print("pgroupbg: FAIL install handler\n"); + return 1; + } + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("pgroupbg: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("pgroupbg: FAIL attach victim pipe\n"); + return 1; + } + + print("pgroupbg: ready, blocking on go pipe\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + if (!handlerFired) { + print("pgroupbg: FAIL read returned without being signaled\n"); + return 1; + } + + /* Reached only if the read was genuinely interrupted (errInterrupted, + no sigFlagRestart) by onUsr1 above, itself only ever sent by + kmain.c's own injector thread well AFTER the Ctrl-C event -- proving + this process was never touched by it at all. */ + print("pgroupbg: resumed-after-signal-ok\n"); + return 0; +} diff --git a/user/pgroupfg.c b/user/pgroupfg.c new file mode 100644 index 0000000..2d07769 --- /dev/null +++ b/user/pgroupfg.c @@ -0,0 +1,57 @@ +#include "ulib.h" + +/* + * Spawned directly by kmain.c at boot, alongside pgroupbg.c -- the live + * ring-3 proof that sched.h's new Thread.pgid and keyboard.c's new real + * Ctrl-C actually scope a terminal-generated signal to the FOREGROUND + * process group, not to "every process reading a keyboard" or "one single + * hardcoded target". kmain.c makes this process's own pgid (its default, + * unchanged: every boot-time spawnUser call becomes its own new group + * leader, sched.c's initialPgid) the foreground via + * keyboardSetForegroundPgid right after spawning it. + * + * Installs NO handler for sigInt at all -- the real-world default + * disposition (signal.c's sigDefaultIsIgnore does not list sigInt) is + * Terminate, exactly like a real un-caught Ctrl-C killing a foreground + * program with no signal handling of its own. The read below blocks on a + * pipe no writer will ever attach to (the same "blocked forever" setup + * eintrtarget.c/killvictim.c/alarmdefault.c already use), so the only way + * this process ever leaves it is the kernel-injected Ctrl-C's early-wake + * (signal.c's sigWouldDeliver/sched.c's threadWakeForSignal, the same + * mechanism eintrtarget.c's own sigTerm already proves) actually reaching + * a genuinely Blocked thread and terminating it. If keyboard.c's group + * scoping were ever broken such that this process were somehow excluded + * from its own foreground group, this process would simply never return + * from the read below, hanging the whole boot gate to its 25s QEMU timeout + * instead of merely failing a check -- the same fault-injection-visible + * shape eintrtest.c's own file comment already establishes. + */ + +int main(void) +{ + print("pgroupfg: start, pid="); + printDec(userGetpid()); + print("\n"); + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("pgroupfg: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("pgroupfg: FAIL attach victim pipe\n"); + return 1; + } + + print("pgroupfg: ready, blocking on go pipe\n"); + char buffer[4]; + userRead((int)victimFd, buffer, sizeof(buffer)); + + /* Only reached if userRead somehow returned on its own -- nothing ever + writes to the victim pipe, so the foreground Ctrl-C never arriving + (or arriving but not actually terminating this process) is the only + way to get here. */ + print("pgroupfg: FAIL read returned without being interrupted\n"); + return 1; +} diff --git a/user/pidfdtest.c b/user/pidfdtest.c new file mode 100644 index 0000000..dc2083a --- /dev/null +++ b/user/pidfdtest.c @@ -0,0 +1,87 @@ +#include "ulib.h" + +/* + * Exercises pidfd.h end to end through the real syscall path + * (sysPidfdOpen/sysPidfdSend/sysPidfdWait) -- the fd-table-wrapped, + * generation-checked handle sched.c's Thread.generation and pidfd.c add + * as an ADDITIVE alternative to a raw pid, mirroring real Linux's own + * pidfd_open(2) (see pidfd.h's own file comment for the full design). + * Reuses killvictim.c (blocks forever in a real, syscall-level pipe read + * with no writer ever attaching, see killtest.c) as its target -- the + * same already-proven "genuinely stuck, only killable from outside" + * shape, no new victim binary needed. Fully synchronous like + * nicetest.c/date.c/timetest.c: no injected keystrokes, no shared shell + * channel, so there is no scheduling-timing race to flake on regardless + * of host load. + */ + +int main(void) +{ + print("pidfdtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userPidfdOpen(0) != -1) { + print("pidfdtest: FAIL pidfd on pid 0 should be rejected\n"); + return 1; + } + if (userPidfdOpen(999999) != -1) { + print("pidfdtest: FAIL pidfd on bogus pid should be rejected\n"); + return 1; + } + print("pidfdtest: bad-target rejected\n"); + + long victim = userSpawn("/bin/killvictim", 0); + if (victim < 0) { + print("pidfdtest: FAIL spawn killvictim\n"); + return 1; + } + + long pfd = userPidfdOpen(victim); + if (pfd < 0) { + print("pidfdtest: FAIL pidfd open\n"); + return 1; + } + print("pidfdtest: pidfd-open-ok\n"); + + /* Give the victim time to actually attach and reach its blocking read + -- same reasoning as killtest.c's own identical sleep. */ + userSleep(100); + + if (userPidfdSend(pfd, sigKill) != 0) { + print("pidfdtest: FAIL pidfd send sigKill\n"); + return 1; + } + print("pidfdtest: pidfd-send-ok\n"); + + long status = userPidfdWait(pfd); + print("pidfdtest: victim exit status="); + printDec(status); + print("\n"); + if (status != 9) { + print("pidfdtest: FAIL expected exit status 9\n"); + return 1; + } + print("pidfdtest: pidfd-wait-ok\n"); + + /* Already reaped by the wait above: a further send/wait on the SAME + handle must cleanly report the target as gone, not silently act on + whichever thread (if any) now occupies that exact numeric slot -- + the whole reason pidfd.h exists (see its own file comment) over + caching victim's raw pid and calling userSigSend(victim, ...)/ + userWait(victim) again, which killtest.c's own comment already + documents as unsafe once a slot is recycled. */ + if (userPidfdSend(pfd, sigUsr1) != -1) { + print("pidfdtest: FAIL send on reaped pidfd should fail\n"); + return 1; + } + if (userPidfdWait(pfd) != -1) { + print("pidfdtest: FAIL wait on reaped pidfd should fail\n"); + return 1; + } + print("pidfdtest: reaped-target-detected-ok\n"); + + userClose((int)pfd); + print("pidfdtest: PASS\n"); + return 0; +} diff --git a/user/pipecat.c b/user/pipecat.c new file mode 100644 index 0000000..b8339e6 --- /dev/null +++ b/user/pipecat.c @@ -0,0 +1,56 @@ +#include "ulib.h" + +/* + * Middle stage of shell.c's new N-stage pipeline live demo + * (`pipesrc | pipecat | pipesink`) -- the first process in this kernel with + * BOTH ends of two DIFFERENT real inter-process pipes baked into its fds at + * once (fd 0 from jobctlSpawnPiped's stdinHandle, fd 1 from its + * stdoutHandle). user/hcat.c already proved a piped stdout alongside a + * piped stdin, but that stdin was always a shell-fed here-string + * (jobctlSpawnPipedHereString), never a second real process-to-process + * pipe the way this one's is -- this program is the actual proof that + * shell.c's runPipelineStages can wire a genuine middle pipeline stage + * where NEITHER end is the console. From this program's own point of view + * there is nothing special about either fd: it is simply "cat" -- read + * stdin to real EOF, then write exactly those bytes to stdout -- with no + * idea either end is a pipe rather than a real file or console, the same + * shape as hcat.c's/pipesink.c's own read loops. + * + * Diagnostics go to fd 2 (never fd 1), same reason as every other filter in + * this pipeline family: fd 1 here is the real payload pipesink.c compares + * byte-for-byte, and print()/printDec() (ulib.h) are both hardcoded to fd 1. + */ + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +int main(void) +{ + printErr("pipecat: reading piped stdin\n"); + + char buffer[64]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + printErr("pipecat: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + + long written = userWrite(1, buffer, total); + if (written != (long)total) { + printErr("pipecat: FAIL short/failed write\n"); + return 1; + } + + printErr("pipecat: forwarded payload\n"); + return 0; +} diff --git a/user/pipesink.c b/user/pipesink.c new file mode 100644 index 0000000..af04442 --- /dev/null +++ b/user/pipesink.c @@ -0,0 +1,65 @@ +#include "ulib.h" + +/* + * Right half of shell.c's new `cmd1 | cmd2` pipeline syntax's live demo -- + * see pipesrc.c's own header comment for the full picture. jobctlSpawnPiped + * bakes the SAME pipe's read end into this process's own fd 0 before it + * ever runs, so the blocking userRead below has to genuinely retry across + * timer ticks (fd 0's pipe end is flagged fdFlagBlocking, see + * vfsPipeAttachInto) until pipesrc.c's own write actually lands, exactly + * the same real cross-process wake this kernel's every other pipe/fifo + * demo already proves (schedWakeChannel via pipeWrite, pipe.c) -- never a + * coincidence of scheduling order (jobctlSpawnPiped installs pipesrc.c's + * write end into the pipe's writer count the moment IT is created, well + * before it ever runs, so this process can never see a false "no writer, + * EOF" even if it is scheduled to run first). + * + * Reads in a loop until a real EOF (0 return, once pipesrc.c has both + * written and exited -- see that file's own comment on why exit matters + * here) rather than trusting a single userRead call to receive the whole + * payload in one shot, the honest way a real pipe reader has to work. + * + * Unlike pipesrc.c, this process's own fd 1 (stdout) is NOT part of the + * pipeline (only fd 0 was redirected) -- print()/printDec() (ulib.h) reach + * the real console exactly like any unpiped demo, so the needle below is + * exactly as observable to ci-test.sh as every other one. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("pipesink: blocking on read, waiting for pipesrc\n"); + + char buffer[64]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + print("pipesink: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + buffer[total] = 0; + + print("pipesink: got: "); + print(buffer); + print("\n"); + + int ok = streq(buffer, "pipeline-payload-ok"); + print(ok ? "pipesink: payload-ok\n" : "pipesink: payload-FAIL\n"); + + return ok ? 0 : 1; +} diff --git a/user/pipesrc.c b/user/pipesrc.c new file mode 100644 index 0000000..c14023c --- /dev/null +++ b/user/pipesrc.c @@ -0,0 +1,43 @@ +#include "ulib.h" + +/* + * Left half of shell.c's new `cmd1 | cmd2` pipeline syntax's live demo. + * jobctlSpawnPiped (jobctl.h) bakes a real pipe end into this process's own + * fd 1 BEFORE it ever runs a single instruction (vfsPipeAttachInto, vfs.h) + * -- from this program's own point of view it writes its stdout exactly + * like any well-behaved Unix filter, with no idea a pipe is on the other + * end instead of the real console. fd 2 (stderr) is deliberately left + * alone by the pipeline setup (only fd 0/fd 1 are ever redirected, matching + * a real shell), so the diagnostic lines below go straight to the real + * console even though fd 1's own payload does not -- writing them through + * fd 1 instead (print()/printDec(), ulib.h, both hardcoded to fd 1) would + * corrupt the exact payload pipesink.c compares against. + * + * Deliberately writes once and exits immediately: pipesink.c's own + * blocking read has to notice the resulting EOF (this process's own exit + * reclaims its fd 1, dropping the pipe's writer count to 0 -- see + * sched.c's reclaimOpenFds) the same way every other pipe/fifo EOF demo in + * this kernel already does, not just receive the payload. + */ + +#define pipelinePayload "pipeline-payload-ok" + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +int main(void) +{ + printErr("pipesrc: writing payload to stdout (pipe)\n"); + + unsigned long length = stringLength(pipelinePayload); + long written = userWrite(1, pipelinePayload, length); + if (written != (long)length) { + printErr("pipesrc: FAIL short/failed write\n"); + return 1; + } + + printErr("pipesrc: wrote payload\n"); + return 0; +} diff --git a/user/pipetest.c b/user/pipetest.c new file mode 100644 index 0000000..f2580d5 --- /dev/null +++ b/user/pipetest.c @@ -0,0 +1,79 @@ +#include "ulib.h" + +static void printLong(const char* label, long value) +{ + print(label); + printDec(value); + print("\n"); +} + +int main(void) +{ + print("pipetest: start, pid="); + printDec(userGetpid()); + print("\n"); + + int fds[2]; + long rc = userPipe(fds); + printLong("pipetest: userPipe rc=", rc); + if (rc < 0) { + print("pipetest: pipe() failed\n"); + return 1; + } + int readFd = fds[0]; + int writeFd = fds[1]; + + /* nothing written yet: a read must report "would block" (-1), not EOF */ + char probe[4]; + long earlyRead = userRead(readFd, probe, sizeof(probe)); + printLong("pipetest: read before write rc=", earlyRead); + if (earlyRead != -1) { + print("pipetest: FAIL expected -1 on empty open pipe\n"); + return 1; + } + + const char* payload = "hello through the pipe\n"; + long written = userWrite(writeFd, payload, stringLength(payload)); + printLong("pipetest: wrote bytes=", written); + if (written != (long)stringLength(payload)) { + print("pipetest: FAIL short write\n"); + return 1; + } + + char buffer[64]; + long got = userRead(readFd, buffer, sizeof(buffer) - 1); + if (got < 0) { + print("pipetest: FAIL read after write blocked\n"); + return 1; + } + buffer[got] = 0; + print("pipetest: read back: "); + print(buffer); + + userClose(writeFd); + + /* buffer is now empty and the write end is closed: true EOF (0) */ + long afterClose = userRead(readFd, probe, sizeof(probe)); + printLong("pipetest: read after writer closed rc=", afterClose); + if (afterClose != 0) { + print("pipetest: FAIL expected EOF (0) after writer closed\n"); + return 1; + } + + userClose(readFd); + + /* a second independent pipe proves broken-pipe (write with no reader) */ + int fds2[2]; + userPipe(fds2); + userClose(fds2[0]); + long brokenWrite = userWrite(fds2[1], "x", 1); + printLong("pipetest: write after reader closed rc=", brokenWrite); + if (brokenWrite != -1) { + print("pipetest: FAIL expected broken pipe (-1)\n"); + return 1; + } + userClose(fds2[1]); + + print("pipetest: done\n"); + return 0; +} diff --git a/user/polltest.c b/user/polltest.c new file mode 100644 index 0000000..77348da --- /dev/null +++ b/user/polltest.c @@ -0,0 +1,152 @@ +#include "ulib.h" + +/* + * Boot-time driver for real poll(2) (poll.h's PollFd, syscall.c's sysPoll, + * sched.c's schedPollChannel + schedule()'s own pollDeadline expiry scan): + * waits for readiness across MORE THAN ONE fd at once, something no + * earlier demo in this kernel does -- every previous blocking read/write + * always committed ahead of time to exactly one fd (vfsBlockingChannel, + * vfs.h). Three phases: + * + * phase 1: a non-blocking poll (timeoutMs 0) on a shared pipe's read end + * nobody has written to yet -- must report zero ready fds WITHOUT ever + * parking the calling thread, proven simply by returning at all (a real + * accidental-park bug here would hang the whole boot to ci-test.sh's 25s + * QEMU timeout instead of merely failing a check, the same + * fault-injection-visible shape every other demo in this kernel already + * has). + * + * phase 2: a REAL, bounded-timeout poll on that same still-empty fd -- + * proves schedule()'s pollDeadline expiry scan actually forces a wake + * when no fd ever becomes ready, not just phase 1's immediate-return + * fast path. + * + * phase 3: an INFINITE-timeout (-1) poll across TWO fds at once (the + * pipe pollwriter.c will write to, and a SECOND, entirely unrelated pipe + * nobody ever writes -- deliberately not fd 0/console: every open + * reader, including this process's own fd 0, gets a copy of every + * keystroke keyboardHandleScancode ever broadcasts, keyboard.h, and this + * same boot image has other demos genuinely typing at the shell + * concurrently, so fd 0 could not be trusted to stay silent for the + * whole wait) while a freshly spawned child (pollwriter.c) sleeps + * briefly then writes to the first pipe -- proves the real cross-process + * wake path (pipeWrite -> schedWakeChannel(schedPollChannel()) -> this + * thread's retried sysPoll re-checking every fd from scratch) AND that + * only the written pipe's own PollFd entry gets revents set, never the + * untouched second one's, i.e. the multiplexer is genuinely selective, + * not just "anything happened, assume the first entry". + */ + +#define pollTimeoutMs 200 + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("polltest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long handle = userPipeCreate(); + if (handle < 0) { + print("polltest: FAIL pipe create\n"); + return 1; + } + long readFd = userPipeAttach(handle, 0); + if (readFd < 0) { + print("polltest: FAIL attach read end\n"); + return 1; + } + + /* Never written to by anyone -- the second phase-3 fd that must stay + not-ready for the whole wait. A private, unshared userPipe() end + (rather than a second userPipeCreate/userPipeAttach handle) is + enough since nothing else ever touches it. */ + int idleFds[2]; + if (userPipe(idleFds) != 0) { + print("polltest: FAIL idle pipe create\n"); + return 1; + } + + /* Spawned immediately, like fifomaster.c spawns its own two children, + rather than after phase 1/2 do their own work below -- every other + boot-time demo's runtime spawn happens right at its own start, and + pollwriter's own delay (pollWriterDelayMs, pollwriter.c) is already + comfortably longer than phase 2's bounded-timeout wait below, so + phase 3 still genuinely blocks waiting for it either way. */ + char handleStr[24]; + decToString(handle, handleStr); + char* writerArgv[] = { "/bin/pollwriter", handleStr, 0 }; + long writerPid = userSpawn("/bin/pollwriter", writerArgv); + if (writerPid < 0) { + print("polltest: FAIL spawn pollwriter\n"); + return 1; + } + + PollFd single; + single.fd = (int)readFd; + single.events = pollIn; + single.revents = 12345; + long n = userPoll(&single, 1, 0); + if (n != 0 || single.revents != 0) { + print("polltest: FAIL non-blocking poll on empty pipe\n"); + return 1; + } + print("polltest: non-blocking-poll-empty-ok\n"); + + single.revents = 12345; + n = userPoll(&single, 1, pollTimeoutMs); + if (n != 0 || single.revents != 0) { + print("polltest: FAIL bounded-timeout poll returned early or ready\n"); + return 1; + } + print("polltest: poll-timeout-ok\n"); + + PollFd pfds[2]; + pfds[0].fd = (int)readFd; + pfds[0].events = pollIn; + pfds[0].revents = 0; + pfds[1].fd = idleFds[0]; + pfds[1].events = pollIn; + pfds[1].revents = 0; + + print("polltest: blocking on multi-fd poll, waiting for pollwriter\n"); + n = userPoll(pfds, 2, -1); + if (n != 1 || !(pfds[0].revents & pollIn) || pfds[1].revents != 0) { + print("polltest: FAIL multi-fd poll woke wrong or with wrong revents\n"); + return 1; + } + print("polltest: poll-woken-by-write-ok\n"); + + char buffer[32]; + long got = userRead((int)readFd, buffer, sizeof(buffer) - 1); + if (got <= 0) { + print("polltest: FAIL read after poll-ready\n"); + return 1; + } + buffer[got] = 0; + + int ok = streq(buffer, "poll payload"); + print(ok ? "polltest: payload-ok\n" : "polltest: payload-FAIL\n"); + if (!ok) + return 1; + + long writerStatus = userWait(writerPid); + if (writerStatus != 0) { + print("polltest: FAIL pollwriter reported failure\n"); + return 1; + } + + userClose((int)readFd); + userClose(idleFds[0]); + userClose(idleFds[1]); + print("polltest: poll-multiplex-ok\n"); + return 0; +} diff --git a/user/pollwriter.c b/user/pollwriter.c new file mode 100644 index 0000000..721e859 --- /dev/null +++ b/user/pollwriter.c @@ -0,0 +1,51 @@ +#include "ulib.h" + +/* + * Only ever spawned by polltest.c, with the shared pipe's handle number as + * argv[1] (decToString'd -- see polltest.c, mirrors fifomaster.c's own + * handle-passing convention). Sleeps well past when polltest.c will + * already be parked inside its own userPoll(..., -1) call, so the write + * below has to actually wake it back up (schedWakeChannel(schedPollChannel()), + * pipeWrite in pipe.c) instead of polltest simply finding the pipe already + * readable -- proof the block was real, not a lucky race, the same shape + * fifowriter.c already proves for a plain blocking read(). + */ + +/* Comfortably longer than polltest.c's own phase-2 bounded-timeout poll + (pollTimeoutMs, polltest.c) -- pollwriter is spawned immediately, at the + very start of polltest.c (mirroring fifomaster.c's own immediate double- + spawn), so by the time polltest reaches its phase-3 blocking multi-fd + poll, phase 2's own wait has already used up part of this delay; the + margin here guarantees phase 3 still genuinely has to block and be woken + by the write below, not race a write that already happened. */ +#define pollWriterDelayMs 500 + +static const char* payload = "poll payload"; + +int main(int argc, char** argv) +{ + if (argc != 2) { + print("pollwriter: FAIL expected one argv (pipe handle)\n"); + return 1; + } + + long handle = parseDec(argv[1]); + long fd = userPipeAttach(handle, 1); + if (fd < 0) { + print("pollwriter: FAIL attach\n"); + return 1; + } + + print("pollwriter: attached, sleeping before write\n"); + userSleep(pollWriterDelayMs); + + long written = userWrite((int)fd, payload, stringLength(payload)); + if (written != (long)stringLength(payload)) { + print("pollwriter: FAIL short/failed write\n"); + return 1; + } + print("pollwriter: wrote payload\n"); + + userClose((int)fd); + return 0; +} diff --git a/user/proctest.c b/user/proctest.c new file mode 100644 index 0000000..edfb169 --- /dev/null +++ b/user/proctest.c @@ -0,0 +1,83 @@ +#include "ulib.h" + +/* + * Exercises the process-management syscalls added this wave: sysSpawn + * (posix_spawn()-lite -- loads a binary into a brand new thread) and + * sysWait (threadJoin, sched.c/sched.h) together, then sysExec (execve()- + * lite -- replaces this very process's image in place, see syscall.c + * doExec). Split across two helper binaries deliberately: spawntarget.c is + * only ever spawned (never exec'd), exectarget.c is only ever exec'd + * (never spawned) -- frostbyte has one shared address space system-wide + * (paging.c has a single page table, no per-process CR3), so two binaries + * whose fixed link addresses collide can never be genuinely live at the + * same time. Keeping "the spawn target" and "the exec target" as distinct + * binaries, and waiting for the spawned child before ever calling exec, + * means this test never asks the kernel to do something the architecture + * cannot support. + * + * This function never returns on its own success path: the final call is + * userExec("/bin/exectarget"), which -- if the syscall does its job -- + * replaces this thread's own code/stack/pid identity with exectarget's, so + * "proctest" never gets to print anything past that call. kmain.c's + * execReaperThread separately threadJoin()s this same pid and checks it + * eventually reports exectarget's exit code, which is the only way to + * observe that the exec() actually happened from outside this process. + */ + +int main(void) +{ + print("proctest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long child = userSpawn("/bin/spawntarget", 0); + if (child < 0) { + print("proctest: FAIL spawn /bin/spawntarget\n"); + return 1; + } + + long status = userWait(child); + if (status != 3) { + print("proctest: FAIL wait returned unexpected status\n"); + return 1; + } + print("proctest: spawn+wait ok\n"); + + if (userSpawn("/bin/does-not-exist", 0) != -1) { + print("proctest: FAIL spawn of missing binary should fail\n"); + return 1; + } + print("proctest: spawn bad path rejected\n"); + + if (userWait(999999) != -1) { + print("proctest: FAIL wait on bogus pid should fail immediately\n"); + return 1; + } + print("proctest: wait bad pid rejected\n"); + + /* argtest.c checks its own argc/argv against exactly this list -- + proves the whole chain (copyUserArgv copying these out of THIS + process's memory, elfBuildInitialStack laying them onto the child's + brand new stack, crt.S's _start reading them back off of it) rather + than just that spawn/wait still works with a null argv. */ + char* argtestArgv[] = { "/bin/argtest", "alpha", "beta", "gamma", 0 }; + long argChild = userSpawn("/bin/argtest", argtestArgv); + if (argChild < 0) { + print("proctest: FAIL spawn /bin/argtest\n"); + return 1; + } + + long argStatus = userWait(argChild); + if (argStatus != 0) { + print("proctest: FAIL argtest reported bad argv\n"); + return 1; + } + print("proctest: spawn with argv ok\n"); + + userExec("/bin/exectarget", 0); + + /* Only reached if userExec failed outright (bad path, load failure) -- + a successful exec never returns here at all. */ + print("proctest: FAIL exec did not take over\n"); + return 1; +} diff --git a/user/redirreader.c b/user/redirreader.c new file mode 100644 index 0000000..bf1aa6e --- /dev/null +++ b/user/redirreader.c @@ -0,0 +1,57 @@ +#include "ulib.h" + +/* + * Companion to redirwriter.c -- reads fd 0 (redirected by the shell to a + * REAL file via `<`, jobctlApplyRedirects) in a loop until a real EOF (a + * regular file's own vfsRead already returns 0 the instant the offset + * reaches its size -- no writer-count bookkeeping needed the way + * pipesink.c's pipe-backed EOF does, since a file's "end" is just its + * length, not "every writer has gone away"), then compares byte-for-byte + * against two back-to-back copies of redirwriter.c's own payload -- proof + * that `>` (create/truncate) followed by `>>` (append) on the SAME path + * produced exactly that file, not a truncated/duplicated/reordered one. + * + * Unlike pipesink.c, this process's own fd 1 (stdout) is NOT redirected -- + * print()/printDec() (ulib.h) reach the real console exactly like any + * unredirected demo, so the needle below is exactly as observable to + * ci-test.sh as every other one. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("redirreader: reading redirected stdin\n"); + + char buffer[80]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + print("redirreader: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + buffer[total] = 0; + + print("redirreader: got: "); + print(buffer); + print("\n"); + + int ok = streq(buffer, "redirect-payload-okredirect-payload-ok"); + print(ok ? "redirreader: payload-ok\n" : "redirreader: payload-FAIL\n"); + + return ok ? 0 : 1; +} diff --git a/user/redirwriter.c b/user/redirwriter.c new file mode 100644 index 0000000..33b06d4 --- /dev/null +++ b/user/redirwriter.c @@ -0,0 +1,43 @@ +#include "ulib.h" + +/* + * Live ring-3 proof for shell.c's new `cmd > file` / `cmd >> file` file + * redirection (jobctlApplyRedirects/jobctlSpawnForegroundRedirected, + * jobctl.h): from this program's own point of view it just writes its + * stdout exactly like pipesrc.c already does for the pipe case -- it has + * no idea whether fd 1 is the real console, a pipe, or (here) a real file + * on tmpfs, the same "redirection is invisible to the child" property a + * real shell's dup2()-before-exec gives every Unix program. fd 2 is + * deliberately left alone (only fd 0/fd 1 are ever redirected, matching + * jobctlSpawnPiped's own pipeline convention), so the diagnostic line + * below reaches the real console even while fd 1's own payload goes to + * the file instead -- writing it through fd 1 would corrupt the exact + * bytes redirreader.c later compares. + * + * kmain.c's redirDriverThread runs this program TWICE against the same + * path -- once through `>` (truncate/create) and once through `>>` + * (append) -- so the resulting file holds two back-to-back copies of this + * same payload with no separator, proving `>>` genuinely continues from + * the end of what `>` already wrote rather than overwriting it. + */ +#define redirectPayload "redirect-payload-ok" + +static void printErr(const char* text) +{ + userWrite(2, text, stringLength(text)); +} + +int main(void) +{ + printErr("redirwriter: writing payload to stdout (redirected)\n"); + + unsigned long length = stringLength(redirectPayload); + long written = userWrite(1, redirectPayload, length); + if (written != (long)length) { + printErr("redirwriter: FAIL short/failed write\n"); + return 1; + } + + printErr("redirwriter: wrote payload\n"); + return 0; +} diff --git a/user/restarttarget.c b/user/restarttarget.c new file mode 100644 index 0000000..0042400 --- /dev/null +++ b/user/restarttarget.c @@ -0,0 +1,153 @@ +#include "ulib.h" + +/* + * Only ever spawned by restarttest.c, with four shared pipe handles as argv + * (ready/announce1/announce2/data, decToString'd -- the same convention + * nesttarget.c/jobtarget.c already establish for a multi-pipe rendezvous). + * + * Proves signal.h's SA_RESTART/EINTR distinction (signalnum.h's + * sigFlagRestart, sched.h's Thread.sigInRetryableSyscall) end to end, in + * two phases against two independently registered handlers: + * + * phase 1 (no sigFlagRestart): blocks in a real userRead on a pipe NO ONE + * will EVER write (the exact "blocked forever" setup user/eintrtarget.c + * already uses for threadKill/early-wake), gets sigUsr1 -- registered + * WITHOUT sigFlagRestart -- and must see that read return errInterrupted + * the instant onUsr1 returns, not silently retry and hang forever. + * + * phase 2 (WITH sigFlagRestart): blocks in a real userRead on a SECOND + * pipe restarttest.c genuinely does write, gets sigUsr2 -- registered + * WITH sigFlagRestart -- while blocked, and must see that read + * transparently resume and return the real byte once onUsr2 returns, + * exactly like every other retryable syscall already did before this + * flag existed (user/nesttarget.c's onUsr1/goFd relies on precisely this + * for an unrelated reason -- see that file's own comment). + * + * Each phase's blocking read is announced ("about to call it now") over + * its own pipe immediately beforehand, the same "announce, then the OTHER + * process learns it via its own blocking read before acting" discipline + * nesttest.c/nesttarget.c already established for the analogous nested- + * delivery race -- not airtight against an adversarial scheduler in + * principle, but the SAME assumption those already-verified demos (and + * eintrtest.c's) rely on in practice. The rigorous, race-free proof of the + * actual rip/rax rewrite lives in sigTest.c, driving sigConsumePending + * directly. + */ + +static volatile int handlerUsr1Fired = 0; +static volatile int handlerUsr2Fired = 0; + +static void onUsr1(int signum) +{ + (void)signum; + handlerUsr1Fired = 1; + print("restarttarget: usr1-handler-fired\n"); +} + +static void onUsr2(int signum) +{ + (void)signum; + handlerUsr2Fired = 1; + print("restarttarget: usr2-handler-fired\n"); +} + +int main(int argc, char** argv) +{ + print("restarttarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 5) { + print("restarttarget: FAIL expected four argv (ready/announce1/announce2/data handles)\n"); + return 1; + } + + /* sigUsr1 deliberately has NO sigFlagRestart (0): phase 1 below expects + its own interrupted read to return errInterrupted, not resume. + sigUsr2 deliberately DOES have sigFlagRestart: phase 2 expects the + opposite, a transparent resume with the real data. */ + if (userSigAction(sigUsr1, onUsr1, 0, 0) == (long)sigSetFailed || + userSigAction(sigUsr2, onUsr2, 0, sigFlagRestart) == (long)sigSetFailed) { + print("restarttarget: FAIL install handlers\n"); + return 1; + } + + long victimHandle = userPipeCreate(); + if (victimHandle < 0) { + print("restarttarget: FAIL create victim pipe\n"); + return 1; + } + long victimFd = userPipeAttach(victimHandle, 0); + if (victimFd < 0) { + print("restarttarget: FAIL attach victim pipe\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long announce1Handle = parseDec(argv[2]); + long announce2Handle = parseDec(argv[3]); + long dataHandle = parseDec(argv[4]); + + long readyFd = userPipeAttach(readyHandle, 1); + long announce1Fd = userPipeAttach(announce1Handle, 1); + long announce2Fd = userPipeAttach(announce2Handle, 1); + long dataFd = userPipeAttach(dataHandle, 0); + if (readyFd < 0 || announce1Fd < 0 || announce2Fd < 0 || dataFd < 0) { + print("restarttarget: FAIL attach ready/announce/data pipes\n"); + return 1; + } + + if (userWrite((int)readyFd, "r", 1) != 1) { + print("restarttarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("restarttarget: ready, handlers installed\n"); + + /* Phase 1: no-restart. */ + print("restarttarget: phase1 blocking on victim read (expect errInterrupted)\n"); + if (userWrite((int)announce1Fd, "a", 1) != 1) { + print("restarttarget: FAIL write announce1 byte\n"); + return 1; + } + userClose((int)announce1Fd); + + char victimBuffer[4]; + long n1 = userRead((int)victimFd, victimBuffer, sizeof(victimBuffer)); + userClose((int)victimFd); + + if (!handlerUsr1Fired) { + print("restarttarget: FAIL usr1 handler never fired\n"); + return 1; + } + if (n1 != errInterrupted) { + print("restarttarget: FAIL victim read did not return errInterrupted\n"); + return 1; + } + print("restarttarget: phase1-eintr-no-restart-ok\n"); + + /* Phase 2: sigFlagRestart. */ + print("restarttarget: phase2 blocking on data read (expect transparent restart)\n"); + if (userWrite((int)announce2Fd, "a", 1) != 1) { + print("restarttarget: FAIL write announce2 byte\n"); + return 1; + } + userClose((int)announce2Fd); + + char dataByte = 0; + long n2 = userRead((int)dataFd, &dataByte, 1); + userClose((int)dataFd); + + if (!handlerUsr2Fired) { + print("restarttarget: FAIL usr2 handler never fired\n"); + return 1; + } + if (n2 != 1 || dataByte != 'd') { + print("restarttarget: FAIL data read did not transparently resume with real data\n"); + return 1; + } + print("restarttarget: phase2-restart-transparent-ok\n"); + + print("restarttarget: all-phases-ok\n"); + return 55; +} diff --git a/user/restarttest.c b/user/restarttest.c new file mode 100644 index 0000000..3acda58 --- /dev/null +++ b/user/restarttest.c @@ -0,0 +1,115 @@ +#include "ulib.h" + +/* + * Boot-time driver for user/restarttarget.c -- see that file's own comment + * for the full two-phase SA_RESTART/EINTR story. Four real happens-before + * rendezvous pipes (ready/announce1/announce2/data), never a sleep guess, + * the same discipline nesttest.c already established for a comparable + * race: read announce1 before sending the no-restart signal (sigUsr1), then + * read announce2 -- which restarttarget.c only ever writes AFTER phase 1's + * read has already come back as errInterrupted and been validated -- before + * sending the restart-flagged signal (sigUsr2) and writing the real data + * byte phase 2 expects to receive back. + */ + +int main(void) +{ + print("restarttest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long readyHandle = userPipeCreate(); + long announce1Handle = userPipeCreate(); + long announce2Handle = userPipeCreate(); + long dataHandle = userPipeCreate(); + if (readyHandle < 0 || announce1Handle < 0 || announce2Handle < 0 || dataHandle < 0) { + print("restarttest: FAIL create pipes\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 0); + long announce1Fd = userPipeAttach(announce1Handle, 0); + long announce2Fd = userPipeAttach(announce2Handle, 0); + long dataFd = userPipeAttach(dataHandle, 1); + if (readyFd < 0 || announce1Fd < 0 || announce2Fd < 0 || dataFd < 0) { + print("restarttest: FAIL attach pipes\n"); + return 1; + } + + char readyHandleStr[24]; + char announce1HandleStr[24]; + char announce2HandleStr[24]; + char dataHandleStr[24]; + decToString(readyHandle, readyHandleStr); + decToString(announce1Handle, announce1HandleStr); + decToString(announce2Handle, announce2HandleStr); + decToString(dataHandle, dataHandleStr); + + char* targetArgv[] = { "/bin/restarttarget", readyHandleStr, announce1HandleStr, + announce2HandleStr, dataHandleStr, 0 }; + long target = userSpawn("/bin/restarttarget", targetArgv); + if (target < 0) { + print("restarttest: FAIL spawn restarttarget\n"); + return 1; + } + + char byte; + if (userRead((int)readyFd, &byte, 1) != 1) { + print("restarttest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("restarttest: restarttarget ready\n"); + + /* Real blocking read: does not return until restarttarget has actually + written announce1, right before it calls its own victim read -- the + happens-before that makes "sigUsr1 lands while genuinely blocked" + meaningful rather than a coincidence of timing (same guarantee + nesttest.c's handlerReadyFd already gives a different race). */ + if (userRead((int)announce1Fd, &byte, 1) != 1) { + print("restarttest: FAIL read announce1 byte\n"); + return 1; + } + userClose((int)announce1Fd); + print("restarttest: restarttarget about to block (phase1)\n"); + + if (userSigSend(target, sigUsr1) != 0) { + print("restarttest: FAIL send sigUsr1 to restarttarget\n"); + return 1; + } + print("restarttest: sent sigUsr1 (no restart) to restarttarget\n"); + + /* restarttarget only ever writes announce2 after phase 1's read has + already come back as errInterrupted and been validated, so this read + doubles as confirmation phase 1 actually passed. */ + if (userRead((int)announce2Fd, &byte, 1) != 1) { + print("restarttest: FAIL read announce2 byte\n"); + return 1; + } + userClose((int)announce2Fd); + print("restarttest: restarttarget about to block (phase2)\n"); + + if (userSigSend(target, sigUsr2) != 0) { + print("restarttest: FAIL send sigUsr2 to restarttarget\n"); + return 1; + } + print("restarttest: sent sigUsr2 (restart) to restarttarget\n"); + + if (userWrite((int)dataFd, "d", 1) != 1) { + print("restarttest: FAIL write data byte\n"); + return 1; + } + userClose((int)dataFd); + + long status = userWait(target); + print("restarttest: restarttarget exit status="); + printDec(status); + print("\n"); + if (status != 55) { + print("restarttest: FAIL expected restarttarget exit status 55\n"); + return 1; + } + print("restarttest: sa-restart-eintr-distinction-ok\n"); + + return 0; +} diff --git a/user/rmtest.c b/user/rmtest.c new file mode 100644 index 0000000..261d8ba --- /dev/null +++ b/user/rmtest.c @@ -0,0 +1,112 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Real ring-3 proof of unlink(2)/rmdir(2) as two DISTINCT syscalls + * (sysUnlink=13, pre-existing; sysRmdir=43, new this fala) through + * userUnlink/userRmdir (ulib.h), exercising both tmpfs (/tmp) and the + * persistent on-disk SFS mount (/mnt) to prove vfs.c's dispatch layer + * enforces the same real POSIX split -- unlink refuses a directory, + * rmdir refuses anything that is not an EMPTY directory -- on both + * backing stores, not just one. Fully synchronous like nicetest.c/ + * date.c/timetest.c: no injected keystrokes, no shared shell channel, so + * there is no scheduling-timing race to flake on regardless of host load. + */ + +static int createFile(const char* path) +{ + int fd = (int)userOpen(path, oReadWrite | oCreate); + if (fd < 0) + return -1; + userClose(fd); + return 0; +} + +int main(void) +{ + print("rmtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userMkdir("/tmp/rmtest") != 0) { + print("rmtest: FAIL mkdir /tmp/rmtest\n"); + return 1; + } + if (createFile("/tmp/rmtest/f.txt") != 0) { + print("rmtest: FAIL create /tmp/rmtest/f.txt\n"); + return 1; + } + + if (userRmdir("/tmp/rmtest") == 0) { + print("rmtest: FAIL rmdir succeeded on a non-empty tmpfs dir\n"); + return 1; + } + if (userUnlink("/tmp/rmtest") == 0) { + print("rmtest: FAIL unlink succeeded on a tmpfs directory\n"); + return 1; + } + FbStat st; + if (userStat("/tmp/rmtest", &st) != 0 || st.type != fsTypeDir) { + print("rmtest: FAIL tmpfs dir did not survive the rejected attempts\n"); + return 1; + } + print("rmtest: tmpfs-reject-ok\n"); + + if (userUnlink("/tmp/rmtest/f.txt") != 0) { + print("rmtest: FAIL unlink /tmp/rmtest/f.txt\n"); + return 1; + } + if (userRmdir("/tmp/rmtest") != 0) { + print("rmtest: FAIL rmdir empty tmpfs dir\n"); + return 1; + } + if (userStat("/tmp/rmtest", &st) == 0) { + print("rmtest: FAIL tmpfs dir still present after rmdir\n"); + return 1; + } + print("rmtest: tmpfs-remove-ok\n"); + + if (userMkdir("/mnt/rmtest") != 0) { + print("rmtest: FAIL mkdir /mnt/rmtest\n"); + return 1; + } + + /* The exact previously-shipped gap this fala closed: unlinking an + EMPTY on-disk directory used to silently succeed (sfsRemove's own + dir-emptiness check let it through with no type gate above it) -- + must now be refused, EISDIR-shaped, same as tmpfs above. */ + if (userUnlink("/mnt/rmtest") == 0) { + print("rmtest: FAIL unlink succeeded on an empty SFS directory\n"); + return 1; + } + if (userStat("/mnt/rmtest", &st) != 0 || st.type != fsTypeDir) { + print("rmtest: FAIL SFS dir did not survive the rejected unlink\n"); + return 1; + } + print("rmtest: sfs-reject-ok\n"); + + if (createFile("/mnt/rmtest/g.txt") != 0) { + print("rmtest: FAIL create /mnt/rmtest/g.txt\n"); + return 1; + } + if (userRmdir("/mnt/rmtest") == 0) { + print("rmtest: FAIL rmdir succeeded on a non-empty SFS dir\n"); + return 1; + } + if (userUnlink("/mnt/rmtest/g.txt") != 0) { + print("rmtest: FAIL unlink /mnt/rmtest/g.txt\n"); + return 1; + } + if (userRmdir("/mnt/rmtest") != 0) { + print("rmtest: FAIL rmdir empty SFS dir\n"); + return 1; + } + if (userStat("/mnt/rmtest", &st) == 0) { + print("rmtest: FAIL SFS dir still present after rmdir\n"); + return 1; + } + print("rmtest: sfs-remove-ok\n"); + + print("rmtest: PASS\n"); + return 0; +} diff --git a/user/selfstoptarget.c b/user/selfstoptarget.c new file mode 100644 index 0000000..aaea931 --- /dev/null +++ b/user/selfstoptarget.c @@ -0,0 +1,72 @@ +#include "ulib.h" + +/* + * Only ever spawned by selfstoptest.c, with argv[1] = a shared pipe handle + * (userPipeCreate'd by selfstoptest.c, decToString'd exactly like every + * other *test / *target pair's own shared-handle argv) this process attaches + * as the WRITER end. + * + * Unlike jobtest.c/jobtarget.c (job control's other live demo), the SIGSTOP + * here targets THIS process's OWN pid -- self-directed job control + * (syscall.c's sysSigSend, the "selfTargeted" branch right after + * sigRaise/threadStop) rather than one process stopping a different one. + * No ready-pipe rendezvous is needed before the self-stop itself: unlike + * jobtest.c's own sigStop (sent from a DIFFERENT process, which could + * otherwise race ahead of jobtarget's own setup), this process is the only + * one that can ever call self-stop, so there is nothing else to order it + * against -- it simply self-stops the moment it has attached the pipe. + * + * selfstoptest.c's own real proof that this process is genuinely Stopped + * (not still running, not merely eventually preempted by the next timer + * tick) is entirely on ITS side (a real SIGCHLD siCodeChldStopped + * notification, then a non-blocking poll proving the pipe below is still + * empty) -- this process only needs to write its own proof byte AFTER + * actually resuming, which can only happen once selfstoptest.c's own + * userSigSend(this pid, sigCont) runs. + */ + +int main(int argc, char** argv) +{ + print("selfstoptarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 2) { + print("selfstoptarget: FAIL expected one argv (proof pipe handle)\n"); + return 1; + } + + long handle = parseDec(argv[1]); + long proofFd = userPipeAttach(handle, 1); + if (proofFd < 0) { + print("selfstoptarget: FAIL attach proof pipe as writer\n"); + return 1; + } + + print("selfstoptarget: self-stopping now\n"); + + /* Self-targeted SIGSTOP: syscall.c's sysSigSend recognizes rawPid == + senderPid (this process's own pid) as "selfTargeted", and after + threadStop (called from sigRaise above) flips this thread's own + state to threadStopped, explicitly reschedules instead of just + falling through to its normal return-to-ring3 path -- the exact gap + syscall.c's own comment documents (a self-stopped thread that simply + returned to ring 3 would keep running, unnoticed, until the next + timer tick). Does not return until a real + userSigSend(this pid, sigCont) from selfstoptest.c moves this thread + back to threadRunnable and the scheduler actually picks it again. */ + if (userSigSend(userGetpid(), sigStop) != 0) { + print("selfstoptarget: FAIL self-directed sigStop rejected\n"); + return 1; + } + + print("selfstoptarget: resumed-after-self-stop-ok\n"); + + if (userWrite((int)proofFd, "p", 1) != 1) { + print("selfstoptarget: FAIL write proof byte\n"); + return 1; + } + userClose((int)proofFd); + + return 66; +} diff --git a/user/selfstoptest.c b/user/selfstoptest.c new file mode 100644 index 0000000..60378d1 --- /dev/null +++ b/user/selfstoptest.c @@ -0,0 +1,193 @@ +#include "ulib.h" + +/* + * Boot-time driver closing the one gap left open by jobtest.c/jobtarget.c + * (job control's OTHER live demo): a process stopping ITSELF (self-directed + * SIGSTOP through sysSigSend, syscall.c) rather than being stopped by a + * different one. Reasoned about but deliberately left without a live demo + * for many falas (see include/signal.h's own file comment) because proving + * it needs a second, independent process to race-freely learn "the target + * has now actually stopped" before sending SIGCONT -- exactly the kind of + * guess-free rendezvous sigtarget.c's own pipe-rendezvous comment already + * had to solve once for a different race. The rendezvous this file uses is + * sched.c's threadStop already raising a real SIGCHLD (siCodeChldStopped) + * against the PARENT the instant a child (self- or otherwise) actually + * transitions to threadStopped -- an event the kernel can only ever raise + * if threadStop genuinely ran, so a real SigInfo handler catching it here + * is a zero-guess, race-free signal that selfstoptarget.c's self-stop + * syscall did not silently no-op. + * + * That alone would only prove the STATE flipped, not that execution + * actually paused -- syscall.c's own comment describes exactly the bug + * class this demo also has to rule out (a self-stopped thread that falls + * through to its ordinary return-to-ring3 path instead of explicitly + * rescheduling would keep running, unnoticed, until the next timer tick). + * So this file adds a second check the moment the SIGCHLD confirms the + * stop: a non-blocking (timeoutMs 0) userPoll on the proof pipe + * selfstoptarget.c writes to only AFTER it resumes -- if that byte were + * already there, the self-stop never actually paused execution and + * selfstoptarget raced all the way to writing it before this process's own + * bounded SIGCHLD-wait loop even noticed. Only once BOTH checks pass does + * this send the real sigCont. + */ + +static volatile int stopFired = 0; +static volatile int stopPid = -1; +static volatile int stopStatus = -1; + +static void onChld(int signum, SigInfo* info, void* context) +{ + (void)signum; + (void)context; + if (info->siCode != siCodeChldStopped) + return; + stopPid = info->siPid; + stopStatus = info->siStatus; + stopFired = 1; +} + +int main(void) +{ + print("selfstoptest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* Installed BEFORE selfstoptarget even exists -- threadStop can raise + the real SIGCHLD the instant selfstoptarget's self-stop syscall + runs, which could be very soon after userSpawn returns below. */ + if (userSigActionInfo(sigChld, onChld, 0, sigFlagSigInfo) == (long)sigSetFailed) { + print("selfstoptest: FAIL install siginfo sigchld handler\n"); + return 1; + } + + long proofHandle = userPipeCreate(); + if (proofHandle < 0) { + print("selfstoptest: FAIL create proof pipe\n"); + return 1; + } + + /* Attach as reader before selfstoptarget exists, the same "attach + first" reason sigtest.c/jobtest.c's own comments already document at + length -- whichever side attaches second risks the pipe's slot + already being freed by pipeReleaseIfUnused. */ + long proofFd = userPipeAttach(proofHandle, 0); + if (proofFd < 0) { + print("selfstoptest: FAIL attach proof pipe as reader\n"); + return 1; + } + + char proofHandleStr[24]; + decToString(proofHandle, proofHandleStr); + + char* targetArgv[] = { "/bin/selfstoptarget", proofHandleStr, 0 }; + long target = userSpawn("/bin/selfstoptarget", targetArgv); + if (target < 0) { + print("selfstoptest: FAIL spawn selfstoptarget\n"); + return 1; + } + + /* Bounded poll for the real, kernel-raised SIGCHLD, same idiom + siginfotest.c/sigchldtest.c already use for the same reason (no + timing guess, just enough sleep-wake cycles for the return-to-ring3 + pending-signal scan, isr.c, to notice it whenever it actually + lands). */ + for (int i = 0; i < 80 && !stopFired; i++) + userSleep(20); + + if (!stopFired) { + print("selfstoptest: FAIL sigchld-on-self-stop handler never fired\n"); + return 1; + } + if (stopPid != (int)target) { + print("selfstoptest: FAIL si_pid="); + printDec(stopPid); + print(" does not match real child pid="); + printDec(target); + print("\n"); + return 1; + } + if (stopStatus != sigStop) { + print("selfstoptest: FAIL si_status did not report sigStop\n"); + return 1; + } + print("selfstoptest: self-stop-confirmed-via-sigchld-ok\n"); + + /* Non-blocking: must return immediately either way (timeoutMs 0), and + must report the proof pipe as NOT ready -- selfstoptarget.c only + ever writes to it AFTER resuming from its own self-stop syscall, + which has not happened yet (sigCont is not sent until after this + check). A self-stop that silently failed to actually pause execution + would have let selfstoptarget race ahead and write this byte well + before this process's own 80-iteration SIGCHLD-wait loop above even + finished, making this the one check that would catch that exact + regression. */ + PollFd proofPoll; + proofPoll.fd = (int)proofFd; + proofPoll.events = pollIn; + proofPoll.revents = 12345; + long ready = userPoll(&proofPoll, 1, 0); + if (ready != 0 || proofPoll.revents != 0) { + print("selfstoptest: FAIL proof pipe already had data before sigCont " + "(self-stop did not actually pause execution)\n"); + return 1; + } + print("selfstoptest: still-stopped-before-cont-ok\n"); + + if (userSigSend(target, sigCont) != 0) { + print("selfstoptest: FAIL send sigCont to selfstoptarget\n"); + return 1; + } + print("selfstoptest: sent sigCont to selfstoptarget\n"); + + /* onChld is installed WITHOUT sigFlagRestart (it needs to fire once, + immediately, for the stop notification above -- a restarting handler + would transparently retry, hiding the exact event this file is + trying to observe). That means it is ALSO live for a second, later + SIGCHLD: selfstoptarget's own exit, which can land at almost any + real time after sigCont resumes it. If that exit's SIGCHLD arrives + while this thread is genuinely parked mid the blocking userRead just + below, sigConsumePending (signal.c) rewrites the interrupted + context to return errInterrupted (-4) instead of letting the + retry-on-block loop run and find the byte selfstoptarget already + wrote -- the exact same self-signal-vs-own-blocking-syscall race + stopchldtest.c's own comment documents and retries past (see that + file for the full reasoning); a correctly written caller with a + non-restarting handler for the same signal its own blocking syscall + is waiting on must loop past EINTR the same way. */ + char proofByte = 0; + long readStatus; + for (;;) { + readStatus = userRead((int)proofFd, &proofByte, 1); + if (readStatus != errInterrupted) + break; + } + if (readStatus != 1) { + print("selfstoptest: FAIL read proof byte\n"); + return 1; + } + userClose((int)proofFd); + if (proofByte != 'p') { + print("selfstoptest: FAIL wrong proof byte\n"); + return 1; + } + print("selfstoptest: post-cont-proof-received-ok\n"); + + /* Same EINTR hazard as the read above, in case the exit SIGCHLD is + still unconsumed by the time this runs instead. */ + long status; + for (;;) { + status = userWait(target); + if (status != errInterrupted) + break; + } + print("selfstoptest: selfstoptarget exit status="); + printDec(status); + print("\n"); + if (status != 66) { + print("selfstoptest: FAIL expected selfstoptarget exit status 66\n"); + return 1; + } + print("selfstoptest: self-stop-continue-ok\n"); + + return 0; +} diff --git a/user/sigchldchild.c b/user/sigchldchild.c new file mode 100644 index 0000000..a5d3e8c --- /dev/null +++ b/user/sigchldchild.c @@ -0,0 +1,24 @@ +#include "ulib.h" + +/* + * Only ever spawned by sigchldtest.c. Does nothing rendezvous-shaped + * (unlike eintrtarget.c/masktarget.c's own targets) because it does not + * need to: sigchldtest.c's own proof (a real sigChld raised by the kernel + * against whoever spawned this process, sched.c's parentId/threadExit) + * does not depend on any particular ordering between this process's own + * death and sigchldtest.c noticing it -- sigRaise sets the pending bit on + * the parent unconditionally, whatever its state is at that instant, and + * sigchldtest.c's own bounded poll loop (the same "check a flag every + * userSleep(20)" idiom masktarget.c/nesttarget.c already use) picks it up + * on its very next natural wake either way. Just runs and exits with a + * distinctive code sigchldtest.c can verify through userWait after the + * fact. + */ +int main(void) +{ + print("sigchldchild: start, pid="); + printDec(userGetpid()); + print("\n"); + print("sigchldchild: exiting now\n"); + return 84; +} diff --git a/user/sigchldtest.c b/user/sigchldtest.c new file mode 100644 index 0000000..313cbda --- /dev/null +++ b/user/sigchldtest.c @@ -0,0 +1,80 @@ +#include "ulib.h" + +/* + * Boot-time driver for real, kernel-generated SIGCHLD (sched.h's new + * Thread.parentId, threadExit/threadKill's new sigRaise(parentId, sigChld) + * call): proves the kernel itself notifies a process's SPAWNING parent the + * instant a child it created via sysSpawn actually dies, asynchronously, + * without that parent ever having to call sysWait first to find out. + * + * eintrtest.c already proves early-wake delivery works generically for an + * arbitrary signal explicitly sent at a controlled time; proctest.c + * already proves sysWait/threadJoin's synchronous "block until this pid is + * Dead" contract. Neither proves the ONE thing new here: that the kernel + * itself, unprompted, raises a specific signal (sigChld) against a + * specific target (the spawning parent, not just "whoever calls wait") + * from exactly the right lifecycle event (threadExit/threadKill). + * + * Uses the same bounded "check a flag every userSleep(20)" poll idiom + * masktarget.c/nesttarget.c already use for their own outer verification, + * deliberately NOT eintrtarget.c's tight write-then-block-forever shape: + * sigchldchild.c's death is not explicitly time-controlled by any + * userSigSend call the way eintrtest.c's sigTerm or masktest.c's sigUsr1/ + * sigUsr2 are, so there is no readiness rendezvous to build here that + * would not itself just be racing the same autonomous kernel event it is + * trying to order against. The poll loop sidesteps that entirely: whether + * sigchldchild.c dies before or after any given iteration starts, the very + * next natural sleep-wake cycle's return to ring 3 re-checks pending + * signals (isr.c's deliverPendingSignals, unconditional on every return), + * so the pending sigChld bit is picked up regardless of ordering -- no + * timing guess, just a different (still real, still ring-3) rendezvous + * shape than the tight-block one. + */ + +static volatile int handlerFired = 0; + +static void onChld(int signum) +{ + (void)signum; + print("sigchldtest: sigchld-handler-fired\n"); + handlerFired = 1; +} + +int main(void) +{ + print("sigchldtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userSigAction(sigChld, onChld, 0, 0) == (long)sigSetFailed) { + print("sigchldtest: FAIL install sigchld handler\n"); + return 1; + } + + long child = userSpawn("/bin/sigchldchild", 0); + if (child < 0) { + print("sigchldtest: FAIL spawn sigchldchild\n"); + return 1; + } + + for (int i = 0; i < 80 && !handlerFired; i++) + userSleep(20); + + if (!handlerFired) { + print("sigchldtest: FAIL sigchld handler never fired\n"); + return 1; + } + print("sigchldtest: sigchld-delivered-to-parent-ok\n"); + + long status = userWait(child); + print("sigchldtest: sigchldchild exit status="); + printDec(status); + print("\n"); + if (status != 84) { + print("sigchldtest: FAIL expected sigchldchild exit status 84\n"); + return 1; + } + print("sigchldtest: chld-reap-after-signal-ok\n"); + + return 0; +} diff --git a/user/sigdefault.c b/user/sigdefault.c new file mode 100644 index 0000000..b18b878 --- /dev/null +++ b/user/sigdefault.c @@ -0,0 +1,26 @@ +#include "ulib.h" + +/* + * Only ever spawned by sigtest.c. Installs no handler at all, so the + * sigTerm sigtest.c sends it must run the OTHER branch of + * signal.c's sigConsumePending: default disposition, which + * isr.c's deliverPendingSignals turns into a real threadExit -- proving a + * signal with no handler actually terminates its target (exit code + * 128+signum, see isr.c), not just that a handler-based one can be caught. + * Reaching the FAIL print below would mean the default-terminate path + * never fired at all. + */ + +int main(void) +{ + print("sigdefault: start, pid="); + printDec(userGetpid()); + print("\n"); + print("sigdefault: waiting to be terminated by default disposition\n"); + + for (int i = 0; i < 40; i++) + userSleep(20); + + print("sigdefault: FAIL still alive after default-disposition signal\n"); + return 1; +} diff --git a/user/siginfotarget.c b/user/siginfotarget.c new file mode 100644 index 0000000..91b2481 --- /dev/null +++ b/user/siginfotarget.c @@ -0,0 +1,111 @@ +#include "ulib.h" + +/* + * Only ever spawned by siginfotest.c, with argv[1]=the ready-pipe handle + * (decToString'd, exactly like sigtarget.c's own argv[1]) and argv[2]= + * siginfotest's own pid (also decToString'd) -- the one extra argument + * this process needs that sigtarget.c's own version of this rendezvous + * did not, since proving SA_SIGINFO's si_pid is real means comparing it + * against a KNOWN sender pid, not just observing "some nonzero value came + * through". + * + * Otherwise the exact same happens-before shape sigtarget.c already + * established (userSigActionInfo BEFORE the ready byte is written, a real + * blocking read on siginfotest.c's side of the pipe before it ever calls + * userSigSend) -- see that file's own comment for why a plain sleep is not + * enough here either: sigUsr1's default disposition is Terminate, so a + * signal delivered before the handler is installed would kill this + * process outright. + */ + +static volatile int handlerFired = 0; +static volatile int capturedSignum = 0; +static volatile int capturedCode = -1; +static volatile int capturedPid = -1; + +static void onUsr1(int signum, SigInfo* info, void* context) +{ + (void)context; + capturedSignum = signum; + capturedCode = info->siCode; + capturedPid = info->siPid; + handlerFired = 1; + print("siginfotarget: handler invoked signum="); + printDec(signum); + print(" siCode="); + printDec(info->siCode); + print(" siPid="); + printDec(info->siPid); + print("\n"); +} + +int main(int argc, char** argv) +{ + print("siginfotarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 3) { + print("siginfotarget: FAIL expected two argv (pipe handle, parent pid)\n"); + return 1; + } + + /* sigFlagSigInfo (signalnum.h) is what actually makes the kernel call + onUsr1 with three arguments instead of one -- without it, this + handler would be invoked as if it were a plain SigHandler, reading + `info`/`context` out of whatever garbage rsi/rdx happened to hold + (see userSigActionInfo's own doc comment, ulib.h). */ + if (userSigActionInfo(sigUsr1, onUsr1, 0, sigFlagSigInfo) == (long)sigSetFailed) { + print("siginfotarget: FAIL install siginfo handler\n"); + return 1; + } + + long parentPid = parseDec(argv[2]); + + long handle = parseDec(argv[1]); + long readyFd = userPipeAttach(handle, 1); + if (readyFd < 0) { + print("siginfotarget: FAIL attach ready pipe\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("siginfotarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + + print("siginfotarget: waiting for signal\n"); + + for (int i = 0; i < 40 && !handlerFired; i++) + userSleep(20); + + if (!handlerFired) { + print("siginfotarget: FAIL handler never fired\n"); + return 1; + } + if (capturedSignum != sigUsr1) { + print("siginfotarget: FAIL wrong signum delivered\n"); + return 1; + } + if (capturedCode != siCodeUser) { + print("siginfotarget: FAIL expected siCodeUser\n"); + return 1; + } + if (capturedPid != (int)parentPid) { + print("siginfotarget: FAIL si_pid="); + printDec(capturedPid); + print(" does not match real sender pid="); + printDec(parentPid); + print("\n"); + return 1; + } + print("siginfotarget: siginfo-sender-pid-ok\n"); + + /* Reached only if sysSigReturn correctly restored the exact pre-signal + context (same proof sigtarget.c's own tail comment gives), plus this + time only if the trampoline's three-argument call into onUsr1 above + (rdi/rsi/rdx all genuinely set up, not just rdi) did not corrupt + anything either. */ + print("siginfotarget: resumed-after-siginfo-handler-ok\n"); + return 77; +} diff --git a/user/siginfotest.c b/user/siginfotest.c new file mode 100644 index 0000000..f675680 --- /dev/null +++ b/user/siginfotest.c @@ -0,0 +1,151 @@ +#include "ulib.h" + +/* + * Boot-time driver for real SA_SIGINFO delivery (signalnum.h's + * sigFlagSigInfo/SigInfo, signal.c's sigConsumePending) -- proves the + * extra (SigInfo*, void*) arguments a sigFlagSigInfo handler receives + * carry genuinely accurate data, not just a valid-looking pointer, in BOTH + * directions this kernel ever populates SigInfo from: + * + * 1. si_pid on a user-sent signal (sysSigSend, syscall.c): spawns + * siginfotarget.c and sends it a real sigUsr1 through userSigSend; + * siginfotarget's own handler verifies the si_pid it received is + * genuinely THIS process's pid (passed as an argv string so there is + * no ambiguity about what "correct" means), not just some nonzero + * value that happened to be lying around. + * + * 2. si_code/si_pid/si_status on a real, kernel-raised SIGCHLD + * (threadExit, sched.c): installs its OWN sigFlagSigInfo handler for + * sigChld before ever spawning siginfotarget (the same race sigchldtest.c + * already had to close with a pre-spawn install), then -- once + * siginfotarget's own round trip above completes and it exits with a + * distinctive code (77) -- verifies the real SIGCHLD its own handler + * received reports siCodeChldExited, the CHILD's real pid, and the + * CHILD's real exit status, exactly matching what userWait's + * synchronous reap independently confirms right after. + * + * Reuses siginfotarget.c's own ready-pipe rendezvous (sigtest.c's pattern) + * for the same reason sigtest.c needed it: sigUsr1's default disposition + * is Terminate, so sending it before siginfotarget has actually installed + * its handler would kill it instead of proving anything. + */ + +static volatile int chldFired = 0; +static volatile int chldCode = -1; +static volatile int chldPid = -1; +static volatile int chldStatus = -1; + +static void onChld(int signum, SigInfo* info, void* context) +{ + (void)signum; + (void)context; + chldCode = info->siCode; + chldPid = info->siPid; + chldStatus = info->siStatus; + chldFired = 1; + print("siginfotest: sigchld-info-handler-fired siCode="); + printDec(info->siCode); + print(" siPid="); + printDec(info->siPid); + print(" siStatus="); + printDec(info->siStatus); + print("\n"); +} + +int main(void) +{ + print("siginfotest: start, pid="); + printDec(userGetpid()); + print("\n"); + + /* Installed BEFORE siginfotarget even exists, exactly like + sigchldtest.c's own onChld -- a real SIGCHLD can arrive the instant + siginfotarget dies, which this process has no other way to order + itself ahead of. */ + if (userSigActionInfo(sigChld, onChld, 0, sigFlagSigInfo) == (long)sigSetFailed) { + print("siginfotest: FAIL install siginfo sigchld handler\n"); + return 1; + } + + long readyHandle = userPipeCreate(); + if (readyHandle < 0) { + print("siginfotest: FAIL create ready pipe\n"); + return 1; + } + + /* Attach as reader before siginfotarget exists -- same detached-slot + race sigtest.c's own comment documents avoiding. */ + long readyFd = userPipeAttach(readyHandle, 0); + if (readyFd < 0) { + print("siginfotest: FAIL attach ready pipe\n"); + return 1; + } + + char readyHandleStr[24]; + decToString(readyHandle, readyHandleStr); + char ownPidStr[24]; + decToString(userGetpid(), ownPidStr); + + char* targetArgv[] = { "/bin/siginfotarget", readyHandleStr, ownPidStr, 0 }; + long target = userSpawn("/bin/siginfotarget", targetArgv); + if (target < 0) { + print("siginfotest: FAIL spawn siginfotarget\n"); + return 1; + } + + char readyByte; + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("siginfotest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + + if (userSigSend(target, sigUsr1) != 0) { + print("siginfotest: FAIL send sigUsr1 to siginfotarget\n"); + return 1; + } + print("siginfotest: sent sigUsr1 to siginfotarget\n"); + + /* Bounded poll for the real, kernel-raised SIGCHLD, same idiom + sigchldtest.c already uses for the same reason (no timing guess, + just enough sleep-wake cycles for the return-to-ring3 pending-signal + scan, isr.c, to notice it whenever it actually lands). */ + for (int i = 0; i < 80 && !chldFired; i++) + userSleep(20); + + if (!chldFired) { + print("siginfotest: FAIL sigchld siginfo handler never fired\n"); + return 1; + } + if (chldCode != siCodeChldExited) { + print("siginfotest: FAIL expected siCodeChldExited\n"); + return 1; + } + if (chldPid != (int)target) { + print("siginfotest: FAIL si_pid="); + printDec(chldPid); + print(" does not match real child pid="); + printDec(target); + print("\n"); + return 1; + } + if (chldStatus != 77) { + print("siginfotest: FAIL si_status="); + printDec(chldStatus); + print(" does not match real child exit code 77\n"); + return 1; + } + print("siginfotest: sigchld-siginfo-accurate-ok\n"); + + long status = userWait(target); + print("siginfotest: siginfotarget exit status="); + printDec(status); + print("\n"); + if (status != 77) { + print("siginfotest: FAIL expected siginfotarget exit status 77\n"); + return 1; + } + print("siginfotest: siginfo-roundtrip-ok\n"); + + return 0; +} diff --git a/user/sigtarget.c b/user/sigtarget.c new file mode 100644 index 0000000..3202afd --- /dev/null +++ b/user/sigtarget.c @@ -0,0 +1,95 @@ +#include "ulib.h" + +/* + * Only ever spawned by sigtest.c, with a shared pipe handle as argv[1] + * (decToString'd, exactly like fifomaster.c's children -- see that file). + * Proves real, catchable signal delivery end to end: install a handler for + * sigUsr1 through userSigAction, wait to actually be interrupted by one + * arriving from a completely separate process (sigtest.c, via + * userSigSend -- not the kernel injecting it directly), and keep running + * normally afterwards -- the handler firing AND the loop below correctly + * resuming (not corrupting registers, not re-entering main, not looping + * forever) is the round trip through signal.c's sigConsumePending, the + * shared trampoline page (sigTrampoline.S), and sysSigReturn all actually + * working together, not just each piece compiling. + * + * The pipe exists purely to close a real race sigtest.c originally papered + * over with a fixed userSleep: sigUsr1's default disposition (no handler + * installed) is Terminate, not Ignore, so a signal that arrives before + * userSigAction below has actually run would kill this process instead of + * being caught -- observed for real under host load (sigtarget killed by + * "uncaught signal 10" before it ever printed "waiting for signal"). + * Writing one byte AFTER installing the handler, and having sigtest.c + * block on reading it before ever calling userSigSend, makes "handler + * installed" happen-before "signal sent" for real, the same guarantee + * fifomaster.c's children get from their own shared pipe. + */ + +static volatile int handlerFired = 0; +static volatile int handlerSignum = 0; + +static void onUsr1(int signum) +{ + handlerFired = 1; + handlerSignum = signum; + print("sigtarget: handler invoked signum="); + printDec(signum); + print("\n"); +} + +int main(int argc, char** argv) +{ + print("sigtarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 2) { + print("sigtarget: FAIL expected one argv (pipe handle)\n"); + return 1; + } + + if (userSigAction(sigUsr1, onUsr1, 0, 0) == (long)sigSetFailed) { + print("sigtarget: FAIL install handler\n"); + return 1; + } + + long handle = parseDec(argv[1]); + long readyFd = userPipeAttach(handle, 1); + if (readyFd < 0) { + print("sigtarget: FAIL attach ready pipe\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("sigtarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + + print("sigtarget: waiting for signal\n"); + + /* Several short sleeps rather than one long one: this kernel only + checks for a pending signal when the target thread actually returns + to ring 3 (isr.c's deliverPendingSignals) -- a thread that is still + genuinely Sleeping is not scheduled at all, so a shorter sleep gives + sigtest.c's userSigSend more chances to land promptly, without + depending on exact timing between the two independent processes. */ + for (int i = 0; i < 40 && !handlerFired; i++) + userSleep(20); + + if (!handlerFired) { + print("sigtarget: FAIL handler never fired\n"); + return 1; + } + if (handlerSignum != sigUsr1) { + print("sigtarget: FAIL wrong signum delivered\n"); + return 1; + } + print("sigtarget: handler-fired-ok\n"); + + /* Reached only if sysSigReturn correctly restored the exact + pre-signal context: a corrupted resume (wrong rip/rsp/registers) + would have crashed, hung, or skipped straight past this line + instead of returning here from the interrupted sleep loop above. */ + print("sigtarget: resumed-after-handler-ok\n"); + return 42; +} diff --git a/user/sigtest.c b/user/sigtest.c new file mode 100644 index 0000000..43fa245 --- /dev/null +++ b/user/sigtest.c @@ -0,0 +1,127 @@ +#include "ulib.h" + +/* + * Boot-time driver for the whole signal-delivery demo (signal.h). Spawns + * two independent children -- sigtarget.c (installs a real handler) and + * sigdefault.c (installs none) -- and sends each a real signal through the + * full syscall path (userSigSend, not a kernel-internal shortcut), proving + * both outcomes sigConsumePending can produce: caught-and-resumed, and + * uncaught-and-terminated. + * + * sigtarget needs a real happens-before guarantee, not a sleep guess: + * sigUsr1's default disposition is Terminate, so a signal that arrives + * before sigtarget's userSigAction has actually run would kill it instead + * of being caught -- this was originally "give it 100ms and hope", which + * really did fail under host load (sigtarget killed by the uncaught + * default before it ever installed its handler). See sigtarget.c for the + * shared-pipe rendezvous (fifomaster.c's pattern) that fixes it: this + * process blocks on a real read for sigtarget's own "handler installed" + * byte before ever calling userSigSend. + * + * sigdefault has no such window -- its default disposition applies from + * the moment it exists, whether or not it has run any code yet -- so a + * plain sleep-then-send (mirroring killtest.c's own "give the victim time + * to reach its blocking read") is genuinely sufficient there. + */ + +int main(void) +{ + print("sigtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userSigSend(999999, sigUsr1) != -1) { + print("sigtest: FAIL signal to bogus pid should fail\n"); + return 1; + } + print("sigtest: signal to bad pid rejected\n"); + + if (userSigAction(sigKill, (SigHandler)0, 0, 0) != (long)sigSetFailed) { + print("sigtest: FAIL installing a sigKill handler should be rejected\n"); + return 1; + } + print("sigtest: sigkill-handler-rejected-ok\n"); + + long readyHandle = userPipeCreate(); + if (readyHandle < 0) { + print("sigtest: FAIL create ready pipe\n"); + return 1; + } + + /* Attach as reader BEFORE sigtarget even exists, let alone before it + can possibly attach+write+close its own end: pipeCloseWrite (pipe.c) + reclaims a detached pipe's slot the moment readers AND writers both + read 0, which they still would right after sigtarget's write+close + if this process had not attached yet -- observed for real (sigtarget + raced ahead, wrote its ready byte, closed, and freed the slot before + this process's own attach below ever ran, which then failed outright + on the now-invalid handle). Attaching first makes readers==1 from + the very start, so that race window never opens. */ + long readyFd = userPipeAttach(readyHandle, 0); + if (readyFd < 0) { + print("sigtest: FAIL attach ready pipe\n"); + return 1; + } + + char readyHandleStr[24]; + decToString(readyHandle, readyHandleStr); + + char* targetArgv[] = { "/bin/sigtarget", readyHandleStr, 0 }; + long target = userSpawn("/bin/sigtarget", targetArgv); + if (target < 0) { + print("sigtest: FAIL spawn sigtarget\n"); + return 1; + } + + char readyByte; + /* Real blocking read: does not return until sigtarget has actually + installed its handler and written this byte (see sigtarget.c) -- + the happens-before guarantee a fixed userSleep could not give. */ + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("sigtest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + + if (userSigSend(target, sigUsr1) != 0) { + print("sigtest: FAIL send sigUsr1 to sigtarget\n"); + return 1; + } + print("sigtest: sent sigUsr1 to sigtarget\n"); + + long status = userWait(target); + print("sigtest: sigtarget exit status="); + printDec(status); + print("\n"); + if (status != 42) { + print("sigtest: FAIL expected sigtarget exit status 42\n"); + return 1; + } + print("sigtest: handler-roundtrip-ok\n"); + + long defaultTarget = userSpawn("/bin/sigdefault", 0); + if (defaultTarget < 0) { + print("sigtest: FAIL spawn sigdefault\n"); + return 1; + } + + userSleep(100); + + if (userSigSend(defaultTarget, sigTerm) != 0) { + print("sigtest: FAIL send sigTerm to sigdefault\n"); + return 1; + } + print("sigtest: sent sigTerm to sigdefault\n"); + + long defaultStatus = userWait(defaultTarget); + print("sigtest: sigdefault exit status="); + printDec(defaultStatus); + print("\n"); + if (defaultStatus != 128 + sigTerm) { + print("sigtest: FAIL expected sigdefault exit status 128+sigTerm\n"); + return 1; + } + print("sigtest: default-terminate-ok\n"); + + return 0; +} diff --git a/user/spawntarget.c b/user/spawntarget.c new file mode 100644 index 0000000..1e5f045 --- /dev/null +++ b/user/spawntarget.c @@ -0,0 +1,13 @@ +#include "ulib.h" + +/* A plain child program: only ever launched via proctest.c's userSpawn, so + its pid is a genuinely different, freshly created thread rather than the + caller's own (unlike exectarget.c, which replaces the caller in place). */ + +int main(void) +{ + print("spawntarget: child running, pid="); + printDec(userGetpid()); + print("\n"); + return 3; +} diff --git a/user/stderrreader.c b/user/stderrreader.c new file mode 100644 index 0000000..d2ec0d1 --- /dev/null +++ b/user/stderrreader.c @@ -0,0 +1,53 @@ +#include "ulib.h" + +/* + * Companion to stderrwriter.c -- reads fd 0 (redirected by the shell to a + * REAL file via `<`, jobctlApplyRedirects) in a loop until a real EOF, + * exactly like redirreader.c does, then compares byte-for-byte against two + * back-to-back copies of stderrwriter.c's own payload -- proof that `2>` + * (create/truncate) followed by `2>>` (append) on the SAME path produced + * exactly that file, not a truncated/duplicated/reordered one. + * + * This process's own fd 1 (stdout) is NOT redirected -- print() (ulib.h) + * reaches the real console exactly like any unredirected demo, so the + * needle below is exactly as observable to ci-test.sh as every other one. + */ + +static int streq(const char* a, const char* b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +int main(void) +{ + print("stderrreader: reading redirected stdin\n"); + + char buffer[80]; + unsigned long total = 0; + for (;;) { + long got = userRead(0, buffer + total, sizeof(buffer) - 1 - total); + if (got < 0) { + print("stderrreader: FAIL read\n"); + return 1; + } + if (got == 0) + break; + total += (unsigned long)got; + if (total >= sizeof(buffer) - 1) + break; + } + buffer[total] = 0; + + print("stderrreader: got: "); + print(buffer); + print("\n"); + + int ok = streq(buffer, "stderr-payload-okstderr-payload-ok"); + print(ok ? "stderrreader: payload-ok\n" : "stderrreader: payload-FAIL\n"); + + return ok ? 0 : 1; +} diff --git a/user/stderrwriter.c b/user/stderrwriter.c new file mode 100644 index 0000000..e630789 --- /dev/null +++ b/user/stderrwriter.c @@ -0,0 +1,40 @@ +#include "ulib.h" + +/* + * Live ring-3 proof for shell.c's new `cmd 2> file` / `cmd 2>> file` stderr + * redirection (jobctlApplyRedirects/jobctlSpawnForegroundRedirected, + * jobctl.h): the exact mirror image of redirwriter.c's own `>`/`>>` proof, + * with fd 1 and fd 2's roles swapped -- this program writes its payload to + * fd 2 (the fd the shell redirects here) and its own diagnostics to fd 1 + * (which stays on the real console in this demo, since only fd 2 is ever + * redirected). Writing the diagnostics through fd 2 would corrupt the exact + * bytes stderrreader.c later compares, the same hazard redirwriter.c avoids + * by keeping its own diagnostics off fd 1. + * + * kmain.c's stderrDriverThread runs this program TWICE against the same + * path -- once through `2>` (truncate/create) and once through `2>>` + * (append) -- so the resulting file holds two back-to-back copies of this + * same payload with no separator, proving `2>>` genuinely continues from + * the end of what `2>` already wrote rather than overwriting it. + */ +#define stderrPayload "stderr-payload-ok" + +static void printOut(const char* text) +{ + userWrite(1, text, stringLength(text)); +} + +int main(void) +{ + printOut("stderrwriter: writing payload to stderr (redirected)\n"); + + unsigned long length = stringLength(stderrPayload); + long written = userWrite(2, stderrPayload, length); + if (written != (long)length) { + printOut("stderrwriter: FAIL short/failed write\n"); + return 1; + } + + printOut("stderrwriter: wrote payload\n"); + return 0; +} diff --git a/user/stopchldtarget.c b/user/stopchldtarget.c new file mode 100644 index 0000000..6fa58d6 --- /dev/null +++ b/user/stopchldtarget.c @@ -0,0 +1,68 @@ +#include "ulib.h" + +/* + * Only ever spawned by stopchldtest.c, with two shared pipe handles as + * argv[1] (the "ready" pipe, this process writes) and argv[2] (the "go" + * pipe, this process reads) -- identical shape to user/jobtarget.c, reused + * here so stopchldtest.c can stop/continue a target genuinely Blocked + * mid-syscall while ALSO watching for the new SIGCHLD-on-stop/ + * SIGCHLD-on-continue notifications (sched.c's threadStop/threadContinue) + * against itself, something jobtest.c/jobtarget.c never needed since + * neither side there installs a sigChld handler. A distinct exit code (66, + * not jobtarget.c's 55) keeps the two pairs' needles/asserts from ever + * being confusable in the serial log. + */ + +int main(int argc, char** argv) +{ + print("stopchldtarget: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (argc != 3) { + print("stopchldtarget: FAIL expected two argv (ready handle, go handle)\n"); + return 1; + } + + long readyHandle = parseDec(argv[1]); + long goHandle = parseDec(argv[2]); + + long goFd = userPipeAttach(goHandle, 0); + if (goFd < 0) { + print("stopchldtarget: FAIL attach go pipe as reader\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 1); + if (readyFd < 0) { + print("stopchldtarget: FAIL attach ready pipe as writer\n"); + return 1; + } + if (userWrite((int)readyFd, "r", 1) != 1) { + print("stopchldtarget: FAIL write ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("stopchldtarget: ready, blocking on go pipe\n"); + + char goByte = 0; + /* Real blocking read: parks this thread (threadBlocked) until a byte is + actually available. stopchldtest.c raises SIGSTOP against this exact + blocking read, waits for its own SIGCHLD-on-stop notification, writes + the byte, raises SIGCONT, waits for its own SIGCHLD-on-continue + notification, and only then this read can return -- the same + restart-syscall retry contract user/jobtarget.c already relies on. */ + if (userRead((int)goFd, &goByte, 1) != 1) { + print("stopchldtarget: FAIL read go byte\n"); + return 1; + } + userClose((int)goFd); + + if (goByte != 'g') { + print("stopchldtarget: FAIL wrong go byte\n"); + return 1; + } + print("stopchldtarget: received go\n"); + + return 66; +} diff --git a/user/stopchldtest.c b/user/stopchldtest.c new file mode 100644 index 0000000..0df4c2c --- /dev/null +++ b/user/stopchldtest.c @@ -0,0 +1,180 @@ +#include "ulib.h" + +/* + * Boot-time driver for the new SIGCHLD-on-stop/SIGCHLD-on-continue + * notification (sched.c's threadStop/threadContinue, mirroring + * threadExit/threadKill's existing sigChld-on-death contract): proves the + * kernel itself notifies a process's parent, asynchronously, both when a + * child it spawned actually stops AND when it later resumes -- not only + * when it eventually dies (user/sigchldtest.c already covers death; + * user/jobtest.c already covers stop/continue genuinely working against a + * thread blocked mid-syscall, but installs no sigChld handler at all). + * This is the first live demo combining both: a real installed handler + * (sigchldtest.c's shape) watching a target genuinely Blocked in its own + * syscall (jobtest.c's shape) across THREE distinct lifecycle events in a + * single run. + * + * sigChld's pending bit (signal.c) is a single flag, not a queue -- two + * raises before the handler ever runs would coalesce into one delivery. + * This is why each stage below waits (the same bounded "check a counter + * every userSleep(20)" poll idiom sigchldtest.c/masktarget.c/nesttarget.c + * already use) for its own handler firing to be OBSERVED before triggering + * the next event: stop, confirm, write+cont, confirm, wait, confirm -- + * proving all three notifications actually land distinctly rather than + * silently collapsing into fewer than three. + * + * Two independent pipes (ready, go), attached before spawning, for the + * same "attach first" race-avoidance reason user/jobtest.c's own file + * comment documents at length. + */ + +static volatile int chldSigCount = 0; + +static void onChld(int signum) +{ + (void)signum; + chldSigCount++; +} + +/* Bounded poll for chldSigCount reaching `want`, exactly the idiom + user/sigchldtest.c's own file comment explains: whether the kernel event + already happened or is about to, the next natural sleep-wake cycle's + return to ring 3 re-checks pending signals unconditionally (isr.c's + deliverPendingSignals), so this never has to guess timing. */ +static int waitForChldSigCount(int want) +{ + for (int i = 0; i < 80 && chldSigCount < want; i++) + userSleep(20); + return chldSigCount >= want; +} + +int main(void) +{ + print("stopchldtest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userSigAction(sigChld, onChld, 0, 0) == (long)sigSetFailed) { + print("stopchldtest: FAIL install sigchld handler\n"); + return 1; + } + + long readyHandle = userPipeCreate(); + long goHandle = userPipeCreate(); + if (readyHandle < 0 || goHandle < 0) { + print("stopchldtest: FAIL create pipes\n"); + return 1; + } + + long readyFd = userPipeAttach(readyHandle, 0); + long goFd = userPipeAttach(goHandle, 1); + if (readyFd < 0 || goFd < 0) { + print("stopchldtest: FAIL attach pipes\n"); + return 1; + } + + char readyHandleStr[24]; + char goHandleStr[24]; + decToString(readyHandle, readyHandleStr); + decToString(goHandle, goHandleStr); + + char* targetArgv[] = { "/bin/stopchldtarget", readyHandleStr, goHandleStr, 0 }; + long target = userSpawn("/bin/stopchldtarget", targetArgv); + if (target < 0) { + print("stopchldtest: FAIL spawn stopchldtarget\n"); + return 1; + } + + char readyByte; + if (userRead((int)readyFd, &readyByte, 1) != 1) { + print("stopchldtest: FAIL read ready byte\n"); + return 1; + } + userClose((int)readyFd); + print("stopchldtest: stopchldtarget ready\n"); + + if (userSigSend(target, sigStop) != 0) { + print("stopchldtest: FAIL send sigStop to stopchldtarget\n"); + return 1; + } + print("stopchldtest: sent sigStop to stopchldtarget\n"); + + if (!waitForChldSigCount(1)) { + print("stopchldtest: FAIL sigchld-on-stop never fired\n"); + return 1; + } + print("stopchldtest: sigchld-on-stop-ok\n"); + + /* Same "write while stopped" proof as jobtest.c: the byte the target is + blocked on queues up behind a genuinely Stopped thread without being + lost or delivered early. */ + if (userWrite((int)goFd, "g", 1) != 1) { + print("stopchldtest: FAIL write go byte\n"); + return 1; + } + + if (userSigSend(target, sigCont) != 0) { + print("stopchldtest: FAIL send sigCont to stopchldtarget\n"); + return 1; + } + print("stopchldtest: sent sigCont to stopchldtarget\n"); + userClose((int)goFd); + + if (!waitForChldSigCount(2)) { + print("stopchldtest: FAIL sigchld-on-continue never fired\n"); + return 1; + } + print("stopchldtest: sigchld-on-continue-ok\n"); + + /* onChld is installed WITHOUT sigFlagRestart (see above), so this + userWait is itself one of the three retryable syscalls sigConsumePending + (signal.c) can hijack: if stopchldtarget's own death (and the sigChld + it raises against US, the exact signal this handler is for) happens + WHILE this thread is genuinely parked mid userWait's retry-on-block + pattern, delivery does not politely wait its turn behind the retry -- + it REWRITES the interrupted context to return errInterrupted right + then, exactly like restarttarget.c's own phase 1 proves for an + unrelated signal/syscall pair, and the retry that would have found + the now-dead child never gets to run at all. A real, correctly + written POSIX program with a non-restarting SIGCHLD handler has this + exact same hazard against its own wait()/waitpid() and handles it the + same way: loop past EINTR. Only the FAST path (target already Dead + by the time this syscall's body first runs, no parking needed at + all) returns 66 on the first try; whichever way it goes, the retry + (if one was needed) always finds the child already reaped-or-dead, + so this never loops more than twice in practice. */ + long status; + for (;;) { + status = userWait(target); + if (status != errInterrupted) + break; + } + print("stopchldtest: stopchldtarget exit status="); + printDec(status); + print("\n"); + if (status != 66) { + print("stopchldtest: FAIL expected stopchldtarget exit status 66\n"); + return 1; + } + + /* Whether userWait above needed one retry or returned 66 immediately, + onChld has by now fired exactly a third time: either it ran AS the + interruption above (incrementing the count before rewriting status + to errInterrupted, so the retry's own later success finds it already + at 3), or it ran as a genuine nested delivery riding the same + successful return home (the fast-path case) -- sigChld's pending bit + is a single flag either way, so exactly one of those two shapes + happens per death, never both, never neither. This checks the count + is EXACTLY 3, not just at least 3, proving stop+continue+exit + produced three distinct notifications rather than two of them + silently coalescing into one pending bit somewhere along the way. */ + if (chldSigCount != 3) { + print("stopchldtest: FAIL expected exactly 3 sigchld deliveries, got "); + printDec(chldSigCount); + print("\n"); + return 1; + } + + print("stopchldtest: job-control-sigchld-ok\n"); + return 0; +} diff --git a/user/timetest.c b/user/timetest.c new file mode 100644 index 0000000..5e2a964 --- /dev/null +++ b/user/timetest.c @@ -0,0 +1,89 @@ +#include "ulib.h" +#include "fs.h" + +/* + * Exercises SFS's new real wall-clock inode timestamps (sfs.h's + * sfsMtimeOf/sfsCtimeOf) through the exact same syscall path a real + * userspace program would use (userOpen/userWrite/userStat/userLink on + * "/mnt", like fstest.c/linktest.c already do for size/content), proving the + * full stack -- syscall -> vfs -> sfs -> the on-disk inode's mtime/ctime + * fields, stamped from the real CMOS RTC (rtc.c) -- works end to end, not + * just the in-kernel sfs.c unit tests. Fully synchronous like nicetest.c/ + * date.c: no injected keystrokes, no shared shell channel, so there is no + * scheduling-timing race to flake on regardless of host load. + */ + +int main(void) +{ + print("timetest: start, pid="); + printDec(userGetpid()); + print("\n"); + + if (userMkdir("/mnt/timetest") != 0) { + print("timetest: FAIL mkdir /mnt/timetest\n"); + return 1; + } + + long before = userTime(); + int fd = (int)userOpen("/mnt/timetest/data.txt", oReadWrite | oCreate); + long after = userTime(); + if (fd < 0) { + print("timetest: FAIL open for create\n"); + return 1; + } + + FbStat st; + if (userStat("/mnt/timetest/data.txt", &st) != 0) { + print("timetest: FAIL stat after create\n"); + return 1; + } + long createdMtime = (long)st.mtime; + long createdCtime = (long)st.ctime; + if (createdMtime != createdCtime || createdMtime < before || createdMtime > after) { + print("timetest: FAIL creation timestamp out of range\n"); + return 1; + } + print("timetest: create-stamped-ok\n"); + + const char* payload = "timetest payload\n"; + unsigned long payloadLen = stringLength(payload); + if (userWrite(fd, payload, payloadLen) != (long)payloadLen) { + print("timetest: FAIL write\n"); + return 1; + } + userClose(fd); + + if (userStat("/mnt/timetest/data.txt", &st) != 0) { + print("timetest: FAIL stat after write\n"); + return 1; + } + if ((long)st.mtime != (long)st.ctime || (long)st.mtime < createdMtime) { + print("timetest: FAIL write did not bump mtime\n"); + return 1; + } + print("timetest: write-stamped-ok\n"); + long mtimeAfterWrite = (long)st.mtime; + + if (userLink("/mnt/timetest/data.txt", "/mnt/timetest/alias.txt") != 0) { + print("timetest: FAIL hardlink\n"); + return 1; + } + + FbStat aliasSt; + if (userStat("/mnt/timetest/alias.txt", &aliasSt) != 0) { + print("timetest: FAIL stat alias\n"); + return 1; + } + if ((long)aliasSt.mtime != mtimeAfterWrite) { + print("timetest: FAIL hardlink does not share mtime\n"); + return 1; + } + if ((long)aliasSt.ctime < mtimeAfterWrite) { + print("timetest: FAIL hardlink ctime went backwards\n"); + return 1; + } + print("timetest: hardlink-shares-mtime-ok\n"); + + print("timetest: PASS\n"); + return 0; +} diff --git a/user/ulib.c b/user/ulib.c index f0ea959..fb279d6 100644 --- a/user/ulib.c +++ b/user/ulib.c @@ -11,6 +11,41 @@ static long syscall3(long number, long a1, long a2, long a3) return ret; } +/* Same trap as syscall3, with a 4th argument -- passed in r10, not rcx, + the same choice the real Linux x86-64 syscall ABI makes (rcx is already + spoken for by the `int $0x80`/syscall instruction's own use, so a fourth + argument needs a register the trap does not otherwise touch). Only + userSigAction needs one today. */ +static long syscall4(long number, long a1, long a2, long a3, long a4) +{ + long ret; + register long a4Reg __asm__("r10") = a4; + __asm__ volatile( + "int $0x80" + : "=a"(ret) + : "a"(number), "D"(a1), "S"(a2), "d"(a3), "r"(a4Reg) + : "memory", "rcx", "r11"); + return ret; +} + +/* Same trap again, with a 5th and 6th argument in r8/r9 -- the same real + Linux x86-64 syscall ABI register order this file's own 4th-argument + choice (r10) already follows, continued one register further. Only + userMmap needs this many today. */ +static long syscall6(long number, long a1, long a2, long a3, long a4, long a5, long a6) +{ + long ret; + register long a4Reg __asm__("r10") = a4; + register long a5Reg __asm__("r8") = a5; + register long a6Reg __asm__("r9") = a6; + __asm__ volatile( + "int $0x80" + : "=a"(ret) + : "a"(number), "D"(a1), "S"(a2), "d"(a3), "r"(a4Reg), "r"(a5Reg), "r"(a6Reg) + : "memory", "rcx", "r11"); + return ret; +} + long userWrite(int fd, const char* buffer, unsigned long length) { return syscall3(sysWrite, fd, (long)buffer, (long)length); @@ -46,6 +81,16 @@ void userSleep(unsigned long milliseconds) syscall3(sysSleep, (long)milliseconds, 0, 0); } +unsigned long userAlarm(unsigned long seconds) +{ + return (unsigned long)syscall3(sysAlarm, (long)seconds, 0, 0); +} + +long userPoll(PollFd* fds, unsigned long nfds, long timeoutMs) +{ + return syscall3(sysPoll, (long)fds, (long)nfds, timeoutMs); +} + long userOpen(const char* path, unsigned int flags) { return syscall3(sysOpen, (long)path, (long)flags, 0); @@ -81,6 +126,211 @@ long userUnlink(const char* path) return syscall3(sysUnlink, (long)path, 0, 0); } +long userRmdir(const char* path) +{ + return syscall3(sysRmdir, (long)path, 0, 0); +} + +long userChmod(const char* path, unsigned int mode) +{ + return syscall3(sysChmod, (long)path, (long)mode, 0); +} + +long userChown(const char* path, unsigned int uid) +{ + return syscall3(sysChown, (long)path, (long)uid, 0); +} + +long userGetuid(void) +{ + return syscall3(sysGetuid, 0, 0, 0); +} + +long userSetuid(unsigned int uid) +{ + return syscall3(sysSetuid, (long)uid, 0, 0); +} + +long userGetgid(void) +{ + return syscall3(sysGetgid, 0, 0, 0); +} + +long userSetgid(unsigned int gid) +{ + return syscall3(sysSetgid, (long)gid, 0, 0); +} + +long userChgrp(const char* path, unsigned int gid) +{ + return syscall3(sysChgrp, (long)path, (long)gid, 0); +} + +long userChdir(const char* path) +{ + return syscall3(sysChdir, (long)path, 0, 0); +} + +long userGetcwd(char* buffer, unsigned long cap) +{ + return syscall3(sysGetcwd, (long)buffer, (long)cap, 0); +} + +long userGetEnv(const char* name, char* buffer, unsigned long cap) +{ + return syscall3(sysGetEnv, (long)name, (long)buffer, (long)cap); +} + +long userSetEnv(const char* name, const char* value) +{ + return syscall3(sysSetEnv, (long)name, (long)value, 0); +} + +long userUnsetEnv(const char* name) +{ + return syscall3(sysUnsetEnv, (long)name, 0, 0); +} + +long userPipe(int fds[2]) +{ + return syscall3(sysPipe, (long)fds, 0, 0); +} + +long userPipeCreate(void) +{ + return syscall3(sysPipeCreate, 0, 0, 0); +} + +long userPipeAttach(long handle, int wantWrite) +{ + return syscall3(sysPipeAttach, handle, wantWrite, 0); +} + +long userSymlink(const char* target, const char* path) +{ + return syscall3(sysSymlink, (long)target, (long)path, 0); +} + +long userReadlink(const char* path, char* buffer, unsigned long cap) +{ + return syscall3(sysReadlink, (long)path, (long)buffer, (long)cap); +} + +long userLink(const char* existingPath, const char* newPath) +{ + return syscall3(sysLink, (long)existingPath, (long)newPath, 0); +} + +long userMkfifo(const char* path) +{ + return syscall3(sysMkfifo, (long)path, 0, 0); +} + +long userExec(const char* path, char* const argv[]) +{ + return syscall3(sysExec, (long)path, (long)argv, 0); +} + +long userSpawn(const char* path, char* const argv[]) +{ + return syscall3(sysSpawn, (long)path, (long)argv, 0); +} + +long userWait(long pid) +{ + return syscall3(sysWait, pid, 0, 0); +} + +long userWaitAny(long* outPid) +{ + return syscall3(sysWait, (long)waitAnyPid, (long)outPid, 0); +} + +long userKill(long pid) +{ + return syscall3(sysKill, pid, 0, 0); +} + +long userSigSend(long pid, int signum) +{ + return syscall3(sysSigSend, pid, signum, 0); +} + +long userPidfdOpen(long pid) +{ + return syscall3(sysPidfdOpen, pid, 0, 0); +} + +long userPidfdSend(long fd, int signum) +{ + return syscall3(sysPidfdSend, fd, signum, 0); +} + +long userPidfdWait(long fd) +{ + return syscall3(sysPidfdWait, fd, 0, 0); +} + +void* userMmap(void* addrHint, unsigned long length, int prot, int flags, int fd, long offset) +{ + return (void*)syscall6(sysMmap, (long)addrHint, (long)length, prot, flags, fd, offset); +} + +long userMunmap(void* addr, unsigned long length) +{ + return syscall3(sysMunmap, (long)addr, (long)length, 0); +} + +long userSigAction(int signum, SigHandler handler, unsigned long mask, unsigned long flags) +{ + return syscall4(sysSigAction, signum, (long)handler, (long)mask, (long)flags); +} + +long userSigActionInfo(int signum, SigInfoHandler handler, unsigned long mask, unsigned long flags) +{ + return syscall4(sysSigAction, signum, (long)handler, (long)mask, (long)flags); +} + +unsigned long userSigProcMask(int how, unsigned long mask) +{ + return (unsigned long)syscall3(sysSigProcMask, how, (long)mask, 0); +} + +long userSetpgid(long pid, long pgid) +{ + return syscall3(sysSetpgid, pid, pgid, 0); +} + +long userGetpgid(long pid) +{ + return syscall3(sysGetpgid, pid, 0, 0); +} + +long userNice(long pid, long weight) +{ + return syscall3(sysNice, pid, weight, 0); +} + +long userGetPriority(long pid) +{ + return syscall3(sysGetPriority, pid, 0, 0); +} + +long userTime(void) +{ + return syscall3(sysTime, 0, 0, 0); +} + +long userSetForegroundPgid(long pgid) +{ + return syscall3(sysSetFgPgrp, pgid, 0, 0); +} + +long userGetForegroundPgid(void) +{ + return syscall3(sysGetFgPgrp, 0, 0, 0); +} + unsigned long stringLength(const char* text) { unsigned long length = 0; @@ -121,3 +371,43 @@ void printDec(long value) ordered[i] = reversed[length - 1 - i]; userWrite(1, ordered, (unsigned long)length); } + +void decToString(long value, char* out) +{ + unsigned long magnitude; + int i = 0; + if (value < 0) { + out[i++] = '-'; + magnitude = (unsigned long)(-value); + } else { + magnitude = (unsigned long)value; + } + + char digits[24]; + int length = 0; + do { + digits[length++] = (char)('0' + (magnitude % 10)); + magnitude /= 10; + } while (magnitude > 0); + + while (length > 0) + out[i++] = digits[--length]; + out[i] = 0; +} + +long parseDec(const char* text) +{ + int i = 0; + int negative = 0; + if (text[0] == '-') { + negative = 1; + i = 1; + } + + long value = 0; + while (text[i] >= '0' && text[i] <= '9') { + value = value * 10 + (text[i] - '0'); + i++; + } + return negative ? -value : value; +} diff --git a/user/ulib.h b/user/ulib.h index 72967f4..a37970d 100644 --- a/user/ulib.h +++ b/user/ulib.h @@ -3,6 +3,19 @@ #include "syscall.h" #include "fs.h" +#include "signalnum.h" +#include "poll.h" + +typedef void (*SigHandler)(int signum); + +/* Real sigaction()'s sa_sigaction shape: signum (same as SigHandler's own + argument, kept for parity -- see signalnum.h's SigInfo.siSignum comment), + a pointer to the real SigInfo describing THIS delivery, and a context + pointer this kernel always hands back as NULL (see signalnum.h's + sigFlagSigInfo comment for why). Only ever meaningful for a handler + installed through userSigActionInfo below, WITH sigFlagSigInfo actually + set in its flags -- see that function's own doc comment. */ +typedef void (*SigInfoHandler)(int signum, SigInfo* info, void* context); long userWrite(int fd, const char* buffer, unsigned long length); long userRead(int fd, char* buffer, unsigned long length); @@ -12,6 +25,22 @@ long userGetpid(void); void* userSbrk(long increment); void userSleep(unsigned long milliseconds); +/* Real alarm(2): schedules sigAlrm against the calling process `seconds` + real seconds from now, replacing any alarm already pending; 0 cancels + instead of scheduling. Returns the whole seconds remaining on whatever + alarm was pending before this call (rounded up), or 0 if none was. See + sched.h's threadSetAlarm for the full contract. */ +unsigned long userAlarm(unsigned long seconds); + +/* Real poll(2): waits for any of `nfds` PollFd entries (poll.h) to become + ready, filling each one's revents in place. timeoutMs < 0 waits forever, + 0 reports current readiness without ever blocking, > 0 blocks at most + that many milliseconds (rounded up to this kernel's ~10ms PIT tick + granularity -- see sched.c's threadSetAlarm for the same rounding on a + different timer). Returns the number of entries with a nonzero revents + (0 on timeout, never negative except -1 for a bad nfds/pointer). */ +long userPoll(PollFd* fds, unsigned long nfds, long timeoutMs); + long userOpen(const char* path, unsigned int flags); long userClose(int fd); long userLseek(int fd, long offset, int whence); @@ -20,8 +49,384 @@ long userGetdents(int fd, FbDirent* out, unsigned long length); long userMkdir(const char* path); long userUnlink(const char* path); +/* Real rmdir(2): the counterpart userUnlink above refuses a directory + (EISDIR); this refuses anything that is NOT an empty directory + (ENOTDIR/ENOTEMPTY, both collapsed to -1 here, matching userUnlink's own + "just -1" error shape). */ +long userRmdir(const char* path); + +/* Real chmod(2): overwrites `path`'s whole owner-rwx mode (fs.h's + fsModeOwner*, e.g. 0644) with `mode` -- no symbolic +x-style merge, + matching real chmod's own numeric-mode form. 0 on success, -1 for a + missing path or (tmpfs only) a node under a non-writable mount, same + restriction userUnlink/userRmdir already have on the ustar-loaded + backing store. Takes effect immediately for any FUTURE userOpen/ + userSpawn/userExec on this path -- an fd already open, or a process + already running, is never affected (real Unix only checks permission at + open/exec time). */ +long userChmod(const char* path, unsigned int mode); + +/* Real chown(2) (vfs.h's vfsChown): reassigns `path`'s owner uid outright. + Root-only (this process's own userGetuid() must be 0), full stop, unlike + userChmod -- real Unix's _POSIX_CHOWN_RESTRICTED default means not even a + file's own owner may give it away. -1 for a missing path, a node under a + non-writable mount, or a non-root caller; the target's uid is left + completely unchanged on any failure. */ +long userChown(const char* path, unsigned int uid); + +/* Real getuid(2)/setuid(2) (sched.h's Thread.uid, currentThreadUid/ + currentThreadSetUid): userGetuid always succeeds, returning THIS + process's own current uid. userSetuid enforces the real privilege rule + itself (no additional check happens at the syscall layer) -- 0 ("root") + may become any uid; any other uid may only pass its own current value + back in (a no-op that still returns 0, matching real setuid(getuid())), + and any other target returns -1 with the credential unchanged. There is + no way back to uid 0 once dropped short of already being uid 0 at the + time of the call -- a real, permanent privilege drop for the lifetime of + this process, exactly like a daemon that setuid()s away from root once + at startup. */ +long userGetuid(void); +long userSetuid(unsigned int uid); + +/* Real getgid(2)/setgid(2) (sched.h's Thread.gid, currentThreadGid/ + currentThreadSetGid): userGetgid always succeeds, returning THIS + process's own current gid. userSetgid's privilege gate is THIS process's + own userGetuid() (== 0), not its gid -- matching real setgid(2)'s + CAP_SETGID-shaped rule -- any non-root caller may only pass its own + current gid back in (a no-op that still returns 0), any other target + returns -1 unchanged. */ +long userGetgid(void); +long userSetgid(unsigned int gid); + +/* Real chgrp(2) (vfs.h's vfsChgrp): reassigns `path`'s group gid. Unlike + userChown (root-only, full stop), the file's own owner may also call + this -- but only to set the group to THIS process's own current gid + (userGetgid()), never an arbitrary one, since this kernel tracks exactly + one gid per process (no supplementary-groups list). Root may set any + gid. -1 for a missing path, a node under a non-writable mount, a + non-owner non-root caller, or a non-root caller naming any gid other + than its own. */ +long userChgrp(const char* path, unsigned int gid); + +/* Real chdir(2)/getcwd(2) (sched.h's Thread.cwd, vfs.h's vfsChdir/ + vfsGetcwd): userChdir resolves a relative `path` against THIS process's + own current cwd, requires the result to already be a real directory, and + makes it this process's new cwd -- 0 on success, -1 (nothing changed) if + it does not name a directory. A child spawned by userSpawn/userExec + AFTER this call inherits the new value; this process's parent, and any + already-spawned sibling, are never affected (each process's cwd is an + independent copy, not shared state). userGetcwd copies the current + value into `buffer` (nul-terminated) and returns its length, or -1 + (buffer untouched) if it does not fit `cap` -- real getcwd(2)'s own + ERANGE case. */ +long userChdir(const char* path); +long userGetcwd(char* buffer, unsigned long cap); + +/* Real getenv(3)/setenv(3) (sched.h's Thread.env, syscall.c's sysGetEnv/ + sysSetEnv): userGetEnv copies THIS process's own value for `name` into + `buffer` (nul-terminated) and returns its length, or -1 (buffer + untouched) if `name` has no entry or the value does not fit `cap`. + userSetEnv overwrites an existing entry with a matching `name`, or + creates a new one, in THIS process's own table only -- 0 on success, -1 + if the combined "NAME=VALUE" would not fit the kernel's fixed entry size + or the table is already full. A child spawned by userSpawn/userExec + AFTER a userSetEnv call inherits the new value (a real, independent + copy, same as userChdir above); this process's parent, and any + already-spawned sibling, are never affected. */ +long userGetEnv(const char* name, char* buffer, unsigned long cap); +long userSetEnv(const char* name, const char* value); + +/* Real unsetenv(3): removes THIS process's own entry named `name` from its + table, if any, so a following userGetEnv on the same name returns -1 + again -- 0 whether or not an entry actually existed (matching real + unsetenv(3), a no-op success either way), never touches the parent's or + any sibling's own table (same independent-copy shape as userSetEnv). */ +long userUnsetEnv(const char* name); + +long userPipe(int fds[2]); + +/* Shared pipes: unlike userPipe (both ends always land in the caller's own + fd table), userPipeCreate returns a raw handle number that means nothing + until an fd table actually attaches to it (userPipeAttach) -- meant to be + handed to a different, independently spawned process (e.g. as an argv + string) so the two can share a pipe without needing fork(). Once + attached, userRead/userWrite on that fd really block (park the whole + process, not busy-retry) instead of returning -1 for "try again" the way + an unshared userPipe end does. */ +long userPipeCreate(void); +long userPipeAttach(long handle, int wantWrite); +long userSymlink(const char* target, const char* path); +long userReadlink(const char* path, char* buffer, unsigned long cap); +long userLink(const char* existingPath, const char* newPath); + +/* Named pipe: creates a fifo at `path` (see vfsMkfifo, vfs.h). Unlike + userPipeCreate/userPipeAttach, which need a raw handle number passed to + both ends (e.g. as an argv string, see fifomaster.c), any two processes + that simply agree on a path can rendezvous by both calling userOpen on it + -- oWriteOnly for a blocking write end, oReadOnly for a blocking read + end. 0 on success, -1 if the path already exists or its parent is not a + writable directory. */ +long userMkfifo(const char* path); +/* argv is a NULL-terminated array of C strings, exactly like execv(3)'s -- + pass nullptr for "no arguments" (argc 0 on the other side). */ +long userExec(const char* path, char* const argv[]); +long userSpawn(const char* path, char* const argv[]); +long userWait(long pid); + +/* waitpid(-1)-style "wait for ANY child, not one specific pid": reaps + whichever child of the caller happens to already be dead (or blocks, the + same real blocking semantics as userWait(pid), until one dies) and + returns its exit code. Writes the reaped child's own pid to *outPid + (ignored if NULL). Returns -1 immediately, without blocking, if the + caller currently has no children at all, dead or alive -- the real + waitpid(-1)'s ECHILD case. See user/waitanytest.c for a live demo. */ +long userWaitAny(long* outPid); + +/* Unconditionally terminates another process by pid -- the only way to + recover a process genuinely stuck forever (e.g. blocked in userRead on a + shared pipe whose writer never shows up, see userPipeAttach). 0 on + success, -1 for an invalid pid, an already-dead one, or your own. The + killed process gets no chance to run any cleanup -- its parent's + userWait still reports its exit status, which will be exactly 9. Unlike + userSigSend below, this can never be caught, blocked, or ignored -- + internally it goes straight to threadKill (sched.c), the same as + userSigSend(pid, sigKill) does (signal.h). + + Real kill(2)'s own pid<=0 convention (sched.h's Thread.pgid) also + applies here: pid==0 kills every OTHER member of the caller's own + process group (never the caller itself -- a Running thread is never one + of threadKill's accepted states, the same reason plain self-targeting + already does nothing special); pid<0 kills every member of group -pid. + 0 is still returned for success as long as AT LEAST ONE target thread + was actually killed (real kill(2)'s own ESRCH-if-nobody-matched + semantics for the group form). */ +long userKill(long pid); + +/* Real, catchable signal delivery (signal.h) -- see that header for the + full design (a shared trampoline page, delivery on return-to-ring3, + sysSigReturn to resume exactly where a handler interrupted). */ + +/* Raises signal `signum` (signal.h's sigHup..sigTstp) against `pid`. 0 on + success, -1 for a bad signum or an invalid/dead/unused target. sigKill + is delegated straight to the same unconditional path userKill uses -- + it is never caught, blocked, or ignored, matching real kill(2). Any + other signal is only actually delivered once the target next returns to + user code on its own (this kernel does not wake a sleeping/blocked + target early -- see signal.h's file comment). + + Same pid<=0 group convention as userKill above: pid==0 targets the + caller's own process group (INCLUDING itself, unlike userKill's group + form -- sigRaiseInfo has no "never self" exclusion the way threadKill + does), pid<0 targets group -pid. See sched.h's Thread.pgid and + signal.h's sigRaiseGroup for the full design, and user/pgroupfg.c/ + user/pgroupbg.c for a live demo of the SAME scoping rule applied by + keyboard.c's own real Ctrl-C. */ +long userSigSend(long pid, int signum); + +/* Real pidfd_open(2): opens a handle on process `pid` that stays correct + even if `pid`'s own numeric slot is later reaped and handed to a + completely different, unrelated process (pidfd.h/sched.h's + Thread.generation) -- unlike userKill/userSigSend/userWait above, which + all still take a raw pid the exact same way real kill(2)/waitpid(2) do + (and so share their exact same well-known pid-reuse race, deliberately + left unfixed for them, see sched.c's allocSlot doc comment). Returns a + small non-negative fd on success (closed with the ordinary userClose), + or -1 for pid 0, an out-of-range pid, an already-dead-and-reaped or + never-existed pid, or a full pidfd table. Opening a pidfd does NOT keep + the target alive in any way -- it can still exit and be reaped by + anyone else in the meantime, exactly like a raw pid could; the whole + point of this handle is only ever noticing that when it happens, instead + of silently acting on whoever now sits at that same number. */ +long userPidfdOpen(long pid); + +/* Sends signal `signum` to whichever process `fd` (from userPidfdOpen) + still safely refers to. 0 on success, -1 for a bad signum, an `fd` that + is not an open pidfd at all, or one whose original target has since + been reaped or recycled -- the failure userSigSend(cachedPid, signum) + can never distinguish from "the signal was delivered to a live but + completely unrelated process that happens to share that old pid now". + Otherwise identical semantics to userSigSend's own single-target path + (sigKill still unconditional and uncatchable, signum still delivered on + the target's own next return to user code). */ +long userPidfdSend(long fd, int signum); + +/* Real (partial) waitid(2)-over-a-pidfd: blocks until the process `fd` + still safely refers to actually exits, then reaps it and returns its + exit code -- the same real blocking semantics as userWait(pid), except + -1 also covers "the original target was reaped by someone else while + this call was waiting", which userWait(pid) has no way to report (it + would simply keep waiting on whichever unrelated thread now occupies + that pid instead). See user/pidfdtest.c for a live ring-3 demonstration + of a pidfd correctly reporting a just-reaped target as gone through the + real syscall path (not just the in-kernel unit tests), and + schedTest.c's pidfd tests for a deterministic, create/discard/recreate + reproduction of the full scenario -- a stale handle whose slot has + since been handed to a brand new, unrelated, live thread -- that a live + boot cannot reliably force on demand (allocSlot's own round-robin + headroom makes hitting one specific freed slot again take dozens-to- + hundreds of unrelated allocations first, see its doc comment, sched.c). */ +long userPidfdWait(long fd); + +/* Real mmap(2): addrHint of 0 asks the kernel to pick the address itself + (mmap.c's mmapReserve, first-fit inside this process's own mmap region); + a nonzero addrHint asks for that EXACT address, refused (not silently + relocated) if it does not fit -- there is no MAP_FIXED bit here, every + addrHint already behaves like real mmap's own weaker "hint" narrowed + down to "exact or refused". prot is fs.h's mmapProtRead/Write/Exec + (mmapProtExec IS hardware-enforced -- executing a mapping that never + asked for it takes a real SIGSEGV -- see fs.h's own doc comment for the + exact scope, including the CPU-support gate and the same enforcement + applied to a binary's own ELF image and its stack/brk); flags is fs.h's + mmapAnonymous or 0. When mmapAnonymous is NOT + set, `fd` must already be open on a real seekable file (fdFile/fdSfs/ + fdBlockDev -- the same three kinds userLseek's own vfsLseek already + restricts to) and `offset` must be frameSize-aligned; both are ignored + for an anonymous mapping. No physical memory is actually touched until + the first real access to a given page -- see mmap.h's own file comment + for the full demand-paging contract, including why closing `fd` before + every page has been faulted in leaves the rest of this mapping unable + to fill (an honestly scoped limitation, not a hidden bug). Returns the + mapped base address, or (void*)-1 (compare against userMmapFailed) on + any failure: bad alignment, a range outside this process's own mmap + region, overlap with an existing mapping, a bad/wrong-kind fd for a + file-backed mapping, or no mapping slot/free region left. */ +void* userMmap(void* addrHint, unsigned long length, int prot, int flags, int fd, long offset); + +/* Sentinel userMmap returns on failure, real MAP_FAILED's own value -- + never a real mapped address, since every real one falls inside this + process's own bounded 256 MiB elfSlotSize slot (elf.h), nowhere near + the top of the address space. */ +#define userMmapFailed ((void*)-1) + +/* Real munmap(2): see mmap.h's own mmapRelease for the full contract + (partial unmap shrinks the mapping in place, a range entirely inside + one mapping's interior splits it, already-unmapped memory is a no-op). + 0 on success, -1 for a misaligned addr, a zero length, or (a real, + if unlikely, resource failure) no free mapping slot left to hold a + split's own second half. */ +long userMunmap(void* addr, unsigned long length); + +/* Installs `handler` as the calling process's own disposition for + `signum` (nullptr/sigHandlerDefault restores the default action, + sigHandlerIgnore silently discards it), `mask` as its sa_mask -- the + additional signals blocked for as long as THIS handler is running (on top + of whatever was already blocked and signum's own bit, always self-blocked + like real Unix's implicit default) -- and `flags` as its sa_flags + (signalnum.h's sigFlagRestart, or 0). A signal NOT covered by `mask` can + still interrupt this handler with a genuinely nested one of its own + (see signal.h's file comment) rather than waiting for it to return. + Without sigFlagRestart, a signal that interrupts THIS process blocked in + userRead/userWrite/userWait makes that call return errInterrupted once + the handler returns instead of transparently resuming it (real EINTR); + sigFlagRestart opts back into the transparent-resume behavior every + handler had before this flag existed. Returns the PREVIOUS disposition, + or -1 for a bad signum or an attempt to touch sigKill (uncatchable, like + real EINVAL on SIGKILL). `handler` is called with the signal number as + its only argument; it must return normally (not userExit, not + longjmp-style tricks this kernel does not support) for execution to + resume exactly where the signal interrupted. */ +long userSigAction(int signum, SigHandler handler, unsigned long mask, unsigned long flags); + +/* Identical wire format to userSigAction (same syscall, same signum/mask + validation, same PREVIOUS-disposition return value) -- the only + difference is `handler`'s real C type, SigInfoHandler instead of + SigHandler, so a caller does not need its own cast at the call site. The + kernel itself does not distinguish the two function-pointer shapes at + all (it only ever stores/calls a raw address, see signal.c's + sigConsumePending): what actually determines whether the handler is + CALLED with one argument or three is whether `flags` includes + sigFlagSigInfo (signalnum.h) -- the caller must include it here + deliberately, exactly like real sigaction()'s SA_SIGINFO. Passing a + SigInfoHandler through this function WITHOUT that flag set (or, equally, + passing a plain SigHandler through this function WITH it set) is a + caller bug this kernel does not protect against, mirroring real C's own + union-based sa_handler/sa_sigaction footgun -- the handler would run + with whatever raw rsi/rdx happened to hold, not a valid SigInfo + pointer. */ +long userSigActionInfo(int signum, SigInfoHandler handler, unsigned long mask, unsigned long flags); + +/* Blocks (sigMaskBlock), unblocks (sigMaskUnblock), or replaces + (sigMaskSetMask) the calling process's blocked-signal mask, returning + the mask as it was immediately before this call. A blocked signal still + gets raised (sigPending) but is not delivered until unblocked. */ +unsigned long userSigProcMask(int how, unsigned long mask); + +/* Real setpgid(2): moves process `pid` (0 means the caller itself) into + process group `pgid` (0 means "become a new group leader, using pid's + own resolved id as the group id" -- the same "0 means self/derive from + self" shape sysSetFgPgrp does NOT have, since a foreground group is + named directly, never derived). 0 on success, -1 for a pid that does not + name any currently live thread (sched.h's threadSetPgid; id 0, the idle + thread, is also always rejected -- see that function's own doc comment). + No session/permission check at all -- see threadSetPgid's own honestly- + documented omission. */ +long userSetpgid(long pid, long pgid); + +/* Real getpgid(2): returns process `pid`'s current process group (0 means + the caller itself), or -1 for a pid that does not name any currently + live thread. */ +long userGetpgid(long pid); + +/* Real setpriority(PRIO_PROCESS, pid, ...)/nice(2)'s underlying mechanism: + sets process `pid`'s (0 means the caller itself) CFS-lite scheduling + weight (sched.h's threadSetPriority) to `weight` directly -- higher runs + proportionally more often, exactly like kmain.c's own kernel-internal + workerA/workerB demo already proves for a kernel-created thread, now + reachable for any real userspace process including one the caller did + not create itself. Unlike real nice(), takes an absolute weight rather + than a relative increment, and has no privilege check at all (any + process may renice any other) -- this kernel's already-documented, + honest lack of a uid/permission model (see threadSetPgid's own comment + for the same omission). 0 on success, -1 for a `pid` that does not name + any currently live thread, the idle thread (pid 0's OWN id, not the + `pid == 0` self-convention above), or a `weight` of 0. */ +long userNice(long pid, long weight); + +/* Real getpriority(PRIO_PROCESS, pid)-equivalent: returns process `pid`'s + (0 means the caller itself) current CFS-lite scheduling weight, or -1 + for a pid that does not name any currently live thread -- unambiguous + because a real weight is always > 0 (userNice above always rejects + weight 0, and every thread starts at schedDefaultWeight, sched.h). */ +long userGetPriority(long pid); + +/* Real time(NULL): seconds since the Unix epoch, off the kernel's real + CMOS RTC (rtc.h/syscall.c's sysTime) -- every other notion of time this + ulib exposes (userSleep, userAlarm) is relative to boot, not the + calendar. Never fails; a hardware read failure comes back as 0 + (rtc.c's rtcNow contract), the same "epoch itself" sentinel real + time(2) would return for 1970-01-01T00:00:00Z. */ +long userTime(void); + +/* Real tcsetpgrp(3): names `pgid` as the process group keyboard.c's real + Ctrl-C/Ctrl-Z (SIGINT/SIGTSTP) is delivered to from now on -- see + keyboard.h's keyboardSetForegroundPgid for the full contract, including + why 0 (before anything ever calls this) safely means "nobody". Always + succeeds (returns 0): unlike a real terminal driver, this kernel has no + session/controlling-terminal ownership to check the caller against. */ +long userSetForegroundPgid(long pgid); + +/* Real tcgetpgrp(3): the pgid userSetForegroundPgid (by anyone) last set, + or 0 if nobody ever has since boot. */ +long userGetForegroundPgid(void); + void print(const char* text); void printDec(long value); unsigned long stringLength(const char* text); +/* Writes value's decimal representation (with a leading '-' if negative) + into out, nul-terminated -- unlike printDec, which prints it directly, + this is for building an argv string to hand a value (e.g. a shared pipe + handle, see userPipeCreate/userSpawn) to a child process. out must have + room for at least 21 bytes (20 digits for INT64_MIN plus the sign, plus + the terminator). */ +void decToString(long value, char* out); + +/* Parses the decimal integer (optionally '-'-prefixed) at the start of + text, stopping at the first non-digit -- the inverse of decToString, for + a child process to recover a value (e.g. a shared pipe handle) passed to + it as an argv string. No error signaling: text that starts with no + digits at all (after an optional sign) parses as 0, matching how a + deliberately small, trusted set of internal callers uses it. */ +long parseDec(const char* text); + #endif diff --git a/user/user.ld b/user/user.ld index 298a01d..8f2cf91 100644 --- a/user/user.ld +++ b/user/user.ld @@ -1,18 +1,36 @@ ENTRY(_start) +/* Two PT_LOAD segments, not one: text+rodata read+execute only (FLAGS(5) + = PF_R|PF_X), data+bss read+write only (FLAGS(6) = PF_R|PF_W) -- real + W^X for every binary this links, mirroring the kernel's own linker.ld + (:text/:rodata/:data). elf.c's elfLoad reads each segment's own p_flags + (elfSegmentPagingFlags) to map it with paging.h's pageWrite/pageNoExecute + accordingly, so a binary that writes into its own string literals or + jumps into its own .data now takes a real, catchable SIGSEGV instead of + silently succeeding the way one shared RWX segment always allowed -- + see user/elfwritevictim.c and user/elfexecvictim.c for the live proof. */ PHDRS { - load PT_LOAD FILEHDR PHDRS FLAGS(7); + text PT_LOAD FILEHDR PHDRS FLAGS(5); + data PT_LOAD FLAGS(6); } SECTIONS { . = USER_BASE + SIZEOF_HEADERS; - .text : { *(.text .text.*) } :load - .rodata : { *(.rodata .rodata.*) } :load - .data : { *(.data .data.*) } :load - .bss : { *(.bss .bss.*) *(COMMON) } :load + .text : { *(.text .text.*) } :text + .rodata : { *(.rodata .rodata.*) } :text + + /* Fresh page for .data: the two PT_LOAD segments above must never + share a page, or the CPU would have to apply the union of both + segments' flags to that shared page, quietly reintroducing a + writable+executable (or writable .rodata) page exactly where this + split exists to remove one. */ + . = ALIGN(4096); + + .data : { *(.data .data.*) } :data + .bss : { *(.bss .bss.*) *(COMMON) } :data /DISCARD/ : { diff --git a/user/waitanyfast.c b/user/waitanyfast.c new file mode 100644 index 0000000..87dd9cf --- /dev/null +++ b/user/waitanyfast.c @@ -0,0 +1,18 @@ +#include "ulib.h" + +/* + * Only ever spawned by waitanytest.c. Exits immediately with no sleep at + * all, so waitanytest.c's very first userWaitAny() call is overwhelmingly + * likely to find this one already Dead (the other child, waitanyslow.c, + * cannot possibly have finished its own userSleep by then) -- proves + * threadJoinAnyChild's waitScanFoundDead path picks up an already-dead + * child without ever needing to block at all. + */ +int main(void) +{ + print("waitanyfast: start, pid="); + printDec(userGetpid()); + print("\n"); + print("waitanyfast: exiting now\n"); + return 44; +} diff --git a/user/waitanyslow.c b/user/waitanyslow.c new file mode 100644 index 0000000..f449664 --- /dev/null +++ b/user/waitanyslow.c @@ -0,0 +1,23 @@ +#include "ulib.h" + +/* + * Only ever spawned by waitanytest.c. Sleeps a real, non-trivial amount + * before exiting -- long enough that waitanytest.c's own userWaitAny() + * calls cannot possibly race past it by luck: its first call happens + * essentially immediately after spawning both children (see + * waitanytest.c's own file comment), so this thread genuinely cannot be + * Dead yet by the time that first call runs. That is what forces + * waitanytest.c's SECOND userWaitAny() call to take threadJoinAnyChild's + * waitScanNoneDead branch and actually block through the real + * retry-syscall path, rather than getting lucky and finding it already + * dead too. + */ +int main(void) +{ + print("waitanyslow: start, pid="); + printDec(userGetpid()); + print("\n"); + userSleep(150); + print("waitanyslow: exiting now\n"); + return 33; +} diff --git a/user/waitanytest.c b/user/waitanytest.c new file mode 100644 index 0000000..f38d21b --- /dev/null +++ b/user/waitanytest.c @@ -0,0 +1,102 @@ +#include "ulib.h" + +/* + * Boot-time driver for waitpid(-1)-style userWaitAny (ulib.c/sched.c's new + * threadJoinAnyChild) -- proves a parent can reap TWO independently exiting + * children without ever naming either pid, in whichever order they + * actually die, and correctly reports ECHILD (-1, no blocking) once none + * are left. + * + * Spawns waitanyfast.c (exits immediately) then waitanyslow.c (sleeps + * 150ms first), then calls userWaitAny() twice: + * - the first call happens essentially immediately after both spawns, so + * waitanyslow.c cannot possibly be Dead yet (its own 150ms sleep alone + * dwarfs the handful of syscalls in between) -- it must be + * waitanyfast.c that gets reaped here, exercising + * threadJoinAnyChild's waitScanFoundDead branch without ever blocking. + * - the second call therefore has only waitanyslow.c left, which is + * genuinely still alive at that point -- it must take the + * waitScanNoneDead branch and actually block through sysWait's real + * retry-syscall path (schedBlockCurrent(currentThread()), sched.c's + * new any-child wake in threadExit) until waitanyslow.c's sleep + * elapses and it exits. + * Verified without leaning on that ordering being guaranteed, though: both + * reaped pids together must equal exactly {fastPid, slowPid} as a set, and + * each one's own exit code must match whichever pid it actually was, in + * whatever order they came back. + * A third userWaitAny() call, once both children are gone, must return -1 + * immediately (ECHILD) rather than hang forever with no children left to + * wait for -- proves threadJoinAnyChild's waitScanNoChildren branch live. + */ + +int main(void) +{ + print("waitanytest: start, pid="); + printDec(userGetpid()); + print("\n"); + + long fastPid = userSpawn("/bin/waitanyfast", 0); + if (fastPid < 0) { + print("waitanytest: FAIL spawn waitanyfast\n"); + return 1; + } + long slowPid = userSpawn("/bin/waitanyslow", 0); + if (slowPid < 0) { + print("waitanytest: FAIL spawn waitanyslow\n"); + return 1; + } + + long reapedPid[2]; + long reapedCode[2]; + for (int i = 0; i < 2; i++) { + long pid = -1; + long code = userWaitAny(&pid); + print("waitanytest: reaped pid="); + printDec(pid); + print(" code="); + printDec(code); + print("\n"); + if (code < 0) { + print("waitanytest: FAIL userWaitAny reported no children too early\n"); + return 1; + } + reapedPid[i] = pid; + reapedCode[i] = code; + } + + int sawFast = 0; + int sawSlow = 0; + for (int i = 0; i < 2; i++) { + if (reapedPid[i] == fastPid) { + sawFast = 1; + if (reapedCode[i] != 44) { + print("waitanytest: FAIL waitanyfast exit code mismatch\n"); + return 1; + } + } else if (reapedPid[i] == slowPid) { + sawSlow = 1; + if (reapedCode[i] != 33) { + print("waitanytest: FAIL waitanyslow exit code mismatch\n"); + return 1; + } + } else { + print("waitanytest: FAIL reaped an unrecognized pid\n"); + return 1; + } + } + if (!sawFast || !sawSlow) { + print("waitanytest: FAIL did not reap both known children exactly once\n"); + return 1; + } + print("waitanytest: any-child-reap-set-ok\n"); + + long pid = 0; + long status = userWaitAny(&pid); + if (status != -1) { + print("waitanytest: FAIL expected -1 (ECHILD) once no children remain\n"); + return 1; + } + print("waitanytest: echild-when-no-children-ok\n"); + + return 0; +}