Skip to content

engine: built-in fs module (Node.js-shaped filesystem API for rules) + import/export in .ts rule files - #231

Draft
evgeny-boger wants to merge 3 commits into
quickjs-tsfrom
quickjs-fs
Draft

engine: built-in fs module (Node.js-shaped filesystem API for rules) + import/export in .ts rule files#231
evgeny-boger wants to merge 3 commits into
quickjs-tsfrom
quickjs-fs

Conversation

@evgeny-boger

Copy link
Copy Markdown
Member

Что происходит; кому и зачем нужно

Встроенный модуль fs — файловый API в стиле Node.js для сценариев правил: чтение sysfs, журналы, конфиги, временные файлы, наблюдение за изменениями — без runShellCommand("cat …"). Поверх #224 (TypeScript), заменяет #187 (глобальный объект fs эпохи Duktape): модуль вместо глобала, промисы вместо колбэков, Node-точные формы данных, типы и документация.

const fs = require("fs");            // также "fs/promises", "node:fs"
const raw = fs.readFileSync("/sys/class/thermal/thermal_zone0/temp");
await fs.appendFile("/var/log/events.log", `${new Date().toISOString()} motion\n`);
const watcher = fs.watch("/etc/wb-rules/heating.conf", (event) => reload());
import * as fs from "fs";            // .ts: import/export теперь работают
const st: fs.Stats = fs.statSync("/etc/hostname");

Что поменялось для пользователей

  • require("fs") / require("fs/promises")node:-варианты): readFile, writeFile, appendFile, stat/lstat, readdir, exists, mkdir, rm, rmdir, unlink, rename, copyFile, access, realpath, readlink, symlink, chmod, mkdtemp, truncate, utimes — каждая как блокирующая …Sync и как возвращающая Promise; fs.watch() (inotify); fs.constants.
  • Формы данных как в Node: Stats с isFile()/isDirectory()/…, числовым mode, mtimeMs/mtime; readdir → имена или Dirent (withFileTypes, recursive); ошибки — Error с code/errno/syscall/path/dest и Node-сообщением; неверные аргументы — TypeError с ERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUE.
  • Сознательные отличия от Node (задокументированы): только строки UTF-8 (без Buffer); только промисы (колбэк — ошибка с понятным сообщением); fs.exists() как промис; fs.watch возвращает объект с close(), без EventEmitter и без recursive; readFile не читает файлы > 10 МиБ; нет дескрипторов/потоков.
  • Доступ — с правами процесса (root), как у spawn(); относительные пути — от cwd процесса (README рекомендует абсолютные).
  • TypeScript: import/export в .ts-сценариях теперь работают — транспилятор выдаёт CommonJS (+esModuleInterop), фоновая проверка получила --esModuleInterop. Раньше любой import ронял загрузку файла с SyntaxError.
  • Типы: declare module "fs"/"fs/promises" и перегрузки require("fs") в types/wb-rules.d.ts — редактор и проверка на контроллере знают модуль (homeui: отдельный PR с синхронизацией d.ts).
  • Документация: README (раздел «Файловая система: модуль fs», правка про import/export), arc42 §5/§8/§9/§12, ADR-017; changelog (в станце 2.47.0~quickjs2.1 — версия намеренно не поднята, чтобы сборка ветки не перекрыла сборку quickjs-ts в публичном наборе experimental.quickjs2; поднять при слиянии).

