Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog/unreleased/1607.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- **BREAKING CHANGES:** `effects`: every operation's return type carries a
`Result`. Infallible operations answer `OpResult<T>`
(`Result<T, NotImplemented>`); host IO answers `IoResult<T>`, whose error is
now the structured `NotImplemented | IoError` rather than `unknown`. Runner
handlers wrap their output in `ok(...)`; `isNotFound` takes a channel error
- `effects/node`: new `IoError` / `IoErrorInfo` / `OpResult` types with
`ioError`, `toIoError`, `errorMessage` and `exitStep`, the `NodeProgram`
exit-code policy
- `effects/io`: new `unwrapStep`, which leaves the layer by panicking on the
error branch
25 changes: 10 additions & 15 deletions fjs/cas/cli/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import { sha256 } from '../../crypto/sha2/module.f.mjs'
import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.mjs'
import { forEachStep, pure, step } from '../../effects/module.f.mjs'
import { errorExit, log, writeFromStream } from '../../effects/node/module.f.mjs'
import { errorExit, exitStep, log, writeFromStream } from '../../effects/node/module.f.mjs'
import { step as ioStep, unwrapStep } from '../../effects/io/module.f.mjs'
import { dispatch } from '../../cli/module.f.mjs'
import { casAddFile, fileCas } from '../module.f.mjs'

Expand All @@ -26,15 +27,9 @@ export const commands = [
return errorExit("'cas add' expects one parameter")
}
const c = fileCas(sha256)(home)
return step(
casAddFile(c)(path),
hashResult => hashResult[0] === 'error'
? pure(1)
: step(
log(vecToCBase32(hashResult[1])),
() => pure(0)
)
)
const added = casAddFile(c)(path)
const logged = ioStep(added, hash => log(vecToCBase32(hash)))
return exitStep(logged)
},
},
{
Expand All @@ -50,18 +45,18 @@ export const commands = [
}
const c = fileCas(sha256)(home)
const x = c.read(hash)
return step(
writeFromStream(path, x),
([r, v]) => r === 'error' ? errorExit(`e: ` + String(v)) : pure(0),
)
return exitStep(writeFromStream(path, x))
},
},
{
names: ['list'],
description: 'List all stored content hashes',
handler: ({ home }) => {
const c = fileCas(sha256)(home)
const x0 = forEachStep(c.list(), j => log(vecToCBase32(j)))
// A listing that cannot reach stdout has no useful fallback, and
// `forEachStep`'s `void` accumulator would otherwise discard each
// write's outcome silently.
const x0 = forEachStep(c.list(), j => unwrapStep(log(vecToCBase32(j))))
return step(
x0,
() => pure(0)
Expand Down
10 changes: 7 additions & 3 deletions fjs/cas/cli/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ export const proof = {
mainAddMissingFile: () => {
// The source path doesn't exist, so `streamFile`'s first read comes back as an
// error item; `write` fails closed with that error and the handler exits 1
// without ever calling `log` — covers the `hashResult[0] === 'error'` branch.
const [, exitCode] = virtual(emptyState)(main(makeOptions(['add', 'missing'])))
// without ever calling `log` — covers `exitStep`'s error branch.
const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['add', 'missing'])))
assertEq(exitCode, 1)
assertEq(finalState.stderr, 'no such file or directory\n', finalState.stderr)
},
mainGetFound: () => {
const content = vec8(0x2An)
Expand All @@ -76,7 +77,10 @@ export const proof = {
// use an empty store so the hash is not found
const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get', hashStr, 'output'])))
assertEq(exitCode, 1, ['expected exit 1', exitCode])
assert(finalState.stderr.length !== 0, 'expected error in stderr')
// The *message*, not just a non-empty line: the failure reaches the user
// as the host's own words. Asserting only that something was written is
// what let a stringified error tuple (`ioError,[object Object]`) pass.
assertEq(finalState.stderr, 'no such file or directory\n', finalState.stderr)
},
mainGetWrongArgs: () => {
const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get'])))
Expand Down
11 changes: 7 additions & 4 deletions fjs/cas/evo/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
*/

import { pure, foldStep, mapStep, step } from '../../effects/module.f.mjs'
import { unwrapStep } from '../../effects/io/module.f.mjs'
import { create, read, write } from '../../effects/memory/module.f.mjs'
import { collectRead } from '../module.f.mjs'
import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.mjs'
Expand Down Expand Up @@ -229,13 +230,15 @@ export const buildCache = cas =>
* @returns {Effect<O | MemOp, Key<Cache>>}
*/
export const initEvo = cas =>
step(buildCache(cas), cache => create(cache))
step(buildCache(cas), cache => unwrapStep(create(cache)))

/** Reads, then rewrites, the cache at `cacheKey` with `revision` folded in at `hash`.
* @type {(cacheKey: Key<Cache>) => (hash: Hash) => (revision: Revision) => Effect<MemOp, void>}
*/
const foldIntoCache = cacheKey => hash => revision =>
step(read(cacheKey), cache => write(cacheKey, addRevisionToCache(hash, revision)(cache)))
step(
unwrapStep(read(cacheKey)),
cache => unwrapStep(write(cacheKey, addRevisionToCache(hash, revision)(cache))))

/**
* Folds `value` — bytes already written to a `Cas` at `hash` by some other
Expand Down Expand Up @@ -564,11 +567,11 @@ export const evo = cas => cacheKey => ({
list: archived => {
const listed = subjectListed(archived)
return mapStep(
read(cacheKey),
unwrapStep(read(cacheKey)),
cache => definedEntries(cache.bySubject)
.flatMap(([subject, state]) => listed(state) ? [subject] : []))
},
head: subject => mapStep(read(cacheKey), cache => {
head: subject => mapStep(unwrapStep(read(cacheKey)), cache => {
const state = at(subject)(cache.bySubject)
return state === null ? [] : headsOf(state)
}),
Expand Down
11 changes: 6 additions & 5 deletions fjs/cas/evo/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
assertStructurallySame,
} from '../../asserts/module.f.mjs'
import { pure } from '../../effects/module.f.mjs'
import { ioError } from '../../effects/node/module.f.mjs'
import { fileCas } from '../module.f.mjs'
import { sha256 } from '../../crypto/sha2/module.f.mjs'
import { emptyState, virtual } from '../../effects/node/virtual/module.f.mjs'
Expand All @@ -33,7 +34,7 @@ const home = '.'
/** @type {Cas<never>} */
const writeFailingCas = {
read: () => elEmpty(),
write: () => pure(error('boom')),
write: () => pure(error(ioError({ message: 'boom' }))),
list: () => pure([]),
}

Expand All @@ -42,8 +43,8 @@ const writeFailingCas = {
// large for `collectRead` to buffer looks like to a caller.
/** @type {Cas<never>} */
const readFailingCas = {
read: () => nonEmpty(error('boom'), elEmpty()),
write: () => pure(error('write not supported')),
read: () => nonEmpty(error(ioError({ message: 'boom' })), elEmpty()),
write: () => pure(error(ioError({ message: 'write not supported' }))),
list: () => pure([]),
}

Expand All @@ -56,10 +57,10 @@ const fixedCas = entries => ({
read: hash => {
const found = entries.find(([h]) => vecToCBase32(h) === vecToCBase32(hash))
return found === undefined
? nonEmpty(error('not found'), elEmpty())
? nonEmpty(error(ioError({ message: 'not found' })), elEmpty())
: nonEmpty(ok(found[1]), elEmpty())
},
write: () => pure(error('write not supported')),
write: () => pure(error(ioError({ message: 'write not supported' }))),
list: () => pure(entries.map(([h]) => h)),
})

Expand Down
22 changes: 13 additions & 9 deletions fjs/cas/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* @import { Sha2, State as Sha2State } from '../crypto/sha2/types.ts'
* @import { Vec } from '../types/bit_vec/types.ts'
* @import { Effect, Operation } from '../effects/types.ts'
* @import { NotImplemented } from '../effects/io/types.ts'
* @import {
* IoError,
* IoResult,
* Now,
* RandomInt,
Expand All @@ -22,9 +24,11 @@ import { join, normalize, parse } from '../path/module.f.mjs'
import { empty, length, maxLength, maxLengthBytes, msb, vec } from '../types/bit_vec/module.f.mjs'
import { cBase32ToVec, vecToCBase32 } from '../basen/cbase32/module.f.mjs'
import { foldStep, forEachStep, history, historyStep, mapStep, okStep, pure, step } from '../effects/module.f.mjs'
import { unwrapStep } from '../effects/io/module.f.mjs'
import {
access,
createExclusive,
ioError,
isNotFound,
mkdir,
now,
Expand Down Expand Up @@ -78,7 +82,7 @@ export const collectRead = stream => {
const [t, v] = first
if (t === 'error') { return pure(first) }
if (length(acc) + length(v) > maxLength) {
return pure(error(`cas blob exceeds maximum vector length of ${maxLength} bits`))
return pure(error(ioError({ message: `cas blob exceeds maximum vector length of ${maxLength} bits` })))
}
return loop(msb.concat(acc)(v))(tail)
})
Expand Down Expand Up @@ -134,7 +138,7 @@ const gcStage = stageDir => {
// link, so the timestamp is carried forward in a history rather than closed
// over by a nested continuation.
const listed = historyStep(
history(now()),
history(unwrapStep(now())),
() => readdir(stageDir, {}))
return step(
listed,
Expand Down Expand Up @@ -180,10 +184,10 @@ const writeImpl = (sha2, path, stageDir, payload) => {
const stated = step(removed, () => stat(dst))
return mapStep(
stated,
st => st[0] === 'ok' && st[1].size === offset ? ok(hash) : error('publish size mismatch'))
st => st[0] === 'ok' && st[1].size === offset ? ok(hash) : error(ioError({ message: 'publish size mismatch' })))
}
// Any streaming error fails closed: delete the partial file, return the error.
/** @type {(curPath: string, e: unknown) => Effect<FileCasOperation, IoResult<Vec>>} */
/** @type {(curPath: string, e: NotImplemented | IoError) => Effect<FileCasOperation, IoResult<Vec>>} */
const fail = (curPath, e) =>
mapStep(rm(curPath), () => error(e))
const rndEffect = step(gcStage(stageDir), () => random256)
Expand All @@ -208,9 +212,9 @@ const writeImpl = (sha2, path, stageDir, payload) => {
// Renew the lease: rename to a fresh deadline (keeps `delta` constant).
// The new path is still needed after the rename, to recurse with,
// so the rename captures it rather than closing over it.
const nextPath = step(
now(),
t => pure(join(stageDir, stageName(t + leaseDelta, rndStr))))
const nextPath = mapStep(
unwrapStep(now()),
t => join(stageDir, stageName(t + leaseDelta, rndStr)))
const renamed = historyStep(
history(nextPath),
next => rename(curPath, next))
Expand All @@ -222,7 +226,7 @@ const writeImpl = (sha2, path, stageDir, payload) => {
: loop(newState, newOffset, next)(tail))
})
})
const started = step(mkdir(stageDir, { recursive: true }), () => now())
const started = step(mkdir(stageDir, { recursive: true }), () => unwrapStep(now()))
return step(started, t0 => {
const path0 = join(stageDir, stageName(t0 + leaseDelta, rndStr))
return step(
Expand Down Expand Up @@ -294,7 +298,7 @@ const random256 =
pure([0, 1, 2, 3, 4, 5, 6, 7]),
empty,
() => (/** @type {Vec} */ acc) =>
mapStep(randomInt(), r => msb.concat(acc)(vec(32n)(BigInt(r)))))
mapStep(unwrapStep(randomInt()), r => msb.concat(acc)(vec(32n)(BigInt(r)))))

/** Streams any file at `filePath` in `<=128 KiB` chunks as a `ListEffect` of `ok` items.
*
Expand Down
35 changes: 22 additions & 13 deletions fjs/cas/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { cBase32ToVec, vecToCBase32 } from '../basen/cbase32/module.f.mjs'
import { computeSync, sha256 } from '../crypto/sha2/module.f.mjs'
import { fileCas, casAddFile, collectRead } from './module.f.mjs'
import { match, pure, runPure, step } from '../effects/module.f.mjs'
import { mkdir, writeFile, rm, readFile, access } from '../effects/node/module.f.mjs'
import { ioError, mkdir, writeFile, rm, readFile, access } from '../effects/node/module.f.mjs'
import { error, ok } from '../types/result/module.f.mjs'
import { emptyState, virtual } from '../effects/node/virtual/module.f.mjs'
import { join } from '../path/module.f.mjs'
Expand Down Expand Up @@ -48,8 +48,8 @@ const casCommand = match({
/** @type {(cmd: string) => unknown} */
const casDefaultResponse = cmd => {
switch (cmd) {
case 'now': return 0
case 'randomInt': return 0
case 'now': return ok(0)
case 'randomInt': return ok(0)
case 'mkdir': case 'createExclusive': case 'rename': case 'rm':
case 'writeBytes': case 'access':
return ok(undefined)
Expand Down Expand Up @@ -117,6 +117,15 @@ const drive = overrides => {
* result really is an `error` pair rather than assuming it: these proofs exist
* to establish that a write fails closed, so the shape is the claim.
*/
/**
* Asserts that a channel error is a host failure carrying `message`.
* @type {(e: unknown, message: string) => void}
*/
const assertIoMessage = (e, message) => {
assert(e instanceof Array && e[0] === 'ioError', ['expected an ioError', e])
assertEq(e[1].message, message)
}

/** @type {(result: unknown) => unknown} */
const errorMessage = result => {
assert(result instanceof Array && result.length === 2 && result[0] === 'error',
Expand Down Expand Up @@ -298,7 +307,7 @@ export const proof = {
/** @type {IoResult<Vec>} */
const okItem = ok(vec8(0x11n))
/** @type {IoResult<Vec>} */
const errItem = error({ code: 'BOOM' })
const errItem = error(ioError({ code: 'BOOM', message: 'boom' }))
/** @type {List<FileCasOperation, IoResult<Vec>>} */
const payload = nonEmpty(okItem, nonEmpty(errItem, /** @satisfies {List<FileCasOperation, IoResult<Vec>>} */ (empty())))
const [state1, result] = virtual(emptyState)(c.write(payload))
Expand Down Expand Up @@ -382,8 +391,8 @@ export const proof = {
const c = fileCas(sha256)('.')
/** @type {List<never, IoResult<Vec>>} */
const payload = nonEmpty(ok(vec8(0x11n)), empty())
const [result, log] = drive({ writeBytes: [error('disk full')] })(c.write(payload))
assertEq(errorMessage(result), 'disk full')
const [result, log] = drive({ writeBytes: [error(ioError({ message: 'disk full' }))] })(c.write(payload))
assertIoMessage(errorMessage(result), 'disk full')
// The cleanup `rm` of the partial staging file must actually run, not just be
// implied by the returned error tag.
assertEq(log[log.length - 1], 'rm', ['expected cleanup rm to run', log])
Expand All @@ -394,8 +403,8 @@ export const proof = {
const c = fileCas(sha256)('.')
/** @type {List<never, IoResult<Vec>>} */
const payload = nonEmpty(ok(vec8(0x11n)), empty())
const [result, log] = drive({ rename: [error('rename failed')] })(c.write(payload))
assertEq(errorMessage(result), 'rename failed')
const [result, log] = drive({ rename: [error(ioError({ message: 'rename failed' }))] })(c.write(payload))
assertIoMessage(errorMessage(result), 'rename failed')
assertEq(log[log.length - 1], 'rm', ['expected cleanup rm to run', log])
},
casWritePublishSizeMismatchErrors: () => {
Expand All @@ -410,7 +419,7 @@ export const proof = {
/** @type {List<never, IoResult<Vec>>} */
const payload = nonEmpty(ok(vec8(0x11n)), empty())
const [result] = drive({ stat: [ok({ size: 999 })] })(c.write(payload))
assertEq(errorMessage(result), 'publish size mismatch')
assertIoMessage(errorMessage(result), 'publish size mismatch')
},
casWritePublishStatErrorErrorsEvenWithMatchingSize: () => {
// Pins the tag half of the same check: a `stat` that fails outright must still
Expand All @@ -423,7 +432,7 @@ export const proof = {
/** @type {List<never, IoResult<Vec>>} */
const payload = nonEmpty(ok(vec8(0x11n)), empty())
const [result] = drive({ stat: [error({ size: 1 })] })(c.write(payload))
assertEq(errorMessage(result), 'publish size mismatch')
assertIoMessage(errorMessage(result), 'publish size mismatch')
},
collectReadDrainsChunks: () => {
// The common path: every chunk is `ok`, so collectRead concatenates them all
Expand All @@ -437,14 +446,14 @@ export const proof = {
collectReadPropagatesErrorItem: () => {
// An error item mid-stream short-circuits collectRead with that same error.
/** @type {IoResult<Vec>} */
const boom = error('boom')
const boom = error(ioError({ message: 'boom' }))
/** @type {List<never, IoResult<Vec>>} */
const stream = nonEmpty(ok(vec8(0x11n)), nonEmpty(boom, /** @satisfies {List<never, IoResult<Vec>>} */ (empty())))
const o = runPure(collectRead(stream))
assert(o.length === 1, 'expected collectRead to finish without issuing a command')
const [r] = o
assertEq(r[0], 'error')
assertEq(r[1], 'boom')
assertEq(r[1], boom[1])
},
// A single `Vec` cannot exceed `maxLength` bits — feed a pure stream whose second
// chunk pushes the running total just over the limit so the overflow guard fires
Expand All @@ -463,7 +472,7 @@ export const proof = {
// A non-ENOENT `access` failure (permissions, corruption) is a genuine storage
// error and must propagate out of `list`, not be swallowed as an empty store.
const c = fileCas(sha256)('.')
const boom = { code: 'EACCES' }
const boom = ioError({ code: 'EACCES', message: 'permission denied' })
const r = casCommand(c.list())
assert(r[0] === 'cont', 'expected list() to issue an access command first')
assertEq(r[1], 'access')
Expand Down
7 changes: 5 additions & 2 deletions fjs/ci/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { mapStep, step } from '../effects/module.f.mjs'
import { access, writeUtf8File } from '../effects/node/module.f.mjs'
import { unwrapStep } from '../effects/io/module.f.mjs'
import { functionalscript, images } from './config/module.f.mjs'
import {
architecture,
Expand Down Expand Up @@ -79,9 +80,11 @@ export const ci = ({ nodeExtra }) => step(
},
jobs,
}
const workflowWritten = writeUtf8File(
// A generator that cannot write its own workflow file has nothing to
// fall back on, so the failure is this program's panic.
const workflowWritten = unwrapStep(writeUtf8File(
'.github/workflows/ci.yml',
JSON.stringify(gha, null, ' '))
JSON.stringify(gha, null, ' ')))
const flakesWritten = step(workflowWritten, () => nixFlakes(nixJobs))
return mapStep(flakesWritten, () => 0)
})
Expand Down
Loading
Loading