kernel: split init.lua into separate files, concatenated by build.lua

also properly implements forwarding os yields
main
cinder 3 weeks ago
parent 563c63a1c8
commit 5014e23401

@ -87,27 +87,7 @@ do
end
local function exportFile(srcpath, dstpath)
dstpath = outdir .. (dstpath or srcpath)
if dstpath:sub(-1) == '/' then
dstpath = dstpath .. srcpath
end
srcpath = 'src/' .. srcpath
--load the file to validate syntax, as a very minimal linter
do
local ok, err = loadfile(srcpath)
if not ok then
stderr:write(err, '\n')
os.exit(1)
end
end
local srcf = assert(io.open(srcpath))
local dstf = assert(io.open(dstpath, 'w'))
local function preprocess(srcf, dstf)
--for now, run a very minimal minifier
local longEnd --nil normally, some "]==]" string if we're in a long comment
@ -154,15 +134,60 @@ local function exportFile(srcpath, dstpath)
assert(not longEnd, 'unclosed long comment')
assert(dstf:flush())
end
local function exportFile(srcpath, dstpath)
dstpath = outdir .. (dstpath or srcpath)
if dstpath:sub(-1) == '/' then
dstpath = dstpath .. srcpath
end
srcpath = 'src/' .. srcpath
--load the file to validate syntax, as a very minimal linter
do
local ok, err = loadfile(srcpath)
if not ok then
stderr:write(err, '\n')
os.exit(1)
end
end
local srcf = assert(io.open(srcpath))
local dstf = assert(io.open(dstpath, 'w'))
preprocess(srcf, dstf)
dstf:close()
srcf:close()
end
local function buildKernel(dst)
local sources = fs.list('src/kernel/')
table.sort(sources)
function sources:lines()
return coroutine.wrap(function()
for i, path in ipairs(sources) do
local f = assert(io.open('src/kernel/' .. path))
for l in f:lines() do
coroutine.yield(l)
end
f:close()
end
end)
end
local dstf = assert(io.open(outdir .. dst, 'w'))
preprocess(sources, dstf)
dstf:close()
end
fs.makeDirectory(outdir)
exportFile('boot.lua')
fs.makeDirectory(outdir .. 'core/')
exportFile('init.lua', 'core/')
buildKernel('core/init.lua')
fs.makeDirectory(outdir .. 'core/bin')
exportFile('bin/tty.lua', 'core/')

