Skip to content

Add threads.v8Flags to apply V8 flags such as --optimize-for-size to every worker thread - #3138

Merged
kriszyp merged 4 commits into
mainfrom
feat/threads-v8-flags
Oct 13, 2026
Merged

kriszyp merged 4 commits into
mainfrom
feat/threads-v8-flags

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Note that one alternative is to pass in these settings with CLI flags, but that would involve modifying the harper script that is used to start the node process, and this is probably simpler/cleaner.

⊙ Problem

A constrained deployment (1 GB container, 1 CPU, threads.count: 1) wants V8's --optimize-for-size. Measured on that shape, it cuts idle memory from about 212 MB to about 173 MB. There is nowhere to pass it: Node refuses it in NODE_OPTIONS ("not allowed in NODE_OPTIONS"), worker threads' execArgv accepts no V8 options, and the container entrypoint is the harper shebang script. V8 flags are process-wide and read when an isolate is created, so setting them in the main thread before the first worker starts reaches every worker.

❓ Your call: Is a generic flag pass-through the right surface? As specified, yes. Host-manager will set threads.v8Flags through HARPER_SET_CONFIG for constrained plans, and a per-flag key would need a code change for every new tuning flag. The cost is an expert setting with no allowlist: any -- entry is accepted, and a contradictory set can abort the process at startup. Adding an allowlist later would break anyone using a flag outside it.

💡 Solution

New setting threads.v8Flags, a string or a list of strings:

threads:
  v8Flags:
    - --optimize-for-size
    - --max-semi-space-size=1

applyConfiguredV8Flags() reads it once per process, on the main thread. It validates every entry, then passes each to v8.setFlagsFromString and logs the result at notify level. It runs at startHTTPThreads() entry and again in startWorker(), which holds the only production new Worker. So the flags are set before any Harper worker isolate exists, including under threads.count: 0, where the first worker may be a job started long after the server is serving. Unset, null, or [] changes nothing. Bun logs a warning and applies nothing.

⚠️ Look hardest: the timing (startHTTPThreads entry, startWorker). The value is read at the first call, after bin/run.ts initialize() has finished writing config. It is deliberately not read at manageThreads.js module load, which happens before install composes HARPER_SET_CONFIG into the file on a fresh container's first boot, and before the THREADS_V8FLAGS env form is written.

Each entry must start with -- (surrounding whitespace is trimmed, empty entries are dropped). Joi and the JSON schema enforce it, so set_configuration refuses a bad value before writing or replicating it. A bad value supplied at boot (file edit, env var, HARPER_SET_CONFIG) stops startup with an error naming threads.v8Flags.

❓ Your call: A bad boot value fails startup, as the task specified. threads.maxHeapMemory instead recovers an unusable value at the point of use (harper-pro#558): it logs an error and boots without it. For entries that are not flags, what #558 guards against (one replicated set_configuration stopping every node) is closed here by the write-time Joi check. What remains is only an operator's own boot input. Switching to "log and boot without flags" would mean relaxing Joi to type-only plus a setConfiguration gate, a small change.

❓ Your call: The -- check is only a prefix check. A flag set that V8 aborts on (for example --abort-on-contradictory-flags with a contradicting pair) passes it, can be replicated by set_configuration, and aborts each node natively at its next process start. Accepted as the cost of an unrestricted expert setting; the alternative is an allowlist of supported flags.

Runtime flags skip the CLI's implied flags. Measured on Node 26.2.0 with a worker given Harper's 1 GB-tier limits (maxOldGenerationSizeMb: 512, maxYoungGenerationSizeMb: 16):

--optimize-for-size given… worker heap limit
not at all 536 MB
at runtime (setFlagsFromString) 536 MB
on the node command line 515 MB
at runtime, plus --max-semi-space-size=1 515 MB

The CLI form implies a 1 MB semi-space; the runtime call does not. This probably explains why the in-process measurement under load (206–211 MB) trailed the CLI (about 195 MB).

❓ Your call: For the free tier, host-manager should set ["--optimize-for-size", "--max-semi-space-size=1"], not --optimize-for-size alone. Not yet measured on the 1 GB container; the heap-limit probe is the only evidence.

⚖️ Alternatives

  • Apply at manageThreads.js module load, next to heapSnapshotNearLimit (the seed design). The planning review returned Framing-Verdict: better-alternative-exists. Adopted its alternative: initialize at startHTTPThreads entry and keep the startWorker guard. Disqualifier for module load: config is not final then (install and env-var writes come later in bin/run.ts).
  • Container command node --optimize-for-size $(command -v harper) run. Gives exact CLI semantics, including the main isolate. Rejected because the requirement is a portable config key that host-manager sets through HARPER_SET_CONFIG.
  • Re-exec the main process with CLI flags, or add them to the execArgv of Harper's own relaunch fork (utility/processManagement/servicesConfig.js, used by launch()/restart). Either would get CLI implications for free. But a container runs harper run directly, and HARPER_EXIT_ON_RESTART exits instead of relaunching, so that fork never runs there; a new re-exec changes the process tree and PID-1 behavior.

❓ Your call: Flags are set on a main thread that already has stores open. Node's docs warn that changing flags after the VM starts "may result in unpredictable behavior, including crashes and data loss". The motivating flags (--optimize-for-size, --max-semi-space-size) were measured; other flags are the operator's risk, undone by removing the key and restarting.

  • threads.preloadRequire with a module calling setFlagsFromString. It runs inside an already-created worker, too late for flags read at isolate creation, and safe mode skips it.
  • Per-flag keys, or an allowlist. Either needs a code change for every new tuning flag.

🔧 Changes

  • server/threads/v8Flags.ts (new): applyConfiguredV8Flags() — main thread only; latches after the first successful call (a rolling restart cannot undo process-wide flags); validates every entry before applying any; Bun no-op with a warning.

    ❓ Your call: Under HARPER_SAFE_MODE the flags are still applied, while threads.preload/preloadRequire are skipped. Safe mode is about not loading application code, and the free tier's memory budget still applies there. The cost: safe mode cannot rescue a node whose flag set aborts V8; the config must be edited. Skipping them in safe mode is a one-line guard.

  • server/threads/socketRouter.ts: import and call at startHTTPThreads() entry, before any asynchronous startup work.

  • server/threads/manageThreads.js: import and call in startWorker() after the shutdown guard, before any worker resources are allocated.

  • utility/hdbTerms.ts: CONFIG_PARAMS.THREADS_V8FLAGS, which also gives the env-var and set_configuration names through the derived CONFIG_PARAM_MAP.

  • validation/configValidator.ts (entry rule, threads.v8Flags), config-root.schema.json: string or string list or null, each entry ---prefixed (blank allowed). The schema's threads keeps additionalProperties: true, and main's validator accepts the unknown key, so older versions ignore it.

  • server/DESIGN.md, DESIGN.md: the invariant (applied before the first Harper worker, never at module load), the latch, the boot-input decision, and the implied-flags caveat.

✅ Verification

  • unitTests/server/threads/v8Flags.test.js with v8Flags-fixtures/harness.cjs and worker.cjs (8 passing). Each case runs in its own child process, because flags are process-wide. The harness loads manageThreads before assigning the setting, as bin/run.ts does, then starts a real worker that reports Error.stackTraceLimit. Cases: list and string forms; unset, null, [] and blank; a changed value before a second worker (the first value stays); a refused list leaves V8 untouched and starts no worker; whitespace trimming; a non-string entry; and startHTTPThreads(0) refusing an invalid value.

    • Fails on base: with only THREADS_V8FLAGS added to main's hdbTerms.ts, 5 of 6 original cases fail (stackTraceLimit: 10, V8's default); the unset case passes, as expected.
    • Mutation check: removing the startHTTPThreads call from dist makes its test fail at the 30 s harness deadline, with the child reaped.
  • unitTests/validation/configValidator.test.js: accepted and refused shapes.

  • End to end, live (built dist, isolated root, threads.count: 1, a probe component returning the worker's Error.stackTraceLimit):

    Case Result
    Fresh install, HARPER_SET_CONFIG v8Flags: ["--stack-trace-limit=7"] first boot logs the flags; worker reports 7
    THREADS_V8FLAGS='["--stack-trace-limit=5"]' worker reports 5
    v8Flags: null worker reports 10, no log line
    THREADS_V8FLAGS=optimize-for-size exit 1, error names threads.v8Flags
    threads.count: 0 with an invalid value exit 1 before serving
    bad value on disk exit 1: 'threads.v8Flags' … fails to match the V8 flag starting with -- pattern
    set_configuration threads_v8Flags: "optimize-for-size" refused; config file unchanged
  • Gates, run locally on the branch: test:unit:main 6850 passing, test:unit:resources 4132 passing, test:integration:all 2338 passing, 0 failing, 6 cancelled. All 6 cancelled are OllamaBackend against a real Ollama instance; this box's Ollama lacks the default models, unrelated to this change. lint:required and prettier are clean.

  • Pre-push review: planning round plus full and delta code rounds (Codex graded, Gemini, Cursor Composer, Harper domain adjudication). The major (a bad value accepted by set_configuration replicating to every node) is fixed. Declined: listing v8Flags: null in static/defaultConfig.yaml; the docs are the discovery surface.

Docs: HarperFast/documentation#724 — Document threads.v8Flags (options reference, set_configuration restart notes, 5.4 release notes).

Belongs with Reduce each thread's base JavaScript heap by about 40% in the "Worker startup heap" group.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @kriszyp.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VKZAtTiprHW8Sp1Ff4hGAZ

Related PRs: none found
Complexity: medium

Origin — the dispatch brief this PR was written from

Add threads.v8Flags: V8 flags applied process-wide before worker threads start

Add a Harper configuration option threads.v8Flags (a string or an array of strings, e.g. ["--optimize-for-size"]) whose V8 flags are applied in the main thread with v8.setFlagsFromString before any worker thread is created, so every worker isolate (and the main isolate from that point on) runs with them. Follow harper-engineering-guidelines and the DLC, open a draft PR against main with milestone v5.4, and open a companion HarperFast/documentation PR documenting the option.

Acceptance

  • threads.v8Flags (string or string[]) applied via v8.setFlagsFromString in the main thread before any Worker is constructed; no-op under Bun; invalid entries (not starting with --) fail startup naming the setting.
  • Schema + CONFIG_PARAMS entries; settable via yaml, HARPER_SET_CONFIG and env.
  • Unit test proving a worker observes a flag set this way (e.g. --stack-trace-limit), and that it fails without the change; validation test.
  • Draft PR on HarperFast/harper with milestone v5.4; companion HarperFast/documentation PR (reference/configuration options, threads section) cross-linked.
  • Do not change any default behavior when the option is unset.

Dispatch: task harper-threads-v8flags · queued by kris-session · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=4; full=2 @ 3d662eb

Review-Attention: study ~9m (hot: manageThreads.js, socketRouter.ts +1; decisions: do-less-alternative, runtime-flags-on-live-main-isolate, unrestricted-expert-setting, safe-mode-still-applies-v8flags) @ 3d662eb

@kriszyp kriszyp added this to the v5.4 milestone Oct 10, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the threads.v8Flags configuration option, allowing V8 flags to be applied to the main thread before any worker threads are started. It includes schema updates, validation logic, design documentation, and comprehensive unit tests. The review feedback suggests using a namespace import for harperLogger in server/threads/v8Flags.ts to align with the codebase's import style and avoid potential runtime issues with default exports.

Comment thread server/threads/v8Flags.ts
kriszyp and others added 4 commits October 13, 2026 08:06
…orker starts

Node refuses flags such as --optimize-for-size in NODE_OPTIONS and worker
execArgv rejects V8 options, so a constrained deployment had no way to pass
them. threads.v8Flags (a string or list of strings) is applied with
v8.setFlagsFromString on the main thread at startHTTPThreads entry and in
startWorker, before any Harper worker isolate exists. Entries not starting
with "--" fail startup naming the setting. Unset changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZAtTiprHW8Sp1Ff4hGAZ
Dispatch-Task: harper-threads-v8flags
…s tests

Joi and the JSON schema now require each entry to start with "--", so
set_configuration refuses a bad value before writing or replicating it.
Entries are trimmed and empty ones ignored. The confirmation is logged at
notify level and says the flags were passed to V8, since V8 gives no
success signal. The latch is set after submission. Tests cover the
startHTTPThreads entry call, bound their child process, and use a single
owned root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZAtTiprHW8Sp1Ff4hGAZ
Dispatch-Task: harper-threads-v8flags
…nset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZAtTiprHW8Sp1Ff4hGAZ
Dispatch-Task: harper-threads-v8flags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKZAtTiprHW8Sp1Ff4hGAZ
Dispatch-Task: harper-threads-v8flags
@kriszyp
kriszyp force-pushed the feat/threads-v8-flags branch from 3d662eb to c07514f Compare October 13, 2026 14:10
@kriszyp
kriszyp merged commit 6038a29 into main Oct 13, 2026
56 of 58 checks passed
@kriszyp
kriszyp deleted the feat/threads-v8-flags branch October 13, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant