docs: added comprehensive documentation for @oh-my-pi/pi-natives architecture

- Added comprehensive documentation for @oh-my-pi/pi-natives architecture covering three-layer design (TypeScript wrapper, addon loading/validation, Rust N-API module).
- Added deep-dive documentation for addon loading runtime including platform support, CPU variant selection, embedded addon extraction lifecycle, and validation contracts.
- Added binding contract documentation defining TypeScript-side API surface, wrapper behavior, and N-API addon validation with maintainer checklist.
- Added subsystem deep-dives for text/search pipeline (grep, glob, fs_cache, ANSI utilities), shell/PTY/process execution, task cancellation patterns, and media/system utilities.
This commit is contained in:
can1357
2026-02-16 15:08:23 +00:00
parent 2e45297c43
commit 53ad78f7fb
8 changed files with 1815 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
# Natives Addon Loader Runtime
This document deep-dives the addon loading/validation layer in `@oh-my-pi/pi-natives`: how `native.ts` decides which `.node` file to load, when embedded payload extraction runs, and how startup failures are reported.
## Implementation files
- `packages/natives/src/native.ts`
- `packages/natives/src/embedded-addon.ts`
- `packages/natives/src/bindings.ts`
- `packages/natives/package.json`
## Scope and responsibility
Loader/runtime responsibilities are intentionally narrow:
- Build a platform/CPU-aware candidate list for addon filenames and directories.
- Optionally materialize an embedded addon into a versioned per-user cache directory.
- Attempt candidates in deterministic order.
- Reject stale or incompatible addons via `validateNative` before exposing bindings.
Out of scope here: module-specific grep/text/highlight behavior.
## Runtime inputs and derived state
At module initialization (`export const native = loadNative();`), `native.ts` computes static context:
- **Platform tag**: ``${process.platform}-${process.arch}`` (for example `darwin-arm64`).
- **Package version**: from `packages/natives/package.json` (`version` field).
- **Core directories**:
- `nativeDir`: package-local `packages/natives/native`.
- `execDir`: directory containing `process.execPath`.
- `versionedDir`: `<getNativesDir()>/<packageVersion>`.
- `userDataDir` fallback:
- Windows: `%LOCALAPPDATA%/omp` (or `%USERPROFILE%/AppData/Local/omp`).
- Non-Windows: `~/.local/bin`.
- **Compiled-binary mode** (`isCompiledBinary`): true if any of:
- `PI_COMPILED` env var is set, or
- `import.meta.url` contains Bun-embedded markers (`$bunfs`, `~BUN`, `%7EBUN`).
- **Variant override**: `PI_NATIVE_VARIANT` (`modern`/`baseline` only; invalid values ignored).
- **Selected variant**: explicit override, otherwise runtime AVX2 detection on x64 (`modern` if AVX2, else `baseline`).
## Platform support and tag resolution
`SUPPORTED_PLATFORMS` is fixed to:
- `linux-x64`
- `linux-arm64`
- `darwin-x64`
- `darwin-arm64`
- `win32-x64`
Behavior detail:
- Unsupported platforms are not rejected up-front.
- Loader still tries all computed candidates first.
- If nothing loads, it throws an explicit unsupported-platform error listing supported tags.
This preserves useful diagnostics for near-miss cases while still failing hard for truly unsupported targets.
## Variant selection (`modern` / `baseline` / default)
### x64 behavior
1. If `PI_NATIVE_VARIANT` is `modern` or `baseline`, that value wins.
2. Else detect AVX2 support:
- Linux: scan `/proc/cpuinfo` for `avx2`.
- macOS: query `sysctl` (`machdep.cpu.leaf7_features`, fallback `machdep.cpu.features`).
- Windows: run PowerShell `[System.Runtime.Intrinsics.X86.Avx2]::IsSupported`.
3. Result:
- AVX2 available -> `modern`
- AVX2 unavailable/undetectable -> `baseline`
### Non-x64 behavior
- No variant is used; loader stays on the default filename (`pi_natives.<platform>-<arch>.node`).
### Filename construction
Given `tag = <platform>-<arch>`:
- Non-x64 or no variant: `pi_natives.<tag>.node`
- x64 + `modern`: try in order
1. `pi_natives.<tag>-modern.node`
2. `pi_natives.<tag>-baseline.node` (intentional fallback)
- x64 + `baseline`: only `pi_natives.<tag>-baseline.node`
The `addonLabel` used in final error messages is either `<tag>` or `<tag> (<variant>)`.
## Candidate path construction and fallback ordering
`native.ts` builds candidate pools before any `require(...)` call.
### Debug/dev candidates (only when `PI_DEV` is set)
Prepended first:
1. `<nativeDir>/pi_natives.dev.node`
2. `<execDir>/pi_natives.dev.node`
This path is explicit debug intent and always outranks release candidates.
### Release candidates
Built from variant-resolved filename list and searched in this order:
- **Non-compiled runtime**:
1. `<nativeDir>/<filename>`
2. `<execDir>/<filename>`
- **Compiled runtime** (`PI_COMPILED` or Bun embedded markers):
1. `<versionedDir>/<filename>`
2. `<userDataDir>/<filename>`
3. `<nativeDir>/<filename>`
4. `<execDir>/<filename>`
`dedupedCandidates` removes duplicates while preserving first occurrence order.
### Final runtime sequence
At load time:
1. Optional embedded extraction candidate (if produced) is inserted at the front.
2. Remaining deduplicated candidates are tried in order.
3. First candidate that both `require(...)`s and passes `validateNative(...)` wins.
## Embedded addon extraction lifecycle
`embedded-addon.ts` defines a generated manifest shape:
- `platformTag`
- `version`
- `files[]` where each entry has `variant`, `filename`, `filePath`
Current checked-in default is `embeddedAddon: null`; compiled artifacts may replace this with real metadata.
### Extraction state machine
Extraction (`maybeExtractEmbeddedAddon`) runs only when all gates pass:
1. `isCompiledBinary === true`
2. `embeddedAddon !== null`
3. `embeddedAddon.platformTag === platformTag`
4. `embeddedAddon.version === packageVersion`
5. A variant-appropriate embedded file is found
Variant file selection mirrors runtime variant intent:
- Non-x64: prefer `default`, then first available file.
- x64 + `modern`: prefer `modern`, fallback to `baseline`.
- x64 + `baseline`: require `baseline`.
Materialization behavior:
1. Ensure `<versionedDir>` exists (`mkdirSync(..., { recursive: true })`).
2. If `<versionedDir>/<selected filename>` already exists, reuse it (no rewrite).
3. Else read embedded source `filePath` and write target file.
4. Return target path for highest-priority load attempt.
On failure, extraction does not crash immediately; it appends an error entry (directory creation or write failure) and loader proceeds to normal candidate probing.
## Lifecycle and state transitions
```text
Init
-> Compute platform/version/variant/candidate lists
-> (Compiled + embedded manifest matches?)
yes -> Try extract embedded to versionedDir (record errors, continue)
no -> Skip extraction
-> For each runtime candidate in order:
require(candidate)
-> success: validateNative
-> pass: return bindings (READY)
-> fail: record error, continue
-> failure: record error, continue
-> none loaded:
if unsupported platform tag -> throw Unsupported platform
else -> throw Failed to load (full tried-path diagnostics + hints)
```
## `validateNative` contract checks
`validateNative(bindings, source)` enforces a function-only contract over `NativeBindings` at startup.
Mechanics:
- For each required export name, it checks `typeof bindings[name] === "function"`.
- Missing names are aggregated.
- If any are missing, loader throws:
- source addon path,
- missing export list,
- rebuild command hint.
This is a hard compatibility gate against stale binaries, partial builds, and symbol/name drift.
### JS API ↔ native export mapping (validation gate)
| JS binding name checked in `validateNative` | Expected native export name |
| --- | --- |
| `grep` | `grep` |
| `glob` | `glob` |
| `highlightCode` | `highlightCode` |
| `executeShell` | `executeShell` |
| `PtySession` | `PtySession` |
| `Shell` | `Shell` |
| `visibleWidth` | `visibleWidth` |
| `getSystemInfo` | `getSystemInfo` |
| `getWorkProfile` | `getWorkProfile` |
| `invalidateFsScanCache` | `invalidateFsScanCache` |
Note: `bindings.ts` declares only the base `cancelWork(id)` member; module `types.ts` files declaration-merge additional symbols that `validateNative` enforces.
## Failure behavior and diagnostics
## Unsupported platform
If all candidates fail and `platformTag` is not in `SUPPORTED_PLATFORMS`, loader throws:
- `Unsupported platform: <tag>`
- Full supported-platform list
- Explicit issue-reporting guidance
## Stale binary / mismatch symptoms
Typical stale mismatch signal:
- `Native addon missing exports (<candidate>). Missing: ...`
Common causes:
- Old `.node` binary from previous package version/API shape.
- Wrong variant artifact selected (for x64).
- New Rust export not present in loaded artifact.
Loader behavior:
- Records per-candidate missing-export failures.
- Continues probing remaining candidates.
- If no candidate validates, final error includes every attempted path with each failure message.
## Compiled-binary startup failures
In compiled mode final diagnostics include:
- expected versioned cache target paths (`<versionedDir>/<filename>`),
- remediation to delete stale `<versionedDir>` and rerun,
- direct release download `curl` commands for each expected filename.
## Non-compiled startup failures
In normal package/runtime mode final diagnostics include:
- reinstall hint (`bun install @oh-my-pi/pi-natives`),
- local rebuild command (`bun --cwd=packages/natives run build:native`),
- optional x64 variant build hint (`TARGET_VARIANT=baseline|modern ...`).
## Dev/debug versus release behavior
When `PI_DEV` is set:
- `pi_natives.dev.node` candidates are prepended ahead of all release candidates.
- Loader emits per-candidate console diagnostics (`Loaded native addon...` and load errors).
Without `PI_DEV`:
- Only release candidate chain is used.
- No dev console diagnostics are emitted.
Operationally, this means debug sessions can validate an ad-hoc dev addon first, while production/release runs remain on deterministic release artifact probing.
+168
View File
@@ -0,0 +1,168 @@
# Natives Architecture
`@oh-my-pi/pi-natives` is a three-layer stack:
1. **TypeScript wrapper/API layer** exposes stable JS/TS entrypoints.
2. **Addon loading/validation layer** resolves and validates the `.node` binary for the current runtime.
3. **Rust N-API module layer** implements performance-critical primitives exported to JS.
This document is the foundation for deeper module-level docs.
## Implementation files
- `packages/natives/src/index.ts`
- `packages/natives/src/native.ts`
- `packages/natives/src/bindings.ts`
- `packages/natives/src/embedded-addon.ts`
- `packages/natives/scripts/build-native.ts`
- `packages/natives/scripts/embed-native.ts`
- `packages/natives/package.json`
- `crates/pi-natives/src/lib.rs`
## Layer 1: TypeScript wrapper/API layer
`packages/natives/src/index.ts` is the public barrel. It groups exports by capability domain and re-exports typed wrappers rather than exposing raw N-API bindings directly.
Current top-level groups:
- **Search/text primitives**: `grep`, `glob`, `text`, `highlight`
- **Execution/process/terminal primitives**: `shell`, `pty`, `ps`, `keys`
- **System/media/conversion primitives**: `image`, `html`, `clipboard`, `system-info`, `work`
`packages/natives/src/bindings.ts` defines the base interface contract:
- `NativeBindings` starts with shared members (`cancelWork(id: number)`)
- module-specific bindings are added by declaration merging from each module’s `types.ts`
- `Cancellable` standardizes timeout and abort-signal options for wrappers that expose cancellation
**Guaranteed contract (API-facing):** consumers import from `@oh-my-pi/pi-natives` and use typed wrappers.
**Implementation detail (may change):** declaration merging and internal wrapper layout (`src/<module>/index.ts`, `src/<module>/types.ts`).
## Layer 2: Addon loading and validation
`packages/natives/src/native.ts` owns runtime addon selection, optional extraction, and export validation.
### Candidate resolution model
- Platform tag is `"${process.platform}-${process.arch}"`.
- Supported tags are currently:
- `linux-x64`
- `linux-arm64`
- `darwin-x64`
- `darwin-arm64`
- `win32-x64`
- x64 can use CPU variants:
- `modern` (AVX2-capable)
- `baseline` (fallback)
- Non-x64 uses the default filename (no variant suffix).
Filename strategy:
- Release: `pi_natives.<platform>-<arch>.node`
- x64 variant release: `pi_natives.<platform>-<arch>-modern.node` and/or `...-baseline.node`
- Dev: `pi_natives.dev.node` (preferred when `PI_DEV` is set)
### Platform-specific variant detection
For x64, variant selection uses:
- **Linux**: `/proc/cpuinfo`
- **macOS**: `sysctl machdep.cpu.leaf7_features` / `machdep.cpu.features`
- **Windows**: PowerShell check for `System.Runtime.Intrinsics.X86.Avx2`
`PI_NATIVE_VARIANT` can explicitly force `modern` or `baseline`.
### Binary distribution and extraction model
`packages/natives/package.json` includes both `src` and `native` in published files. The `native/` directory stores prebuilt platform artifacts.
For compiled binaries (`PI_COMPILED` or Bun embedded runtime markers), loader behavior is:
1. Check versioned user cache path: `<getNativesDir()>/<packageVersion>/...`
2. Check legacy compiled-binary location:
- Windows: `%LOCALAPPDATA%/omp` (fallback `%USERPROFILE%/AppData/Local/omp`)
- non-Windows: `~/.local/bin`
3. Fall back to packaged `native/` and executable directory candidates
If an embedded addon manifest is present (`embedded-addon.ts` generated by `scripts/embed-native.ts`), `native.ts` can materialize the matching embedded binary into the versioned cache directory before loading.
### Validation and failure modes
After `require(candidate)`, `validateNative(...)` verifies required exports (for example `grep`, `glob`, `highlightCode`, `PtySession`, `Shell`, `getSystemInfo`, `getWorkProfile`, `invalidateFsScanCache`).
Failure paths are explicit:
- **Unsupported platform tag**: throws with supported platform list
- **No loadable candidate**: throws with all attempted paths and remediation hints
- **Missing exports**: throws with exact missing names and rebuild command
- **Embedded extraction errors**: records directory/write failures and includes them in final load diagnostics
**Guaranteed contract (API-facing):** addon load either succeeds with a validated binding set or fails fast with actionable error text.
**Implementation detail (may change):** exact candidate search order and compiled-binary fallback path ordering.
## Layer 3: Rust N-API module layer
`crates/pi-natives/src/lib.rs` is the Rust entry module that declares exported module ownership:
- `clipboard`
- `fd`
- `fs_cache`
- `glob`
- `glob_util`
- `grep`
- `highlight`
- `html`
- `image`
- `keys`
- `prof`
- `ps`
- `pty`
- `shell`
- `system_info`
- `task`
- `text`
These modules implement the N-API symbols consumed and validated by `native.ts`. JS-level names are surfaced through the TS wrappers in `packages/natives/src`.
**Guaranteed contract (API-facing):** Rust module exports must match the binding names expected by `validateNative` and wrapper modules.
**Implementation detail (may change):** internal Rust module decomposition and helper module boundaries (`glob_util`, `task`, etc.).
## Ownership boundaries
At architecture level, ownership is split as follows:
- **TS wrapper/API ownership (`packages/natives/src`)**
- public API grouping, option typing, and stable JS ergonomics
- cancellation surface (`timeoutMs`, `AbortSignal`) exposed to callers
- **Loader ownership (`packages/natives/src/native.ts`)**
- runtime binary selection
- CPU variant selection and override handling
- compiled-binary extraction and candidate probing
- hard validation of required native exports
- **Rust ownership (`crates/pi-natives/src`)**
- algorithmic and system-level implementation
- platform-native behavior and performance-sensitive logic
- N-API symbol implementation that TS wrappers consume
## Runtime flow (high level)
1. Consumer imports from `@oh-my-pi/pi-natives`.
2. Wrapper module calls into singleton `native` binding.
3. `native.ts` selects candidate binary for platform/arch/variant.
4. Optional embedded binary extraction occurs for compiled distributions.
5. Addon is loaded and export set is validated.
6. Wrapper returns typed results to caller.
## Glossary
- **Native addon**: A `.node` binary loaded via Node-API (N-API).
- **Platform tag**: Runtime tuple `platform-arch` (for example `darwin-arm64`).
- **Variant**: x64 CPU-specific build flavor (`modern` AVX2, `baseline` fallback).
- **Wrapper**: TS function/class that provides typed API over raw native exports.
- **Declaration merging**: TS technique used by module `types.ts` files to extend `NativeBindings`.
- **Compiled binary mode**: Runtime mode where the CLI is bundled and native addons are resolved from extracted/cache paths instead of only package-local paths.
- **Embedded addon**: Build artifact metadata and file references generated into `embedded-addon.ts` so compiled binaries can extract matching `.node` payloads.
- **Validation gate**: `validateNative(...)` check that rejects stale/mismatched binaries missing required exports.
+221
View File
@@ -0,0 +1,221 @@
# Natives Binding Contract (TypeScript Side)
This document defines the TypeScript-side contract that sits between `@oh-my-pi/pi-natives` callers and the loaded N-API addon.
It focuses on three pieces:
1. contract shape (`NativeBindings` + module augmentation),
2. wrapper behavior (`src/<module>/index.ts`),
3. public export surface (`src/index.ts`).
## Implementation files
- `packages/natives/src/bindings.ts`
- `packages/natives/src/native.ts`
- `packages/natives/src/index.ts`
- `packages/natives/src/clipboard/types.ts`
- `packages/natives/src/clipboard/index.ts`
- `packages/natives/src/glob/types.ts`
- `packages/natives/src/glob/index.ts`
- `packages/natives/src/grep/types.ts`
- `packages/natives/src/grep/index.ts`
- `packages/natives/src/highlight/types.ts`
- `packages/natives/src/highlight/index.ts`
- `packages/natives/src/html/types.ts`
- `packages/natives/src/html/index.ts`
- `packages/natives/src/image/types.ts`
- `packages/natives/src/image/index.ts`
- `packages/natives/src/keys/types.ts`
- `packages/natives/src/keys/index.ts`
- `packages/natives/src/ps/types.ts`
- `packages/natives/src/ps/index.ts`
- `packages/natives/src/pty/types.ts`
- `packages/natives/src/pty/index.ts`
- `packages/natives/src/shell/types.ts`
- `packages/natives/src/shell/index.ts`
- `packages/natives/src/system-info/types.ts`
- `packages/natives/src/system-info/index.ts`
- `packages/natives/src/text/types.ts`
- `packages/natives/src/text/index.ts`
- `packages/natives/src/work/types.ts`
- `packages/natives/src/work/index.ts`
## Contract model
`packages/natives/src/bindings.ts` defines the base contract:
- `NativeBindings` (base interface, currently includes `cancelWork(id: number): void`)
- `Cancellable` (`timeoutMs?: number`, `signal?: AbortSignal`)
- `TsFunc<T>` callback shape used by N-API threadsafe callbacks
Each module adds its own fields by declaration merging:
```ts
// packages/natives/src/<module>/types.ts
declare module "../bindings" {
interface NativeBindings {
grep(options: GrepOptions, onMatch?: TsFunc<GrepMatch>): Promise<GrepResult>;
}
}
```
This keeps one aggregate binding interface without a monolithic central type file.
## Declaration-merging lifecycle and state transitions
### 1) Compile-time type assembly
- `bindings.ts` provides the base `NativeBindings` symbol.
- Every `src/<module>/types.ts` augments `NativeBindings`.
- `src/native.ts` imports all `./<module>/types` files for side effects so the merged contract is in scope where `NativeBindings` is used.
State transition: **Base contract** → **Merged contract**.
### 2) Runtime addon load and validation gate
- `src/native.ts` loads candidate `.node` binaries.
- Loaded object is treated as `NativeBindings` and immediately passed through `validateNative(...)`.
- `validateNative` verifies required export keys by `typeof bindings[name] === "function"`.
State transition: **Untrusted addon object** → **Validated native binding object** (or hard failure).
### 3) Wrapper invocation
- Module wrappers in `src/<module>/index.ts` call `native.<export>`.
- Wrappers adapt defaults and callback shape (`(err, value)` to value-only callback patterns in JS APIs).
- `src/index.ts` re-exports module wrappers/types as the public package API.
State transition: **Validated raw bindings** → **Ergonomic public API**.
## Wrapper responsibilities
Wrappers are intentionally thin; they do not re-implement native logic.
Primary responsibilities:
- **Argument normalization/defaulting**
- `glob()` resolves `options.path` to absolute path and defaults `hidden`, `gitignore`, `recursive`.
- `hasMatch()` fills default flags (`ignoreCase`, `multiline`) before native call.
- **Callback adaptation**
- `grep()`, `glob()`, `executeShell()` convert `TsFunc<T>` (`error, value`) into user callback receiving only successful values.
- **Environment or policy behavior around native calls**
- Clipboard wrapper adds OSC52/Termux/headless handling and treats copy as best effort.
- **Public naming and re-export curation**
- `searchContent()` maps to native export `search`.
## Public export surface organization
`packages/natives/src/index.ts` is the canonical public barrel. It groups exports by capability domain:
- Search/text: `grep`, `glob`, `text`, `highlight`
- Execution/process/terminal: `shell`, `pty`, `ps`, `keys`
- System/media/conversion: `image`, `html`, `clipboard`, `system-info`, `work`
Maintainer rule: if a wrapper is not re-exported from `src/index.ts`, it is not part of the intended public package surface.
## JS API ↔ native export mapping (representative)
The Rust side uses N-API export names (typically via `#[napi(js_name = ...)]`) that must match these binding keys.
| Category | Public JS API (wrapper) | Native binding key | Return type | Async? |
|---|---|---|---|---|
| Grep | `grep(options, onMatch?)` | `grep` | `Promise<GrepResult>` | Yes |
| Grep | `searchContent(content, options)` | `search` | `SearchResult` | No |
| Grep | `hasMatch(content, pattern, opts?)` | `hasMatch` | `boolean` | No |
| Grep | `fuzzyFind(options)` | `fuzzyFind` | `Promise<FuzzyFindResult>` | Yes |
| Glob | `glob(options, onMatch?)` | `glob` | `Promise<GlobResult>` | Yes |
| Glob | `invalidateFsScanCache(path?)` | `invalidateFsScanCache` | `void` | No |
| Shell | `executeShell(options, onChunk?)` | `executeShell` | `Promise<ShellExecuteResult>` | Yes |
| Shell | `Shell` | `Shell` | class constructor | N/A |
| PTY | `PtySession` | `PtySession` | class constructor | N/A |
| Text | `truncateToWidth(...)` | `truncateToWidth` | `string` | No |
| Text | `sliceWithWidth(...)` | `sliceWithWidth` | `SliceWithWidthResult` | No |
| Text | `visibleWidth(text)` | `visibleWidth` | `number` | No |
| Highlight | `highlightCode(code, lang, colors)` | `highlightCode` | `string` | No |
| HTML | `htmlToMarkdown(html, options?)` | `htmlToMarkdown` | `Promise<string>` | Yes |
| System | `getSystemInfo()` | `getSystemInfo` | `SystemInfo` | No |
| Work | `getWorkProfile(lastSeconds)` | `getWorkProfile` | `WorkProfile` | No |
| Process | `killTree(pid, signal)` | `killTree` | `number` | No |
| Process | `listDescendants(pid)` | `listDescendants` | `number[]` | No |
| Clipboard | `copyToClipboard(text)` | `copyToClipboard` | `Promise<void>` (best effort wrapper behavior) | Yes |
| Clipboard | `readImageFromClipboard()` | `readImageFromClipboard` | `Promise<ClipboardImage \| null>` | Yes |
| Keys | `parseKey(data, kittyProtocolActive)` | `parseKey` | `string \| null` | No |
## Sync vs async contract differences
The contract mixes sync and async APIs; wrappers preserve native call style rather than forcing one model:
- **Promise-based async exports** for I/O or long-running work (`grep`, `glob`, `htmlToMarkdown`, `executeShell`, clipboard, image operations).
- **Synchronous exports** for deterministic in-memory transforms/parsers (`search`, `hasMatch`, highlighting, text width/slicing, key parsing, process queries).
- **Constructor exports** for stateful runtime objects (`Shell`, `PtySession`, `PhotonImage`).
Implication for maintainers: changing sync ↔ async for an existing export is a breaking API and contract change across wrappers and callers.
## Object and enum typing patterns
### Object patterns (`#[napi(object)]`-style JS objects)
TS models object-shaped native values as interfaces, for example:
- `GrepResult`, `SearchResult`, `GlobResult`
- `SystemInfo`, `WorkProfile`
- `ClipboardImage`, `ParsedKittyResult`
These are structural contracts at compile time; runtime shape correctness is owned by native implementation.
### Enum patterns
Numeric native enums are represented as `const enum` values in TS:
- `FileType` (`1=file`, `2=dir`, `3=symlink`)
- `ImageFormat` (`0=PNG`, `1=JPEG`, `2=WEBP`, `3=GIF`)
- `SamplingFilter`, `Ellipsis`, `KeyEventType`
Callers see named enum members; the binding boundary passes numbers.
## How mismatches are caught
Mismatch detection happens at two layers:
1. **Compile-time TypeScript contract checks**
- Wrappers call `native.<name>` against merged `NativeBindings`.
- Missing/renamed binding keys break TS type-checking in wrappers.
2. **Runtime validation in `validateNative`**
- After load, `native.ts` checks required exports and throws if any are missing.
- Error message includes missing keys and rebuild instruction.
This catches the common stale-binary drift: wrapper/type exists but loaded `.node` lacks the export.
## Failure behavior and caveats
### Load/validation failures (hard failures)
- Addon load failure or unsupported platform throws during module init in `native.ts`.
- Missing required exports throws before wrappers are usable.
Effect: package fails fast rather than deferring failure to first call.
### Wrapper-level behavior differences
- Some wrappers intentionally soften failures (`copyToClipboard` is best effort and swallows native failure).
- Streaming callbacks ignore callback error payloads and only forward successful value events.
### Type-level caveats (runtime stricter than TS)
- TS optional fields do not guarantee semantic validity; native layer can still reject malformed values.
- `const enum` typing does not prevent out-of-range numeric values from untyped callers at runtime.
- `validateNative` checks only presence/function-ness of required exports, not deep argument/return-shape compatibility.
- `bindings.ts` includes `cancelWork(id)` in the base interface, but current runtime validation list does not enforce that key.
## Maintainer checklist for binding changes
When adding/changing an export, update all of:
1. `src/<module>/types.ts` (augmentation + contract types)
2. `src/<module>/index.ts` (wrapper behavior)
3. `src/native.ts` imports for the module types (if new module)
4. `validateNative` required export checks
5. `src/index.ts` public re-exports
Skipping any step creates either compile-time drift or runtime load-time failure.
+237
View File
@@ -0,0 +1,237 @@
# Natives Build, Release, and Debugging Runbook
This runbook describes how the `@oh-my-pi/pi-natives` build pipeline produces `.node` addons, how compiled distributions load them, and how to debug loader/build failures.
It follows the architecture terms from `docs/natives-architecture.md`:
- **build-time artifact production** (`scripts/build-native.ts`)
- **embedded addon manifest generation** (`scripts/embed-native.ts`)
- **runtime addon loading + validation gate** (`src/native.ts`)
## Implementation files
- `packages/natives/scripts/build-native.ts`
- `packages/natives/scripts/embed-native.ts`
- `packages/natives/package.json`
- `packages/natives/src/native.ts`
- `crates/pi-natives/Cargo.toml`
## Build pipeline overview
### 1) Build entrypoints
`packages/natives/package.json` scripts:
- `bun scripts/build-native.ts` (`build:native`) → release build
- `bun scripts/build-native.ts --dev` (`dev:native`) → debug/dev build
- `bun scripts/embed-native.ts` (`embed:native`) → generate `src/embedded-addon.ts` from built files
### 2) Rust artifact build
`build-native.ts` runs Cargo in `crates/pi-natives`:
- base command: `cargo build`
- release mode adds `--release` unless `--dev` is passed
- cross target adds `--target <CROSS_TARGET>`
`crates/pi-natives/Cargo.toml` declares `crate-type = ["cdylib"]`, so Cargo emits a shared library (`.so`/`.dylib`/`.dll`) that is then copied/renamed to a `.node` addon filename.
### 3) Artifact discovery and install
After Cargo completes, `build-native.ts` scans candidate output directories in order:
1. `${CARGO_TARGET_DIR}` (if set)
2. `<repo>/target`
3. `crates/pi-natives/target`
For each root it checks profile directories:
- cross build: `<root>/<crossTarget>/<profile>` then `<root>/<profile>`
- native build: `<root>/<profile>`
Then it looks for one of:
- `libpi_natives.so`
- `libpi_natives.dylib`
- `pi_natives.dll`
- `libpi_natives.dll`
When found, it atomically installs into `packages/natives/native/` with temp-file + rename semantics (Windows fallback handles locked DLL replacement failures explicitly).
## Target/variant model and naming conventions
## Platform tag
Both build and runtime use platform tag:
`<platform>-<arch>` (example: `darwin-arm64`, `linux-x64`)
## Variant model (x64 only)
x64 supports CPU variants:
- `modern` (AVX2-capable path)
- `baseline` (fallback)
Non-x64 uses a single default artifact (no variant suffix).
### Output filenames
Release builds:
- x64: `pi_natives.<platform>-<arch>-modern.node` or `...-baseline.node`
- non-x64: `pi_natives.<platform>-<arch>.node`
Dev build (`--dev`):
- `pi_natives.dev.node`
Runtime loader candidate order in `native.ts`:
- if `PI_DEV` is set: try `pi_natives.dev.node` first
- then release candidates
- compiled mode prepends extracted/cache candidates before package-local files
## Environment flags and build options
## Runtime flags
- `PI_DEV` (loader behavior): prefer dev addon candidates first
- `PI_NATIVE_VARIANT` (loader behavior, x64 only): force `modern` or `baseline` selection at runtime
- `PI_COMPILED` (loader behavior): enable compiled-binary candidate/extraction behavior
## Build-time flags/options
- `--dev` (script arg): build debug profile and emit `pi_natives.dev.node`
- `CROSS_TARGET`: passed to Cargo `--target`
- `TARGET_PLATFORM`: override output platform tag naming
- `TARGET_ARCH`: override output arch naming
- `TARGET_VARIANT` (x64 only): force `modern` or `baseline` for output filename and RUSTFLAGS policy
- `CARGO_TARGET_DIR`: additional root when searching Cargo outputs
- `RUSTFLAGS`:
- if unset and not cross-compiling, script sets:
- modern: `-C target-cpu=x86-64-v3`
- baseline: `-C target-cpu=x86-64-v2`
- non-x64 / no variant: `-C target-cpu=native`
- if already set, script does not override
## Build state/lifecycle transitions
### Build lifecycle (`build-native.ts`)
1. **Init**: parse args/env (`--dev`, target overrides, cross flags)
2. **Variant resolve**:
- non-x64 → no variant
- x64 + `TARGET_VARIANT` → explicit variant
- x64 cross-build without `TARGET_VARIANT` → hard error
- x64 local build without override → detect host AVX2
3. **Compile**: run Cargo with resolved profile/target
4. **Locate artifact**: scan target roots/profile dirs/library names
5. **Install**: copy + atomic rename into `packages/natives/native`
6. **Complete**: output addon ready for loader candidates
Failure exits happen at any stage with explicit error text (invalid variant, failed cargo build, missing output library, install/rename failure).
### Embed lifecycle (`embed-native.ts`)
1. **Init**: compute platform tag from `TARGET_PLATFORM`/`TARGET_ARCH` or host values
2. **Candidate set**:
- x64 expects both `modern` and `baseline`
- non-x64 expects one default file
3. **Validate availability** in `packages/natives/native`
4. **Generate manifest** (`src/embedded-addon.ts`) with Bun `file` imports and package version
5. **Runtime extraction ready** for compiled mode
`--reset` bypasses validation and writes a null manifest stub (`embeddedAddon = null`).
## Dev workflow vs shipped/compiled behavior
## Local development workflow
Typical local loop:
1. Build addon:
- release: `bun --cwd=packages/natives run build:native`
- debug: `bun --cwd=packages/natives run dev:native`
2. Set `PI_DEV=1` when testing debug addon loading
3. Loader in `native.ts` resolves package-local `native/` (and executable-dir fallback) candidates
4. `validateNative` enforces export compatibility before wrappers use the binding
## Shipped/compiled binary workflow
In compiled mode (`PI_COMPILED` or Bun embedded markers):
1. Loader computes versioned cache dir: `<getNativesDir()>/<packageVersion>` (operationally `~/.omp/natives/<version>`)
2. If embedded manifest matches current platform+version, loader may extract selected embedded file into that versioned dir
3. Runtime candidate order includes:
- versioned cache dir
- legacy compiled-binary dir (`%LOCALAPPDATA%/omp` on Windows, `~/.local/bin` elsewhere)
- package/executable directories
4. First successfully loaded addon still must pass `validateNative`
This is why packaging + runtime loader expectations must align: filenames, platform tags, and exported symbols must match what `native.ts` probes and validates.
## JS API ↔ Rust export mapping (validation gate subset)
`native.ts` requires these JS-visible exports to exist on the loaded addon. They map to Rust N-API exports in `crates/pi-natives/src`:
| JS name required by `validateNative` | Rust export declaration | Rust source file |
| --- | --- | --- |
| `glob` | `#[napi(js_name = "glob")] pub fn glob(...)` | `crates/pi-natives/src/glob.rs` |
| `grep` | `#[napi(js_name = "grep")] pub fn grep(...)` | `crates/pi-natives/src/grep.rs` |
| `search` | `#[napi(js_name = "search")] pub fn search(...)` | `crates/pi-natives/src/grep.rs` |
| `highlightCode` | `#[napi(js_name = "highlightCode")] pub fn highlight_code(...)` | `crates/pi-natives/src/highlight.rs` |
| `getSystemInfo` | `#[napi(js_name = "getSystemInfo")] pub fn get_system_info(...)` | `crates/pi-natives/src/system_info.rs` |
| `getWorkProfile` | `#[napi] pub fn get_work_profile(...)` (camel-cased export) | `crates/pi-natives/src/prof.rs` |
| `invalidateFsScanCache` | `#[napi(js_name = "invalidateFsScanCache")] pub fn invalidate_fs_scan_cache(...)` | `crates/pi-natives/src/fs_cache.rs` |
If any required symbol is missing, loader fails fast with a rebuild hint.
## Failure behavior and diagnostics
## Build-time failures
- Invalid variant configuration:
- `TARGET_VARIANT` set on non-x64 → immediate error
- x64 cross-build without explicit `TARGET_VARIANT` → immediate error
- Cargo build failure:
- script surfaces non-zero exit and stderr
- Artifact not found:
- script prints every checked profile directory
- Install failure:
- explicit message; Windows includes locked-file hint
## Runtime loader failures (`native.ts`)
- Unsupported platform tag:
- throws with supported platform list
- No candidate could load:
- throws with full candidate error list and mode-specific remediation hints
- Missing exports:
- throws with exact missing symbol names and rebuild command
- Embedded extraction problems:
- extraction mkdir/write errors recorded and included in final diagnostics
## Troubleshooting matrix
| Symptom | Likely cause | Verify | Fix |
| --- | --- | --- | --- |
| `Native addon missing exports ... Missing: <name>` | Stale `.node` binary, Rust export name mismatch, or wrong binary loaded | Run with `PI_DEV=1` to see loaded path; inspect export list for that file | Rebuild `build:native`; ensure Rust `#[napi(js_name=...)]` matches JS name; remove stale cached/versioned files |
| x64 machine loads baseline when modern expected | `PI_NATIVE_VARIANT=baseline`, no AVX2 detected, or only baseline file present | Check `PI_NATIVE_VARIANT`; inspect `native/` for `-modern` file | Build modern variant (`TARGET_VARIANT=modern ... build:native`) and ensure file is shipped |
| Cross-build produces unusable/wrong-labeled binary | Mismatch between `CROSS_TARGET` and `TARGET_PLATFORM`/`TARGET_ARCH`, or missing `TARGET_VARIANT` for x64 | Confirm env tuple and output filename | Re-run with consistent env values and explicit x64 `TARGET_VARIANT` |
| Compiled binary fails after upgrade | Stale extracted cache (`~/.omp/natives/<old-or-mismatched-version>`) or embedded manifest mismatch | Inspect versioned natives dir and loader error list | Delete versioned natives cache for the package version and rerun; regenerate embedded manifest during packaging |
| Loader probes many paths and none work | Platform mismatch or missing release artifact in package `native/` | Check `platformTag` vs actual filename(s) | Ensure built filename exactly matches `pi_natives.<platform>-<arch>(-variant).node` convention and package includes `native/` |
| `embed:native` fails with "Incomplete native addons" | Required variant files not built before embedding | Check expected vs found list in error text | Build required files first (x64: both modern+baseline; non-x64: default), then rerun `embed:native` |
## Operational commands
```bash
# Release artifact for current host
bun --cwd=packages/natives run build:native
# Debug artifact (load first when PI_DEV=1)
bun --cwd=packages/natives run dev:native
# Build explicit x64 variants
TARGET_VARIANT=modern bun --cwd=packages/natives run build:native
TARGET_VARIANT=baseline bun --cwd=packages/natives run build:native
# Generate embedded addon manifest from built native files
bun --cwd=packages/natives run embed:native
# Reset embedded manifest to null stub
bun --cwd=packages/natives run embed:native -- --reset
```
+202
View File
@@ -0,0 +1,202 @@
# Natives media + system utilities
This document is a subsystem deep-dive for the **system/media/conversion primitives** layer described in [`docs/natives-architecture.md`](./natives-architecture.md): `image`, `html`, `clipboard`, `system-info`, and `work` profiling.
## Implementation files
- `crates/pi-natives/src/image.rs`
- `crates/pi-natives/src/html.rs`
- `crates/pi-natives/src/clipboard.rs`
- `crates/pi-natives/src/system_info.rs`
- `crates/pi-natives/src/prof.rs`
- `crates/pi-natives/src/task.rs`
- `packages/natives/src/image/index.ts`
- `packages/natives/src/image/types.ts`
- `packages/natives/src/html/index.ts`
- `packages/natives/src/html/types.ts`
- `packages/natives/src/clipboard/index.ts`
- `packages/natives/src/clipboard/types.ts`
- `packages/natives/src/system-info/index.ts`
- `packages/natives/src/system-info/types.ts`
- `packages/natives/src/work/index.ts`
- `packages/natives/src/work/types.ts`
> Note: there is no `crates/pi-natives/src/work.rs`; work profiling is implemented in `prof.rs` and fed by instrumentation in `task.rs`.
## TS API ↔ Rust export/module mapping
| TS export (packages/natives) | Rust N-API export | Rust module |
| --- | --- | --- |
| `PhotonImage.parse(bytes)` | `PhotonImage::parse` (`js_name = "parse"`) | `image.rs` |
| `PhotonImage#resize(width, height, filter)` | `PhotonImage::resize` (`js_name = "resize"`) | `image.rs` |
| `PhotonImage#encode(format, quality)` | `PhotonImage::encode` (`js_name = "encode"`) | `image.rs` |
| `htmlToMarkdown(html, options)` | `html_to_markdown` (`js_name = "htmlToMarkdown"`) | `html.rs` |
| `copyToClipboard(text)` | `copy_to_clipboard` (`js_name = "copyToClipboard"`) + TS fallback logic | `clipboard.rs` + `clipboard/index.ts` |
| `readImageFromClipboard()` | `read_image_from_clipboard` (`js_name = "readImageFromClipboard"`) | `clipboard.rs` |
| `getSystemInfo()` | `get_system_info` (`js_name = "getSystemInfo"`) | `system_info.rs` |
| `getWorkProfile(lastSeconds)` | `get_work_profile` | `prof.rs` |
## Data format boundaries and conversions
### Image (`image`)
- **JS input boundary**: `Uint8Array` encoded image bytes.
- **Rust decode boundary**: bytes are copied to `Vec<u8>`, format is guessed with `ImageReader::with_guessed_format()`, then decoded to `DynamicImage`.
- **In-memory state**: `PhotonImage` stores `Arc<DynamicImage>`.
- **Output boundary**: `encode(format, quality)` returns `Promise<Uint8Array>` (Rust `Vec<u8>`).
Format IDs are numeric:
- `0`: PNG
- `1`: JPEG
- `2`: WebP (lossless encoder)
- `3`: GIF
Constraints:
- `quality` is only used for JPEG.
- PNG/WebP/GIF ignore `quality`.
- Unsupported format IDs fail (`Invalid image format: <id>`).
### HTML conversion (`html`)
- **JS input boundary**: HTML `string` + optional object `{ cleanContent?: boolean; skipImages?: boolean }`.
- **Rust conversion boundary**: `String` input is converted by `html_to_markdown_rs::convert`.
- **Output boundary**: Markdown `string`.
Conversion behavior:
- `cleanContent` defaults to `false`.
- When `cleanContent=true`, preprocessing is enabled with `PreprocessingPreset::Aggressive` and hard-removal flags for navigation/forms.
- `skipImages` defaults to `false`.
### Clipboard (`clipboard`)
- **Text path**:
- TS first emits OSC 52 (`\x1b]52;c;<base64>\x07`) when stdout is a TTY.
- Same text is then attempted via native clipboard API (`native.copyToClipboard`) as best-effort.
- On Termux, TS attempts `termux-clipboard-set` first.
- **Image read path**:
- Rust reads raw image from `arboard`.
- Rust re-encodes it to PNG bytes (`image` crate), returns `{ data: Uint8Array, mimeType: "image/png" }`.
- TS returns `null` early on Termux or Linux sessions without display server (`DISPLAY`/`WAYLAND_DISPLAY` missing).
### System info (`system-info`)
- **Output boundary**: plain object returned synchronously.
- Rust currently populates: `distro`, `kernel`, `cpu`, `disk`.
- Linux distro comes from `/etc/os-release` parsing; macOS may append a marketing name (`Tahoe`, `Sequoia`, etc.) to OS version text.
- Disk summary is normalized to human-readable strings (`used/total (pct%)`), with platform-dependent selection:
- Windows: aggregates each mount entry.
- non-Windows: prefers `/`, falls back to first disk.
### Work profiling (`work`)
- **Collection boundary**: profiling samples are produced by `profile_region(tag)` guards in `task::blocking` and `task::future`.
- **Storage format**: fixed-size circular buffer (`MAX_SAMPLES = 10_000`) storing stack path + duration (`μs`) + timestamp (`μs since process start`).
- **Output boundary**: `getWorkProfile(lastSeconds)` returns object:
- `folded`: folded-stack text (flamegraph input)
- `summary`: markdown table summary
- `svg`: optional flamegraph SVG
- `totalMs`, `sampleCount`
## Lifecycle and state transitions
### Image lifecycle
1. `PhotonImage.parse(bytes)` schedules a blocking decode task (`image.decode`).
2. On success, a native `PhotonImage` handle exists in JS.
3. `resize(...)` creates a new native handle (`image.resize`), old and new handles can coexist.
4. `encode(...)` materializes bytes (`image.encode`) without mutating image dimensions.
Failure transitions:
- Format detection/decode failure rejects parse promise.
- Encode failure rejects encode promise.
- Invalid format ID rejects encode promise.
### HTML lifecycle
1. `htmlToMarkdown(html, options)` schedules a blocking conversion task.
2. Conversion runs with defaulted options (`cleanContent=false`, `skipImages=false`) unless specified.
3. Returns markdown string or rejects.
Failure transitions:
- Converter failure returns rejected promise (`Conversion error: ...`).
### Clipboard lifecycle
`copyToClipboard(text)` is intentionally best-effort and multi-path:
1. If TTY: attempt OSC 52 write (base64 payload).
2. Try Termux command when `TERMUX_VERSION` is set.
3. Try native `arboard` text copy.
4. Swallow errors at TS layer.
`readImageFromClipboard()` strictness differs by stage:
1. TS hard-gates unsupported runtime contexts (Termux/headless Linux) to `null`.
2. Rust `arboard` read runs only when TS allows it.
3. `ContentNotAvailable` maps to `null`.
4. Other Rust errors reject.
### System info lifecycle
1. `getSystemInfo()` refreshes `sysinfo::System` and disk list synchronously.
2. Per-platform helpers derive distro/kernel/cpu/disk snapshots.
3. Object is returned directly; no async task scheduling.
Failure transitions:
- Missing optional data degrades to omitted fields (`Option::None`), not thrown errors.
- `/etc/os-release` parse failures are soft-fail (`None` distro).
- Disk total space `0` is treated as unavailable (`None` disk).
### Work profiling lifecycle
1. No explicit start: profiling is always on when task helpers execute.
2. Every instrumented task scope records one sample on guard drop.
3. Samples overwrite oldest entries after buffer capacity is reached.
4. `getWorkProfile(lastSeconds)` reads a time window and derives folded/summary/svg artifacts.
Failure transitions:
- SVG generation failure is soft-fail (`svg: null`), while folded and summary still return.
- Empty sample window returns empty folded data and `svg: null`, not an error.
## Unsupported operations and error propagation
### Image
- Unsupported decode input or corrupted bytes: strict failure (promise rejection).
- Unsupported encode format ID: strict failure.
- No best-effort fallback path in TS wrapper.
### HTML
- Conversion errors are strict failures (rejection).
- Option omission is best-effort defaulting, not failure.
### Clipboard
- Text copy is best-effort at TS layer: operational failures are suppressed.
- Image read distinguishes "no image" (`null`) from operational failure (rejection).
- Termux/headless Linux are treated as unsupported contexts for image read (`null`).
### System info
- Designed for partial success: fields are optional and may be absent by platform.
- Current TS type is broader than current Rust-populated fields; maintainers should expect sparse payloads unless Rust expands output.
### Work profiling
- Retrieval is strict for function call itself, but artifact generation is partially best-effort (`svg` nullable).
- Buffer truncation is expected behavior (ring buffer), not data loss bug.
## Platform caveats
- **Clipboard text**: OSC 52 depends on terminal support; native clipboard access depends on desktop environment/session.
- **Clipboard image read**: blocked in TS for Termux and Linux without display server.
- **System info distro**: Linux distro name quality depends on `/etc/os-release` fields; macOS marketing name mapping is version-table-based and may lag new releases.
- **Disk reporting**: Windows returns a comma-separated multi-volume summary; non-Windows returns one primary mount summary.
+208
View File
@@ -0,0 +1,208 @@
# Native Rust task execution and cancellation (`pi-natives`)
This document describes how `crates/pi-natives` schedules native work and how cancellation flows from JS options (`timeoutMs`, `AbortSignal`) to Rust execution.
## Implementation files
- `crates/pi-natives/src/task.rs`
- `crates/pi-natives/src/grep.rs`
- `crates/pi-natives/src/glob.rs`
- `crates/pi-natives/src/fd.rs`
- `crates/pi-natives/src/shell.rs`
- `crates/pi-natives/src/pty.rs`
- `crates/pi-natives/src/html.rs`
- `crates/pi-natives/src/image.rs`
- `crates/pi-natives/src/clipboard.rs`
- `crates/pi-natives/src/text.rs`
- `crates/pi-natives/src/ps.rs`
## Core primitives (`task.rs`)
`task.rs` defines three core pieces:
1. `task::blocking(tag, cancel_token, work)`
- Wraps `napi::AsyncTask` / `Task`.
- `compute()` runs on libuv worker threads (for CPU-bound or blocking/sync system calls).
- Returns a JS `Promise<T>`.
2. `task::future(env, tag, work)`
- Wraps `env.spawn_future(...)`.
- Runs async work on Tokio runtime.
- Returns `PromiseRaw<'env, T>`.
3. `CancelToken` / `AbortToken` / `AbortReason`
- `CancelToken::new(timeout_ms, signal)` combines deadline + optional `AbortSignal`.
- `CancelToken::heartbeat()` is cooperative cancellation for blocking loops.
- `CancelToken::wait()` is async cancellation wait (`Signal` / `Timeout` / `User` Ctrl-C).
- `AbortToken` lets external code request abort (`abort(reason)`).
## `blocking` vs `future`: execution model and selection
### Use `task::blocking`
Use when work is CPU-heavy or fundamentally synchronous/blocking:
- regex/file scanning (`grep`, `glob`, `fuzzy_find`)
- synchronous PTY loop internals (`run_pty_sync` via `spawn_blocking`)
- clipboard/image/html conversions
Behavior:
- Work closure receives a cloned `CancelToken`.
- Cancellation is only observed where code checks `ct.heartbeat()?`.
- Closure `Err(...)` rejects JS promise.
### Use `task::future`
Use when work must `await` async operations:
- shell session orchestration (`shell.run`, `executeShell`)
- task racing (`tokio::select!`) between completion and cancellation
Behavior:
- Future can race normal completion against `ct.wait()`.
- On cancel path, async implementations typically propagate cancellation to inner subsystems (e.g., `tokio_util::CancellationToken`) and optionally force abort on grace timeout.
## JS API ↔ Rust export mapping (task/cancel relevant)
| JS-facing API | Rust export (`#[napi]`) | Scheduler | Cancellation hookup |
|---|---|---|---|
| `grep(options, onMatch?)` | `grep` | `task::blocking("grep", ct, ...)` | `CancelToken::new(options.timeoutMs, options.signal)` + `ct.heartbeat()` |
| `glob(options, onMatch?)` | `glob` | `task::blocking("glob", ct, ...)` | `CancelToken::new(...)` + `ct.heartbeat()` in filter loop |
| `fuzzyFind(options)` | `fuzzy_find` (`js_name = "fuzzyFind"`) | `task::blocking("fuzzy_find", ct, ...)` | `CancelToken::new(...)` + `ct.heartbeat()` in scoring loop |
| `shell.run(options, onChunk?)` | `Shell::run` | `task::future(env, "shell.run", ...)` | `ct.wait()` raced against run task; bridges to Tokio `CancellationToken` |
| `executeShell(options, onChunk?)` | `execute_shell` (`js_name = "executeShell"`) | `task::future(env, "shell.execute", ...)` | same as above |
| `pty.start(options, onChunk?)` | `PtySession::start` | `task::future(env, "pty.start", ...)` + inner `spawn_blocking` | `CancelToken` checked in sync PTY loop via `heartbeat()` |
| `htmlToMarkdown(html, options?)` | `html_to_markdown` (`js_name = "htmlToMarkdown"`) | `task::blocking("html_to_markdown", (), ...)` | none (`()` token) |
| `PhotonImage.parse/encode/resize` | `PhotonImage::{parse,encode,resize}` | `task::blocking(...)` | none (`()` token) |
| `copyToClipboard/readImageFromClipboard` | `copy_to_clipboard` / `read_image_from_clipboard` | `task::blocking(...)` | none (`()` token) |
`text.rs` and `ps.rs` currently do not use `task::blocking`/`task::future` and therefore do not participate in this cancellation path.
## Cancellation lifecycle and state transitions
### `CancelToken` lifecycle
`CancelToken` is cooperative and stateful:
```text
Created
├─ no signal + no timeout -> passive token (never aborts unless externally emplaced)
├─ signal registered -> waits for AbortSignal callback
└─ deadline set -> timeout check becomes active
Running
├─ heartbeat()/wait() sees signal -> AbortReason::Signal
├─ heartbeat()/wait() sees deadline -> AbortReason::Timeout
├─ wait() sees Ctrl-C -> AbortReason::User
└─ no abort -> continue
Aborted (terminal)
└─ first abort reason wins (atomic flag + notifier)
```
### Before-start vs mid-execution cancellation
- **Before start / before first cancellation check**:
- `task::future` users that race on `ct.wait()` can resolve cancel immediately once they enter `select!`.
- `task::blocking` users only observe cancellation when closure code reaches `heartbeat()`. If closure does not heartbeat early, cancellation is delayed.
- **Mid-execution**:
- `blocking`: next `heartbeat()` returns `Err("Aborted: ...")`.
- `future`: `ct.wait()` branch wins `select!`, then code cancels subordinate async machinery (for shell: cancels Tokio token, waits up to 2s, then aborts task).
## Heartbeat expectations for long-running loops
`heartbeat()` must run at predictable cadence in loops with unbounded or large work sets.
Observed patterns:
- `glob::filter_entries`: check each entry before filtering/matching.
- `fd::score_entries`: check each scanned candidate.
- `grep_sync`: explicit cancellation check before heavy search phase, plus fs-cache calls that also receive token.
- `run_pty_sync`: check every loop tick (~16ms sleep cadence) and kill child on cancellation.
Practical rule: no loop over external-size input should exceed a short bounded interval without a heartbeat.
## Failure behavior and error propagation to JS
### Blocking tasks
Error path:
1. Closure returns `Err(napi::Error)` (including `heartbeat()` abort).
2. `Task::compute()` returns `Err`.
3. `AsyncTask` rejects JS promise.
Typical error strings:
- `Aborted: Timeout`
- `Aborted: Signal`
- domain errors (`Failed to decode image: ...`, `Conversion error: ...`, etc.)
### Future tasks
Error path:
1. Async body returns `Err(napi::Error)` or join failure is mapped (`... task failed: {err}`).
2. `task::future`-spawned promise rejects.
3. Some APIs intentionally return structured cancellation results instead of rejection (`ShellRunResult`/`ShellExecuteResult` with `cancelled`/`timed_out` flags and `exit_code: None`).
### Cancellation reporting split
- **Abort as error**: most blocking exports using `heartbeat()?`.
- **Abort as typed result**: shell/pty style command APIs that model cancellation in result structs.
Choose one model per API and document it explicitly.
## Common pitfalls
1. **Missing heartbeat in blocking loops**
- Symptom: timeout/signal appears ignored until loop ends.
- Fix: add `ct.heartbeat()?` at loop top and before expensive per-item steps.
2. **Long uncancelable sections**
- Symptom: cancellation latency spikes during single large call (decode, sort, compression, etc.).
- Fix: split work into chunks with heartbeat boundaries; if impossible, document latency.
3. **Blocking async executor**
- Symptom: async API stalls when sync-heavy code runs directly in future.
- Fix: move CPU/sync blocks to `task::blocking` or `tokio::task::spawn_blocking`.
4. **Inconsistent cancel semantics**
- Symptom: one API rejects on cancel, another resolves with flags, confusing callers.
- Fix: standardize per domain and keep wrapper docs aligned.
5. **Forgetting cancellation bridge in nested async tasks**
- Symptom: outer token is cancelled but inner readers/subprocess tasks keep running.
- Fix: bridge cancellation to inner token/signal and enforce grace timeout + forced abort fallback.
## Checklist for new cancellable exports
1. Classify work correctly:
- CPU-bound or sync blocking -> `task::blocking`
- async I/O / `await` orchestration -> `task::future`
2. Expose cancel inputs when needed:
- include `timeoutMs` and `signal` in `#[napi(object)]` options
- create `let ct = task::CancelToken::new(timeout_ms, signal);`
3. Wire cancellation through all layers:
- blocking loops: `ct.heartbeat()?` at stable intervals
- async orchestration: race with `ct.wait()` and cancel sub-tasks/tokens
4. Decide cancellation contract:
- reject promise with abort error, or
- resolve typed `{ cancelled, timedOut, ... }`
- keep this contract consistent for the API family
5. Propagate failures with context:
- map errors via `Error::from_reason(format!("...: {err}"))`
- include stage-specific prefixes (`spawn`, `decode`, `wait`, etc.)
6. Handle before-start and mid-flight cancellation:
- cancellation check/await must happen before expensive body and during long execution
7. Validate no executor misuse:
- no long sync work directly inside async futures without `spawn_blocking`/blocking task wrapper
+268
View File
@@ -0,0 +1,268 @@
# Natives Shell, PTY, Process, and Key Internals
This document covers the **execution/process/terminal primitives** in `@oh-my-pi/pi-natives`: `shell`, `pty`, `ps`, and `keys`, using the architecture terms from `docs/natives-architecture.md`.
## Implementation files
- `crates/pi-natives/src/shell.rs`
- `crates/pi-natives/src/shell/windows.rs` (Windows only)
- `crates/pi-natives/src/pty.rs`
- `crates/pi-natives/src/ps.rs`
- `crates/pi-natives/src/keys.rs`
- `crates/pi-natives/src/task.rs` (shared cancellation behavior used by shell/pty)
- `packages/natives/src/shell/index.ts`
- `packages/natives/src/shell/types.ts`
- `packages/natives/src/pty/index.ts`
- `packages/natives/src/pty/types.ts`
- `packages/natives/src/ps/index.ts`
- `packages/natives/src/ps/types.ts`
- `packages/natives/src/keys/index.ts`
- `packages/natives/src/keys/types.ts`
- `packages/natives/src/bindings.ts`
## Layer ownership
- **TS wrapper/API layer** (`packages/natives/src/*`): typed entrypoints, cancellation surface (`timeoutMs`, `AbortSignal`), and JS ergonomics.
- **Rust N-API module layer** (`crates/pi-natives/src/*`): shell/PTY process execution, process-tree traversal/termination, and key-sequence parsing.
- **Validation gate** (`native.ts`, architecture-level): ensures required exports (`Shell`, `executeShell`, `PtySession`, `killTree`, `listDescendants`, key helpers) exist before wrappers are used.
## Shell subsystem (`shell`)
### API model
Two execution modes are exposed:
1. **One-shot** via `executeShell(options, onChunk?)`.
2. **Persistent session** via `new Shell(options?)` then `shell.run(...)` repeatedly.
Both stream output through a threadsafe callback and return `{ exitCode?, cancelled, timedOut }`.
### Session creation and environment model
Rust creates `brush_core::Shell` with:
- non-interactive mode,
- `do_not_inherit_env: true`,
- explicit environment reconstruction from host env,
- skip-list for shell-sensitive vars (`PS1`, `PWD`, `SHLVL`, bash function exports, etc.).
Session env behavior:
- `ShellOptions.sessionEnv` is applied once at session creation.
- `ShellRunOptions.env` is command-scoped (`EnvironmentScope::Command`) and popped after each run.
- `PATH` is merged specially on Windows with case-insensitive dedupe.
Windows-only path enrichment (`shell/windows.rs`): discovered Git-for-Windows paths (`cmd`, `bin`, `usr/bin`) are appended if present and not already included.
### Runtime lifecycle and state transitions
Persistent shell (`Shell.run`) uses this state machine:
- **Idle/Uninitialized**: `session: None`.
- **Running**: first `run()` lazily creates session, stores `current_abort` token, executes command.
- **Completed + keepalive**: if execution control flow is `Normal`, `current_abort` is cleared and session is reused.
- **Completed + teardown**: if control flow is loop/script/shell-exit related (`BreakLoop`, `ContinueLoop`, `ReturnFromFunctionOrScript`, `ExitShell`), session is dropped (`session: None`).
- **Cancelled/Timed out**: run task is cancelled, grace wait (2s), then force-abort; session is dropped.
- **Error**: session is dropped.
One-shot shell (`executeShell`) always creates and drops a fresh session per call.
### Streaming/output behavior
- Stdout/stderr are routed into a shared pipe and read concurrently.
- Reader decodes UTF-8 incrementally; invalid byte sequences emit `U+FFFD` replacement chunks.
- After process completion, output drain has idle/max guards (`250ms` idle, `2s` max) to avoid hanging on background jobs keeping descriptors open.
### Cancellation, timeout, and background jobs
- `CancelToken` is constructed from `timeoutMs` and optional `AbortSignal`.
- On cancellation/timeout, shell cancellation token is triggered, then task gets a 2s graceful window before forced abort.
- If cancellation occurs, background jobs are terminated (`TERM`, then delayed `KILL`) using brush job metadata.
`Shell.abort()` behavior:
- aborts only current running command for that `Shell` instance,
- no-op success when nothing is running.
### Failure behavior
Common surfaced errors include:
- session init failures (`Failed to initialize shell`),
- cwd errors (`Failed to set cwd`),
- env set/pop failures,
- snapshot source failures,
- pipe creation/clone failures,
- execution failure (`Shell execution failed: ...`),
- task wrapper failures (`Shell execution task failed: ...`).
Result-level cancellation flags:
- timeout -> `exitCode: undefined`, `timedOut: true`.
- abort signal -> `exitCode: undefined`, `cancelled: true`.
## PTY subsystem (`pty`)
### API model
`new PtySession()` exposes:
- `start(options, onChunk?) -> Promise<{ exitCode?, cancelled, timedOut }>`
- `write(data)`
- `resize(cols, rows)`
- `kill()`
### Runtime lifecycle and state transitions
`PtySession` state machine:
- **Idle**: `core: None`.
- **Reserved**: `start()` installs control channel synchronously (`core: Some`) before async work begins, so `write/resize/kill` become immediately valid.
- **Running**: blocking PTY loop handles child state, reader events, cancellation heartbeat, and control messages.
- **Terminal closed**: child exit + reader completion.
- **Finalized**: `core` is always reset to `None` after start task completion (success or error).
Concurrency guard:
- starting while already running returns `PTY session already running`.
### Spawn/attach/write/read/terminate patterns
- PTY opened via `portable_pty::native_pty_system().openpty(...)`.
- Command currently runs as `sh -lc <command>` with optional `cwd` and env overrides.
- `write()` sends raw bytes to PTY stdin.
- `resize()` clamps dimensions (`cols 20..400`, `rows 5..200`) and calls master resize.
- `kill()` marks run as cancelled and kills child process.
Output path:
- dedicated reader thread reads master stream,
- incremental UTF-8 decode with `U+FFFD` replacement on invalid bytes,
- chunks forwarded through N-API threadsafe callback.
### Cancellation and timeout semantics
- `timeoutMs` and `AbortSignal` feed a `CancelToken`.
- loop calls `ct.heartbeat()` periodically; abort triggers child kill.
- timeout classification is string-based (`"Timeout"` substring in heartbeat error).
### Failure behavior
Error surfaces include:
- PTY allocation/open failure,
- PTY spawn failure,
- writer/reader acquisition failure,
- child status/wait failures,
- lock poisoning,
- control-channel disconnection (`PTY session is no longer available`).
Control call failures when not running:
- `write/resize/kill` return `PTY session is not running`.
## Process-tree subsystem (`ps`)
### API model
- `killTree(pid, signal) -> number`
- `listDescendants(pid) -> number[]`
TS wrapper also registers native kill-tree integration into shared utils via `setNativeKillTree(native.killTree)`.
### Platform-specific implementation
- **Linux**: recursively reads `/proc/<pid>/task/<pid>/children`.
- **macOS**: uses `libproc` `proc_listchildpids`.
- **Windows**: snapshots process table with `CreateToolhelp32Snapshot`, builds parent->children map, terminates with `OpenProcess(PROCESS_TERMINATE)` + `TerminateProcess`.
### Kill-tree behavior
- Descendants are collected recursively.
- Kill order is bottom-up (deepest descendants first) to reduce orphan re-parenting.
- Root pid is killed last.
- Return value is count of successful terminations.
Signal behavior:
- POSIX: provided `signal` is passed to `kill`.
- Windows: `signal` is ignored; termination is unconditional process terminate.
### Failure behavior
This module is intentionally non-throwing at API surface:
- missing/inaccessible process tree branches are skipped,
- per-pid kill failures are counted as unsuccessful (not errors),
- lookup miss typically yields `[]` from `listDescendants` and `0` from `killTree`.
## Key parsing subsystem (`keys`)
### API model
Exposed helpers:
- `parseKey(data, kittyProtocolActive)`
- `matchesKey(data, keyId, kittyProtocolActive)`
- `parseKittySequence(data)`
- `matchesKittySequence(data, expectedCodepoint, expectedModifier)`
- `matchesLegacySequence(data, keyName)`
### Parsing model
The parser combines:
- direct single-byte mappings (`enter`, `tab`, `ctrl+<letter>`, printable ASCII),
- O(1) legacy escape-sequence lookup (PHF map),
- xterm `modifyOtherKeys` parsing,
- Kitty protocol parsing (`CSI u`, `CSI ~`, `CSI 1;...<letter>`),
- normalization to key IDs (`ctrl+c`, `shift+tab`, `pageUp`, `f5`, etc.).
Modifier handling:
- only shift/alt/ctrl bits are compared for key matching,
- lock bits are masked out before comparisons.
Layout behavior:
- base-layout fallback is intentionally constrained so remapped layouts do not create false matches for ASCII letters/symbols.
### Failure behavior
- Unrecognized or invalid sequences produce `null` from parse functions.
- Match functions return `false` on parse failure or mismatch.
- No thrown error surface for malformed key input.
## JS wrapper API ↔ Rust export mapping
### Shell + PTY + Process
| TS wrapper API | Rust N-API export | Notes |
|---|---|---|
| `executeShell(options, onChunk?)` | `executeShell` (`execute_shell`) | One-shot shell execution |
| `new Shell(options?)` | `Shell` class | Persistent shell session |
| `shell.run(options, onChunk?)` | `Shell::run` | Reuses session on keepalive control flow |
| `shell.abort()` | `Shell::abort` | Aborts active run for that shell instance |
| `new PtySession()` | `PtySession` class | Stateful PTY session |
| `pty.start(options, onChunk?)` | `PtySession::start` | Interactive PTY run |
| `pty.write(data)` | `PtySession::write` | Raw stdin passthrough |
| `pty.resize(cols, rows)` | `PtySession::resize` | Clamped terminal dimensions |
| `pty.kill()` | `PtySession::kill` | Force-kills active PTY child |
| `killTree(pid, signal)` | `killTree` (`kill_tree`) | Children-first process tree termination |
| `listDescendants(pid)` | `listDescendants` (`list_descendants`) | Recursive descendants listing |
### Keys
| TS wrapper API | Rust N-API export | Notes |
|---|---|---|
| `matchesKittySequence(data, cp, mod)` | `matchesKittySequence` (`matches_kitty_sequence`) | Kitty codepoint+modifier match |
| `parseKey(data, kittyProtocolActive)` | `parseKey` (`parse_key`) | Normalized key-id parser |
| `matchesLegacySequence(data, keyName)` | `matchesLegacySequence` (`matches_legacy_sequence`) | Exact legacy sequence map check |
| `parseKittySequence(data)` | `parseKittySequence` (`parse_kitty_sequence_napi`) | Structured Kitty parse result |
| `matchesKey(data, keyId, kittyProtocolActive)` | `matchesKey` (`matches_key`) | High-level key matcher |
## Abandoned session cleanup and finalization notes
- **Shell persistent session**: if a run is cancelled/timed out/errors/non-keepalive control flow, Rust explicitly drops the internal session state. Successful normal runs keep the session for reuse.
- **PTY session**: `core` is always cleared after `start()` finishes, including failure paths.
- **No explicit JS finalizer-driven kill contract** is exposed by wrappers; cleanup is primarily tied to run completion/cancellation paths. Callers should use `timeoutMs`, `AbortSignal`, `shell.abort()`, or `pty.kill()` for deterministic teardown.
+243
View File
@@ -0,0 +1,243 @@
# Natives Text/Search Pipeline
This document maps the `@oh-my-pi/pi-natives` text/search surface (`grep`, `glob`, `text`, `highlight`) from TypeScript wrappers to Rust N-API exports and back to JS result objects.
Terminology follows `docs/natives-architecture.md`:
- **Wrapper**: TS API in `packages/natives/src/*`
- **Rust module layer**: N-API exports in `crates/pi-natives/src/*`
- **Shared scan cache**: `fs_cache`-backed directory-entry cache used by discovery/search flows
## Implementation files
- `packages/natives/src/grep/index.ts`
- `packages/natives/src/grep/types.ts`
- `packages/natives/src/glob/index.ts`
- `packages/natives/src/glob/types.ts`
- `packages/natives/src/text/index.ts`
- `packages/natives/src/text/types.ts`
- `packages/natives/src/highlight/index.ts`
- `packages/natives/src/highlight/types.ts`
- `crates/pi-natives/src/grep.rs`
- `crates/pi-natives/src/glob.rs`
- `crates/pi-natives/src/glob_util.rs`
- `crates/pi-natives/src/fs_cache.rs`
- `crates/pi-natives/src/text.rs`
- `crates/pi-natives/src/highlight.rs`
- `crates/pi-natives/src/fd.rs`
## JS API ↔ Rust export mapping
| JS wrapper API | Rust export (`#[napi(js_name = ...)]`) | Rust module |
| --- | --- | --- |
| `grep(options, onMatch?)` | `grep` | `grep.rs` |
| `searchContent(content, options)` | `search` | `grep.rs` |
| `hasMatch(content, pattern, options?)` | `hasMatch` | `grep.rs` |
| `fuzzyFind(options)` | `fuzzyFind` | `fd.rs` |
| `glob(options, onMatch?)` | `glob` | `glob.rs` |
| `invalidateFsScanCache(path?)` | `invalidateFsScanCache` | `fs_cache.rs` |
| `wrapTextWithAnsi(text, width)` | `wrapTextWithAnsi` | `text.rs` |
| `truncateToWidth(text, maxWidth, ellipsis, pad)` | `truncateToWidth` | `text.rs` |
| `sliceWithWidth(line, startCol, length, strict?)` | `sliceWithWidth` | `text.rs` |
| `extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter)` | `extractSegments` | `text.rs` |
| `sanitizeText(text)` | `sanitizeText` | `text.rs` |
| `visibleWidth(text)` | `visibleWidth` | `text.rs` |
| `highlightCode(code, lang, colors)` | `highlightCode` | `highlight.rs` |
| `supportsLanguage(lang)` | `supportsLanguage` | `highlight.rs` |
| `getSupportedLanguages()` | `getSupportedLanguages` | `highlight.rs` |
## Pipeline overview by subsystem
## 1) Regex search (`grep`, `searchContent`, `hasMatch`)
### Input/options flow
1. TS wrapper forwards options to native:
- `grep/index.ts` passes `options` mostly unchanged and wraps callback from `(match) => void` to napi threadsafe callback shape `(err, match)`.
- `searchContent` and `hasMatch` pass string/`Uint8Array` directly.
2. Rust option structs in `grep.rs` deserialize camelCase fields (`ignoreCase`, `maxCount`, `contextBefore`, `contextAfter`, `maxColumns`, `timeoutMs`).
3. `grep` creates `CancelToken` from `timeoutMs` + `AbortSignal` and runs inside `task::blocking("grep", ...)`.
### Execution branches
- **In-memory branch (pure utility)**
- `search` → `search_sync` → `run_search` on provided content bytes.
- No filesystem scan, no `fs_cache`.
- **Single-file branch (filesystem-dependent)**
- `grep_sync` resolves path, checks metadata is file, streams up to `MAX_FILE_BYTES` per file (`4 MiB`) through ripgrep matcher.
- **Directory branch (filesystem-dependent)**
- Optional cache lookup via `fs_cache::get_or_scan` when `cache: true`.
- Fresh scan via `fs_cache::force_rescan` when `cache: false`.
- Optional empty-result recheck when cache age exceeds `empty_recheck_ms()`.
- Entry filtering: file-only + optional glob filter (`glob_util`) + optional type filter mapping (`js`, `ts`, `rust`, etc.).
### Search/collection semantics
- Regex engine: `grep_regex::RegexMatcherBuilder` with `ignoreCase` and `multiline`.
- Context resolution:
- `contextBefore/contextAfter` override legacy `context`.
- Non-content modes zero out context collection.
- Output modes:
- `content` => one `GrepMatch` per hit.
- `count` and `filesWithMatches` both map to count-style entries (`lineNumber=0`, `line=""`, `matchCount` set).
- Limits:
- Global `offset` and `maxCount` applied across files.
- Parallel path is used only when `maxCount` is unset and `offset == 0`; otherwise sequential path preserves deterministic global offset/limit semantics.
### Result shaping back to JS
- Rust `SearchResult`/`GrepResult` fields map to TS types via `#[napi(js_name = ...)]`.
- Counters are clamped to `u32` before crossing N-API.
- Optional booleans are omitted unless true in some paths (`limitReached`).
- Streaming callback receives each shaped `GrepMatch` (content or count entry).
### Failure behavior
- `searchContent` returns `SearchResult.error` for regex/search failures instead of throwing.
- `grep` rejects on hard errors (invalid path, invalid glob/regex, cancellation timeout/abort).
- `hasMatch` returns `Result<bool>` and throws on invalid pattern/UTF-8 decoding errors.
- File open/search errors in multi-file scans are skipped per-file; scan continues.
### Malformed regex handling
`grep.rs` sanitizes braces before regex compile:
- Invalid repetition-like braces are escaped (`{`/`}` -> `\{`/`\}`) when they cannot form `{N}`, `{N,}`, `{N,M}`.
- This prevents common literal-template fragments (for example `${platform}`) from failing as malformed repetition.
- Remaining invalid regex syntax still returns a regex error.
## 2) File discovery (`glob`) and fuzzy path search (`fuzzyFind`)
`glob` and `fuzzyFind` share `fs_cache` scans; matching logic differs.
### `glob` flow
1. TS wrapper (`glob/index.ts`):
- `path.resolve(options.path)`.
- Defaults: `pattern="*"`, `hidden=false`, `gitignore=true`, `recursive=true`.
2. Rust `glob` builds `GlobConfig` and compiles pattern via `glob_util::compile_glob`.
3. Entry source:
- `cache=true` => `get_or_scan` + optional stale-empty `force_rescan`.
- `cache=false` => `force_rescan(..., store=false)` (fresh only).
4. Filtering:
- Skip `.git` always.
- Skip `node_modules` unless requested (`includeNodeModules` or pattern mentioning node_modules).
- Apply glob match.
- Apply file-type filter; symlink `file/dir` filters resolve target metadata.
5. Optional sort by mtime desc (`sortByMtime`) before truncating to `maxResults`.
### `fuzzyFind` flow (implemented in `fd.rs`)
1. TS wrapper is exported from `grep` module, but Rust implementation lives in `fd.rs`.
2. Shared scan source from `fs_cache` with same cache/no-cache split and stale-empty recheck policy.
3. Scoring:
- exact / starts-with / contains / subsequence-based fuzzy score
- separator/punctuation-normalized scoring path
- directory bonus and deterministic tie-break (`score desc`, then `path asc`)
4. Symlink entries are excluded from fuzzy results.
### Failure behavior
- Invalid glob pattern => error from `glob_util::compile_glob`.
- Search root must be an existing directory (`resolve_search_path`), otherwise error.
- Cancellation/timeouts propagate as abort errors via `CancelToken::heartbeat()` checks in loops.
### Malformed glob handling
`glob_util::build_glob_pattern` is tolerant:
- Normalizes `\` to `/`.
- Auto-prefixes simple recursive patterns with `**/` when `recursive=true`.
- Auto-closes unbalanced `{...` alternation groups before compile.
## 3) Shared scan/cache lifecycle (`fs_cache`)
`fs_cache` stores scan results as normalized relative entries (`path`, `fileType`, optional `mtime`) keyed by:
- canonical search root
- `include_hidden`
- `use_gitignore`
### Cache state transitions
1. **Miss / disabled**
- TTL is `0` or key absent/expired -> fresh `collect_entries`.
2. **Hit**
- Entry age `< cache_ttl_ms()` -> return cached entries + `cache_age_ms`.
3. **Stale-empty recheck** (caller policy in `glob`/`grep`/`fd`)
- If query yields zero matches and `cache_age_ms >= empty_recheck_ms()`, force one rescan.
4. **Invalidation**
- `invalidateFsScanCache(path?)`:
- no arg: clear all keys
- path arg: remove keys whose root prefixes that target path
### Stale-result tradeoff
- Cache favors low-latency repeated scans over immediate consistency.
- TTL window can return stale positives/negatives.
- Empty-result recheck reduces stale negatives for older cached scans at the cost of one extra scan.
- Explicit invalidation is the intended correctness hook after file mutations.
## 4) ANSI text utilities (`text`)
These are pure, in-memory utilities (no filesystem scanning).
### Boundaries and responsibilities
- **`text.rs` owns terminal-cell semantics**:
- ANSI sequence parsing
- grapheme-aware width and slicing
- wrap/truncate/sanitize behavior
- **`grep.rs` line truncation (`maxColumns`) is separate**:
- simple character-boundary truncation of matched lines with `...`
- not ANSI-state-preserving and not terminal-cell width aware
### Key behaviors
- `wrapTextWithAnsi`: wraps by visible width, carries active SGR codes across wrapped lines.
- `truncateToWidth`: visible-cell truncation with ellipsis policy (`Unicode`, `Ascii`, `Omit`), optional right padding, and fast-path returning original JS string when unchanged.
- `sliceWithWidth`: column slicing with optional strict width enforcement.
- `extractSegments`: extracts before/after segments around an overlay while restoring ANSI state for the `after` segment.
- `sanitizeText`: strips ANSI escapes + control chars, drops lone surrogates, normalizes CR/LF by removing `\r`.
- `visibleWidth`: counts visible terminal cells (tabs use fixed `TAB_WIDTH` from Rust implementation).
### Failure behavior
Text functions generally return deterministic transformed output; errors are limited to JS string conversion boundaries (N-API argument conversion failures).
## 5) Syntax highlighting (`highlight`)
`highlight.rs` is pure transformation (no FS, no cache).
### Flow
1. Wrapper forwards `code`, optional `lang`, and ANSI color palette.
2. Rust resolves syntax by:
- token/name lookup
- extension lookup
- alias table fallback (`ts/tsx/js -> JavaScript`, etc.)
- fallback to plain text syntax when unresolved
3. Parse each line with syntect `ParseState` and scope stack.
4. Map scopes to 11 semantic color categories and inject/reset ANSI color codes.
### Failure behavior
- Per-line parse failure does not fail the call: that line is appended unhighlighted and processing continues.
- Unknown/unsupported language falls back to plain text syntax.
## Pure utility vs filesystem-dependent flows
| Flow | Filesystem access | Shared cache | Notes |
| --- | --- | --- | --- |
| `searchContent` / `hasMatch` | No | No | regex on provided bytes/string only |
| `text` module functions | No | No | ANSI/width/sanitization only |
| `highlight` module functions | No | No | syntax + ANSI coloring only |
| `glob` | Yes | Optional | directory scans + glob filtering |
| `fuzzyFind` | Yes | Optional | directory scans + fuzzy scoring |
| `grep` (file/dir path) | Yes | Optional (dir mode) | ripgrep over files, optional filters/callback |
## End-to-end lifecycle summary
1. Caller invokes TS wrapper with typed options.
2. Wrapper normalizes defaults (notably `glob`) and forwards to `native.*` export.
3. Rust validates/normalizes options and builds matcher/search config.
4. For filesystem flows, entries are scanned (cache hit/miss/rescan) then filtered/scored.
5. Worker loops periodically call cancel heartbeat; timeout/abort can terminate execution.
6. Rust shapes outputs into N-API objects (`lineNumber`, `matchCount`, `limitReached`, etc.).
7. TS wrapper returns typed JS objects (and optional per-match callbacks for `grep`/`glob`).