@ -1,298 +0,0 @@
--our own boot.lua gives us the boot [address, path] as arguments...
local bootfs = ...
-- ...but [lua bios, openloader] don't, so fall back to the usual way
bootfs = bootfs or computer.getBootAddress()
--store all functions we need in locals so userspace mischief can't replace them
--prefix 'k' (for kernel) on any functions userspace will also have a version of to ensure kernelspace
--function usage is intentional (ie prevent accidentally using the kernel's `load` in a userspace `loadfile`)
local envBase = _G --used for creating process environments
local assert, checkArg, error, kload, ipairs, next, pairs, rawequal, rawset, setmetatable, tostring, type = assert, checkArg, error, load, ipairs, next, pairs, rawequal, rawset, setmetatable, tostring, type
local invoke = component.invoke
local kpullSignal, shutdown, uptime = computer.pullSignal, computer.shutdown, computer.uptime
local co_create, co_status, kresume, kyield = coroutine.create, coroutine.status, coroutine.resume, coroutine.yield
local huge = math.huge
local format = string.format
local pack, unpack = table.pack, table.unpack
local big = math.maxinteger or huge --some large amount to read at once in kreadfile
--completely disable the environment, ensuring the kernel only uses local variables, to prevent userspace
--mischief (somehow editing _G through a mirror) from affecting kernelspace
_ENV = setmetatable({}, {
__index = function(self, key) return error(format('accessed global %q', key), 2) end,
__newindex = function(self, key) return error(format('assigned global %q', key), 2) end,
})
local function kreadfile(path, fs)
fs = fs or bootfs
local text, chunk, fd, err = '', nil, invoke(fs, 'open', path)
if not fd then
local msg = format('can\'t open %s on %s', path, fs:sub(1, 3))
if not rawequal(path, err) then msg = msg .. ': ' .. err end
return nil, msg
end
repeat
chunk, err = invoke(fs, 'read', fd, big)
text = text .. (chunk or '')
until not chunk
invoke(fs, 'close', fd)
if err then return nil, format('reading %s: %s', path, err) end
return text
end
--[=====[ process internals ]=====]--
local processes = {} --key is thread, value is a table of process state (ie env)
local runnable = {} --key is thread, value is pack(...)ed args to resume it with
local schedDeadline = {} --key is thread, value is absolute deadline of uptime()
local schedSignal = {} --key is thread, value is `true` (may allow specific signal names in the future)
local fillEnv --function(proc:thread, env:table):() -- given a process and an empty environment, fill it in before the process runs
--[=====[ process environments ]=====]--
--<https://ocdoc.cil.li/tutorial:custom_oses#what_s_available>
-- _G (through envBase) is used as the base for process environments, so clear it of anything process-specific
envBase._OSVERSION = 'kitn 0.1.1' --prefer [openloader, openos] "$name $ver" over plan9k "$name/$ver"
envBase._G, envBase.load = nil
envBase.coroutine.resume = function(co, ...)
checkArg(1, co, 'thread')
local results = pack(co_resume(co, ...)) --ok, os_reason, ...
if results[1] and results[2] then
return kyield(unpack(results, 2, results.n))
else
return unpack(results, 1, results.n)
end
end
envBase.coroutine.yield = function(...) return kyield(false, ...) end
envBase.os.sleep = function(n)
checkArg(1, n, 'number')
return kyield('deadline', n)
end
envBase.computer.pullSignal = function(timeout)
checkArg(1, timeout, 'number', 'nil')
return kyield('signal', timeout)
end
envBase.kitn = {} --our specific goodies
--environment sandboxing uses mirrors: tables that provide a read-only "reflection" of what's on the other side
local mirror --function(target:table[, mirror:table]):table -- create a mirror (or make a table into one) that reflects `target`
local mirrors = setmetatable({}, { __mode = 'k' }) --key is mirror given to userspace, value is what it mirrors
local function mirrorNext(self, prev)
local reflected = assert(mirrors[self], 'mirror reflects nothing')
local key = next(reflected, prev)
return key, self[key] --ensure all accesses go through the mirror so table values are mirrored too
end
local mirrormt = {
__metatable = '<mirror>', --hide the metatable to prevent replacing or modifying it
__index = function(self, key)
local reflected = assert(mirrors[self], 'mirror reflects nothing')
local val = reflected[key] --if mirrors[self] is somehow nil, erroring is fine
if type(val) == 'table' then val = mirror(val) end --recursively mirror tables on first access
if val ~= nil then rawset(self, key, val) end
return val
end,
__pairs = function(self)
--reuse a single function with the mirror as the control variable, instead of creating a new function with
--upvalues every time, for vague feelings of less memory usage (TODO benchmark that)
return mirrorNext, self
end,
--mirrors allow assignment but don't do anything with it
}
function mirror(target, mirror)
mirror = mirror or {}
mirrors[mirror] = target
setmetatable(mirror, mirrormt)
return mirror
end
function fillEnv(proc, uenv)
mirror(envBase, uenv)
uenv._G = uenv
local function uload(chunk, name, mode, env)
checkArg(1, chunk, 'function', 'string')
checkArg(2, name, 'string', 'nil')
checkArg(3, mode, 'string', 'nil')
checkArg(4, env, 'table', 'nil')
return kload(chunk, name, mode, env or uenv)
end
local function uloadfile(path, mode, env)
checkArg(1, path, 'string')
checkArg(2, mode, 'string', 'nil')
checkArg(3, env, 'table', 'nil')
local code, err = kreadfile(path)
if not code then return nil, err end
code, err = uload(code, '@' .. path, mode, env)
return code, err
end
local function udofile(path, ...)
checkArg(1, path, 'string')
local fn = assert(uloadfile(path))
return fn(...)
end
uenv.load, uenv.loadfile, uenv.dofile = uload, uloadfile, udofile
--env will automatically mirror envBase.kitn here
local ukitn = uenv.kitn
function ukitn.createThread(fn, ...)
checkArg(1, fn, 'function')
local newThread = co_create(fn)
processes[newThread] = { thread = true, parent = proc } --mark as thread of same process
runnable[newThread] = pack(...)
return newThread
end
function ukitn.createProcess(path, ...)
checkArg(1, path, 'string')
local env = {}
local fn, err = uloadfile(path, nil, env)
if not fn then return nil, err end
local newProc = co_create(fn)
fillEnv(newProc, env)
processes[newProc] = { env = env, parent = proc, path = path }
runnable[newProc] = pack(...)
return newProc
end
function ukitn.running()
--TODO return name, child processes, child threads, ...
--TODO return information for the current thread within the process too
return proc, nil
end
end
--[=====[ init, pid 1 ]=====]--
do
local initEnv = {}
--since init is intended to be configured per-computer, it lives in /etc
local initPath = '/etc/init.lua'
local initCode = assert(kreadfile(initPath))
local initFn = assert(kload(initCode, '@' .. initPath, 'bt', initEnv))
local initThread = co_create(initFn)
fillEnv(initThread, initEnv)
processes[initThread] = { env = initEnv, source = initPath }
runnable[initThread] = { initPath, n = 1 }
end
--[=====[ scheduler main loop ]=====]--
--logic after co_resume(process, ...) returns, in its own function to use varargs instead of table.pack
local function postResume(co, ok, reason, ...)
if co_status(co) == 'dead' then
processes[co], schedDeadline[co], schedSignal[co] = nil
--TODO bubble to parent process
--tostring could run userspace code and even yield, but at this point we're crashing so it doesn't matter
if not ok then error('process crashed: ' .. tostring(reason), 0) end
elseif not reason then
--userspace called coroutine.yield(); TODO figure out what that's supposed to do
--for now... yield until another process resumes it?
schedDeadline[co], schedSignal[co] = nil
elseif rawequal(reason, 'signal') then
local timeout = ...
schedDeadline[co] = timeout and (uptime() + timeout)
schedSignal[co] = true
elseif rawequal(reason, 'deadline') then
local timeout = ...
schedDeadline[co] = uptime() + timeout
schedSignal[co] = nil
end
end
--logic after kpullSignal returns, in its own function to use varargs instead of table.pack
local function postPull(signal, ...)
local args = pack(nil)
for proc, deadline in pairs(schedDeadline) do
if uptime() >= deadline then
schedDeadline[proc] = nil
runnable[proc] = args
end
end
--intentionally override {resumed by deadline} with {resumed by signal}
if signal then
args = pack(signal, ...)
for proc in pairs(schedSignal) do
schedDeadline[proc], schedSignal[proc] = nil
runnable[proc] = args
end
end
end
--goto instead of while-true for vague feeling of performance (TODO benchmark this)
::tickScheduler::
--[[clear the run queue
this is a delicate formulation. if proc creates new threads, those are added as new keys to runnable, which
makes next's behavior undefined*; removing proc before resuming causes the next loop iteration to crash with
"invalid key to 'next'". unconditionally removing proc after is also wrong, because proc may have rescheduled
itself immediately. instead we assign proc a value it should never have (false) before, test if it's still set
to that value after, and remove it if so.
the outer loop is for the occasional case that some of the child processes end up before their parent in next's
order and so wouldn't be resumed until after another signal was pulled otherwise.
*is this c-like ub that can cause memory corruption and segfaults? the manual doesn't say! (TODO make this not ub)
]]
while next(runnable) do
for proc, args in pairs(runnable) do
runnable[proc] = false
postResume(proc, kresume(proc, unpack(args, 1, args.n)))
if not runnable[proc] then
runnable[proc] = nil
end
end
end
local deadline = nil
for proc, pdeadline in pairs(schedDeadline) do
if not deadline or pdeadline < deadline then
deadline = pdeadline
end
end
if next(schedSignal) then
deadline = deadline or huge
end
if not deadline then
--no processes are scheduled? are any still runnable somehow?
if next(runnable) then
--tail-return early to jump back to the run queue
goto tickScheduler
end
--nothing scheduled, nothing runnable; have all processes exited?
if not next(processes) then
return shutdown()
end
--this seems like a deadlock; the scheduler will never resume any process again
error('all processes are idle', 0)
end
--`deadline` is set, so at least one process is waiting for a deadline or signal
postPull(kpullSignal(deadline - uptime()))
--then do it again
goto tickScheduler
-- vi: set ts=2:

@ -0,0 +1,29 @@
--our own boot.lua gives us the boot [address, path] as arguments...
local bootfs = ...
-- ...but [lua bios, openloader] don't, so fall back to the usual way
bootfs = bootfs or computer.getBootAddress()
--store all functions we need in locals so userspace mischief can't replace them
--prefix 'k' (for kernel) on any functions userspace will also have a version of to ensure kernelspace
--function usage is intentional (ie prevent accidentally using the kernel's `load` in a userspace `loadfile`)
local envBase = _G --used for creating process environments
local assert, checkArg, error, kload, ipairs, next, pairs, rawequal, rawset, setmetatable, tostring, type = assert, checkArg, error, load, ipairs, next, pairs, rawequal, rawset, setmetatable, tostring, type
local invoke = component.invoke
local kpullSignal, shutdown, uptime = computer.pullSignal, computer.shutdown, computer.uptime
local co_create, co_status, kresume, kyield = coroutine.create, coroutine.status, coroutine.resume, coroutine.yield
local huge = math.huge
local format = string.format
local pack, unpack = table.pack, table.unpack
local big = math.maxinteger or huge --some large amount to read at once in kreadfile
--completely disable the environment, ensuring the kernel only uses local variables, to prevent userspace
--mischief (somehow editing _G through a mirror) from affecting kernelspace
_ENV = setmetatable({}, {
__index = function(self, key) return error(format('accessed global %q', key), 2) end,
__newindex = function(self, key) return error(format('assigned global %q', key), 2) end,
})
-- vi: set ts=2:

@ -0,0 +1,38 @@
--environment sandboxing uses mirrors: tables that provide a read-only "reflection" of what's on the other side
local mirror --function(target:table[, mirror:table]):table -- create a mirror (or make a table into one) that reflects `target`
do
local mirrors = setmetatable({}, { __mode = 'k' }) --key is mirror given to userspace, value is what it mirrors
local function mirrorNext(self, prev)
local reflected = assert(mirrors[self], 'mirror reflects nothing')
local key = next(reflected, prev)
return key, self[key] --ensure all accesses go through the mirror so table values are mirrored too
end
local mirrormt = {
__metatable = '<mirror>', --hide the metatable to prevent replacing or modifying it
__index = function(self, key)
local reflected = assert(mirrors[self], 'mirror reflects nothing')
local val = reflected[key] --if mirrors[self] is somehow nil, erroring is fine
if type(val) == 'table' then val = mirror(val) end --recursively mirror tables on first access
if val ~= nil then rawset(self, key, val) end
return val
end,
__pairs = function(self)
--reuse a single function with the mirror as the control variable, instead of creating a new function with
--upvalues every time, for vague feelings of less memory usage (TODO benchmark that)
return mirrorNext, self
end,
--mirrors allow assignment but don't do anything with it
}
function mirror(target, mirror)
mirror = mirror or {}
mirrors[mirror] = target
setmetatable(mirror, mirrormt)
return mirror
end
end
-- vi: set ts=2:

@ -0,0 +1,59 @@
local function kreadfile(path, fs)
fs = fs or bootfs
local text, chunk, fd, err = '', nil, invoke(fs, 'open', path)
if not fd then
local msg = format('can\'t open %s on %s', path, fs:sub(1, 3))
if not rawequal(path, err) then msg = msg .. ': ' .. err end
return nil, msg
end
repeat
chunk, err = invoke(fs, 'read', fd, big)
text = text .. (chunk or '')
until not chunk
invoke(fs, 'close', fd)
if err then return nil, format('reading %s: %s', path, err) end
return text
end
--[[table of process information. key is the main thread's coroutine, value is a table with:
- array part: coroutines for child threads and processes
- `parent`: coroutine of parent process
- `env`: process environment
- `path`: filesystem path the process was loaded from (for diagnostics)
]]
local processes = {}
--run queue. key is coroutine (process or thread), value is pack()ed args to resume it with.
--`runqueue[co]` is temporarily set to `false` while the thread is running.
local runqueue = {}
--tasks waiting for a timeout. key is coroutine (process or thread), value is absolute deadline (from uptime()).
local schedDeadline = {}
--tasks waiting for a signal. key is coroutine (process or thread), value is `true` for all signals (may support specific signals in the future).
local schedSignal = {}
local fillEnv --function(proc:thread, env:table):() -- given a process and an empty environment, fill it in before the process runs
local function createProcess(path, parent)
local code, err = kreadfile(path)
if not code then return nil, err end
local env = {}
--using kload here is okay because we always pass a non-nil environment
local f, err = kload(code, '@' .. path, 'bt', env)
if not f then return nil, err end
local co = co_create(f)
fillEnv(co, env)
local info = { env = env, parent = parent, path = path }
processes[co] = info
return co, info
end
-- vi: set ts=2:

@ -0,0 +1,115 @@
--[[environments for new processes
given <https://ocdoc.cil.li/tutorial:custom_oses#what_s_available>:
- replace load to default to the current process's environment, not the global environment
- provide [loadfile, dofile] using the boot filesystem and the current process's environment
- replace computer.pullSignal and provide os.sleep to yield from the current coroutine, not the entire computer
- replace coroutine.yield to differentiate its yields from os yields (pullSignal/sleep)
- replace coroutine.resume to pass os yields through, suspending the os thread and not just the current coroutine
- provide kitn namespace for os functions
]]
envBase._OSVERSION = 'kitn 0.1.2' --prefer [openloader, openos] "$name $ver" over plan9k "$name/$ver"
envBase.kitn = {}
envBase._G, envBase.load = nil
do
--[[propogating os yields
when a process calls coroutine.yield(), we prepend `false` to the yielded values. os yields start with a truthy value.
in coroutine.resume(), if the coroutine yielded (ie isn't dead) and the first value is truthy (ie an os yield),
we also yield the current coroutine, unwinding the current stack of running coroutines back to the os thread.
when the current coroutine is resumed, we pass the values back into the inner coroutine and repeat.
]]
envBase.coroutine.yield = function(...) return kyield(false, ...) end
local forwardYield
envBase.coroutine.resume = function(co, ...)
checkArg(1, co, 'thread')
return forwardYield(co, kresume(co, ...))
end
function forwardYield(co, ok, ...)
if co_status(co) ~= 'suspended' then --it's not a sysyield if the coroutine didn't yield (because it returned/errored)
return ok, ...
elseif (...) then --firest arg is truthy for os yield, false for coroutine.yield()
--yield to the scheduler, pass the results (ie signal) back to `co`, then repeat for `co`'s next yield
return forwardYield(co, kresume(co, kyield(...)))
else --`co` called coroutine.yield(), so return its values to the waiting coroutine.resume()
return ...
end
end
end
envBase.os.sleep = function(n)
checkArg(1, n, 'number')
return kyield('deadline', n)
end
envBase.computer.pullSignal = function(timeout)
checkArg(1, timeout, 'number', 'nil')
return kyield('signal', timeout)
end
function fillEnv(proc, uenv)
mirror(envBase, uenv)
uenv._G = uenv
--define these in local variables so the process deleting `load` doesn't break its `loadfile`
local function uload(chunk, name, mode, env)
checkArg(1, chunk, 'function', 'string')
checkArg(2, name, 'string', 'nil')
checkArg(3, mode, 'string', 'nil')
checkArg(4, env, 'table', 'nil')
return kload(chunk, name, mode, env or uenv)
end
local function uloadfile(path, mode, env)
checkArg(1, path, 'string')
checkArg(2, mode, 'string', 'nil')
checkArg(3, env, 'table', 'nil')
local code, err = kreadfile(path)
if not code then return nil, err end
code, err = uload(code, '@' .. path, mode, env)
--`return code, err` here would return 2 values on success, unlike vanilla loadfile
if not code then return nil, err end
return code
end
local function udofile(path, ...)
checkArg(1, path, 'string')
local fn = assert(uloadfile(path))
return fn(...)
end
uenv.load, uenv.loadfile, uenv.dofile = uload, uloadfile, udofile
--env will automatically mirror envBase.kitn here
local ukitn = uenv.kitn
function ukitn.createThread(fn, ...)
checkArg(1, fn, 'function')
local co = co_create(fn)
local info = processes[proc]
info[#info+1] = co --add it as a thread of the current process
runnable[co] = pack(...)
return co
end
function ukitn.createProcess(path, ...)
checkArg(1, path, 'string')
local co, info = createProcess(path, proc)
if not co then return nil, info end
runqueue[co] = pack(...)
return co
end
function ukitn.running()
--TODO return name, child processes, child threads, ...
--TODO return information for the current thread within the process too
return proc, nil
end
end
-- vi: set ts=2:

@ -0,0 +1,103 @@
--init is intended to be configured per-computer, so it lives in /etc/
local initCo, initInfo = createProcess('/etc/init.lua', nil) --no parent
if not initCo then
error(initInfo, 0)
end
runqueue[initCo] = { initInfo.path, n = 1 }
local reschedule, dispatchSignal
--given the yield values of a coroutine (process or thread), reschedule it as necessary or mark the process as crashed.
local function postResume(co, ok, reason, ...)
if co_status(co) == 'dead' then
processes[co], schedDeadline[co], schedSignal[co] = nil
--TODO bubble to parent process
--tostring could run userspace code and even yield, but at this point we're crashing so it doesn't matter
if not ok then error('process crashed: ' .. tostring(reason), 0) end
elseif not reason then
--userspace called coroutine.yield(); TODO figure out what that's supposed to do
--for now... yield until another process resumes it?
schedDeadline[co], schedSignal[co] = nil
elseif rawequal(reason, 'signal') then
local timeout = ...
schedDeadline[co] = timeout and (uptime() + timeout)
schedSignal[co] = true
elseif rawequal(reason, 'deadline') then
local timeout = ...
schedDeadline[co] = uptime() + timeout
schedSignal[co] = nil
end
end
--logic after kpullSignal returns, in its own function to use varargs instead of table.pack
local function postPull(signal, ...)
local args
for proc, deadline in pairs(schedDeadline) do
if uptime() >= deadline then
args = args or { n = 1 } --resume with a single nil
schedDeadline[proc] = nil
runqueue[proc] = args
end
end
--intentionally override {resumed by deadline} with {resumed by signal}
if signal then
args = pack(signal, ...)
for proc in pairs(schedSignal) do
schedSignal[proc] = nil
runqueue[proc] = args
end
end
end
--goto instead of while-true for vague feeling of performance (TODO benchmark this)
::tickScheduler::
--[[clear the run queue
this is a delicate formulation. if proc creates new threads, those are added as new keys to runnable, which
makes next's behavior undefined*; removing proc before resuming causes the next loop iteration to crash with
"invalid key to 'next'". unconditionally removing proc after is also wrong, because proc may have rescheduled
itself immediately. instead we assign proc a value it should never have (false) before, test if it's still set
to that value after, and remove it if so.
the outer loop is for the occasional case that some of the child processes end up before their parent in next's
order and so wouldn't be resumed until after another signal was pulled otherwise.
*is this c-like ub that can cause memory corruption and segfaults? the manual doesn't say! (TODO make this not ub)
]]
while next(runqueue) do
for proc, args in pairs(runqueue) do
runqueue[proc] = false
postResume(proc, kresume(proc, unpack(args, 1, args.n)))
if not runqueue[proc] then
runqueue[proc] = nil
end
end
end
local deadline = nil --absolute deadline to wait until for a signal; only set to nonnil if there is something waiting
for proc, pdeadline in pairs(schedDeadline) do
if not deadline or pdeadline < deadline then
deadline = pdeadline
end
end
if next(schedSignal) then
deadline = deadline or huge
end
if deadline then --at least one process/thread is waiting
postPull(kpullSignal(deadline - uptime()))
goto tickScheduler --then do it again
elseif next(runqueue) then --nothing is scheduled; is something still runnable somehow?
goto tickScheduler --jump back to drain the runqueue again
elseif next(processes) then --nothing scheduled or runnable; are there any processes at all?
error('all processes are idle', 0) --deadlock
else --no processes remaining, everything exited cleanly
shutdown()
end
-- vi: set ts=2:
Loading…
Cancel
Save