Как устроено

  • wbrules/fsmodule.js (встроен через go:embed) — тело модуля: валидация аргументов, опции, классы Stats/Dirent/FSWatcher, промисы. ModSearch отдаёт его раньше каталогов модулей (как модули ядра Node), компилируется в realm каждого файла → промисы/ошибки принадлежат файлу, атрибуция после await сохраняется.
  • wbrules/fsmodule.go — realm-локальные builtin'ы _wbFsSync(op, args) / _wbFsAsync(op, args, cb) над одной таблицей операций fsOps; асинхронный путь повторяет жизненный цикл spawn (MaybeCallSync, однократный sweep stash-записи, orphan-sweep при остановленном движке).
  • wbrules/fswatch.gofs.watch напрямую поверх inotify(7) через golang.org/x/sys/unix (уже зависимость той же версии v0.38.0, что у wbgo-private → ABI плагина wbgo.so не затронут; fsnotify из Добавить Node.js-style файловый API (глобальный объект fs) #187 не нужен): дескриптор на watcher, горутина-читатель, маска libuv; закрытие cleanup'ом файла, Stop(), close() из JS, при IN_IGNORED.
  • wbrules/tsloader.gomodule: CommonJS + esModuleInterop в transpileModule, --esModuleInterop в проверке.

Коммиты: 0d86856 engine: built-in fs module; 7574f38 ts: CommonJS output so import/export work in .ts rule files; 5202d47 fs module: review round.

Как проверял

  • Тесты: rule_fs_test.go (все операции sync+promise, коды ошибок/errno/syscall/path/dest, throwIfNoEntry, withFileTypes/recursive, алиасы fs/promises/node:fs, перекрытие пользовательского fs.js), TestRuleFsWatchSuite (события dir/file, close(), авто-закрытие при удалении пути, закрытие при удалении файла правил и при Stop), TestRuleTsFsSuite (три стиля import, export, номера строк трейсбека с interop-преамбулой), testrules_ts_typeassert.ts (контракт типов, @ts-expect-error в обе стороны, --strict true/false). Полный go test ./wbrules/ зелёный.
  • WB8 (arm64, 192.168.1.103, hand-built бинарник ветки + d.ts, stop→swap→start): .js-проба — readFileSync("/etc/hostname"), readdirSync("/sys/class/thermal"), existsSync; кнопка → await writeFile/appendFile/stat/readdir; ожидаемая ошибка ENOENT open ENOENT: no such file or directory, open '/nonexistent/file'; fs.watch на каталоге: rename/change при создании и записи, rename при удалении (счётчик событий 6/6); cron-правило читает sysfs каждые 10 с (на этом стенде thermal_zone0 отдаёт 0). .ts-проба: import * as fs, import fsd from "fs", import { readdirSync }, export const — загрузилась и отработала (size=20 entries=13 same=true, async после TLA). Намеренно неправильный .ts: загрузка падает TypeError: The "data" argument must be of type string. Received type number (42) с указанием fs-probe-bad.ts:4:17, фоновая проверка отмечает все три строки (3:7, 4:28, 5:30). 0 ошибок/паник в журнале, RSS 42 МБ. Сборка после проверки снята со стенда (он занят другим агентом), бинарник лежит в /root/wb-rules.fs-new.
  • Ревью: три независимых from-scratch агента (Go/lifecycle, JS-API/типы/доки, CommonJS-эмит) — см. ниже.

Supersedes #187.

Ревью (три независимых агента, from scratch) и что исправлено

Все находки закрыты в коммите 5202d47 (review round); ниже — по одной строке на находку.

Go/lifecycle

  • HIGH copyFile в самого себя (тот же путь, symlink, hardlink) обнулял источник (O_TRUNC до сравнения inode) → порядок libuv: открыть оба, сравнить dev/ino, no-op если совпали, только потом ftruncate; тест.
  • MED-HIGH с флагом -cleanup cleanup-список выполняется с горутины Stop до остановки sync-loop — cleanup-замыкание watcher'а трогало JS-кучу → watcher'ы учитываются по realm'у (как таймеры) и закрываются из runCleanups; замыканий в cleanup-списке нет (заодно ушёл рост списка при churn watch()/close()).
  • MED-LOW завершение, принятое в очередь останавливающегося движка и выброшенное при drain, теряло stash-запись → orphan-note ДО постановки в очередь, thunk снимает его сам (orphanedCallbacks — map); тест с 40 чтениями «в полёте» на Stop.
  • LOW закрытие inotify-fd ~8 мс (RCU) в engine loop → один экземпляр на движок, снятие watch'а дёшево, закрытие экземпляра вне loop; LOW mkdir{recursive} поверх битого symlink → EEXIST; LOW setuid/setgid/sticky терялись в os.FileMode(uint32)posixMode(); LOW поддельная args.length=2**31 → guard.

JS-API / типы / документация

  • MED rmdir(...,{recursive:true}) удалял обычные файлы → ENOTDIR как в Node ≥ 16; MED readFile игнорировал flag ("a+" теперь создаёт файл) и не валидировал его; MED в d.ts не было fallback-перегрузок для булевых переменных (stat/readdir/mkdir) → добавлены и закреплены в фикстуре; MED один inotify-экземпляр на watcher упирался в max_user_instances (128 на uid, общий с другими демонами) → общий экземпляр.
  • LOW throwIfNoEntry:false теперь и для ENOTDIR; ELOOP для петли symlink'ов; utimes с невалидной Date; флаги rs/rs+/as/as+; валидация типа symlink (ERR_FS_INVALID_SYMLINK_TYPE); диапазон mode в access/copyFile (ERR_OUT_OF_RANGE); ERR_FS_FILE_TOO_LARGERangeError; COPYFILE_FICLONE_FORCEENOTSUP; README: exists, birthtime = ctime, bigint, FIFO/зависшие пути блокируют, неограниченный Promise.all, не-UTF-8 имена.

CommonJS-эмит

  • HIGH трейсбеки async rule error никогда не проходили через source map (только синхронный путь) — с interop-преамбулой сдвиг стал систематическим → translateStackLines в обработчике ошибок промис-джобов; тест TestTsFsAsyncErrorLineNumbers.
  • MED проверка с --module esnext считала import x = require()/export = синтаксическими ошибками (TS1202/1203) и выкидывала файл из батча, хотя рантайм их исполняет → --module preserve (диагностики на всех фикстурах и системных правилах идентичны, TLA работает); homeui LS — так же.
  • MED относительные import "./x" проходят проверку по соседнему .ts, но рантайм ищет только .js в каталогах модулей → задокументировано; LOW неиспользуемый импорт вырезается, import * as h модуля-функции не вызываем, import.meta — задокументировано/оставлено; docs drift командной строки проверки (arc42 §3/§6, ADR-010/012) исправлен.

Сознательно не сделано: bounded pool для асинхронных операций (как threadpool в Node) — задокументировано как ограничение; spawn остаётся на старой схеме orphan-учёта (известный долг, отдельный PR); удаление частично записанного dest при сбое copyFile.

require("fs") (also "fs/promises" and the node: spellings) resolves to a
module implemented by the engine: readFile, writeFile, appendFile,
stat/lstat, readdir, exists, mkdir, rm, rmdir, unlink, rename, copyFile,
access, realpath, readlink, symlink, chmod, mkdtemp, truncate, utimes -
each as a blocking ...Sync function and as a promise-returning one - plus
fs.watch over inotify and fs.constants.

Shape: files are UTF-8 strings (no Buffer, no other encoding); the
asynchronous functions return promises instead of taking callbacks
(fs.readFile === fs.promises.readFile); Stats and Dirent carry Node's
predicates and fields; failures are Errors with code/errno/syscall/path/
dest and Node's message; bad arguments are TypeErrors with
ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE. readFile refuses files over
10 MiB (one JS heap serves every rule file). Supersedes the global-object
proposal of #187.

Implementation: fsmodule.js is embedded and compiled once per rule file
(ModSearch serves it before the module directories, like Node's core
modules), so its promises and errors belong to the requiring realm.
Two realm-local builtins do the I/O - _wbFsSync on the engine loop,
_wbFsAsync on a goroutine with the spawn callback lifecycle (MaybeCallSync,
one-shot stash sweep, orphan sweep on a stopped engine) - over one table of
operations shared by both. fs.watch is inotify(7) through the x/sys/unix
package that is already a dependency (no fsnotify: the wbgo.so plugin's
package set stays untouched): one descriptor per watcher, a reader
goroutine, libuv's event mask; watchers close with their file's cleanup
scope, at Stop, from JS and when the kernel drops the watch.

Types: declare module "fs" / "fs/promises" in wb-rules.d.ts plus require()
overloads, pinned by the type-assertion fixture. Docs: README section,
arc42 5/8/9/12, ADR-017. Tests: rule_fs_test.go (every operation and error
code, per-file instances, shadowing of a user fs.js), watch events, close,
cleanup on reload, Stop.

Note for callers of ESContext.GetJSObject(idx): it converts the stack top
regardless of idx (getArray enumerates -1); fsOpArgs dups the argument to
the top first.
The transpiler left ESM syntax in place, and inside the async function
wrapper an import or export statement is a SyntaxError - every .ts rule
file with an import failed to load. transpileModule now runs with module
CommonJS and esModuleInterop: import * as fs from "fs", import fs from
"fs" (interop helper) and import { readFileSync } from "fs" become
require() calls, export const x becomes an exports assignment - exactly
what the wrapper's require/exports/module parameters provide. Top-level
await passes through untouched. The background check gets
--esModuleInterop so default imports type-check the same way; the strict
type-assertion run mirrors it.

The helpers TypeScript prepends shift generated lines; the source-map
line table already accounts for that, pinned by TestTsFsErrorLineNumbers.
…ce, cleanup and orphan lifecycle, Node fidelity, async tracebacks

Three from-scratch reviews (Go lifecycle, JS API and types, CommonJS emit);
every finding is addressed here.

copyFile onto the same inode (same path, a symlink or a hard link to the
source) truncated the source before copying. Now libuv's order: open the
destination without O_TRUNC, fstat both, a no-op when they are the same
file, only then truncate. Modes are carried with their setuid/setgid/
sticky bits (posixMode - a plain os.FileMode cast dropped them).

fs.watch used one inotify instance per watcher; instances are a per-user
resource (max_user_instances, typically 128, shared with every root
daemon) and closing one costs milliseconds of RCU grace period on the
engine loop. Now one instance per engine, watchers of an inode share the
watch descriptor, the descriptor is kept apart from the os.File because
(*os.File).Fd() switches it to blocking mode, and the instance is closed
off-loop with the last watcher. Watchers are tracked per realm like
timers and closed from runCleanups: the per-watcher cleanup-list closure
they used before would have raced the interpreter under -cleanup, where
RunAllCleanups runs from the Stop caller's goroutine before the sync loop
handshake - and it grew the list without bound on watch()/close() churn.

An async completion accepted by a stopping engine's queue and dropped in
its drain left its callback stash entry behind, because MaybeCallSync
cannot report the drop. The orphan note is now taken before the thunk is
queued and withdrawn by the thunk (orphanedCallbacks is a map); the fs
async path and the watcher reader use it. spawn keeps the old scheme -
noted as a follow-up.

Node fidelity: rmdir(path, {recursive: true}) refuses non-directories
(ENOTDIR) instead of unlinking files; readFile honours and validates
flag; throwIfNoEntry: false also covers ENOTDIR; symlink loops map to
ELOOP; utimes rejects an invalid Date; the rs/rs+/as/as+ flags; symlink
type validation (ERR_FS_INVALID_SYMLINK_TYPE); access/copyFile mode range
(ERR_OUT_OF_RANGE); ERR_FS_FILE_TOO_LARGE is a RangeError;
COPYFILE_FICLONE_FORCE reports ENOTSUP; mkdir recursive through a
dangling symlink reports EEXIST; a forged huge args array is refused
before the converter preallocates by length.

Types: fallback overloads so stat/readdir/mkdir accept boolean variables
(stat then yields Stats | undefined), the extra flags, the symlink type
union - pinned in the type-assertion fixture.

TypeScript: async rule errors reported by the promise-rejection tracker
never went through the source-map line translation (only the synchronous
path did); with the CommonJS interop preamble the shift became
systematic, so the job error handler now translates too
(TestTsFsAsyncErrorLineNumbers). The background check moves from
--module esnext to --module preserve: esnext reported the CommonJS forms
the transpiler emits and runs - import x = require("m"), export = - as
syntax errors (TS1202/TS1203) that dropped the file from the batch;
preserve accepts them with otherwise identical diagnostics. The strict
type-assertion run mirrors it.

Docs: README (exists wording, birthtime = ctime, bigint, FIFO and stuck
paths block, unbounded Promise.all, non-UTF-8 names, rmdir recursive,
readFile flag, relative and side-effect imports, namespace import of a
function module), arc42 command lines (3, 5, 6, ADR-010, ADR-012),
ADR-017 (shared instance, per-realm cleanup, orphan bookkeeping),
changelog date.

Tests: copy onto itself and through a symlink, rmdir recursive on a file,
readFile a+, stat through a file, invalid Date, symlink type, mode range,
ELOOP, oversized file (sync RangeError and async rejection), setgid bit,
mkdir over a dangling link; two watchers on one path sharing a descriptor,
instance released with the last watcher and recreated, watcher created
inside a rule closed with the file; 40 in-flight completions swept across
Stop (the callback stash is runtime-wide, measured against a baseline).
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 critical · 14 high

Alerts:
⚠ 15 issues (≤ 0 issues of at least minor severity)

Results:
15 new issues

Category Results
Security 1 critical
14 high

View in Codacy

🟢 Metrics 420 complexity · 4 duplication

Metric Results
Complexity 420
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant