Skip to content

feat(taskferry): configurable maxResponseBytes ceiling (#506) - #540

Open
jeremysball wants to merge 2 commits into
feat/extract-option-helpers-consumefrom
feat/issue-506-max-response-bytes
Open

jeremysball wants to merge 2 commits into
feat/extract-option-helpers-consumefrom
feat/issue-506-max-response-bytes

Conversation

@jeremysball

Copy link
Copy Markdown
Owner

Closes #506

Makes 1 MiB response cap configurable: --max-response-bytes flag, TASKFERRY_MAX_RESPONSE_BYTES env, maxResponseBytes config (flag > env > config > 1 MiB). Threads cap into daemon/client/task manager; maxOutputFileBytes validated against dynamic cap.

Known check failure: tasks.sandbox.test.js UV_CACHE_DIR test (pre-existing, not this change).

Test: docs/config updated, daemon-server + tasks wired, 1405/1406 pass except that one.

Comment thread src/daemon.js Outdated
* @param {string[]} argv
* @returns {number|undefined}
*/
function parseMaxResponseBytesFlag(argv) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

we already parse flags at daemon's cli entrypoint? we should not be duplicating that by rolling our own argv parser. if you need a way to get at said parsed flags we can work on that. otherwise this is just bad

@jeremysball jeremysball Aug 23, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Pushing back a bit here — main() is the arg parser right now, there's no other flag parsing at the daemon entrypoint to reuse. On main (src/daemon.js:1050) it's just:\n\njs\nasync function main() {\n const daemon = await startDaemon({ taskManagerOptions: { config: loadConfig() } });\n}\n\n\nNo commander/yargs, no shared CLI helper. So this PR's parseMaxResponseBytesFlag(process.argv.slice(2)) isn't duplicating — it's the first time the daemon has any CLI flag at all.\n\nI agree hand-rolling a second argv loop inside resolveDaemonOptions would be bad, but that's not what landed — main() parses argv once and threads maxResponseBytes into startDaemon() as an option, which is the shape we actually want (tests call startDaemon({ maxResponseBytes: ... }) without argv).\n\nIf you want a shared helper, I'd extract parseMaxResponseBytesFlag into src/cli.js / src/numbers.js and have both src/daemon.js and any future CLI entry import it, rather than pretending a daemon CLI framework already exists to piggyback on. Clean to do, but adding commander for one flag feels like overkill — keeping the tiny strict parser with explicit error messages (must be positive integer) is more honest. Happy to refactor if you have a specific shared parser in mind.

Comment thread src/daemon.js Outdated
const configForMax = /** @type {Record<string, unknown>|undefined} */ (
/** @type {any} */ (merged.taskManagerOptions)?.config ?? /** @type {any} */ (merged).config
);
const maxOutboundBytes = resolveMaxResponseBytes(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

surely we can create an abstraction that does triplet (env var, config, flag) checks for us?

@jeremysball jeremysball Aug 23, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not quite 1:1 — they look similar but have different failure semantics, which is why I didn't just import it.\n\ntasks.js:4241 resolvePositiveIntOption is lenient:\njs\nfunction resolvePositiveIntOption(raw, env, config, def) {\n if (raw !== undefined) return raw;\n return positiveInteger(Number(env), positiveInteger(config, def));\n}\n\nInvalid env silently falls back to config/default via positiveInteger(..., fallback).\n\nDaemon's resolveMaxResponseBytes is strict — invalid TASKFERRY_MAX_RESPONSE_BYTES throws with error: ... must be positive integer / help: ... so a typo in env fails fast at daemon startup instead of silently ignoring the cap you thought you set. That's intentional for a wire-level limit where silent fallback hides misconfig.\n\nI agree we shouldn't maintain two copies. The fix is to make resolvePositiveIntOption take a { strictEnv: true } option (or extract a resolvePositiveIntOptionStrict into src/numbers.js) so both callers share the flag>env>config>default chain and the error-handling choice. Just aliasing one to the other would quietly change daemon startup from fail-fast to silent-fallback, which I think is the wrong tradeoff here. I'll wire that strict/shared helper on next push.

Comment thread src/daemon.js
// construction (512 KiB > 1 byte would throw). A raised production cap
// (>=1 MiB) is the only case where the manager needs to know the wire cap.
const taskManagerOptions = /** @type {Record<string, any>} */ (merged.taskManagerOptions);
if (maxOutboundBytes >= MAX_BUFFER_BYTES && taskManagerOptions.maxResponseBytes === undefined && taskManagerOptions.maxBufferBytes === undefined && taskManagerOptions.maxOutboundBytes === undefined) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

you say here that we shouldn't thread tiny test caps but I don't see logic to do that. unless you're saying implicitly that tests that do set the cap also set taskManagerOptions fields

@jeremysball jeremysball Aug 23, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

You're right the comment buries the lead — but the logic is there and it's load-bearing, worth walking through why it has to be this shape.\n\njs\n// src/daemon.js:923-926 (this PR)\nif (maxOutboundBytes >= MAX_BUFFER_BYTES && taskManagerOptions.maxResponseBytes === undefined ...) {\n taskManagerOptions.maxResponseBytes = maxOutboundBytes;\n}\n\nDowngrade tests spin up an in-process daemon with { maxOutboundBytes: 1 } / 200 / 512 to assert the wire degrades to RESPONSE_TOO_LARGE. If we threaded that 1-byte cap into the manager, its own constructor would immediately throw:\n\njs\n// src/tasks.js:4346\nif (maxOutputFileBytes (default 512 KiB) > maxResponseBytes (1 byte)) throw ...\n\n\nSo tiny caps must not propagate — only a raised production cap (>= 1 MiB, the default) should. The other half of the guard (taskManagerOptions.maxResponseBytes === undefined ...) respects your point: when a test explicitly sets taskManagerOptions it shouldn't be overwritten implicitly, even for large caps.\n\nThe current comment says "don't thread tiny test caps" but doesn't point to the >= MAX_BUFFER_BYTES line that actually implements it, so it reads as aspirational. I'll make it explicit: "only thread when >=1 MiB, otherwise downgrade tests with artificially tiny wire caps would fail manager construction" and cite the maxOutputFileBytes > maxResponseBytes check.

Comment thread src/output-dir.js
// No static MAX_BUFFER_BYTES clamp here: the caller (tasks.js) validates
// maxBytes against the dynamic daemon response limit (TASKFERRY_MAX_RESPONSE_BYTES
// / maxResponseBytes flag/config/env, default 1 MiB). Clamping to the static
// 1 MiB here would silently undo a raised cap and break taskferry#506's

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

this comment not needed

@jeremysball jeremysball Aug 23, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Disagree here — that comment is the only thing stopping a future re-introduction of the clamp.\n\nOn main (src/output-dir.js:505) we did:\njs\nif (maxBytes > MAX_BUFFER_BYTES) maxBytes = MAX_BUFFER_BYTES;\n\nThat silently re-capped any raised TASKFERRY_MAX_RESPONSE_BYTES back to 1 MiB. This PR removes it and adds:\njs\n// No static MAX_BUFFER_BYTES clamp here: the caller (tasks.js) validates\n// maxBytes against the dynamic daemon response limit ... Clamping to the\n// static 1 MiB here would silently undo a raised cap and break taskferry#506's\n// configurable ceiling.\n\nWithout that note, the next person seeing an unbounded maxBytes flowing into readTaskOutputFile will reasonably re-add the clamp as a "safety" fix. tasks.js:4346 does validate, but the reader of output-dir.js doesn't have that context — this file is imported in other paths too.\n\nI can shorten it to one line (// Caller validates against dynamic cap; don't re-clamp to static 1 MiB (see taskferry#506)) but I'd keep the intent. Want it trimmed vs removed?

jeremysball added a commit that referenced this pull request Aug 23, 2026
- move --max-response-bytes argv parsing and flag>env>config>default
  chain into src/numbers.js (parsePositiveIntFlag,
  resolvePositiveIntOption with strictEnv) so daemon no longer carries
  a bespoke 40-line copy of the same logic
- daemon now uses strictEnv throw-on-bad-env for
  TASKFERRY_MAX_RESPONSE_BYTES while tasks stays lenient via the same
  helper (default), preserving flag>env>config>default precedence and
  empty-string fallthrough used by all other numeric options

Fixes review comments on #540: hand-rolled argv loop and duplicated
positive-int chain.
Stacked on feat/extract-option-helpers stack.

Closes #506

Makes 1 MiB response cap configurable: --max-response-bytes flag,
TASKFERRY_MAX_RESPONSE_BYTES env, maxResponseBytes config (flag > env >
config > 1 MiB). Threads cap into daemon/client/task manager via shared
src/options.js helpers (parsePositiveIntFlag,
resolvePositiveIntOption with strictEnv:true for daemon, lenient for
tasks); maxOutputFileBytes validated against dynamic cap.

On top of extraction/consumption stack so daemon no longer carries
bespoke flag/env parsers — uses the shared strictEnv path.
@jeremysball
jeremysball force-pushed the feat/issue-506-max-response-bytes branch from cedf351 to 764f3b7 Compare August 23, 2026 16:33
@jeremysball
jeremysball changed the base branch from main to feat/extract-option-helpers-consume August 23, 2026 16:33
@review-axi review-axi Bot added the needs-review PR awaiting ferry review label Sep 3, 2026
@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angle: line-by-line (A) · Verified: 6/6 CONFIRMED

  1. src/daemon.js:840 shared-defaults mutation leak — CONFIRMED.
  2. src/tasks.js:4297 alias precedence diverges from daemon — CONFIRMED.
  3. src/options.js:13 bare trailing flag silently ignored — CONFIRMED.
  4. src/client.js:598 explicit maxBufferBytes bypasses validation — CONFIRMED.
  5. src/daemon.js:1004 sub-1MiB lowering splits the ceiling three ways — CONFIRMED.
  6. src/tasks.js:6814 safeCap goes negative for tiny caps — CONFIRMED.

@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angle: cross-file tracer (C) · Verified: 4/6 (2 REFUTED)

Kept: src/daemon.js:819 default-shadowing kills env/config raise (CONFIRMED); src/client.js:604 new throwing precondition (CONFIRMED); src/tasks.js:4296 construction throw on small caps (CONFIRMED); src/tasks.js:4298 global-process.env divergence (dropped — REFUTED: manager threading covers the supported path); src/output-dir.js:504 clamp removal (dropped — REFUTED: caps cannot disagree via supported knobs).

@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angle: wrapper/proxy (E) · Verified: 1/3 (2 REFUTED)

Kept: src/client.js:640 programmatic raise never forwarded to auto-started daemon (CONFIRMED). Dropped: static-fallback claim (REFUTED — outputFor threads the dynamic ceiling) and the :840 duplicate of angle A.

@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angles: reuse + simplification · Verified: 0 kept (all REFUTED)

All duplication findings in these two angles were voted style-only or factually wrong on current inputs: triplicated fallback (identical expression, no divergent behavior), 4× ceiling formula (identical output everywhere today — future-edit DRY risk, not a bug), hand-rolled client chain (both sides throw identically on bad input), duplicate constants (actually an alias, MAX_BUFFER_BYTES = DEFAULT_MAX_BUFFER_BYTES), triple coercion (pure construction-time, identical results). Noted as cleanup opinion, not findings.

@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angle: efficiency · Verified: 2 kept, 1 PLAUSIBLE, 3 REFUTED

Kept: src/client.js:608 loadConfig on every connect (CONFIRMED — statSync+parse on the CLI startup path); src/tasks.js:6810 double-encode to measure (CONFIRMED). PLAUSIBLE: src/daemon-server.js:352 per-chunk full-buffer rescan (mechanism real, stall unproven). Dropped: O(N²) listing claim (REFUTED — 256-entry cap bounds it), double-check claim (REFUTED — re-asserts an invariant, strings built only on throw), triple-coercion duplicate.

@jeremysball

Copy link
Copy Markdown
Owner Author

🤖 Automated review (taskferry)

Effort: medium · Model(s): meta/muse-spark-1.3-contributor · 2026-09-03T19:30-0400 · Angles: altitude + asymmetry + conventions · Verified: 5 kept

Kept: single knob drives inbound+outbound with 1MiB floor (CONFIRMED); stale static cap text src/commands.js:570 + src/command-specs.js:29 (CONFIRMED); no --max-response-bytes CLI flag so flag-raised daemon caps hit the default client ceiling (CONFIRMED); self-restart drops flag-only raise (CONFIRMED); sub-1MiB carve-out missing its things-that-look-like-bugs entry (CONFIRMED). Dropped: out-of-scope static rewrite (untouched file), swallowed-config-docs claim (comment shows the fallback is deliberate and surfaced via daemon-boot.err).

@review-axi review-axi Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ferry review, medium effort, 9 finder angles + 30 per-candidate verifier votes, all on meta/muse-spark-1.3-contributor. 19 survived; top 9 by severity (medium cap):

  1. src/daemon.js:840 (CONFIRMED): resolveDaemonOptions mutates shared DAEMON_DEFAULTS.taskManagerOptions — a raised cap leaks into later daemons in-process.
  2. src/tasks.js:4297 vs daemon.js:820 (CONFIRMED): alias precedence order differs between manager and wire caps, so the two ceilings can disagree.
  3. src/daemon.js:819 (CONFIRMED): 1MiB default shadows env/config — a raised maxResponseBytes via env/config never takes effect on the daemon.
  4. src/daemon.js:1004 (CONFIRMED): lowering below 1MiB splits the ceiling three ways (wire shrinks, inbound+manager stay).
  5. src/tasks.js:4296 (CONFIRMED): small explicit caps throw at construction against the default maxOutputFileBytes.
  6. src/client.js:640 (CONFIRMED): programmatic client raise never forwarded to auto-started daemon.
  7. src/daemon.js:292 (CONFIRMED): source-change self-restart drops a flag-only raise.
  8. src/tasks.js:6814 (CONFIRMED): safeCap goes negative for tiny caps, nonsense help text.
  9. src/options.js:13 (CONFIRMED): trailing bare flag silently ignored.

Cut by cap but kept on record in angle comments: client validation bypass, throwing-precondition change, per-connect loadConfig, double-encode measure, single-knob inbound/outbound, stale static cap text, CLI flag gap, missing bugs-that-aren't entry.

11 candidates refuted (documented per-angle). Stacking note: base is feat/extract-option-helpers-consume — merge waits on that branch regardless.

@review-axi review-axi Bot removed the needs-review PR awaiting ferry review label Sep 3, 2026
- daemon: read cap from raw options (default no longer shadows env/config)
- daemon: clone taskManagerOptions (no shared-defaults mutation leak)
- daemon: forward resolved cap to self-restart child env (flag-only raise survives)
- client: forward programmatic raise to auto-started daemon via env copy
- tasks: clamp safeCap at 0, unset output-file budget clamps instead of throwing
- options: bare trailing flag throws with usage instead of silent ignore

1406/1406 tests pass.
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.

Raise (or make configurable) the daemon's 1 MiB response cap

1 participant