Compare commits

..
Author SHA1 Message Date
morlutoand吴佳浩 1b36fc2d50 fix(scripts): validate package-map ownership and preserve HTML fallback (#10097)
* fix(website): keep the HTML validator when the Markdown route is absent

A caller that prefers Markdown gets the HTML probe with its conditional headers
deleted, so the Markdown route can be evaluated on its own. When the Markdown
sidecar is missing the worker answers with that probe, which never saw the
caller's `If-None-Match`, so a matching HTML ETag returned 200 instead of 304.

Ask the asset service again with the caller's own request before answering with
HTML. The decision moves into `htmlFallback` so the request handler keeps its
complexity below the repository bound.

Verified by executing `node --test website/worker.test.mjs`: without the change
the new case fails with 200 instead of 304, and all four cases pass with it.
`oxlint` reports no findings and `oxfmt --check` passes. The website worker test
is not part of a CI workflow, so this suite runs locally only.

* fix(scripts): resolve package-map imports in the module-boundary guard

The guard resolved only relative, absolute and `file:` specifiers, so every
`#`-prefixed package-map import returned no target and was skipped. A pure-layer
file importing `#javascript-analysis-worker` passed, while the same import
written relatively failed with a `pure-layer` diagnostic: the gate failed open
on the spelling the package map exists to encourage.

Resolve a package-map specifier to the source file that produces its built
output, then apply the same ownership rules. The package map is read once per
repository root.

Verified with the new `scripts/lib/module-boundaries.test.mjs` (2 pass; the
package-map case failed before the change) and by running the complete guard on
this checkout, which still reports 4138 files with no findings, so the new rule
adds no false positive. `oxfmt --check` passes; `oxlint` reports the same single
pre-existing `failedBoundary` complexity warning it reported before this change.
The script tests are not part of a CI workflow, so this suite runs locally only.

* fix(scripts): normalize package targets as file URLs

---------

Co-authored-by: 吴佳浩 <[email protected]>
2026-11-11 08:27:39 -05:00
N0zoM1z0andgensokyo-dev 40d9f1a5e8 test(kuna): cancel inspection scenarios at their owning boundary
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev fbbf94a2c4 test(kuna): cover cancellation during native ownership preparation
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 4a17bf76ed test(kuna): distinguish prelaunch expiry from captured command output
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 04042cf540 test(kuna): scope short deadlines to hanging decompile work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 2127cff82d test(kuna): acknowledge subprocess phases and join cancelled work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 1dc54783ef fix(kuna): preserve cancellation through workspace admission and rollback
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
morluto 48357cb34c fix(settings): expose provider startup limits for binary open (#10105) 2026-11-11 20:19:27 +08:00
morluto ab8e31dc76 test(settings): exercise Windows limits and refresh guidance 2026-11-11 20:16:24 +08:00
morluto 03610972f7 fix: expose file admission settings for affected operations 2026-11-11 20:16:24 +08:00
morluto 44bbd13b55 fix: preserve capture configuration failures 2026-11-11 20:16:24 +08:00
morluto 86fb3e757d fix: expose Windows settling controls for binary opens 2026-11-11 20:16:24 +08:00
morluto 66e85df5c5 fix: preserve cancellation when change-time settles 2026-11-11 20:16:24 +08:00
morluto 5bb34ce522 fix: scope change-time settings to Windows 2026-11-11 20:16:24 +08:00
morluto f4867e60fa fix: preserve Cutter cancellation before limit admission 2026-11-11 20:16:24 +08:00
morluto ef754ce717 fix: retain invalid runtime seeds for long-lived providers 2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 64d1a57aef fix(kuna): report runtime recovery after resetting invalid deadline seeds
Both function inventory and project queries now retain runtime editability and name the recoverable setting when reset exposes an invalid launch seed. Four provider-boundary regressions fail before the fix and pass afterward.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 47bcd9c129 docs(settings): retain runtime recovery guidance after stack integration
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 11bc738198 fix(settings): preserve runtime contracts while restacking operation limits
Retain the original stack merge resolutions for runtime update modes, migrated guard exceptions, deadline ownership and boundary expectations on the merged settings base.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
morluto 6b102e61b3 fix(gdb): isolate console output admission from control requests 2026-11-11 20:16:24 +08:00
morluto 73ee767656 fix(gdb): preserve invalid command configuration errors 2026-11-11 20:16:24 +08:00
morluto 2aa5ba836d fix(kuna): retain failed inventory admission as result 2026-11-11 20:16:24 +08:00
morluto c4e3e48574 fix(rizin): return command configuration failures 2026-11-11 20:16:24 +08:00
morluto 527c7c5d8b fix(rizin): reserve command capacity after limit validation 2026-11-11 20:16:24 +08:00
morluto 3ccb35a8b5 fix(kuna): admit regex settings before acquisition 2026-11-11 20:16:24 +08:00
morluto e675f9b29d fix(hopper): retain startup admission failures in result 2026-11-11 20:16:24 +08:00
morluto 4361cad981 fix(hopper): defer startup setting until admission 2026-11-11 20:16:24 +08:00
morluto 925df00caa fix(ghidra): scope runtime limits to applicable operations 2026-11-11 20:16:24 +08:00
morluto e8908507c8 fix(settings): preserve regex cancellation and capability truth 2026-11-11 20:16:24 +08:00
morluto 40b6f1ab21 fix(kuna): preserve run ownership on deadline refusal 2026-11-11 20:16:24 +08:00
Cursor Agent 0bf161f1bc fix(kuna): resolve the function deadline before taking a run slot
An invalid REA_KUNA_TIMEOUT_MS throws once its runtime override is reset. That check now happens before either shared-slot acquisition, so a failed independent decompile cannot leave the provider-wide pool blocked.
2026-11-11 20:16:24 +08:00
Cursor Agent 5ba464527b fix(limits): reject invalid runtime seeds and keep the value an operation used
A malformed migrated launch seed stays on the config and the operation view reports configuration_invalid unless a snapshot override is active. Kuna, regex, GDB, and Rizin keep the deadline or heap accepted when the work started, and reattributed resource errors copy the structured limit.
2026-11-11 20:16:24 +08:00
morluto 125de0bdc4 fix(kuna): retain the deadline owned by project export 2026-11-11 20:16:24 +08:00
morluto b3dcddc115 fix(providers): preserve bridge limits through error attribution 2026-11-11 20:16:24 +08:00
Cursor Agent 57dd7f26b1 fix(limits): carry runtime ceilings into Hopper and pwntools recovery
A runtime bridge ceiling is injected into the Hopper bootstrap. Pwntools input-limit guidance names the layer that selected the ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent b9be864801 fix(settings): parse runtime launch seeds at the operation boundary
Startup no longer rejects a runtime-owned REA_ seed, so update_analysis_settings can replace it. Debugger construction keeps only the restart-only caps, Kuna stores the launch baseline rather than a cached override, and the advertised operations include GDB close, Rizin close, Kuna list_procedures, and the stable-file ChangeTime callers.
2026-11-11 20:16:24 +08:00
Cursor Agent 6ebd033dff chore(plugin): regenerate the skill bundle from the authored skill
The committed Cursor plugin copy had missed the parameter update mode and the runtime bridge ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent 559f725509 fix(jeb): keep the deadline limit when a timeout is reattributed
The provider copies the selected jeb.mcp_timeout_ms limit onto the operation timeout, so callers can read its source and update mode.
2026-11-11 20:16:24 +08:00
Cursor Agent db5a0e0f4a test(jeb): assert the timeout limit source instead of a restart instruction
A launch-selected JEB deadline reports source setting and update_mode runtime. The recovery tells the caller to use update_analysis_settings.
2026-11-11 20:16:24 +08:00
Cursor Agent aefdacd697 test(pwntools): expect parameter when the caller selects the input limit
A request-sourced pwntools input limit is a parameter update, so update_analysis_settings cannot change that call.
2026-11-11 20:16:24 +08:00
Cursor Agent e0dcdab1a9 fix(settings): advertise every bridge operation and prefer accepted overrides
The bridge ceiling lists every Hopper and Ghidra bridge operation plus the Cutter calls, and a test fails when a registry entry is missing. ChangeTime observation and the other launch-seed parsers read an accepted override before rejecting an invalid value.
2026-11-11 20:16:24 +08:00
Cursor Agent 2bac42cde5 fix(settings): apply runtime overrides before provider limit validation
JEB, Kuna, Windows, debugger, and Cutter consult an accepted override before rejecting an invalid launch value. JEB and Hopper startup deadlines keep default, setting, or runtime as the limit source, and a Kuna timeout without its own limit uses the selected project deadline source.
2026-11-11 20:16:24 +08:00
Cursor Agent 1613f2b777 fix(settings): keep limit provenance through wrappers and Cutter inspection
Startup timeout wrapping keeps the debugger setting limit. Pwntools and Kuna failures report the selected source. Cutter session and command operations can inspect the bridge ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent 649b9c089c fix(settings): enforce runtime caps on each GDB command and Cutter request
GDB reads the accepted command-output cap when it admits a command. Cutter uses that same accepted bridge ceiling for mismatch and request checks. Kuna project-timeout metadata includes the operations that wait on the shared export.
2026-11-11 20:16:24 +08:00
Cursor Agent b0541c80b3 feat(settings): read per-call REA_ limits from the operation snapshot
Part of #9881. Move the limits checked on each call into update_analysis_settings, keep their REA_ seeds and bounds, and reject updates outside the allowed range.
2026-11-11 20:16:24 +08:00
morluto 9924bf3445 test(mcp): own presentation fixture cleanup (#10103) 2026-11-11 19:50:51 +08:00
N0zoM1z0andgensokyo-dev 91b11f62c9 fix(settings): preserve syntax guard contracts across stack integration
Retain existing merge resolutions for update modes, source-scan failures and external-reader fixtures while replaying the settings layer on the merged inventory. Preserve parent provenance, candidate counts and presentation-test resource bounds.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:48:33 +08:00
morluto 1626461ec1 docs: clarify limit update modes and rejected settings 2026-11-11 19:48:33 +08:00
morluto dc521dc0d9 fix(settings): omit unknown pending restart state 2026-11-11 19:48:33 +08:00
morluto 804784720a fix(ci): fail when limit guard cannot scan sources 2026-11-11 19:48:33 +08:00
morluto 6194524d00 fix(settings): avoid inventing pending restart state 2026-11-11 19:48:33 +08:00
Cursor Agent 88202cbf67 fix(settings): keep invocation recovery on the launch seed
A one-shot CLI failure tells the caller to set the launch environment for the next REA process. update_analysis_settings stays on the continuing session. Reused JavaScript workers also keep the limit source that selected them.
2026-11-11 19:48:33 +08:00
Cursor Agent 073dda2a0a docs(settings): list parameter as an update_mode
The changelog and the limit migration now name runtime, restart, and parameter. parameter is the mode for a value this call supplied.
2026-11-11 19:48:33 +08:00
Cursor Agent 0ae186e196 fix(settings): classify caller-selected limits as parameter updates
analysis_timeout_ms, max_heap_mb, and command_timeout_ms keep the source chosen by the caller, the runtime override, or the built-in default. A request value reports update_mode parameter because update_analysis_settings cannot change that call.
2026-11-11 19:48:33 +08:00
Cursor Agent 58a1216a57 fix(settings): mark per-call parameters and keep historical receipts
artifact_limits and web-network budgets report update_mode parameter because update_analysis_settings cannot change them. The legacy-name scan no longer reads docs, and the 2026-11-09 Ghidra receipts are restored to the fields they recorded.
2026-11-11 19:48:33 +08:00
Cursor Agent 9bcc95a801 fix(settings): keep Windows update mode on configuration errors
Invalid ChangeTime settings now carry update_mode and pending_restart through the configuration error and its projection, including startup parsing. Runtime recoveries name update_analysis_settings instead of telling the caller to restart REA.
2026-11-11 19:48:33 +08:00
Cursor Agent be20bdfb26 feat(settings)!: replace effect with update_mode and pending_restart
Callers read update_mode to see whether update_analysis_settings can change a limit, and pending_restart only when a configured value is not active in this process.
2026-11-11 19:48:33 +08:00
Cursor Agent aee1178839 feat(settings)!: report when a limit change takes effect
Part of #9881. Add effect immediate or next_process and keep rea_restart_required, restart_required, and restartRequired as compatibility aliases.
2026-11-11 19:48:33 +08:00
N0zoM1z0andgensokyo-dev cc6c52b886 test(mcp): retain measured full-catalog execution budget
The prepared full/full fixture takes 31.73 seconds on two CPU cores; restoring a 20 second child deadline causes a real bounded-run failure. Keep the 60 second child budget and 70 second outer deadline.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
N0zoM1z0andgensokyo-dev 3ac05e14ba docs(limits): distinguish source candidates from external controls
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
morluto 3038caad2b test(mcp): restore presentation subprocess deadline 2026-11-11 19:39:06 +08:00
N0zoM1z0andgensokyo-dev d65cd41b78 test(mcp): align presentation deadlines and cancel unfinished child
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
morluto 3769fe8077 fix(ci): reject mutable setting spreads 2026-11-11 19:39:06 +08:00
morluto 021b5de719 fix(ci): parse runtime setting seeds as syntax 2026-11-11 19:39:06 +08:00
morluto 6e2e5a0aca fix: preserve numeric limit evidence and uncertainty 2026-11-11 19:39:06 +08:00
morluto 694e8c4f7b fix(ci): classify direct numeric value expressions 2026-11-11 19:39:06 +08:00
morluto 729d1a2138 test(ghidra): avoid restoring source grep assertion 2026-11-11 19:39:06 +08:00
morluto bc01caea10 fix(ci): remove stale temporary limit exceptions 2026-11-11 19:39:06 +08:00
Cursor Agent 646daa1d29 fix(limits): classify parsed property reads and ignore template prose
A numeric parser now reads property and element accesses in its argument, so Number(process.env.REA_ARCHIVE_ENTRY_LIMIT) is a producer without a schema key. Template discovery keeps generated assignments and no longer treats a diagnostic sentence as a read.
2026-11-11 19:39:06 +08:00
Cursor Agent 3f0c77c1f5 fix(limits): type discovered limit reads as path and lifecycle 2026-11-11 19:39:06 +08:00
Cursor Agent a24e0156fe fix(limits): keep temporary per-call names on an explicit allowlist
New per-call registry entries are rejected. Inventory checks require a table row, and the Electron argv handoff is process protocol rather than a caller setting.
2026-11-11 19:39:06 +08:00
Cursor Agent ead6cc83d2 fix(limits): record construction reads and schema-owned numeric names
A constructor copy is not a per-call read. Numeric limits also come from a number schema or a numeric parse, and the inventory no longer lists path and provider constants.
2026-11-11 19:39:06 +08:00
Cursor Agent 07d6fad807 test(mcp): give the catalog presentation child a 60s bound
A loaded shard killed the full-catalog child at its 20s limit.
2026-11-11 19:39:06 +08:00
Cursor Agent 157d495029 test(ghidra): restore the bridge script read for the context contract
The main merge dropped the fs import that the file-level assertion uses.
2026-11-11 19:39:06 +08:00
Cursor Agent 575a6c486e fix(limits): read destructured environment keys and keep the pre-install guard dependency-free
Object bindings, in-checks, and object keys that a spread can copy are producers. The architecture guard skips the TypeScript parse until typescript6 is installed, and the inventory links stay inside the published site.
2026-11-11 19:39:06 +08:00
Cursor Agent b1f8a9bba8 fix(limits): discover numeric limit reads from the syntax tree
Property access, element access, and helper-call arguments are producers. Comments and array literals are excluded by the tree, so a later argument such as read(environment, "REA_NEW_MAX_BYTES") stays classified.
2026-11-11 19:39:06 +08:00
Cursor Agent 7fe465505f fix(limits): keep bracket-access reads in numeric-limit discovery
A quoted name after an identifier, call, or optional chain is a producer. Only declaration arrays and comments stay out of discovery.
2026-11-11 19:39:06 +08:00
Cursor Agent f2d55aba7b fix(limits): ignore allowlists and require tracked limits to be settings
Discovery no longer treats comments or registration-list entries as producers. The three limits moving on their own PRs must join ANALYSIS_SETTINGS; the exception registry cannot classify them.
2026-11-11 19:39:06 +08:00
Cursor Agent 710d2af41c fix(limits): classify endpoint candidates and ignore registry declarations
The numeric-limit check now treats REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES as a count limit and does not count registry text as a producer.
2026-11-11 19:39:06 +08:00
Cursor Agent 3b39991b02 ci(limits): inventory numeric REA_ limits and check them
Part of #9881. Record every numeric REA_ limit and fail static checks when one is neither a runtime analysis setting nor a documented exception with a reason.
2026-11-11 19:39:06 +08:00
morluto 0e0af455f2 fix(reference): preserve physical failures across late cancellation (#10072)
* fix(reference): preserve physical failures across late cancellation

* fix(reference): retain late cancellation alongside read failures

* fix(reference): preserve metadata errors through cancellation

* fix(reference): retain directory failure identity

* fix(reference): preserve parallel physical and cancellation outcomes

* fix(reference): preserve optional Git metadata absence
2026-11-11 18:53:08 +08:00
morluto 3ad2e214be fix(verifier): retain UF2 workspace until resource cleanup completes (#10096)
* fix(verifier): retain UF2 workspace until resource cleanup completes

* fix(verifiers): retain firmware workspace cleanup ownership
2026-11-11 18:53:02 +08:00
morluto c1c7a78117 fix(browser): retain source-map cleanup and discarded-map capacity (#10053)
* fix(browser): retain source-map cleanup and discarded-map capacity

* fix(browser): retain source map fetch and response cleanup

* fix(browser): retain response when deadline precedes body read
2026-11-11 18:38:52 +08:00
morluto 84041fff23 fix(process): retain cleanup ownership through input finalization (#10095) 2026-11-11 18:37:29 +08:00
186 changed files with 10245 additions and 1556 deletions

No files matched your search

@@ -108,11 +108,13 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `configuration_invalid`: when the error reports `update_mode: "runtime"`,
call `update_analysis_settings` for that setting. When it reports
`update_mode: "parameter"`, change that call's argument; the setter does not
accept it. Otherwise correct the named setting in the environment that starts
REA (the shell or the MCP registration) and start REA again. Run `rea doctor`
to see the `runtime-configuration:` check. Re-run `rea setup` for that client
when the registration must carry the new setting, then reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
@@ -539,8 +539,7 @@ the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. `bridge.max_request_bytes` can be changed at runtime with `update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`. Start Hopper or Cutter from that same environment so the bridge keeps the ceiling it started with until that bridge starts again. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
@@ -108,11 +108,13 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `configuration_invalid`: when the error reports `update_mode: "runtime"`,
call `update_analysis_settings` for that setting. When it reports
`update_mode: "parameter"`, change that call's argument; the setter does not
accept it. Otherwise correct the named setting in the environment that starts
REA (the shell or the MCP registration) and start REA again. Run `rea doctor`
to see the `runtime-configuration:` check. Re-run `rea setup` for that client
when the registration must carry the new setting, then reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
@@ -539,8 +539,7 @@ the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. `bridge.max_request_bytes` can be changed at runtime with `update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`. Start Hopper or Cutter from that same environment so the bridge keeps the ceiling it started with until that bridge starts again. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
+2
View File
@@ -1,5 +1,7 @@
# Changelog
The next release changes analysis-setting output (#9885). `update_analysis_settings`, `inspect_analysis_settings`, and limit errors no longer include `rea_restart_required`, `restart_required`, `restartRequired`, or `effect`. Read `update_mode` (`runtime`, `restart`, or `parameter`) and `pending_restart`. `runtime` means the setter can change the active value. `restart` means this process keeps the value it started with. `parameter` means this call supplied the value, so `update_analysis_settings` cannot change it; artifact reader limits, web-network capture budgets, and an explicit `analysis_timeout_ms`, `max_heap_mb`, or `command_timeout_ms` use it. There is no compatibility alias. Release Please should copy this migration into that release's breaking-changes section.
## [6.8.0](https://github.com/morluto/rea/compare/rea-agents-6.7.0...rea-agents-6.8.0) (2026-10-25)
### Highlights
+5
View File
@@ -363,6 +363,11 @@ export const documentationSections = [
source: "design/limits-and-recovery.md",
route: "/design/limits-and-recovery",
},
{
text: "REA limit inventory",
source: "design/rea-limit-inventory.md",
route: "/design/rea-limit-inventory",
},
{
text: "MCP tool design",
source: "development/tool-design.md",
+1
View File
@@ -112,6 +112,7 @@ product pages. The VitePress documentation source is this `docs/` tree.
## Development and verification
- [Settings and recovery](design/limits-and-recovery.md)
- [REA limit inventory](design/rea-limit-inventory.md)
- [Flutter budget calibration](verification/flutter-budget-calibration.md)
- [Source-map budget calibration](verification/source-map-budget-calibration.md)
- [Source-location delivery qualification](verification/source-location-delivery.md)
+44 -13
View File
@@ -5,7 +5,9 @@ who owns each value, when an update takes effect, how providers enforce it, and
how to extend the system. The public tool contract is in
[MCP contracts](../reference/mcp-contracts.md#editable-analysis-settings); recorded runtime
checks and their limitations are in the
[verification report](../verification/runtime-analysis-settings.md).
[verification report](../verification/runtime-analysis-settings.md). The
[limit inventory](rea-limit-inventory.md) lists every numeric `REA_` limit, which
of them are runtime settings, and which stay restart-only.
## Problem and design boundary
@@ -129,12 +131,14 @@ Windows ChangeTime admission waits out a fresh timestamp before recording a
baseline. `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` (default 2000 ms, floor 1 ms,
maximum 60000 ms), `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` (default 25 ms,
floor 1 ms, maximum 1000 ms), and `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS`
(default 40, floor 1, maximum 1000) are launch settings. Unset keeps those
defaults. An invalid value is a configuration error: it names the setting, the
rejected value, source `setting`, the default, the floor, and the maximum, and
says to set a positive decimal integer in the environment that starts REA and
then restart REA. There is no runtime override. Setup forwards an explicit
selection. When the poll budget runs out, admission keeps the last observation
(default 40, floor 1, maximum 1000) are runtime settings. Unset keeps those
defaults. An invalid launch value is a configuration error: it names the
setting, the rejected value, source `setting`, the default, the floor, the
maximum, and `update_mode: "runtime"`. It omits `pending_restart` because
the rejected value is not an accepted configuration. Change the
active value with `update_analysis_settings`. The launch seed must be a
positive decimal integer in that range. Setup forwards an explicit selection.
When the poll budget runs out, admission keeps the last observation
instead of failing the readable file. Raising a setting only changes how long
that wait can last. It does not change Windows ChangeTime itself.
@@ -255,8 +259,8 @@ are runtime-wide, not scoped to a target or an individual connection.
### Failure and retry
An ASAR admission error supplies `details.limit` with the exact setting,
effective value, units, bounds, scope, application point, restart requirement
and, when known, required bytes and value source. The agent can immediately call:
effective value, units, bounds, scope, application point, `update_mode`,
`pending_restart` and, when known, required bytes and value source. The agent can immediately call:
```json
{
@@ -273,13 +277,34 @@ that argument; raising a runtime default cannot override it.
The resource owner constructs `details.limit` from the registry plus the budget
it actually enforced. Metadata includes `agent_editable: true`,
`scope: "rea-runtime"`, and `rea_restart_required: false`. Optional
`scope: "rea-runtime"`, `update_mode`, and `pending_restart`. `update_mode`
is `runtime` when `update_analysis_settings` can change the active value,
`restart` when this process keeps the value it started with, and `parameter`
when the budget is adjustable through a per-call argument. Its `source` records
whether the reported value came from a request or a default. An explicit `analysis_timeout_ms`,
`max_heap_mb`, or `command_timeout_ms`, plus artifact reader limits and
web-network capture budgets, use `parameter`. `pending_restart` is false when
that reported value is the one this process is using. Optional
`required_value`, `source`, and `parameter` fields are supplied only where known
or applicable. Do not infer a source because a value happens to equal a default.
Error copying, attribution, redaction, and delivery must preserve these facts.
Human-readable remediation explains the same failure; clients should not need
to parse that sentence to identify an editable setting.
This output is breaking. Results and errors no longer carry the previous
restart boolean or a single effect field, and there is no compatibility alias.
Read `update_mode` to see whether the setter can change the active value, and
`pending_restart` to see whether a configured value is waiting. A configuration
error for a Windows ChangeTime setting carries `update_mode` on
`details.settings`. Its rejected value does not establish an accepted pending
configuration, so `pending_restart` is omitted. The removed field names
are listed in the changelog.
An inspected setting whose launch value is invalid and which has no runtime
override likewise omits `pending_restart`: it has no accepted configured value
to classify. Setting a valid runtime override reports `pending_restart: false`;
resetting it restores the invalid launch state and the omission.
`scope` and `applies_at` describe a continuing caller, such as an MCP
connection. A one-shot invocation, such as a CLI command, has released its
provider session and exited its runtime before its caller reads the failure,
@@ -288,8 +313,11 @@ so its adapter projects with an explicit `"invocation"` caller lifetime.
`scope: "rea-invocation"`, `applies_at: "next_invocation"` and the launch
`environment` seed when one exists. `agent_editable: true` still holds, because
the caller chooses that environment value or per-call `parameter` for the new
invocation; `rea_restart_required: false` means no running REA process needs a
restart. A producer keeps guidance that holds only while its caller retains the
invocation; `update_mode: "runtime"` and `pending_restart: false` mean the
reported value is active in this process. `update_mode: "restart"` means
`update_analysis_settings` does not change it. `update_mode: "parameter"` means
the budget is adjustable through a per-call argument; `source` identifies where
the reported value came from. A producer keeps guidance that holds only while its caller retains the
session, such as "the Ghidra session remains usable" or a same-session runtime
update, in `continuingCallerGuidance`; the shared presentation adds it only for
a continuing caller. The producer's own message and remediation must stay true
@@ -499,7 +527,10 @@ own `expanded_bytes` controls its independently enforced bundle expansion.
Failures preserve `details.reader_limit` with the exact parameter, effective
value, units, supported range, required value when known, next-call scope and
`can_increase`. When `can_increase` is true, raise that field and retry the same
`can_increase`. `update_mode` is `parameter`: the caller selects the value on
the next call, and `update_analysis_settings` does not accept it.
`pending_restart` is false. Web-network capture budgets use the same mode.
When `can_increase` is true, raise that field and retry the same
complete artifact. At the supported maximum, or when the required representation
exceeds it, the failure is not retryable by increasing that budget; inspect the
declared dimensions and input validity, or explicitly choose a suitable reader.
+244
View File
@@ -0,0 +1,244 @@
# REA limit inventory
Audit for [#9881](https://github.com/morluto/rea/issues/9881). Counts below are
from this revision. The inventory includes numeric `REA_` launch limits and
the fixed reconnect control generated into the Hopper bridge.
`scripts/verify/rea-numeric-limits.mjs` checks recognized TypeScript source
reads (tests excluded) against the environment seeds of
[`ANALYSIS_SETTINGS`](https://github.com/morluto/rea/blob/main/src/domain/analysisSettingDefinitions.ts) or an
exception in [`numericLimitRegistry.ts`](https://github.com/morluto/rea/blob/main/src/domain/numericLimitRegistry.ts)
with a reason. Static syntax recognition cannot prove arbitrary dynamic reads
or how often a function executes.
The Hopper demo-dialog timeout is read by `scripts/hopper-demo-x11.py`, and
the reconnect control is generated by `src/hopper/BridgeLauncher.ts` for
`bridge/hopper_bridge.py`. Their registry entries identify behavioral witness
tests. Node lint checks those paths exist but reports their behavior as
unverified; it does not execute Python or prove either reader. Run
`npm run test:focused -- tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts tests/boundary/providers/hopper/hopperBridgeReconnect.test.ts`
to exercise the witnesses. The reconnect witness exercises the bridge consumer
with an injected timeout; `npm run test:local -- src/hopper/bridgeBootstrapLimit.test.ts`
verifies the separate launcher output. Neither test is run by Node lint. A
skipped Python test leaves its behavior unverified.
`update_mode` is `runtime` when `update_analysis_settings` can change the active
value, `restart` when this process keeps the value it started with, and
`parameter` when the budget is adjustable through a per-call argument; `source`
separately identifies where the reported value came from.
`pending_restart` is true only when an accepted configured value is not the one
this process is using. A rejected Windows ChangeTime launch value does not
establish an accepted configuration, so that failure omits `pending_restart`.
The other values reported here are in force, so `pending_restart` is false. Prose that only says to
restart REA is marked `prose`. `mcp_server_restart_required` is a
server-identity alignment state in `src/serverIdentity.ts`, not a setting field.
Three numeric limits are absent from this tree and are being moved into the
runtime system on their own PRs. This audit does not change them:
| Name | Tracking |
| -------------------------------------------- | -------- |
| `REA_NTFS_MFT_MAX_FILE_BYTES` | #9868 |
| `REA_REGF_HIVE_MAX_FILE_BYTES` | #9865 |
| `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` | #9836 |
When those names land in `src`, the static check requires them to be analysis
settings. They are not on the restart-only list.
## Counts
| Category | Count |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------: |
| Runtime analysis settings | 28 (25 have an `REA_` seed; 3 are per-call parameters only) |
| Per-call numeric `REA_` limits still read from the launch environment | 0 |
| Restart-only numeric `REA_` limits | 15 |
| Numeric `REA_` candidates recognized in TypeScript source | 38 |
| Numeric `REA_` controls with external or generated-reader provenance | 2 |
| Non-numeric `REA_` settings kept restart-only | 52 caller settings, plus process-protocol names that are not caller settings |
| Hardcoded ceilings left for follow-up | grouped below; none converted in the inventory change |
## Runtime analysis settings
Already adjustable with `update_analysis_settings`. Each call reads the snapshot
accepted for that operation. Launch source is `default` or `setting`; a runtime
override is `source: "runtime"`. The structured field is `update_mode: "runtime"` and `pending_restart: false`.
`applies_at` is the finer point
(`next_operation`, `next_worker`, or `next_startup`).
| Setting | Environment seed | Owner | Default | Floor | Maximum | Read |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------- | ---------------: | -------------------------------------: | --------------------- |
| `asar.max_header_bytes` | `REA_ASAR_MAX_HEADER_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 268435456 | per operation |
| `asar.max_listing_bytes` | `REA_ASAR_MAX_LISTING_BYTES` | `src/domain/analysisResourceLimits.ts` | 67108864 | 1 | `Number.MAX_SAFE_INTEGER` | per operation |
| `ghidra.value_flow_max_operations` | `REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS` | `src/domain/analysisResourceLimits.ts` | 3000 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_inputs_per_operation` | `REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION` | `src/domain/analysisResourceLimits.ts` | 64 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_edges` | `REA_GHIDRA_VALUE_FLOW_MAX_EDGES` | `src/domain/analysisResourceLimits.ts` | 12000 | 1 | 2147483647 | per operation |
| `ghidra.decompile_max_payload_mib` | `REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB` | `src/domain/analysisResourceLimits.ts` | unset (`null`, unbounded payload policy) | 1 | 2047 | per operation |
| `ghidra.startup_timeout_ms` | `REA_GHIDRA_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 330000 | 1 | 2147483647 | next provider startup |
| `hopper.startup_timeout_ms` | `REA_HOPPER_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | next provider startup |
| `ghidra.decompile_timeout_seconds` | `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | `src/domain/analysisResourceLimits.ts` | unset (`null`, decompile stays unbounded) | 1 | `MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | per operation |
| `jadx.operation_timeout_ms` | `REA_JADX_OPERATION_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | per operation |
| `regex.matching_deadline_ms` | `REA_REGEX_MATCHING_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 5000 | 1 | 2147483647 | per operation |
| `regex.worker_heap_request_mib` | `REA_REGEX_WORKER_HEAP_REQUEST_MIB` | `src/domain/analysisResourceLimits.ts` | 128 | 7 | 16384 | next worker |
| `capture.decoder_old_generation_mib` | `REA_CAPTURE_DECODER_OLD_GENERATION_MIB` | `src/domain/analysisResourceLimits.ts` | 192 | semi-space floor | 16384 | next worker |
| `javascript.analysis_timeout_ms` | none (`analysis_timeout_ms`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | 1 | 2147483647 | per operation |
| `javascript.max_heap_mb` | none (`max_heap_mb`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | owner floor | owner maximum | next worker |
| `wasm.command_timeout_ms` | none (`command_timeout_ms`) | `src/domain/wasm/wasmArtifact.ts` | owner default | 1 | owner maximum | per operation |
| `native.matching_representation_bytes` | `REA_NATIVE_MATCHING_REPRESENTATION_BYTES` | `src/domain/analysisResourceLimits.ts` | 100663296 | 1 | 1073741824 | per operation |
An out-of-range `update_analysis_settings` value is rejected by the setting's
schema. Invalid launch seeds that the global environment parser accepts as
provider-local stay inspectable with `source: "invalid"`.
## Per-call limits in the runtime system
These are checked when the work runs. Each keeps its `REA_` seed, default,
floor, and maximum. The accepted operation snapshot supplies the value.
`update_mode` is `runtime` and `pending_restart` is false for the value in
force. An update outside the range is rejected. A hit names the setting and
reports the current value, source, and range. A rejected Windows ChangeTime launch value omits `pending_restart`.
| Environment | Owner | Default | Floor | Maximum | Applies | Fields |
| ------------------------------------------ | ------------------------------------------- | ----------------------: | ----: | ---------: | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | discovery and each tools/call, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | each project export and whole-program scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes`; otherwise the operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
## Restart-only numeric limits
These stay out of `update_analysis_settings`.
| Environment | Owner | Default | Floor | Maximum | Read | Why restart-only |
| ----------------------------------- | -------------------------------------- | ------------------: | -------: | ------------------------: | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | `src/domain/analysisResourceLimits.ts` | 16 | 1 | 256 | debugger manager construction | Queue capacity of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_DEBUGGER_MAX_SESSIONS` | `src/domain/analysisResourceLimits.ts` | 8 | 1 | 64 | debugger manager construction | Session-pool size of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS` | `src/domain/hopperStartupFailure.ts` | 10000 | 1 | 2147483647 | Linux private-display adapter start | The helper reads the process environment. Prose restart. |
| `REA_JADX_ACTIVE_PROCESSOR_COUNT` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | 2147483647 | JADX JVM launch | `-XX:ActiveProcessorCount` is fixed for that JVM. Prose on invalid values. |
| `REA_JADX_HEAP_MIB` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | `Number.MAX_SAFE_INTEGER` | JADX JVM launch | `-Xmx` is fixed for that JVM. Prose on invalid values. |
| `REA_KUNA_CACHE_MAX_BYTES` | `src/kuna/KunaConfiguration.ts` | 4294967296 | 1048576 | `Number.MAX_SAFE_INTEGER` | Kuna cache construction | The cache is sized once for the provider. Prose restart. |
| `REA_KUNA_JOBS` | `src/kuna/KunaConfiguration.ts` | 1 | 1 | 32 | Kuna run-slot construction | Worker-pool width for the provider. Prose restart. |
| `REA_MCP_DOCTOR_DEADLINE_MS` | `src/config/mcpStartupBudgets.ts` | 30000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_FIRST_CATALOG_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 20000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_INITIALIZE_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 15000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_MAX_INPUT_ELEMENTS` | `src/config/mcpInputBudget.ts` | 1024 | 1 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Argument admission for the server process. Prose restart. |
| `REA_MCP_MAX_RESPONSE_BYTES` | `src/config/mcpResponseBudget.ts` | 10485760 when unset | 10485760 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Stdio receive buffer and the paired client buffer. Prose restart. |
| `REA_MCP_TOOLS_LIST_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 5000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_RECONNECT_TIMEOUT_SECONDS` | `src/hopper/BridgeLauncher.ts` | 30 | 30 | 30 | written into the Hopper bridge helper | Not a caller setting. It is `HOPPER_SHUTDOWN_TIMEOUT_MS / 1000`. |
| `REA_SWIFTC_COMPILE_TIMEOUT_MS` | `src/config/providerStartupTimeout.ts` | 120000 | 1 | 2147483647 | Darwin ownership-helper reader construction | Captured at process start for that helper compile. Prose restart. |
## Restart-only non-numeric settings
No floor or maximum. They are read from the launch environment at startup.
Recovery text tells the caller to change the launch environment, which takes
effect on the next process. They stay
restart-only because they choose a command, a binary, a filesystem root, the
browser sandbox, secret patterns, the provider, or the shape of the MCP server
or logger.
| Name | Owner | Why |
| ------------------------------------ | -------------------------------------------------- | ------------------------------------------- |
| `REA_ADB_PATH` | `src/adb/AdbConfiguration.ts` | Caller-selected executable path. |
| `REA_AGENT_ENV_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ANALYSIS_PROVIDER` | `src/config/environment.ts` | Provider choice for the process. |
| `REA_APKTOOL_COMMAND` | `src/apktool/ApktoolConfiguration.ts` | Caller-selected command. |
| `REA_BINWALK_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_BROWSER_EXECUTABLE` | `src/application/CapabilityInventory.ts` | Caller-selected browser binary. |
| `REA_BROWSER_NO_SANDBOX` | `src/application/SetupRegistrationEnvironment.ts` | Browser sandbox policy. |
| `REA_CAPINFOS_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_CUTTER_BRIDGE_DIR` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem root for the Cutter bridge. |
| `REA_DARWIN_MODULE_CACHE_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem path for the Swift module cache. |
| `REA_DARWIN_SWIFTC_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected compiler path. |
| `REA_DCONF_COMMAND` | `src/desktop/DconfDatabaseReader.ts` | Caller-selected command. |
| `REA_DOCKER_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_EVM_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_FIRMWARE_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_GDB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected debugger binary. |
| `REA_GHIDRA_CHECKPOINT_DIR` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_COMPILER_SPEC_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_DISABLED_ANALYZERS` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_LANGUAGE_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_NATIVEAOT_JAR` | `src/config/environment.ts` | Caller-selected binary path. |
| `REA_GHIDRA_PROGRAM_NAME` | `src/config/environment.ts` | Provider project selection. |
| `REA_GHIDRA_PROJECT_PATH` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_SEED_FILE` | `src/config/environment.ts` | Filesystem path. |
| `REA_IDA_MCP_CONFIG` | `src/config/environment.ts` | Filesystem path of the IDA MCP config. |
| `REA_ILSPY_CMD_PATH` | `src/config/environment.ts` | Caller-selected executable path. |
| `REA_JADX_MCP_JAR` | `src/android/JadxConfiguration.ts` | Caller-selected binary path. |
| `REA_JAVASCRIPT_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_JEB_MCP_URL` | `src/config/environment.ts` | Provider endpoint. |
| `REA_KUNA_CACHE_DIR` | `src/kuna/KunaConfiguration.ts` | Filesystem root. |
| `REA_KUNA_COMMAND` | `src/kuna/KunaConfiguration.ts` | Caller-selected executable path. |
| `REA_LOG_LEVEL` | `src/config/environment.ts` | Logger configuration for the process. |
| `REA_MCP_INPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_OUTPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_TOOL_CATALOG_PROFILE` | `src/config/environment.ts` | MCP server catalog shape. |
| `REA_MCP_TOOL_FAMILIES` | `src/config/environment.ts` | Which tools the MCP server registers. |
| `REA_MITMDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_OBJDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_PWNDBG_GDB` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected debugger binary. |
| `REA_PWNDBG_GDBINIT` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected file path. |
| `REA_PWNDBG_VENV_PATH` | `src/native/pwndbg/PwndbgCoreContext.ts` | Filesystem root. |
| `REA_PWNTOOLS_PYTHON` | `src/native/pwntools/PwntoolsDecoder.ts` | Caller-selected interpreter path. |
| `REA_REFERENCE_SECRET_PATTERNS_JSON` | `src/config/environment.ts` | Secret-pattern policy. |
| `REA_RIZIN_COMMAND` | `src/rizin/RizinDebugSessionManager.ts` | Caller-selected debugger binary. |
| `REA_SIGROK_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_SIGROK_PRLIMIT_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_TSHARK_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_TSHARK_PRLIMIT_COMMAND` | `src/pcap/PcapNativeBound.ts` | Caller-selected command. |
| `REA_UNBLOB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WABT_BIN_DIRECTORY` | `src/wasm/WabtArtifactProvider.ts` | Filesystem root of the WABT tools. |
| `REA_WAKARU_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_XPROP_COMMAND` | `src/application/desktop/DesktopCaptureFailure.ts` | Caller-selected command. |
`REA_MANAGED_LINUX_HOPPER_LAUNCHER` is a fixed path constant, and
`REA_WORKFLOW_PROVIDER` is a provider identity object. Neither is an
environment setting. Names that look like `REA_` but are process protocol, not
caller settings:
`REA_ELECTRON_CAPTURE_ARGV`, `REA_BUILD_COMMIT`, `REA_BUILD_COMPUTER_REA_ARCHIVE`, `REA_INSTALL_SCRIPT`,
`REA_OWNS_PROCESS_LIFETIME`, `REA_PAYLOAD`, `REA_PROCESS_RUN_ID`, `REA_RUN_ID`,
`REA_SOCKET`, `REA_STATE_FORMAT`, `REA_TARGET_PATH`, `REA_TOKEN`, bridge markers
such as `REA_COMMAND_COMPLETE_`, and the `ERR_REA_WINDOWS_NATIVE` code.
`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_PATH`,
`HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` are launch configuration
without the `REA_` prefix. Paths and the provider choice among them stay
restart-only for the same reason as the table above.
## Hardcoded ceilings left for follow-up
These are checked per call and tell the caller this release has no runtime
override. They are not `REA_` settings, so the numeric-limit check does not
list them. Each family has its own owner. Turning one into a setting needs that
owner's default, floor, maximum, and an error that carries the setting id,
value, source, and range. Format and protocol ceilings that the
[limits guide](limits-and-recovery.md) already calls fixed stay fixed. In
particular, JSON nesting stops at `MAX_JSON_DEPTH` (1000) and the debugger
control-reply budget stays separate from
`REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`.
| Family | Examples | Follow-up |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| Native structural input ceilings | PE resources, imports, load config, certificates, exports, initializers, binary hardening, Windows shortcuts | One setting per ceiling, or a shared input budget only where the owners match. |
| Native plist | `MAX_NATIVE_PLIST_BYTES`, decode bytes, nesting depth | Separate from JSON depth. |
| SQLite inspection | concurrent inspections, input bytes, diagnostic bytes | Pool width stays restart-only if it sizes the worker set; byte budgets can become settings. |
| Producer command allowances | fixed command deadline and retained output where the text says there is no runtime override | Distinct from per-call `producer_limits`. |
| Hopper caller-scan retention and page allowances | `HOPPER_CALLER_SCAN_RETENTION_BYTES`, `HOPPER_CALLER_PAGE_ALLOWANCE_BYTES` | Explicitly not raised by the scan time budget. |
| Rizin frame protocol | `MAX_FRAME_BYTES`, queued frames | Protocol bounds, not caller budgets. |
| Debugger detach and control replies | `DETACH_TIMEOUT_MS`, `DEBUGGER_CONTROL_REPLY_BYTES` | Ownership deadlines, not the shared command deadline. |
| Web network capture nesting | `max_nesting_depth` already has a per-call parameter | The fixed depth maximum is the follow-up, not the parameter. |
## Static check
`npm run lint` runs `scripts/verify/rea-numeric-limits.mjs` after
dependencies are installed. `scripts/verify/architecture-guards.mjs` runs the
same check when `typescript6` is already installed; the pre-install CI step
cannot. The check scans `src` excluding tests,
keeps names that are numeric limits, and requires each name to be an
`environment` on an analysis setting or a registry entry with a reason.
The registry accepts only `restart-only`. A limit checked on each call has to
be an analysis setting. The check rejects a `per-call` registry entry.
+3 -1
View File
@@ -462,7 +462,9 @@ most 64 procedures and retains at most 4 MiB of encoded scan results. A composed
workflow reuses its own index and clears it when the workflow finishes. The
default cooperative scan budget is
2,000 ms; set `REA_HOPPER_CALLER_SCAN_BUDGET_MS` to a positive integer number of
milliseconds before starting REA to change it. The default limits additional
milliseconds before starting REA to change the launch value, or use
`update_analysis_settings` with `hopper.caller_scan_budget_ms` for newly accepted
operations. The default limits additional
work on the serial Hopper bridge; larger documents may need a higher setting.
Results report the examined procedure count and any budget interruption.
An individual page, native caller list, and target instruction list each have
+2 -2
View File
@@ -566,7 +566,7 @@ per-script deadline above the effective total is rejected. A failed or
timed-out script becomes an explicit unknown while completed findings and later
scripts remain available. Total-deadline expiry returns a typed timeout with
partial Evidence and identifies unstarted scripts. `source_map_fetch_timeout_ms`
defaults to 30000 and is an item limitation, not that analysis timeout. These
defaults to 30000 and, after response cleanup completes, is an item limitation, not that analysis timeout. Pending or failed response cleanup returns an error retaining its owner. These
controls are also available as CLI flags with hyphenated names. Worker protocol
and result byte budgets have no runtime override.
@@ -635,7 +635,7 @@ rejects it as unknown; other command rejections retain their reported failure.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a default 64 MiB aggregate streamed-response budget across redirects. Select `source_map_limits.fetch_bytes` to change byte admission for the next call. A fetch overrun is an item limitation, not a thrown analysis timeout. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding defaults to 64 MiB for raw resolved identities and 32 MiB for aggregate inline evidence; `source_map_limits.fetch_bytes` and `source_map_limits.output_bytes` own those independent admissions. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a default 64 MiB aggregate streamed-response budget across redirects. Select `source_map_limits.fetch_bytes` to change byte admission for the next call. After response cleanup completes, a fetch overrun is an item limitation, not a thrown analysis timeout. Pending or failed response cleanup instead returns an error retaining its owner. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding defaults to 64 MiB for raw resolved identities and 32 MiB for aggregate inline evidence; `source_map_limits.fetch_bytes` and `source_map_limits.output_bytes` own those independent admissions. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
A fully read response that fails UTF-8, JSON, mapping, or structural validation
retains `artifact: null`, empty original-source, module-edge, and mapping
+7 -5
View File
@@ -59,7 +59,7 @@ Pseudocode read from a parallel project export includes a prototype-context
limitation in its Evidence, including in function dossiers and composed
workflows. Early single-function results have their own source limitation.
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline. Change the reported budget for newly accepted operations with `update_analysis_settings` using `kuna.function_timeout_ms` or `kuna.project_timeout_ms`. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
@@ -90,7 +90,7 @@ The first text or assembly request in a session looks for this target's export i
- Meanwhile, pseudocode comes from one `kuna decompile --addr` run. Name and address lookups, and listings, use the inventory the export takes with `kuna functions --json` before it decompiles. The session runs `kuna functions` itself, under `REA_KUNA_TIMEOUT_MS`, only when no export is building (an inventory-only request does not start one) or the export ends without an inventory.
- Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped.
- The session keeps each completed single-function text, up to 16 MiB in all with the least recently used dropped first, and answers repeats from it until the export can. After a failed export that lasts for the session.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. REA does not expose per-function records from a failed project export, even if the retained Kuna diagnostic refers to them. Single-function fallback Evidence reports the failed state and this limitation. If cleanup remains incomplete, its error takes precedence and `close_binary` retries the retained resources. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. REA does not expose per-function records from a failed project export, even if the retained Kuna diagnostic refers to them. Single-function fallback Evidence reports the failed state and this limitation. If cleanup remains incomplete, its error takes precedence and `close_binary` retries the retained resources. Change the deadline for a newly accepted project export with `update_analysis_settings` using `kuna.project_timeout_ms`; a failed session does not restart its export, so close and reopen the target to start another. `REA_KUNA_JOBS` is read when REA starts, so changing it requires a restart. Pseudocode keeps using single-function runs.
- A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function.
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`; omitted [Mach-O data entries](#mach-o-data-entries) are reported in their limitations. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
@@ -99,7 +99,7 @@ The background export belongs to the session. `close_binary`, or the end of a CL
The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files.
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. The setting is read from the environment that starts REA, so a change needs a restart. A deadline failure names the effective milliseconds, whether they came from the default or the setting, the default, and the maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. The setting is read from the environment that starts REA, so a change needs a restart. A heap failure names the effective request, whether it came from the default or the setting, the default, the floor, and the maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. Change the budget for newly accepted searches using `update_analysis_settings` and `regex.matching_deadline_ms`; changing the launch environment requires a restart. A deadline failure names the effective milliseconds, known source, default, and maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. Change the request for workers of newly accepted searches using `update_analysis_settings` and `regex.worker_heap_request_mib`; changing the launch environment requires a restart. A heap failure names the effective request, known source, default, floor, and maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger project-export spans remain in the local export and the error identifies the file and range. A single-function run does not by itself retain a complete project export. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question.
@@ -196,5 +196,7 @@ The lane compiles a small source-owned fixture with the host C compiler and veri
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
Doctor reports an invalid Kuna cache, job count, or timeout setting with that
setting's correction. Correct or unset the named setting and restart REA; these
configuration rejections do not require replacing the selected Kuna executable.
setting's correction. Cache size and job count stay with the process that
started them. The two timeouts can be changed at runtime with
`update_analysis_settings`. These configuration rejections do not require
replacing the selected Kuna executable.
+2 -1
View File
@@ -65,7 +65,8 @@ escaping, before allocating the serialized request or sending it. The framing
newline is excluded. An oversized request returns `resource_constraint` with
the effective setting, its source, and the exact payload byte count.
Set the value in the environment that starts REA, then restart REA to apply it.
`bridge.max_request_bytes` can be changed at runtime with
`update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`.
This setting is enforced by the REA client; the Java bridge has no corresponding
request ceiling.
+4 -2
View File
@@ -35,8 +35,10 @@ Catalog discovery and each `tools/call` share `REA_JEB_MCP_TIMEOUT_MS`. Set it
in REA's launch environment to an integer from 1 to 2147483647 milliseconds,
for example `REA_JEB_MCP_TIMEOUT_MS=300000` for a five-minute budget. Omitted
means the pinned MCP client SDK default of 60000 ms (`@modelcontextprotocol/client`
2.3.1). Restart REA and reconnect to apply a change. Raising the setting raises
both phases. It does not make one end-to-end deadline across a whole CLI or MCP
2.3.1). Change the budget for subsequent operations with
`update_analysis_settings` using `jeb.mcp_timeout_ms`. To change the launch
value, update REA's environment and restart. Raising the setting raises both
phases. It does not make one end-to-end deadline across a whole CLI or MCP
operation: discovery and each `tools/call` each get that budget. A request that
reaches its budget returns a typed timeout naming the phase and the effective
milliseconds, while caller cancellation remains a cancellation. Allow execution,
+7 -4
View File
@@ -107,10 +107,13 @@ GDB and Rizin share these settings in the environment that launches REA:
| `REA_DEBUGGER_MAX_SESSIONS` | 8 | 1 to 64 |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | 16 | 1 to 256 |
Unset settings keep the defaults. Invalid selections fail configuration instead
of falling back. Restart REA after changing its launch environment; these are
not `update_analysis_settings` options. For MCP, set the values in the client's
REA registration and restart that server.
Unset settings keep the defaults. Invalid values are refused when the setting
is used instead of falling back. Change the command deadline and output budget
for subsequent operations with `update_analysis_settings` using
`debugger.command_deadline_ms` and `debugger.max_command_output_bytes`. Session
and pending-command capacities require a restart. To change the launch values,
update REA's environment and restart; for MCP, edit the client registration and
restart that server.
These settings also apply to one-shot `rea debug-with-gdb` and
`rea debug-with-rizin` commands. They do not change the fixed initial-prompt
@@ -147,15 +147,10 @@ the measured build and selected source-location owners. A later unrelated genera
desktop SQLite diagnostic change and rebuild are outside this snapshot; later
whole-tree hashes do not replace the measured one.
The measured revision of the [portable driver](https://github.com/morluto/rea/blob/main/scripts/verify/analysis/source-location-delivery/task.mjs)
had SHA-256 `b21ec9d5315682337eb55a4ad208e2145a7397d8d567cdb6653facd3f0bec90e`.
The [portable driver](https://github.com/morluto/rea/blob/main/scripts/verify/analysis/source-location-delivery/task.mjs)
has SHA-256 `b21ec9d5315682337eb55a4ad208e2145a7397d8d567cdb6653facd3f0bec90e`.
The [observer](https://github.com/morluto/rea/blob/main/scripts/verify/analysis/budget-calibration-run.py)
has SHA-256 `c18a06c44715c97ec9cbc76248dfbcd862d0e5ca3cd0c159d868bc7fa1fa9e9f`.
The current driver additionally checks the MCP child's final exit and output
closure, preserving both task and shutdown failures. Its shared process-owner
sources are included in new aggregation hashes; the historical measurements
above have not been rerun with this lifecycle correction.
Use the repository's built runtime and pinned Node. Acquisition refuses changed
publisher bytes; the existing acquisition helper creates `artifacts.json` and
analyst manifests under the selected artifact directory.
+1 -1
View File
@@ -151,7 +151,7 @@
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries && node scripts/verify/rea-numeric-limits.mjs",
"lint:details": "oxlint && npm run verify:module-boundaries",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip --cache",
+55 -2
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -29,8 +30,60 @@ const CENTRALIZED_CONSTRUCTOR_OWNER =
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
const sourceTarget = (file, specifier) => {
const packageImports = new Map();
/** Read the package map once per repository root. */
const readPackageImports = (root) => {
if (!packageImports.has(root)) {
try {
const manifest = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
);
packageImports.set(root, manifest.imports ?? {});
} catch (cause) {
throw new Error(
`Cannot read package imports from ${resolve(root, "package.json")}: ${cause.message}`,
{ cause },
);
}
}
return packageImports.get(root);
};
/**
* The own source file a package-map specifier names.
*
* The map points at built output under `dist/`, so the same ownership rule is
* applied to the source file that produces it. Without this the gate silently
* accepts every `#`-prefixed import, which is the spelling the package map exists
* to encourage.
*/
const packageMapTarget = (root, specifier) => {
const target = readPackageImports(root)[specifier];
if (typeof target !== "string")
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)}: this boundary check supports exact string mappings only; use a relative source import for conditional, array, pattern, or undeclared mappings.`,
);
const source = target.startsWith("./")
? repositoryPath(
root,
fileURLToPath(
new URL(target, pathToFileURL(resolve(root, "package.json"))),
),
)
.replace(/^dist\//u, "src/")
.replace(/\.(?:js|mjs|cjs)$/u, ".ts")
: undefined;
if (source === undefined || !source.startsWith("src/"))
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)} to a source owner from ${JSON.stringify(target)}; use a relative source import for this boundary check.`,
);
return resolve(root, source);
};
const sourceTarget = (root, file, specifier) => {
if (specifier.startsWith("file:")) return fileURLToPath(specifier);
if (specifier.startsWith("#")) return packageMapTarget(root, specifier);
if (isAbsolute(specifier)) return resolve(specifier);
return specifier.startsWith(".")
? fileURLToPath(new URL(specifier, pathToFileURL(file)))
@@ -101,7 +154,7 @@ export const inspectModuleBoundaries = (file, source, root) => {
const importer = repositoryPath(root, absoluteFile);
const violations = [];
for (const { specifier, line, typeOnly } of readImportSpecifiers(source)) {
const resolved = sourceTarget(absoluteFile, specifier);
const resolved = sourceTarget(root, absoluteFile, specifier);
if (resolved === undefined) continue;
const target = repositoryPath(root, resolved);
const boundary = failedBoundary(importer, target, typeOnly);
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
+5
View File
@@ -0,0 +1,5 @@
export function withVerifierWorkspace<T>(
operation: () => T | Promise<T>,
commands: { close(): Promise<void> },
workspace: () => string | undefined,
): Promise<T>;
+34
View File
@@ -0,0 +1,34 @@
import { rm } from "node:fs/promises";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { importVerifierSource } from "./verifier-source-loader.mjs";
/** Keep the workspace and its owned commands together until cleanup completes. */
export async function withVerifierWorkspace(operation, commands, workspace) {
const { AnalysisCleanupLease } = await importVerifierSource(
new URL("../../src/artifacts/AnalysisCleanupLease.ts", import.meta.url)
.href,
import.meta.url,
);
let lease;
const close = async () => {
const root = workspace();
if (root === undefined) return commands.close();
lease ??= new AnalysisCleanupLease(async () => {
await commands.close();
await rm(root, { recursive: true, force: true });
}, [root]);
await lease.release();
};
try {
return await withVerifierCleanup(operation, [close]);
} catch (cause) {
if (lease !== undefined && !lease.released)
throw Object.assign(
new Error(`Verifier workspace cleanup incomplete: ${workspace()}`, {
cause,
}),
{ cleanupLeases: [lease] },
);
throw cause;
}
}
@@ -170,7 +170,7 @@ def main():
medians = {name: {'runs': len(group), **{field: statistics.median(record[field] for record in group) for field in fields}} for name, group in groups.items()}
assert all(len(group) == 3 for group in groups.values()) and len(groups) == 6
recorded_runtime = json.loads((args.measurement_dir / 'runtime.json').read_text())
paths = ['scripts/verify/analysis/source-location-delivery/task.mjs', 'scripts/verify/analysis/budget-calibration-run.py', 'scripts/rea.mjs', 'dist/cli.js', 'dist/mcp/main.js', 'src/application/WebSourceLocationService.ts', 'src/domain/analysisView/webSourceLocationView.ts', 'src/application/analysisView/AnalysisViewService.ts', 'src/contracts/webScriptToolContracts.ts', 'src/server/ToolResultDelivery.ts', 'src/server/toolResult.ts', 'src/domain/sourceMapLimits.ts', 'src/server/registerWebSourceLocationTool.ts', 'src/cli/webSourceLocationCommands.ts', 'scripts/verify/analysis/source-location-delivery/aggregate.py', 'package-lock.json', 'docs/verification/source-map-budgets-2026-11-09/artifacts.json', 'scripts/lib/verifier-cleanup.mjs', 'scripts/lib/verifier-source-loader.mjs', 'src/process/ProviderProcess.ts']
paths = ['scripts/verify/analysis/source-location-delivery/task.mjs', 'scripts/verify/analysis/budget-calibration-run.py', 'scripts/rea.mjs', 'dist/cli.js', 'dist/mcp/main.js', 'src/application/WebSourceLocationService.ts', 'src/domain/analysisView/webSourceLocationView.ts', 'src/application/analysisView/AnalysisViewService.ts', 'src/contracts/webScriptToolContracts.ts', 'src/server/ToolResultDelivery.ts', 'src/server/toolResult.ts', 'src/domain/sourceMapLimits.ts', 'src/server/registerWebSourceLocationTool.ts', 'src/cli/webSourceLocationCommands.ts', 'scripts/verify/analysis/source-location-delivery/aggregate.py', 'package-lock.json', 'docs/verification/source-map-budgets-2026-11-09/artifacts.json']
hashes = {path: sha256(repo / path) for path in paths}
assert hashes[paths[0]] == recorded_runtime['driver_sha256']
assert hashes[paths[1]] == recorded_runtime['observer_sha256']
@@ -3,8 +3,6 @@ import { readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import assert from "node:assert/strict";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { withVerifierCleanup } from "../../../lib/verifier-cleanup.mjs";
import { importVerifierSource } from "../../../lib/verifier-source-loader.mjs";
// Acquire artifactDir with docs/verification/source-map-budgets-2026-11-09/acquire.py.
// This driver runs the public compiled stdio workflow; no target application is executed.
@@ -101,10 +99,6 @@ assert.equal(bytes.length, source.bytes);
assert.equal(createHash("sha256").update(bytes).digest("hex"), source.sha256);
const artifact = JSON.parse(bytes.toString("utf8"));
const manifestPath = localFile(source.generated_script.path + ".manifest.json");
const { ProviderProcessSupervisor } = await importVerifierSource(
new URL("../../../../src/process/ProviderProcess.ts", import.meta.url).href,
import.meta.url,
);
const child = spawn(process.execPath, [join(root, "scripts/rea.mjs"), "mcp"], {
cwd: root,
env: {
@@ -121,14 +115,6 @@ const child = spawn(process.execPath, [join(root, "scripts/rea.mjs"), "mcp"], {
},
stdio: ["pipe", "pipe", "pipe"],
});
const supervisor = new ProviderProcessSupervisor(
{ process: child, ownsProcessLifetime: true },
{
captureStdout: false,
// Protocol and bounded stderr receipts remain owned by this driver.
maxDiagnosticBytes: 0,
},
);
let buffer = "",
sequence = 0,
stderr = "";
@@ -213,7 +199,7 @@ const contentOf = (r) => {
assert(!r.isError, JSON.stringify(errorOf(r)));
return r.structuredContent;
};
await withVerifierCleanup(async () => {
try {
await rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
@@ -424,24 +410,17 @@ await withVerifierCleanup(async () => {
{ flag: "wx" },
);
console.log(JSON.stringify(result));
}, [
async () => {
child.stdin.end();
const exited = await supervisor.waitForExit(5000);
const stopped = await supervisor.stop();
if (stopped.status === "incomplete")
throw Object.assign(
new Error(`MCP child cleanup incomplete: ${stopped.reason}`, {
cause: stopped,
}),
{ cleanupOwner: supervisor },
);
assert(exited, "MCP close timeout");
assert(supervisor.observedOutputClose(), "MCP output did not close");
const exit = supervisor.observedExit();
assert(
exit?.code === 0 && exit.signal === null,
`MCP server exited (${String(exit?.code)}, ${String(exit?.signal)}): ${stderr}`,
);
},
]);
} finally {
child.stdin.end();
if (child.exitCode === null && child.signalCode === null)
await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(Error("MCP close timeout"));
}, 5000);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
+21
View File
@@ -1,3 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -193,4 +195,23 @@ if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);
+6 -11
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { withVerifierWorkspace } from "../../lib/verifier-workspace.mjs";
import {
assertAgrees,
buildElf,
@@ -392,7 +392,7 @@ const damaged = async (report) => {
);
};
await withVerifierCleanup(async () => {
const verify = async () => {
const tools = await checkTools("verify:esp-flash");
const espIdf = await checkEspIdf();
workspace = await mkdtemp(join(tmpdir(), "rea-esp-flash-proof-"));
@@ -409,11 +409,6 @@ await withVerifierCleanup(async () => {
await esp32Origin(report);
await damaged(report);
console.log(JSON.stringify(report, null, 2));
}, [
// Remove outputs only after every owned command tree has stopped.
async () => {
await commands.close();
if (workspace !== undefined)
await rm(workspace, { recursive: true, force: true });
},
]);
};
await withVerifierWorkspace(verify, commands, () => workspace);
+6 -12
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { withVerifierWorkspace } from "../../lib/verifier-workspace.mjs";
import {
assertAgrees,
buildElf,
@@ -27,7 +27,7 @@ import {
let workspace;
const path = (name) => join(workspace, name);
await withVerifierCleanup(async () => {
const verify = async () => {
const { compilerVersion, esptoolVersion, xtensaVersion } = await checkTools();
workspace = await mkdtemp(join(tmpdir(), "rea-esp-image-proof-"));
const report = {
@@ -407,12 +407,6 @@ await withVerifierCleanup(async () => {
);
report.images.push({ name: "esp8266-0x00000.bin", refused: true });
console.log(JSON.stringify(report, null, 2));
}, [
// Remove outputs only after every owned command tree has stopped; a failed
// close keeps the workspace for inspection.
async () => {
await commands.close();
if (workspace !== undefined)
await rm(workspace, { recursive: true, force: true });
},
]);
};
await withVerifierWorkspace(verify, commands, () => workspace);
+6 -10
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createVerifierCommands } from "../../lib/verify-commands.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { withVerifierWorkspace } from "../../lib/verifier-workspace.mjs";
// Optional verification tool only; the UF2 reader starts no tool.
const python = process.env.REA_ESPTOOL_PYTHON ?? "python3";
@@ -263,7 +263,7 @@ const checkLostBlock = async () => {
};
};
await withVerifierCleanup(async () => {
const verify = async () => {
const versionText = await tool(["version"]);
const version = /\bv?(\d+)\.(\d+)\.(\d+)\b/u.exec(versionText);
assert.ok(version, `Unrecognized esptool version: ${versionText}`);
@@ -275,10 +275,6 @@ await withVerifierCleanup(async () => {
report.cases.push(await check("concatenated.uf2", CASES));
report.cases.push(await checkLostBlock());
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}, [
() => commands.close(),
() =>
workspace === undefined
? undefined
: rm(workspace, { recursive: true, force: true }),
]);
};
await withVerifierWorkspace(verify, commands, () => workspace);
+66
View File
@@ -0,0 +1,66 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
/** Statically declared environment seeds owned by ANALYSIS_SETTINGS. */
export function parseRuntimeSettingEnvironments(source: string): Set<string>;
/** Detect stale or newly introduced temporary per-call exceptions. */
export function perCallLimitFailures(
listed: ReadonlyMap<string, string>,
temporary: ReadonlySet<string>,
): string[];
/** Parse static registry declarations, including external-reader provenance. */
export function parseNumericLimitRegistry(source: string): Array<{
name: string;
update_mode: "restart";
policy: "restart-only" | "per-call";
read: "startup" | "per-call" | "construction";
reason: string;
provenance?: {
kind: "external-reader" | "generated-control";
source: string;
witness: string;
generator?: string;
generatorWitness?: string;
};
}>;
/** Producer paths for numeric limits. Registry text, comments, and allowlists are omitted. */
export function discoverNumericLimitUses(
files: readonly { readonly path: string; readonly source: string }[],
): Map<
string,
{
readonly path: string;
read: "module" | "construction" | "unknown";
readonly sites: Array<{
readonly path: string;
readonly read: "module" | "construction" | "unknown";
readonly offset: number;
readonly evidence: "direct" | "candidate";
}>;
}
> & {
readonly unresolved: readonly {
readonly path: string;
readonly offset: number;
readonly reason: string;
}[];
};
/** Why a tracked limit is not an analysis setting, when it is present. */
export function requiredRuntimeLimitFailure(
name: string,
presence: { readonly inRuntime: boolean; readonly inRegistry: boolean },
): string | undefined;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -36,7 +36,8 @@ it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", ()
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
});
expect(projected).toMatchObject({
+288 -2
View File
@@ -1,8 +1,20 @@
import { expect, it } from "vitest";
import { AnalysisSettings } from "./AnalysisSettings.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
runtimeAnalysisConfig,
runtimeAsarHeaderAdmission,
runtimeCaptureDecoderHeap,
} from "../config/runtimeAnalysisSettings.js";
import { parseConfig } from "../config/parseConfig.js";
@@ -151,6 +163,17 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB: "64",
};
const store = new AnalysisSettings(environment);
for (const provider of ["ghidra", "hopper"] as const)
expect(
store
.inspect({ provider, operation: "open_binary" })
.settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
`${provider}.startup_timeout_ms`,
"bridge.max_request_bytes",
]),
);
const parsed = parseConfig(environment);
if (!parsed.ok) throw parsed.error;
const config = runtimeAnalysisConfig(parsed.value, store.read);
@@ -159,7 +182,13 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ provider: "ghidra", operation: "search_strings" })
.settings.map(({ setting }) => setting),
).toEqual(["ghidra.startup_timeout_ms"]);
).toEqual(["ghidra.startup_timeout_ms", "bridge.max_request_bytes"]);
for (const operation of ["list_cutter_sessions", "cutter_command"] as const)
expect(
store
.inspect({ provider: "cutter", operation })
.settings.map(({ setting }) => setting),
).toContain("bridge.max_request_bytes");
expect(
store
.inspect({ provider: "ida", operation: "search_strings" })
@@ -169,7 +198,51 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ operation: "inventory_artifact" })
.settings.map(({ setting }) => setting),
).toEqual(["asar.max_listing_bytes", "asar.max_header_bytes"]);
).toEqual([
"asar.max_listing_bytes",
"asar.max_header_bytes",
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]);
for (const operation of [
"open_binary",
"export_web_scripts",
"inspect_analysis_view",
"inspect_android_package",
"capture_process_scenario",
]) {
expect(
store.inspect({ operation }).settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]),
);
}
expect(
store
.inspect({ provider: "jadx", operation: "inspect_android_package" })
.settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]),
);
for (const operation of [
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
] as const)
expect(
store
.inspect({ provider: "kuna", operation })
.settings.map(({ setting }) => setting),
).toContain("kuna.project_timeout_ms");
expect(
store.update({
changes: [
@@ -217,3 +290,216 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
).toBe(true);
expect(config.ghidraDecompileTimeoutSeconds).toBe(7);
});
it("rejects an out-of-range per-call limit and reads an accepted value from the operation snapshot", () => {
const store = new AnalysisSettings({});
const rejected = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 0 },
],
});
expect(rejected.ok).toBe(false);
if (!rejected.ok)
expect(projectAnalysisError(rejected.error)).toMatchObject({
code: "invalid_request",
});
expect(
store.update({
changes: [
{ setting: "bridge.max_request_bytes", action: "set", value: 1 },
],
}).ok,
).toBe(false);
expect(
store.update({
changes: [
{
setting: "windows.change_time_settle_max_polls",
action: "set",
value: 1001,
},
],
}).ok,
).toBe(false);
const accepted = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 5000 },
{ setting: "pwntools.max_input_bytes", action: "set", value: 65536 },
{ setting: "bridge.max_request_bytes", action: "set", value: 33554432 },
],
});
expect(accepted.ok).toBe(true);
if (!accepted.ok) return;
expect(accepted.value).toMatchObject({
pending_restart: false,
});
const parsed = parseConfig({});
if (!parsed.ok) throw parsed.error;
const live = runtimeAnalysisConfig(parsed.value, store.read);
store.run(() => {
const windows = resolveWindowsChangeTimeSettle({});
if (!windows.ok) throw windows.error;
expect(applyWindowsChangeTimeSnapshot(windows.value)).toMatchObject({
driftMs: 5000,
driftSource: "runtime",
pollSource: "default",
});
expect(pwntoolsInputBytes({})).toMatchObject({
configured: true,
bytes: 65536,
source: "runtime",
});
expect(live.bridgeMaxRequestBytes).toMatchObject({
maximumBytes: 33554432,
source: "runtime",
});
expect(live.hopperStartupTimeoutSource).toBe("default");
});
expect(pwntoolsInputBytes({})).toMatchObject({
bytes: 32 * 1024 * 1024,
source: "default",
});
});
it("reads an accepted override before an invalid launch seed", () => {
const resolved = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
"asar.max_header_bytes": 8192,
"asar.max_listing_bytes": 4096,
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
deadline: resolveRegexMatchingDeadline({
REA_REGEX_MATCHING_DEADLINE_MS: "nope",
}),
heap: resolveRegexWorkerHeapRequest({
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "1",
}),
asar: runtimeAsarHeaderAdmission({
REA_ASAR_MAX_HEADER_BYTES: "nope",
REA_ASAR_MAX_LISTING_BYTES: "0",
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
});
expect(resolved.deadline).toMatchObject({
deadlineMs: 250,
source: "runtime",
});
expect(resolved.heap).toMatchObject({ heapMiB: 128, source: "runtime" });
expect(resolved.asar).toMatchObject({
maximumBytes: 8192,
source: "runtime",
listing: { maximumBytes: 4096, source: "runtime" },
});
});
it("keeps the Hopper startup deadline source through a runtime override", () => {
const parsed = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: "45000" });
if (!parsed.ok) throw parsed.error;
const store = new AnalysisSettings({});
const live = runtimeAnalysisConfig(parsed.value, store.read);
expect(live.hopperStartupTimeoutSource).toBe("setting");
expect(
store.update({
changes: [
{ setting: "hopper.startup_timeout_ms", action: "set", value: 8000 },
],
}).ok,
).toBe(true);
store.run(() => {
expect(live.hopperStartupTimeoutMs).toBe(8000);
expect(live.hopperStartupTimeoutSource).toBe("runtime");
});
});
it("retains invalid ASAR seeds across long-lived views and override resets", async () => {
const environment = {
REA_ASAR_MAX_HEADER_BYTES: "invalid",
REA_ASAR_MAX_LISTING_BYTES: "invalid",
};
const store = new AnalysisSettings(environment);
const admission = runtimeAsarHeaderAdmission(environment, store.read);
expect(() => admission.maximumBytes).toThrow("REA_ASAR_MAX_HEADER_BYTES");
expect(() => admission.listing?.source).toThrow("REA_ASAR_MAX_LISTING_BYTES");
store.update({
changes: [
{ setting: "asar.max_header_bytes", action: "set", value: 8192 },
{ setting: "asar.max_listing_bytes", action: "set", value: 4096 },
],
});
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const accepted = store.run(async () => {
await barrier;
expect(admission).toMatchObject({
maximumBytes: 8192,
source: "runtime",
listing: { maximumBytes: 4096, source: "runtime" },
});
});
store.update({
changes: [
{ setting: "asar.max_header_bytes", action: "reset" },
{ setting: "asar.max_listing_bytes", action: "reset" },
],
});
release();
await accepted;
for (const read of [() => admission.maximumBytes, () => admission.source])
expect(read).toThrow("REA_ASAR_MAX_HEADER_BYTES");
for (const read of [
() => admission.listing?.maximumBytes,
() => admission.listing?.source,
])
expect(read).toThrow("REA_ASAR_MAX_LISTING_BYTES");
});
it("retains an invalid capture heap seed through override and reset", () => {
const environment = { REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "invalid" };
const store = new AnalysisSettings(environment);
const heap = runtimeCaptureDecoderHeap(environment, store.read);
expect(() => heap.oldGenerationMiB).toThrow(
"REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
);
store.update({
changes: [
{
setting: "capture.decoder_old_generation_mib",
action: "set",
value: 64,
},
],
});
expect(heap).toMatchObject({ oldGenerationMiB: 64, source: "runtime" });
store.update({
changes: [
{ setting: "capture.decoder_old_generation_mib", action: "reset" },
],
});
expect(() => heap.oldGenerationMiB).toThrow(
"REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
);
expect(() => heap.source).toThrow("REA_CAPTURE_DECODER_OLD_GENERATION_MIB");
});
+9 -2
View File
@@ -1,3 +1,4 @@
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import { AsyncLocalStorage } from "node:async_hooks";
@@ -42,7 +43,9 @@ export class AnalysisSettings {
readonly read = (): AnalysisSettingsSnapshot =>
this.#accepted.getStore() ?? this.#current;
run<Value>(operation: () => Value): Value {
return this.#accepted.run(this.#current, operation);
return this.#accepted.run(this.#current, () =>
runWithAnalysisOperationSnapshot(this.#current, operation),
);
}
inspect(input: z.infer<typeof inspectAnalysisSettingsInputSchema> = {}) {
return {
@@ -134,7 +137,7 @@ export class AnalysisSettings {
};
}),
active_work_changed: false as const,
rea_restart_required: false as const,
pending_restart: false as const,
});
}
#describe(setting: AnalysisSettingId) {
@@ -156,6 +159,10 @@ export class AnalysisSettings {
minimum: definition.minimum,
maximum: definition.maximum,
applies_at: definition.appliesAt,
update_mode: definition.updateMode,
...(override === undefined && !launch.ok
? {}
: { pending_restart: false as const }),
provider: definition.provider,
operations: [...definition.operations],
...(definition.environment === undefined
@@ -1,8 +1,13 @@
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
} from "../domain/analysisResourceLimits.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -318,6 +323,56 @@ const validatedDetails = (
return ok(environmentDetails(environment, validated.value, unresolved));
};
const admittedTimeout = (
id: Parameters<typeof launchAnalysisSetting>[0],
environment: Readonly<Record<string, string | undefined>>,
fallback: number,
): number => {
const parsed = launchAnalysisSetting(id, environment);
return parsed.ok && parsed.value.value !== null
? parsed.value.value
: fallback;
};
const registrationTimeoutInputs = (
environment: Readonly<Record<string, string>>,
): Pick<
McpToolTimeoutInputs,
| "ghidraStartupTimeoutMs"
| "hopperStartupTimeoutMs"
| "ghidraDecompileTimeoutMs"
| "jebMcpTimeoutMs"
> => {
const decompile = launchAnalysisSetting(
"ghidra.decompile_timeout_seconds",
environment,
);
const jeb = launchAnalysisSetting("jeb.mcp_timeout_ms", environment);
return {
ghidraStartupTimeoutMs: admittedTimeout(
"ghidra.startup_timeout_ms",
environment,
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
),
hopperStartupTimeoutMs: admittedTimeout(
"hopper.startup_timeout_ms",
environment,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
...(decompile.ok &&
decompile.value.source === "setting" &&
decompile.value.value !== null
? { ghidraDecompileTimeoutMs: decompile.value.value * 1000 }
: {}),
...(jeb.ok &&
jeb.value.source === "setting" &&
jeb.value.value !== null &&
environment.REA_JEB_MCP_TIMEOUT_MS !== undefined
? { jebMcpTimeoutMs: jeb.value.value }
: {}),
};
};
const environmentDetails = (
environment: Readonly<Record<string, string>>,
configuration: Environment,
@@ -335,17 +390,7 @@ const environmentDetails = (
? undefined
: {
analysisProvider: configuration.REA_ANALYSIS_PROVIDER,
ghidraStartupTimeoutMs: configuration.REA_GHIDRA_STARTUP_TIMEOUT_MS,
...(configuration.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS === undefined
? {}
: {
ghidraDecompileTimeoutMs:
configuration.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS * 1000,
}),
hopperStartupTimeoutMs: configuration.REA_HOPPER_STARTUP_TIMEOUT_MS,
...(configuration.REA_JEB_MCP_TIMEOUT_MS === undefined
? {}
: { jebMcpTimeoutMs: configuration.REA_JEB_MCP_TIMEOUT_MS }),
...registrationTimeoutInputs(environment),
},
});
+26 -2
View File
@@ -34,7 +34,10 @@ import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import { bindRegularFileCleanup } from "./RegularFileRead.js";
import {
bindRegularFileCleanup,
regularFileCleanupLeases,
} from "./RegularFileRead.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
@@ -56,6 +59,23 @@ export interface TextWriteCancellation {
type TextWriteResult<Failure = EvidenceFileError | AnalysisCancelledError> =
Result<{ readonly path: string; readonly bytes: number }, Failure>;
// The JSON read owner records the exact leases it binds; consumers never
// infer cleanup ownership from matching resource labels.
const jsonReadLeases = new WeakMap<
EvidenceFileError,
readonly AnalysisCleanupLease[]
>();
export const jsonFileReadCleanupLeases = (
error: EvidenceFileError,
): readonly AnalysisCleanupLease[] | undefined => jsonReadLeases.get(error);
const rememberJsonReadCleanup = (
error: EvidenceFileError,
cause: unknown,
): void => {
const leases = regularFileCleanupLeases(cause);
if (leases !== undefined) jsonReadLeases.set(error, leases);
};
/** Read JSON data without changing the caller's existing data-only contract. */
export const readJsonFile = async (
path: string,
@@ -173,6 +193,7 @@ const readJsonFileInput = async <Value>(
: childFailureDiagnostics({ primary_error: { error: primary } })),
});
bindRegularFileCleanup(cause, error);
rememberJsonReadCleanup(error, cause);
return err(error);
}
return err(jsonFileReadFailure(cause, requestedPath, operation, signal));
@@ -247,6 +268,7 @@ const jsonFileCleanupError = <Value>(
},
});
bindRegularFileCleanup(failure, cleanup);
rememberJsonReadCleanup(cleanup, failure);
const cancellation = regularFileCancellation(failure);
let primary: AnalysisError | undefined;
if (failure.outcome.kind === "failed") {
@@ -263,7 +285,7 @@ const jsonFileCleanupError = <Value>(
});
}
if (primary === undefined) return cleanup;
return new EvidenceFileError("read", "io", {
const combined = new EvidenceFileError("read", "io", {
path,
cause: new AggregateError(
[primary, cleanup],
@@ -274,6 +296,8 @@ const jsonFileCleanupError = <Value>(
cleanup: cleanup.cleanup,
...childFailureDiagnostics({ primary_error: { error: primary } }),
}).bindCleanupDependencies([primary, cleanup]);
if (!primary.cleanupIncomplete) rememberJsonReadCleanup(combined, failure);
return combined;
};
const isJsonReadCancellation = (
+21 -11
View File
@@ -263,33 +263,43 @@ export const importReferenceSource = async (
};
const combineReadFailures = (
primary: ReferenceSourceReaderError,
other: ReferenceSourceReaderError | undefined,
inventory: ReferenceSourceReaderError,
metadata: ReferenceSourceReaderError | undefined,
partial: ReferenceSourceRead | undefined,
): ReferenceSourceImportError => {
const code =
primary.code === "cancelled" || other?.code === "cancelled"
? "cancelled"
: primary.code;
const primary =
inventory.code === "cancelled" &&
metadata !== undefined &&
metadata.code !== "cancelled"
? metadata
: inventory;
const other = primary === inventory ? metadata : inventory;
const cleanup = mergeCleanup(primary.cleanup, other?.cleanup);
const message =
other === undefined
other === undefined || other.code === "cancelled"
? primary.message
: `${primary.message}; additional reference metadata failure: ${other.message}`;
: `${primary.message}; additional reference failure: ${other.message}`;
const cause =
other === undefined
other === undefined || other.code === "cancelled"
? primary.cause
: new AggregateError(
[primary, other],
"Reference source inventory and metadata both failed",
{ cause: primary },
);
const secondaryCancellation =
primary.secondaryCancellation ??
other?.secondaryCancellation ??
(other?.code === "cancelled" && primary.code !== "cancelled"
? { cause: other.cause }
: undefined);
return {
...failure(code, message),
...(primary.remediationAction === undefined || code !== primary.code
...failure(primary.code, message),
...(primary.remediationAction === undefined
? {}
: { remediationAction: primary.remediationAction }),
...(cleanup === undefined ? {} : { cleanup }),
...(secondaryCancellation === undefined ? {} : { secondaryCancellation }),
...(partial === undefined ? {} : { partial }),
...(cause === undefined ? {} : { cause }),
};
+21 -3
View File
@@ -106,10 +106,10 @@ const resolveRoot = async (
});
return ok(canonicalRoot);
} catch (cause: unknown) {
if (isAborted(signal))
if (signal?.aborted === true && cause === signal.reason)
return err({
...failure("cancelled", "Reference source import cancelled"),
cause: signal?.reason,
cause: signal.reason,
});
const rootFailure = rootFilesystemFailure(
cause,
@@ -122,6 +122,9 @@ const resolveRoot = async (
? {}
: { remediationAction: rootFailure.remediationAction }),
cause,
...(signal?.aborted === true
? { secondaryCancellation: { cause: signal.reason } }
: {}),
});
}
};
@@ -182,6 +185,12 @@ const readPolicyFile = async (
),
cleanup: retry.cleanup,
cause: primaryCause,
...(signal !== undefined &&
isAborted(signal) &&
!isIgnorePolicyCancellation(primary) &&
operationCancellation === undefined
? { secondaryCancellation: { cause: signal.reason } }
: {}),
});
}
if (retry.outcome.kind === "failed") {
@@ -202,6 +211,9 @@ const readPolicyFile = async (
`Reference source policy could not be read at ${policyPath}: ${errorMessage(causeValue)}`,
),
cause: causeValue,
...(signal?.aborted === true && !isIgnorePolicyCancellation(cause)
? { secondaryCancellation: { cause: signal.reason } }
: {}),
});
}
};
@@ -253,7 +265,10 @@ const readIgnoreCase = async (
);
} catch (cause: unknown) {
if (readFailure !== undefined) return err(readFailure);
if (isAborted(signal) || isIgnorePolicyCancellation(cause))
if (
isIgnorePolicyCancellation(cause) ||
(signal?.aborted === true && cause === signal.reason)
)
return err({
...failure("cancelled", "Reference source import cancelled"),
cause:
@@ -265,6 +280,9 @@ const readIgnoreCase = async (
`Reference source Git policy could not be read: ${errorMessage(cause)}`,
),
cause,
...(signal?.aborted === true
? { secondaryCancellation: { cause: signal.reason } }
: {}),
});
}
};
@@ -38,6 +38,23 @@ describe("reference-source import error projection", () => {
}
});
it("projects a later cancellation without replacing a physical failure", () => {
const reason = { caller: "stop after I/O failure" };
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: "io",
message: "directory read failed",
secondaryCancellation: { cause: reason },
});
expect(projected.category).toBe("execution_failure");
expect(projected.details?.secondary_failures).toEqual([
expect.objectContaining({ category: "cancelled" }),
]);
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
});
it("preserves cancellation, partial facts, and cleanup diagnostics", () => {
const bytes = Buffer.from("export const observed = true;\n");
const partial: ReferenceSourceRead = {
+27 -8
View File
@@ -4,6 +4,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
@@ -25,6 +26,7 @@ export interface ReferenceSourceImportError {
readonly cleanup?: AnalysisCleanupObservation;
readonly partial?: ReferenceSourceRead;
readonly cause?: unknown;
readonly secondaryCancellation?: { readonly cause: unknown };
}
const IMPORT_FAILURE_GUIDANCE = {
@@ -71,14 +73,16 @@ export const referenceSourceImportAnalysisError = (
}),
},
};
let primary: AnalysisError;
switch (error.code) {
case "cancelled":
return new AnalysisCancelledError("import_reference_source", {
primary = new AnalysisCancelledError("import_reference_source", {
...options,
userMessage: message,
});
break;
case "invalid-root":
return new AnalysisInputError(
primary = new AnalysisInputError(
"import_reference_source",
{
...options,
@@ -89,21 +93,36 @@ export const referenceSourceImportAnalysisError = (
},
[{ path: ["root"], reason: "invalid_value", message: error.message }],
);
break;
case "unsupported":
return new AnalysisCapabilityUnavailableError(
primary = new AnalysisCapabilityUnavailableError(
"filesystem",
"import_reference_source",
error.message,
{ ...options, userMessage: message, remediationAction: guidance },
);
break;
case "io":
case "parse":
return new AnalysisOutputError("import_reference_source", error.message, {
...options,
userMessage: message,
remediationAction: guidance,
});
primary = new AnalysisOutputError(
"import_reference_source",
error.message,
{
...options,
userMessage: message,
remediationAction: guidance,
},
);
break;
}
return error.secondaryCancellation === undefined
? primary
: analysisErrorWithCancellation(
primary,
new AnalysisCancelledError("import_reference_source", {
cause: error.secondaryCancellation.cause,
}),
);
};
/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */
+45 -6
View File
@@ -4,8 +4,15 @@ import { locateReferenceSourceGitDirectory } from "./ReferenceSourceGitDirectory
import { err, ok, type Result } from "../domain/result.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { readRegularFile, retryRegularFileCleanup } from "./RegularFileRead.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import {
readRegularFile,
RegularFileReadFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
import { referenceSourceResourceFailure } from "../reference/ReferenceSourceReaderErrors.js";
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
@@ -39,7 +46,12 @@ export const readReferenceSourceVcs = async (
});
} catch (cause: unknown) {
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
return err(referenceSourceResourceFailure(cause));
return err({
...referenceSourceResourceFailure(cause),
...(cause.reason !== "cancelled" && signal?.aborted === true
? { secondaryCancellation: { cause: signal.reason } }
: {}),
});
}
};
@@ -63,7 +75,25 @@ const resolveSourceVcs = async (
return await readRegularFile(path, { signal });
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
if (!(cause instanceof RegularFileCleanupFailure)) {
if (
signal?.aborted === true &&
regularFileCancellation(cause) === undefined
) {
const physical =
cause instanceof RegularFileReadFailure
? cause.operationCause
: cause;
// The Git directory owner interprets absence of optional metadata.
if (errorCode(physical) === "ENOENT") throw physical;
throw new ArtifactReaderFailure(
"io",
`Git metadata could not be read safely: ${errorMessage(physical)}`,
{ cause: physical },
);
}
throw cause;
}
const retry = await retryRegularFileCleanup(cause, resources, signal);
if (
retry.outcome.kind === "failed" &&
@@ -81,7 +111,10 @@ const resolveSourceVcs = async (
? retry.outcome.cause
: cause.cleanupCause;
throw new ArtifactReaderFailure(
isAborted(signal) ? "cancelled" : "io",
regularFileCancellation(cause) !== undefined ||
(signal?.aborted === true && primary === signal.reason)
? "cancelled"
: "io",
`Git metadata could not be read safely: ${errorMessage(primary)}`,
{
cause: primary,
@@ -97,7 +130,11 @@ const resolveSourceVcs = async (
failure.cleanup === undefined ? [] : [failure.cleanup],
);
throw new ArtifactReaderFailure(
isAborted(signal) ? "cancelled" : "io",
(primary instanceof ArtifactReaderFailure &&
primary.reason === "cancelled") ||
(signal?.aborted === true && primary === signal.reason)
? "cancelled"
: "io",
`Git metadata could not be read safely: ${errorMessage(primary)}`,
{
cause: new AggregateError(
@@ -155,6 +192,8 @@ const resolveSourceVcs = async (
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure) {
cleanupFailures.push(cause);
if (cause.reason !== "cancelled")
failedRead ??= { cause: cause.cause ?? cause };
} else if (errorCode(cause) !== "ENOENT") failedRead ??= { cause };
throw cause;
}
+10 -6
View File
@@ -53,16 +53,20 @@ export class RetainedRegularFileCleanupFailure<
}
/** Bind only explicit cleanup capabilities, never resource labels or opaque causes. */
export const regularFileCleanupLeases = (
cause: unknown,
): readonly AnalysisCleanupLease[] | undefined =>
cause instanceof RetainedRegularFileCleanupFailure
? [cause.cleanupLease]
: cause instanceof ArtifactReaderFailure
? cause.cleanupLeases
: undefined;
export const bindRegularFileCleanup = (
cause: unknown,
error: AnalysisError,
): void => {
const leases =
cause instanceof RetainedRegularFileCleanupFailure
? [cause.cleanupLease]
: cause instanceof ArtifactReaderFailure
? cause.cleanupLeases
: undefined;
const leases = regularFileCleanupLeases(cause);
if (leases !== undefined) new AnalysisCleanupLeaseGroup(error, leases);
};
+11
View File
@@ -17,6 +17,7 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
@@ -323,6 +324,16 @@ const captureReadFailure = (
"Capture read and file-handle close both failed",
{ cause },
);
if (cause instanceof ConfigurationError) {
if (closeFailure === undefined) return cause;
return new ConfigurationError(cause.message, {
cause: causeWithClose,
settings: cause.settings,
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
}
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
+29 -6
View File
@@ -72,6 +72,7 @@ import {
} from "./BinarySessionExecution.js";
import { closeAnalysisClient } from "../AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ConfigurationError } from "../../domain/configurationErrors.js";
import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
@@ -705,11 +706,27 @@ export class BinarySession
profile = route.profile;
}
const runId = randomUUID();
const client = route.createClient(target, {
runId,
});
let client: ReturnType<typeof route.createClient>;
try {
client = route.createClient(target, {
runId,
});
} catch (cause: unknown) {
if (!(cause instanceof ConfigurationError)) throw cause;
return err(
previous === undefined
? cause
: await this.#restoreAfterFailedOpen(previous, cause),
);
}
const run: SessionRun = { client, route, runId };
const started = await client.execute("health", {}, options);
let started: Awaited<ReturnType<typeof client.execute>>;
try {
started = await client.execute("health", {}, options);
} catch (cause: unknown) {
if (!(cause instanceof ConfigurationError)) throw cause;
return err(await this.#failedOpen(run, previous, cause));
}
if (!started.ok) {
return err(await this.#failedOpen(run, previous, started.error));
}
@@ -1562,8 +1579,14 @@ export class BinarySession
// Stop replay before dispatch, including reads overlapping the write.
if (capability.effects.mutatesArtifact) this.invalidateSnapshot();
const dispatch = this.#runtimeAvailability.dispatch();
const call = active.client.execute(name, arguments_, options);
const result = unrecordedExecution(await call);
let dispatched: Awaited<ReturnType<typeof active.client.execute>>;
try {
dispatched = await active.client.execute(name, arguments_, options);
} catch (cause: unknown) {
if (cause instanceof ConfigurationError) return err(cause);
throw cause;
}
const result = unrecordedExecution(dispatched);
const profiled = bindExecutionTarget(
commitExecutionProfile(name, result, profile),
name,
@@ -1,6 +1,7 @@
import { discoverJavaScriptPreloadPaths } from "./JavaScriptPreloadDiscovery.js";
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { AnalysisSettingValues } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import {
DEFAULT_ASAR_HEADER_ADMISSION,
@@ -181,18 +182,24 @@ export const reconstructJavaScriptArtifact = async (
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const heap = selectedAnalysisSetting(
input.max_heap_mb,
overrides?.["javascript.max_heap_mb"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
);
const timeout = selectedAnalysisSetting(
input.analysis_timeout_ms,
overrides?.["javascript.analysis_timeout_ms"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
);
const sourceLimits = {
...(input.semantic_precision === undefined
? {}
: { semanticPrecision: input.semantic_precision }),
heapMb:
input.max_heap_mb ??
overrides?.["javascript.max_heap_mb"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
timeoutMs:
input.analysis_timeout_ms ??
overrides?.["javascript.analysis_timeout_ms"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
heapMb: heap.value,
heapSource: heap.source,
timeoutMs: timeout.value,
timeoutSource: timeout.source,
};
const preloads = await discoverJavaScriptPreloadPaths(
files,
+195 -38
View File
@@ -31,12 +31,61 @@ import {
type ProcessCaptureEvidenceFailure,
} from "./ProcessEvidence.js";
import { prepareProcessScenarioPlan } from "./ProcessScenarioPreparation.js";
import { readJsonFile } from "../JsonFiles.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { readJsonFile, jsonFileReadCleanupLeases } from "../JsonFiles.js";
import {
ArtifactReaderFailure,
artifactPrimaryCleanupLeases,
} from "../../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
/** Preserve the owning reader's facts when retaining a different cause. */
const artifactFailureFacts = (failure: ArtifactReaderFailure) => ({
...(failure.userMessage === undefined
? {}
: { userMessage: failure.userMessage }),
...(failure.resolvedPath === undefined
? {}
: { resolvedPath: failure.resolvedPath }),
...(failure.boundary === undefined ? {} : { boundary: failure.boundary }),
...(failure.admission === undefined ? {} : { admission: failure.admission }),
...(failure.readerLimit === undefined
? {}
: { readerLimit: failure.readerLimit }),
...(failure.cleanup === undefined ? {} : { cleanup: failure.cleanup }),
...(failure.partialObservation === undefined
? {}
: { partialObservation: failure.partialObservation }),
...(failure.cancellation === undefined
? {}
: { cancellation: failure.cancellation }),
});
/** Keep the completed capture or primary failure while its exact owner retries. */
class RetainedProcessCaptureFailure extends ArtifactReaderFailure {
constructor(
failure: ArtifactReaderFailure,
readonly outcome: Result<Evidence, unknown>,
readonly owner: AnalysisCleanupLease,
) {
super(
failure.reason,
failure.message,
{
cause: failure,
...artifactFailureFacts(failure),
...(failure.cleanupLeases === undefined
? {}
: { cleanupLeases: failure.cleanupLeases }),
},
failure.details,
);
}
}
/** Safe process-command failure returned to the CLI adapter. */
export interface ProcessCliErrorOutput extends AnalysisErrorProjection {
readonly error: "Process command failed";
@@ -86,31 +135,70 @@ export const captureProcessScenarioFile = async (
{ minimumIntervalMs: 100 },
);
const resourceScope = new ProcessCaptureResourceScope();
const captured = await captureProcessScenario(
parsed.data,
signal,
process.platform,
environment,
undefined,
undefined,
progress,
resourceScope,
);
let captureOutcome: Result<Evidence, unknown>;
try {
const captured = await captureProcessScenario(
parsed.data,
signal,
process.platform,
environment,
undefined,
undefined,
progress,
resourceScope,
);
captureOutcome = captured.ok
? ok(createProcessCaptureEvidence(parsed.data, captured.value))
: err(captured.error);
} catch (cause: unknown) {
captureOutcome = err(cause);
}
try {
await resourceScope.close();
} catch (cause: unknown) {
if (!captured.ok && cause instanceof AnalysisError)
throw captured.error instanceof ProcessCaptureError
? captured.error.withFinalization({ cleanupFailure: cause })
: analysisErrorWithCleanupFailure(
captured.error,
cause,
"capture_process_scenario",
).bindCleanupDependencies([captured.error, cause]);
throw cause;
const owner = new AnalysisCleanupLease(() => resourceScope.close(), {
get cleanupResources() {
return cause instanceof AnalysisError
? cause.cleanupResources
: undefined;
},
});
resources.retain({
kind: "lease",
resource: "process capture resources",
lease: owner,
});
const original = captureOutcome.ok ? undefined : captureOutcome.error;
const priorIncomplete =
original instanceof AnalysisError && original.cleanupIncomplete;
const primary = captureOutcome.ok
? cause
: original instanceof AnalysisError && cause instanceof AnalysisError
? original instanceof ProcessCaptureError
? original.withFinalization({ cleanupFailure: cause })
: analysisErrorWithCleanupFailure(
original,
cause,
"capture_process_scenario",
).bindCleanupDependencies([original, cause])
: new AggregateError(
[original, cause],
"Process capture and cleanup failed",
{ cause: original },
);
const failure = ArtifactReaderFailure.withCleanup(
primary,
ArtifactReaderFailure.cleanupObservation(
cause,
"process capture resources",
),
captureOutcome.ok ? captureOutcome.value : undefined,
priorIncomplete ? undefined : [owner],
);
throw new RetainedProcessCaptureFailure(failure, captureOutcome, owner);
}
if (!captured.ok) throw captured.error;
return createProcessCaptureEvidence(parsed.data, captured.value);
if (!captureOutcome.ok) throw captureOutcome.error;
return captureOutcome.value;
},
);
@@ -283,18 +371,49 @@ const PROCESS_UNEXPECTED_FAILURE_REMEDIATION =
const projectProcessCliError = (
cause: unknown,
operation: "capture_process_scenario" | "compare_process_captures",
signal?: AbortSignal,
): ProcessCliErrorOutput => {
if (cause instanceof AnalysisError) return cliAnalysisError(cause);
const failure =
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(PROCESS_PROVIDER.id, operation, {
cause,
diagnostics: { phase: "process_cli" },
userMessage: `Process command could not complete: ${describeValidationFailure(cause)}`,
remediationAction: PROCESS_UNEXPECTED_FAILURE_REMEDIATION,
});
return cliAnalysisError(
new ProviderAdapterError(PROCESS_PROVIDER.id, operation, {
cause,
diagnostics: { phase: "process_cli" },
userMessage: `Process command could not complete: ${describeValidationFailure(cause)}`,
remediationAction: PROCESS_UNEXPECTED_FAILURE_REMEDIATION,
}),
signal?.aborted
? analysisErrorWithCancellation(
failure,
new AnalysisCancelledError(operation, { cause: signal.reason }),
)
: failure,
);
};
/** Retain a reader failure's exact cleanup proof while recording later cancellation. */
const artifactFailureWithCancellation = (
failure: ArtifactReaderFailure,
cancellation: AnalysisCancelledError,
): ArtifactReaderFailure =>
new ArtifactReaderFailure(
failure.reason,
failure.message,
{
cause: new AggregateError(
[failure, cancellation],
"Process input failed and was cancelled",
{ cause: failure },
),
...artifactFailureFacts(failure),
...(failure.cleanupLeases === undefined
? {}
: { cleanupLeases: failure.cleanupLeases }),
},
failure.details,
);
/** One invocation owns every input descriptor through final cleanup and retry. */
const withProcessInputResources = async (
operation: "capture_process_scenario" | "compare_process_captures",
@@ -337,19 +456,57 @@ const withProcessInputResources = async (
? outcome.error
: outcome.error instanceof AnalysisError
? analysisErrorWithCancellation(outcome.error, cancellation)
: new AggregateError(
[outcome.error, cancellation],
"Process input failed and was cancelled",
{ cause: outcome.error },
);
throw ArtifactReaderFailure.withCleanup(
: outcome.error instanceof ArtifactReaderFailure
? artifactFailureWithCancellation(outcome.error, cancellation)
: new AggregateError(
[outcome.error, cancellation],
"Process input failed and was cancelled",
{ cause: outcome.error },
);
// JSON input owns the exact artifact failure used to bind this error's
// cleanup group. Other AnalysisErrors may own independent resources.
const inherited =
primary instanceof EvidenceFileError
? (jsonFileReadCleanupLeases(primary) ??
artifactPrimaryCleanupLeases(primary))
: artifactPrimaryCleanupLeases(primary);
const local =
cause instanceof ArtifactReaderFailure ? cause.cleanupLeases : undefined;
const leases =
inherited === undefined || local === undefined
? undefined
: [...new Set([...inherited, ...local])];
const failure = ArtifactReaderFailure.withCleanup(
primary,
cleanup,
outcome.ok ? outcome.value : undefined,
cause instanceof ArtifactReaderFailure ? cause.cleanupLeases : undefined,
leases,
);
if (leases !== undefined || !(cause instanceof ArtifactReaderFailure))
throw failure;
// Preserve the independently owned local leases even when the primary's
// full retry authority is unknown. Do not claim they cover both failures.
throw new ArtifactReaderFailure(
failure.reason,
failure.message,
{
cause: new AggregateError(
[failure, cause],
"Process and input cleanup remain incomplete",
),
...artifactFailureFacts(failure),
},
failure.details,
);
}
if (!outcome.ok) return projectProcessCliError(outcome.error, operation);
if (
!outcome.ok &&
outcome.error instanceof RetainedProcessCaptureFailure &&
outcome.error.owner.released
)
outcome = outcome.error.outcome;
if (!outcome.ok)
return projectProcessCliError(outcome.error, operation, signal);
const completed = completeProviderEvidence(outcome.value, signal);
return completed.ok ? completed.value : cliAnalysisError(completed.error);
};
+2
View File
@@ -15,6 +15,8 @@ export type WasmArtifactExecution = Omit<AnalysisExecution, "subject"> & {
/** Effective operation budget, separate from caller-authored Evidence parameters. */
export interface WasmArtifactExecutionOptions extends ExecutionOptions {
readonly commandTimeoutMs?: number;
/** Selection owner for `commandTimeoutMs`. Absent when the caller did not record it. */
readonly commandTimeoutSource?: "default" | "runtime" | "request" | undefined;
}
/** Replaceable offline WASM inspection engine, independent of target execution and network clients. */
@@ -240,6 +240,7 @@ it("gives cancellation precedence over malformed raw input", async () => {
it("uses runtime defaults only for omitted budgets, preserving explicit input and reset", async () => {
const settings = new AnalysisSettings({});
const accepted: number[] = [];
const sources: (string | undefined)[] = [];
const authored: (number | undefined)[] = [];
const service = new WasmArtifactService(
{
@@ -248,6 +249,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
const value = wasmArtifactFixture();
value.tool_profile.timeout_ms = options!.commandTimeoutMs!;
accepted.push(options!.commandTimeoutMs!);
sources.push(options!.commandTimeoutSource);
authored.push(input.command_timeout_ms);
return ok(wasmArtifactExecution(value));
},
@@ -275,6 +277,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
});
expect((await settings.run(() => service.inspect({ path }))).ok).toBe(true);
expect(accepted).toEqual([123000, 42000, 30000]);
expect(sources).toEqual(["runtime", "request", "default"]);
expect(authored).toEqual([undefined, 42000, undefined]);
});
+9 -6
View File
@@ -1,4 +1,5 @@
import type { AnalysisSettingsReader } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { constants as bufferConstants } from "node:buffer";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
@@ -78,13 +79,15 @@ export class WasmArtifactService {
),
);
}
const timeout =
input.value.command_timeout_ms ??
this.settings?.().overrides["wasm.command_timeout_ms"] ??
DEFAULT_WASM_COMMAND_TIMEOUT_MS;
const timeout = selectedAnalysisSetting(
input.value.command_timeout_ms,
this.settings?.().overrides["wasm.command_timeout_ms"],
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
);
const inspected = await this.provider.inspect(input.value, {
...options,
commandTimeoutMs: timeout,
commandTimeoutMs: timeout.value,
commandTimeoutSource: timeout.source,
});
if (!inspected.ok) return inspected;
const execution = inspected.value;
@@ -108,7 +111,7 @@ export class WasmArtifactService {
!report.success ||
report.data.artifact.path !== input.value.path ||
execution.subject.path !== input.value.path ||
report.data.tool_profile.timeout_ms !== timeout ||
report.data.tool_profile.timeout_ms !== timeout.value ||
report.data.tool_profile.output_budget_bytes !==
wasmResourceLimits(input.value).outputBytes ||
!wasmArtifactMatchesSubject(report.data.artifact, execution.subject) ||
@@ -54,7 +54,8 @@ it("preserves adjustable reader admission facts and gives cleanup priority", ()
required_value: 2,
unit: "count",
applies_at: "next_operation",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+2 -1
View File
@@ -40,7 +40,8 @@ export const artifactReaderLimitFailure = (
applies_at: "next_operation",
agent_editable: true,
can_increase: canIncrease,
rea_restart_required: false,
update_mode: "parameter" as const,
pending_restart: false as const,
},
});
};
+70
View File
@@ -2,6 +2,7 @@ import type { Stats } from "node:fs";
import { expect, it } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { settleChangeTimeBaseline } from "../filesystem/ChangeTimeBaseline.js";
const DRIFT_MS = 2_000;
@@ -229,3 +230,72 @@ it("throws the caller's typed cancellation rather than the raw reason", async ()
}),
).rejects.toBeInstanceOf(Cancelled);
});
it("uses a Windows settle override before rejecting an invalid launch seed", async () => {
const delays: number[] = [];
let reads = 0;
const observed = await runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"windows.change_time_drift_ms": 50,
"windows.change_time_settle_poll_ms": 10,
"windows.change_time_settle_max_polls": 4,
},
},
() =>
settleChangeTimeBaseline(
async () => {
reads += 1;
return statsAt(1_000);
},
{
now: () => 1_010,
sleep: async (delay) => {
delays.push(delay);
},
environment: {
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nope",
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "0",
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001",
},
platform: "win32",
},
),
);
expect(observed.ctimeMs).toBe(1_000);
expect(reads).toBe(5);
expect(delays).toEqual([10, 10, 10, 10]);
});
it("does not consume Windows limits on other hosts and preserves cancellation during the read", async () => {
const controller = new AbortController();
const reason = new Error("cancelled during baseline read");
await expect(
settleChangeTimeBaseline(
async () => {
controller.abort(reason);
return statsAt(1_000);
},
{
platform: "linux",
environment: { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "invalid" },
signal: controller.signal,
},
),
).rejects.toBe(reason);
});
it("preserves cancellation during a Windows read even when the timestamp is stable", async () => {
const controller = new AbortController();
const reason = new Error("cancelled during read");
await expect(
settleChangeTimeBaseline(
async () => {
controller.abort(reason);
return statsAt(1);
},
{ platform: "win32", now: () => 10000, signal: controller.signal },
),
).rejects.toBe(reason);
});
@@ -130,7 +130,12 @@ const sameLimits = (
left: JavaScriptSourceAnalysisLimits,
right: JavaScriptSourceAnalysisLimits,
): boolean => {
if (left.heapMb !== right.heapMb || left.timeoutMs !== right.timeoutMs)
if (
left.heapMb !== right.heapMb ||
left.timeoutMs !== right.timeoutMs ||
left.heapSource !== right.heapSource ||
left.timeoutSource !== right.timeoutSource
)
return false;
const leftPrecision = resolveJavaScriptSemanticPrecision(
left.semanticPrecision,
+710
View File
@@ -1,4 +1,6 @@
import process from "node:process";
import { createServer } from "node:http";
import { once } from "node:events";
import { expect, it } from "vitest";
@@ -82,6 +84,154 @@ it("cancels a response body that arrives after its fetch host aborts synchronous
expect(cancellationReason).toBe(reason);
});
it("retains cleanup when a response arrives after fetch cancellation", async () => {
const controller = new AbortController();
const reason = new Error("caller cancelled during fetch");
let resolveFetch!: (response: Response) => void;
const fetching = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
const [body, sibling] = new ReadableStream<Uint8Array>({
start(stream) {
stream.enqueue(new Uint8Array([1]));
},
}).tee();
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
controller.signal,
{ fetch: async () => fetching },
);
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBe(reason);
try {
resolveFetch(new Response(body));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([request.fetchUrl]);
await sibling.cancel();
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
} finally {
await sibling.cancel();
}
});
it("releases a late response already errored with the caller reason", async () => {
const controller = new AbortController();
const reason = new Error("caller cancelled during fetch");
let resolveFetch!: (response: Response) => void;
const fetching = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
controller.signal,
{ fetch: async () => fetching },
);
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cleanupIncomplete).toBe(true);
resolveFetch(
new Response(
new ReadableStream<Uint8Array>({
start(stream) {
stream.error(reason);
},
}),
),
);
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(result.error.cause).toBe(reason);
});
it("retains the exact failure of a late response cancellation", async () => {
const controller = new AbortController();
const reason = new Error("caller cancelled during fetch");
const physical = new Error("late response cancellation failed");
let resolveFetch!: (response: Response) => void;
const fetching = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
controller.signal,
{ fetch: async () => fetching },
);
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected cancellation");
resolveFetch(
new Response(
new ReadableStream<Uint8Array>({
cancel() {
throw physical;
},
}),
),
);
await expect.poll(() => result.error.secondaryFailures.length).toBe(1);
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([request.fetchUrl]);
expect(result.error.secondaryFailures[0]?.secondaryFailures[0]?.cause).toBe(
physical,
);
});
it("retains a fetch host that ignores an item deadline until it settles", async () => {
let rejectFetch!: (cause: unknown) => void;
const fetching = new Promise<Response>((_resolve, reject) => {
rejectFetch = reject;
});
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{ fetch: async () => fetching, timeoutMs: 50 },
);
const result = await pending;
if (result.ok) throw new Error("Expected pending-fetch cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([request.fetchUrl]);
rejectFetch(result.error.cause);
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
});
it("preserves a physical fetch failure when the caller cancels before delivery", async () => {
const controller = new AbortController();
const physical = new Error("physical fetch failure");
const reason = { kind: "late caller cancellation" };
const result = await fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
controller.signal,
{
fetch: () => {
const rejected = Promise.reject(physical);
void rejected.catch(() => {
queueMicrotask(() => controller.abort(reason));
});
return rejected;
},
},
);
if (result.ok) throw new Error("Expected physical fetch failure");
expect(result.error).toMatchObject({
_tag: "ProviderAdapterError",
cause: physical,
});
expect(
result.error.secondaryFailures.some((failure) => failure.cause === reason),
).toBe(true);
});
it("observes a fetch rejection that arrives after its host aborts synchronously", async () => {
const controller = new AbortController();
const reason = new Error("fetch host aborted during admission");
@@ -170,6 +320,505 @@ it.each([
},
);
it.each([
{ trigger: "caller-abort", rejects: false },
{ trigger: "caller-abort", rejects: true },
{ trigger: "response-limit", rejects: false },
{ trigger: "response-limit", rejects: true },
])(
"retains original cancellation after $trigger (rejects: $rejects)",
async ({ trigger, rejects }) => {
let started!: () => void;
const reading = new Promise<void>((resolve) => {
started = resolve;
});
let cancelled!: () => void;
const cancelling = new Promise<void>((resolve) => {
cancelled = resolve;
});
let finish!: () => void;
const held = new Promise<void>((resolve) => {
finish = resolve;
});
let calls = 0;
const cleanupFailure = new Error("response cancellation failed");
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
started();
if (trigger === "response-limit")
controller.enqueue(new Uint8Array(2));
},
async cancel() {
calls += 1;
cancelled();
await held;
if (rejects) throw cleanupFailure;
},
},
{ highWaterMark: 0 },
);
const controller = new AbortController();
const reason = { requested: "stop source-map response" };
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
},
controller.signal,
{ fetch: async () => new Response(body), maxResponseBytes: 1 },
);
try {
await reading;
if (trigger === "caller-abort") controller.abort(reason);
await cancelling;
if (trigger === "response-limit") controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected caller cancellation");
expect(result.error).toMatchObject({
_tag:
trigger === "caller-abort"
? "AnalysisCancelledError"
: "ProviderAdapterError",
cleanupIncomplete: true,
cleanupResources: [request.fetchUrl],
});
expect(result.error.hasCleanupOwner).toBe(true);
expect(result.error.cleanupRetry).toBeUndefined();
expect(result.error.cleanup?.reason).toContain("pending");
finish();
await expect
.poll(() =>
rejects
? result.error.cleanup?.reason.includes(
"response cancellation failed",
)
: !result.error.cleanupIncomplete,
)
.toBe(true);
if (trigger === "caller-abort") expect(result.error.cause).toBe(reason);
else {
expect(result.error.cause).toMatchObject({
name: "SourceMapSizeLimitError",
});
expect(
result.error.secondaryFailures.some(
(failure) => failure.cause === reason,
),
).toBe(true);
}
expect(result.error.cleanupIncomplete).toBe(rejects);
if (rejects)
expect(
result.error.secondaryFailures.some(
(failure) => failure.cause === cleanupFailure,
),
).toBe(true);
expect(result.error.cleanupResources).toEqual(
rejects ? [request.fetchUrl] : [],
);
expect(calls).toBe(1);
} finally {
finish();
controller.abort(reason);
await pending;
}
},
);
it("keeps same-URL response cleanup owners independent across mixed outcomes", async () => {
const cleanupFailure = new Error("second response cancellation failed");
const operations = [false, true].map((rejects) => {
let start!: () => void;
const reading = new Promise<void>((resolve) => {
start = resolve;
});
let finish!: () => void;
const held = new Promise<void>((resolve) => {
finish = resolve;
});
let calls = 0;
const body = new ReadableStream<Uint8Array>(
{
pull() {
start();
},
async cancel() {
calls += 1;
await held;
if (rejects) throw cleanupFailure;
},
},
{ highWaterMark: 0 },
);
const controller = new AbortController();
const reason = { rejects };
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
},
controller.signal,
{ fetch: async () => new Response(body) },
);
return {
reading,
finish,
controller,
reason,
pending,
calls: () => calls,
body,
};
});
try {
await Promise.all(operations.map(({ reading }) => reading));
for (const operation of operations)
operation.controller.abort(operation.reason);
const errors = await Promise.all(
operations.map(async ({ pending }) => {
const result = await pending;
if (result.ok) throw new Error("Expected cancellation");
return result.error;
}),
);
expect(errors.map((error) => error.cleanupResources)).toEqual([
[request.fetchUrl],
[request.fetchUrl],
]);
operations[0]!.finish();
await expect.poll(() => errors[0]!.cleanupIncomplete).toBe(false);
expect(errors[1]!.cleanupIncomplete).toBe(true);
expect(errors[1]!.cleanupResources).toEqual([request.fetchUrl]);
operations[1]!.finish();
await expect.poll(() => errors[1]!.secondaryFailures.length).toBe(1);
expect(errors[1]!.secondaryFailures[0]?.cause).toBe(cleanupFailure);
expect(errors.map((error) => error.cleanupIncomplete)).toEqual([
false,
true,
]);
expect(errors.map((error) => error.cause)).toEqual(
operations.map(({ reason }) => reason),
);
expect(operations.map(({ calls }) => calls())).toEqual([1, 1]);
expect(operations.map(({ body }) => body.locked)).toEqual([false, false]);
} finally {
for (const operation of operations) {
operation.finish();
operation.controller.abort(operation.reason);
}
await Promise.all(operations.map(({ pending }) => pending));
}
});
it.each([null, false])(
"preserves a falsy caller reason and releases a native HTTP body: %s",
async (reason) => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.write('{"version":3,');
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected TCP address");
const url = `http://127.0.0.1:${address.port}/map`;
const controller = new AbortController();
let delivered!: () => void;
const responseReceived = new Promise<void>((resolve) => {
delivered = resolve;
});
try {
const pending = fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
controller.signal,
{
fetch: async (...args) => {
const response = await fetch(...args);
delivered();
return response;
},
timeoutMs: 10_000,
},
);
await responseReceived;
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected caller cancellation");
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
},
);
it("does not mistake an independent body AbortError for a falsy caller cancellation", async () => {
const controller = new AbortController();
const physical = new DOMException("independent body failure", "AbortError");
const body = new ReadableStream<Uint8Array>(
{
pull(stream) {
stream.error(physical);
queueMicrotask(() => controller.abort(false));
},
},
{ highWaterMark: 0 },
);
const result = await fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
controller.signal,
{ fetch: async () => new Response(body) },
);
if (result.ok) throw new Error("Expected the independent body failure");
expect(result.error).toMatchObject({
_tag: "ProviderAdapterError",
cause: physical,
});
expect(
result.error.secondaryFailures.some((failure) => failure.cause === false),
).toBe(true);
});
it("keeps an actual stalled HTTP header deadline local to its map", async () => {
const server = createServer(() => {});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected TCP address");
const url = `http://127.0.0.1:${address.port}/map`;
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
undefined,
{ fetch, timeoutMs: 1000 },
);
expect(result).toMatchObject({
ok: true,
value: {
items: [
{
status: "fetch_failed",
limitation: expect.stringContaining("deadline"),
},
],
},
});
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
});
it("keeps an actual stalled HTTP response deadline local to its map", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.write('{"version":3,');
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected TCP address");
const url = `http://127.0.0.1:${address.port}/map`;
try {
const result = await fetchWebSourceMaps(
[{ ...request, declaredUrl: url, fetchUrl: url }],
{ ...input, allowed_origins: [new URL(url).origin] },
undefined,
{ fetch, timeoutMs: 1000 },
);
expect(result).toMatchObject({
ok: true,
value: {
items: [
{
status: "fetch_failed",
limitation: expect.stringContaining("deadline"),
},
],
},
});
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
});
it.each([302, 503])(
"retains HTTP %s response cleanup without hanging on an independent tee branch",
async (status) => {
const [body, sibling] = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1]));
},
}).tee();
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{
fetch: async () =>
new Response(body, {
status,
...(status === 302 ? { headers: { location: "/next" } } : {}),
}),
timeoutMs: 100,
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
pending,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("response cleanup waited for sibling")),
1000,
);
}),
]);
expect(result).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [request.fetchUrl],
},
});
} finally {
if (timer !== undefined) clearTimeout(timer);
await sibling.cancel();
await pending;
}
},
);
it("retains an admitted response when the deadline expires before body reading", async () => {
const [body, sibling] = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1]));
},
}).tee();
const pending = fetchWebSourceMaps(
[request],
{ ...input, allowed_origins: [new URL(request.fetchUrl).origin] },
undefined,
{
fetch: async () => {
// Keep the timer callback queued while synchronous delivery crosses the deadline.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150);
return new Response(body);
},
timeoutMs: 50,
},
);
try {
const result = await pending;
if (result.ok) throw new Error("Expected owned response cleanup");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
cleanupResources: [request.fetchUrl],
});
await sibling.cancel();
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
} finally {
await Promise.allSettled([body.cancel(), sibling.cancel()]);
}
});
it("retains an oversized redirect identity's pending response cleanup", async () => {
const [body, sibling] = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1]));
},
}).tee();
const finalUrl = new URL(`/${"a".repeat(6000)}`, request.fetchUrl).href;
const pending = fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
source_map_limits: { output_bytes: 4000 },
},
undefined,
{
fetch: async (url) =>
String(url) === request.fetchUrl
? new Response(null, { status: 302, headers: { location: finalUrl } })
: new Response(body),
timeoutMs: 100,
},
);
try {
const result = await pending;
if (result.ok) throw new Error("Expected output capacity refusal");
expect(result.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
cleanupIncomplete: true,
cleanupResources: [finalUrl],
});
expect(result.error.hasCleanupOwner).toBe(true);
await sibling.cancel();
await expect.poll(() => result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
} finally {
await sibling.cancel();
await pending;
}
});
it("preserves the physical reader failure when stream cancellation also rejects", async () => {
const failure = new Error("response transport failed");
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
controller.error(failure);
},
},
{ highWaterMark: 0 },
);
const result = await fetchWebSourceMaps(
[request],
{
...input,
allowed_origins: [new URL(request.fetchUrl).origin],
},
undefined,
{ fetch: async () => new Response(body) },
);
if (result.ok) throw new Error("Expected the physical read failure");
expect(result.error).toMatchObject({
_tag: "ProviderAdapterError",
cause: failure,
cleanupIncomplete: true,
cleanupResources: [request.fetchUrl],
});
await expect
.poll(() => result.error.secondaryFailures[0]?.cause)
.toBe(failure);
expect(result.error.cleanup?.reason).toContain("response transport failed");
expect(result.error.cleanupRetry).toBeUndefined();
expect(body.locked).toBe(false);
});
it("recovers a fetched map by raising its exact structural admission on the next call", async () => {
const text = JSON.stringify({
version: 3,
@@ -207,3 +856,64 @@ it("recovers a fetched map by raising its exact structural admission on the next
mappings: [{ generated_line: 1, generated_column: 0 }],
});
});
it.each([
{ limits: { decoded_records: 4 }, directory: "maps" },
{ limits: { fetch_bytes: 1500 }, directory: "m".repeat(1000) },
])(
"releases discarded map interpretations before the next map: $limits",
async ({ limits, directory }) => {
const selected = {
...request,
fetchUrl: `https://app.example.test/${directory}/app.js.map`,
};
const responses = ["ACAA", "AAAA"].map((mappings) =>
JSON.stringify({ version: 3, sources: ["a.js"], names: [], mappings }),
);
const result = await fetchWebSourceMapsOk(
[selected, { ...selected, scriptKey: `scr_${"2".repeat(64)}` }],
{
...input,
allowed_origins: [new URL(selected.fetchUrl).origin],
source_map_limits: limits,
},
undefined,
{ fetch: async () => new Response(responses.shift()) },
);
expect(result.items.map(({ status }) => status)).toEqual([
"invalid",
"included",
]);
expect(result.items[0]).toMatchObject({
response_identity: { representation: "fetch-response-body-bytes" },
});
expect(result.items[1]?.mappings).toMatchObject([
{ source: `https://app.example.test/${directory}/a.js` },
]);
const retained = await fetchWebSourceMapsOk(
[selected, { ...selected, scriptKey: `scr_${"2".repeat(64)}` }],
{
...input,
allowed_origins: [new URL(selected.fetchUrl).origin],
source_map_limits: limits,
},
undefined,
{
fetch: async () =>
new Response(
JSON.stringify({
version: 3,
sources: ["a.js"],
names: [],
mappings: "AAAA",
}),
),
},
);
expect(retained.items.map(({ status }) => status)).toEqual([
"included",
"fetch_failed",
]);
},
);
+431 -49
View File
@@ -44,6 +44,8 @@ import {
type SourceMapLeaf,
} from "../javascript/sourceMaps/SourceMapFormat.js";
import { selectedBrowserDeadlineMs } from "../domain/browserDeadline.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
selectedSourceMapLimits,
sourceMapLimitRecovery,
@@ -138,7 +140,11 @@ export const fetchWebSourceMaps = async (
),
fetchTimeoutMs,
);
const abortFromCaller = (): void => operationController.abort(signal?.reason);
const callerAbort = new SourceMapCallerAbort();
const abortFromCaller = (): void => {
callerAbort.reason = signal?.reason;
operationController.abort(callerAbort);
};
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const deadlineAt = performance.now() + fetchTimeoutMs;
@@ -225,7 +231,10 @@ export const fetchWebSourceMaps = async (
items.push(item);
}
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason)
if (
signal?.aborted === true &&
(cause === signal.reason || cause === callerAbort)
)
return err(
retainCompleted(
new AnalysisCancelledError("analyze_web_bundle", {
@@ -233,13 +242,33 @@ export const fetchWebSourceMaps = async (
}),
),
);
if (cause instanceof AnalysisError) return err(retainCompleted(cause));
if (signal?.aborted === true)
return err(
retainCompleted(
if (cause instanceof AnalysisError) {
if (
signal?.aborted === true &&
cause.cause !== signal.reason &&
!cause.secondaryFailures.some(
(failure) =>
failure instanceof AnalysisCancelledError &&
failure.cause === signal.reason,
)
)
cause.retainSecondaryFailure(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal.reason,
}),
);
return err(retainCompleted(cause));
}
if (signal?.aborted === true)
return err(
retainCompleted(
new ProviderAdapterError("rea-cdp-browser", "analyze_web_bundle", {
cause,
}).retainSecondaryFailure(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal.reason,
}),
),
),
);
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
@@ -299,6 +328,17 @@ class SourceMapDeadlineError extends Error {
}
}
/** Truthy transport reason prevents Fetch from substituting falsy caller reasons. */
class SourceMapCallerAbort extends Error {
reason: unknown;
constructor() {
super("Source-map fetch cancelled by caller");
}
}
const sourceMapCallerReason = (value: unknown): unknown =>
value instanceof SourceMapCallerAbort ? value.reason : value;
class SourceMapSizeLimitError extends Error {
constructor(maxBytes: number) {
super(
@@ -324,6 +364,225 @@ class SourceMapRedirectLocationError extends Error {
}
}
/** Own the original cancellation promise; cancelling a closed reader cannot retry it. */
class SourceMapResponseCleanup extends Error {
readonly failure: AnalysisError;
readonly completion: Promise<void>;
#resource: { url: string; body: ReadableStream<Uint8Array> } | undefined;
get cleanupResources(): readonly string[] {
return this.#resource === undefined ? [] : [this.#resource.url];
}
constructor(
reader: ReadableStreamDefaultReader<Uint8Array>,
body: ReadableStream<Uint8Array>,
primary: unknown,
resource: string,
signal: AbortSignal | undefined,
cancelReason: unknown,
) {
super("Source-map response cleanup", { cause: primary });
this.#resource = { url: resource, body };
let reason = "Source-map response cancellation is pending.";
const options = {
cause: sourceMapCallerReason(primary),
cleanup: {
get reason() {
return reason;
},
resources: [resource],
},
};
this.failure =
signal?.aborted === true &&
primary === signal.reason &&
!(primary instanceof SourceMapDeadlineError)
? new AnalysisCancelledError("analyze_web_bundle", options)
: new ProviderAdapterError("rea-cdp-browser", "analyze_web_bundle", {
...options,
userMessage:
sourceMapCallerReason(primary) instanceof Error
? (sourceMapCallerReason(primary) as Error).message
: String(sourceMapCallerReason(primary)),
});
this.failure.bindCleanupResourceOwner(this);
const failed = (cause: unknown): void => {
reason = `Source-map response cancellation failed: ${cause instanceof Error ? cause.message : String(cause)}`;
this.failure.retainSecondaryFailure(
new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{ reason },
{ operation: "analyze_web_bundle", cause },
),
{ source_map_response_cleanup: reason },
);
};
const closed = reader.closed.then(
() => ({ status: "closed" as const }),
(cause: unknown) => ({ status: "errored" as const, cause }),
);
this.completion = reader
.cancel(cancelReason)
.catch(async (cause: unknown) => {
if (
signal?.aborted === true &&
primary === signal.reason &&
(cause === primary || cause === sourceMapCallerReason(primary))
) {
const terminal = await closed;
if (terminal.status === "errored" && terminal.cause === cause) return;
}
throw cause;
});
void this.completion.then(
() => {
try {
reader.releaseLock();
reason = "Source-map response cancellation completed.";
this.#resource = undefined;
this.failure.resolveCleanup();
} catch (cause: unknown) {
failed(cause);
}
},
(cause: unknown) => {
try {
reader.releaseLock();
} catch (releaseCause: unknown) {
failed(releaseCause);
}
failed(cause);
},
);
}
}
/** Retain a response that may arrive after its fetch caller stops waiting. */
class SourceMapPendingFetch extends Error {
readonly failure: AnalysisError;
readonly settled: Promise<void>;
#resource: { url: string; response?: Response } | undefined;
get cleanupResources(): readonly string[] {
return this.#resource === undefined ? [] : [this.#resource.url];
}
constructor(
promise: Promise<Response>,
signal: AbortSignal,
resource: string,
) {
const reason = signal.reason;
super("Source-map fetch is settling after cancellation", { cause: reason });
this.#resource = { url: resource };
const options = {
cause: sourceMapCallerReason(reason),
cleanup: {
reason: "Source-map fetch is still settling after cancellation.",
resources: [resource],
},
};
this.failure =
reason instanceof SourceMapDeadlineError
? new ProviderAdapterError(
"rea-cdp-browser",
"analyze_web_bundle",
options,
)
: new AnalysisCancelledError("analyze_web_bundle", options);
this.failure.bindCleanupResourceOwner(this);
const finished = (): void => {
this.#resource = undefined;
this.failure.resolveCleanup();
};
this.settled = promise.then(
async (response) => {
if (this.#resource !== undefined) this.#resource.response = response;
if (response.body === null) {
finished();
return;
}
try {
const cleanup = new SourceMapResponseCleanup(
response.body.getReader(),
response.body,
reason,
resource,
signal,
sourceMapCallerReason(reason),
);
this.failure.bindCleanupDependencies([cleanup.failure]);
try {
await cleanup.completion;
finished();
} catch {
this.failure.retainSecondaryFailure(cleanup.failure);
}
} catch (cause: unknown) {
this.failure.retainSecondaryFailure(
new ProviderCleanupError(
"rea-cdp-browser",
[resource],
{
reason:
"Late source-map response could not be admitted for cleanup.",
},
{ operation: "analyze_web_bundle", cause },
),
);
}
},
(cause: unknown) => {
if (cause !== reason)
this.failure.retainSecondaryFailure(
new ProviderAdapterError("rea-cdp-browser", "analyze_web_bundle", {
cause,
}),
);
finished();
},
);
}
}
// A redirect or refused response still owns its unread body until cancellation
// completes; the caller may own a tee sibling that keeps cancellation pending.
const cancelUnreadResponse = async (
response: Response,
resource: string,
primary: unknown,
signal: AbortSignal | undefined,
cancelReason?: unknown,
): Promise<void> => {
if (response.body === null) return;
const cleanup = new SourceMapResponseCleanup(
response.body.getReader(),
response.body,
primary,
resource,
signal,
cancelReason,
);
try {
await awaitWithAbort(() => cleanup.completion, signal);
} catch (cause: unknown) {
if (
signal?.aborted === true &&
cause === signal.reason &&
!(cause instanceof SourceMapDeadlineError) &&
primary !== cause
)
cleanup.failure.retainSecondaryFailure(
new AnalysisCancelledError("analyze_web_bundle", {
cause: sourceMapCallerReason(cause),
}),
);
throw cleanup;
}
};
const fetchOne = async (
request: WebSourceMapRequest,
input: AnalyzeWebBundleInput,
@@ -370,26 +629,43 @@ const fetchOne = async (
);
} catch (cause: unknown) {
try {
await response.body?.cancel();
await cancelUnreadResponse(response, fetchedUrl, cause, signal);
} catch (cleanupCause: unknown) {
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
throw sourceMapOutputError(cause, budget.decodedRecords.limits, {
cause: new AggregateError(
[cause, cleanupCause],
"Source-map response cleanup failed after its output exceeded capacity.",
),
cleanup: {
reason:
"Source-map response cancellation failed after its output exceeded capacity.",
resources: ["source-map-response"],
if (
cause instanceof SourceMapFormatFailure &&
cause.reason === "limit"
) {
const error = sourceMapOutputError(
cause,
budget.decodedRecords.limits,
{
cause,
cleanup: {
reason:
"Source-map response cancellation is incomplete after its output exceeded capacity.",
resources: [fetchedUrl],
},
},
});
);
if (cleanupCause instanceof SourceMapResponseCleanup) {
error.retainSecondaryFailure(cleanupCause.failure);
error.bindCleanupDependencies([cleanupCause.failure]);
}
throw error;
}
throw cause;
}
throw cause;
}
if (!response.ok) {
await response.body?.cancel();
await cancelUnreadResponse(
response,
fetchedUrl,
new Error(
`Source-map server returned HTTP ${String(response.status)}.`,
),
signal,
);
return emptySourceMapItem(
request,
"fetch_failed",
@@ -397,17 +673,31 @@ const fetchOne = async (
fetchedUrl,
);
}
checkOperation(
undefined,
signal,
budget.deadlineAt,
budget.deadlineLimitation,
);
try {
checkOperation(
undefined,
signal,
budget.deadlineAt,
budget.deadlineLimitation,
);
} catch (cause: unknown) {
// The response is already admitted even if the timer callback has not
// run yet; keep its unread body owned through the interrupted boundary.
await cancelUnreadResponse(
response,
fetchedUrl,
cause,
signal,
sourceMapCallerReason(cause),
);
throw cause;
}
const body = await readBoundedText(
response,
budget,
host.maxResponseBytes ?? budget.decodedRecords.limits.fetchBytes,
signal,
fetchedUrl,
);
completedResponseIdentity = body.responseIdentity;
return normalizeSourceMap(
@@ -422,8 +712,31 @@ const fetchOne = async (
budget: budget.decodedRecords,
},
);
} catch (cause: unknown) {
if (cause instanceof AnalysisResourceConstraintError) throw cause;
} catch (caught: unknown) {
if (caught instanceof SourceMapPendingFetch) {
if (caught.cause instanceof SourceMapDeadlineError) {
// Native fetch reports abort completion asynchronously. Check the next
// event-loop turn without waiting for an uncooperative host indefinitely.
await Promise.race([
caught.settled,
new Promise<void>((resolve) => setImmediate(resolve)),
]);
if (
!caught.failure.cleanupIncomplete &&
caught.failure.secondaryFailures.length === 0
)
caught = caught.cause;
else throw caught.failure;
} else throw caught.failure;
}
if (
caught instanceof SourceMapResponseCleanup &&
caught.failure.cleanupIncomplete
)
throw caught.failure;
const cause =
caught instanceof SourceMapResponseCleanup ? caught.cause : caught;
if (cause instanceof AnalysisError) throw cause;
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
throw cause;
if (
@@ -493,10 +806,17 @@ const fetchFollowingApprovedRedirects = async (
...(signal === undefined ? {} : { signal }),
}),
signal,
current,
);
observeResponse(current);
if (signal !== undefined && signalIsAborted(signal)) {
await response.body?.cancel(signal.reason).catch(() => undefined);
await cancelUnreadResponse(
response,
current,
signal.reason,
signal,
sourceMapCallerReason(signal.reason),
);
throw signal.reason;
}
// `Location` is a redirect target only for Fetch redirect statuses.
@@ -506,7 +826,12 @@ const fetchFollowingApprovedRedirects = async (
return { response, fetchedUrl: current };
const location = response.headers.get("location");
if (location === null) return { response, fetchedUrl: current };
await response.body?.cancel();
await cancelUnreadResponse(
response,
current,
new Error("Source-map redirect response cleanup failed."),
signal,
);
try {
current = new URL(location, current).href;
} catch {
@@ -520,6 +845,7 @@ const readBoundedText = async (
budget: { retainedBytes: number },
maxBytes: number,
signal: AbortSignal | undefined,
resource: string,
): Promise<{ text: string; responseIdentity: ResponseIdentity }> => {
if (response.body === null)
return {
@@ -533,23 +859,72 @@ const readBoundedText = async (
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
let cleanup: SourceMapResponseCleanup | undefined;
let streamFailure: { cause: unknown } | undefined;
void reader.closed.catch((cause: unknown) => {
streamFailure = { cause };
});
try {
for (;;) {
if (signal?.aborted === true) throw signal.reason;
const { done, value } = await readWithAbort(reader, signal);
const { done, value } = await awaitWithAbort(() => reader.read(), signal);
if (done) break;
const nextBytes = value?.byteLength ?? 0;
if (budget.retainedBytes + bytes + nextBytes > maxBytes) {
await reader.cancel(new SourceMapSizeLimitError(maxBytes));
throw new SourceMapSizeLimitError(maxBytes);
}
if (value !== undefined) chunks.push(value);
bytes += nextBytes;
}
} catch (cause: unknown) {
// Fetch can error its body with the abort reason before read cancellation.
// A confirmed errored stream is already terminal and needs only its lock released.
await Promise.resolve();
if (
signal?.aborted === true &&
cause === signal.reason &&
streamFailure !== undefined
) {
if (
streamFailure.cause === cause ||
streamFailure.cause === sourceMapCallerReason(cause)
)
throw cause;
// A distinct terminal reader failure is an observed physical failure,
// even when the abort notification wins the promise-delivery race.
cause = streamFailure.cause;
}
cleanup = new SourceMapResponseCleanup(
reader,
response.body,
cause,
resource,
signal,
sourceMapCallerReason(cause),
);
if (cause instanceof SourceMapSizeLimitError) {
try {
// Preserve the size-limit item's existing finalization boundary.
const completion = cleanup.completion;
await awaitWithAbort(() => completion, signal);
} catch (completionFailure: unknown) {
// The original cancellation's rejection is retained by its owner.
// A caller abort during that join is a separate observed outcome.
if (
signal?.aborted === true &&
completionFailure === signal.reason &&
!(completionFailure instanceof SourceMapDeadlineError)
)
cleanup.failure.retainSecondaryFailure(
new AnalysisCancelledError("analyze_web_bundle", {
cause: sourceMapCallerReason(signal.reason),
}),
);
}
}
throw cleanup;
} finally {
// Aborting cancels the reader in readWithAbort. It owns the pending read
// until that cancellation settles, so releasing the lock here can throw.
if (signal?.aborted !== true) reader.releaseLock();
if (cleanup === undefined) reader.releaseLock();
}
budget.retainedBytes += bytes;
const joined = new Uint8Array(bytes);
@@ -575,11 +950,11 @@ const readBoundedText = async (
}
};
const readWithAbort = <T>(
reader: ReadableStreamDefaultReader<T>,
const awaitWithAbort = <T>(
operation: () => Promise<T>,
signal: AbortSignal | undefined,
): Promise<Awaited<ReturnType<ReadableStreamDefaultReader<T>["read"]>>> => {
if (signal === undefined) return reader.read();
): Promise<T> => {
if (signal === undefined) return operation();
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
let settled = false;
@@ -588,11 +963,10 @@ const readWithAbort = <T>(
if (settled) return;
settled = true;
cleanup();
void reader.cancel(signal.reason).catch(() => undefined);
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
void reader.read().then(
void operation().then(
(value) => {
if (settled) return;
settled = true;
@@ -603,7 +977,6 @@ const readWithAbort = <T>(
if (settled) return;
settled = true;
cleanup();
void reader.cancel(cause).catch(() => undefined);
reject(cause);
},
);
@@ -616,6 +989,7 @@ const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
const promiseWithAbort = (
promise: Promise<Response>,
signal: AbortSignal | undefined,
resource: string,
): Promise<Response> => {
if (signal === undefined) return promise;
return new Promise((resolve, reject) => {
@@ -625,18 +999,22 @@ const promiseWithAbort = (
if (settled) return;
settled = true;
cleanup();
reject(signal.reason);
reject(new SourceMapPendingFetch(promise, signal, resource));
};
signal.addEventListener("abort", abort, { once: true });
void promise.then(
(response) => {
if (settled || signal.aborted) {
void response.body?.cancel(signal.reason).catch(() => undefined);
if (!settled) {
settled = true;
cleanup();
reject(signal.reason);
}
if (settled) return;
if (signal.aborted) {
settled = true;
cleanup();
reject(
new SourceMapPendingFetch(
Promise.resolve(response),
signal,
resource,
),
);
return;
}
settled = true;
@@ -662,6 +1040,8 @@ const normalizeSourceMap = (
context: SourceMapDecodeContext,
): WebSourceMapItem => {
const retainedBytesBeforeMap = context.budget.expandedBytes;
const recordsBeforeMap = context.budget.records;
const resolvedSourceBytesBeforeMap = context.budget.resolvedSourceBytes;
const textBytes =
jsonByteLength(text) +
artifactMetadataBytes("application/source-map+json", text) -
@@ -836,6 +1216,8 @@ const normalizeSourceMap = (
// Failed interpretations retain completed body identity without consuming
// the collection budget needed for later valid maps.
context.budget.expandedBytes = retainedBytesBeforeMap;
context.budget.records = recordsBeforeMap;
context.budget.resolvedSourceBytes = resolvedSourceBytesBeforeMap;
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") {
return emptySourceMapItem(
request,
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
captureDecoderHeapContinuingRecovery,
captureDecoderHeapRecovery,
DEFAULT_CAPTURE_DECODER_HEAP,
} from "../../config/captureDecoderHeap.js";
@@ -74,7 +75,7 @@ it.each([
retryable: false,
remediation: {
action: expect.stringContaining(
captureDecoderHeapRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
captureDecoderHeapContinuingRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
),
},
details: {
+9
View File
@@ -30,6 +30,7 @@ import {
type InspectWebNetworkCaptureInput,
} from "../../domain/webNetworkCapture.js";
import {
captureDecoderHeapContinuingRecovery,
captureDecoderHeapRecovery,
DEFAULT_CAPTURE_DECODER_HEAP,
type CaptureDecoderHeap,
@@ -299,6 +300,7 @@ export const captureMemoryFailure = (
heap.oldGenerationMiB,
);
const recovery = captureDecoderHeapRecovery(heap);
const continuingRecovery = captureDecoderHeapContinuingRecovery(heap);
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
@@ -341,6 +343,13 @@ export const captureMemoryFailure = (
}
: {}),
remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format !== "mitmproxy" ? ` ${recovery}` : ""}${input.format !== "mitmproxy" && processMemoryLimitBytes !== null ? ` The Windows Job Object commit limit for this decoder is ${String(processMemoryLimitBytes)} bytes, ${String(heap.oldGenerationMiB)} MiB plus a 512 MiB Bun reserve.` : ""}`,
...(input.format === "mitmproxy"
? {}
: {
continuingCallerGuidance: {
remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage. ${continuingRecovery}${processMemoryLimitBytes !== null ? ` The Windows Job Object commit limit for this decoder is ${String(processMemoryLimitBytes)} bytes, ${String(heap.oldGenerationMiB)} MiB plus a 512 MiB Bun reserve.` : ""}`,
},
}),
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
? {
capturedOutput: {
@@ -89,7 +89,8 @@ it.each(["max_input_bytes", "max_reply_bytes", "max_nesting_depth"] as const)(
parameter: control,
effective_value: selected,
source: "request",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+1 -1
View File
@@ -46,7 +46,7 @@ export const createNativeDispatchBinarySession = (
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
): BinarySession =>
composeDeepBinarySession(
config,
runtimeAnalysisConfig(config),
logger,
selectedEnvironment,
"inspect_native_dispatch_metadata",
+2 -2
View File
@@ -569,8 +569,8 @@ const modules = {
},
}),
),
cutter: service(({ providerEnvironment }) =>
createCutterBridgeService(providerEnvironment),
cutter: service(({ providerEnvironment, settings }) =>
createCutterBridgeService(providerEnvironment, { read: settings }),
),
browserObservation: capability(
({ options }) => options.browserObservation,
+46 -24
View File
@@ -3,6 +3,7 @@ import { parseJebMcpTimeoutMs } from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { err } from "../domain/result.js";
import type { JebMcpConnectionFactory } from "../jeb/JebMcpConnection.js";
import { JebProvider } from "../jeb/JebProvider.js";
@@ -10,7 +11,8 @@ import { JebProvider } from "../jeb/JebProvider.js";
/**
* Construct one JEB provider bound to the caller-selected MCP endpoint. An
* unusable endpoint makes only JEB unavailable; every other CLI command and
* MCP tool is composed alongside it.
* MCP tool is composed alongside it. An invalid launch timeout stays
* unavailable until the accepted operation supplies jeb.mcp_timeout_ms.
*/
export const createJebAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
@@ -19,29 +21,49 @@ export const createJebAnalysisProvider = (
const requestTimeout = parseJebMcpTimeoutMs(
environment.REA_JEB_MCP_TIMEOUT_MS,
);
if (!requestTimeout.ok) return unconfiguredJebProvider(requestTimeout.error);
try {
return new JebProvider({
environment,
...(requestTimeout.value === undefined
? {}
: {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
const build = (): JebAnalysisPort => {
try {
return new JebProvider({
environment,
...(requestTimeout.ok && requestTimeout.value !== undefined
? {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}
: {}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
};
if (requestTimeout.ok) return build();
const rejected = unconfiguredJebProvider(requestTimeout.error);
let provider: JebAnalysisPort | undefined;
const live = (): JebAnalysisPort => (provider ??= build());
const overrideActive = (): boolean =>
currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"] !==
undefined;
return {
inspectAvailability: (signal) =>
overrideActive()
? live().inspectAvailability(signal)
: rejected.inspectAvailability(signal),
close: () =>
provider === undefined ? Promise.resolve() : provider.close(),
execute: (request, options) =>
overrideActive()
? live().execute(request, options)
: rejected.execute(request, options),
};
};
const unconfiguredJebProvider = (
+112 -54
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { launchAnalysisSetting } from "./config/analysisSettings.js";
import { runtimeAnalysisConfig } from "./config/runtimeAnalysisSettings.js";
import { runWithAnalysisOperationSnapshot } from "./domain/analysisOperationSnapshot.js";
import { ConfigurationError } from "./domain/configurationErrors.js";
import {
defaultHopperLauncherPath,
inspectNativeAnalysisConfiguration,
@@ -21,19 +25,46 @@ it("still rejects a NUL in an unresolved expression", () => {
});
it.each([
["REA_ASAR_MAX_LISTING_BYTES", "9007199254740992"],
["REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS", "0"],
["REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION", "2147483648"],
["REA_GHIDRA_VALUE_FLOW_MAX_EDGES", "1.5"],
["REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB", "2048"],
])(
"rejects invalid %s at launch with its exact setting identity",
(setting, value) => {
["asar.max_listing_bytes", "REA_ASAR_MAX_LISTING_BYTES", "9007199254740992"],
[
"ghidra.value_flow_max_operations",
"REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS",
"0",
],
[
"ghidra.value_flow_max_inputs_per_operation",
"REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION",
"2147483648",
],
["ghidra.value_flow_max_edges", "REA_GHIDRA_VALUE_FLOW_MAX_EDGES", "1.5"],
[
"ghidra.decompile_max_payload_mib",
"REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB",
"2048",
],
] as const)(
"defers invalid %s until the operation reads it",
(id, setting, value) => {
const parsed = parseConfig({ [setting]: value });
if (parsed.ok) throw new Error("Invalid resource policy accepted");
expect(parsed.error.settings).toContainEqual(
if (!parsed.ok) throw new Error("runtime seed rejected startup");
const boundary = launchAnalysisSetting(id, { [setting]: value });
if (boundary.ok) throw new Error("invalid runtime seed was admitted");
expect(boundary.error.settings).toContainEqual(
expect.objectContaining({ setting }),
);
const live = runtimeAnalysisConfig(parsed.value);
const read = (): unknown => {
if (id === "asar.max_listing_bytes") return live;
if (id === "ghidra.value_flow_max_operations")
return live.ghidraValueFlowMaxOperations;
if (id === "ghidra.value_flow_max_inputs_per_operation")
return live.ghidraValueFlowMaxInputsPerOperation;
if (id === "ghidra.value_flow_max_edges")
return live.ghidraValueFlowMaxEdges;
return live.ghidraDecompileMaxPayloadMiB;
};
if (id !== "asar.max_listing_bytes")
expect(read).toThrow(ConfigurationError);
},
);
@@ -141,7 +172,18 @@ describe("runtime configuration", () => {
expect(empty.value.hopperTargetPath).toBeUndefined();
expect(empty.value.analysisProvider).toBe("auto");
expect(empty.value.hopperStartupTimeoutMs).toBe(120_000);
expect(empty.value.hopperStartupTimeoutSource).toBe("default");
}
const hopperTimeout = parseConfig({
REA_HOPPER_STARTUP_TIMEOUT_MS: "45000",
});
expect(hopperTimeout).toMatchObject({
ok: true,
value: {
hopperStartupTimeoutMs: 45_000,
hopperStartupTimeoutSource: "setting",
},
});
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
@@ -189,7 +231,6 @@ describe("runtime configuration", () => {
});
expect(rejected.issues.map(({ setting }) => setting)).toEqual([
"REA_ANALYSIS_PROVIDER",
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
]);
expect(
parseConfig({
@@ -349,6 +390,20 @@ describe("runtime collection configuration", () => {
);
});
const expectInvalidStartupSeed = (
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS" | "REA_GHIDRA_STARTUP_TIMEOUT_MS",
id: "hopper.startup_timeout_ms" | "ghidra.startup_timeout_ms",
value: string,
read: (config: ReturnType<typeof runtimeAnalysisConfig>) => number,
): void => {
const result = parseConfig({ [setting]: value });
if (!result.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(result.value);
expect(() => read(live)).toThrow(ConfigurationError);
const boundary = launchAnalysisSetting(id, { [setting]: value });
if (boundary.ok) throw new Error("invalid startup seed was admitted");
};
describe("configuration failure diagnostics", () => {
it.each(["1", "900000", "2147483647"])(
"accepts Hopper startup deadline %s without changing Ghidra's deadline",
@@ -366,22 +421,26 @@ describe("configuration failure diagnostics", () => {
);
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
"rejects malformed Hopper startup deadline %j with its setting",
"defers a malformed Hopper startup deadline %j until the operation reads it",
(value) => {
const result = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid startup deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
{
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
expectInvalidStartupSeed(
"REA_HOPPER_STARTUP_TIMEOUT_MS",
"hopper.startup_timeout_ms",
value,
(live) => live.hopperStartupTimeoutMs,
);
const parsed = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
if (!parsed.ok) throw parsed.error;
const live = runtimeAnalysisConfig(parsed.value);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "hopper.startup_timeout_ms": 8000 },
},
() => live.hopperStartupTimeoutMs,
),
).toBe(8000);
},
);
@@ -399,20 +458,12 @@ describe("configuration failure diagnostics", () => {
])(
"rejects malformed Ghidra startup deadline %j with its setting and constraint",
(value) => {
const result = parseConfig({ REA_GHIDRA_STARTUP_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid startup deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
{
setting: "REA_GHIDRA_STARTUP_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
expectInvalidStartupSeed(
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
"ghidra.startup_timeout_ms",
value,
(live) => live.ghidraStartupTimeoutMs,
);
},
);
@@ -513,8 +564,8 @@ describe("JEB MCP timeout configuration", () => {
value: 900_000,
});
expect(
parseEnvironmentValues({ REA_JEB_MCP_TIMEOUT_MS: "900000" }),
).toMatchObject({ ok: true, value: { REA_JEB_MCP_TIMEOUT_MS: 900_000 } });
parseEnvironmentValues({ REA_JEB_MCP_TIMEOUT_MS: "900000" }).ok,
).toBe(true);
});
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
@@ -630,18 +681,25 @@ describe("Ghidra decompile limit configuration", () => {
const result = parseConfig({
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: value,
});
if (result.ok) throw new Error("Expected invalid decompile limit");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
details: {
settings: [
{
setting: "REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS",
constraint: expect.any(String),
},
],
},
});
if (!result.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(result.value);
expect(() => live.ghidraDecompileTimeoutSeconds).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "ghidra.decompile_timeout_seconds": null },
},
() => live.ghidraDecompileTimeoutSeconds,
),
).toBeUndefined();
const boundary = launchAnalysisSetting(
"ghidra.decompile_timeout_seconds",
{ REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: value },
);
if (boundary.ok) throw new Error("invalid decompile limit was admitted");
},
);
});
+23
View File
@@ -17,6 +17,17 @@ import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js";
import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js";
import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js";
import { parseNativeMatchingAllowance } from "./nativeMatchingAllowance.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
import {
debuggerCommandDeadlineMsSchema,
debuggerMaxCommandOutputBytesSchema,
} from "./debuggerSessionLimits.js";
import {
windowsChangeTimeDriftMsSchema,
windowsChangeTimeSettleMaxPollsSchema,
windowsChangeTimeSettlePollMsSchema,
} from "./windowsChangeTimeSettle.js";
import { pwntoolsInputBytesSchema } from "../native/pwntools/PwntoolsInputLimit.js";
const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
{
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema,
@@ -33,6 +44,18 @@ const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema,
REA_CAPTURE_DECODER_OLD_GENERATION_MIB:
captureDecoderOldGenerationMiBSchema,
REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema,
REA_DEBUGGER_COMMAND_DEADLINE_MS: debuggerCommandDeadlineMsSchema,
REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: debuggerMaxCommandOutputBytesSchema,
REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema,
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_PROJECT_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_PWNTOOLS_MAX_INPUT_BYTES: pwntoolsInputBytesSchema,
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: windowsChangeTimeSettlePollMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS:
windowsChangeTimeSettleMaxPollsSchema,
};
export const launchAnalysisSetting = (
id: AnalysisSettingId,
+64 -14
View File
@@ -1,8 +1,14 @@
import { expect, it } from "vitest";
import { analysisSettingDefinition } from "../domain/analysisSettingDefinitions.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { GHIDRA_OPERATIONS } from "../ghidra/GhidraProviderMetadata.js";
import { HOPPER_OPERATIONS } from "../hopper/HopperProviderMetadata.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
BRIDGE_MAX_REQUEST_BYTES_SETTING,
DEFAULT_BRIDGE_MAX_REQUEST,
@@ -19,6 +25,21 @@ const symbolName = "\u0001".repeat(1024);
const session = "00000000-0000-4000-8000-000000000001";
const token = "a".repeat(64);
it("advertises the bridge ceiling for every bridge-backed operation", () => {
const advertised = new Set(
analysisSettingDefinition("bridge.max_request_bytes").operations,
);
const required = [
...HOPPER_OPERATIONS,
...GHIDRA_OPERATIONS,
"list_cutter_sessions",
"cutter_command",
];
expect(required.filter((operation) => !advertised.has(operation))).toEqual(
[],
);
});
it("keeps the 16 MiB ceiling when the setting is unset", () => {
expect(parseBridgeMaxRequestBytes(undefined)).toEqual({
ok: true,
@@ -31,7 +52,7 @@ it("keeps the 16 MiB ceiling when the setting is unset", () => {
);
const environment = parseEnvironment({});
if (!environment.ok) throw new Error("doctor rejected an unset ceiling");
expect(environment.value.REA_BRIDGE_MAX_REQUEST_BYTES).toBeUndefined();
expect(environment.ok).toBe(true);
});
it("accepts a caller-selected ceiling inside the floor and maximum", () => {
@@ -44,7 +65,7 @@ it("accepts a caller-selected ceiling inside the floor and maximum", () => {
[BRIDGE_MAX_REQUEST_BYTES_SETTING]: "8192",
});
if (!environment.ok) throw new Error("doctor rejected a valid ceiling");
expect(environment.value.REA_BRIDGE_MAX_REQUEST_BYTES).toBe(8192);
expect(environment.ok).toBe(true);
});
it("sets the maximum to the serialized string bound minus the framing LF", () => {
@@ -135,16 +156,45 @@ it.each([
const doctor = parseEnvironment({
[BRIDGE_MAX_REQUEST_BYTES_SETTING]: value,
});
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error._tag).toBe("ConfigurationError");
expect(startup.error.settings).toContainEqual({
setting: BRIDGE_MAX_REQUEST_BYTES_SETTING,
constraint: parsed.error.message,
});
if (doctor.ok) throw new Error(`Doctor accepted ${JSON.stringify(value)}`);
expect(doctor.error._tag).toBe("ConfigurationError");
expect(doctor.error.settings).toContainEqual({
setting: BRIDGE_MAX_REQUEST_BYTES_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
expect(
startup.value.invalidRuntimeSeeds?.["bridge.max_request_bytes"]?.settings,
).toEqual([
expect.objectContaining({ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING }),
]);
const live = runtimeAnalysisConfig(startup.value);
expect(() => live.bridgeMaxRequestBytes.maximumBytes).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "bridge.max_request_bytes": 8192 },
},
() => ({
maximumBytes: live.bridgeMaxRequestBytes.maximumBytes,
source: live.bridgeMaxRequestBytes.source,
}),
),
).toEqual({ maximumBytes: 8192, source: "runtime" });
try {
void live.bridgeMaxRequestBytes.maximumBytes;
throw new Error("reset override activated the built-in default");
} catch (cause) {
expect(cause).toBeInstanceOf(ConfigurationError);
if (cause instanceof ConfigurationError)
expect(projectAnalysisError(cause)).toMatchObject({
code: "configuration_invalid",
details: {
settings: [{ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING }],
},
});
}
if (!doctor.ok)
throw new Error(`Doctor rejected a runtime seed ${JSON.stringify(value)}`);
expect(() => parseBridgeMaxRequestBytes(value)).not.toThrow();
if (parseBridgeMaxRequestBytes(value).ok)
throw new Error(`Bridge parser accepted ${JSON.stringify(value)}`);
});
+43 -18
View File
@@ -1,13 +1,24 @@
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
} from "../domain/analysisResourceLimits.js";
export const BRIDGE_MAX_REQUEST_BYTES_SETTING = "REA_BRIDGE_MAX_REQUEST_BYTES";
/** Request payload ceiling used when the setting is unset. Today's fixed constant. */
export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
};
/**
* Smallest ceiling that still admits the largest bounded bridge request.
@@ -22,10 +33,7 @@ export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
* Comments, search text, and Cutter command text are unbounded; this ceiling
* is what limits them. The floor stays below the 16 MiB default so a caller
* can lower the ceiling.
*/
export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
/**
*
* Largest configurable request, in encoded payload bytes.
*
* The ceiling is the final JSON payload, not the raw command. Callers measure
@@ -39,15 +47,11 @@ export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
* then parses that string. A fitting response does not keep the paged wire
* frame, a second concatenated copy, and an outer JSON string.
*/
const NODE_MAX_STRING_LENGTH = 536_870_888;
const FRAMING_LF_CODE_UNITS = 1;
export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES =
NODE_MAX_STRING_LENGTH - FRAMING_LF_CODE_UNITS;
/** Effective bridge request ceiling and where its value came from. */
export interface BridgeMaxRequestBytes {
readonly maximumBytes: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
}
export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = {
@@ -99,6 +103,9 @@ export const parseBridgeMaxRequestBytes = (
export const resolveBridgeMaxRequestBytes = (
environment: Readonly<Record<string, string | undefined>>,
): BridgeMaxRequestBytes => {
const override = currentAnalysisSettingOverride("bridge.max_request_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
const parsed = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
@@ -107,16 +114,22 @@ export const resolveBridgeMaxRequestBytes = (
};
/** How to read and change the effective bridge request ceiling. */
export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) =>
analysisSettingLimit(
"bridge.max_request_bytes",
limit.maximumBytes,
limit.source,
);
export const bridgeMaxRequestBytesRecovery = (
limit: BridgeMaxRequestBytes,
peer: BridgePeer = "Hopper",
): string =>
[
`The effective ${BRIDGE_MAX_REQUEST_BYTES_SETTING} value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}).`,
`Set ${BRIDGE_MAX_REQUEST_BYTES_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}, then restart REA.`,
`The effective bridge.max_request_bytes value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}). It can be changed at runtime with update_analysis_settings. The launch seed is ${BRIDGE_MAX_REQUEST_BYTES_SETTING}.`,
peer === "Ghidra"
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies ${BRIDGE_MAX_REQUEST_BYTES_SETTING} to Ghidra requests.`
: "Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.",
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies bridge.max_request_bytes to Ghidra requests.`
: "The Hopper or Cutter bridge keeps the ceiling it started with until that bridge starts again.",
].join(" ");
const limitFacts = (
@@ -142,7 +155,11 @@ export const bridgeRequestOversizeError = (
"transport",
`${refusal} ${recovery}`,
{ ...limitFacts(limit), payload_bytes: payloadBytes },
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -163,7 +180,11 @@ export const bridgeRequestLimitMismatchError = (
bridge_max_request_bytes: bridgeBytes,
client_max_request_bytes: limit.maximumBytes,
},
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -177,6 +198,10 @@ export const bridgeResponseOversizeError = (
"transport",
`Cutter bridge response exceeded its framed transport limit of ${String(limit.maximumBytes)} bytes. ${recovery}`,
limitFacts(limit),
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
+15 -14
View File
@@ -21,6 +21,7 @@ import {
CAPTURE_DECODER_OLD_GENERATION_SETTING,
DEFAULT_CAPTURE_DECODER_HEAP,
parseCaptureDecoderHeap,
resolveCaptureDecoderHeap,
} from "./captureDecoderHeap.js";
import { MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB } from "../domain/analysisResourceLimits.js";
@@ -103,11 +104,10 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
/floor 8 MiB.*maximum 16384 MiB/iu,
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toMatch(/restart REA/iu);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toContain(
"capture.decoder_old_generation_mib",
);
expect(projected.remediation.action).toMatch(/without a REA restart/iu);
expect(projected.remediation.action).toMatch(
/removes inherited NODE_OPTIONS/iu,
);
@@ -125,7 +125,8 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
unit: "MiB",
applies_at: "next_worker",
agent_editable: true,
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
old_generation_heap_mib: 256,
@@ -258,20 +259,20 @@ it.each([
const startup = parseEnvironment({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
});
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error.settings).toContainEqual({
setting: CAPTURE_DECODER_OLD_GENERATION_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
const config = parseConfig({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
});
if (config.ok)
throw new Error(`parseConfig accepted ${JSON.stringify(value)}`);
expect(projectAnalysisError(config.error)).toMatchObject({
code: "configuration_invalid",
message: expect.stringContaining("REA_CAPTURE_DECODER_OLD_GENERATION_MIB"),
});
if (!config.ok)
throw new Error(
`parseConfig rejected a runtime seed ${JSON.stringify(value)}`,
);
expect(() =>
resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
}),
).toThrow(ConfigurationError);
expect(
() =>
new NodeCaptureAdapter("har", {
+23 -3
View File
@@ -6,6 +6,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const CAPTURE_DECODER_OLD_GENERATION_SETTING =
@@ -60,6 +61,11 @@ export const parseCaptureDecoderHeap = (
export const resolveCaptureDecoderHeap = (
environment: Readonly<Record<string, string | undefined>>,
): CaptureDecoderHeap => {
const override = currentAnalysisSettingOverride(
"capture.decoder_old_generation_mib",
);
if (override !== undefined)
return { oldGenerationMiB: override, source: "runtime" };
const parsed = parseCaptureDecoderHeap(
environment[CAPTURE_DECODER_OLD_GENERATION_SETTING],
);
@@ -67,11 +73,25 @@ export const resolveCaptureDecoderHeap = (
return parsed.value;
};
/** How to read and change the effective HAR/SAZ/qlog old-generation heap. */
export const captureDecoderHeapRecovery = (heap: CaptureDecoderHeap): string =>
const captureDecoderHeapFacts = (heap: CaptureDecoderHeap): string =>
[
`The effective ${CAPTURE_DECODER_OLD_GENERATION_SETTING} value is ${String(heap.oldGenerationMiB)} MiB (source ${heap.source}; default ${String(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB, floor ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB, maximum ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB).`,
`Set ${CAPTURE_DECODER_OLD_GENERATION_SETTING} in the environment that starts REA to a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}, then restart REA. In a running MCP server, use update_analysis_settings with setting capture.decoder_old_generation_mib and action set instead; the new value applies to new work without a REA restart.`,
`On Node.js the decoder passes --max-old-space-size=${String(heap.oldGenerationMiB)} and removes inherited NODE_OPTIONS, so additional host memory and NODE_OPTIONS do not raise this heap. The ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB semi-space stays fixed.`,
"On Bun the V8 flags are ignored. Windows limits the decoder's committed memory to this value plus a 512 MiB reserve. Other Bun hosts enforce no decoder heap limit.",
].join(" ");
/** Recovery after this REA process has exited. */
export const captureDecoderHeapRecovery = (heap: CaptureDecoderHeap): string =>
[
captureDecoderHeapFacts(heap),
`Set ${CAPTURE_DECODER_OLD_GENERATION_SETTING} in the environment that starts the next REA invocation. It must be a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}.`,
].join(" ");
/** Recovery while this session can still call update_analysis_settings. */
export const captureDecoderHeapContinuingRecovery = (
heap: CaptureDecoderHeap,
): string =>
[
captureDecoderHeapFacts(heap),
`Call update_analysis_settings with setting capture.decoder_old_generation_mib. It applies to the next decoder in this session. The launch seed remains ${CAPTURE_DECODER_OLD_GENERATION_SETTING}.`,
].join(" ");
+52 -21
View File
@@ -15,6 +15,7 @@ import {
} from "../domain/analysisResourceLimits.js";
import { GdbSessionManager } from "../gdb/GdbSessionManager.js";
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { parseEnvironment } from "./environment.js";
import {
DEBUGGER_COMMAND_DEADLINE_SETTING,
@@ -138,14 +139,52 @@ it.each([
);
expect(detail.constraint).toContain(`default is ${defaultValue} ${unit}`);
const startup = parseEnvironment({ [setting]: value });
if (startup.ok) throw new Error(`${setting} was accepted at startup`);
expect(projectAnalysisError(startup.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [detail] },
});
const runtimeSeed =
setting === DEBUGGER_COMMAND_DEADLINE_SETTING ||
setting === DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING;
if (runtimeSeed) {
if (!startup.ok) throw new Error(`${setting} was rejected at startup`);
} else {
if (startup.ok) throw new Error(`${setting} was accepted at startup`);
expect(projectAnalysisError(startup.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [detail] },
});
}
},
);
it("applies runtime debugger overrides before rejecting invalid launch values", () => {
const parsed = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"debugger.command_deadline_ms": 25,
"debugger.max_command_output_bytes": 4096,
},
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "nope",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: "0",
}),
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.commandDeadline.source).toBe("default");
expect(parsed.value.maxCommandOutputBytes.source).toBe("default");
const sessions = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "debugger.command_deadline_ms": 25 },
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_MAX_SESSIONS_SETTING]: "nope",
}),
);
expect(sessions.ok).toBe(false);
});
it("names the session setting on a Rizin capacity refusal", async () => {
const manager = new RizinDebugSessionManager(
{ [DEBUGGER_MAX_SESSIONS_SETTING]: "1" },
@@ -171,7 +210,8 @@ it("names the session setting on a Rizin capacity refusal", async () => {
minimum: MINIMUM_DEBUGGER_MAX_SESSIONS,
maximum: MAXIMUM_DEBUGGER_MAX_SESSIONS,
unit: "sessions",
restart_required: true,
update_mode: "restart",
pending_restart: false,
max_sessions: 1,
active_or_reserved_sessions: 1,
},
@@ -247,30 +287,21 @@ it("does not let one Rizin manager consume another manager's session cap", async
}
});
it("refuses an invalid GDB deadline before a session starts", () => {
it("defers an invalid GDB deadline until a command reads it", () => {
expect(
() =>
new GdbSessionManager({
environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" },
}),
).toThrow(ConfigurationError);
).not.toThrow();
const parsed = parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "0",
});
if (parsed.ok) throw new Error("invalid deadline was accepted");
try {
new GdbSessionManager({
environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" },
});
} catch (error) {
if (!(error instanceof ConfigurationError)) throw error;
expect(projectAnalysisError(error)).toMatchObject({
code: "configuration_invalid",
details: {
settings: parsed.error.settings,
},
});
}
expect(projectAnalysisError(parsed.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: parsed.error.settings },
});
expect(
resolveDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
+73 -22
View File
@@ -1,3 +1,5 @@
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import type { AnalysisSettingValues } from "../domain/analysisSettings.js";
import {
DEBUGGER_CONTROL_REPLY_BYTES,
DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
@@ -34,7 +36,7 @@ export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING =
export interface DebuggerAdmissionLimit {
readonly setting: string;
readonly value: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultValue: number;
readonly minimum: number;
readonly maximum: number;
@@ -131,14 +133,11 @@ export const debuggerMaxCommandOutputBytesSchema = positiveDecimalIntegerSchema(
);
/** Schemas startup validation spreads. The setting names are not restated. */
/** Restart-only debugger caps. Runtime deadline and output are parsed per call. */
export const debuggerSessionLimitEnvironmentShape = {
[DEBUGGER_COMMAND_DEADLINE_SETTING]:
debuggerCommandDeadlineMsSchema.optional(),
[DEBUGGER_MAX_SESSIONS_SETTING]: debuggerMaxSessionsSchema.optional(),
[DEBUGGER_MAX_PENDING_COMMANDS_SETTING]:
debuggerMaxPendingCommandsSchema.optional(),
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
debuggerMaxCommandOutputBytesSchema.optional(),
};
export interface DebuggerSessionLimits {
@@ -148,6 +147,12 @@ export interface DebuggerSessionLimits {
readonly maxCommandOutputBytes: DebuggerAdmissionLimit;
}
const RUNTIME_DEBUGGER_OVERRIDES = {
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "debugger.command_deadline_ms",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
"debugger.max_command_output_bytes",
} as const satisfies Partial<Record<string, keyof AnalysisSettingValues>>;
const parseOne = (
admission: DebuggerAdmissionLimit,
schema: ReturnType<typeof positiveDecimalIntegerSchema>,
@@ -156,25 +161,50 @@ const parseOne = (
): Result<DebuggerAdmissionLimit, ConfigurationError> => {
if (value === undefined) return ok(admission);
const parsed = schema.safeParse(value);
return parsed.success
? ok({ ...admission, value: parsed.data, source: "setting" })
: err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
if (parsed.success)
return ok({ ...admission, value: parsed.data, source: "setting" });
const override =
RUNTIME_DEBUGGER_OVERRIDES[
admission.setting as keyof typeof RUNTIME_DEBUGGER_OVERRIDES
];
if (
override !== undefined &&
currentAnalysisOperationSnapshot()?.overrides[override] !== undefined
)
return ok(admission);
return err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
};
/** Resolve every shared debugger cap from one environment. Invalid values are refused. */
export const parseDebuggerSessionLimits = (
/** Parse the selected runtime cap without coupling it to another setting. */
export const parseDebuggerCommandDeadline = (
environment: Readonly<Record<string, string | undefined>>,
): Result<DebuggerSessionLimits, ConfigurationError> => {
const commandDeadline = parseOne(
) =>
parseOne(
DEFAULT_DEBUGGER_COMMAND_DEADLINE,
debuggerCommandDeadlineMsSchema,
debuggerCommandDeadlineConstraint,
environment[DEBUGGER_COMMAND_DEADLINE_SETTING],
);
export const parseDebuggerCommandOutput = (
environment: Readonly<Record<string, string | undefined>>,
) =>
parseOne(
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT,
debuggerMaxCommandOutputBytesSchema,
debuggerMaxCommandOutputBytesConstraint,
environment[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING],
);
/** Resolve every shared debugger cap from one environment. Invalid values are refused. */
export const parseDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): Result<DebuggerSessionLimits, ConfigurationError> => {
const commandDeadline = parseDebuggerCommandDeadline(environment);
const maxSessions = parseOne(
DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT,
debuggerMaxSessionsSchema,
@@ -187,12 +217,7 @@ export const parseDebuggerSessionLimits = (
debuggerMaxPendingCommandsConstraint,
environment[DEBUGGER_MAX_PENDING_COMMANDS_SETTING],
);
const maxCommandOutputBytes = parseOne(
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT,
debuggerMaxCommandOutputBytesSchema,
debuggerMaxCommandOutputBytesConstraint,
environment[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING],
);
const maxCommandOutputBytes = parseDebuggerCommandOutput(environment);
const settings = [
commandDeadline,
maxSessions,
@@ -219,6 +244,32 @@ export const parseDebuggerSessionLimits = (
});
};
const RUNTIME_DEBUGGER_SETTINGS = new Set<string>([
DEBUGGER_COMMAND_DEADLINE_SETTING,
DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING,
]);
/**
* Session and pending caps still fail construction. An invalid runtime deadline
* or output cap does not: the next command reads the snapshot or the launch seed.
*/
export const debuggerConstructionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): DebuggerSessionLimits => {
const parsed = parseDebuggerSessionLimits(environment);
if (parsed.ok) return parsed.value;
const blockers = parsed.error.settings.filter(
(setting) => !RUNTIME_DEBUGGER_SETTINGS.has(setting.setting),
);
if (blockers.length > 0) throw parsed.error;
const startup = { ...environment };
delete startup[DEBUGGER_COMMAND_DEADLINE_SETTING];
delete startup[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING];
const again = parseDebuggerSessionLimits(startup);
if (!again.ok) throw again.error;
return again.value;
};
/** Refuse an invalid launch cap instead of falling back to the default. */
export const resolveDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
+33 -55
View File
@@ -6,7 +6,10 @@ import { z } from "zod";
import { mcpInputBudgetSchema } from "./mcpInputBudget.js";
import { isAbsolute } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
ConfigurationError,
type ConfigurationSettingIssue,
} from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisProviderSelectorSchema } from "../domain/providerSelection.js";
import {
@@ -16,34 +19,13 @@ import {
import { ghidraDisabledAnalyzersSchema } from "./ghidraDisabledAnalyzers.js";
import {
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
ghidraDecompileTimeoutSchema,
providerStartupTimeoutSchema,
} from "./providerStartupTimeout.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
} from "../domain/analysisResourceLimits.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js";
import { mcpToolCatalogProfileSchema } from "./mcpToolCatalogProfile.js";
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js";
import { asarMaxListingBytesSchema } from "./asarListingAdmission.js";
import {
ghidraValueFlowCountSchema,
ghidraDecompilePayloadMiBSchema,
} from "./ghidraValueFlowLimits.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js";
import { debuggerSessionLimitEnvironmentShape } from "./debuggerSessionLimits.js";
import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js";
import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js";
import {
windowsChangeTimeDriftMsSchema,
windowsChangeTimeSettleMaxPollsSchema,
windowsChangeTimeSettlePollMsSchema,
} from "./windowsChangeTimeSettle.js";
import {
mcpDoctorDeadlineMsSchema,
mcpFirstCatalogBudgetMsSchema,
@@ -58,26 +40,8 @@ import { parseJebMcpEndpoint } from "./jebMcpEndpoint.js";
const environmentSchema = z.object({
...debuggerSessionLimitEnvironmentShape,
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema.optional(),
REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(),
REA_MCP_MAX_INPUT_ELEMENTS: mcpInputBudgetSchema.optional(),
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema.optional(),
REA_ASAR_MAX_LISTING_BYTES: asarMaxListingBytesSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS: ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION:
ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_EDGES: ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB:
ghidraDecompilePayloadMiBSchema.optional(),
REA_CAPTURE_DECODER_OLD_GENERATION_MIB:
captureDecoderOldGenerationMiBSchema.optional(),
REA_REGEX_MATCHING_DEADLINE_MS: regexMatchingDeadlineMsSchema.optional(),
REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema.optional(),
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema.optional(),
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS:
windowsChangeTimeSettlePollMsSchema.optional(),
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS:
windowsChangeTimeSettleMaxPollsSchema.optional(),
REA_MCP_INITIALIZE_BUDGET_MS: mcpInitializeBudgetMsSchema.optional(),
REA_MCP_TOOLS_LIST_BUDGET_MS: mcpToolsListBudgetMsSchema.optional(),
REA_MCP_FIRST_CATALOG_BUDGET_MS: mcpFirstCatalogBudgetMsSchema.optional(),
@@ -91,7 +55,6 @@ const environmentSchema = z.object({
.min(1)
.refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute")
.optional(),
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema.optional(),
REA_JEB_MCP_URL: z
.string()
.superRefine((value, context) => {
@@ -121,19 +84,9 @@ const environmentSchema = z.object({
.min(1)
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
.optional(),
REA_GHIDRA_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
),
REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
),
REA_SWIFTC_COMPILE_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
),
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(),
REA_GHIDRA_PROJECT_PATH: ghidraProjectPathSchema.optional(),
REA_GHIDRA_CHECKPOINT_DIR: z
.string()
@@ -165,6 +118,31 @@ const environmentSchema = z.object({
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
});
/** Copy producer-owned update facts from a Zod issue without inventing them. */
const configurationSettingFromIssue = (issue: {
readonly path: readonly PropertyKey[];
readonly message: string;
readonly code: string;
readonly params?: Readonly<Record<string, unknown>> | undefined;
}): ConfigurationSettingIssue => {
const setting = String(issue.path[0] ?? "environment");
const constraint = issue.message;
if (issue.code !== "custom" || issue.params === undefined)
return { setting, constraint };
const updateMode = issue.params.update_mode;
const pendingRestart = issue.params.pending_restart;
return {
setting,
constraint,
...(updateMode === "runtime" || updateMode === "restart"
? { update_mode: updateMode }
: {}),
...(typeof pendingRestart === "boolean"
? { pending_restart: pendingRestart }
: {}),
};
};
/** Parse the JEB-only timeout at its isolated provider-composition boundary. */
export const parseJebMcpTimeoutMs = (
value: string | undefined,
@@ -231,10 +209,10 @@ export const parseEnvironmentValues = (
const parsed = environmentSchema.safeParse(literals);
if (!parsed.success) {
// Zod messages name the constraint, never the rejected value.
const settings = parsed.error.issues.map((issue) => ({
setting: String(issue.path[0] ?? "environment"),
constraint: issue.message,
}));
// A producer may attach update_mode and pending_restart on a custom issue.
const settings = parsed.error.issues.map((issue) =>
configurationSettingFromIssue(issue),
);
return err(
new ConfigurationError("Invalid REA environment configuration", {
cause: parsed.error,
+33 -18
View File
@@ -11,8 +11,12 @@ import {
type HopperCallerScanFacts,
} from "../domain/native/hopperCallerReconciliation.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
HOPPER_CALLER_SCAN_BUDGET_SETTING,
hopperCallerScanLimit,
hopperCallerScanRecovery,
} from "./hopperCallerScanBudget.js";
@@ -53,25 +57,38 @@ it("records the default caller-scan budget and forwards an explicit setting", ()
).toEqual({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
});
it("rejects a malformed caller-scan seed unless an override is active", () => {
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "0" });
if (!parsed.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(parsed.value);
expect(() => live.hopperCallerScanBudget.milliseconds).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "hopper.caller_scan_budget_ms": 250 },
},
() => live.hopperCallerScanBudget.milliseconds,
),
).toBe(250);
});
it("explains the time budget and that fixed byte allowances have no override", () => {
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
if (!parsed.ok) throw parsed.error;
const budget = parsed.value.hopperCallerScanBudget;
const time = hopperCallerScanRecovery(
facts({ stop: { code: "work_budget", budget_ms: 250 } }),
budget,
);
expect(time).toContain(`${HOPPER_CALLER_SCAN_BUDGET_SETTING} is 250 ms`);
expect(time).toContain(
`The default is ${String(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`,
);
expect(time).toContain(
`from ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} to ${String(MAX_NODE_TIMER_DELAY_MS)} ms`,
);
expect(time).toContain("no runtime override");
expect(time).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(hopperCallerScanLimit(budget)).toMatchObject({
setting: "hopper.caller_scan_budget_ms",
effective_value: 250,
source: "setting",
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
maximum: MAX_NODE_TIMER_DELAY_MS,
update_mode: "runtime",
pending_restart: false,
});
expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000);
const page = hopperCallerScanRecovery(
facts({
stop: {
@@ -82,9 +99,7 @@ it("explains the time budget and that fixed byte allowances have no override", (
budget,
);
expect(page).toContain("no runtime override");
expect(page).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(page).toContain("hopper.caller_scan_budget_ms does not raise them");
expect(page).not.toContain("is 250 ms");
expect(
hopperCallerScanRecovery(facts({ stop: { code: "complete" } }), budget),
+15 -4
View File
@@ -1,3 +1,4 @@
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import {
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
MAX_NODE_TIMER_DELAY_MS,
@@ -16,7 +17,7 @@ export const HOPPER_CALLER_SCAN_BUDGET_SETTING =
export interface HopperCallerScanBudget {
readonly setting: typeof HOPPER_CALLER_SCAN_BUDGET_SETTING;
readonly milliseconds: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultMilliseconds: number;
readonly floorMilliseconds: number;
readonly maximumMilliseconds: number;
@@ -52,7 +53,15 @@ export const hopperCallerScanBudgetForMilliseconds = (
milliseconds,
);
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. ${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them.`;
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`;
/** Structured facts for the scan time budget. The sentence is display text. */
export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
analysisSettingLimit(
"hopper.caller_scan_budget_ms",
budget.milliseconds,
budget.source,
);
/** Environment recovery for a caller-scan limit. Domain facts stay separate. */
export const hopperCallerScanRecovery = (
@@ -64,7 +73,9 @@ export const hopperCallerScanRecovery = (
facts.stop.code === "retention_budget" ||
!facts.instructions_complete ||
facts.native_callers_complete === false;
if (facts.stop.code === "work_budget")
return `${budget.setting} is ${String(budget.milliseconds)} ms (source: ${budget.source}). The default is ${String(budget.defaultMilliseconds)} ms. Allowed values are integers from ${String(budget.floorMilliseconds)} to ${String(budget.maximumMilliseconds)} ms. Set it in the environment that starts REA and restart REA. ${fixedAllowanceRecovery}`;
if (facts.stop.code === "work_budget") {
const limit = hopperCallerScanLimit(budget);
return `hopper.caller_scan_budget_ms is ${String(limit.effective_value)} ms (source ${limit.source ?? "unknown"}; default ${String(budget.defaultMilliseconds)} ms, floor ${String(limit.minimum)} ms, maximum ${String(limit.maximum)} ms). It can be changed at runtime with update_analysis_settings. The launch seed is ${budget.setting}. ${fixedAllowanceRecovery}`;
}
return fixedLimit ? fixedAllowanceRecovery : "";
};
+43
View File
@@ -0,0 +1,43 @@
import { expect, it } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
kunaProjectDeadline,
readKunaConfiguration,
} from "../kuna/KunaConfiguration.js";
import { acceptedExportDeadline } from "../kuna/KunaProjectExport.js";
it("keeps the deadline a failed export accepted after the override is reset", () => {
const environment = {
REA_KUNA_COMMAND: "/usr/bin/kuna",
REA_KUNA_PROJECT_TIMEOUT_MS: "not-a-timeout",
};
const configuration = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "kuna.project_timeout_ms": 5000 },
},
() => {
const parsed = readKunaConfiguration(environment, "linux");
if (!parsed.ok) throw new Error(parsed.error.reason);
return parsed.value;
},
);
expect(configuration.projectTimeoutLaunchValid).toBe(false);
const accepted = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "kuna.project_timeout_ms": 5000 },
},
() => kunaProjectDeadline(configuration),
);
expect(accepted).toEqual({
setting: "REA_KUNA_PROJECT_TIMEOUT_MS",
ms: 5000,
source: "runtime",
});
expect(() => kunaProjectDeadline(configuration)).toThrow(
/no runtime override is active/,
);
expect(acceptedExportDeadline(configuration, accepted)).toEqual(accepted);
expect(accepted).toMatchObject({ ms: 5000, source: "runtime" });
});
+70 -36
View File
@@ -5,9 +5,16 @@ import {
type ConfigurationSettingIssue,
} from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { launchAnalysisSetting } from "./analysisSettings.js";
import type { AnalysisSettingId } from "../domain/analysisSettings.js";
import { parseEnvironment } from "./environment.js";
import { providerStartupTimeoutSchema } from "./providerStartupTimeout.js";
import { homeDirectoryFromEnvironment } from "./homeDirectory.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
} from "../domain/analysisResourceLimits.js";
import { DEFAULT_BRIDGE_MAX_REQUEST } from "./bridgeMaxRequestBytes.js";
import { hopperCallerScanBudgetFromEnvironment } from "./hopperCallerScanBudget.js";
import { DEFAULT_REGEX_MATCHING_DEADLINE } from "./regexMatchingDeadline.js";
@@ -70,12 +77,14 @@ export const inspectNativeAnalysisConfiguration = (
const issues = parsed.ok ? [] : parsed.error.settings;
const configuredTimeout = environment.REA_HOPPER_STARTUP_TIMEOUT_MS;
if (configuredTimeout === undefined) return { issues };
if (parsed.ok)
const admitted = launchAnalysisSetting(
"hopper.startup_timeout_ms",
environment,
);
if (parsed.ok && admitted.ok && admitted.value.value !== null)
return {
issues,
configuredHopperStartupTimeout: ok(
parsed.value.REA_HOPPER_STARTUP_TIMEOUT_MS,
),
configuredHopperStartupTimeout: ok(admitted.value.value),
};
// Another setting may reject the aggregate before this supplied timeout's
// facts are available. Its dedicated diagnostic still uses the same schema.
@@ -103,6 +112,28 @@ export const parseConfig = (
const parsed = parseEnvironment(nativeAnalysisEnvironment(environment));
if (!parsed.ok) return parsed;
const env = parsed.value;
const invalidRuntimeSeeds: Partial<
Record<AnalysisSettingId, ConfigurationError>
> = {};
const admitted = (id: AnalysisSettingId): number | null | undefined => {
const result = launchAnalysisSetting(id, environment);
if (result.ok) return result.value.value;
invalidRuntimeSeeds[id] = result.error;
return undefined;
};
const ghidraStartup = admitted("ghidra.startup_timeout_ms");
const hopperStartup = admitted("hopper.startup_timeout_ms");
const decompileTimeout = admitted("ghidra.decompile_timeout_seconds");
const decompilePayload = admitted("ghidra.decompile_max_payload_mib");
const valueFlowOperations = admitted("ghidra.value_flow_max_operations");
const valueFlowInputs = admitted(
"ghidra.value_flow_max_inputs_per_operation",
);
const valueFlowEdges = admitted("ghidra.value_flow_max_edges");
const callerScan = admitted("hopper.caller_scan_budget_ms");
const bridgeBytes = admitted("bridge.max_request_bytes");
const regexDeadline = admitted("regex.matching_deadline_ms");
const regexHeap = admitted("regex.worker_heap_request_mib");
return ok({
...(env.REA_MCP_TOOL_FAMILIES === undefined
? {}
@@ -141,34 +172,22 @@ export const parseConfig = (
? {}
: { ghidraCheckpointDirectory: env.REA_GHIDRA_CHECKPOINT_DIR }),
ghidraJavaHome: env.JAVA_HOME,
ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS,
...(env.REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB === undefined
ghidraStartupTimeoutMs: ghidraStartup ?? DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
...(decompilePayload === undefined || decompilePayload === null
? {}
: {
ghidraDecompileMaxPayloadMiB:
env.REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB,
}),
...(env.REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS === undefined
: { ghidraDecompileMaxPayloadMiB: decompilePayload }),
...(valueFlowOperations === undefined || valueFlowOperations === null
? {}
: {
ghidraValueFlowMaxOperations:
env.REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS,
}),
...(env.REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION === undefined
: { ghidraValueFlowMaxOperations: valueFlowOperations }),
...(valueFlowInputs === undefined || valueFlowInputs === null
? {}
: {
ghidraValueFlowMaxInputsPerOperation:
env.REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION,
}),
...(env.REA_GHIDRA_VALUE_FLOW_MAX_EDGES === undefined
: { ghidraValueFlowMaxInputsPerOperation: valueFlowInputs }),
...(valueFlowEdges === undefined || valueFlowEdges === null
? {}
: { ghidraValueFlowMaxEdges: env.REA_GHIDRA_VALUE_FLOW_MAX_EDGES }),
...(env.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS === undefined
: { ghidraValueFlowMaxEdges: valueFlowEdges }),
...(decompileTimeout === undefined || decompileTimeout === null
? {}
: {
ghidraDecompileTimeoutSeconds:
env.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,
}),
: { ghidraDecompileTimeoutSeconds: decompileTimeout }),
...(env.REA_GHIDRA_LANGUAGE_ID === undefined
? {}
: {
@@ -195,10 +214,18 @@ export const parseConfig = (
process.platform,
homeDirectoryFromEnvironment(environment, process.platform),
),
hopperStartupTimeoutMs: env.REA_HOPPER_STARTUP_TIMEOUT_MS,
hopperStartupTimeoutMs: hopperStartup ?? DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
hopperStartupTimeoutSource:
environment.REA_HOPPER_STARTUP_TIMEOUT_MS === undefined ||
hopperStartup === undefined
? "default"
: "setting",
hopperCallerScanBudget: hopperCallerScanBudgetFromEnvironment(
environment.REA_HOPPER_CALLER_SCAN_BUDGET_MS,
env.REA_HOPPER_CALLER_SCAN_BUDGET_MS,
callerScan === undefined ||
environment.REA_HOPPER_CALLER_SCAN_BUDGET_MS === undefined
? undefined
: environment.REA_HOPPER_CALLER_SCAN_BUDGET_MS,
callerScan ?? DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
),
hopperTargetPath: env.HOPPER_TARGET_PATH,
hopperTargetKind: env.HOPPER_TARGET_KIND,
@@ -208,25 +235,32 @@ export const parseConfig = (
secretPatterns: env.referenceSecretPatterns,
},
bridgeMaxRequestBytes:
env.REA_BRIDGE_MAX_REQUEST_BYTES === undefined
bridgeBytes === undefined ||
bridgeBytes === null ||
environment.REA_BRIDGE_MAX_REQUEST_BYTES === undefined
? DEFAULT_BRIDGE_MAX_REQUEST
: {
maximumBytes: env.REA_BRIDGE_MAX_REQUEST_BYTES,
maximumBytes: bridgeBytes,
source: "setting",
},
regexMatchingDeadline:
env.REA_REGEX_MATCHING_DEADLINE_MS === undefined
regexDeadline === undefined ||
regexDeadline === null ||
environment.REA_REGEX_MATCHING_DEADLINE_MS === undefined
? DEFAULT_REGEX_MATCHING_DEADLINE
: {
deadlineMs: env.REA_REGEX_MATCHING_DEADLINE_MS,
deadlineMs: regexDeadline,
source: "setting",
},
regexWorkerHeapRequest:
env.REA_REGEX_WORKER_HEAP_REQUEST_MIB === undefined
regexHeap === undefined ||
regexHeap === null ||
environment.REA_REGEX_WORKER_HEAP_REQUEST_MIB === undefined
? DEFAULT_REGEX_WORKER_HEAP_REQUEST
: {
heapMiB: env.REA_REGEX_WORKER_HEAP_REQUEST_MIB,
heapMiB: regexHeap,
source: "setting",
},
invalidRuntimeSeeds,
});
};
+45 -19
View File
@@ -8,10 +8,14 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { regexMatchingDeadlineError } from "../process/RegexMatcher.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
DEFAULT_REGEX_MATCHING_DEADLINE,
parseRegexMatchingDeadline,
resolveRegexMatchingDeadline,
REGEX_MATCHING_DEADLINE_SETTING,
regexMatchingDeadlineContinuingRecovery,
regexMatchingDeadlineRecovery,
} from "./regexMatchingDeadline.js";
import { MAXIMUM_REGEX_MATCHING_DEADLINE_MS } from "../domain/analysisResourceLimits.js";
@@ -32,9 +36,9 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
const recovery = regexMatchingDeadlineRecovery(
DEFAULT_REGEX_MATCHING_DEADLINE,
);
const projected = projectAnalysisError(
regexMatchingDeadlineError("search_procedures"),
);
const error = regexMatchingDeadlineError("search_procedures");
const projected = projectAnalysisError(error);
const invocation = projectAnalysisError(error, "invocation");
expect(projected).toMatchObject({
code: "resource_constraint",
details: {
@@ -49,7 +53,8 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
matching_deadline_ms: DEFAULT_REGEX_MATCHING_DEADLINE.deadlineMs,
@@ -57,7 +62,13 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
},
},
});
expect(projected.remediation.action).toContain(recovery);
expect(projected.remediation.action).toContain(
regexMatchingDeadlineContinuingRecovery(DEFAULT_REGEX_MATCHING_DEADLINE),
);
expect(invocation.remediation.action).toContain(recovery);
expect(invocation.remediation.action).not.toContain(
"update_analysis_settings",
);
expect(projected.remediation.action).toContain(
REGEX_MATCHING_DEADLINE_SETTING,
);
@@ -65,8 +76,7 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
expect(projected.remediation.action).toContain("source default");
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toContain("regex.matching_deadline_ms");
expect(projected.remediation.action).toMatch(/restart REA/i);
expect(projected.remediation.action).toMatch(/without a REA restart/i);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toMatch(
/heap does not raise this deadline/i,
);
@@ -146,19 +156,35 @@ it.each([
},
]);
const startup = parseEnvironment({ REA_REGEX_MATCHING_DEADLINE_MS: value });
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error.settings).toContainEqual({
setting: REGEX_MATCHING_DEADLINE_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
const config = parseConfig({ REA_REGEX_MATCHING_DEADLINE_MS: value });
if (config.ok)
throw new Error(`parseConfig accepted ${JSON.stringify(value)}`);
expect(projectAnalysisError(config.error)).toMatchObject({
code: "configuration_invalid",
message: expect.stringContaining("REA_REGEX_MATCHING_DEADLINE_MS"),
});
if (!config.ok)
throw new Error(
`parseConfig rejected a runtime seed ${JSON.stringify(value)}`,
);
expect(
() => new KunaProvider({ REA_REGEX_MATCHING_DEADLINE_MS: value }),
config.value.invalidRuntimeSeeds?.["regex.matching_deadline_ms"]?.settings,
).toEqual([
expect.objectContaining({ setting: REGEX_MATCHING_DEADLINE_SETTING }),
]);
const live = runtimeAnalysisConfig(config.value);
expect(() => live.regexMatchingDeadline.deadlineMs).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "regex.matching_deadline_ms": 250 },
},
() => ({
deadlineMs: live.regexMatchingDeadline.deadlineMs,
source: live.regexMatchingDeadline.source,
}),
),
).toEqual({ deadlineMs: 250, source: "runtime" });
expect(() =>
resolveRegexMatchingDeadline({ REA_REGEX_MATCHING_DEADLINE_MS: value }),
).toThrow(ConfigurationError);
});
+20 -3
View File
@@ -5,6 +5,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const REGEX_MATCHING_DEADLINE_SETTING = "REA_REGEX_MATCHING_DEADLINE_MS";
@@ -57,6 +58,9 @@ export const parseRegexMatchingDeadline = (
export const resolveRegexMatchingDeadline = (
environment: Readonly<Record<string, string | undefined>>,
): RegexMatchingDeadline => {
const override = currentAnalysisSettingOverride("regex.matching_deadline_ms");
if (override !== undefined)
return { deadlineMs: override, source: "runtime" };
const parsed = parseRegexMatchingDeadline(
environment[REGEX_MATCHING_DEADLINE_SETTING],
);
@@ -64,12 +68,25 @@ export const resolveRegexMatchingDeadline = (
return parsed.value;
};
/** How to read and change the effective supervised matching deadline. */
const regexMatchingDeadlineFacts = (deadline: RegexMatchingDeadline): string =>
`The effective ${REGEX_MATCHING_DEADLINE_SETTING} value is ${String(deadline.deadlineMs)} ms (source ${deadline.source}; default ${String(DEFAULT_REGEX_MATCHING_DEADLINE_MS)} ms, floor 1 ms, maximum ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)} ms).`;
/** Recovery after this REA process has exited. The next invocation reads the launch seed. */
export const regexMatchingDeadlineRecovery = (
deadline: RegexMatchingDeadline,
): string =>
[
`The effective ${REGEX_MATCHING_DEADLINE_SETTING} value is ${String(deadline.deadlineMs)} ms (source ${deadline.source}; default ${String(DEFAULT_REGEX_MATCHING_DEADLINE_MS)} ms, floor 1 ms, maximum ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)} ms).`,
`Set ${REGEX_MATCHING_DEADLINE_SETTING} in the environment that starts REA to a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)}, then restart REA. In a running MCP server, use update_analysis_settings with setting regex.matching_deadline_ms and action set instead; the new value applies to new work without a REA restart.`,
regexMatchingDeadlineFacts(deadline),
`Set ${REGEX_MATCHING_DEADLINE_SETTING} in the environment that starts the next REA invocation. It must be a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)}.`,
"The worker old-generation heap does not raise this deadline.",
].join(" ");
/** Recovery while the same REA session can still call update_analysis_settings. */
export const regexMatchingDeadlineContinuingRecovery = (
deadline: RegexMatchingDeadline,
): string =>
[
regexMatchingDeadlineFacts(deadline),
`Call update_analysis_settings with setting regex.matching_deadline_ms. It applies to the next match in this session. The launch seed remains ${REGEX_MATCHING_DEADLINE_SETTING}.`,
"The worker old-generation heap does not raise this deadline.",
].join(" ");
+39 -15
View File
@@ -8,10 +8,14 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { regexWorkerHeapError } from "../process/RegexMatcher.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
DEFAULT_REGEX_WORKER_HEAP_REQUEST,
parseRegexWorkerHeapRequest,
resolveRegexWorkerHeapRequest,
REGEX_WORKER_HEAP_REQUEST_SETTING,
regexWorkerHeapRequestContinuingRecovery,
regexWorkerHeapRequestRecovery,
} from "./regexWorkerHeapRequest.js";
import {
@@ -32,7 +36,7 @@ it("keeps the 128 MiB request when the setting is unset", () => {
expect(parsed.value.regexWorkerHeapRequest).toEqual(
DEFAULT_REGEX_WORKER_HEAP_REQUEST,
);
const recovery = regexWorkerHeapRequestRecovery(
const recovery = regexWorkerHeapRequestContinuingRecovery(
DEFAULT_REGEX_WORKER_HEAP_REQUEST,
);
const projected = projectAnalysisError(
@@ -56,7 +60,8 @@ it("keeps the 128 MiB request when the setting is unset", () => {
applies_at: "next_worker",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
requested_worker_heap_mib: DEFAULT_REGEX_WORKER_HEAP_REQUEST.heapMiB,
@@ -76,8 +81,7 @@ it("keeps the 128 MiB request when the setting is unset", () => {
);
expect(projected.remediation.action).toContain("--max-old-space-size");
expect(projected.remediation.action).toContain("NODE_OPTIONS");
expect(projected.remediation.action).toMatch(/restart REA/i);
expect(projected.remediation.action).toMatch(/without a REA restart/i);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toMatch(/overrides this request/i);
});
@@ -167,18 +171,38 @@ it.each([
const startup = parseEnvironment({
REA_REGEX_WORKER_HEAP_REQUEST_MIB: value,
});
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error.settings).toContainEqual({
setting: REGEX_WORKER_HEAP_REQUEST_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
const config = parseConfig({ REA_REGEX_WORKER_HEAP_REQUEST_MIB: value });
if (config.ok)
throw new Error(`parseConfig accepted ${JSON.stringify(value)}`);
expect(projectAnalysisError(config.error)).toMatchObject({
code: "configuration_invalid",
message: expect.stringContaining("REA_REGEX_WORKER_HEAP_REQUEST_MIB"),
});
if (!config.ok)
throw new Error(
`parseConfig rejected a runtime seed ${JSON.stringify(value)}`,
);
expect(
config.value.invalidRuntimeSeeds?.["regex.worker_heap_request_mib"]
?.settings,
).toEqual([
expect.objectContaining({ setting: REGEX_WORKER_HEAP_REQUEST_SETTING }),
]);
const live = runtimeAnalysisConfig(config.value);
expect(() => live.regexWorkerHeapRequest.heapMiB).toThrow(ConfigurationError);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "regex.worker_heap_request_mib": 256 },
},
() => ({
heapMiB: live.regexWorkerHeapRequest.heapMiB,
source: live.regexWorkerHeapRequest.source,
}),
),
).toEqual({ heapMiB: 256, source: "runtime" });
expect(() =>
resolveRegexWorkerHeapRequest({
REA_REGEX_WORKER_HEAP_REQUEST_MIB: value,
}),
).toThrow(ConfigurationError);
expect(
() => new KunaProvider({ REA_REGEX_WORKER_HEAP_REQUEST_MIB: value }),
).toThrow(ConfigurationError);
+21 -3
View File
@@ -6,6 +6,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const REGEX_WORKER_HEAP_REQUEST_SETTING =
@@ -59,6 +60,10 @@ export const parseRegexWorkerHeapRequest = (
export const resolveRegexWorkerHeapRequest = (
environment: Readonly<Record<string, string | undefined>>,
): RegexWorkerHeapRequest => {
const override = currentAnalysisSettingOverride(
"regex.worker_heap_request_mib",
);
if (override !== undefined) return { heapMiB: override, source: "runtime" };
const parsed = parseRegexWorkerHeapRequest(
environment[REGEX_WORKER_HEAP_REQUEST_SETTING],
);
@@ -66,12 +71,25 @@ export const resolveRegexWorkerHeapRequest = (
return parsed.value;
};
/** How to read and change the regex worker old-generation request. */
const regexWorkerHeapFacts = (request: RegexWorkerHeapRequest): string =>
`The effective ${REGEX_WORKER_HEAP_REQUEST_SETTING} value is ${String(request.heapMiB)} MiB (source ${request.source}; default ${String(DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, floor ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, maximum ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB).`;
/** Recovery after this REA process has exited. */
export const regexWorkerHeapRequestRecovery = (
request: RegexWorkerHeapRequest,
): string =>
[
`The effective ${REGEX_WORKER_HEAP_REQUEST_SETTING} value is ${String(request.heapMiB)} MiB (source ${request.source}; default ${String(DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, floor ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, maximum ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB).`,
`Set ${REGEX_WORKER_HEAP_REQUEST_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)}, then restart REA. In a running MCP server, use update_analysis_settings with setting regex.worker_heap_request_mib and action set instead; the new value applies to new work without a REA restart.`,
regexWorkerHeapFacts(request),
`Set ${REGEX_WORKER_HEAP_REQUEST_SETTING} in the environment that starts the next REA invocation. It must be a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)}.`,
"A positive Node --max-old-space-size, including NODE_OPTIONS, overrides this request and sets the effective heap. That flag is not limited by this setting's floor or maximum. Raising host memory alone does not raise the request.",
].join(" ");
/** Recovery while this session can still call update_analysis_settings. */
export const regexWorkerHeapRequestContinuingRecovery = (
request: RegexWorkerHeapRequest,
): string =>
[
regexWorkerHeapFacts(request),
`Call update_analysis_settings with setting regex.worker_heap_request_mib. It changes the request for the next worker in this session. The launch seed remains ${REGEX_WORKER_HEAP_REQUEST_SETTING}.`,
"A positive Node --max-old-space-size, including NODE_OPTIONS, overrides this request and sets the effective heap. That flag is not limited by this setting's floor or maximum. Raising host memory alone does not raise the request.",
].join(" ");
+145 -81
View File
@@ -1,4 +1,8 @@
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
import {
currentAnalysisOperationSnapshot,
currentAnalysisSettingOverride,
} from "../domain/analysisOperationSnapshot.js";
import type { ConfigurationError } from "../domain/configurationErrors.js";
import { ok, type Result } from "../domain/result.js";
import type { AppConfig } from "./types.js";
@@ -13,7 +17,7 @@ import {
type AsarHeaderAdmission,
} from "./asarHeaderAdmission.js";
import {
resolveCaptureDecoderHeap,
parseCaptureDecoderHeap,
type CaptureDecoderHeap,
} from "./captureDecoderHeap.js";
import {
@@ -21,91 +25,142 @@ import {
type NativeMatchingAllowance,
} from "./nativeMatchingAllowance.js";
/** Typed views read the enclosing operation snapshot, never the ambient environment. */
/**
* Typed views read the enclosing operation snapshot, never the ambient
* environment. A malformed launch seed is rejected here unless that snapshot
* supplies an override. Resetting the override reveals the same seed.
*/
export const runtimeAnalysisConfig = (
config: AppConfig,
read?: AnalysisSettingsReader,
): AppConfig => {
if (read === undefined) return config;
const deadline = config.regexMatchingDeadline;
const heap = config.regexWorkerHeapRequest;
const scan = config.hopperCallerScanBudget;
const bridge = config.bridgeMaxRequestBytes;
const overrides = () =>
(read === undefined
? currentAnalysisOperationSnapshot()?.overrides
: read().overrides) ?? {};
const admit = (id: keyof NonNullable<AppConfig["invalidRuntimeSeeds"]>) => {
const error = config.invalidRuntimeSeeds?.[id];
if (error !== undefined) throw error;
};
return {
...config,
get ghidraStartupTimeoutMs() {
return (
read().overrides["ghidra.startup_timeout_ms"] ??
config.ghidraStartupTimeoutMs
overrides()["ghidra.startup_timeout_ms"] ??
(admit("ghidra.startup_timeout_ms"), config.ghidraStartupTimeoutMs)
);
},
get hopperStartupTimeoutMs() {
return (
read().overrides["hopper.startup_timeout_ms"] ??
config.hopperStartupTimeoutMs
overrides()["hopper.startup_timeout_ms"] ??
(admit("hopper.startup_timeout_ms"), config.hopperStartupTimeoutMs)
);
},
get hopperStartupTimeoutSource() {
if (overrides()["hopper.startup_timeout_ms"] !== undefined)
return "runtime";
admit("hopper.startup_timeout_ms");
return config.hopperStartupTimeoutSource;
},
get ghidraDecompileTimeoutSeconds() {
const value = read().overrides["ghidra.decompile_timeout_seconds"];
return value === undefined
? config.ghidraDecompileTimeoutSeconds
: value === null
? undefined
: value;
const value = overrides()["ghidra.decompile_timeout_seconds"];
if (value !== undefined) return value === null ? undefined : value;
admit("ghidra.decompile_timeout_seconds");
return config.ghidraDecompileTimeoutSeconds;
},
get ghidraDecompileMaxPayloadMiB() {
const value = read().overrides["ghidra.decompile_max_payload_mib"];
return value === undefined
? config.ghidraDecompileMaxPayloadMiB
: value === null
? undefined
: value;
const value = overrides()["ghidra.decompile_max_payload_mib"];
if (value !== undefined) return value === null ? undefined : value;
admit("ghidra.decompile_max_payload_mib");
return config.ghidraDecompileMaxPayloadMiB;
},
get ghidraValueFlowMaxOperations() {
return (
read().overrides["ghidra.value_flow_max_operations"] ??
overrides()["ghidra.value_flow_max_operations"] ??
(admit("ghidra.value_flow_max_operations"),
config.ghidraValueFlowMaxOperations ??
DEFAULT_GHIDRA_VALUE_FLOW_MAX_OPERATIONS
DEFAULT_GHIDRA_VALUE_FLOW_MAX_OPERATIONS)
);
},
get ghidraValueFlowMaxInputsPerOperation() {
return (
read().overrides["ghidra.value_flow_max_inputs_per_operation"] ??
overrides()["ghidra.value_flow_max_inputs_per_operation"] ??
(admit("ghidra.value_flow_max_inputs_per_operation"),
config.ghidraValueFlowMaxInputsPerOperation ??
DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION
DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION)
);
},
get ghidraValueFlowMaxEdges() {
return (
read().overrides["ghidra.value_flow_max_edges"] ??
config.ghidraValueFlowMaxEdges ??
DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES
overrides()["ghidra.value_flow_max_edges"] ??
(admit("ghidra.value_flow_max_edges"),
config.ghidraValueFlowMaxEdges ?? DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES)
);
},
regexMatchingDeadline: {
get deadlineMs() {
return (
read().overrides["regex.matching_deadline_ms"] ?? deadline.deadlineMs
overrides()["regex.matching_deadline_ms"] ??
(admit("regex.matching_deadline_ms"), deadline.deadlineMs)
);
},
get source() {
return read().overrides["regex.matching_deadline_ms"] === undefined
? deadline.source
: "runtime";
if (overrides()["regex.matching_deadline_ms"] !== undefined)
return "runtime";
admit("regex.matching_deadline_ms");
return deadline.source;
},
},
regexWorkerHeapRequest: {
get heapMiB() {
return (
read().overrides["regex.worker_heap_request_mib"] ?? heap.heapMiB
overrides()["regex.worker_heap_request_mib"] ??
(admit("regex.worker_heap_request_mib"), heap.heapMiB)
);
},
get source() {
return read().overrides["regex.worker_heap_request_mib"] === undefined
? heap.source
: "runtime";
if (overrides()["regex.worker_heap_request_mib"] !== undefined)
return "runtime";
admit("regex.worker_heap_request_mib");
return heap.source;
},
},
hopperCallerScanBudget: {
...scan,
get milliseconds() {
return (
overrides()["hopper.caller_scan_budget_ms"] ??
(admit("hopper.caller_scan_budget_ms"), scan.milliseconds)
);
},
get source() {
if (overrides()["hopper.caller_scan_budget_ms"] !== undefined)
return "runtime";
admit("hopper.caller_scan_budget_ms");
return scan.source;
},
},
bridgeMaxRequestBytes: {
get maximumBytes() {
return (
overrides()["bridge.max_request_bytes"] ??
(admit("bridge.max_request_bytes"), bridge.maximumBytes)
);
},
get source() {
if (overrides()["bridge.max_request_bytes"] !== undefined)
return "runtime";
admit("bridge.max_request_bytes");
return bridge.source;
},
},
};
};
export const runtimeAsarHeaderAdmission = (
environment: Readonly<Record<string, string | undefined>>,
read?: AnalysisSettingsReader,
@@ -113,61 +168,70 @@ export const runtimeAsarHeaderAdmission = (
const parsed = parseAsarHeaderAdmission(
environment.REA_ASAR_MAX_HEADER_BYTES,
);
if (!parsed.ok) throw parsed.error;
const listing = parseAsarListingAdmission(
environment.REA_ASAR_MAX_LISTING_BYTES,
);
if (!listing.ok) throw listing.error;
const baseline = parsed.value;
return read === undefined
? { ...baseline, listing: listing.value }
: {
listing: {
get maximumBytes() {
return (
read().overrides["asar.max_listing_bytes"] ??
listing.value.maximumBytes
);
},
get source() {
return read().overrides["asar.max_listing_bytes"] === undefined
? listing.value.source
: "runtime";
},
},
get maximumBytes() {
return (
read().overrides["asar.max_header_bytes"] ?? baseline.maximumBytes
);
},
get source() {
return read().overrides["asar.max_header_bytes"] === undefined
? baseline.source
: "runtime";
},
};
const header = () => {
const override =
read?.().overrides["asar.max_header_bytes"] ??
currentAnalysisSettingOverride("asar.max_header_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" as const };
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
const expandedListing = () => {
const override =
read?.().overrides["asar.max_listing_bytes"] ??
currentAnalysisSettingOverride("asar.max_listing_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" as const };
if (!listing.ok) throw listing.error;
return listing.value;
};
if (read === undefined) return { ...header(), listing: expandedListing() };
return {
get maximumBytes() {
return header().maximumBytes;
},
get source() {
return header().source;
},
listing: {
get maximumBytes() {
return expandedListing().maximumBytes;
},
get source() {
return expandedListing().source;
},
},
};
};
export const runtimeCaptureDecoderHeap = (
environment: Readonly<Record<string, string | undefined>>,
read?: AnalysisSettingsReader,
): CaptureDecoderHeap => {
const baseline = resolveCaptureDecoderHeap(environment);
return read === undefined
? baseline
: {
get oldGenerationMiB() {
return (
read().overrides["capture.decoder_old_generation_mib"] ??
baseline.oldGenerationMiB
);
},
get source() {
return read().overrides["capture.decoder_old_generation_mib"] ===
undefined
? baseline.source
: "runtime";
},
};
const parsed = parseCaptureDecoderHeap(
environment.REA_CAPTURE_DECODER_OLD_GENERATION_MIB,
);
const effective = () => {
const override =
read?.().overrides["capture.decoder_old_generation_mib"] ??
currentAnalysisSettingOverride("capture.decoder_old_generation_mib");
if (override !== undefined)
return { oldGenerationMiB: override, source: "runtime" as const };
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
if (read === undefined) return effective();
return {
get oldGenerationMiB() {
return effective().oldGenerationMiB;
},
get source() {
return effective().source;
},
};
};
/**
* Resolve the accepted operation's allowance on each call. An invalid launch
+12
View File
@@ -12,6 +12,8 @@ import type { BridgeMaxRequestBytes } from "./bridgeMaxRequestBytes.js";
import type { RegexMatchingDeadline } from "./regexMatchingDeadline.js";
import type { RegexWorkerHeapRequest } from "./regexWorkerHeapRequest.js";
import type { HopperCallerScanBudget } from "./hopperCallerScanBudget.js";
import type { ConfigurationError } from "../domain/configurationErrors.js";
import type { AnalysisSettingId } from "../domain/analysisSettings.js";
export interface AppConfig {
readonly mcpToolFamilies?: readonly McpToolFamilies[number][];
@@ -41,6 +43,8 @@ export interface AppConfig {
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
readonly hopperStartupTimeoutMs: number;
/** Whether `hopperStartupTimeoutMs` came from the launch environment. */
readonly hopperStartupTimeoutSource: "default" | "setting" | "runtime";
/** Cooperative per-operation budget for forward caller reconciliation. */
readonly hopperCallerScanBudget: HopperCallerScanBudget;
readonly hopperTargetPath: string | undefined;
@@ -54,4 +58,12 @@ export interface AppConfig {
readonly regexMatchingDeadline: RegexMatchingDeadline;
/** Regex worker old-generation request resolved at startup. */
readonly regexWorkerHeapRequest: RegexWorkerHeapRequest;
/**
* Malformed runtime launch seeds. The numeric fields keep a placeholder so
* startup can spread this object; the operation view rejects the seed unless
* a snapshot override is active.
*/
readonly invalidRuntimeSeeds?: Readonly<
Partial<Record<AnalysisSettingId, ConfigurationError>>
>;
}
+47 -19
View File
@@ -2,11 +2,13 @@ import { expect, it } from "vitest";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { parseEnvironment } from "./environment.js";
import {
WINDOWS_CHANGE_TIME_DRIFT_MS,
WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
} from "./windowsChangeTimeSettle.js";
@@ -27,7 +29,7 @@ const requiredFacts = (
expect(message).toContain(String(fact.defaultValue));
expect(message).toContain(String(fact.floor));
expect(message).toContain(String(fact.maximum));
expect(message).toContain("restart REA");
expect(message).toContain("positive decimal integer");
};
it("keeps today's settle defaults when the settings are unset", () => {
@@ -44,11 +46,7 @@ it("keeps today's settle defaults when the settings are unset", () => {
});
const startup = parseEnvironment({});
if (!startup.ok) throw new Error("unset settle settings were rejected");
expect(startup.value.REA_WINDOWS_CHANGE_TIME_DRIFT_MS).toBeUndefined();
expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS).toBeUndefined();
expect(
startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
).toBeUndefined();
expect(startup.ok).toBe(true);
});
it("uses valid settle overrides", () => {
@@ -75,9 +73,7 @@ it("uses valid settle overrides", () => {
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1000",
});
if (!startup.ok) throw new Error("valid settle settings were rejected");
expect(startup.value.REA_WINDOWS_CHANGE_TIME_DRIFT_MS).toBe(60_000);
expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS).toBe(1);
expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS).toBe(1_000);
expect(startup.ok).toBe(true);
});
it.each([
@@ -98,17 +94,49 @@ it.each([
const message = resolved.error.settings[0]?.constraint;
if (message === undefined) throw new Error("Missing configuration guidance");
requiredFacts(message, { ...bound, currentValue: value, source: "setting" });
expect(resolved.error.settings).toEqual([
{ setting: bound.setting, constraint: message },
]);
expect(projectAnalysisError(resolved.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [{ setting: bound.setting, constraint: message }] },
});
const startup = parseEnvironment({ [bound.setting]: value });
if (startup.ok) throw new Error(`Startup accepted ${bound.setting}=${value}`);
expect(startup.error.settings).toContainEqual({
const facts = {
setting: bound.setting,
constraint: message,
update_mode: "runtime" as const,
};
expect(resolved.error.settings).toEqual([facts]);
expect(resolved.error.settings[0]).not.toHaveProperty("pending_restart");
expect(message).toContain("supplied");
expect(projectAnalysisError(resolved.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [facts] },
});
const startup = parseEnvironment({ [bound.setting]: value });
if (!startup.ok)
throw new Error(`Startup rejected runtime seed ${bound.setting}`);
});
it("applies a Windows settle override before rejecting the launch value", () => {
const limits = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"windows.change_time_drift_ms": 50,
"windows.change_time_settle_poll_ms": 10,
"windows.change_time_settle_max_polls": 4,
},
},
() => {
const resolved = resolveWindowsChangeTimeSettle({
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nope",
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "0",
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001",
});
if (!resolved.ok) throw resolved.error;
return applyWindowsChangeTimeSnapshot(resolved.value);
},
);
expect(limits).toMatchObject({
driftMs: 50,
driftSource: "runtime",
pollMs: 10,
pollSource: "runtime",
maxPolls: 4,
maxPollsSource: "runtime",
});
});
+63 -20
View File
@@ -1,5 +1,14 @@
import { z } from "zod";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
} from "../domain/analysisResourceLimits.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -19,18 +28,18 @@ interface WindowsChangeTimeBound {
*/
export const WINDOWS_CHANGE_TIME_DRIFT_MS = {
setting: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS",
defaultValue: 2_000,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
floor: 1,
maximum: 60_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
unit: "ms",
} as const satisfies WindowsChangeTimeBound;
/** Delay between ChangeTime observations. Unset keeps the 25 ms poll. */
export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = {
setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS",
defaultValue: 25,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
floor: 1,
maximum: 1_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
unit: "ms",
} as const satisfies WindowsChangeTimeBound;
@@ -40,9 +49,9 @@ export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = {
*/
export const WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = {
setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS",
defaultValue: 40,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
floor: 1,
maximum: 1_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
unit: "polls",
} as const satisfies WindowsChangeTimeBound;
@@ -55,17 +64,17 @@ export interface WindowsChangeTimeSettingFailure {
readonly floor: number;
readonly maximum: number;
readonly unit: "ms" | "polls";
readonly restartRequired: true;
readonly update_mode: "runtime";
}
/** Effective settle budget and whether each part came from the default or a setting. */
export interface WindowsChangeTimeSettleLimits {
readonly driftMs: number;
readonly driftSource: "default" | "setting";
readonly driftSource: "default" | "setting" | "runtime";
readonly pollMs: number;
readonly pollSource: "default" | "setting";
readonly pollSource: "default" | "setting" | "runtime";
readonly maxPolls: number;
readonly maxPollsSource: "default" | "setting";
readonly maxPollsSource: "default" | "setting" | "runtime";
}
const windowsChangeTimeSettingFailure = (
@@ -79,14 +88,14 @@ const windowsChangeTimeSettingFailure = (
floor: bound.floor,
maximum: bound.maximum,
unit: bound.unit,
restartRequired: true,
update_mode: "runtime",
});
/** Name the rejected value, its source, the allowed range, and the required restart. */
/** Name the rejected value, its source, and the allowed range. */
const windowsChangeTimeSettingMessage = (
failure: WindowsChangeTimeSettingFailure,
): string =>
`The effective ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). Set ${failure.setting} in the environment that starts REA to a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}, then restart REA.`;
`The supplied ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). windows change-time settings can be changed at runtime with update_analysis_settings. The launch seed ${failure.setting} must be a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}. The rejected value is not active.`;
const inRange = (bound: WindowsChangeTimeBound, value: string): boolean => {
if (!decimalInteger.test(value)) return false;
@@ -100,11 +109,13 @@ export const windowsChangeTimeSettingSchema = (bound: WindowsChangeTimeBound) =>
.string()
.superRefine((value, context) => {
if (inRange(bound, value)) return;
const failure = windowsChangeTimeSettingFailure(bound, value);
context.addIssue({
code: "custom",
message: windowsChangeTimeSettingMessage(
windowsChangeTimeSettingFailure(bound, value),
),
message: windowsChangeTimeSettingMessage(failure),
params: {
update_mode: failure.update_mode,
},
});
})
.transform(Number);
@@ -117,6 +128,14 @@ export const windowsChangeTimeSettlePollMsSchema =
export const windowsChangeTimeSettleMaxPollsSchema =
windowsChangeTimeSettingSchema(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS);
const WINDOWS_CHANGE_TIME_OVERRIDES = {
[WINDOWS_CHANGE_TIME_DRIFT_MS.setting]: "windows.change_time_drift_ms",
[WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.setting]:
"windows.change_time_settle_poll_ms",
[WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.setting]:
"windows.change_time_settle_max_polls",
} as const;
const readBound = (
bound: WindowsChangeTimeBound,
environment: Readonly<Record<string, string | undefined>>,
@@ -125,11 +144,15 @@ const readBound = (
const value = environment[bound.setting];
if (value === undefined) return undefined;
const parsed = windowsChangeTimeSettingSchema(bound).safeParse(value);
if (!parsed.success) {
failures.push(windowsChangeTimeSettingFailure(bound, value));
if (parsed.success) return parsed.data;
const override =
WINDOWS_CHANGE_TIME_OVERRIDES[
bound.setting as keyof typeof WINDOWS_CHANGE_TIME_OVERRIDES
];
if (currentAnalysisOperationSnapshot()?.overrides[override] !== undefined)
return undefined;
}
return parsed.data;
failures.push(windowsChangeTimeSettingFailure(bound, value));
return undefined;
};
/** Resolve the settle budget from one environment. Invalid values are refused. */
@@ -160,6 +183,7 @@ export const resolveWindowsChangeTimeSettle = (
settings: failures.map((failure) => ({
setting: failure.setting,
constraint: windowsChangeTimeSettingMessage(failure),
update_mode: failure.update_mode,
})),
},
),
@@ -173,3 +197,22 @@ export const resolveWindowsChangeTimeSettle = (
maxPollsSource: maxPolls === undefined ? "default" : "setting",
});
};
/** Apply the accepted operation snapshot. An explicit caller budget is left alone. */
export const applyWindowsChangeTimeSnapshot = (
limits: WindowsChangeTimeSettleLimits,
): WindowsChangeTimeSettleLimits => {
const overrides = currentAnalysisOperationSnapshot()?.overrides;
if (overrides === undefined) return limits;
const driftMs = overrides["windows.change_time_drift_ms"];
const pollMs = overrides["windows.change_time_settle_poll_ms"];
const maxPolls = overrides["windows.change_time_settle_max_polls"];
return {
driftMs: driftMs ?? limits.driftMs,
driftSource: driftMs === undefined ? limits.driftSource : "runtime",
pollMs: pollMs ?? limits.pollMs,
pollSource: pollMs === undefined ? limits.pollSource : "runtime",
maxPolls: maxPolls ?? limits.maxPolls,
maxPollsSource: maxPolls === undefined ? limits.maxPollsSource : "runtime",
};
};
+8 -1
View File
@@ -48,6 +48,13 @@ const descriptor = z.strictObject({
minimum: z.number(),
maximum: z.number(),
applies_at: z.enum(["next_operation", "next_worker", "next_startup"]),
update_mode: z.enum(["runtime", "restart"]),
pending_restart: z
.boolean()
.optional()
.describe(
"Whether an accepted configuration is waiting for restart. Omitted when the launch value is invalid and there is no accepted runtime override.",
),
provider: z.string(),
operations: z.array(z.string()),
environment: z.string().optional(),
@@ -83,7 +90,7 @@ export const updateAnalysisSettingsResultSchema =
}),
),
active_work_changed: z.literal(false),
rea_restart_required: z.literal(false),
pending_restart: z.literal(false),
});
export const ANALYSIS_SETTINGS_TOOL_CONTRACTS = [
{
+53 -19
View File
@@ -18,7 +18,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
resourceConstraintRecovery,
reattributedResourceConstraint,
} from "../domain/analysisErrorCore.js";
import {
randomUUID,
@@ -47,12 +47,16 @@ import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import {
BRIDGE_MAX_REQUEST_BYTES_SETTING,
bridgeRequestLimitMismatchError,
bridgeRequestOversizeError,
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
resolveBridgeMaxRequestBytes,
parseBridgeMaxRequestBytes,
type BridgeMaxRequestBytes,
} from "../config/bridgeMaxRequestBytes.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
const descriptorSchema = z.object({
...cutterSessionSchema.shape,
@@ -255,11 +259,15 @@ export interface WindowsCutterDescriptorReader {
interface CutterBridgeClientOptions {
readonly platform?: NodeJS.Platform;
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
/** Accepted runtime overrides. The operation snapshot wins when one is bound. */
readonly read?: AnalysisSettingsReader;
}
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
export class CutterBridgeClient {
readonly #maxRequestBytes: BridgeMaxRequestBytes;
readonly #launchRequestBytes: BridgeMaxRequestBytes | undefined;
readonly #launchRequestError: ConfigurationError | undefined;
readonly #read: AnalysisSettingsReader | undefined;
readonly #directory: string;
readonly #descriptorResources = new ArtifactResourceScope();
readonly #platform: NodeJS.Platform;
@@ -270,7 +278,16 @@ export class CutterBridgeClient {
environment: Readonly<NodeJS.ProcessEnv> = process.env,
options: CutterBridgeClientOptions = {},
) {
this.#maxRequestBytes = resolveBridgeMaxRequestBytes(environment);
const launchRequest = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
this.#launchRequestBytes = launchRequest.ok
? launchRequest.value
: undefined;
this.#launchRequestError = launchRequest.ok
? undefined
: launchRequest.error;
this.#read = options.read;
const configured = environment.REA_CUTTER_BRIDGE_DIR;
const localAppData = environment.LOCALAPPDATA;
this.#platform = options.platform ?? process.platform;
@@ -306,6 +323,23 @@ export class CutterBridgeClient {
);
}
/** Ceiling for this call. A bound operation keeps the value it accepted. */
#requestLimit(): BridgeMaxRequestBytes {
const snapshot = currentAnalysisOperationSnapshot();
const override =
snapshot === undefined
? this.#read?.().overrides["bridge.max_request_bytes"]
: snapshot.overrides["bridge.max_request_bytes"];
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
if (this.#launchRequestBytes === undefined)
throw (
this.#launchRequestError ??
new ConfigurationError("Invalid REA environment configuration")
);
return this.#launchRequestBytes;
}
async retryDescriptorCleanup(): Promise<void> {
try {
await this.#descriptorResources.retryCleanup();
@@ -326,6 +360,8 @@ export class CutterBridgeClient {
options: CutterDiscoveryOptions = {},
): Promise<CutterBridgeDiscovery> {
const { signal } = options;
assertBridgeActive(signal, "list_cutter_sessions");
this.#requestLimit();
const mismatches: number[] = [];
const loaded = await this.#readDescriptors("list_cutter_sessions", signal);
if (loaded.error !== undefined)
@@ -341,7 +377,7 @@ export class CutterBridgeClient {
assertBridgeActive(signal, "list_cutter_sessions");
const advertised =
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
if (advertised !== this.#maxRequestBytes.maximumBytes) {
if (advertised !== this.#requestLimit().maximumBytes) {
// A crashed process can leave the previous ceiling on disk. Count
// the mismatch only after the hello proves a live authenticated
// bridge. The probe does not send a status request.
@@ -353,7 +389,7 @@ export class CutterBridgeClient {
const status = responseSchema.safeParse(
await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "status",
session_id: descriptor.session_id,
}),
@@ -389,12 +425,9 @@ export class CutterBridgeClient {
cause: cause.cancellation.reason,
});
if (cause.limit !== undefined)
throw new AnalysisResourceConstraintError(
throw reattributedResourceConstraint(
"list_cutter_sessions",
cause.limit.resource,
cause.limit.reason,
cause.limit.reportedLimits,
resourceConstraintRecovery(cause.limit),
cause.limit,
);
}
return undefined;
@@ -410,7 +443,7 @@ export class CutterBridgeClient {
if (mismatches.length > 0)
throw bridgeRequestLimitMismatchError(
"list_cutter_sessions",
this.#maxRequestBytes,
this.#requestLimit(),
mismatches[0] ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
"Cutter",
"No status request was sent to the mismatched session.",
@@ -433,20 +466,21 @@ export class CutterBridgeClient {
}): Promise<CutterBridgeExecution> {
try {
assertBridgeActive(input.signal, "cutter_command");
this.#requestLimit();
const descriptor = await this.#find(input.sessionId, input.signal);
assertBridgeActive(input.signal, "cutter_command");
const advertised =
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
if (advertised !== this.#maxRequestBytes.maximumBytes)
if (advertised !== this.#requestLimit().maximumBytes)
throw bridgeRequestLimitMismatchError(
"cutter_command",
this.#maxRequestBytes,
this.#requestLimit(),
advertised,
"Cutter",
);
const requestId = randomUUID();
const commandRequest = serializeRequest(
this.#maxRequestBytes,
this.#requestLimit(),
descriptor,
{
kind: "command",
@@ -633,7 +667,7 @@ export class CutterBridgeClient {
): Promise<z.infer<typeof responseSchema>> {
const raw = await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "status",
session_id: descriptor.session_id,
}),
@@ -682,7 +716,7 @@ export class CutterBridgeClient {
try {
const raw = await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "ack",
request_id: requestId,
}),
@@ -964,7 +998,7 @@ export class CutterBridgeClient {
host: descriptor.host,
port: descriptor.port,
});
const frames = new CutterBridgeResponseFrames(this.#maxRequestBytes);
const frames = new CutterBridgeResponseFrames(this.#requestLimit());
let settled = false;
const settle = (
outcome:
@@ -1039,7 +1073,7 @@ export class CutterBridgeClient {
host: descriptor.host,
port: descriptor.port,
});
const frames = new CutterBridgeResponseFrames(this.#maxRequestBytes);
const frames = new CutterBridgeResponseFrames(this.#requestLimit());
let completion: (() => void) | undefined;
let requestSent = false;
let authenticated = false;
+22
View File
@@ -96,6 +96,28 @@ const resourceConstraintRecoveryFrom = (
};
};
/**
* Re-attribute a resource refusal to another operation. Recovery and the
* structured setting limit travel with it; a new error that keeps only the
* sentence drops the setting, source, and effective value.
*/
export const reattributedResourceConstraint = (
operation: string,
error: AnalysisResourceConstraintError,
options?: AnalysisErrorOptions,
): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
operation,
error.resource,
error.reason,
error.reportedLimits,
{
...options,
...resourceConstraintRecovery(error),
...(error.limit === undefined ? {} : { limit: error.limit }),
},
);
/** Carry one refusal's recovery to an error re-attributed or redacted elsewhere. */
export const resourceConstraintRecovery = (
error: AnalysisResourceConstraintError,
+16 -1
View File
@@ -1512,7 +1512,8 @@ describe("analysis error projection: caller lifetime", () => {
minimum: limit.minimum,
maximum: limit.maximum,
agent_editable: true,
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
applies_at: "next_invocation",
scope: "rea-invocation",
environment: "REA_REGEX_MATCHING_DEADLINE_MS",
@@ -1529,11 +1530,25 @@ describe("analysis error projection: caller lifetime", () => {
expect.objectContaining({
setting: "wasm.command_timeout_ms",
source: "request",
update_mode: "parameter",
parameter: "command_timeout_ms",
applies_at: "next_invocation",
scope: "rea-invocation",
}),
);
expect(
analysisSettingLimit("wasm.command_timeout_ms", 100, "runtime"),
).toMatchObject({ source: "runtime", update_mode: "runtime" });
expect(
analysisSettingLimit("javascript.max_heap_mb", 128, "request"),
).toMatchObject({
source: "request",
update_mode: "parameter",
parameter: "max_heap_mb",
});
expect(
analysisSettingLimit("javascript.analysis_timeout_ms", 1, "default"),
).toMatchObject({ source: "default", update_mode: "runtime" });
expect(
projectAnalysisError(error, "invocation").details?.limit,
).not.toHaveProperty("environment");
+9 -3
View File
@@ -1045,9 +1045,15 @@ const lifecycleErrorDetails = (
}
if (error instanceof ConfigurationError && error.settings.length > 0)
return {
settings: error.settings.map(({ setting, constraint }) => ({
setting,
constraint,
settings: error.settings.map((issue) => ({
setting: issue.setting,
constraint: issue.constraint,
...(issue.update_mode === undefined
? {}
: { update_mode: issue.update_mode }),
...(issue.pending_restart === undefined
? {}
: { pending_restart: issue.pending_restart }),
})),
};
if (error instanceof BinaryTargetError)
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type {
AnalysisSettingValues,
AnalysisSettingsSnapshot,
} from "./analysisSettings.js";
const snapshots = new AsyncLocalStorage<AnalysisSettingsSnapshot>();
/**
* Bind the snapshot accepted for one operation. Providers and filesystem
* helpers read it at the point they enforce a limit. A later update does not
* change work already inside this call.
*/
export const runWithAnalysisOperationSnapshot = <Value>(
snapshot: AnalysisSettingsSnapshot,
operation: () => Value,
): Value => snapshots.run(snapshot, operation);
/** The snapshot for the operation on this async stack, when one was accepted. */
export const currentAnalysisOperationSnapshot = ():
| AnalysisSettingsSnapshot
| undefined => snapshots.getStore();
/** Accepted override for one setting, when this operation has a snapshot. */
export const currentAnalysisSettingOverride = <
Setting extends keyof AnalysisSettingValues,
>(
setting: Setting,
): AnalysisSettingValues[Setting] | undefined =>
currentAnalysisOperationSnapshot()?.overrides[setting];
+25
View File
@@ -191,3 +191,28 @@ export const MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = Math.floor(
* a caller cap below this budget must not truncate those required replies.
*/
export const DEBUGGER_CONTROL_REPLY_BYTES = 16 * 1024 * 1024;
/** Hopper, Ghidra, and Cutter request payload ceiling. Same bound as the bridge owner. */
export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES = 536_870_888 - 1;
/** MCP client SDK default request timeout. JEB uses it when the setting is unset. */
export const DEFAULT_JEB_MCP_TIMEOUT_MS = 60_000;
/** Kuna project export and whole-program scans. */
export const DEFAULT_KUNA_PROJECT_TIMEOUT_MS = 30 * 60 * 1000;
/** One Kuna function decompile. */
export const DEFAULT_KUNA_FUNCTION_TIMEOUT_MS = 120_000;
/** Pwntools artifact admission when producer_limits.input_bytes is omitted. */
export const DEFAULT_PWNTOOLS_MAX_INPUT_BYTES = 32 * 1024 * 1024;
export const MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES = 1024 * 1024 * 1024;
/** Windows ChangeTime settle window, poll, and poll count. */
export const DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS = 2_000;
export const MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS = 60_000;
export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 25;
export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 1_000;
export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 40;
export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 1_000;
@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
analysisSettingDefinition,
runtimeAnalysisEnvironmentNames,
} from "./analysisSettingDefinitions.js";
const source = (path: string) =>
readFileSync(join(import.meta.dirname, path), "utf8");
it("does not validate runtime-owned launch seeds at startup", () => {
const startup = [
source("../config/environment.ts"),
source("../config/debuggerSessionLimits.ts"),
].join("\n");
const mentioned = [...runtimeAnalysisEnvironmentNames()].filter(
(name) => startup.includes(`${name}:`) || startup.includes(`[${name}]`),
);
expect(mentioned).toEqual([]);
});
it("advertises the operations that consume each migrated setting", () => {
const operations = (id: Parameters<typeof analysisSettingDefinition>[0]) =>
analysisSettingDefinition(id).operations;
expect(operations("debugger.command_deadline_ms")).toEqual(
expect.arrayContaining(["close_gdb_session", "close_rizin_debug_session"]),
);
expect(operations("kuna.function_timeout_ms")).toContain("list_procedures");
expect(operations("windows.change_time_drift_ms")).toEqual(
expect.arrayContaining(["inspect_binary_layout", "inspect_recorded_crash"]),
);
expect(
operations("pwntools.max_input_bytes").includes("inspect_binary_layout"),
).toBe(true);
expect(analysisSettingDefinition("pwntools.max_input_bytes").parameter).toBe(
"producer_limits.input_bytes",
);
});
+292 -1
View File
@@ -29,6 +29,28 @@ import {
DEFAULT_JADX_OPERATION_TIMEOUT_MS,
DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES,
MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES,
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
DEFAULT_JEB_MCP_TIMEOUT_MS,
DEFAULT_KUNA_FUNCTION_TIMEOUT_MS,
DEFAULT_KUNA_PROJECT_TIMEOUT_MS,
DEFAULT_PWNTOOLS_MAX_INPUT_BYTES,
MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES,
DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
} from "./analysisResourceLimits.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
@@ -46,6 +68,7 @@ interface SettingDefinition {
readonly maximum: number;
readonly unit: "bytes" | "ms" | "seconds" | "MiB" | "count";
readonly appliesAt: "next_operation" | "next_worker" | "next_startup";
readonly updateMode: "runtime" | "restart";
readonly provider: string;
readonly relatedProviders?: readonly string[];
readonly operations: readonly string[];
@@ -67,9 +90,33 @@ const ghidraDecompileOperations = [
"trace_native_values",
] as const;
// These operations can use the shared owned-file baseline on Windows, depending
// on their input and provider. Other workflows may also use it; this is not a
// complete reader inventory.
const windowsChangeTimeOperations = [
"open_binary",
"export_web_scripts",
"capture_process_scenario",
"compare_process_captures",
"inspect_analysis_view",
"compare_bundles",
"import_evidence_bundle",
"inspect_android_package",
"search_android_classes",
"inspect_android_class",
"inspect_android_method",
"trace_android_references",
"inventory_artifact",
"inspect_artifact",
"extract_artifact",
"inspect_binary_layout",
"inspect_recorded_crash",
] as const;
/** Metadata aggregates existing owner definitions; no environment mutation or engine discovery. */
export const ANALYSIS_SETTINGS = {
"ghidra.decompile_max_payload_mib": {
updateMode: "runtime",
environment: "REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB",
defaultValue: null,
minimum: 1,
@@ -80,6 +127,7 @@ export const ANALYSIS_SETTINGS = {
operations: ghidraDecompileOperations,
},
"asar.max_listing_bytes": {
updateMode: "runtime",
environment: "REA_ASAR_MAX_LISTING_BYTES",
defaultValue: DEFAULT_ASAR_MAX_LISTING_BYTES,
minimum: 1,
@@ -103,6 +151,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_operations": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_OPERATIONS,
minimum: 1,
@@ -117,6 +166,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_inputs_per_operation": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION,
minimum: 1,
@@ -131,6 +181,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_edges": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_EDGES",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES,
minimum: 1,
@@ -145,6 +196,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"asar.max_header_bytes": {
updateMode: "runtime",
environment: "REA_ASAR_MAX_HEADER_BYTES",
defaultValue: DEFAULT_ASAR_MAX_HEADER_BYTES,
minimum: 1,
@@ -168,12 +220,14 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.startup_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_GHIDRA_STARTUP_TIMEOUT_MS",
defaultValue: DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
appliesAt: "next_startup",
provider: "ghidra",
operations: [
"open_binary",
"binary_overview",
"list_documents",
"list_procedures",
@@ -188,12 +242,14 @@ export const ANALYSIS_SETTINGS = {
],
},
"hopper.startup_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_HOPPER_STARTUP_TIMEOUT_MS",
defaultValue: DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
appliesAt: "next_startup",
provider: "hopper",
operations: [
"open_binary",
"binary_overview",
"list_documents",
"list_procedures",
@@ -208,6 +264,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.decompile_timeout_seconds": {
updateMode: "runtime",
environment: "REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS",
defaultValue: null,
minimum: 1,
@@ -218,6 +275,7 @@ export const ANALYSIS_SETTINGS = {
operations: ghidraDecompileOperations,
},
"jadx.operation_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_JADX_OPERATION_TIMEOUT_MS",
defaultValue: DEFAULT_JADX_OPERATION_TIMEOUT_MS,
@@ -231,6 +289,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"regex.matching_deadline_ms": {
updateMode: "runtime",
...timer,
environment: "REA_REGEX_MATCHING_DEADLINE_MS",
defaultValue: DEFAULT_REGEX_MATCHING_DEADLINE_MS,
@@ -240,6 +299,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["search_strings", "search_procedures"],
},
"regex.worker_heap_request_mib": {
updateMode: "runtime",
environment: "REA_REGEX_WORKER_HEAP_REQUEST_MIB",
defaultValue: DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB,
minimum: MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB,
@@ -251,6 +311,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["search_strings", "search_procedures"],
},
"capture.decoder_old_generation_mib": {
updateMode: "runtime",
environment: "REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
defaultValue: DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB,
minimum: CAPTURE_DECODER_SEMI_SPACE_MIB,
@@ -261,6 +322,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["inspect_web_network_capture"],
},
"javascript.analysis_timeout_ms": {
updateMode: "runtime",
...timer,
defaultValue: JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
provider: "rea-javascript-application",
@@ -268,6 +330,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "analysis_timeout_ms",
},
"javascript.max_heap_mb": {
updateMode: "runtime",
defaultValue: JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
minimum: JAVASCRIPT_WORKER_HEAP_MIB_MINIMUM,
maximum: JAVASCRIPT_WORKER_HEAP_MIB_MAXIMUM,
@@ -278,6 +341,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "max_heap_mb",
},
"wasm.command_timeout_ms": {
updateMode: "runtime",
...timer,
defaultValue: DEFAULT_WASM_COMMAND_TIMEOUT_MS,
maximum: MAX_WASM_COMMAND_TIMEOUT_MS,
@@ -286,6 +350,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "command_timeout_ms",
},
"native.matching_representation_bytes": {
updateMode: "runtime",
environment: "REA_NATIVE_MATCHING_REPRESENTATION_BYTES",
defaultValue: DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES,
minimum: 1,
@@ -295,7 +360,215 @@ export const ANALYSIS_SETTINGS = {
provider: "rea-function-comparison",
operations: ["match_native_functions"],
},
"bridge.max_request_bytes": {
updateMode: "runtime",
environment: "REA_BRIDGE_MAX_REQUEST_BYTES",
defaultValue: DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
minimum: MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
maximum: MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "hopper",
relatedProviders: ["ghidra", "cutter"],
// Every Hopper bridge operation, every Ghidra bridge operation, and the
// Cutter bridge calls. bridgeMaxRequestBytes.test.ts fails when a registry
// gains an operation that is missing here.
operations: [
"open_binary",
"address_name",
"comment",
"current_address",
"current_procedure",
"current_document",
"goto_address",
"inline_comment",
"list_bookmarks",
"list_documents",
"list_names",
"list_procedures",
"list_segments",
"list_strings",
"next_address",
"prev_address",
"procedure_address",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"read_function_instructions",
"read_bytes",
"address_to_file_offset",
"procedure_references",
"procedure_pseudo_code",
"resolve_containing_procedure",
"search_procedures",
"search_strings",
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
"xrefs",
"analyze_function",
"binary_overview",
"inspect_native_load_image",
"search_bytes",
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"resolve_native_call_targets",
"list_cutter_sessions",
"cutter_command",
],
},
"debugger.command_deadline_ms": {
updateMode: "runtime",
...timer,
environment: "REA_DEBUGGER_COMMAND_DEADLINE_MS",
defaultValue: DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
minimum: MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
maximum: MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
provider: "gdb",
relatedProviders: ["rizin"],
operations: [
"start_gdb_session",
"gdb_console",
"close_gdb_session",
"start_rizin_debug_session",
"rizin_debug_command",
"close_rizin_debug_session",
],
},
"debugger.max_command_output_bytes": {
updateMode: "runtime",
environment: "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES",
defaultValue: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
minimum: MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
maximum: MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "gdb",
relatedProviders: ["rizin"],
operations: ["gdb_console", "rizin_debug_command"],
},
"hopper.caller_scan_budget_ms": {
updateMode: "runtime",
...timer,
environment: "REA_HOPPER_CALLER_SCAN_BUDGET_MS",
defaultValue: DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
provider: "hopper",
operations: [
"procedure_callers",
"analyze_function",
"procedure_references",
],
},
"jeb.mcp_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_JEB_MCP_TIMEOUT_MS",
defaultValue: DEFAULT_JEB_MCP_TIMEOUT_MS,
provider: "jeb",
operations: [
"inspect_jeb_client",
"open_jeb_project",
"list_jeb_units",
"decompile_jeb_item",
],
},
"kuna.project_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_KUNA_PROJECT_TIMEOUT_MS",
defaultValue: DEFAULT_KUNA_PROJECT_TIMEOUT_MS,
provider: "kuna",
operations: [
"list_strings",
"search_strings",
"list_procedures",
"search_procedures",
"xrefs",
"procedure_callers",
"procedure_callees",
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
],
},
"kuna.function_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_KUNA_TIMEOUT_MS",
defaultValue: DEFAULT_KUNA_FUNCTION_TIMEOUT_MS,
provider: "kuna",
operations: [
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
"list_procedures",
],
},
"pwntools.max_input_bytes": {
updateMode: "runtime",
environment: "REA_PWNTOOLS_MAX_INPUT_BYTES",
defaultValue: DEFAULT_PWNTOOLS_MAX_INPUT_BYTES,
minimum: 1,
maximum: MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "pwntools",
operations: ["inspect_binary_layout", "inspect_recorded_crash"],
parameter: "producer_limits.input_bytes",
},
"windows.change_time_drift_ms": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
unit: "ms",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
relatedProviders: ["jadx"],
operations: windowsChangeTimeOperations,
},
"windows.change_time_settle_poll_ms": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
unit: "ms",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
relatedProviders: ["jadx"],
operations: windowsChangeTimeOperations,
},
"windows.change_time_settle_max_polls": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
unit: "count",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
relatedProviders: ["jadx"],
operations: windowsChangeTimeOperations,
},
} as const satisfies Record<AnalysisSettingId, SettingDefinition>;
/** Launch seeds `update_analysis_settings` can replace. Startup must not reject them. */
export const runtimeAnalysisEnvironmentNames = (): ReadonlySet<string> =>
new Set(
Object.values(ANALYSIS_SETTINGS).flatMap((definition) =>
definition.updateMode === "runtime" && "environment" in definition
? [definition.environment]
: [],
),
);
export const analysisSettingIds = Object.keys(
ANALYSIS_SETTINGS,
) as AnalysisSettingId[];
@@ -315,6 +588,23 @@ export const analysisSettingValueSchema = (id: AnalysisSettingId) => {
? number.nullable()
: number;
};
/**
* Caller argument, then the accepted runtime override, then the built-in
* default. The third value is that default, not a launch-environment seed.
*/
export const selectedAnalysisSetting = (
request: number | undefined,
override: number | undefined,
fallback: number,
): {
readonly value: number;
readonly source: "default" | "runtime" | "request";
} => {
if (request !== undefined) return { value: request, source: "request" };
if (override !== undefined) return { value: override, source: "runtime" };
return { value: fallback, source: "default" };
};
/** Project one owner's accepted budget, including the explicit per-call precedence. */
export const analysisSettingLimit = (
setting: AnalysisSettingId,
@@ -332,7 +622,8 @@ export const analysisSettingLimit = (
applies_at: definition.appliesAt,
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: source === "request" ? "parameter" : definition.updateMode,
pending_restart: false,
...(definition.parameter === undefined
? {}
: { parameter: definition.parameter }),
+25 -1
View File
@@ -17,6 +17,17 @@ export interface AnalysisSettingValues {
readonly "javascript.max_heap_mb": number;
readonly "wasm.command_timeout_ms": number;
readonly "native.matching_representation_bytes": number;
readonly "bridge.max_request_bytes": number;
readonly "debugger.command_deadline_ms": number;
readonly "debugger.max_command_output_bytes": number;
readonly "hopper.caller_scan_budget_ms": number;
readonly "jeb.mcp_timeout_ms": number;
readonly "kuna.project_timeout_ms": number;
readonly "kuna.function_timeout_ms": number;
readonly "pwntools.max_input_bytes": number;
readonly "windows.change_time_drift_ms": number;
readonly "windows.change_time_settle_poll_ms": number;
readonly "windows.change_time_settle_max_polls": number;
}
export type AnalysisSettingId = keyof AnalysisSettingValues;
export interface AnalysisSettingsSnapshot {
@@ -26,6 +37,14 @@ export interface AnalysisSettingsSnapshot {
/** Injected by the runtime owner; reads the accepted operation's immutable snapshot. */
export type AnalysisSettingsReader = () => AnalysisSettingsSnapshot;
/**
* Whether `update_analysis_settings` can change the active value.
* `runtime` applies inside this process. `restart` stays at the value this
* process already started with. A limit fact uses `parameter` when its value
* is adjustable through a per-call argument; its source is recorded separately.
*/
export type AnalysisSettingUpdateMode = "runtime" | "restart";
/** Limit facts supplied by the failing owner, never inferred from error prose. */
export interface AnalysisSettingLimit {
readonly setting: AnalysisSettingId;
@@ -37,7 +56,12 @@ export interface AnalysisSettingLimit {
readonly applies_at: "next_operation" | "next_worker" | "next_startup";
readonly agent_editable: true;
readonly scope: "rea-runtime";
readonly rea_restart_required: false;
readonly update_mode: AnalysisSettingUpdateMode | "parameter";
/**
* True only when a configured value is not the one this process is using.
* The reported effective value is active, so limit errors leave this false.
*/
readonly pending_restart: false;
readonly required_value?: number;
readonly parameter?: string;
}
+2 -1
View File
@@ -191,7 +191,8 @@ export interface ArtifactReaderLimitFact {
readonly applies_at: "next_operation";
readonly agent_editable: true;
readonly can_increase: boolean;
readonly rea_restart_required: false;
readonly update_mode: "parameter";
readonly pending_restart: false;
}
/** Runtime representation ceilings differ from aggregate plaintext-work budgets. */
+14 -3
View File
@@ -7,6 +7,10 @@ import {
export interface ConfigurationSettingIssue {
readonly setting: string;
readonly constraint: string;
/** Whether `update_analysis_settings` can change this setting, when the producer owns that fact. */
readonly update_mode?: "runtime" | "restart";
/** Whether a known accepted configuration is waiting for restart, when observed. */
readonly pending_restart?: boolean;
}
/** Rejected settings retained for diagnostics; their values are not. */
@@ -24,9 +28,16 @@ export class ConfigurationError extends AnalysisError {
constructor(message: string, options?: ConfigurationErrorOptions) {
super(message, options);
this.remediationAction = options?.remediationAction;
this.settings = (options?.settings ?? []).map(
({ setting, constraint }) => ({ setting, constraint }),
);
this.settings = (options?.settings ?? []).map((issue) => ({
setting: issue.setting,
constraint: issue.constraint,
...(issue.update_mode === undefined
? {}
: { update_mode: issue.update_mode }),
...(issue.pending_restart === undefined
? {}
: { pending_restart: issue.pending_restart }),
}));
}
}
+6 -1
View File
@@ -239,6 +239,7 @@ const redactFailure = (
...(error.remediationAction === undefined
? {}
: { remediationAction: text(error.remediationAction) }),
...(error.limit === undefined ? {} : { limit: error.limit }),
});
if (error instanceof AnalysisAccessDeniedError)
return new AnalysisAccessDeniedError(
@@ -320,7 +321,11 @@ const redactFailure = (
error.resource,
text(error.reason),
error.reportedLimits === null ? null : diagnostics(error.reportedLimits),
{ ...context, ...resourceConstraintRecovery(error, text) },
{
...context,
...resourceConstraintRecovery(error, text),
...(error.limit === undefined ? {} : { limit: error.limit }),
},
);
if (error instanceof ProviderCleanupError)
return new ProviderCleanupError(
@@ -41,7 +41,21 @@ export interface JavaScriptSourceAnalysisFailure {
export interface JavaScriptSourceAnalysisLimits {
readonly semanticPrecision?: JavaScriptSemanticPrecisionInput;
readonly heapMb: number;
/** Where `heapMb` was selected. Absent when the caller did not record it. */
readonly heapSource?:
| "default"
| "setting"
| "runtime"
| "request"
| undefined;
readonly timeoutMs: number;
/** Where `timeoutMs` was selected. Absent when the caller did not record it. */
readonly timeoutSource?:
| "default"
| "setting"
| "runtime"
| "request"
| undefined;
}
/** Sequential source-analysis authority with independently verified cleanup. */
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import { ANALYSIS_SETTINGS } from "./analysisSettingDefinitions.js";
import { NUMERIC_LIMIT_REGISTRY } from "./numericLimitRegistry.js";
const inventory = readFileSync(
join(import.meta.dirname, "../../docs/design/rea-limit-inventory.md"),
"utf8",
);
const inventoryRows = inventory
.split("\n")
.filter((line) => line.startsWith("|"));
const cells = (line: string): string[] =>
line
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
/** A markdown table row whose first cell is exactly this name. */
const exceptionRow = (name: string): string[] => {
const line = inventoryRows.find((row) => cells(row)[0] === `\`${name}\``);
expect(line, name).toBeDefined();
return cells(line ?? "");
};
it("gives every numeric exception a reason and a matching inventory row", () => {
const names = NUMERIC_LIMIT_REGISTRY.map((entry) => entry.name);
expect(new Set(names).size).toBe(names.length);
const runtimeEnvironments = new Set<string>(
Object.values(ANALYSIS_SETTINGS).flatMap((definition) =>
"environment" in definition && definition.environment !== undefined
? [definition.environment]
: [],
),
);
for (const entry of NUMERIC_LIMIT_REGISTRY) {
expect(entry.update_mode).toBe("restart");
expect(entry.reason.trim().length).toBeGreaterThanOrEqual(40);
expect(entry.policy).toBe("restart-only");
expect(["startup", "construction"]).toContain(entry.read);
expect(runtimeEnvironments.has(entry.name)).toBe(false);
const row = exceptionRow(entry.name);
expect(row.length).toBeGreaterThanOrEqual(5);
expect(row[1]?.length ?? 0).toBeGreaterThan(0);
}
expect(NUMERIC_LIMIT_REGISTRY).toHaveLength(15);
for (const definition of Object.values(ANALYSIS_SETTINGS)) {
if (!("environment" in definition) || definition.environment === undefined)
continue;
const listed = inventoryRows.some((row) =>
cells(row).includes(`\`${definition.environment}\``),
);
expect(listed, definition.environment).toBe(true);
}
expect(inventory).not.toMatch(/^\| `REA_MANAGED_LINUX_HOPPER_LAUNCHER`/mu);
expect(inventory).not.toMatch(/^\| `REA_WORKFLOW_PROVIDER`/mu);
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Numeric `REA_` limits that are not runtime analysis settings.
*
* The static check in `scripts/verify/rea-numeric-limits.mjs` checks recognized
* TypeScript reads against this list and `ANALYSIS_SETTINGS`. External readers
* and generated controls declare separate behavioral witnesses, which the
* Node-only check cannot verify. Every remaining entry is restart-only; limits
* checked on each call belong in `ANALYSIS_SETTINGS` instead of this list.
*
* Narrative bounds, owners, and the non-numeric settings live in
* `docs/design/rea-limit-inventory.md`.
*/
export interface NumericLimitRegistryEntry {
readonly name: string;
readonly update_mode: "restart";
readonly policy: "restart-only";
readonly read: "startup" | "construction";
readonly reason: string;
/** Read outside the TypeScript scan or generated into a bridge; execution needs its behavioral witness. */
readonly provenance?: {
readonly kind: "external-reader" | "generated-control";
readonly source: string;
readonly witness: string;
readonly generator?: string;
readonly generatorWitness?: string;
};
}
export const NUMERIC_LIMIT_REGISTRY = [
{
name: "REA_DEBUGGER_MAX_PENDING_COMMANDS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Caps the pending-command queue created with the debugger session manager. It is pool capacity for that manager, not a budget checked independently on each command.",
},
{
name: "REA_DEBUGGER_MAX_SESSIONS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Caps how many debugger sessions the session manager created at startup will admit. Changing the pool size of a manager that already exists requires a new REA process.",
},
{
name: "REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"The Linux private-display adapter reads this watch from the process environment when that helper starts. A running adapter does not observe a value stored only in the REA process.",
provenance: {
kind: "external-reader",
source: "scripts/hopper-demo-x11.py",
witness: "tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts",
},
},
{
name: "REA_JADX_ACTIVE_PROCESSOR_COUNT",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Passed to the JVM as -XX:ActiveProcessorCount when the JADX worker process starts. A running JVM does not adopt a new processor count.",
},
{
name: "REA_JADX_HEAP_MIB",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Passed to the JVM as -Xmx when the JADX worker process starts. A running JVM does not adopt a new heap size.",
},
{
name: "REA_KUNA_CACHE_MAX_BYTES",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Sizes the Kuna project cache when that cache is constructed for the provider. An existing cache does not resize from a later value.",
},
{
name: "REA_KUNA_JOBS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Selects the Kuna worker-pool width when the provider builds its run slots. The pool is not resized for a process that is already running.",
},
{
name: "REA_MCP_DOCTOR_DEADLINE_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP doctor phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_FIRST_CATALOG_BUDGET_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Bounds the first MCP catalog phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_INITIALIZE_BUDGET_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP initialize phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_MCP_MAX_INPUT_ELEMENTS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Sizes argument admission for the MCP server created at process start. A running server does not rebuild that parser for a new allowance.",
},
{
name: "REA_MCP_MAX_RESPONSE_BYTES",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Sizes the MCP stdio receive buffer when the server process starts. A running transport and the paired client buffer do not adopt a new ceiling.",
},
{
name: "REA_MCP_TOOLS_LIST_BUDGET_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Bounds the MCP tools/list phase while the server is starting. It shapes server startup, not a later analysis call.",
},
{
name: "REA_RECONNECT_TIMEOUT_SECONDS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Fixed 30-second value written into the Hopper bridge helper from HOPPER_SHUTDOWN_TIMEOUT_MS. It is not a caller-selected launch setting.",
provenance: {
kind: "generated-control",
source: "bridge/hopper_bridge.py",
generator: "src/hopper/BridgeLauncher.ts",
generatorWitness: "src/hopper/bridgeBootstrapLimit.test.ts",
witness: "tests/boundary/providers/hopper/hopperBridgeReconnect.test.ts",
},
},
{
name: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
update_mode: "restart",
policy: "restart-only",
read: "startup",
reason:
"Captured when the Darwin ownership-helper reader is constructed at process start. That helper compile uses the captured deadline, so a later value needs a new REA process.",
},
] as const satisfies readonly NumericLimitRegistryEntry[];
@@ -0,0 +1,45 @@
import { expect, it } from "vitest";
import { analysisSettingLimit } from "./analysisSettingDefinitions.js";
import {
AnalysisResourceConstraintError,
reattributedResourceConstraint,
} from "./analysisErrorCore.js";
import { projectAnalysisError } from "./analysisErrorProjection.js";
it("keeps the structured limit when a resource refusal is reattributed", () => {
const limit = analysisSettingLimit(
"bridge.max_request_bytes",
8192,
"runtime",
);
const original = new AnalysisResourceConstraintError(
"cutter_command",
"transport",
"Cutter status exceeded the request ceiling.",
{ max_request_bytes: 8192 },
{
remediationAction: "Raise bridge.max_request_bytes.",
retryable: false,
limit,
},
);
const discovery = reattributedResourceConstraint(
"list_cutter_sessions",
original,
);
expect(discovery.operation).toBe("list_cutter_sessions");
expect(discovery.limit).toEqual(original.limit);
expect(discovery.recovery).toEqual(original.recovery);
expect(discovery.reportedLimits).toEqual(original.reportedLimits);
const projected = projectAnalysisError(discovery);
expect(projected).toMatchObject({
code: "resource_constraint",
details: {
limit: {
setting: "bridge.max_request_bytes",
effective_value: 8192,
source: "runtime",
},
},
});
});
+1 -1
View File
@@ -67,7 +67,7 @@ export const analyzeWebBundleInputSchema = inspectWebPageWithSourceInputSchema
webBundleTotalTimeoutDescription,
),
source_map_fetch_timeout_ms: optionalBrowserDeadlineMs(
`Source-map fetch deadline in milliseconds (default ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)}). Omitted means ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)} and applies on the next fetch. A fetch overrun is an item limitation (fetch_failed), not a thrown analysis timeout. Response/representation and decoder budgets are independent source_map_limits fields.`,
`Source-map fetch deadline in milliseconds (default ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)}). Omitted means ${String(DEFAULT_SOURCE_MAP_FETCH_TIMEOUT_MS)} and applies on the next fetch. After response cleanup completes, a fetch overrun is an item limitation (fetch_failed), not a thrown analysis timeout. Pending or failed response cleanup is reported as an error with its retained owner. Response/representation and decoder budgets are independent source_map_limits fields.`,
),
})
.superRefine((input, context) => {
+2 -1
View File
@@ -81,7 +81,8 @@ export const webNetworkCaptureBudgetFacts = (
? MAX_WEB_NETWORK_CAPTURE_NESTING_DEPTH
: Number.MAX_SAFE_INTEGER,
applies_at: "next_operation",
rea_restart_required: false,
update_mode: "parameter" as const,
pending_restart: false as const,
};
};
+9 -3
View File
@@ -1,6 +1,7 @@
import type { Stats } from "node:fs";
import {
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
type WindowsChangeTimeSettleLimits,
} from "../config/windowsChangeTimeSettle.js";
@@ -49,7 +50,7 @@ const limitsFrom = (
seams.environment ?? process.env,
);
if (!resolved.ok) throw resolved.error;
return resolved.value;
return applyWindowsChangeTimeSnapshot(resolved.value);
};
/** Stop settling as soon as the owning operation is cancelled. */
@@ -100,16 +101,21 @@ export const settleChangeTimeBaseline = async (
read: () => Promise<Stats>,
seams: ChangeTimeSettleSeams = {},
): Promise<Stats> => {
throwIfAborted(seams);
if ((seams.platform ?? process.platform) !== "win32") {
const observed = await read();
throwIfAborted(seams);
return observed;
}
const limits = limitsFrom(seams);
const settled = { ...seams, limits };
const sleep = seams.sleep ?? defaultSleep;
throwIfAborted(seams);
let observed = await read();
for (let poll = 0; poll < limits.maxPolls; poll += 1) {
throwIfAborted(seams);
if (!changeTimeMayStillDrift(observed, settled)) return observed;
// A settle window is bounded by driftMs, not by the poll budget, so a
// cancelled scope must not keep an inventory waiting it out.
throwIfAborted(seams);
await sleep(limits.pollMs);
throwIfAborted(seams);
observed = await read();
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more