What this project has had to understand about Crystal, beyond its manual: the points that changed a design or caused a failure. It is not a guide to Crystal. Read it before writing any: its first section is how OCX's code is written, and the guards hold every change to it.
Writing OCX's Crystal
Use the language: let the type say what the code means, and let the compiler check it (stone's review, 4bb787403b).
- Know what the compiler checks. Types are static even where they are
inferred: a value of a union type must be narrowed (
is_a?,case,if x) before it is used as one member, or the program does not compile. The compiler cannot check a cast that runs:as(T),JSON::Any#as_sand the like raise only when they run. AHash(K, V)fixes the types of its keys and values, not which keys exist (a missing one raisesKeyErrorwhen read) or which key holds which member of a union. A struct or aNamedTuplefixes both: a misspelled name does not compile. - Typed inside, JSON at the edges. Read JSON straight into a typed
element (
JSON::Serializable), where it enters; write it from one, where it leaves.JSON::Anystays at the edge that reads a message (src/ocx/mcp.cr); guard:spec/guards/typing_spec.cr. - Generate from the type. A tool's input and answer are elements
(
src/ocx/elements.cr): a macro walks each type's described fields to give its JSON, its shape, its arguments, its reading from a command line or a form, and its text and HTML. Write nothing per tool or per entry point that the type already says. to_sper element. A leaf, a value with a rule of its own, says how it reads (to_s) and parses itself. A branch writes no text of its own: its layout belongs to each entry point, which walks it (src/ocx/text.cr,src/ocx/markdown.cr). Guard:spec/guards/derivation_spec.cr. The one tool laid out by hand isconsole, a presentation the maintainer asked to have fitted to each entry point (HISTORY.md decision 100): its page and its text are its own (src/ocx/console_page.cr,src/ocx/console_text.cr), and every other tool,questionsamong them, keeps the one walk.- Derive the schema; never write one. A tool's input, output and
context, and its log's operations, are types; their JSON Schemas come from
those types (
Shape.of), so what the list, help, the web page's forms and MCP's tools/list say cannot differ from what the code reads. - Name the arguments when building a value.
Step.new(does: d, outcome: o), neverStep.new(d, o, s): two arguments of one type swap unseen by position, and a misspelled name does not compile. Guard:spec/guards/typing_spec.cr, past two positional arguments. - One base exception for refusals. Everything a caller can cause raises
an
OCX::Refusal(src/ocx/refusal.cr), which every tool's answer rescues, so a refusal is answered in every entry point and nothing a caller does crashes a run. Guard:spec/guards/typing_spec.cr. - The pinned compiler, and newer ones. The gate runs the pinned
compiler (below), which cannot see what a newer one deprecates. 1.19.1
deprecates
Time.monotonicforTime.instant, which OCX uses for monotonic readings.crystal run ci.crdoes not show deprecation warnings. OCX times a run withTime.utcand shows milliseconds, which neither deprecates. Try a newer compiler before moving the pin. - Name a program by its path when its environment's PATH matters.
Crystal 1.19 looks up a bare program name on OCX's own PATH, not on the
PATH given to the process, so
Process.new("fossil", env: ...)runs whichever fossil OCX's PATH finds first. Look it up withProcess.find_executable(name, path)and pass the path, asFossil.programandLaunch.programdo.
The toolchain
- The compiler is pinned, and CI prints and checks the pin (precept I3,
Pinned Toolchain, in
PLAN.md), now Crystal 1.19.1, the client's. It has no--cache-diroption; its cache path comes fromcrystal env, and a sandbox that cannot write there needs one it can. crystal runresolvesrequirefrom the file's own directory, so a throwaway script outside the tree cannot require the tree's sources; run it from inside the tree.crystal run NAME.crbuilds to one path in its cache,crystal-run-NAME.tmp, and deletes it once the program ends, so two at once in one cache run or delete each other's program: two parts of the specs, each staging a release whose gate is aci.cr, failed with "you've found a bug in the Crystal compiler". Each part has a cache of its own (spec/support/parts.cr).- The spec runner splits its examples: a spec program given
SPEC_SPLIT=PART%COUNTin its environment, as the compiler is givenCRYSTAL_CACHE_DIR, runs only the examples whose place, counted in the order the files define them before any tag filters them, leaves PART over when divided by COUNT. CI builds the spec program once and runs it as parts side by side, and fails unless the parts ran as many examples as a dry run (--dry-run) lists (spec/support/parts.cr).
Regular expressions
Crystal's Regex is PCRE2, which differs from what many expect:
\his horizontal whitespace, not a hex digit; use[0-9a-f]. A spec matching a check-in hash with\h+found nothing (0160971667).^and$match only at the ends of the string unless the pattern starts with(?m); a scan of comment lines found none without it (873fc0fb59).- A regex made from a caller's text can be too large to compile: an
edit's
oldof 200 000 characters, escaped into aRegex, raised "regular expression is too large"; count where one text appears in another withsplit, and refuse text that is not UTF-8, on which a regex fails, where it enters (720b6b5cf3).
Processes and the environment
- OCX reads no environment variable (precept C6, Explicit Arguments).
Configuration arrives as arguments; a child process is started with
clear_env: trueand only the variables it needs, such asHOMEandPATH. - An argument holding a NUL cannot start a process:
Process.runraisesError executing process: 'fossil': String contains null byte (ArgumentError), the child's backtrace after it, so OCX refuses such text where it enters and an answer keeps only an error's first line (720b6b5cf3). Fossil reads its caller's environment: a CGI request's variables made
fossil cloneanswer as a web request, and withoutHOMEit could not start. Each Fossil call states its environment (666c151f57, bbbd12aa26).Crystal reaps every child: its SIGCHLD handler calls
waitpid(-1, WNOHANG)and keeps a status noProcess#waitasked for, so code of its own that waits for any child takes statusesProcess#waitwaits for, and blocks on any child still running: a spec whose process serves Fossil never returned. Wait for a child by its pid (HISTORY.md decision 80).A
CHLDtrap runs once Crystal's reaper has waited:Signal::CHLD.trapsets a handler that the reaper's fiber calls after it has taken every status it can, so aProcessthe handler asks about reads asterminated?once its program has ended; andProcess#waitgives the status only once each copy of its output has ended, which a child the program left holding that output delays for as long as it runs. The watcher ends what a program left, in its session's process groups and in a sandbox bubblewrap left, from such a trap. A trap is the whole process's, and a spec that watches two runs at once in one process resets it as the first watch ends, so the watcher waits until what it ended has ended by waiting for each by its pid, never by the trap (HISTORY.md decisions 97 and 118).Process#waitraises what the copy of an input met: given an input that is no file descriptor, such as anIO::Memory,Process.newcopies it into a pipe in a fiber, andProcess#waitraises that copy's error before it gives the status. A run's program that exited without reading its first line raised "Broken pipe" there, at once for a line longer than the pipe holds and at times for a short one, and the watcher, which rescued it, wrote noended(a review of 9118789d1a).Sandbox.startwrites such an input itself, so a process that does not read it is no error.A write to a terminal whose far end has hung up never waits: Crystal 1.18's event loop takes a descriptor that has said it hung up as ready for ever, so a write that finds the terminal full tries again at once, without end, and no other fiber runs, not the one that reaps a child nor the one a TERM wakes. A watcher typing into a terminal its program had left full, a whole line unread and 64 KiB more, ran at 100% CPU for ever once the program exited (a review of 35b2b0febb).
Terminal::Typedwrites itself, asks poll(2) whether the far end has hung up, and otherwise waits throughCrystal::EventLoop, the runtime's own, which a newer compiler may change: try it before moving the pin.wait_writableis bounded by its descriptor alone:Crystal::EventLoop#wait_writabletakes no bound; it waits as long as the descriptor'swrite_timeout, which every fiber waiting on that descriptor shares, and a write edge wakes one of them.Terminal::Typed, waiting for its program to read what it typed, waits through a duplicate descriptor of the terminal's near end, its own, whose timeout is its bound; closing it takes it out of the runtime's epoll set first (HISTORY.md decision 106).
Files
- Check a file through the handle that reads it. Checking a path and
then opening it lets the file change in between; OCX opens once and checks
that handle's real path
(8ef3cd9765; see
linux.md). File.realpathfollows links; compare real paths, not the paths given, to tell whether a file is inside a tree (877097e6b9).File.exists?follows them too, so a link to nothing reads as not there and a write through it lands where it leads: look at each part not there withFile.symlink?, which raisesFile::Errorunder a link that leads back to itself (898527c82d).
HTTP
HTTP::Client's timeouts bound one wait, not the answer: a server sending a byte now and then, each withinread_timeout, held a clone's asks for ever;Exchange.within(src/ocx/exchange.cr) bounds each request in full, ten seconds from its start, and closes its client (d748182db9).