This commit is contained in:
Raven Scott
2026-04-03 17:56:46 -04:00
parent a7e08e093c
commit ec236f9cc7
28 changed files with 1052 additions and 97 deletions
+12 -2
View File
@@ -32,7 +32,10 @@ import {
applyLoginKeys
} from './lib/identity-session.js'
import { HdmsController, runHdmsCli } from './lib/hdms-manager.js'
import { startBareInitd } from './lib/bare-initd.js'
import {
startBareInitd,
registerKernelShutdownHook
} from './lib/bare-initd.js'
import './lib/bare-cron.js'
const _pkg = packageRootDir(import.meta.url)
@@ -317,6 +320,13 @@ async function executeKernel(disk, store, swarm, initSource) {
*/
async runBinCommand(argv) {
return runBinCommand(this, argv)
},
/**
* Register async/sync teardown when the kernel session ends (before initd disposers).
* @param {() => void | Promise<void>} fn
*/
registerKernelShutdownHook(fn) {
registerKernelShutdownHook(fn)
}
}
@@ -375,7 +385,7 @@ async function executeKernel(disk, store, swarm, initSource) {
try {
await runKernelFromSource(b4a.toString(initSource), ctx)
} finally {
session.cleanup()
await session.cleanup()
}
return sessionExitCode
}
+26
View File
@@ -11,6 +11,9 @@ const registry = []
/** @type {(() => void)[]} */
const disposers = []
/** @type {(() => void | Promise<void>)[]} */
const kernelShutdownHooks = []
/**
* Register cleanup (e.g. clearInterval) for when the kernel session ends.
* @param {() => void} fn
@@ -19,6 +22,29 @@ export function registerBareInitdDisposer(fn) {
if (typeof fn === 'function') disposers.push(fn)
}
/**
* Register a function to run when the REPL/kernel session ends (before initd disposers).
* Use for async teardown (flush buffers, close handles). Errors are swallowed.
* @param {() => void | Promise<void>} fn
*/
export function registerKernelShutdownHook(fn) {
if (typeof fn === 'function') kernelShutdownHooks.push(fn)
}
/**
* Runs shutdown hooks in reverse registration order (LIFO), then clears the queue.
*/
export async function runKernelShutdownHooks() {
while (kernelShutdownHooks.length) {
const fn = kernelShutdownHooks.pop()
try {
await fn()
} catch {
/* ignore */
}
}
}
export function stopBareInitd() {
while (disposers.length) {
const fn = disposers.pop()
+6 -1
View File
@@ -67,7 +67,9 @@ async function runScriptFromSource(ctx, src, argv, label = argv[0]) {
'argv',
`${body}\nif (typeof run !== 'function') throw new Error('missing run() in ${label}')\nreturn run(ctx, argv)\n`
)
return await fn(ctx, argv)
await fn(ctx, argv)
if (ctx.exitCode === undefined || ctx.exitCode === null) ctx.exitCode = 0
return
} catch (e) {
const msg = e?.message || String(e)
const stack = e?.stack
@@ -80,6 +82,7 @@ async function runScriptFromSource(ctx, src, argv, label = argv[0]) {
}
}
}
ctx.exitCode = 1
}
}
@@ -115,6 +118,7 @@ export async function runBinCommand(ctx, argv) {
const buf = await drive.get(path, { follow: true })
if (!buf) {
ctx.console.log('not found: ' + cmd)
ctx.exitCode = 127
return
}
const source = b4a.toString(buf)
@@ -143,6 +147,7 @@ export async function runBinCommand(ctx, argv) {
}
ctx.console.log('unknown command: ' + cmd)
ctx.exitCode = 127
}
/**
+6 -2
View File
@@ -9,7 +9,10 @@ import {
replDbg,
unbindReplDebugStream
} from './debug-repl.js'
import { stopBareInitd } from './bare-initd.js'
import {
runKernelShutdownHooks,
stopBareInitd
} from './bare-initd.js'
/**
* Kernel `console` must write to the same stream as the line editor so cursor stays in sync.
@@ -162,8 +165,9 @@ export async function createKernelReplSession({
return line
}
function cleanup() {
async function cleanup() {
if (isReplDebug()) replDbg('repl', 'cleanup', fishRead ? 'fish teardown' : 'noop')
await runKernelShutdownHooks()
stopBareInitd()
if (fishRead && stdin) {
disableFishRawMode(stdin)
+197 -20
View File
@@ -20,6 +20,16 @@ function isShellBuiltin(cmd) {
return SHELL_BUILTINS.has(cmd)
}
/**
* @param {Record<string, unknown>} ctx
* @param {Record<string, unknown>} childCtx
*/
function mergeChildExitCode(ctx, childCtx) {
if (childCtx.exitCode !== undefined && childCtx.exitCode !== null) {
ctx.exitCode = childCtx.exitCode
}
}
/** Max alias indirections (prevents cycles). */
const MAX_ALIAS_DEPTH = 16
@@ -224,11 +234,31 @@ export function tokenize(line) {
if (i >= line.length) break
const c = line[i]
if (c === '|') {
tokens.push({ type: 'op', value: '|' })
if (c === ';') {
tokens.push({ type: 'op', value: ';' })
i++
continue
}
if (c === '&') {
if (line[i + 1] === '&') {
tokens.push({ type: 'op', value: '&&' })
i += 2
} else {
tokens.push({ type: 'op', value: '&' })
i++
}
continue
}
if (c === '|') {
if (line[i + 1] === '|') {
tokens.push({ type: 'op', value: '||' })
i += 2
} else {
tokens.push({ type: 'op', value: '|' })
i++
}
continue
}
if (c === '>') {
if (line[i + 1] === '>') {
tokens.push({ type: 'op', value: '>>' })
@@ -274,7 +304,15 @@ export function tokenize(line) {
if (i < line.length) i++
continue
}
if (/\s/.test(ch) || ch === '|' || ch === '>' || ch === '<') break
if (
/\s/.test(ch) ||
ch === '|' ||
ch === '>' ||
ch === '<' ||
ch === ';' ||
ch === '&'
)
break
word += ch
i++
}
@@ -423,19 +461,66 @@ function parseSimpleCommand(seg) {
return { argv: argvWords, assign, redirIn, redirOut, redirAppend }
}
/**
* Split token list on `;` into separate commands (AND-OR lists).
* @param {Token[]} tokens
* @returns {Token[][]}
*/
export function splitTokensBySemicolon(tokens) {
/** @type {Token[][]} */
const lists = []
/** @type {Token[]} */
let cur = []
for (const t of tokens) {
if (t.type === 'op' && t.value === ';') {
lists.push(cur)
cur = []
} else {
cur.push(t)
}
}
lists.push(cur)
return lists
}
/**
* Split one semicolon-separated list on `&&` / `||` (left-associative chain).
* @param {Token[]} tokens
* @returns {{ segments: Token[][], ops: string[] }}
*/
export function splitTokensByAndOr(tokens) {
/** @type {Token[][]} */
const segments = []
/** @type {string[]} */
const ops = []
/** @type {Token[]} */
let cur = []
for (const t of tokens) {
if (t.type === 'op' && (t.value === '&&' || t.value === '||')) {
segments.push(cur)
ops.push(t.value)
cur = []
} else {
cur.push(t)
}
}
segments.push(cur)
return { segments, ops }
}
/** @param {Token[]} seg */
function segmentHasCommand(seg) {
if (!seg.length) return false
const cmd = parseSimpleCommand(seg)
return cmd.argv.length > 0 || Object.keys(cmd.assign).length > 0
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} line
* @param {SimpleCmd[]} pipeline
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execShellLine(ctx, line) {
const raw = line.trim()
if (!raw) return 'ok'
const tokens = tokenize(raw)
if (!tokens.length) return 'ok'
const pipeline = parsePipeline(tokens)
async function execParsedPipeline(ctx, pipeline) {
const vfs = ctx.vfs
const env = vfs.env
@@ -454,6 +539,7 @@ export async function execShellLine(ctx, line) {
argv = expandArgvAliases(argv, ctx.shellAliases)
} catch (e) {
ctx.console.error((e && e.message) || String(e))
ctx.exitCode = 1
continue
}
const name = argv[0]
@@ -474,12 +560,18 @@ export async function execShellLine(ctx, line) {
ctx.shellReadonlyVars.has(k)
) {
origErr.call(ctx.console, k + ': readonly variable')
ctx.exitCode = 1
continue
}
env[k] = expandWord(val, env)
}
if (!cmd.argv.length) continue
if (!cmd.argv.length) {
ctx.exitCode = 0
continue
}
ctx.exitCode = 0
if (!isLast || cmd.redirOut) {
ctx.console.log = (...args) => {
@@ -506,15 +598,20 @@ export async function execShellLine(ctx, line) {
ctx.console,
'alias: usage: alias name=value [name=value ...]'
)
ctx.exitCode = 1
}
}
} else if (name === 'unalias') {
runUnaliasBuiltin(ctx, argv, (m) => origErr.call(ctx.console, m))
runUnaliasBuiltin(ctx, argv, (m) => {
origErr.call(ctx.console, m)
ctx.exitCode = 1
})
} else if (name === 'cd') {
try {
await vfs.chdir(argv[1] || vfs.home)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
} else if (name === 'export') {
for (const a of argv.slice(1)) {
@@ -526,6 +623,7 @@ export async function execShellLine(ctx, line) {
ctx.shellReadonlyVars.has(k)
) {
origErr.call(ctx.console, k + ': readonly variable')
ctx.exitCode = 1
continue
}
env[k] = expandWord(a.slice(eq + 1), env)
@@ -540,6 +638,7 @@ export async function execShellLine(ctx, line) {
ctx.console,
'unset: ' + a + ': cannot unset: readonly variable'
)
ctx.exitCode = 1
continue
}
delete env[a]
@@ -560,6 +659,7 @@ export async function execShellLine(ctx, line) {
const oct = argv[1]
if (!/^[0-7]{1,4}$/.test(oct)) {
origErr.call(ctx.console, 'umask: invalid octal mask')
ctx.exitCode = 1
} else {
env.UMASK = oct
}
@@ -573,15 +673,21 @@ export async function execShellLine(ctx, line) {
const cargs = argv.slice(1)
if (cargs.length === 0) {
origErr.call(ctx.console, 'command: missing operand')
ctx.exitCode = 1
} else if (cargs[0] === '-v' || cargs[0] === '-V') {
const cmdn = cargs[1]
if (!cmdn) origErr.call(ctx.console, 'command: missing operand')
else if (isShellBuiltin(cmdn)) {
if (!cmdn) {
origErr.call(ctx.console, 'command: missing operand')
ctx.exitCode = 1
} else if (isShellBuiltin(cmdn)) {
origLog.call(ctx.console, cmdn)
} else {
const p = await resolveBinInPath(ctx, cmdn)
if (p) origLog.call(ctx.console, p)
else origErr.call(ctx.console, 'command: ' + cmdn + ': not found')
else {
origErr.call(ctx.console, 'command: ' + cmdn + ': not found')
ctx.exitCode = 1
}
}
} else {
const childCtx =
@@ -589,16 +695,22 @@ export async function execShellLine(ctx, line) {
? Object.assign({}, ctx, { shellStdin: stdinText, env })
: Object.assign({}, ctx, { env })
await runBinCommand(childCtx, cargs)
mergeChildExitCode(ctx, childCtx)
}
} else if (name === 'type') {
const cmdn = argv[1]
if (!cmdn) origErr.call(ctx.console, 'type: missing operand')
else if (isShellBuiltin(cmdn)) {
if (!cmdn) {
origErr.call(ctx.console, 'type: missing operand')
ctx.exitCode = 1
} else if (isShellBuiltin(cmdn)) {
origLog.call(ctx.console, cmdn + ' is a shell builtin')
} else {
const p = await resolveBinInPath(ctx, cmdn)
if (p) origLog.call(ctx.console, cmdn + ' is ' + p)
else origErr.call(ctx.console, 'type: ' + cmdn + ': not found')
else {
origErr.call(ctx.console, 'type: ' + cmdn + ': not found')
ctx.exitCode = 1
}
}
} else if (name === 'login') {
const rest = argv.slice(1)
@@ -610,6 +722,7 @@ export async function execShellLine(ctx, line) {
const passphrase = rest.map((w) => expandWord(w, env)).join(' ')
if (!passphrase) {
origErr.call(ctx.console, 'usage: login [--new] <passphrase>')
ctx.exitCode = 1
} else if (
typeof ctx.applyRegister === 'function' &&
typeof ctx.applyUnlock === 'function'
@@ -619,9 +732,11 @@ export async function execShellLine(ctx, line) {
else await ctx.applyUnlock(passphrase)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
} else {
origErr.call(ctx.console, 'login: not supported in this environment')
ctx.exitCode = 1
}
} else if (name === 'logout') {
const save = argv.includes('--save')
@@ -630,9 +745,11 @@ export async function execShellLine(ctx, line) {
await ctx.applyLogout({ save })
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
} else {
origErr.call(ctx.console, 'logout: not supported in this environment')
ctx.exitCode = 1
}
} else if (name === 'exit') {
code = 'exit'
@@ -651,8 +768,10 @@ export async function execShellLine(ctx, line) {
: Object.assign({}, ctx, { env })
try {
await runBinCommand(childCtx, argv)
mergeChildExitCode(ctx, childCtx)
} catch (e) {
origErr.call(ctx.console, (e && e.message) || String(e))
ctx.exitCode = 1
}
if (name === '/bin/exit' || name.endsWith('/exit')) {
code = 'exit'
@@ -687,3 +806,61 @@ export async function execShellLine(ctx, line) {
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} tokens
* @returns {Promise<'exit' | 'ok'>}
*/
async function execAndOrList(ctx, tokens) {
const { segments, ops } = splitTokensByAndOr(tokens)
for (let s = 0; s < segments.length; s++) {
if (!segmentHasCommand(segments[s])) {
ctx.console.error('shell: invalid null command')
ctx.exitCode = 2
return 'ok'
}
}
let lastStatus = 0
for (let i = 0; i < segments.length; i++) {
if (i > 0) {
const op = ops[i - 1]
if (op === '&&' && lastStatus !== 0) continue
if (op === '||' && lastStatus === 0) continue
}
const pipeline = parsePipeline(segments[i])
const r = await execParsedPipeline(ctx, pipeline)
if (r === 'exit') return 'exit'
lastStatus = Number(ctx.exitCode) || 0
}
return 'ok'
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} line
* @returns {Promise<'exit' | 'ok'>}
*/
export async function execShellLine(ctx, line) {
const raw = line.trim()
if (!raw) return 'ok'
const tokens = tokenize(raw)
if (!tokens.length) return 'ok'
if (tokens.some((t) => t.type === 'op' && t.value === '&')) {
ctx.console.error(
'shell: unsupported operator & (job control is not available)'
)
ctx.exitCode = 2
return 'ok'
}
const lists = splitTokensBySemicolon(tokens)
for (const listTok of lists) {
if (!listTok.length) continue
const r = await execAndOrList(ctx, listTok)
if (r === 'exit') return 'exit'
}
return 'ok'
}
+170 -2
View File
@@ -19,7 +19,9 @@ import {
expandArgvAliases,
defaultShellAliases,
loadBarerc,
BARERC_SKELETON
BARERC_SKELETON,
splitTokensBySemicolon,
splitTokensByAndOr
} from './lib/shell.js'
import {
fuzzyMatch,
@@ -35,7 +37,12 @@ import {
parseCronLine,
jobMatchesDate
} from './lib/bare-cron.js'
import { registerBareInitdDisposer, stopBareInitd } from './lib/bare-initd.js'
import {
registerBareInitdDisposer,
registerKernelShutdownHook,
runKernelShutdownHooks,
stopBareInitd
} from './lib/bare-initd.js'
import {
DEFAULT_CURL_USER_AGENT,
DEFAULT_WGET_USER_AGENT
@@ -562,6 +569,92 @@ async function run(ctx, argv) {
rmSync(dir, { recursive: true, force: true })
})
test('tokenize && || ; and split helpers', async (t) => {
const toks = tokenize('a&&b||c;d')
t.is(
toks.map((x) => x.value).join(' '),
'a && b || c ; d'
)
const lists = splitTokensBySemicolon(toks)
t.is(lists.length, 2)
const { segments, ops } = splitTokensByAndOr(lists[0])
t.is(segments.length, 3)
t.is(ops.join(' '), '&& ||')
})
test('execShellLine && || ; short-circuit and exitCode', async (t) => {
const dir = testCorestoreDir('shandor')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('sand'))
await drive.ready()
await personal.ready()
const stub = `
async function run(ctx, argv) {
const c = argv[0]
ctx.ran.push(c)
ctx.exitCode = c === 'false' ? 1 : 0
}
`
await drive.put('/bin/true', b4a.from(stub))
await drive.put('/bin/false', b4a.from(stub))
await drive.put(
'/bin/rec',
b4a.from(`
async function run(ctx, argv) {
ctx.ran.push('rec')
ctx.exitCode = 0
}
`)
)
const ran = []
const ctx = testCtx(drive, personal)
ctx.ran = ran
ctx.exitCode = 0
await execShellLine(ctx, 'false && rec')
t.is(ran.join(','), 'false')
t.is(ctx.exitCode, 1)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'false || rec')
t.is(ran.join(','), 'false,rec')
t.is(ctx.exitCode, 0)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'true && rec')
t.is(ran.join(','), 'true,rec')
t.is(ctx.exitCode, 0)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'true || rec')
t.is(ran.join(','), 'true')
t.is(ctx.exitCode, 0)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'false || false || rec')
t.is(ran.join(','), 'false,false,rec')
t.is(ctx.exitCode, 0)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'true && false || rec')
t.is(ran.join(','), 'true,false,rec')
t.is(ctx.exitCode, 0)
ran.length = 0
ctx.exitCode = 0
await execShellLine(ctx, 'true; rec')
t.is(ran.join(','), 'true,rec')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('cron fieldMatches and dowFieldMatches', async (t) => {
t.ok(fieldMatches('*', 0, 0, 59))
t.ok(fieldMatches('*/5', 10, 0, 59))
@@ -601,6 +694,20 @@ test('stopBareInitd runs registered disposers', async (t) => {
t.is(n, 1)
})
test('runKernelShutdownHooks runs LIFO once', async (t) => {
const o = []
registerKernelShutdownHook(async () => {
o.push('a')
})
registerKernelShutdownHook(async () => {
o.push('b')
})
await runKernelShutdownHooks()
t.is(o.join(','), 'b,a')
await runKernelShutdownHooks()
t.is(o.join(','), 'b,a')
})
test('fish-readline stripAnsi and fuzzyMatch', async (t) => {
t.is(stripAnsi('\x1b[32mhi\x1b[0m'), 'hi')
t.ok(fuzzyMatch('hello', 'hlo'))
@@ -750,6 +857,67 @@ test('tier-1 jq from system drive', async (t) => {
rmSync(dir, { recursive: true, force: true })
})
test('tier-1 getconf and xargs from system drive', async (t) => {
const dir = testCorestoreDir('gxc')
const store = new Corestore(dir)
const drive = new Hyperdrive(store)
const personal = new Hyperdrive(store.namespace('pgx'))
await drive.ready()
await personal.ready()
await drive.put('/bin/getconf', b4a.from(await readBuiltBin('getconf')))
await drive.put('/bin/echolog', b4a.from(`
async function run(ctx, argv) {
ctx.console.log(argv.slice(1).join(' '))
}
`))
await drive.put('/bin/xargs', b4a.from(await readBuiltBin('xargs')))
await drive.put('/bin/false', b4a.from(await readBuiltBin('false')))
await drive.put('/bin/printf', b4a.from(await readBuiltBin('printf')))
const lines = []
const ctx = testCtx(drive, personal)
ctx.runBinCommand = (argv) => runBinCommand(ctx, argv)
ctx.exitCode = 0
ctx.console = {
log(s) {
lines.push(String(s))
},
error(s) {
lines.push(String(s))
}
}
await runBinCommand(ctx, ['getconf', 'PATH_MAX'])
t.is(ctx.exitCode, 0)
t.is(lines.pop(), '4096')
lines.length = 0
ctx.exitCode = 0
await runBinCommand(ctx, ['getconf', 'NOT_A_REAL_CONF_NAME'])
t.is(ctx.exitCode, 1)
lines.length = 0
ctx.exitCode = 0
ctx.shellStdin = 'hello\tworld\n'
await runBinCommand(ctx, ['xargs', 'echolog'])
t.is(ctx.exitCode, 0)
t.is(lines.join('\n'), 'hello world')
lines.length = 0
ctx.exitCode = 0
ctx.shellStdin = 'a\0b\0'
await runBinCommand(ctx, ['xargs', '-0', '-n1', 'echolog'])
t.is(ctx.exitCode, 0)
t.is(lines.join('\n'), 'a\nb')
lines.length = 0
ctx.exitCode = 0
ctx.shellStdin = ''
await execShellLine(ctx, 'false || printf recovered')
t.is(lines.pop(), 'recovered')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
test('curl delegated from booter with stub fetch', async (t) => {
const dir = testCorestoreDir('curl')
const store = new Corestore(dir)